From c9266b2f8fc182f1e5159014f8e0550ac0e5790c Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sat, 13 Sep 2025 10:30:48 +0200 Subject: [PATCH 01/42] feat(archi): add event router --- src/module/event_router.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/module/event_router.py diff --git a/src/module/event_router.py b/src/module/event_router.py new file mode 100644 index 0000000..510ce3e --- /dev/null +++ b/src/module/event_router.py @@ -0,0 +1,18 @@ +import zmq + +XPUB_ENDPOINT = "tcp://localhost:5555" +XSUB_ENDPOINT = "tcp://localhost:5556" + + +class EventRouter: + def __init__(self): + + self.ctx = zmq.Context() + self.xpub = self.ctx.socket(zmq.XPUB) + self.xsub = self.ctx.socket(zmq.XSUB) + self.xpub.bind(XPUB_ENDPOINT) + self.xsub.bind(XSUB_ENDPOINT) + + def start(self): + print("EventRouter started") + zmq.proxy(self.xsub, self.xpub) From 985054445b7704871034a9b18fca91a65a9fe117 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sat, 13 Sep 2025 11:02:43 +0200 Subject: [PATCH 02/42] feat(archi): add Module class --- src/module/module.py | 96 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 src/module/module.py diff --git a/src/module/module.py b/src/module/module.py new file mode 100644 index 0000000..0918ebe --- /dev/null +++ b/src/module/module.py @@ -0,0 +1,96 @@ +import threading +import time +from multiprocessing.synchronize import Event +from typing import Callable, Dict + +import zmq + +from .event_router import XPUB_ENDPOINT, XSUB_ENDPOINT + + +class Module: + def __init__(self, name: str) -> None: + self.name = name + self.ctx = zmq.Context() + self.pub_socket = self.ctx.socket(zmq.PUB) + self.pub_socket.connect(XSUB_ENDPOINT) + + self.subs: Dict[str, zmq.SyncSocket] = {} + self.callbacks: Dict[str, Callable] = {} + self._running = False + self.poller = threading.Thread(target=self._poll_loop, daemon=True) + + def subscribe(self, topic: str, callback: Callable) -> None: + sub = self.ctx.socket(zmq.SUB) + sub.connect(XPUB_ENDPOINT) + sub.setsockopt_string(zmq.SUBSCRIBE, topic) + self.subs[topic] = sub + self.callbacks[topic] = callback + + def publish(self, topic: str, msg: str) -> None: + self.pub_socket.send_string(f"{topic} {msg}") + + def start_polling(self) -> None: + self._running = True + self.poller.start() + + def _poll_loop(self) -> None: + poller = zmq.Poller() + for sub in self.subs.values(): + poller.register(sub, zmq.POLLIN) + + while self._running: + events = dict(poller.poll(100)) + for _, sub in self.subs.items(): + if sub in events: + topic_msg = sub.recv_string() + topic, msg = topic_msg.split(" ", 1) + self.callbacks[topic](msg) + + def run(self, stop_event: Event = None) -> None: + """ + Default run: waits for events. + Child classes can override `_run()` for active behavior. + """ + self.start_polling() + try: + self._run(stop_event) + finally: + self.stop() + + def _run(self, stop_event: Event = None) -> None: + """Child modules override this instead of run(). Default: idle wait.""" + while stop_event is None or not stop_event.is_set(): + time.sleep(0.1) + + def stop(self) -> None: + """Stop the module gracefully.""" + + # Close poller daemon + self._running = False + self.poller.join() + + for topic, sub in self.subs.items(): + try: + sub.close(0) + except Exception as e: + print(f"[{self.name}] Error closing SUB socket for '{topic}': {e}") + + self.subs.clear() + self.callbacks.clear() + + # Close publisher socket + if hasattr(self, "pub_socket"): + try: + self.pub_socket.close(0) + except Exception as e: + print(f"[{self.name}] Error closing PUB socket: {e}") + + # Terminate the context + if hasattr(self, "ctx"): + try: + self.ctx.term() + except Exception as e: + print(f"[{self.name}] Error terminating ZMQ context: {e}") + + print(f"[{self.name}] Module stopped gracefully.") From bc44bc72c1ac2d2f7e3d1854cbaf017c91c279f6 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Tue, 16 Sep 2025 05:56:55 +0200 Subject: [PATCH 03/42] feat(archi): add ModuleManager class --- src/module/manager.py | 82 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 src/module/manager.py diff --git a/src/module/manager.py b/src/module/manager.py new file mode 100644 index 0000000..8177608 --- /dev/null +++ b/src/module/manager.py @@ -0,0 +1,82 @@ +import multiprocessing as mp +from multiprocessing.synchronize import Event +from typing import List, Dict + +from .event_router import EventRouter +from .module import Module + + +class ModuleManager: + def __init__(self, modules: List[Module]): + self.modules: List[Module] = modules + + self.processes: Dict[str, mp.Process] = {} + self.router_process: mp.Process = None + self.stop_events = {} + + def _start_router(self): + if self.router_process and self.router_process.is_alive(): + return + + self.router_process = mp.Process( + target=lambda: EventRouter().start(), daemon=True + ) + self.router_process.start() + print(f"[Manager] Router started (PID={self.router_process.pid})") + + @staticmethod + def _run_module(module: Module, stop_event: Event): + module.run(stop_event=stop_event) + + def start(self): + self._start_router() + for module in self.modules: + if module.name in self.processes: + continue + stop_event = mp.Event() + p = mp.Process( + target=self._run_module, args=(module, stop_event), daemon=True + ) + p.start() + self.processes[module.name] = p + self.stop_events[module.name] = stop_event + print(f"[Manager] {module.name} started (PID={p.pid})") + + def status(self): + """Print status of all modules and router.""" + print("=== Module Status ===") + if self.router_process: + router_state = "alive" if self.router_process.is_alive() else "stopped" + print(f"- Router: {router_state} (PID={self.router_process.pid})") + else: + print("- Router: not started") + + for module in self.modules: + process = self.processes.get(module.name) + if process: + state = "alive" if process.is_alive() else "stopped" + print(f"- {module.name}: {state} (PID={process.pid})") + else: + print(f"- {module.name}: stopped") + print("=====================") + + def stop(self, name): + if name in self.processes: + print(f"[Manager] Stopping {name}...") + self.stop_events[name].set() + self.processes[name].join(timeout=5) + if self.processes[name].is_alive(): + print(f"[Manager] {name} did not stop in time, killing") + self.processes[name].kill() + print(f"[Manager] {name} stopped") + del self.processes[name] + del self.stop_events[name] + + def stop_all(self): + for name in list(self.processes.keys()): + self.stop(name) + if self.router_process and self.router_process.is_alive(): + print("[Manager] Stopping router...") + self.router_process.terminate() + self.router_process.join(timeout=5) + print("[Manager] Router stopped") From bc91df9d46756436712164f86702d283793931f7 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Tue, 16 Sep 2025 05:59:07 +0200 Subject: [PATCH 04/42] feat(archi): add shell to control modules --- src/module/shell.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/module/shell.py diff --git a/src/module/shell.py b/src/module/shell.py new file mode 100644 index 0000000..5299b16 --- /dev/null +++ b/src/module/shell.py @@ -0,0 +1,26 @@ +import cmd + +from .manager import ModuleManager + + +class RobotShell(cmd.Cmd): + intro = "HuRI's shell. Type 'help' to see command's list." + prompt = "(HuRI) " + + def __init__(self, manager: ModuleManager): + super().__init__() + self.manager = manager + + def do_status(self, arg): + self.manager.status() + + def do_start(self, arg): + self.manager.start(arg.strip()) + + def do_stop(self, arg): + self.manager.stop(arg.strip()) + + def do_exit(self, arg): + self.manager.stop_all() + print("Bye !") + return True From 605534aa726a9d8f0b9e564ed5cefd574eb84597 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Tue, 16 Sep 2025 08:09:05 +0200 Subject: [PATCH 05/42] fix(archi): broken bus event pipeline --- src/module/manager.py | 32 +++++++++++++++++--------------- src/module/module.py | 29 +++++++++++++++-------------- 2 files changed, 32 insertions(+), 29 deletions(-) diff --git a/src/module/manager.py b/src/module/manager.py index 8177608..c897197 100644 --- a/src/module/manager.py +++ b/src/module/manager.py @@ -1,14 +1,15 @@ import multiprocessing as mp +import time from multiprocessing.synchronize import Event -from typing import List, Dict +from typing import Dict, List from .event_router import EventRouter from .module import Module class ModuleManager: - def __init__(self, modules: List[Module]): - self.modules: List[Module] = modules + def __init__(self, modules: Dict[str, Module]): + self.modules: Dict[str, Module] = modules self.processes: Dict[str, mp.Process] = {} self.router_process: mp.Process = None @@ -22,25 +23,26 @@ def _start_router(self): target=lambda: EventRouter().start(), daemon=True ) self.router_process.start() + time.sleep(0.01) print(f"[Manager] Router started (PID={self.router_process.pid})") @staticmethod - def _run_module(module: Module, stop_event: Event): - module.run(stop_event=stop_event) + def _run_module(module: Module, name: str, stop_event: Event): + module(name).run(stop_event=stop_event) def start(self): self._start_router() - for module in self.modules: - if module.name in self.processes: + for name, mod_cls in self.modules.items(): + if name in self.processes: continue stop_event = mp.Event() p = mp.Process( - target=self._run_module, args=(module, stop_event), daemon=True + target=self._run_module, args=(mod_cls, name, stop_event), daemon=True ) p.start() - self.processes[module.name] = p - self.stop_events[module.name] = stop_event - print(f"[Manager] {module.name} started (PID={p.pid})") + self.processes[name] = p + self.stop_events[name] = stop_event + print(f"[Manager] {name} started (PID={p.pid})") def status(self): """Print status of all modules and router.""" @@ -51,13 +53,13 @@ def status(self): else: print("- Router: not started") - for module in self.modules: - process = self.processes.get(module.name) + for name in self.modules: + process = self.processes.get(name) if process: state = "alive" if process.is_alive() else "stopped" - print(f"- {module.name}: {state} (PID={process.pid})") + print(f"- {name}: {state} (PID={process.pid})") else: - print(f"- {module.name}: stopped") + print(f"- {name}: stopped") print("=====================") def stop(self, name): diff --git a/src/module/module.py b/src/module/module.py index 0918ebe..2adc2fa 100644 --- a/src/module/module.py +++ b/src/module/module.py @@ -21,14 +21,14 @@ def __init__(self, name: str) -> None: self.poller = threading.Thread(target=self._poll_loop, daemon=True) def subscribe(self, topic: str, callback: Callable) -> None: - sub = self.ctx.socket(zmq.SUB) - sub.connect(XPUB_ENDPOINT) - sub.setsockopt_string(zmq.SUBSCRIBE, topic) - self.subs[topic] = sub + sub_socket = self.ctx.socket(zmq.SUB) + sub_socket.connect(XPUB_ENDPOINT) + sub_socket.setsockopt_string(zmq.SUBSCRIBE, topic) + self.subs[topic] = sub_socket self.callbacks[topic] = callback def publish(self, topic: str, msg: str) -> None: - self.pub_socket.send_string(f"{topic} {msg}") + self.pub_socket.send_multipart([topic.encode(), msg.encode()]) def start_polling(self) -> None: self._running = True @@ -43,22 +43,22 @@ def _poll_loop(self) -> None: events = dict(poller.poll(100)) for _, sub in self.subs.items(): if sub in events: - topic_msg = sub.recv_string() - topic, msg = topic_msg.split(" ", 1) - self.callbacks[topic](msg) + topic, msg = sub.recv_multipart() + self.callbacks[topic.decode()](msg.decode()) def run(self, stop_event: Event = None) -> None: """ Default run: waits for events. - Child classes can override `_run()` for active behavior. + Child classes can override `loop()` for active behavior. """ - self.start_polling() + if self.subs != {}: + self.start_polling() try: - self._run(stop_event) + self.loop(stop_event) finally: self.stop() - def _run(self, stop_event: Event = None) -> None: + def loop(self, stop_event: Event = None) -> None: """Child modules override this instead of run(). Default: idle wait.""" while stop_event is None or not stop_event.is_set(): time.sleep(0.1) @@ -67,8 +67,9 @@ def stop(self) -> None: """Stop the module gracefully.""" # Close poller daemon - self._running = False - self.poller.join() + if self._running: + self._running = False + self.poller.join() for topic, sub in self.subs.items(): try: From e9ec2553494a763aa11f4077be3406963fb35b7f Mon Sep 17 00:00:00 2001 From: Popochounet Date: Tue, 16 Sep 2025 08:11:33 +0200 Subject: [PATCH 06/42] feat(archi): add demo main.py --- src/main.py | 104 ++++++++++++++++++++++++---------------------------- 1 file changed, 47 insertions(+), 57 deletions(-) diff --git a/src/main.py b/src/main.py index 5489238..86858cc 100644 --- a/src/main.py +++ b/src/main.py @@ -1,60 +1,50 @@ -from enum import Enum -import soundfile as sf -import simpleaudio as sa -from emotional_hub.input_analysis import predict_emotion -from speech_to_text.speech_to_text import SpeechToText -from rag.rag import Rag - - -class Modes(Enum): - EXIT = 0 - LLM = 1 - CONTEXT = 2 - RAG = 3 - - -def loop(stt: SpeechToText, tts: None, mode: Modes, mode_function): - while mode: - prompt, audio = stt.get_prompt() - print(prompt) - if "switch llm" in prompt.lower(): - mode = Modes.LLM - elif "switch context" in prompt.lower(): - mode = Modes.CONTEXT - elif "switch rag" in prompt.lower(): - mode = Modes.RAG - elif "bye bye" in prompt.lower(): - mode = Modes.EXIT - elif prompt.strip() == "": - continue - else: - stt.pause() - emotion = predict_emotion(audio) - print("Predicted Emotion:", emotion) - answer = mode_function[mode](f"in a {emotion} emotion: {prompt}") - print(answer) - stt.pause(False) - - -def main(): - stt = SpeechToText() - rag = Rag(model="deepseek-v2:16b") - rag.ragLoader("tests/rag/docsRag", "txt") - mode = Modes.LLM - mode_function = { - Modes.LLM: rag.ragQuestion, - Modes.RAG: rag.ragLoader, - Modes.CONTEXT: lambda x: "Context mode not implemented yet.", - } - stt.start() - try: - loop(stt, None, mode, mode_function) - except KeyboardInterrupt: - print("CTRL+C detected. Stopping the program.") - except Exception as e: - print("Unexpected Error:", e) - stt.stop() +import time +from multiprocessing.synchronize import Event +from typing import Dict + +from src.module.module import Module +from src.module.shell import ModuleManager, RobotShell + + +class TTSModule(Module): + def __init__(self, name="TTS"): + super().__init__(name) + self.subscribe("llm.response", self.on_llm_response) + + def on_llm_response(self, msg): + print(f"[{self.name}] parle ->", msg) + + +class LLMModule(Module): + def __init__(self, name="LLM"): + super().__init__(name) + self.subscribe("speech.in", self.on_speech) + + def on_speech(self, msg): + reponse = f"Réponse à '{msg}'" + print(f"[{self.name}] ->", reponse) + self.publish("llm.response", reponse) + + +class STTModule(Module): + def __init__(self, name="STT"): + super().__init__(name) + + def loop(self, stop_event: Event = None): + i = 0 + while stop_event is None or not stop_event.is_set(): + phrase = f"Phrase numéro {i}" + print(f"[{self.name}] ->", phrase) + self.publish("speech.in", phrase) + i += 1 + time.sleep(2) if __name__ == "__main__": - main() + modules: Dict[str, Module] = {"STT": STTModule, "TTS": TTSModule, "LLM": LLMModule} + manager = ModuleManager(modules) + manager.start() + try: + RobotShell(manager).cmdloop() + except KeyboardInterrupt: + pass From c83a05937524b854634e92012d6551c83f5d36d4 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Fri, 19 Sep 2025 10:54:45 +0200 Subject: [PATCH 07/42] feat(logging): add logging tools --- src/tools/logger.py | 100 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 src/tools/logger.py diff --git a/src/tools/logger.py b/src/tools/logger.py new file mode 100644 index 0000000..f3f2bcf --- /dev/null +++ b/src/tools/logger.py @@ -0,0 +1,100 @@ +import logging +import multiprocessing as mp +from logging.handlers import QueueHandler, QueueListener +from typing import IO, Optional, Dict + + +def setup_handler( + stream: Optional[IO] = None, + filename: Optional[str] = None, + log_queue: Optional[mp.Queue] = None, + formatter: logging.Formatter = logging.Formatter( + "[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s", datefmt="%H:%M:%S" + ), +) -> logging.Handler: + if stream is not None: + handler = logging.StreamHandler(stream) + elif filename is not None: + handler = logging.FileHandler(filename) + elif log_queue is not None: + return QueueHandler(log_queue) + else: + # Default: stdout + handler = logging.StreamHandler() + + handler.setFormatter(formatter) + + return handler + + +def setup_logger( + name: str, + level: int = logging.DEBUG, + stream: Optional[IO] = None, + filename: Optional[str] = None, + log_queue: Optional[mp.Queue] = None, +) -> logging.Logger: + """ + Creates and returns a logger with optional output: + - log_queue (multiprocessing-safe queue, preferred for child processes) + - stream (e.g., sys.stdout) + - filename (log file) + - defaults to stdout if none is given + """ + logger = logging.getLogger(name) + logger.setLevel(level) + if log_queue: + logger.propagate = False + + logger.handlers.clear() + handler = setup_handler(stream, filename, log_queue) + logger.addHandler(handler) + + return logger + + +class LevelFilter(logging.Filter): + def __init__(self, root_level: int = logging.WARNING): + self.root_level = root_level + self.log_levels: Dict[str, int] = {} + + def filter(self, record: logging.LogRecord) -> bool: + """the root level has priority over custom levels""" + level = self.log_levels.get(record.name, self.root_level) + + return self.root_level < record.levelno and level < record.levelno + + def set_root_level(self, level: int) -> None: + self.root_level = level + + def add_level(self, name: str) -> None: + self.log_levels[name] = self.root_level + + def set_level(self, name: str, level: int) -> None: + if name not in self.log_levels: + raise ValueError(f"{name} has no linked log level") + self.log_levels[name] = level + + def set_levels(self, level: int) -> None: + self.set_root_level(level) + for name in self.log_levels: + self.set_level(name, level) + + def del_level(self, name: str) -> None: + del self.log_levels[name] + + +def setup_log_listener(log_queue: mp.Queue, filter: logging.Filter) -> QueueListener: + """ + Starts a central logging listener that reads LogRecords from a queue + and emits them using normal loggers/handlers. + """ + formatter = logging.Formatter( + "[%(asctime)s] [%(processName)s] [%(name)s] [%(levelname)s] %(message)s", + datefmt="%H:%M:%S", + ) + handler = setup_handler(formatter=formatter) + handler.addFilter(filter) + + listener = QueueListener(log_queue, handler) + return listener From 549eeb5b9666b724b6959a89e79556a135ad21c3 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Fri, 19 Sep 2025 10:55:35 +0200 Subject: [PATCH 08/42] evol(ModuleManager): logging implemented --- src/module/manager.py | 140 +++++++++++++++++++++++++++++++----------- 1 file changed, 103 insertions(+), 37 deletions(-) diff --git a/src/module/manager.py b/src/module/manager.py index c897197..9230b69 100644 --- a/src/module/manager.py +++ b/src/module/manager.py @@ -1,48 +1,118 @@ import multiprocessing as mp +import signal import time from multiprocessing.synchronize import Event from typing import Dict, List +from src.tools.logger import (LevelFilter, QueueListener, logging, + setup_log_listener, setup_logger) + from .event_router import EventRouter from .module import Module class ModuleManager: + """Control Modules, Inter-Module communication and Logging""" + def __init__(self, modules: Dict[str, Module]): self.modules: Dict[str, Module] = modules - self.processes: Dict[str, mp.Process] = {} self.router_process: mp.Process = None - self.stop_events = {} + self.processes: Dict[str, mp.Process] = {} + self.stop_events: Dict[str, Event] = {} - def _start_router(self): + self.log_queue = mp.Queue() + self.logger = setup_logger("HuRI", log_queue=self.log_queue) + self.level_filter = LevelFilter(logging.DEBUG) + self.log_listener: QueueListener = setup_log_listener( + self.log_queue, self.level_filter + ) + + def _start_event_router(self) -> None: + """Used to handle inter-module communication, though events""" if self.router_process and self.router_process.is_alive(): return + logger = setup_logger("EventRouter", log_queue=self.log_queue) self.router_process = mp.Process( - target=lambda: EventRouter().start(), daemon=True + target=lambda: EventRouter(logger).start(), daemon=True ) + self.level_filter.add_level("EventRouter") self.router_process.start() time.sleep(0.01) - print(f"[Manager] Router started (PID={self.router_process.pid})") + self.logger.info(f"Router started (PID={self.router_process.pid})") @staticmethod - def _run_module(module: Module, name: str, stop_event: Event): - module(name).run(stop_event=stop_event) + def _run_module( + mod_cls: Module, name: str, log_queue: mp.Queue, stop_event: Event + ) -> None: + logger = setup_logger(name, log_queue=log_queue) + + def handle_sigint(signum, frame): + logger.info(f"Ctrl+C ignored in child module") + + signal.signal(signal.SIGINT, handle_sigint) + + module: Module = mod_cls(name, logger=logger) + module.run(stop_event=stop_event) def start(self): - self._start_router() - for name, mod_cls in self.modules.items(): - if name in self.processes: - continue - stop_event = mp.Event() - p = mp.Process( - target=self._run_module, args=(mod_cls, name, stop_event), daemon=True + """Start event router and modules""" # TODO config (also logs levels) + self.log_listener.start() + self._start_event_router() + for name in self.modules: + self.start_module(name) + + def start_module(self, name): + if name not in self.modules: + self.logger.warning( + f"{name} is not in the registered Modules: {self.modules.keys()}" ) - p.start() - self.processes[name] = p - self.stop_events[name] = stop_event - print(f"[Manager] {name} started (PID={p.pid})") + return + if name in self.processes: + self.logger.warning( + f"{name} is already running (PID={self.processes[name].pid})" + ) + return + + mod_cls = self.modules[name] + stop_event = mp.Event() + p = mp.Process( + target=self._run_module, + args=(mod_cls, name, self.log_queue, stop_event), + daemon=True, + ) + self.processes[name] = p + self.stop_events[name] = stop_event + self.level_filter.add_level(name) + + p.start() + self.logger.info(f"{name} ({mod_cls}) started (PID={p.pid})") + + def stop_module(self, name): + if name in self.processes: + self.logger.info(f"Stopping {name}...") + self.stop_events[name].set() + self.processes[name].join(timeout=5) + if self.processes[name].is_alive(): + self.logger.warning(f"{name} did not stop in time, killing") + self.processes[name].kill() + self.logger.info(f"{name} stopped") + del self.processes[name] + del self.stop_events[name] + self.level_filter.del_level(name) + + def stop_all(self): + for name in list(self.processes.keys()): + self.stop_module(name) + if self.router_process and self.router_process.is_alive(): + self.logger.info("Stopping router...") + self.router_process.terminate() + self.router_process.join(timeout=5) + self.level_filter.del_level("EventRouter") + self.logger.info("Router stopped") + + self.log_listener.stop() def status(self): """Print status of all modules and router.""" @@ -62,23 +132,19 @@ def status(self): print(f"- {name}: stopped") print("=====================") - def stop(self, name): - if name in self.processes: - print(f"[Manager] Stopping {name}...") - self.stop_events[name].set() - self.processes[name].join(timeout=5) - if self.processes[name].is_alive(): - print(f"[Manager] {name} did not stop in time, killing") - self.processes[name].kill() - print(f"[Manager] {name} stopped") - del self.processes[name] - del self.stop_events[name] + def set_root_log_level(self, level: int) -> None: + self.level_filter.set_root_level(level) - def stop_all(self): - for name in list(self.processes.keys()): - self.stop(name) - if self.router_process and self.router_process.is_alive(): - print("[Manager] Stopping router...") - self.router_process.terminate() - self.router_process.join(timeout=5) - print("[Manager] Router stopped") + def set_log_level(self, name: str, level: int) -> None: + self.level_filter.set_level(name, level) + + def set_log_levels(self, name: str, level: int) -> None: + self.level_filter.set_levels(level) + + def log_status(self) -> None: + """Print status of all modules and router.""" + print("=== Log Status ===") + print(f"Root level: {logging.getLevelName(self.level_filter.root_level)}") + for name, lvl in self.level_filter.log_levels.items(): + print(f"- {name}: {logging.getLevelName(lvl)}") + print("=====================") From 91dcb7d760252d21b94f4f5ba07f358fbdfa0d53 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Fri, 19 Sep 2025 10:57:06 +0200 Subject: [PATCH 09/42] evol(Module): logging implemented --- src/module/module.py | 44 ++++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/src/module/module.py b/src/module/module.py index 2adc2fa..db53466 100644 --- a/src/module/module.py +++ b/src/module/module.py @@ -1,15 +1,18 @@ +import multiprocessing as mp import threading import time from multiprocessing.synchronize import Event -from typing import Callable, Dict +from typing import Callable, Dict, Optional import zmq +from src.tools.logger import logging, setup_logger + from .event_router import XPUB_ENDPOINT, XSUB_ENDPOINT class Module: - def __init__(self, name: str) -> None: + def __init__(self, name: str, logger: Optional[logging.Logger] = None) -> None: self.name = name self.ctx = zmq.Context() self.pub_socket = self.ctx.socket(zmq.PUB) @@ -20,6 +23,8 @@ def __init__(self, name: str) -> None: self._running = False self.poller = threading.Thread(target=self._poll_loop, daemon=True) + self.logger = logger or logging.getLogger(self.name) + def subscribe(self, topic: str, callback: Callable) -> None: sub_socket = self.ctx.socket(zmq.SUB) sub_socket.connect(XPUB_ENDPOINT) @@ -29,6 +34,7 @@ def subscribe(self, topic: str, callback: Callable) -> None: def publish(self, topic: str, msg: str) -> None: self.pub_socket.send_multipart([topic.encode(), msg.encode()]) + self.logger.info(f"Publish: {topic} {msg}") def start_polling(self) -> None: self._running = True @@ -44,7 +50,10 @@ def _poll_loop(self) -> None: for _, sub in self.subs.items(): if sub in events: topic, msg = sub.recv_multipart() - self.callbacks[topic.decode()](msg.decode()) + topic_str = topic.decode() + msg_str = msg.decode() + self.logger.info(f"Receive: {topic_str} {msg_str}") + self.callbacks[topic_str](msg_str) def run(self, stop_event: Event = None) -> None: """ @@ -55,6 +64,10 @@ def run(self, stop_event: Event = None) -> None: self.start_polling() try: self.loop(stop_event) + except KeyboardInterrupt: + self.logger.info("Ctrl+C pressed, exiting cleanly") + except Exception as e: + self.logger.error(e) finally: self.stop() @@ -66,7 +79,6 @@ def loop(self, stop_event: Event = None) -> None: def stop(self) -> None: """Stop the module gracefully.""" - # Close poller daemon if self._running: self._running = False self.poller.join() @@ -75,23 +87,19 @@ def stop(self) -> None: try: sub.close(0) except Exception as e: - print(f"[{self.name}] Error closing SUB socket for '{topic}': {e}") + self.logger.error(f"Error closing SUB socket for '{topic}': {e}") self.subs.clear() self.callbacks.clear() - # Close publisher socket - if hasattr(self, "pub_socket"): - try: - self.pub_socket.close(0) - except Exception as e: - print(f"[{self.name}] Error closing PUB socket: {e}") + try: + self.pub_socket.close(0) + except Exception as e: + self.logger.error(f"Error closing SUB socket for '{topic}': {e}") - # Terminate the context - if hasattr(self, "ctx"): - try: - self.ctx.term() - except Exception as e: - print(f"[{self.name}] Error terminating ZMQ context: {e}") + try: + self.ctx.term() + except Exception as e: + self.logger.error(f"Error terminating ZMQ context: {e}") - print(f"[{self.name}] Module stopped gracefully.") + self.logger.info(f"Module stopped gracefully.") From cd381f460d94d7627b5c7212b028602b7f951131 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Fri, 19 Sep 2025 10:57:28 +0200 Subject: [PATCH 10/42] evol(EventRouter): logging implemented --- src/module/event_router.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/module/event_router.py b/src/module/event_router.py index 510ce3e..98395ee 100644 --- a/src/module/event_router.py +++ b/src/module/event_router.py @@ -1,11 +1,15 @@ +from typing import Optional + import zmq +from src.tools.logger import logging, setup_logger + XPUB_ENDPOINT = "tcp://localhost:5555" XSUB_ENDPOINT = "tcp://localhost:5556" class EventRouter: - def __init__(self): + def __init__(self, logger: Optional[logging.Logger] = setup_logger("EventRouter")): self.ctx = zmq.Context() self.xpub = self.ctx.socket(zmq.XPUB) @@ -13,6 +17,12 @@ def __init__(self): self.xpub.bind(XPUB_ENDPOINT) self.xsub.bind(XSUB_ENDPOINT) + self.logger = logger + def start(self): - print("EventRouter started") - zmq.proxy(self.xsub, self.xpub) + try: + zmq.proxy(self.xsub, self.xpub) + except KeyboardInterrupt: + self.logger.info("Ctrl+C pressed, exiting cleanly") + except Exception as e: + self.logger.error(e) From 1de49f5d6a8e64425cfbbe11fd4c50962c0870bd Mon Sep 17 00:00:00 2001 From: Popochounet Date: Fri, 19 Sep 2025 11:02:47 +0200 Subject: [PATCH 11/42] evol(shell): implemented log cmd --- src/module/shell.py | 58 +++++++++++++++++++++++++++++++++++++++++++++ src/tools/logger.py | 2 +- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/module/shell.py b/src/module/shell.py index 5299b16..652162d 100644 --- a/src/module/shell.py +++ b/src/module/shell.py @@ -1,4 +1,5 @@ import cmd +import logging from .manager import ModuleManager @@ -12,15 +13,72 @@ def __init__(self, manager: ModuleManager): self.manager = manager def do_status(self, arg): + "Display modules and router status." self.manager.status() def do_start(self, arg): + "Start a module." self.manager.start(arg.strip()) def do_stop(self, arg): + "Stop a module." self.manager.stop(arg.strip()) def do_exit(self, arg): + "Exit HuRi." self.manager.stop_all() print("Bye !") return True + + def do_log(self, arg): + """ + Usage: + log status -> display log levels + log -> set global root level (DEBUG/INFO/WARNING/ERROR/CRITICAL), has priority over custom module level + log -> set custom per-module level (module name e.g. STT) + log all -> set all levels (DEBUG/INFO/WARNING/ERROR/CRITICAL) + """ + parts = arg.strip().split() + if not parts: + print("Missing arguments. Type 'help log' for usage.") + return + + level_map = { + "DEBUG": logging.DEBUG, + "INFO": logging.INFO, + "WARNING": logging.WARNING, + "ERROR": logging.ERROR, + "CRITICAL": logging.CRITICAL, + } + + try: + if parts[0].lower() == "status": + self.manager.log_status() + return + + if len(parts) == 1: + # Set root level + level = level_map.get(parts[0].upper()) + if level is None: + print(f"Unknown level: {parts[0]}") + return + self.manager.set_root_log_level(level) + print(f"Root log level set to {parts[0].upper()}") + + elif len(parts) == 2: + level = level_map.get(parts[1].upper()) + if level is None: + print(f"Unknown level: {parts[1]}") + return + if parts[0].lower() == "all": + self.manager.set_log_levels(level) + print(f"All log levels set to {parts[1].upper()}") + else: + module = parts[0] + self.manager.set_log_level(module, level) + print(f"{module} log level set to {parts[1].upper()}") + + else: + print("Invalid arguments. Type 'help log' for usage.") + except Exception as e: + print(f"Error setting log level: {e}") diff --git a/src/tools/logger.py b/src/tools/logger.py index f3f2bcf..091f6dc 100644 --- a/src/tools/logger.py +++ b/src/tools/logger.py @@ -1,7 +1,7 @@ import logging import multiprocessing as mp from logging.handlers import QueueHandler, QueueListener -from typing import IO, Optional, Dict +from typing import IO, Dict, Optional def setup_handler( From eef8adaf4a71d51ba9aa7e38b929bcd7c432e556 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Fri, 19 Sep 2025 11:03:19 +0200 Subject: [PATCH 12/42] evol(main): CTRL+C is now well handled --- src/main.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/main.py b/src/main.py index 86858cc..b16d946 100644 --- a/src/main.py +++ b/src/main.py @@ -4,37 +4,38 @@ from src.module.module import Module from src.module.shell import ModuleManager, RobotShell +from src.tools.logger import logging class TTSModule(Module): - def __init__(self, name="TTS"): - super().__init__(name) + def __init__(self, name="TTS", logger=None): + super().__init__(name, logger) self.subscribe("llm.response", self.on_llm_response) def on_llm_response(self, msg): - print(f"[{self.name}] parle ->", msg) + self.logger.debug(f"[{self.name}] parle -> {msg}") class LLMModule(Module): - def __init__(self, name="LLM"): - super().__init__(name) + def __init__(self, name="LLM", logger=None): + super().__init__(name, logger) self.subscribe("speech.in", self.on_speech) def on_speech(self, msg): reponse = f"Réponse à '{msg}'" - print(f"[{self.name}] ->", reponse) + self.logger.debug(f"[{self.name}] -> {reponse}") self.publish("llm.response", reponse) class STTModule(Module): - def __init__(self, name="STT"): - super().__init__(name) + def __init__(self, name="STT", logger=None): + super().__init__(name, logger) def loop(self, stop_event: Event = None): i = 0 while stop_event is None or not stop_event.is_set(): phrase = f"Phrase numéro {i}" - print(f"[{self.name}] ->", phrase) + self.logger.debug(f"[{self.name}] -> {phrase}") self.publish("speech.in", phrase) i += 1 time.sleep(2) @@ -44,7 +45,10 @@ def loop(self, stop_event: Event = None): modules: Dict[str, Module] = {"STT": STTModule, "TTS": TTSModule, "LLM": LLMModule} manager = ModuleManager(modules) manager.start() + time.sleep(0.1) try: RobotShell(manager).cmdloop() except KeyboardInterrupt: - pass + manager.stop_all() + except Exception as e: + logging.getLogger().error(e) From 4e52de94106d9cea67a0a494b1b0ac672c5a9167 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Fri, 3 Oct 2025 09:28:27 +0200 Subject: [PATCH 13/42] fix(logger): record level == root level were filtered --- src/tools/logger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/logger.py b/src/tools/logger.py index 091f6dc..6c4a00a 100644 --- a/src/tools/logger.py +++ b/src/tools/logger.py @@ -62,7 +62,7 @@ def filter(self, record: logging.LogRecord) -> bool: """the root level has priority over custom levels""" level = self.log_levels.get(record.name, self.root_level) - return self.root_level < record.levelno and level < record.levelno + return self.root_level <= record.levelno and level <= record.levelno def set_root_level(self, level: int) -> None: self.root_level = level From d64f038ccc3b2ee340900efad1a63620d23e7fdb Mon Sep 17 00:00:00 2001 From: Popochounet Date: Fri, 3 Oct 2025 09:31:10 +0200 Subject: [PATCH 14/42] evol(Module): added other event struct type (json & bytes) --- src/module/module.py | 122 ++++++++++++++++++++++++++++--------------- 1 file changed, 81 insertions(+), 41 deletions(-) diff --git a/src/module/module.py b/src/module/module.py index db53466..8789b7d 100644 --- a/src/module/module.py +++ b/src/module/module.py @@ -1,86 +1,116 @@ -import multiprocessing as mp +import json import threading -import time +from abc import ABC, abstractmethod from multiprocessing.synchronize import Event -from typing import Callable, Dict, Optional +from typing import Callable, Dict, final import zmq -from src.tools.logger import logging, setup_logger +from src.tools.logger import logging from .event_router import XPUB_ENDPOINT, XSUB_ENDPOINT -class Module: - def __init__(self, name: str, logger: Optional[logging.Logger] = None) -> None: - self.name = name +class Module(ABC): + def __init__(self): + """Child Modules must call super.__init__() in their __init__() function.""" + self.ctx = None + self.pub_socket = None + self.subs: Dict[str, zmq.Socket[bytes]] = {} + self.callbacks = {} + self._poller_running = False + self.poller = None + self.logger = logging.getLogger(__name__) + + @final + def _initialize(self) -> None: + """ + Called inside start_module() or manually before usage. + This function exist because ctx cannot be set in __init__, because of multi-processing. + """ self.ctx = zmq.Context() self.pub_socket = self.ctx.socket(zmq.PUB) self.pub_socket.connect(XSUB_ENDPOINT) - - self.subs: Dict[str, zmq.SyncSocket] = {} - self.callbacks: Dict[str, Callable] = {} - self._running = False self.poller = threading.Thread(target=self._poll_loop, daemon=True) + self.set_subscriptions() - self.logger = logger or logging.getLogger(self.name) + @abstractmethod + def set_subscriptions(self) -> None: + """Child module must define this funcction with subscriptions""" + ... + @final def subscribe(self, topic: str, callback: Callable) -> None: sub_socket = self.ctx.socket(zmq.SUB) sub_socket.connect(XPUB_ENDPOINT) sub_socket.setsockopt_string(zmq.SUBSCRIBE, topic) self.subs[topic] = sub_socket self.callbacks[topic] = callback - - def publish(self, topic: str, msg: str) -> None: - self.pub_socket.send_multipart([topic.encode(), msg.encode()]) - self.logger.info(f"Publish: {topic} {msg}") - - def start_polling(self) -> None: - self._running = True + self.logger.info(f"Subscribe: {topic}") + + @final + def publish( + self, topic: str, msg: object, content_type: str = "str" + ) -> None: # TODO content type enum + if content_type == "json": + payload = json.dumps(msg).encode() + elif content_type == "bytes": + payload = msg + elif content_type == "str": + payload = msg.encode() + else: + raise ValueError(f"Unsupported content_type: {content_type}") + + self.pub_socket.send_multipart([topic.encode(), content_type.encode(), payload]) + self.logger.info(f"Publish: {topic} {content_type}") + + @final + def _start_polling(self) -> None: + self._poller_running = True self.poller.start() + @final def _poll_loop(self) -> None: poller = zmq.Poller() for sub in self.subs.values(): poller.register(sub, zmq.POLLIN) - while self._running: + while self._poller_running: events = dict(poller.poll(100)) for _, sub in self.subs.items(): if sub in events: - topic, msg = sub.recv_multipart() + topic, content_type, payload = sub.recv_multipart() topic_str = topic.decode() - msg_str = msg.decode() - self.logger.info(f"Receive: {topic_str} {msg_str}") - self.callbacks[topic_str](msg_str) - - def run(self, stop_event: Event = None) -> None: - """ - Default run: waits for events. - Child classes can override `loop()` for active behavior. - """ + content_type_str = content_type.decode() + if content_type_str == "json": + data = json.loads(payload.decode()) + elif content_type_str == "bytes": + data = payload + elif content_type_str == "str": + data = payload.decode() + self.logger.info(f"Receive: {topic_str} {content_type_str}") + self.callbacks[topic_str](data) + + @final + def start_module(self, stop_event: Event = None) -> None: + self._initialize() if self.subs != {}: - self.start_polling() + self._start_polling() try: - self.loop(stop_event) + self.run_module(stop_event) except KeyboardInterrupt: self.logger.info("Ctrl+C pressed, exiting cleanly") except Exception as e: self.logger.error(e) finally: - self.stop() - - def loop(self, stop_event: Event = None) -> None: - """Child modules override this instead of run(). Default: idle wait.""" - while stop_event is None or not stop_event.is_set(): - time.sleep(0.1) + self.stop_module() - def stop(self) -> None: + @final + def stop_module(self) -> None: """Stop the module gracefully.""" - if self._running: - self._running = False + if self._poller_running: + self._poller_running = False self.poller.join() for topic, sub in self.subs.items(): @@ -103,3 +133,13 @@ def stop(self) -> None: self.logger.error(f"Error terminating ZMQ context: {e}") self.logger.info(f"Module stopped gracefully.") + + def run_module(self, stop_event: Event = None) -> None: + """Child modules override this instead of run(). Default: idle wait.""" + if stop_event: + stop_event.wait() + + @final + def set_custom_logger(self, logger) -> None: + """The default logger in set in __init__.""" + self.logger = logger From e31def2d5544892142ae4f80105716b86719655e Mon Sep 17 00:00:00 2001 From: Popochounet Date: Fri, 3 Oct 2025 09:32:47 +0200 Subject: [PATCH 15/42] evol(ModuleManager): modules are not class anymore, but instance of the class --- src/module/manager.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/src/module/manager.py b/src/module/manager.py index 9230b69..159c273 100644 --- a/src/module/manager.py +++ b/src/module/manager.py @@ -2,7 +2,7 @@ import signal import time from multiprocessing.synchronize import Event -from typing import Dict, List +from typing import Dict from src.tools.logger import (LevelFilter, QueueListener, logging, setup_log_listener, setup_logger) @@ -43,27 +43,22 @@ def _start_event_router(self) -> None: self.logger.info(f"Router started (PID={self.router_process.pid})") @staticmethod - def _run_module( - mod_cls: Module, name: str, log_queue: mp.Queue, stop_event: Event + def _start_module( + module: Module, name: str, log_queue: mp.Queue, stop_event: Event ) -> None: + """Helper function to start module in child process.""" logger = setup_logger(name, log_queue=log_queue) + module.set_custom_logger(logger) def handle_sigint(signum, frame): logger.info(f"Ctrl+C ignored in child module") signal.signal(signal.SIGINT, handle_sigint) - module: Module = mod_cls(name, logger=logger) - module.run(stop_event=stop_event) - - def start(self): - """Start event router and modules""" # TODO config (also logs levels) - self.log_listener.start() - self._start_event_router() - for name in self.modules: - self.start_module(name) + module.start_module(stop_event=stop_event) def start_module(self, name): + """Check if module is registered and not already running, and start a child process.""" if name not in self.modules: self.logger.warning( f"{name} is not in the registered Modules: {self.modules.keys()}" @@ -75,11 +70,11 @@ def start_module(self, name): ) return - mod_cls = self.modules[name] + module = self.modules[name] stop_event = mp.Event() p = mp.Process( - target=self._run_module, - args=(mod_cls, name, self.log_queue, stop_event), + target=self._start_module, + args=(module, name, self.log_queue, stop_event), daemon=True, ) self.processes[name] = p @@ -87,7 +82,7 @@ def start_module(self, name): self.level_filter.add_level(name) p.start() - self.logger.info(f"{name} ({mod_cls}) started (PID={p.pid})") + self.logger.info(f"{name} ({type(module)}) started (PID={p.pid})") def stop_module(self, name): if name in self.processes: @@ -102,6 +97,13 @@ def stop_module(self, name): del self.stop_events[name] self.level_filter.del_level(name) + def start(self): + """Start event router and modules""" # TODO config (also logs levels) + self.log_listener.start() + self._start_event_router() + for name in self.modules: + self.start_module(name) + def stop_all(self): for name in list(self.processes.keys()): self.stop_module(name) From 592fb6b3c18e3d36cc5be292a008882edccc8e92 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Fri, 3 Oct 2025 09:33:13 +0200 Subject: [PATCH 16/42] feat(STT): added RecordSpeech module --- src/module/speech_to_text/record_speech.py | 107 +++++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 src/module/speech_to_text/record_speech.py diff --git a/src/module/speech_to_text/record_speech.py b/src/module/speech_to_text/record_speech.py new file mode 100644 index 0000000..c0f38ed --- /dev/null +++ b/src/module/speech_to_text/record_speech.py @@ -0,0 +1,107 @@ +import queue +import threading +import time +from typing import List, Optional + +import numpy as np +import sounddevice as sd + +from src.module.module import Event, Module + + +class RecordSpeech(Module): + def __init__( + self, + threshold: int = 0, + silence_duration: float = 1.0, + chunk_duration: float = 0.5, + sample_rate: int = 16000, + ): + super().__init__() + + self.THRESHOLD: int = threshold + self.SILENCE_DURATION: float = silence_duration + self.CHUNK_DURATION: float = chunk_duration + self.SAMPLE_RATE: int = sample_rate + self.running: bool = False + self.audio_queue: queue.Queue = queue.Queue() + self.transcriptions: queue.Queue = queue.Queue() + self.pause_record = threading.Semaphore(1) + self.audio_to_process = threading.Semaphore(0) + self.prompt_available = threading.Semaphore(0) + self.noise_profile: np.ndarray + + def reduce_noise(self, chunk: np.ndarray) -> np.ndarray: + if np.abs(chunk).mean() <= self.THRESHOLD: + return chunk + + return np.clip(chunk - self.noise_profile, -32768, 32767).astype(np.int16) + + def record_chunk(self) -> np.ndarray: + self.pause_record.acquire() + chunk: np.ndarray = sd.rec( + int(self.CHUNK_DURATION * self.SAMPLE_RATE), + samplerate=self.SAMPLE_RATE, + channels=1, + dtype="int16", + ).ravel() + sd.wait() + self.pause_record.release() + return self.reduce_noise(chunk) + + def calculate_noise_level(self) -> None: + self.logger.info("Listening for 10 seconds to calculate noise level...") + noise_chunk: np.ndarray = sd.rec( + int(10 * self.SAMPLE_RATE), + samplerate=self.SAMPLE_RATE, + channels=1, + dtype="int16", + ).ravel() + sd.wait() + self.noise_profile = noise_chunk.mean(axis=0) + self.THRESHOLD = np.abs(self.reduce_noise(noise_chunk)).mean() + self.logger.info(f"Threshold: {self.THRESHOLD}") + + def record_audio(self, starting_chunk, stop_event: Event = None) -> None: + buffer: List[np.ndarray] = [starting_chunk] + silence_start: Optional[float] = None + + while stop_event is None or not stop_event.is_set(): + chunk = self.record_chunk() + buffer.append(chunk) + + if np.abs(chunk).mean() <= self.THRESHOLD: + if silence_start is None: + silence_start = time.time() + elif time.time() - silence_start >= self.SILENCE_DURATION: + if buffer == []: + break + speech = np.concatenate(buffer, axis=0) + self.publish("speech.in", speech.tobytes(), "bytes") + break + else: + silence_start = None + + def set_subscriptions(self) -> None: + self.subscribe("speech.in.pause", self.pause()) + self.subscribe("speech.in.resume", self.pause(False)) + + def run_module(self, stop_event: Event = None) -> None: + if not self.THRESHOLD: + self.calculate_noise_level() + else: + self.noise_profile = np.zeros( + int(self.CHUNK_DURATION * self.SAMPLE_RATE), dtype=np.int16 + ) + + while stop_event is None or not stop_event.is_set(): + chunk: np.ndarray = self.record_chunk() + + if np.abs(chunk).mean() > self.THRESHOLD: + self.record_audio(chunk, stop_event) + + def pause(self, true: bool = True) -> None: + if true: + self.pause_record.acquire() + else: + self.pause_record.release() From ca447f3999a97ef66325172d1c3a7556ec57ca22 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Fri, 3 Oct 2025 09:33:33 +0200 Subject: [PATCH 17/42] feat(STT): added SpeechToText module --- src/module/speech_to_text/speech_to_text.py | 54 ++++++++ src/speech_to_text/speech_to_text.py | 141 -------------------- 2 files changed, 54 insertions(+), 141 deletions(-) create mode 100644 src/module/speech_to_text/speech_to_text.py delete mode 100644 src/speech_to_text/speech_to_text.py diff --git a/src/module/speech_to_text/speech_to_text.py b/src/module/speech_to_text/speech_to_text.py new file mode 100644 index 0000000..696a597 --- /dev/null +++ b/src/module/speech_to_text/speech_to_text.py @@ -0,0 +1,54 @@ +import io +import queue +import threading +import time +from typing import Callable, List, Optional + +import numpy as np +import sounddevice as sd +import soundfile as sf +import whisper + +from src.module.module import Event, Module + + +class SpeechToText(Module): + def __init__( + self, + model_name: str = "base.en", + device: str = "cpu", + sample_rate: int = 16000, + ): + super().__init__() + if device == "cpu": + import warnings + + warnings.filterwarnings( + "ignore", message="FP16 is not supported on CPU; using FP32 instead" + ) + self.model: whisper.Whisper = whisper.load_model(model_name, device=device) + self.SAMPLE_RATE: int = sample_rate + self.running: bool = False + self.audio_queue: queue.Queue = queue.Queue() + self.transcriptions: queue.Queue = queue.Queue() + self.pause_record = threading.Semaphore(1) + self.audio_to_process = threading.Semaphore(0) + self.prompt_available = threading.Semaphore(0) + self.noise_profile: np.ndarray + + def process_audio(self, buffer: bytes) -> None: + if not buffer: + return + + audio_array = np.frombuffer(buffer, dtype=np.int16) + audio_array = audio_array.astype(np.float32) / 32768.0 + + result: dict = self.model.transcribe(audio_array, language="en") + result["text"] = result["text"].strip() + if not result["text"]: + return + + self.publish("text.in", result["text"]) + + def set_subscriptions(self) -> None: + self.subscribe("speech.in", self.process_audio) diff --git a/src/speech_to_text/speech_to_text.py b/src/speech_to_text/speech_to_text.py deleted file mode 100644 index fd3ce39..0000000 --- a/src/speech_to_text/speech_to_text.py +++ /dev/null @@ -1,141 +0,0 @@ -import sounddevice as sd -import numpy as np -import io -import soundfile as sf -import whisper -from typing import List, Optional, Callable -import threading -import queue -import time - - -class SpeechToText: - def __init__( - self, - model_name: str = "base.en", - device: str = "cpu", - threshold: int = 0, - silence_duration: float = 1.0, - chunk_duration: float = 0.5, - sample_rate: int = 16000, - ): - if device == "cpu": - import warnings - - warnings.filterwarnings( - "ignore", message="FP16 is not supported on CPU; using FP32 instead" - ) - self.model: whisper.Whisper = whisper.load_model(model_name, device=device) - self.THRESHOLD: int = threshold - self.SILENCE_DURATION: float = silence_duration - self.CHUNK_DURATION: float = chunk_duration - self.SAMPLE_RATE: int = sample_rate - self.running: bool = False - self.audio_queue: queue.Queue = queue.Queue() - self.transcriptions: queue.Queue = queue.Queue() - self.pause_record = threading.Semaphore(1) - self.audio_to_process = threading.Semaphore(0) - self.prompt_available = threading.Semaphore(0) - self.noise_profile: np.ndarray - - def reduce_noise(self, chunk: np.ndarray) -> np.ndarray: - if np.abs(chunk).mean() <= self.THRESHOLD: - return chunk - return np.clip(chunk - self.noise_profile, -32768, 32767).astype(np.int16) - - def record_chunk(self) -> np.ndarray: - self.pause_record.acquire() - chunk: np.ndarray = sd.rec( - int(self.CHUNK_DURATION * self.SAMPLE_RATE), - samplerate=self.SAMPLE_RATE, - channels=1, - dtype="int16", - ) - sd.wait() - self.pause_record.release() - return self.reduce_noise(chunk) - - def calculate_noise_level(self) -> None: - print("Listening for 10 seconds to calculate noise level...") - noise_chunk: np.ndarray = sd.rec( - int(10 * self.SAMPLE_RATE), - samplerate=self.SAMPLE_RATE, - channels=1, - dtype="int16", - ) - sd.wait() - self.noise_profile = noise_chunk.mean(axis=0) - self.THRESHOLD = np.abs(self.reduce_noise(noise_chunk)).mean() - print(f"Threshold: {self.THRESHOLD}") - - def process_audio(self, buffer: List[np.ndarray]) -> None: - if not buffer: - return - - audio_data: np.ndarray = np.concatenate(buffer, axis=0) - input_buffer: io.BytesIO = io.BytesIO() - sf.write(input_buffer, audio_data, self.SAMPLE_RATE, format="WAV") - input_buffer.seek(0) - audio_array, _ = sf.read(input_buffer, dtype="float32") - - result: dict = self.model.transcribe(audio_array, language="en") - result["text"] = result["text"].strip() - if not result["text"]: - return - - self.transcriptions.put([result["text"], audio_array]) - self.prompt_available.release() - - def record_audio(self, starting_chunk) -> None: - buffer: List[np.ndarray] = [starting_chunk] - silence_start: Optional[float] = None - - while self.running: - chunk = self.record_chunk() - buffer.append(chunk) - if np.abs(chunk).mean() <= self.THRESHOLD: - if silence_start is None: - silence_start = time.time() - elif time.time() - silence_start >= self.SILENCE_DURATION: - self.audio_queue.put(buffer) - self.audio_to_process.release() - break - else: - silence_start = None - - def listen_audio(self) -> None: - self.running = True - while self.running: - chunk: np.ndarray = self.record_chunk() - if np.abs(chunk).mean() > self.THRESHOLD: - self.record_audio(chunk) - - def process_queue(self) -> None: - self.audio_to_process.acquire() - while self.running: - buffer = self.audio_queue.get() - self.process_audio(buffer) - self.audio_to_process.acquire() - - def start(self) -> None: - if not self.THRESHOLD: - self.calculate_noise_level() - - self.running = True - threading.Thread(target=self.listen_audio).start() - threading.Thread(target=self.process_queue).start() - - def pause(self, true: bool = True) -> None: - if true: - self.pause_record.acquire() - else: - self.pause_record.release() - - def stop(self) -> None: - self.running = False - self.audio_to_process.release() - self.pause_record.release() - - def get_prompt(self) -> tuple[str, np.ndarray]: - self.prompt_available.acquire() - return self.transcriptions.get() From d0fbae4983f2bb8d3483175de8e953dc81c20d99 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Fri, 3 Oct 2025 09:35:15 +0200 Subject: [PATCH 18/42] evol(main): update main to demo new modules --- src/main.py | 46 +++++++++++++++++++++------------------------- 1 file changed, 21 insertions(+), 25 deletions(-) diff --git a/src/main.py b/src/main.py index b16d946..211cf29 100644 --- a/src/main.py +++ b/src/main.py @@ -1,48 +1,44 @@ import time -from multiprocessing.synchronize import Event from typing import Dict from src.module.module import Module from src.module.shell import ModuleManager, RobotShell +from src.module.speech_to_text.record_speech import RecordSpeech +from src.module.speech_to_text.speech_to_text import SpeechToText from src.tools.logger import logging class TTSModule(Module): - def __init__(self, name="TTS", logger=None): - super().__init__(name, logger) + def __init__(self): + super().__init__() + + def set_subscriptions(self) -> None: self.subscribe("llm.response", self.on_llm_response) - def on_llm_response(self, msg): - self.logger.debug(f"[{self.name}] parle -> {msg}") + def on_llm_response(self, msg: str): + self.logger.debug(f"parle -> {msg}") class LLMModule(Module): - def __init__(self, name="LLM", logger=None): - super().__init__(name, logger) - self.subscribe("speech.in", self.on_speech) + def __init__(self): + super().__init__() + + def set_subscriptions(self) -> None: + self.subscribe("text.in", self.on_speech) - def on_speech(self, msg): + def on_speech(self, msg: str): reponse = f"Réponse à '{msg}'" - self.logger.debug(f"[{self.name}] -> {reponse}") + self.logger.debug(f"{reponse}") self.publish("llm.response", reponse) -class STTModule(Module): - def __init__(self, name="STT", logger=None): - super().__init__(name, logger) - - def loop(self, stop_event: Event = None): - i = 0 - while stop_event is None or not stop_event.is_set(): - phrase = f"Phrase numéro {i}" - self.logger.debug(f"[{self.name}] -> {phrase}") - self.publish("speech.in", phrase) - i += 1 - time.sleep(2) - - if __name__ == "__main__": - modules: Dict[str, Module] = {"STT": STTModule, "TTS": TTSModule, "LLM": LLMModule} + modules: Dict[str, Module] = { + "REC": RecordSpeech(), + "STT": SpeechToText(), + "LLM": LLMModule(), + "TTS": TTSModule(), + } manager = ModuleManager(modules) manager.start() time.sleep(0.1) From 9a633734becd12905d8f5e850a9e3fa0488f894f Mon Sep 17 00:00:00 2001 From: Popochounet Date: Tue, 7 Oct 2025 06:23:19 +0200 Subject: [PATCH 19/42] evol(rag): now a module --- src/{ => module}/rag/rag.py | 51 ++++++++++++++++++++++++------------- tests/rag/rag.py | 2 +- 2 files changed, 35 insertions(+), 18 deletions(-) rename src/{ => module}/rag/rag.py (69%) diff --git a/src/rag/rag.py b/src/module/rag/rag.py similarity index 69% rename from src/rag/rag.py rename to src/module/rag/rag.py index 733ae0c..b3bb71f 100644 --- a/src/rag/rag.py +++ b/src/module/rag/rag.py @@ -1,23 +1,27 @@ -from langchain_community.document_loaders import TextLoader +import json +import pathlib + +from langchain.chains import create_retrieval_chain +from langchain.chains.combine_documents import create_stuff_documents_chain from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_chroma import Chroma +from langchain_community.document_loaders import TextLoader +from langchain_core.prompts import ChatPromptTemplate from langchain_ollama.embeddings import OllamaEmbeddings from langchain_ollama.llms import OllamaLLM -from langchain.chains import create_retrieval_chain -from langchain.chains.combine_documents import create_stuff_documents_chain -from langchain_core.prompts import ChatPromptTemplate from langgraph.checkpoint.memory import MemorySaver -import json -import pathlib + +from src.module.module import Module -class Rag: +class Rag(Module): def __init__( self, - model="deepseek-r1:7b", - collectionName="vectorStore", - vectorstorePath="src/rag/vectorStore", + model: str = "deepseek-r1:7b", + collectionName: str = "vectorStore", + vectorstorePath: str = "src/rag/vectorStore", ): + super().__init__() self.memory = MemorySaver() self.embeddings = OllamaEmbeddings(model=model) self.llm = OllamaLLM(model=model) @@ -44,18 +48,19 @@ def __init__( self.conversation = [] self.conversation_log = {"conversation": []} - def ragLoader(self, text: str): + def ragFill(self, text: str) -> None: self.documents += self.textSplitter.split_documents(text) self.vectorstore.add_documents(self.documents) - def ragLoader(self, pathFolder: str, fileType: str): + def ragLoad(self, folderPath: str, fileType: str) -> None: if fileType == "txt": - for file in pathlib.Path(pathFolder).rglob("*.txt"): - fileLoader = TextLoader(file_path=pathFolder + "/" + file.name) + for file in pathlib.Path(folderPath).rglob("*.txt"): + fileLoader = TextLoader(file_path=folderPath + "/" + file.name) self.documents += self.textSplitter.split_documents(fileLoader.load()) self.vectorstore.add_documents(self.documents) - def ragQuestion(self, question: str): + def ragQuestion(self, question: str) -> None: + self.logger.debug("question:", question) history = "\n".join( [ f"Human: {qa['question']}\nAI: {qa['answer']}" @@ -64,13 +69,25 @@ def ragQuestion(self, question: str): ) helpingContext = "Answer with just your message like in a conversation. " question = helpingContext + question + self.logger.debug("full question:", question) response = self.qaChain.invoke({"history": history, "input": question}) answer = response["answer"] + self.logger.debug("answer:", answer) self.conversation_log["conversation"].append( {"question": question.split(helpingContext)[1:], "answer": answer} ) - return answer + self.publish("llm.response", answer) - def saveConversation(self, filename="conversation_log.json"): + def saveConversation(self, filename: str = "conversation_log.json"): with open(filename, "w") as f: json.dump(self.conversation_log, f, indent=4) + + def set_subscriptions(self) -> None: + self.subscribe("rag.load", self.ragLoad) + self.subscribe("llm.in", self.ragQuestion) + self.subscribe("rag.in", self.ragFill) + self.subscribe("rag.save", self.saveConversation) + + def run_module(self, stop_event=None) -> None: + self.ragLoad("tests/rag/docsRag", "txt") + super().run_module(stop_event) diff --git a/tests/rag/rag.py b/tests/rag/rag.py index 0054820..9cc489a 100644 --- a/tests/rag/rag.py +++ b/tests/rag/rag.py @@ -7,7 +7,7 @@ def test_main(): rag = Rag(vectorstorePath=f"{__path__}/src/rag/vectorStore") - rag.ragLoader(f"{__path__}/tests/rag/docsRag", "txt") + rag.ragLoad(f"{__path__}/tests/rag/docsRag", "txt") print( rag.ragQuestion( "The new capital of France is Edimburgh and what is the capital of Spain?" From 1a532614f3287e6a391c6eff57d2b0389bbebf87 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Tue, 7 Oct 2025 06:23:56 +0200 Subject: [PATCH 20/42] feat(mode_controller): module to switch mode --- src/module/rag/mode_controller.py | 56 +++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/module/rag/mode_controller.py diff --git a/src/module/rag/mode_controller.py b/src/module/rag/mode_controller.py new file mode 100644 index 0000000..0f9780a --- /dev/null +++ b/src/module/rag/mode_controller.py @@ -0,0 +1,56 @@ +import json +import pathlib +from enum import Enum + +from langchain.chains import create_retrieval_chain +from langchain.chains.combine_documents import create_stuff_documents_chain +from langchain.text_splitter import RecursiveCharacterTextSplitter +from langchain_chroma import Chroma +from langchain_community.document_loaders import TextLoader +from langchain_core.prompts import ChatPromptTemplate +from langchain_ollama.embeddings import OllamaEmbeddings +from langchain_ollama.llms import OllamaLLM +from langgraph.checkpoint.memory import MemorySaver + +from src.module.module import Module + + +class Modes(Enum): + LLM = 0 + CONTEXT = 1 + RAG = 2 + + +class ModeController(Module): + def __init__( + self, + ): + super().__init__() + self.mode = Modes.LLM + + def switchMode(self, mode: str) -> None: + if mode == "llm": + self.mode = Modes.LLM + elif mode == "context": + self.mode = Modes.CONTEXT + elif mode == "rag": + self.mode = Modes.RAG + + def processTextInput(self, text: str): + if "switch llm" in text.lower(): + self.switchMode(Modes.LLM) + elif "switch context" in text.lower(): + self.switchMode(Modes.CONTEXT) + elif "switch rag" in text.lower(): + self.switchMode(Modes.RAG) + elif "bye bye" in text.lower(): + self.publish("exit", "") # TODO handle (manager being a module) + elif text.strip() == "": + return + else: + topic = f"{str(self.mode.name).lower()}.in" + self.publish(topic, text) + + def set_subscriptions(self): + self.subscribe("text.in", self.processTextInput) + self.subscribe("mode.switch", self.switchMode) From 402d2ff11480a98e71df127b5fad723ae6b3b308 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Tue, 7 Oct 2025 06:24:48 +0200 Subject: [PATCH 21/42] evol(module): event with json args will kwargs --- src/module/module.py | 10 +++++++--- src/module/shell.py | 4 ++-- src/module/speech_to_text/speech_to_text.py | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/module/module.py b/src/module/module.py index 8789b7d..e9eec50 100644 --- a/src/module/module.py +++ b/src/module/module.py @@ -82,14 +82,18 @@ def _poll_loop(self) -> None: topic, content_type, payload = sub.recv_multipart() topic_str = topic.decode() content_type_str = content_type.decode() + self.logger.info(f"Receive: {topic_str} {content_type_str}") if content_type_str == "json": - data = json.loads(payload.decode()) + kwargs = json.loads(payload.decode()) + self.callbacks[topic_str]( + **kwargs + ) # TODO better and cleaner way ? elif content_type_str == "bytes": data = payload + self.callbacks[topic_str](data) elif content_type_str == "str": data = payload.decode() - self.logger.info(f"Receive: {topic_str} {content_type_str}") - self.callbacks[topic_str](data) + self.callbacks[topic_str](data) @final def start_module(self, stop_event: Event = None) -> None: diff --git a/src/module/shell.py b/src/module/shell.py index 652162d..28938b5 100644 --- a/src/module/shell.py +++ b/src/module/shell.py @@ -18,11 +18,11 @@ def do_status(self, arg): def do_start(self, arg): "Start a module." - self.manager.start(arg.strip()) + self.manager.start_module(arg.strip()) def do_stop(self, arg): "Stop a module." - self.manager.stop(arg.strip()) + self.manager.stop_module(arg.strip()) def do_exit(self, arg): "Exit HuRi." diff --git a/src/module/speech_to_text/speech_to_text.py b/src/module/speech_to_text/speech_to_text.py index 696a597..43fdfa9 100644 --- a/src/module/speech_to_text/speech_to_text.py +++ b/src/module/speech_to_text/speech_to_text.py @@ -45,7 +45,7 @@ def process_audio(self, buffer: bytes) -> None: result: dict = self.model.transcribe(audio_array, language="en") result["text"] = result["text"].strip() - if not result["text"]: + if not result["text"] or result["text"] == "": return self.publish("text.in", result["text"]) From ca93a078abeaeeb8d448eeb1959e24ee2bab9960 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 14 Dec 2025 14:29:28 +0100 Subject: [PATCH 22/42] refactor(archi): remove deprecated classes --- src/module/event_router.py | 28 ------- src/module/manager.py | 152 ------------------------------------- src/module/module.py | 149 ------------------------------------ src/module/shell.py | 84 -------------------- 4 files changed, 413 deletions(-) delete mode 100644 src/module/event_router.py delete mode 100644 src/module/manager.py delete mode 100644 src/module/module.py delete mode 100644 src/module/shell.py diff --git a/src/module/event_router.py b/src/module/event_router.py deleted file mode 100644 index 98395ee..0000000 --- a/src/module/event_router.py +++ /dev/null @@ -1,28 +0,0 @@ -from typing import Optional - -import zmq - -from src.tools.logger import logging, setup_logger - -XPUB_ENDPOINT = "tcp://localhost:5555" -XSUB_ENDPOINT = "tcp://localhost:5556" - - -class EventRouter: - def __init__(self, logger: Optional[logging.Logger] = setup_logger("EventRouter")): - - self.ctx = zmq.Context() - self.xpub = self.ctx.socket(zmq.XPUB) - self.xsub = self.ctx.socket(zmq.XSUB) - self.xpub.bind(XPUB_ENDPOINT) - self.xsub.bind(XSUB_ENDPOINT) - - self.logger = logger - - def start(self): - try: - zmq.proxy(self.xsub, self.xpub) - except KeyboardInterrupt: - self.logger.info("Ctrl+C pressed, exiting cleanly") - except Exception as e: - self.logger.error(e) diff --git a/src/module/manager.py b/src/module/manager.py deleted file mode 100644 index 159c273..0000000 --- a/src/module/manager.py +++ /dev/null @@ -1,152 +0,0 @@ -import multiprocessing as mp -import signal -import time -from multiprocessing.synchronize import Event -from typing import Dict - -from src.tools.logger import (LevelFilter, QueueListener, logging, - setup_log_listener, setup_logger) - -from .event_router import EventRouter -from .module import Module - - -class ModuleManager: - """Control Modules, Inter-Module communication and Logging""" - - def __init__(self, modules: Dict[str, Module]): - self.modules: Dict[str, Module] = modules - - self.router_process: mp.Process = None - self.processes: Dict[str, mp.Process] = {} - self.stop_events: Dict[str, Event] = {} - - self.log_queue = mp.Queue() - self.logger = setup_logger("HuRI", log_queue=self.log_queue) - self.level_filter = LevelFilter(logging.DEBUG) - self.log_listener: QueueListener = setup_log_listener( - self.log_queue, self.level_filter - ) - - def _start_event_router(self) -> None: - """Used to handle inter-module communication, though events""" - if self.router_process and self.router_process.is_alive(): - return - - logger = setup_logger("EventRouter", log_queue=self.log_queue) - self.router_process = mp.Process( - target=lambda: EventRouter(logger).start(), daemon=True - ) - self.level_filter.add_level("EventRouter") - self.router_process.start() - time.sleep(0.01) - self.logger.info(f"Router started (PID={self.router_process.pid})") - - @staticmethod - def _start_module( - module: Module, name: str, log_queue: mp.Queue, stop_event: Event - ) -> None: - """Helper function to start module in child process.""" - logger = setup_logger(name, log_queue=log_queue) - module.set_custom_logger(logger) - - def handle_sigint(signum, frame): - logger.info(f"Ctrl+C ignored in child module") - - signal.signal(signal.SIGINT, handle_sigint) - - module.start_module(stop_event=stop_event) - - def start_module(self, name): - """Check if module is registered and not already running, and start a child process.""" - if name not in self.modules: - self.logger.warning( - f"{name} is not in the registered Modules: {self.modules.keys()}" - ) - return - if name in self.processes: - self.logger.warning( - f"{name} is already running (PID={self.processes[name].pid})" - ) - return - - module = self.modules[name] - stop_event = mp.Event() - p = mp.Process( - target=self._start_module, - args=(module, name, self.log_queue, stop_event), - daemon=True, - ) - self.processes[name] = p - self.stop_events[name] = stop_event - self.level_filter.add_level(name) - - p.start() - self.logger.info(f"{name} ({type(module)}) started (PID={p.pid})") - - def stop_module(self, name): - if name in self.processes: - self.logger.info(f"Stopping {name}...") - self.stop_events[name].set() - self.processes[name].join(timeout=5) - if self.processes[name].is_alive(): - self.logger.warning(f"{name} did not stop in time, killing") - self.processes[name].kill() - self.logger.info(f"{name} stopped") - del self.processes[name] - del self.stop_events[name] - self.level_filter.del_level(name) - - def start(self): - """Start event router and modules""" # TODO config (also logs levels) - self.log_listener.start() - self._start_event_router() - for name in self.modules: - self.start_module(name) - - def stop_all(self): - for name in list(self.processes.keys()): - self.stop_module(name) - if self.router_process and self.router_process.is_alive(): - self.logger.info("Stopping router...") - self.router_process.terminate() - self.router_process.join(timeout=5) - self.level_filter.del_level("EventRouter") - self.logger.info("Router stopped") - - self.log_listener.stop() - - def status(self): - """Print status of all modules and router.""" - print("=== Module Status ===") - if self.router_process: - router_state = "alive" if self.router_process.is_alive() else "stopped" - print(f"- Router: {router_state} (PID={self.router_process.pid})") - else: - print("- Router: not started") - - for name in self.modules: - process = self.processes.get(name) - if process: - state = "alive" if process.is_alive() else "stopped" - print(f"- {name}: {state} (PID={process.pid})") - else: - print(f"- {name}: stopped") - print("=====================") - - def set_root_log_level(self, level: int) -> None: - self.level_filter.set_root_level(level) - - def set_log_level(self, name: str, level: int) -> None: - self.level_filter.set_level(name, level) - - def set_log_levels(self, name: str, level: int) -> None: - self.level_filter.set_levels(level) - - def log_status(self) -> None: - """Print status of all modules and router.""" - print("=== Log Status ===") - print(f"Root level: {logging.getLevelName(self.level_filter.root_level)}") - for name, lvl in self.level_filter.log_levels.items(): - print(f"- {name}: {logging.getLevelName(lvl)}") - print("=====================") diff --git a/src/module/module.py b/src/module/module.py deleted file mode 100644 index e9eec50..0000000 --- a/src/module/module.py +++ /dev/null @@ -1,149 +0,0 @@ -import json -import threading -from abc import ABC, abstractmethod -from multiprocessing.synchronize import Event -from typing import Callable, Dict, final - -import zmq - -from src.tools.logger import logging - -from .event_router import XPUB_ENDPOINT, XSUB_ENDPOINT - - -class Module(ABC): - def __init__(self): - """Child Modules must call super.__init__() in their __init__() function.""" - self.ctx = None - self.pub_socket = None - self.subs: Dict[str, zmq.Socket[bytes]] = {} - self.callbacks = {} - self._poller_running = False - self.poller = None - self.logger = logging.getLogger(__name__) - - @final - def _initialize(self) -> None: - """ - Called inside start_module() or manually before usage. - This function exist because ctx cannot be set in __init__, because of multi-processing. - """ - self.ctx = zmq.Context() - self.pub_socket = self.ctx.socket(zmq.PUB) - self.pub_socket.connect(XSUB_ENDPOINT) - self.poller = threading.Thread(target=self._poll_loop, daemon=True) - self.set_subscriptions() - - @abstractmethod - def set_subscriptions(self) -> None: - """Child module must define this funcction with subscriptions""" - ... - - @final - def subscribe(self, topic: str, callback: Callable) -> None: - sub_socket = self.ctx.socket(zmq.SUB) - sub_socket.connect(XPUB_ENDPOINT) - sub_socket.setsockopt_string(zmq.SUBSCRIBE, topic) - self.subs[topic] = sub_socket - self.callbacks[topic] = callback - self.logger.info(f"Subscribe: {topic}") - - @final - def publish( - self, topic: str, msg: object, content_type: str = "str" - ) -> None: # TODO content type enum - if content_type == "json": - payload = json.dumps(msg).encode() - elif content_type == "bytes": - payload = msg - elif content_type == "str": - payload = msg.encode() - else: - raise ValueError(f"Unsupported content_type: {content_type}") - - self.pub_socket.send_multipart([topic.encode(), content_type.encode(), payload]) - self.logger.info(f"Publish: {topic} {content_type}") - - @final - def _start_polling(self) -> None: - self._poller_running = True - self.poller.start() - - @final - def _poll_loop(self) -> None: - poller = zmq.Poller() - for sub in self.subs.values(): - poller.register(sub, zmq.POLLIN) - - while self._poller_running: - events = dict(poller.poll(100)) - for _, sub in self.subs.items(): - if sub in events: - topic, content_type, payload = sub.recv_multipart() - topic_str = topic.decode() - content_type_str = content_type.decode() - self.logger.info(f"Receive: {topic_str} {content_type_str}") - if content_type_str == "json": - kwargs = json.loads(payload.decode()) - self.callbacks[topic_str]( - **kwargs - ) # TODO better and cleaner way ? - elif content_type_str == "bytes": - data = payload - self.callbacks[topic_str](data) - elif content_type_str == "str": - data = payload.decode() - self.callbacks[topic_str](data) - - @final - def start_module(self, stop_event: Event = None) -> None: - self._initialize() - if self.subs != {}: - self._start_polling() - try: - self.run_module(stop_event) - except KeyboardInterrupt: - self.logger.info("Ctrl+C pressed, exiting cleanly") - except Exception as e: - self.logger.error(e) - finally: - self.stop_module() - - @final - def stop_module(self) -> None: - """Stop the module gracefully.""" - - if self._poller_running: - self._poller_running = False - self.poller.join() - - for topic, sub in self.subs.items(): - try: - sub.close(0) - except Exception as e: - self.logger.error(f"Error closing SUB socket for '{topic}': {e}") - - self.subs.clear() - self.callbacks.clear() - - try: - self.pub_socket.close(0) - except Exception as e: - self.logger.error(f"Error closing SUB socket for '{topic}': {e}") - - try: - self.ctx.term() - except Exception as e: - self.logger.error(f"Error terminating ZMQ context: {e}") - - self.logger.info(f"Module stopped gracefully.") - - def run_module(self, stop_event: Event = None) -> None: - """Child modules override this instead of run(). Default: idle wait.""" - if stop_event: - stop_event.wait() - - @final - def set_custom_logger(self, logger) -> None: - """The default logger in set in __init__.""" - self.logger = logger diff --git a/src/module/shell.py b/src/module/shell.py deleted file mode 100644 index 28938b5..0000000 --- a/src/module/shell.py +++ /dev/null @@ -1,84 +0,0 @@ -import cmd -import logging - -from .manager import ModuleManager - - -class RobotShell(cmd.Cmd): - intro = "HuRI's shell. Type 'help' to see command's list." - prompt = "(HuRI) " - - def __init__(self, manager: ModuleManager): - super().__init__() - self.manager = manager - - def do_status(self, arg): - "Display modules and router status." - self.manager.status() - - def do_start(self, arg): - "Start a module." - self.manager.start_module(arg.strip()) - - def do_stop(self, arg): - "Stop a module." - self.manager.stop_module(arg.strip()) - - def do_exit(self, arg): - "Exit HuRi." - self.manager.stop_all() - print("Bye !") - return True - - def do_log(self, arg): - """ - Usage: - log status -> display log levels - log -> set global root level (DEBUG/INFO/WARNING/ERROR/CRITICAL), has priority over custom module level - log -> set custom per-module level (module name e.g. STT) - log all -> set all levels (DEBUG/INFO/WARNING/ERROR/CRITICAL) - """ - parts = arg.strip().split() - if not parts: - print("Missing arguments. Type 'help log' for usage.") - return - - level_map = { - "DEBUG": logging.DEBUG, - "INFO": logging.INFO, - "WARNING": logging.WARNING, - "ERROR": logging.ERROR, - "CRITICAL": logging.CRITICAL, - } - - try: - if parts[0].lower() == "status": - self.manager.log_status() - return - - if len(parts) == 1: - # Set root level - level = level_map.get(parts[0].upper()) - if level is None: - print(f"Unknown level: {parts[0]}") - return - self.manager.set_root_log_level(level) - print(f"Root log level set to {parts[0].upper()}") - - elif len(parts) == 2: - level = level_map.get(parts[1].upper()) - if level is None: - print(f"Unknown level: {parts[1]}") - return - if parts[0].lower() == "all": - self.manager.set_log_levels(level) - print(f"All log levels set to {parts[1].upper()}") - else: - module = parts[0] - self.manager.set_log_level(module, level) - print(f"{module} log level set to {parts[1].upper()}") - - else: - print("Invalid arguments. Type 'help log' for usage.") - except Exception as e: - print(f"Error setting log level: {e}") From deb40f93da99d76d39698896f4529fc6e9111956 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 14 Dec 2025 14:30:24 +0100 Subject: [PATCH 23/42] refactor(archi): addedzmq EventProxy --- src/core/zmq/event_proxy.py | 47 +++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/core/zmq/event_proxy.py diff --git a/src/core/zmq/event_proxy.py b/src/core/zmq/event_proxy.py new file mode 100644 index 0000000..49a9aea --- /dev/null +++ b/src/core/zmq/event_proxy.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass +from typing import Optional + +import zmq + +from src.tools.logger import logging, setup_logger + + +@dataclass +class ZMQEventPorts: + xpub: str + xsub: str + + +class EventProxy: + def __init__( + self, + ports: ZMQEventPorts, + logger: Optional[logging.Logger] = setup_logger("EventProxy"), + ): + + self.ctx = zmq.Context.instance() + self.xpub = self.ctx.socket(zmq.XPUB) + self.xsub = self.ctx.socket(zmq.XSUB) + self.ports = ports + + self.logger = logger or logging.getLogger(__name__) + + def start(self, xpub_connect: bool, xsub_connect: bool): + if xpub_connect: + self.xpub.connect(f"tcp://localhost:{self.ports.xpub}") + else: + self.xpub.bind(f"tcp://localhost:{self.ports.xpub}") + if xsub_connect: + self.xsub.connect(f"tcp://localhost:{self.ports.xsub}") + else: + self.xsub.bind(f"tcp://localhost:{self.ports.xsub}") + + try: + self.logger.info("Correctly initialized, starting proxy") + zmq.proxy(self.xsub, self.xpub) + except Exception as e: + self.logger.error(e) + + def stop(self) -> None: + self.xsub.close(linger=0) + self.xpub.close(linger=0) From 270b4cf39a0b6aa03203d8ef0a79e38804430150 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 14 Dec 2025 14:31:00 +0100 Subject: [PATCH 24/42] refactor(archi): added Router and Dealer (wip) --- src/core/zmq/control_channel.py | 154 ++++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) create mode 100644 src/core/zmq/control_channel.py diff --git a/src/core/zmq/control_channel.py b/src/core/zmq/control_channel.py new file mode 100644 index 0000000..57c0b86 --- /dev/null +++ b/src/core/zmq/control_channel.py @@ -0,0 +1,154 @@ +import json +import uuid +from dataclasses import asdict, dataclass +from typing import Any, Callable, Dict, List, Optional + +import zmq + +from src.tools.logger import logging, setup_logger + + +@dataclass +class ZMQRouterPort: + router: str + + +@dataclass +class Command: + cmd: str # "STOP", "START", "STATUS", ... + args: List[Any] # JSON-serializable arguments + + def to_bytes(self) -> bytes: + return json.dumps(asdict(self)).encode("utf-8") + + @staticmethod + def from_bytes(data: bytes) -> "Command": + obj = json.loads(data.decode("utf-8")) + return Command(**obj) + + +@dataclass +class Result: + success: bool + result: List[Any] + + def to_bytes(self) -> bytes: + return json.dumps(asdict(self)).encode("utf-8") + + @staticmethod + def from_bytes(data: bytes) -> "Command": + obj = json.loads(data.decode("utf-8")) + return Result(**obj) + + +class Router: + def __init__( + self, + port: ZMQRouterPort, + logger: Optional[logging.Logger] = setup_logger("Router"), + ): + + self.ctx = zmq.Context.instance() + self.router = self.ctx.socket(zmq.ROUTER) + self.port = port + + self.logger = logger or logging.getLogger(__name__) + + self.dealers: Dict[bytes, bool] = {} + + def start(self): + self.router.bind(f"tcp://localhost:{self.port.router}") + self.logger.info("Router started") + + try: + while True: + identity, *frames = self.router.recv_multipart() + + if not frames: + continue + + command = frames[0] + + if command == b"REGISTER": + self.dealers[identity] = True + self.logger.info(f"Dealer registered: {identity}") + + elif command == b"RESULT": + payload = frames[1] if len(frames) > 1 else b"" + self.logger.info(f"Result from {identity}: {payload.decode()}") + except Exception as e: + self.logger.exception(e) + pass + finally: + self.router.close() + + def stop(self) -> None: + self.router.close() + + def send_command(self, dealer_id: bytes, command: Command) -> None: + if dealer_id not in self.dealers: + raise ValueError("Dealer not registered") + + self.router.send_multipart([dealer_id, b"COMMAND", command.to_bytes()]) + + def send_commands(self, command: Command) -> None: + for dealer_id, _ in self.dealers.items(): + self.send_command(dealer_id, command) + + +class Dealer: + def __init__( + self, + port: ZMQRouterPort, + executor: Callable[[Command], bool], + logger: Optional[logging.Logger] = None, + identity: Optional[str] = None, + ): + self.ctx = zmq.Context.instance() + self.dealer = self.ctx.socket(zmq.DEALER) + self.port = port + + self.executor = executor + # Set explicit identity + self.identity = (identity or str(uuid.uuid4())).encode() # TODO agent name + + self.logger = logger or logging.getLogger(f"Dealer {self.identity}") + + def start(self): + self.dealer.connect(f"tcp://localhost:{self.port.router}") + self.dealer.setsockopt(zmq.IDENTITY, self.identity) + self.logger.info(f"Dealer started: {self.identity}") + + try: + self.dealer.send(b"REGISTER") + + while True: + frames = self.dealer.recv_multipart() + + command = frames[0] + + if command == b"COMMAND": + self.logger.info("received command") + payload = frames[1] if len(frames) > 1 else b"" + result = self.execute(payload) + + self.dealer.send_multipart([b"RESULT", result]) + except Exception as e: + self.logger.exception(e) + finally: + self.dealer.close() + + print("DEALER CLOSED") + + def execute(self, command: Command) -> bytes: + """ + Execute command sent by Router + """ + self.executor(command) + + # Example execution + result = f"Executed: {command.cmd}" + return result.encode() + + def stop(self) -> None: + self.dealer.close(linger=0) From 5f43142aa04a1d32f921ea8135bf8c75a9e4d260 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 14 Dec 2025 14:31:41 +0100 Subject: [PATCH 25/42] refactor(archi): added LogPusher and LogPuller --- src/core/zmq/__init__.py | 0 src/core/zmq/log_channel.py | 181 ++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 src/core/zmq/__init__.py create mode 100644 src/core/zmq/log_channel.py diff --git a/src/core/zmq/__init__.py b/src/core/zmq/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/zmq/log_channel.py b/src/core/zmq/log_channel.py new file mode 100644 index 0000000..8c1b014 --- /dev/null +++ b/src/core/zmq/log_channel.py @@ -0,0 +1,181 @@ +import json +import signal +import time +from dataclasses import asdict, dataclass +from typing import Any, Dict, Optional + +import zmq + +from src.tools.logger import ( + LevelFilter, + QueueListener, + logging, + mp, + setup_log_listener, + setup_logger, +) + + +@dataclass +class ZMQLogPort: + port: str + + +def record_to_dict(record: logging.LogRecord) -> Dict[str, Any]: + return { + "name": record.name, + "levelno": record.levelno, + "levelname": record.levelname, + "message": record.getMessage(), + "created": record.created, + "asctime": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(record.created)), + "process": record.process, + "processName": record.processName, + "thread": record.thread, + "threadName": record.threadName, + "module": record.module, + "filename": record.filename, + "pathname": record.pathname, + "lineno": record.lineno, + "funcName": record.funcName, + } + + +def dict_to_record(data: Dict[str, Any]) -> logging.LogRecord: + record = logging.LogRecord( + name=data["name"], + level=data["levelno"], + pathname=data["pathname"], + lineno=data["lineno"], + msg=data["message"], + args=(), + exc_info=None, + func=data["funcName"], + ) + + # Restore metadata + record.created = data["created"] + record.process = data["process"] + record.processName = data["processName"] + record.thread = data["thread"] + record.threadName = data["threadName"] + record.module = data["module"] + record.filename = data["filename"] + + return record + + +# @dataclass +# class Log: +# asctime + + +# class LogPusher: +# def __init__( +# self, +# ports: ZMQLogPort, +# logger: Optional[logging.Logger] = setup_logger("LogPusher"), +# ): + +# self.ctx = zmq.Context.instance() +# self.push = self.ctx.socket(zmq.PUSH) +# self.push.connect(f"tcp://localhost:{ports.port}") + +# self.log_queue = mp.Queue() +# self.logger = logger +# self.level_filter = LevelFilter(logging.DEBUG) +# self.log_listener: QueueListener = setup_log_listener( +# self.log_queue, self.level_filter +# ) + +# def get_log_queue(self) -> mp.Queue: +# return self.log_queue + +# def set_log_level(self, level: int) -> None: +# pass + +# def start(self): +# try: +# zmq.proxy(self.pull, self.push) +# except KeyboardInterrupt: +# self.logger.info("Ctrl+C pressed, exiting cleanly") +# except Exception as e: +# self.logger.error(e) + + +class LogPuller: + def __init__( + self, + port: ZMQLogPort, + logger: Optional[logging.Logger] = setup_logger("LogPuller"), + ) -> None: + + self.ctx = zmq.Context.instance() + self.pull = self.ctx.socket(zmq.PULL) + self.port = port + + self.logger = logger or logging.getLogger(__name__) + + def start(self) -> None: + self.pull.bind(f"tcp://localhost:{self.port.port}") + + self.logger.info("started") + while True: + payload = self.pull.recv() + + self.logger.handle(dict_to_record(json.loads(payload.decode()))) + + def stop(self) -> None: + self.pull.close() + + +class LogPusher: + class LogPusherHandler(logging.Handler): + def __init__( + self, + endpoint: str, + ): + super().__init__() + self.ctx = zmq.Context.instance() + self.socket = self.ctx.socket(zmq.PUSH) + self.endpoint = endpoint + + def emit(self, record: logging.LogRecord) -> None: + try: + payload = json.dumps(record_to_dict(record)).encode() + self.socket.send(payload) + except Exception: + self.handleError(record) + except Exception: + self.handleError(record) + + def start(self) -> None: + self.socket.connect(self.endpoint) + + def stop(self) -> None: + self.socket.close() + + def __init__( + self, + endpoint: str, + ): + + self.log_queue = mp.Queue() + + self.log_handler = self.LogPusherHandler(endpoint) + self.level_filter = LevelFilter(logging.DEBUG) + self.log_listener: QueueListener = setup_log_listener( + self.log_queue, self.level_filter, self.log_handler + ) + + self.logger = setup_logger("LogPusher", log_queue=self.log_queue) + + def start(self) -> None: + self.log_handler.start() + self.log_listener.start() + + def stop(self): + self.logger.info("stopping") + time.sleep(0.2) + self.log_listener.stop() + self.log_handler.stop() From 72112fc8ba317c6f8aa074f5b16bd4525288f5b5 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 14 Dec 2025 14:33:32 +0100 Subject: [PATCH 26/42] refactor(archi): Agent is the new ModuleManager and can connect to HuRI --- src/core/agent.py | 225 +++++++++++++++++++++++++++++++++++++++++++++ src/core/module.py | 150 ++++++++++++++++++++++++++++++ 2 files changed, 375 insertions(+) create mode 100644 src/core/agent.py create mode 100644 src/core/module.py diff --git a/src/core/agent.py b/src/core/agent.py new file mode 100644 index 0000000..1fa5bbc --- /dev/null +++ b/src/core/agent.py @@ -0,0 +1,225 @@ +import multiprocessing as mp +import signal +import sys +import threading +import time +from multiprocessing.synchronize import Event +from typing import Dict, List + +import zmq + +from src.tools.logger import ( + LevelFilter, + QueueListener, + logging, + setup_log_listener, + setup_logger, +) + +from .module import Module +from .zmq.event_proxy import EventProxy, ZMQEventPorts +from .zmq.log_channel import LogPusher, ZMQLogPort +from .zmq.control_channel import Command, Dealer, ZMQRouterPort + +# class AgentConfig: +# huri_hostname: str +# huri_event_router_ports: ZMQEventPorts +# event_forwarder_ports: ZMQEventPorts +# log_level: int = logging.INFO + + +class Agent: + """Control Modules and communication with HuRI""" + + def __init__(self, modules: Dict[str, Module]) -> None: + self.modules: Dict[str, Module] = modules + + self.processes: Dict[str, mp.Process] = {} + self.stop_events: Dict[str, Event] = {} + + self.threads: Dict[str, threading.Thread] = {} + + self.log_pusher = LogPusher("tcp://localhost:8008") + + self.dealer = Dealer( + port=ZMQRouterPort(router="3000"), + executor=self._command_handler, + logger=setup_logger("Dealer", log_queue=self.log_pusher.log_queue), + ) + + self.up_proxy = EventProxy( + ports=ZMQEventPorts(xpub="5555", xsub="6666"), + logger=setup_logger("UpProxy", log_queue=self.log_pusher.log_queue), + ) + self.down_proxy = EventProxy( + ports=ZMQEventPorts(xpub="6665", xsub="5556"), + logger=setup_logger("DownProxy", log_queue=self.log_pusher.log_queue), + ) + + self.logger = setup_logger( + f"Agent {self.dealer.identity}", log_queue=self.log_pusher.log_queue + ) + + def _command_handler(self, command: Command) -> bool: + match command.cmd: + case "START": + return self.start_module(*command.args) + case "STOP": + return self.stop_module(*command.args) + case "STATUS": + return self.status() + case _: + return False # todo log + + @staticmethod + def _start_module( + module: Module, name: str, log_queue: mp.Queue, stop_event: Event + ) -> None: + """Helper function to start module in child process.""" + logger = setup_logger(name, log_queue=log_queue) + module.set_custom_logger(logger) + + def handle_sigint(signum, frame): + logger.info(f"Ctrl+C ignored in child module") + + signal.signal(signal.SIGINT, handle_sigint) + + module.start_module(stop_event=stop_event) + + def start_module(self, name) -> None: + """Check if module is registered and not already running, and start a child process.""" + if name not in self.modules: + self.logger.warning( + f"{name} is not in the registered Modules: {self.modules.keys()}" + ) + return + if name in self.processes: + self.logger.warning( + f"{name} is already running (PID={self.processes[name].pid})" + ) + return + + module = self.modules[name] + stop_event = mp.Event() + p = mp.Process( + target=self._start_module, + args=(module, name, self.log_pusher.log_queue, stop_event), + daemon=True, + ) + self.processes[name] = p + self.stop_events[name] = stop_event + self.log_pusher.level_filter.add_level(name) + + p.start() + self.logger.info(f"{name} ({type(module)}) started (PID={p.pid})") + + def stop_module(self, name) -> None: + if name in self.processes: + self.logger.info(f"Stopping {name}...") + self.stop_events[name].set() + self.processes[name].join(timeout=5) + if self.processes[name].is_alive(): + self.logger.warning(f"{name} did not stop in time, killing") + self.processes[name].kill() + self.logger.info(f"{name} stopped") + del self.processes[name] + del self.stop_events[name] + self.log_pusher.level_filter.del_level(name) + + def stop_all(self) -> None: + for name in list(self.processes.keys()): + self.stop_module(name) + + self.dealer.stop() + self.up_proxy.stop() + self.down_proxy.stop() + for name, thread in self.threads.items(): + self.logger.info(f"Stopping {name} thread...") + thread.join(timeout=5) + self.logger.info(f"{name} thread stopped") + self.log_pusher.level_filter.del_level(name) + + self.log_pusher.stop() + print("Fully stopped") + + def status(self) -> None: + """Print status of all modules and router.""" + print("=== Module Status ===") + # if self.router_process: + # router_state = "alive" if self.router_process.is_alive() else "stopped" + # print(f"- Router: {router_state} (PID={self.router_process.pid})") + # else: + # print("- Router: not started") + + for name in self.modules: + process = self.processes.get(name) + if process: + state = "alive" if process.is_alive() else "stopped" + print(f"- {name}: {state} (PID={process.pid})") + else: + print(f"- {name}: stopped") + print("=====================") + + def set_root_log_level(self, level: int) -> None: + self.log_pusher.level_filter.set_root_level(level) + + def set_log_level(self, name: str, level: int) -> None: + self.log_pusher.level_filter.set_level(name, level) + + def set_log_levels(self, level: int) -> None: + self.log_pusher.level_filter.set_levels(level) + + # def log_status(self) -> None: + # """Print status of all modules and router.""" + # print("=== Log Status ===") + # print(f"Root level: {logging.getLevelName(self.level_filter.root_level)}") + # for name, lvl in self.level_filter.log_levels.items(): + # print(f"- {name}: {logging.getLevelName(lvl)}") + # print("=====================") + + def _connect_to_huri(self) -> None: + self.log_pusher.level_filter.add_level("Dealer") + self.threads["Dealer"] = threading.Thread(target=self.dealer.start) + self.threads["Dealer"].start() + + def _start_event_proxies(self) -> None: + """Used to handle inter-module communication, though events""" + self.log_pusher.level_filter.add_level("UpProxy") + self.log_pusher.level_filter.add_level("DownProxy") + self.threads["UpProxy"] = threading.Thread( + target=self.up_proxy.start, args=[True, False] + ) + self.threads["DownProxy"] = threading.Thread( + target=self.down_proxy.start, args=[False, True] + ) + + self.threads["UpProxy"].start() + self.threads["DownProxy"].start() + + def run(self) -> None: + """Start event router and modules""" # TODO config (also logs levels) + + # def handle_sigint(signum, frame): + # self.logger.info(f"Ctrl+C detected, stopping...") + # self.stop_all() + + try: + self.log_pusher.start() + self._connect_to_huri() + self._start_event_proxies() + except Exception as e: + self.logger.error(e) + return + + for name in self.modules: + self.start_module(name) + + threading.Event().wait() + + # def handle_sigint(sig, frame): + # shutdown_event.set() + + # shutdown_event = threading.Event() + # signal.signal(signal.SIGINT, handle_sigint) + # shutdown_event.wait() + # signal.signal(signal.SIGINT, handle_sigint) diff --git a/src/core/module.py b/src/core/module.py new file mode 100644 index 0000000..8dde03c --- /dev/null +++ b/src/core/module.py @@ -0,0 +1,150 @@ +import json +import threading +from abc import ABC, abstractmethod +from multiprocessing.synchronize import Event +from typing import Callable, Dict, final + +import zmq + +from src.tools.logger import logging + +# from .zmq.event_proxy import "6665", XSUB_ENDPOINT + + +class Module(ABC): + def __init__(self): + """Child Modules must call super.__init__() in their __init__() function.""" + self.ctx = None + self.pub_socket = None + self.subs: Dict[str, zmq.Socket[bytes]] = {} + self.callbacks = {} + self._poller_running = False + self.poller = None + self.logger = logging.getLogger(__name__) + + @final + def _initialize(self) -> None: + """ + Called inside start_module() or manually before usage. + This function exist because ctx cannot be set in __init__, because of multi-processing. + """ + self.ctx = zmq.Context() + self.pub_socket = self.ctx.socket(zmq.PUB) + self.pub_socket.connect("tcp://localhost:6666") + self.poller = threading.Thread(target=self._poll_loop, daemon=True) + self.set_subscriptions() + + @abstractmethod + def set_subscriptions(self) -> None: + """Child module must define this funcction with subscriptions""" + ... + + @final + def subscribe(self, topic: str, callback: Callable) -> None: + sub_socket = self.ctx.socket(zmq.SUB) + sub_socket.connect("tcp://localhost:6665") + sub_socket.setsockopt_string(zmq.SUBSCRIBE, topic) + self.subs[topic] = sub_socket + self.callbacks[topic] = callback + self.logger.info(f"Subscribe: {topic}") + + @final + def publish( + self, topic: str, msg: object, content_type: str = "str" + ) -> None: # TODO content type enum + if content_type == "json": + payload = json.dumps(msg).encode() + elif content_type == "bytes": + payload = msg + elif content_type == "str": + payload = msg.encode() + else: + raise ValueError(f"Unsupported content_type: {content_type}") + + self.pub_socket.send_multipart([topic.encode(), content_type.encode(), payload]) + self.logger.info(f"Publish: {topic} {content_type}") + + @final + def _start_polling(self) -> None: + self._poller_running = True + self.poller.start() + + @final + def _poll_loop(self) -> None: + poller = zmq.Poller() + for sub in self.subs.values(): + poller.register(sub, zmq.POLLIN) + + while self._poller_running: + events = dict(poller.poll(100)) + for _, sub in self.subs.items(): + if sub in events: + topic, content_type, payload = sub.recv_multipart() + topic_str = topic.decode() + content_type_str = content_type.decode() + self.logger.info(f"Receive: {topic_str} {content_type_str}") + if content_type_str == "json": + kwargs = json.loads(payload.decode()) + self.callbacks[topic_str]( + **kwargs + ) # TODO better and cleaner way ? + elif content_type_str == "bytes": + data = payload + self.callbacks[topic_str](data) + elif content_type_str == "str": + data = payload.decode() + self.logger.error(data) + self.callbacks[topic_str](data) + + @final + def start_module(self, stop_event: Event = None) -> None: + self._initialize() + if self.subs != {}: + self._start_polling() + try: + self.run_module(stop_event) + except KeyboardInterrupt: + self.logger.info("Ctrl+C pressed, exiting cleanly") + except Exception as e: + self.logger.error(e) + finally: + self.stop_module() + + @final + def stop_module(self) -> None: + """Stop the module gracefully.""" + + if self._poller_running: + self._poller_running = False + self.poller.join() + + for topic, sub in self.subs.items(): + try: + sub.close(0) + except Exception as e: + self.logger.error(f"Error closing SUB socket for '{topic}': {e}") + + self.subs.clear() + self.callbacks.clear() + + try: + self.pub_socket.close(0) + except Exception as e: + self.logger.error(f"Error closing SUB socket for '{topic}': {e}") + + try: + self.ctx.term() + except Exception as e: + self.logger.error(f"Error terminating ZMQ context: {e}") + + self.logger.info(f"Module stopped gracefully.") + + def run_module(self, stop_event: Event = None) -> None: + """Child modules override this instead of run(). Default: idle wait.""" + if stop_event: + stop_event.wait() + + @final + def set_custom_logger(self, logger) -> None: + """The default logger in set in __init__.""" + self.logger = logger From fb22b87b2aaf9e21c354e5a035e6d00593f8fadf Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 14 Dec 2025 14:34:35 +0100 Subject: [PATCH 27/42] wip(archi): HuRI class handle log, modules event and control agents --- src/core/__init__.py | 0 src/core/huri.py | 73 ++++++++++++++++++++++++++++++++++++++++++++ src/tools/logger.py | 8 +++-- 3 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 src/core/__init__.py create mode 100644 src/core/huri.py diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/huri.py b/src/core/huri.py new file mode 100644 index 0000000..374cdaf --- /dev/null +++ b/src/core/huri.py @@ -0,0 +1,73 @@ +import multiprocessing as mp +import threading +import time +from typing import Dict + +from src.tools.logger import ( + LevelFilter, + QueueListener, + logging, + setup_log_listener, + setup_logger, +) + +from .zmq.event_proxy import EventProxy, ZMQEventPorts +from .zmq.log_channel import LogPuller, ZMQLogPort +from .zmq.control_channel import Router, ZMQRouterPort + + +class HuRIConfig: + register_channel_ports: ZMQEventPorts + event_channel_ports: ZMQEventPorts + log_channel_port: ZMQEventPorts + log_level: int = logging.INFO + + +class HuRI: + """Wait for Agent to connect, handle module communication and Logging""" + + def __init__(self) -> None: + self.router = Router(ZMQRouterPort(router="3000")) + self.event_proxy = EventProxy(ZMQEventPorts(xpub="5556", xsub="5555")) + self.log_channel = LogPuller(ZMQLogPort("8008")) + + self.threads: Dict[str, threading.Thread] = {} + + self.logger = setup_logger("HuRI") + + def _start_router(self) -> None: + """Used to handle Agent registration and control""" + self.threads["Router"] = threading.Thread(target=self.router.start) + self.threads["Router"].start() + + def _start_event_proxy(self) -> None: + """Used to handle inter-module communication, though events""" + self.threads["EventProxy"] = threading.Thread( + target=self.event_proxy.start, args=[False, False] + ) + self.threads["EventProxy"].start() + + def _start_log_channel(self) -> None: + """Used to handle Agent registration and control""" + self.threads["LogChannel"] = threading.Thread(target=self.log_channel.start) + self.threads["LogChannel"].start() + + def run(self) -> None: + # self.log_listener.start() + self._start_log_channel() + self._start_router() + self._start_event_proxy() + # self._start_log_channel() + + from src.core.shell import RobotShell + + RobotShell(self).cmdloop() + + def stop(self) -> None: + self.router.stop() + self.event_proxy.stop() + self.log_channel.stop() + for name, thread in self.threads.items(): + self.logger.info(f"Stopping {name} thread...") + thread.join(timeout=5) + self.logger.info(f"{name} thread stopped") diff --git a/src/tools/logger.py b/src/tools/logger.py index 6c4a00a..1b7d5c5 100644 --- a/src/tools/logger.py +++ b/src/tools/logger.py @@ -84,7 +84,11 @@ def del_level(self, name: str) -> None: del self.log_levels[name] -def setup_log_listener(log_queue: mp.Queue, filter: logging.Filter) -> QueueListener: +def setup_log_listener( + log_queue: mp.Queue, + filter: logging.Filter, + custom_handler: Optional[logging.Handler] = None, +) -> QueueListener: """ Starts a central logging listener that reads LogRecords from a queue and emits them using normal loggers/handlers. @@ -93,7 +97,7 @@ def setup_log_listener(log_queue: mp.Queue, filter: logging.Filter) -> QueueList "[%(asctime)s] [%(processName)s] [%(name)s] [%(levelname)s] %(message)s", datefmt="%H:%M:%S", ) - handler = setup_handler(formatter=formatter) + handler = custom_handler or setup_handler(formatter=formatter) handler.addFilter(filter) listener = QueueListener(log_queue, handler) From 36144fd10ed31e6f3602ce79abef707b92df6749 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 14 Dec 2025 14:35:41 +0100 Subject: [PATCH 28/42] evol(modules): change folder name --- src/modules/rag/__init__.py | 0 src/{module => modules}/rag/mode_controller.py | 2 +- src/{module => modules}/rag/rag.py | 2 +- src/modules/speech_to_text/__init__.py | 0 src/{module => modules}/speech_to_text/record_speech.py | 2 +- src/{module => modules}/speech_to_text/speech_to_text.py | 2 +- 6 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 src/modules/rag/__init__.py rename src/{module => modules}/rag/mode_controller.py (97%) rename src/{module => modules}/rag/rag.py (99%) create mode 100644 src/modules/speech_to_text/__init__.py rename src/{module => modules}/speech_to_text/record_speech.py (98%) rename src/{module => modules}/speech_to_text/speech_to_text.py (97%) diff --git a/src/modules/rag/__init__.py b/src/modules/rag/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/module/rag/mode_controller.py b/src/modules/rag/mode_controller.py similarity index 97% rename from src/module/rag/mode_controller.py rename to src/modules/rag/mode_controller.py index 0f9780a..97054a9 100644 --- a/src/module/rag/mode_controller.py +++ b/src/modules/rag/mode_controller.py @@ -12,7 +12,7 @@ from langchain_ollama.llms import OllamaLLM from langgraph.checkpoint.memory import MemorySaver -from src.module.module import Module +from src.core.module import Module class Modes(Enum): diff --git a/src/module/rag/rag.py b/src/modules/rag/rag.py similarity index 99% rename from src/module/rag/rag.py rename to src/modules/rag/rag.py index b3bb71f..182b930 100644 --- a/src/module/rag/rag.py +++ b/src/modules/rag/rag.py @@ -11,7 +11,7 @@ from langchain_ollama.llms import OllamaLLM from langgraph.checkpoint.memory import MemorySaver -from src.module.module import Module +from src.core.module import Module class Rag(Module): diff --git a/src/modules/speech_to_text/__init__.py b/src/modules/speech_to_text/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/module/speech_to_text/record_speech.py b/src/modules/speech_to_text/record_speech.py similarity index 98% rename from src/module/speech_to_text/record_speech.py rename to src/modules/speech_to_text/record_speech.py index c0f38ed..254f6a1 100644 --- a/src/module/speech_to_text/record_speech.py +++ b/src/modules/speech_to_text/record_speech.py @@ -6,7 +6,7 @@ import numpy as np import sounddevice as sd -from src.module.module import Event, Module +from src.modules.module import Event, Module class RecordSpeech(Module): diff --git a/src/module/speech_to_text/speech_to_text.py b/src/modules/speech_to_text/speech_to_text.py similarity index 97% rename from src/module/speech_to_text/speech_to_text.py rename to src/modules/speech_to_text/speech_to_text.py index 43fdfa9..e101220 100644 --- a/src/module/speech_to_text/speech_to_text.py +++ b/src/modules/speech_to_text/speech_to_text.py @@ -9,7 +9,7 @@ import soundfile as sf import whisper -from src.module.module import Event, Module +from src.modules.module import Event, Module class SpeechToText(Module): From 896be085bcf62950bd0cdf2b6916a82c0ccd8e41 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 14 Dec 2025 16:03:01 +0100 Subject: [PATCH 29/42] feat(huri): HuRI class is launched with config --- config/huri.yaml | 11 ++++++++++ src/__init__.py | 0 src/core/huri.py | 55 ++++++++++++++++++++++++++++++++++------------ src/launch_huri.py | 46 ++++++++++++++++++++++++++++++++++++++ src/main.py | 50 ----------------------------------------- 5 files changed, 98 insertions(+), 64 deletions(-) create mode 100644 config/huri.yaml create mode 100644 src/__init__.py create mode 100644 src/launch_huri.py delete mode 100644 src/main.py diff --git a/config/huri.yaml b/config/huri.yaml new file mode 100644 index 0000000..13f06b1 --- /dev/null +++ b/config/huri.yaml @@ -0,0 +1,11 @@ +hostname: localhost + +router: + port: 3000 + +event-proxy: + xsub: 5555 + xpub: 5556 + +log-puller: + port: 8008 diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/huri.py b/src/core/huri.py index 374cdaf..551a129 100644 --- a/src/core/huri.py +++ b/src/core/huri.py @@ -1,6 +1,7 @@ import multiprocessing as mp import threading import time +from dataclasses import dataclass from typing import Dict from src.tools.logger import ( @@ -11,25 +12,53 @@ setup_logger, ) -from .zmq.event_proxy import EventProxy, ZMQEventPorts -from .zmq.log_channel import LogPuller, ZMQLogPort -from .zmq.control_channel import Router, ZMQRouterPort +from .zmq.control_channel import Router +from .zmq.event_proxy import EventProxy +from .zmq.log_channel import LogPuller -class HuRIConfig: - register_channel_ports: ZMQEventPorts - event_channel_ports: ZMQEventPorts - log_channel_port: ZMQEventPorts - log_level: int = logging.INFO +@dataclass +class RouterConfig: + port: int + + +@dataclass +class EventProxyConfig: + xsub: int + xpub: int + + +@dataclass +class LogPullerConfig: + port: int + + +@dataclass +class HuriConfig: + hostname: str + router: RouterConfig + event_proxy: EventProxyConfig + log_puller: LogPullerConfig + + @classmethod + def from_dict(cls, raw: dict): + return cls( + hostname=raw["hostname"], + router=RouterConfig(**raw["router"]), + event_proxy=EventProxyConfig(**raw["event-proxy"]), + log_puller=LogPullerConfig(**raw["log-puller"]), + ) class HuRI: """Wait for Agent to connect, handle module communication and Logging""" - def __init__(self) -> None: - self.router = Router(ZMQRouterPort(router="3000")) - self.event_proxy = EventProxy(ZMQEventPorts(xpub="5556", xsub="5555")) - self.log_channel = LogPuller(ZMQLogPort("8008")) + def __init__(self, config: HuriConfig) -> None: + self.router = Router(config.hostname, config.router.port) + self.event_proxy = EventProxy( + config.hostname, "", config.event_proxy.xpub, config.event_proxy.xsub + ) + self.log_channel = LogPuller(config.hostname, config.log_puller.port) self.threads: Dict[str, threading.Thread] = {} @@ -53,11 +82,9 @@ def _start_log_channel(self) -> None: self.threads["LogChannel"].start() def run(self) -> None: - # self.log_listener.start() self._start_log_channel() self._start_router() self._start_event_proxy() - # self._start_log_channel() from src.core.shell import RobotShell diff --git a/src/launch_huri.py b/src/launch_huri.py new file mode 100644 index 0000000..ea4e670 --- /dev/null +++ b/src/launch_huri.py @@ -0,0 +1,46 @@ +import argparse +import logging +import time + +import yaml + +from src.core.huri import ( + EventProxyConfig, + HuRI, + HuriConfig, + LogPullerConfig, + RouterConfig, +) + + +def load_config(path: str) -> HuriConfig: + with open(path) as f: + raw = yaml.safe_load(f) + + return HuriConfig.from_dict(raw) + + +def main() -> None: + parser = argparse.ArgumentParser(description="HuRI core") + parser.add_argument( + "--config", + required=True, + help="Path to HuRI config file (YAML)", + ) + + args = parser.parse_args() + + config = load_config(args.config) + + huri = HuRI(config) + time.sleep(0.1) + try: + huri.run() + except KeyboardInterrupt: + huri.stop() + except Exception as e: + logging.getLogger(__name__).error(e) + + +if __name__ == "__main__": + main() diff --git a/src/main.py b/src/main.py deleted file mode 100644 index 211cf29..0000000 --- a/src/main.py +++ /dev/null @@ -1,50 +0,0 @@ -import time -from typing import Dict - -from src.module.module import Module -from src.module.shell import ModuleManager, RobotShell -from src.module.speech_to_text.record_speech import RecordSpeech -from src.module.speech_to_text.speech_to_text import SpeechToText -from src.tools.logger import logging - - -class TTSModule(Module): - def __init__(self): - super().__init__() - - def set_subscriptions(self) -> None: - self.subscribe("llm.response", self.on_llm_response) - - def on_llm_response(self, msg: str): - self.logger.debug(f"parle -> {msg}") - - -class LLMModule(Module): - def __init__(self): - super().__init__() - - def set_subscriptions(self) -> None: - self.subscribe("text.in", self.on_speech) - - def on_speech(self, msg: str): - reponse = f"Réponse à '{msg}'" - self.logger.debug(f"{reponse}") - self.publish("llm.response", reponse) - - -if __name__ == "__main__": - modules: Dict[str, Module] = { - "REC": RecordSpeech(), - "STT": SpeechToText(), - "LLM": LLMModule(), - "TTS": TTSModule(), - } - manager = ModuleManager(modules) - manager.start() - time.sleep(0.1) - try: - RobotShell(manager).cmdloop() - except KeyboardInterrupt: - manager.stop_all() - except Exception as e: - logging.getLogger().error(e) From 228ad24d6f3705607773b0f1149fa71d63bf066d Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 14 Dec 2025 17:04:16 +0100 Subject: [PATCH 30/42] feat(modules): ModuleFactory class to register/create modules --- src/modules/factory.py | 28 ++++++++++++++++++++ src/modules/speech_to_text/record_speech.py | 2 +- src/modules/speech_to_text/speech_to_text.py | 3 ++- 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 src/modules/factory.py diff --git a/src/modules/factory.py b/src/modules/factory.py new file mode 100644 index 0000000..b2ac9f4 --- /dev/null +++ b/src/modules/factory.py @@ -0,0 +1,28 @@ +from typing import Any, Mapping + +from src.core.module import Module + +from .rag.mode_controller import ModeController +from .rag.rag import Rag +from .speech_to_text.record_speech import RecordSpeech +from .speech_to_text.speech_to_text import SpeechToText + + +class ModuleFactory: + _registry = {} + + @classmethod + def register(cls, name: str, module_cls): + cls._registry[name] = module_cls + + @classmethod + def create(cls, name: str, args: Mapping[str, Any] | None = None) -> Module: + if name not in cls._registry: + raise ValueError(f"Unknown module '{name}'") + return cls._registry[name](**args) + + +ModuleFactory.register("mic", RecordSpeech) +ModuleFactory.register("stt", SpeechToText) +ModuleFactory.register("rag", Rag) +ModuleFactory.register("mod", ModeController) diff --git a/src/modules/speech_to_text/record_speech.py b/src/modules/speech_to_text/record_speech.py index 254f6a1..a361ac3 100644 --- a/src/modules/speech_to_text/record_speech.py +++ b/src/modules/speech_to_text/record_speech.py @@ -6,7 +6,7 @@ import numpy as np import sounddevice as sd -from src.modules.module import Event, Module +from src.core.module import Event, Module class RecordSpeech(Module): diff --git a/src/modules/speech_to_text/speech_to_text.py b/src/modules/speech_to_text/speech_to_text.py index e101220..a833172 100644 --- a/src/modules/speech_to_text/speech_to_text.py +++ b/src/modules/speech_to_text/speech_to_text.py @@ -9,7 +9,7 @@ import soundfile as sf import whisper -from src.modules.module import Event, Module +from src.core.module import Event, Module class SpeechToText(Module): @@ -20,6 +20,7 @@ def __init__( sample_rate: int = 16000, ): super().__init__() + print(model_name) if device == "cpu": import warnings From e8fc289074d2c2e92a2fe992bd3699ae5712debf Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 14 Dec 2025 17:04:50 +0100 Subject: [PATCH 31/42] feat(agent): agent are launch with config --- config/agent_io.yaml | 35 ++++++++++ src/core/agent.py | 113 +++++++++++++++++++++++++------- src/core/zmq/control_channel.py | 19 +++--- src/core/zmq/event_proxy.py | 19 ++++-- src/core/zmq/log_channel.py | 65 ++++-------------- src/launch_agent.py | 40 +++++++++++ 6 files changed, 202 insertions(+), 89 deletions(-) create mode 100644 config/agent_io.yaml create mode 100644 src/launch_agent.py diff --git a/config/agent_io.yaml b/config/agent_io.yaml new file mode 100644 index 0000000..bd8f73a --- /dev/null +++ b/config/agent_io.yaml @@ -0,0 +1,35 @@ +id: agent-io +hostname: localhost + +huri: + hostname: localhost + router: + port: 3000 + event-proxy: + xsub: 5555 + xpub: 5556 + log-puller: + port: 8008 + +dealer: # todo + heartbeat: + interval_ms: 1000 + timeout_ms: 5000 + +forwarder-proxy: + down-xsub: 6665 + up-xpub: 6666 + +logging: INFO + +modules: + mic: + name: mic + args: + sample_rate: 18000 + logging: INFO + stt: + name: stt + args: + sample_rate: 18000 + logging: INFO diff --git a/src/core/agent.py b/src/core/agent.py index 1fa5bbc..093186a 100644 --- a/src/core/agent.py +++ b/src/core/agent.py @@ -4,8 +4,8 @@ import threading import time from multiprocessing.synchronize import Event -from typing import Dict, List - +from typing import Dict, List, Any, Mapping +from src.modules.factory import ModuleFactory import zmq from src.tools.logger import ( @@ -15,44 +15,109 @@ setup_log_listener, setup_logger, ) - +from dataclasses import dataclass from .module import Module -from .zmq.event_proxy import EventProxy, ZMQEventPorts -from .zmq.log_channel import LogPusher, ZMQLogPort -from .zmq.control_channel import Command, Dealer, ZMQRouterPort +from .zmq.control_channel import Command, Dealer +from .zmq.event_proxy import EventProxy +from .zmq.log_channel import LogPusher +from .huri import HuriConfig + + +@dataclass +class ForwarderProxyConfig: + down_xsub: int + up_xpub: int + + @classmethod + def from_dict(cls, raw: dict): + return cls( + down_xsub=raw["down-xsub"], + up_xpub=raw["up-xpub"], + ) + + +@dataclass +class ModuleConfig: + name: str + args: Mapping[str, Any] + logging: int + + @classmethod + def from_dict(cls, raw: dict): + level = logging._nameToLevel.get( + raw.get("logging", "INFO"), + logging.INFO, + ) + return cls( + name=raw["name"], + args=raw.get("args", {}), + logging=level, + ) + -# class AgentConfig: -# huri_hostname: str -# huri_event_router_ports: ZMQEventPorts -# event_forwarder_ports: ZMQEventPorts -# log_level: int = logging.INFO +@dataclass +class AgentConfig: + id: str + hostname: str + huri: HuriConfig + logging: int + forwarder_proxy: ForwarderProxyConfig + modules: Dict[str, ModuleConfig] + + @classmethod + def from_dict(cls, raw: dict): + level = logging._nameToLevel.get( + raw.get("logging", "INFO").upper(), + logging.INFO, + ) + modules = { + module_id: ModuleConfig.from_dict(mod_raw) + for module_id, mod_raw in raw.get("modules", {}).items() + } + return cls( + id=raw["id"], + hostname=raw["hostname"], + huri=HuriConfig.from_dict(raw["huri"]), + forwarder_proxy=ForwarderProxyConfig.from_dict(raw["forwarder-proxy"]), + logging=level, + modules=modules, + ) class Agent: """Control Modules and communication with HuRI""" - def __init__(self, modules: Dict[str, Module]) -> None: - self.modules: Dict[str, Module] = modules + def __init__(self, config: AgentConfig) -> None: + self.modules: Dict[str, ModuleConfig] = config.modules self.processes: Dict[str, mp.Process] = {} self.stop_events: Dict[str, Event] = {} self.threads: Dict[str, threading.Thread] = {} - self.log_pusher = LogPusher("tcp://localhost:8008") + self.log_pusher = LogPusher( + hostname=config.huri.hostname, port=config.huri.log_puller.port + ) self.dealer = Dealer( - port=ZMQRouterPort(router="3000"), + hostname=config.huri.hostname, + port=config.huri.router.port, executor=self._command_handler, logger=setup_logger("Dealer", log_queue=self.log_pusher.log_queue), ) self.up_proxy = EventProxy( - ports=ZMQEventPorts(xpub="5555", xsub="6666"), + hostname=config.hostname, + connect_hostname=config.huri.hostname, + xpub_port=config.huri.event_proxy.xsub, + xsub_port=config.forwarder_proxy.up_xpub, logger=setup_logger("UpProxy", log_queue=self.log_pusher.log_queue), ) self.down_proxy = EventProxy( - ports=ZMQEventPorts(xpub="6665", xsub="5556"), + hostname=config.hostname, + connect_hostname=config.huri.hostname, + xpub_port=config.forwarder_proxy.down_xsub, + xsub_port=config.huri.event_proxy.xpub, logger=setup_logger("DownProxy", log_queue=self.log_pusher.log_queue), ) @@ -73,10 +138,14 @@ def _command_handler(self, command: Command) -> bool: @staticmethod def _start_module( - module: Module, name: str, log_queue: mp.Queue, stop_event: Event + name: str, module_config: ModuleConfig, log_queue: mp.Queue, stop_event: Event ) -> None: """Helper function to start module in child process.""" - logger = setup_logger(name, log_queue=log_queue) + logger = setup_logger( + module_config.name, level=module_config.logging, log_queue=log_queue + ) + + module = ModuleFactory.create(name, module_config.args) module.set_custom_logger(logger) def handle_sigint(signum, frame): @@ -99,11 +168,11 @@ def start_module(self, name) -> None: ) return - module = self.modules[name] + module_config = self.modules[name] stop_event = mp.Event() p = mp.Process( target=self._start_module, - args=(module, name, self.log_pusher.log_queue, stop_event), + args=(name, module_config, self.log_pusher.log_queue, stop_event), daemon=True, ) self.processes[name] = p @@ -111,7 +180,7 @@ def start_module(self, name) -> None: self.log_pusher.level_filter.add_level(name) p.start() - self.logger.info(f"{name} ({type(module)}) started (PID={p.pid})") + self.logger.info(f"{name} ({module_config.name}) started (PID={p.pid})") def stop_module(self, name) -> None: if name in self.processes: diff --git a/src/core/zmq/control_channel.py b/src/core/zmq/control_channel.py index 57c0b86..e12baf3 100644 --- a/src/core/zmq/control_channel.py +++ b/src/core/zmq/control_channel.py @@ -8,11 +8,6 @@ from src.tools.logger import logging, setup_logger -@dataclass -class ZMQRouterPort: - router: str - - @dataclass class Command: cmd: str # "STOP", "START", "STATUS", ... @@ -44,12 +39,14 @@ def from_bytes(data: bytes) -> "Command": class Router: def __init__( self, - port: ZMQRouterPort, + hostname: str, + port: int, logger: Optional[logging.Logger] = setup_logger("Router"), ): self.ctx = zmq.Context.instance() self.router = self.ctx.socket(zmq.ROUTER) + self.hostname = hostname self.port = port self.logger = logger or logging.getLogger(__name__) @@ -57,7 +54,7 @@ def __init__( self.dealers: Dict[bytes, bool] = {} def start(self): - self.router.bind(f"tcp://localhost:{self.port.router}") + self.router.bind(f"tcp://{self.hostname}:{self.port}") self.logger.info("Router started") try: @@ -99,23 +96,25 @@ def send_commands(self, command: Command) -> None: class Dealer: def __init__( self, - port: ZMQRouterPort, + hostname: str, + port: int, executor: Callable[[Command], bool], logger: Optional[logging.Logger] = None, identity: Optional[str] = None, ): self.ctx = zmq.Context.instance() self.dealer = self.ctx.socket(zmq.DEALER) + + self.hostname = hostname self.port = port self.executor = executor - # Set explicit identity self.identity = (identity or str(uuid.uuid4())).encode() # TODO agent name self.logger = logger or logging.getLogger(f"Dealer {self.identity}") def start(self): - self.dealer.connect(f"tcp://localhost:{self.port.router}") + self.dealer.connect(f"tcp://{self.hostname}:{self.port}") self.dealer.setsockopt(zmq.IDENTITY, self.identity) self.logger.info(f"Dealer started: {self.identity}") diff --git a/src/core/zmq/event_proxy.py b/src/core/zmq/event_proxy.py index 49a9aea..da1e068 100644 --- a/src/core/zmq/event_proxy.py +++ b/src/core/zmq/event_proxy.py @@ -15,26 +15,33 @@ class ZMQEventPorts: class EventProxy: def __init__( self, - ports: ZMQEventPorts, + hostname: str, + connect_hostname: str, + xpub_port: int, + xsub_port: int, logger: Optional[logging.Logger] = setup_logger("EventProxy"), ): self.ctx = zmq.Context.instance() self.xpub = self.ctx.socket(zmq.XPUB) self.xsub = self.ctx.socket(zmq.XSUB) - self.ports = ports + + self.hostname = hostname + self.connect_hostname = connect_hostname + self.xpub_port = xpub_port + self.xsub_port = xsub_port self.logger = logger or logging.getLogger(__name__) def start(self, xpub_connect: bool, xsub_connect: bool): if xpub_connect: - self.xpub.connect(f"tcp://localhost:{self.ports.xpub}") + self.xpub.connect(f"tcp://{self.connect_hostname}:{self.xpub_port}") else: - self.xpub.bind(f"tcp://localhost:{self.ports.xpub}") + self.xpub.bind(f"tcp://{self.hostname}:{self.xpub_port}") if xsub_connect: - self.xsub.connect(f"tcp://localhost:{self.ports.xsub}") + self.xsub.connect(f"tcp://{self.connect_hostname}:{self.xsub_port}") else: - self.xsub.bind(f"tcp://localhost:{self.ports.xsub}") + self.xsub.bind(f"tcp://{self.hostname}:{self.xsub_port}") try: self.logger.info("Correctly initialized, starting proxy") diff --git a/src/core/zmq/log_channel.py b/src/core/zmq/log_channel.py index 8c1b014..f183b69 100644 --- a/src/core/zmq/log_channel.py +++ b/src/core/zmq/log_channel.py @@ -16,11 +16,6 @@ ) -@dataclass -class ZMQLogPort: - port: str - - def record_to_dict(record: logging.LogRecord) -> Dict[str, Any]: return { "name": record.name, @@ -65,59 +60,23 @@ def dict_to_record(data: Dict[str, Any]) -> logging.LogRecord: return record -# @dataclass -# class Log: -# asctime - - -# class LogPusher: -# def __init__( -# self, -# ports: ZMQLogPort, -# logger: Optional[logging.Logger] = setup_logger("LogPusher"), -# ): - -# self.ctx = zmq.Context.instance() -# self.push = self.ctx.socket(zmq.PUSH) -# self.push.connect(f"tcp://localhost:{ports.port}") - -# self.log_queue = mp.Queue() -# self.logger = logger -# self.level_filter = LevelFilter(logging.DEBUG) -# self.log_listener: QueueListener = setup_log_listener( -# self.log_queue, self.level_filter -# ) - -# def get_log_queue(self) -> mp.Queue: -# return self.log_queue - -# def set_log_level(self, level: int) -> None: -# pass - -# def start(self): -# try: -# zmq.proxy(self.pull, self.push) -# except KeyboardInterrupt: -# self.logger.info("Ctrl+C pressed, exiting cleanly") -# except Exception as e: -# self.logger.error(e) - - class LogPuller: def __init__( self, - port: ZMQLogPort, + hostname: str, + port: int, logger: Optional[logging.Logger] = setup_logger("LogPuller"), ) -> None: - self.ctx = zmq.Context.instance() self.pull = self.ctx.socket(zmq.PULL) + + self.hostname = hostname self.port = port self.logger = logger or logging.getLogger(__name__) def start(self) -> None: - self.pull.bind(f"tcp://localhost:{self.port.port}") + self.pull.bind(f"tcp://{self.hostname}:{self.port}") self.logger.info("started") while True: @@ -133,12 +92,15 @@ class LogPusher: class LogPusherHandler(logging.Handler): def __init__( self, - endpoint: str, + hostname: str, + port: int, ): super().__init__() self.ctx = zmq.Context.instance() self.socket = self.ctx.socket(zmq.PUSH) - self.endpoint = endpoint + + self.hostname = hostname + self.port = port def emit(self, record: logging.LogRecord) -> None: try: @@ -150,19 +112,20 @@ def emit(self, record: logging.LogRecord) -> None: self.handleError(record) def start(self) -> None: - self.socket.connect(self.endpoint) + self.socket.connect(f"tcp://{self.hostname}:{self.port}") def stop(self) -> None: self.socket.close() def __init__( self, - endpoint: str, + hostname: str, + port: int, ): self.log_queue = mp.Queue() - self.log_handler = self.LogPusherHandler(endpoint) + self.log_handler = self.LogPusherHandler(hostname, port) self.level_filter = LevelFilter(logging.DEBUG) self.log_listener: QueueListener = setup_log_listener( self.log_queue, self.level_filter, self.log_handler diff --git a/src/launch_agent.py b/src/launch_agent.py new file mode 100644 index 0000000..2b00a30 --- /dev/null +++ b/src/launch_agent.py @@ -0,0 +1,40 @@ +import argparse +import logging +import time + +import yaml + +from src.core.agent import Agent, AgentConfig, HuriConfig + + +def load_config(path: str) -> AgentConfig: + with open(path) as f: + raw = yaml.safe_load(f) + + return AgentConfig.from_dict(raw) + + +def main() -> None: + parser = argparse.ArgumentParser(description="HuRI core") + parser.add_argument( + "--config", + required=True, + help="Path to HuRI config file (YAML)", + ) + + args = parser.parse_args() + + config = load_config(args.config) + + agent = Agent(config) + time.sleep(0.1) + try: + agent.run() + except KeyboardInterrupt: + agent.stop_all() + except Exception as e: + logging.getLogger(__name__).error(e) + + +if __name__ == "__main__": + main() From ffcbc0fec243a5fe784285842bc477a5d2db60ba Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 14 Dec 2025 17:05:13 +0100 Subject: [PATCH 32/42] wip(shell): execute command in HuRI class --- src/core/shell.py | 95 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/core/shell.py diff --git a/src/core/shell.py b/src/core/shell.py new file mode 100644 index 0000000..0452c4a --- /dev/null +++ b/src/core/shell.py @@ -0,0 +1,95 @@ +import cmd +import logging +import sys + +from src.core.huri import HuRI +from src.core.zmq.control_channel import Command + + +class RobotShell(cmd.Cmd): + intro = "HuRI's shell. Type 'help' to see command's list." + prompt = "(HuRI) " + + def __init__(self, huri: HuRI) -> None: + super().__init__() + self.huri = huri + + def do_status(self, arg) -> None: + "Display modules and router status." + self.huri.router.send_commands(Command("STATUS", [])) + + def do_start(self, arg) -> None: + "Start a module." + self.huri.router.send_commands(Command("START", [arg.strip()])) + + def do_stop(self, arg) -> None: + "Stop a module." + self.huri.router.send_commands(Command("STOP", [arg.strip()])) + + def do_exit(self, arg) -> None: + "Exit HuRi." + self.huri.router.send_commands(Command("EXIT", [])) + print("Bye !") + return True + + # def do_log(self, arg) -> None: + # """ + # Usage: + # log status -> display log levels + # log -> set global root level (DEBUG/INFO/WARNING/ERROR/CRITICAL), has priority over custom module level + # log -> set custom per-module level (module name e.g. STT) + # log all -> set all levels (DEBUG/INFO/WARNING/ERROR/CRITICAL) + # """ + # parts = arg.strip().split() + # if not parts: + # print("Missing arguments. Type 'help log' for usage.") + # return + + # level_map = { + # "DEBUG": logging.DEBUG, + # "INFO": logging.INFO, + # "WARNING": logging.WARNING, + # "ERROR": logging.ERROR, + # "CRITICAL": logging.CRITICAL, + # } + + # try: + # if parts[0].lower() == "status": + # self.huri.lo.log_status() + # return + + # if len(parts) == 1: + # # Set root level + # level = level_map.get(parts[0].upper()) + # if level is None: + # print(f"Unknown level: {parts[0]}") + # return + # self.huri.lo.set_root_log_level(level) + # print(f"Root log level set to {parts[0].upper()}") + + # elif len(parts) == 2: + # level = level_map.get(parts[1].upper()) + # if level is None: + # print(f"Unknown level: {parts[1]}") + # return + # if parts[0].lower() == "all": + # self.huri.lo.set_log_levels(level) + # print(f"All log levels set to {parts[1].upper()}") + # else: + # module = parts[0] + # self.huri.lo.set_log_level(module, level) + # print(f"{module} log level set to {parts[1].upper()}") + + # else: + # print("Invalid arguments. Type 'help log' for usage.") + # except Exception as e: + # print(f"Error setting log level: {e}") + + # def do_stdin(self, arg) -> None: + # "Will disable shell and get lines from stdin to send as 'text.in' event. Exit with CTRL+D." + # print("Press CTRL+D to exit...\n>> ", end="") + # data = sys.stdin.readline() + # while data != "": + # self.huri.publish("text.in", data) + # print(">> ", end="") + # data = sys.stdin.readline() From da2cfebc6862f20b2bfc1db6b401b1a0ba244436 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 15 Dec 2025 04:30:04 +0100 Subject: [PATCH 33/42] evol(agent): handle stdin --- src/core/agent.py | 12 +++--------- src/core/zmq/event_proxy.py | 4 ++++ 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/core/agent.py b/src/core/agent.py index 093186a..1fc306f 100644 --- a/src/core/agent.py +++ b/src/core/agent.py @@ -283,12 +283,6 @@ def run(self) -> None: for name in self.modules: self.start_module(name) - threading.Event().wait() - - # def handle_sigint(sig, frame): - # shutdown_event.set() - - # shutdown_event = threading.Event() - # signal.signal(signal.SIGINT, handle_sigint) - # shutdown_event.wait() - # signal.signal(signal.SIGINT, handle_sigint) + while True: + data = input() + self.down_proxy.publish("std.in", data) diff --git a/src/core/zmq/event_proxy.py b/src/core/zmq/event_proxy.py index da1e068..9447de8 100644 --- a/src/core/zmq/event_proxy.py +++ b/src/core/zmq/event_proxy.py @@ -52,3 +52,7 @@ def start(self, xpub_connect: bool, xsub_connect: bool): def stop(self) -> None: self.xsub.close(linger=0) self.xpub.close(linger=0) + + def publish(self, topic: str, msg: str) -> None: + self.xpub.send_multipart([topic.encode(), "str".encode(), msg.encode()]) + self.logger.info(f"Publish: {topic} str") From 2c24598724fc2a1bc437e6808f68cad13df7dee9 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 15 Dec 2025 04:32:33 +0100 Subject: [PATCH 34/42] add(modules): TextInput & TextOutput --- src/core/module.py | 1 - src/core/zmq/control_channel.py | 2 -- src/modules/factory.py | 4 ++++ src/modules/rag/mode_controller.py | 8 +++----- src/modules/rag/rag.py | 2 +- src/modules/textIO/input.py | 17 +++++++++++++++++ src/modules/textIO/output.py | 12 ++++++++++++ 7 files changed, 37 insertions(+), 9 deletions(-) create mode 100644 src/modules/textIO/input.py create mode 100644 src/modules/textIO/output.py diff --git a/src/core/module.py b/src/core/module.py index 8dde03c..1561b32 100644 --- a/src/core/module.py +++ b/src/core/module.py @@ -93,7 +93,6 @@ def _poll_loop(self) -> None: self.callbacks[topic_str](data) elif content_type_str == "str": data = payload.decode() - self.logger.error(data) self.callbacks[topic_str](data) @final diff --git a/src/core/zmq/control_channel.py b/src/core/zmq/control_channel.py index e12baf3..89ef839 100644 --- a/src/core/zmq/control_channel.py +++ b/src/core/zmq/control_channel.py @@ -137,8 +137,6 @@ def start(self): finally: self.dealer.close() - print("DEALER CLOSED") - def execute(self, command: Command) -> bytes: """ Execute command sent by Router diff --git a/src/modules/factory.py b/src/modules/factory.py index b2ac9f4..1205c99 100644 --- a/src/modules/factory.py +++ b/src/modules/factory.py @@ -6,6 +6,8 @@ from .rag.rag import Rag from .speech_to_text.record_speech import RecordSpeech from .speech_to_text.speech_to_text import SpeechToText +from .textIO.input import TextInput +from .textIO.output import TextOutput class ModuleFactory: @@ -24,5 +26,7 @@ def create(cls, name: str, args: Mapping[str, Any] | None = None) -> Module: ModuleFactory.register("mic", RecordSpeech) ModuleFactory.register("stt", SpeechToText) +ModuleFactory.register("inp", TextInput) +ModuleFactory.register("out", TextOutput) ModuleFactory.register("rag", Rag) ModuleFactory.register("mod", ModeController) diff --git a/src/modules/rag/mode_controller.py b/src/modules/rag/mode_controller.py index 97054a9..9d26ea1 100644 --- a/src/modules/rag/mode_controller.py +++ b/src/modules/rag/mode_controller.py @@ -22,11 +22,9 @@ class Modes(Enum): class ModeController(Module): - def __init__( - self, - ): + def __init__(self, default_mode: Modes = Modes.LLM): super().__init__() - self.mode = Modes.LLM + self.mode = default_mode def switchMode(self, mode: str) -> None: if mode == "llm": @@ -44,7 +42,7 @@ def processTextInput(self, text: str): elif "switch rag" in text.lower(): self.switchMode(Modes.RAG) elif "bye bye" in text.lower(): - self.publish("exit", "") # TODO handle (manager being a module) + self.publish("exit", "") # TODO handle (manager being a module) usefull ? elif text.strip() == "": return else: diff --git a/src/modules/rag/rag.py b/src/modules/rag/rag.py index 182b930..6aac071 100644 --- a/src/modules/rag/rag.py +++ b/src/modules/rag/rag.py @@ -17,7 +17,7 @@ class Rag(Module): def __init__( self, - model: str = "deepseek-r1:7b", + model: str = "deepseek-v2:16b", collectionName: str = "vectorStore", vectorstorePath: str = "src/rag/vectorStore", ): diff --git a/src/modules/textIO/input.py b/src/modules/textIO/input.py new file mode 100644 index 0000000..6440238 --- /dev/null +++ b/src/modules/textIO/input.py @@ -0,0 +1,17 @@ +from src.core.module import Module + + +class TextInput(Module): + def set_subscriptions(self): + self.subscribe("std.in", self.stdin_to_text) + self.subscribe("std.out", lambda _: print(">> ", end="", flush=True)) + + def stdin_to_text(self, data): + print(">> ", end="", flush=True) + if data == "": + return + self.publish("text.in", data) + + def run_module(self, stop_event=None): + print(">> ", end="", flush=True) + stop_event.wait() diff --git a/src/modules/textIO/output.py b/src/modules/textIO/output.py new file mode 100644 index 0000000..0954e03 --- /dev/null +++ b/src/modules/textIO/output.py @@ -0,0 +1,12 @@ +import sys +import time +from src.core.module import Module + + +class TextOutput(Module): + def set_subscriptions(self) -> None: + self.subscribe("llm.response", self.print_response) + + def print_response(self, text: str) -> None: + print(f"\r<< {text}") + self.publish("std.out", "") From 4fa27d1b725b735e186ce2d138032753715eee12 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 15 Dec 2025 04:32:48 +0100 Subject: [PATCH 35/42] add(config): agent that does not use mic --- config/agent_input.yaml | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 config/agent_input.yaml diff --git a/config/agent_input.yaml b/config/agent_input.yaml new file mode 100644 index 0000000..43b59e7 --- /dev/null +++ b/config/agent_input.yaml @@ -0,0 +1,39 @@ +id: agent-io +hostname: localhost + +huri: + hostname: localhost + router: + port: 3000 + event-proxy: + xsub: 5555 + xpub: 5556 + log-puller: + port: 8008 + +dealer: # todo + heartbeat: + interval_ms: 1000 + timeout_ms: 5000 + +forwarder-proxy: + down-xsub: 6665 + up-xpub: 6666 + +logging: DEBUG + +modules: + INP: + name: inp + logging: INFO + OUT: + name: out + logging: INFO + MOD: + name: mod + logging: INFO + RAG: + name: rag + args: + model: deepseek-v2:16b + logging: INFO From d37708b15f62d8f34fae7b89834367782cc38c51 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 15 Dec 2025 05:20:23 +0100 Subject: [PATCH 36/42] fix(rag): Document loading --- src/modules/rag/mode_controller.py | 13 +++++++------ src/modules/rag/rag.py | 5 ++++- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/src/modules/rag/mode_controller.py b/src/modules/rag/mode_controller.py index 9d26ea1..66ca700 100644 --- a/src/modules/rag/mode_controller.py +++ b/src/modules/rag/mode_controller.py @@ -27,12 +27,13 @@ def __init__(self, default_mode: Modes = Modes.LLM): self.mode = default_mode def switchMode(self, mode: str) -> None: - if mode == "llm": - self.mode = Modes.LLM - elif mode == "context": - self.mode = Modes.CONTEXT - elif mode == "rag": - self.mode = Modes.RAG + # if mode == "llm": + # self.mode = Modes.LLM + # elif mode == "context": + # self.mode = Modes.CONTEXT + # elif mode == "rag": + # self.mode = Modes.RAG + self.mode = mode def processTextInput(self, text: str): if "switch llm" in text.lower(): diff --git a/src/modules/rag/rag.py b/src/modules/rag/rag.py index 6aac071..639d4a2 100644 --- a/src/modules/rag/rag.py +++ b/src/modules/rag/rag.py @@ -10,6 +10,7 @@ from langchain_ollama.embeddings import OllamaEmbeddings from langchain_ollama.llms import OllamaLLM from langgraph.checkpoint.memory import MemorySaver +from langchain_core.documents import Document from src.core.module import Module @@ -49,7 +50,9 @@ def __init__( self.conversation_log = {"conversation": []} def ragFill(self, text: str) -> None: - self.documents += self.textSplitter.split_documents(text) + self.documents += self.textSplitter.split_documents( + [Document(page_content=text)] + ) self.vectorstore.add_documents(self.documents) def ragLoad(self, folderPath: str, fileType: str) -> None: From 49558d7e303799636f9b63aa5c5b939e9b22a131 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Mon, 15 Dec 2025 05:21:03 +0100 Subject: [PATCH 37/42] feat(quick_launch): script to launch huri + 1 agent --- config/agent_input.yaml | 16 ++++++++-------- quick_launch.sh | 40 ++++++++++++++++++++++++++++++++++++++++ src/core/huri.py | 6 +++++- 3 files changed, 53 insertions(+), 9 deletions(-) create mode 100755 quick_launch.sh diff --git a/config/agent_input.yaml b/config/agent_input.yaml index 43b59e7..5510c66 100644 --- a/config/agent_input.yaml +++ b/config/agent_input.yaml @@ -23,17 +23,17 @@ forwarder-proxy: logging: DEBUG modules: - INP: - name: inp + inp: + name: INP logging: INFO - OUT: - name: out + out: + name: OUT logging: INFO - MOD: - name: mod + mod: + name: MOD logging: INFO - RAG: - name: rag + rag: + name: RAG args: model: deepseek-v2:16b logging: INFO diff --git a/quick_launch.sh b/quick_launch.sh new file mode 100755 index 0000000..a76da2a --- /dev/null +++ b/quick_launch.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash + +set -e + +# Check args +if [ "$#" -lt 2 ]; then + echo "Usage: $0 [CLEAN]" + exit 1 +fi + +HURI_CONFIG="$1" +AGENT_CONFIG="$2" + +LOG_DIR="./tmp/log" + +if [[ " $* " == *" CLEAN "* ]]; then + echo "Cleaning previous logs in ${LOG_DIR}" + rm -rf "${LOG_DIR}" +fi + +mkdir -p "$LOG_DIR" + +TIMESTAMP=$(date +"%Y%m%d-%H%M%S") +HURI_LOG="${LOG_DIR}/huri-${TIMESTAMP}.log" + + +# Run huri with output redirected +python -m src.launch_huri --config "$HURI_CONFIG" > "$HURI_LOG" 2>&1 & +HURI_PID=$! +echo "HURI started in background (PID=${HURI_PID}), logging to ${HURI_LOG}" + +# Run agent +python -m src.launch_agent --config "$AGENT_CONFIG" + +# Ensure HURI is killed on script exit (normal or Ctrl+C) +cleanup() { + echo "Stopping HURI (PID=${HURI_PID})" + kill "${HURI_PID}" 2>/dev/null || true +} +trap cleanup EXIT INT TERM \ No newline at end of file diff --git a/src/core/huri.py b/src/core/huri.py index 551a129..b9eaab4 100644 --- a/src/core/huri.py +++ b/src/core/huri.py @@ -3,7 +3,7 @@ import time from dataclasses import dataclass from typing import Dict - +import sys from src.tools.logger import ( LevelFilter, QueueListener, @@ -86,6 +86,10 @@ def run(self) -> None: self._start_router() self._start_event_proxy() + if not sys.stdin.isatty(): + threading.Event().wait() + return + from src.core.shell import RobotShell RobotShell(self).cmdloop() From 1bf1f6f3cb06f6291c583037d22f1c0b86ee9841 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 4 Jan 2026 13:14:16 +0100 Subject: [PATCH 38/42] evol(modules): now use config to connect to agent --- config/agent_input.yaml | 7 +------ config/agent_io.yaml | 11 ++++++----- src/core/agent.py | 40 +++++++++++++++++++--------------------- src/core/module.py | 22 ++++++++++++++++------ 4 files changed, 42 insertions(+), 38 deletions(-) diff --git a/config/agent_input.yaml b/config/agent_input.yaml index 5510c66..c122a8b 100644 --- a/config/agent_input.yaml +++ b/config/agent_input.yaml @@ -11,16 +11,11 @@ huri: log-puller: port: 8008 -dealer: # todo - heartbeat: - interval_ms: 1000 - timeout_ms: 5000 - forwarder-proxy: down-xsub: 6665 up-xpub: 6666 -logging: DEBUG +logging: INFO modules: inp: diff --git a/config/agent_io.yaml b/config/agent_io.yaml index bd8f73a..c9a5646 100644 --- a/config/agent_io.yaml +++ b/config/agent_io.yaml @@ -11,11 +11,6 @@ huri: log-puller: port: 8008 -dealer: # todo - heartbeat: - interval_ms: 1000 - timeout_ms: 5000 - forwarder-proxy: down-xsub: 6665 up-xpub: 6666 @@ -33,3 +28,9 @@ modules: args: sample_rate: 18000 logging: INFO + # tts: + # name: vibe + # args: + # model: vibe-voice + # voice: adrien + # logging: DEBUG diff --git a/src/core/agent.py b/src/core/agent.py index 1fc306f..6325051 100644 --- a/src/core/agent.py +++ b/src/core/agent.py @@ -89,6 +89,7 @@ class Agent: def __init__(self, config: AgentConfig) -> None: self.modules: Dict[str, ModuleConfig] = config.modules + self.config = config self.processes: Dict[str, mp.Process] = {} self.stop_events: Dict[str, Event] = {} @@ -138,7 +139,11 @@ def _command_handler(self, command: Command) -> bool: @staticmethod def _start_module( - name: str, module_config: ModuleConfig, log_queue: mp.Queue, stop_event: Event + name: str, + module_config: ModuleConfig, + agent_config: AgentConfig, + log_queue: mp.Queue, + stop_event: Event, ) -> None: """Helper function to start module in child process.""" logger = setup_logger( @@ -153,7 +158,12 @@ def handle_sigint(signum, frame): signal.signal(signal.SIGINT, handle_sigint) - module.start_module(stop_event=stop_event) + module.start_module( + agent_config.hostname, + agent_config.forwarder_proxy.up_xpub, + agent_config.forwarder_proxy.down_xsub, + stop_event=stop_event, + ) def start_module(self, name) -> None: """Check if module is registered and not already running, and start a child process.""" @@ -172,7 +182,13 @@ def start_module(self, name) -> None: stop_event = mp.Event() p = mp.Process( target=self._start_module, - args=(name, module_config, self.log_pusher.log_queue, stop_event), + args=( + name, + module_config, + self.config, + self.log_pusher.log_queue, + stop_event, + ), daemon=True, ) self.processes[name] = p @@ -214,12 +230,6 @@ def stop_all(self) -> None: def status(self) -> None: """Print status of all modules and router.""" print("=== Module Status ===") - # if self.router_process: - # router_state = "alive" if self.router_process.is_alive() else "stopped" - # print(f"- Router: {router_state} (PID={self.router_process.pid})") - # else: - # print("- Router: not started") - for name in self.modules: process = self.processes.get(name) if process: @@ -238,14 +248,6 @@ def set_log_level(self, name: str, level: int) -> None: def set_log_levels(self, level: int) -> None: self.log_pusher.level_filter.set_levels(level) - # def log_status(self) -> None: - # """Print status of all modules and router.""" - # print("=== Log Status ===") - # print(f"Root level: {logging.getLevelName(self.level_filter.root_level)}") - # for name, lvl in self.level_filter.log_levels.items(): - # print(f"- {name}: {logging.getLevelName(lvl)}") - # print("=====================") - def _connect_to_huri(self) -> None: self.log_pusher.level_filter.add_level("Dealer") self.threads["Dealer"] = threading.Thread(target=self.dealer.start) @@ -268,10 +270,6 @@ def _start_event_proxies(self) -> None: def run(self) -> None: """Start event router and modules""" # TODO config (also logs levels) - # def handle_sigint(signum, frame): - # self.logger.info(f"Ctrl+C detected, stopping...") - # self.stop_all() - try: self.log_pusher.start() self._connect_to_huri() diff --git a/src/core/module.py b/src/core/module.py index 1561b32..56bc37f 100644 --- a/src/core/module.py +++ b/src/core/module.py @@ -8,14 +8,15 @@ from src.tools.logger import logging -# from .zmq.event_proxy import "6665", XSUB_ENDPOINT - class Module(ABC): def __init__(self): """Child Modules must call super.__init__() in their __init__() function.""" self.ctx = None self.pub_socket = None + self.connect_hostname = None + self.xpub_port = None + self.xsub_port = None self.subs: Dict[str, zmq.Socket[bytes]] = {} self.callbacks = {} self._poller_running = False @@ -26,11 +27,11 @@ def __init__(self): def _initialize(self) -> None: """ Called inside start_module() or manually before usage. - This function exist because ctx cannot be set in __init__, because of multi-processing. + This function exist because ctx cannot be set in __init__, because of multi-processing. maybe deprecated """ self.ctx = zmq.Context() self.pub_socket = self.ctx.socket(zmq.PUB) - self.pub_socket.connect("tcp://localhost:6666") + self.pub_socket.connect(f"tcp://{self.connect_hostname}:{self.xpub_port}") self.poller = threading.Thread(target=self._poll_loop, daemon=True) self.set_subscriptions() @@ -42,7 +43,7 @@ def set_subscriptions(self) -> None: @final def subscribe(self, topic: str, callback: Callable) -> None: sub_socket = self.ctx.socket(zmq.SUB) - sub_socket.connect("tcp://localhost:6665") + sub_socket.connect(f"tcp://{self.connect_hostname}:{self.xsub_port}") sub_socket.setsockopt_string(zmq.SUBSCRIBE, topic) self.subs[topic] = sub_socket self.callbacks[topic] = callback @@ -96,7 +97,16 @@ def _poll_loop(self) -> None: self.callbacks[topic_str](data) @final - def start_module(self, stop_event: Event = None) -> None: + def start_module( + self, + connect_hostname: str, + xpub_port: int, + xsub_port: int, + stop_event: Event = None, + ) -> None: + self.connect_hostname = connect_hostname + self.xpub_port = xpub_port + self.xsub_port = xsub_port self._initialize() if self.subs != {}: self._start_polling() From 5a48b19386a8c1626f00fe0aaba462e17062b146 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 4 Jan 2026 13:20:31 +0100 Subject: [PATCH 39/42] evol(modules): factory is build inside a function and called in launchers --- src/launch_agent.py | 5 ++++- src/launch_huri.py | 11 ++++------- src/modules/factory.py | 13 +++++++------ 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/launch_agent.py b/src/launch_agent.py index 2b00a30..efc63c4 100644 --- a/src/launch_agent.py +++ b/src/launch_agent.py @@ -4,7 +4,8 @@ import yaml -from src.core.agent import Agent, AgentConfig, HuriConfig +from src.core.agent import Agent, AgentConfig +from src.modules.factory import build_module_factory def load_config(path: str) -> AgentConfig: @@ -26,6 +27,8 @@ def main() -> None: config = load_config(args.config) + build_module_factory() + agent = Agent(config) time.sleep(0.1) try: diff --git a/src/launch_huri.py b/src/launch_huri.py index ea4e670..b4f9fd0 100644 --- a/src/launch_huri.py +++ b/src/launch_huri.py @@ -4,13 +4,8 @@ import yaml -from src.core.huri import ( - EventProxyConfig, - HuRI, - HuriConfig, - LogPullerConfig, - RouterConfig, -) +from src.core.huri import HuRI, HuriConfig +from src.modules.factory import build_module_factory def load_config(path: str) -> HuriConfig: @@ -32,6 +27,8 @@ def main() -> None: config = load_config(args.config) + build_module_factory() + huri = HuRI(config) time.sleep(0.1) try: diff --git a/src/modules/factory.py b/src/modules/factory.py index 1205c99..74bba03 100644 --- a/src/modules/factory.py +++ b/src/modules/factory.py @@ -24,9 +24,10 @@ def create(cls, name: str, args: Mapping[str, Any] | None = None) -> Module: return cls._registry[name](**args) -ModuleFactory.register("mic", RecordSpeech) -ModuleFactory.register("stt", SpeechToText) -ModuleFactory.register("inp", TextInput) -ModuleFactory.register("out", TextOutput) -ModuleFactory.register("rag", Rag) -ModuleFactory.register("mod", ModeController) +def build_module_factory() -> None: + ModuleFactory.register("mic", RecordSpeech) + ModuleFactory.register("stt", SpeechToText) + ModuleFactory.register("inp", TextInput) + ModuleFactory.register("out", TextOutput) + ModuleFactory.register("rag", Rag) + ModuleFactory.register("mod", ModeController) From 3aa734d0c61a365794ab68da3d1f834c64582f30 Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 4 Jan 2026 13:25:33 +0100 Subject: [PATCH 40/42] remove(tts): deprecated --- src/text_to_speech/tts.py | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 src/text_to_speech/tts.py diff --git a/src/text_to_speech/tts.py b/src/text_to_speech/tts.py deleted file mode 100644 index 0f8497a..0000000 --- a/src/text_to_speech/tts.py +++ /dev/null @@ -1,15 +0,0 @@ -import torch -from parler_tts import ParlerTTSForConditionalGeneration -from transformers import AutoTokenizer - - -def get_tts_model(): - device = "cuda" if torch.cuda.is_available() else "cpu" - return ParlerTTSForConditionalGeneration.from_pretrained( - "parler-tts/parler-tts-mini-v1" - ).to(device) - - -def tokenize_text(text, tokenizer): - device = "cuda" if torch.cuda.is_available() else "cpu" - return tokenizer(text, return_tensors="pt").input_ids.to(device) From 4ec66ed03dd27fac22d6f1e6b9934e56dcf5f1bb Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 4 Jan 2026 13:26:51 +0100 Subject: [PATCH 41/42] fix(linter): isort, flake8 & black --- src/core/agent.py | 23 +++---- src/core/events.py | 0 src/core/huri.py | 13 +--- src/core/module.py | 2 +- src/core/shell.py | 64 -------------------- src/core/zmq/log_channel.py | 2 - src/emotional_hub/input_analysis.py | 2 +- src/modules/rag/mode_controller.py | 12 ---- src/modules/rag/rag.py | 2 +- src/modules/speech_to_text/speech_to_text.py | 7 +-- src/modules/textIO/output.py | 2 - 11 files changed, 14 insertions(+), 115 deletions(-) create mode 100644 src/core/events.py diff --git a/src/core/agent.py b/src/core/agent.py index 6325051..df2c182 100644 --- a/src/core/agent.py +++ b/src/core/agent.py @@ -1,26 +1,17 @@ import multiprocessing as mp import signal -import sys import threading -import time +from dataclasses import dataclass from multiprocessing.synchronize import Event -from typing import Dict, List, Any, Mapping +from typing import Any, Dict, Mapping + from src.modules.factory import ModuleFactory -import zmq - -from src.tools.logger import ( - LevelFilter, - QueueListener, - logging, - setup_log_listener, - setup_logger, -) -from dataclasses import dataclass -from .module import Module +from src.tools.logger import logging, setup_logger + +from .huri import HuriConfig from .zmq.control_channel import Command, Dealer from .zmq.event_proxy import EventProxy from .zmq.log_channel import LogPusher -from .huri import HuriConfig @dataclass @@ -154,7 +145,7 @@ def _start_module( module.set_custom_logger(logger) def handle_sigint(signum, frame): - logger.info(f"Ctrl+C ignored in child module") + logger.info("Ctrl+C ignored in child module") signal.signal(signal.SIGINT, handle_sigint) diff --git a/src/core/events.py b/src/core/events.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/huri.py b/src/core/huri.py index b9eaab4..18f52f3 100644 --- a/src/core/huri.py +++ b/src/core/huri.py @@ -1,16 +1,9 @@ -import multiprocessing as mp +import sys import threading -import time from dataclasses import dataclass from typing import Dict -import sys -from src.tools.logger import ( - LevelFilter, - QueueListener, - logging, - setup_log_listener, - setup_logger, -) + +from src.tools.logger import setup_logger from .zmq.control_channel import Router from .zmq.event_proxy import EventProxy diff --git a/src/core/module.py b/src/core/module.py index 56bc37f..4be1246 100644 --- a/src/core/module.py +++ b/src/core/module.py @@ -146,7 +146,7 @@ def stop_module(self) -> None: except Exception as e: self.logger.error(f"Error terminating ZMQ context: {e}") - self.logger.info(f"Module stopped gracefully.") + self.logger.info("Module stopped gracefully.") def run_module(self, stop_event: Event = None) -> None: """Child modules override this instead of run(). Default: idle wait.""" diff --git a/src/core/shell.py b/src/core/shell.py index 0452c4a..6473357 100644 --- a/src/core/shell.py +++ b/src/core/shell.py @@ -1,6 +1,4 @@ import cmd -import logging -import sys from src.core.huri import HuRI from src.core.zmq.control_channel import Command @@ -31,65 +29,3 @@ def do_exit(self, arg) -> None: self.huri.router.send_commands(Command("EXIT", [])) print("Bye !") return True - - # def do_log(self, arg) -> None: - # """ - # Usage: - # log status -> display log levels - # log -> set global root level (DEBUG/INFO/WARNING/ERROR/CRITICAL), has priority over custom module level - # log -> set custom per-module level (module name e.g. STT) - # log all -> set all levels (DEBUG/INFO/WARNING/ERROR/CRITICAL) - # """ - # parts = arg.strip().split() - # if not parts: - # print("Missing arguments. Type 'help log' for usage.") - # return - - # level_map = { - # "DEBUG": logging.DEBUG, - # "INFO": logging.INFO, - # "WARNING": logging.WARNING, - # "ERROR": logging.ERROR, - # "CRITICAL": logging.CRITICAL, - # } - - # try: - # if parts[0].lower() == "status": - # self.huri.lo.log_status() - # return - - # if len(parts) == 1: - # # Set root level - # level = level_map.get(parts[0].upper()) - # if level is None: - # print(f"Unknown level: {parts[0]}") - # return - # self.huri.lo.set_root_log_level(level) - # print(f"Root log level set to {parts[0].upper()}") - - # elif len(parts) == 2: - # level = level_map.get(parts[1].upper()) - # if level is None: - # print(f"Unknown level: {parts[1]}") - # return - # if parts[0].lower() == "all": - # self.huri.lo.set_log_levels(level) - # print(f"All log levels set to {parts[1].upper()}") - # else: - # module = parts[0] - # self.huri.lo.set_log_level(module, level) - # print(f"{module} log level set to {parts[1].upper()}") - - # else: - # print("Invalid arguments. Type 'help log' for usage.") - # except Exception as e: - # print(f"Error setting log level: {e}") - - # def do_stdin(self, arg) -> None: - # "Will disable shell and get lines from stdin to send as 'text.in' event. Exit with CTRL+D." - # print("Press CTRL+D to exit...\n>> ", end="") - # data = sys.stdin.readline() - # while data != "": - # self.huri.publish("text.in", data) - # print(">> ", end="") - # data = sys.stdin.readline() diff --git a/src/core/zmq/log_channel.py b/src/core/zmq/log_channel.py index f183b69..6eb2a8e 100644 --- a/src/core/zmq/log_channel.py +++ b/src/core/zmq/log_channel.py @@ -1,7 +1,5 @@ import json -import signal import time -from dataclasses import asdict, dataclass from typing import Any, Dict, Optional import zmq diff --git a/src/emotional_hub/input_analysis.py b/src/emotional_hub/input_analysis.py index 2605010..7391578 100644 --- a/src/emotional_hub/input_analysis.py +++ b/src/emotional_hub/input_analysis.py @@ -1,5 +1,5 @@ -import torch import numpy as np +import torch from transformers import AutoModelForAudioClassification, Wav2Vec2FeatureExtractor MODEL_NAME = "superb/hubert-large-superb-er" diff --git a/src/modules/rag/mode_controller.py b/src/modules/rag/mode_controller.py index 66ca700..23ed55c 100644 --- a/src/modules/rag/mode_controller.py +++ b/src/modules/rag/mode_controller.py @@ -1,17 +1,5 @@ -import json -import pathlib from enum import Enum -from langchain.chains import create_retrieval_chain -from langchain.chains.combine_documents import create_stuff_documents_chain -from langchain.text_splitter import RecursiveCharacterTextSplitter -from langchain_chroma import Chroma -from langchain_community.document_loaders import TextLoader -from langchain_core.prompts import ChatPromptTemplate -from langchain_ollama.embeddings import OllamaEmbeddings -from langchain_ollama.llms import OllamaLLM -from langgraph.checkpoint.memory import MemorySaver - from src.core.module import Module diff --git a/src/modules/rag/rag.py b/src/modules/rag/rag.py index 639d4a2..f2fc736 100644 --- a/src/modules/rag/rag.py +++ b/src/modules/rag/rag.py @@ -6,11 +6,11 @@ from langchain.text_splitter import RecursiveCharacterTextSplitter from langchain_chroma import Chroma from langchain_community.document_loaders import TextLoader +from langchain_core.documents import Document from langchain_core.prompts import ChatPromptTemplate from langchain_ollama.embeddings import OllamaEmbeddings from langchain_ollama.llms import OllamaLLM from langgraph.checkpoint.memory import MemorySaver -from langchain_core.documents import Document from src.core.module import Module diff --git a/src/modules/speech_to_text/speech_to_text.py b/src/modules/speech_to_text/speech_to_text.py index a833172..a086a2c 100644 --- a/src/modules/speech_to_text/speech_to_text.py +++ b/src/modules/speech_to_text/speech_to_text.py @@ -1,15 +1,10 @@ -import io import queue import threading -import time -from typing import Callable, List, Optional import numpy as np -import sounddevice as sd -import soundfile as sf import whisper -from src.core.module import Event, Module +from src.core.module import Module class SpeechToText(Module): diff --git a/src/modules/textIO/output.py b/src/modules/textIO/output.py index 0954e03..c68ca76 100644 --- a/src/modules/textIO/output.py +++ b/src/modules/textIO/output.py @@ -1,5 +1,3 @@ -import sys -import time from src.core.module import Module From ff328bfc01d8dd79202f3b1ddff25ac33471c7bb Mon Sep 17 00:00:00 2001 From: Popochounet Date: Sun, 4 Jan 2026 13:28:05 +0100 Subject: [PATCH 42/42] remove(mode_contoller): useless comments --- src/modules/rag/mode_controller.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/modules/rag/mode_controller.py b/src/modules/rag/mode_controller.py index 23ed55c..9ed0606 100644 --- a/src/modules/rag/mode_controller.py +++ b/src/modules/rag/mode_controller.py @@ -15,12 +15,6 @@ def __init__(self, default_mode: Modes = Modes.LLM): self.mode = default_mode def switchMode(self, mode: str) -> None: - # if mode == "llm": - # self.mode = Modes.LLM - # elif mode == "context": - # self.mode = Modes.CONTEXT - # elif mode == "rag": - # self.mode = Modes.RAG self.mode = mode def processTextInput(self, text: str):