diff --git a/src/robocoop_backend/robocoop_backend.egg-info/SOURCES.txt b/src/robocoop_backend/robocoop_backend.egg-info/SOURCES.txt index d0f3eb4..ff1fdbb 100644 --- a/src/robocoop_backend/robocoop_backend.egg-info/SOURCES.txt +++ b/src/robocoop_backend/robocoop_backend.egg-info/SOURCES.txt @@ -23,6 +23,9 @@ robocoop_backend/modules/audit/audit_logger.py robocoop_backend/modules/audit/audit_service.py robocoop_backend/modules/audit/event_formatter.py robocoop_backend/modules/audit/sinks.py +robocoop_backend/modules/navigation/__init__.py +robocoop_backend/modules/navigation/waypoint.py +robocoop_backend/modules/navigation/waypoint_store.py robocoop_backend/modules/robot/__init__.py robocoop_backend/modules/robot/rgb_chenillard.py robocoop_backend/modules/robot/state_store.py diff --git a/src/robocoop_backend/robocoop_backend/adapters/base_adapter.py b/src/robocoop_backend/robocoop_backend/adapters/base_adapter.py index 4c3979d..5ac0090 100644 --- a/src/robocoop_backend/robocoop_backend/adapters/base_adapter.py +++ b/src/robocoop_backend/robocoop_backend/adapters/base_adapter.py @@ -1,6 +1,6 @@ import logging from abc import ABC, abstractmethod -from typing import Any, Dict +from robocoop_backend.modules.navigation.waypoint import Waypoint logger = logging.getLogger(__name__) @@ -34,3 +34,13 @@ def send_velocity(self, command: TeleopCommand) -> None: def emergency_stop(self) -> None: """Trigger emergency stop.""" ... + + @abstractmethod + def send_goal(self, waypoint: Waypoint) -> None: + """Send navigation goal (action Nav2 NavigateToPose).""" + ... + + @abstractmethod + def cancel_goal(self) -> None: + """Cancel in-progress navigation.""" + ... diff --git a/src/robocoop_backend/robocoop_backend/adapters/mock_adapter.py b/src/robocoop_backend/robocoop_backend/adapters/mock_adapter.py index 22ea3c6..97e0d2a 100644 --- a/src/robocoop_backend/robocoop_backend/adapters/mock_adapter.py +++ b/src/robocoop_backend/robocoop_backend/adapters/mock_adapter.py @@ -1,12 +1,12 @@ +import asyncio import logging +import math from robocoop_backend.adapters.base_adapter import RobotAdapter from robocoop_backend.modules.robot.teleop import TeleopCommand logger = logging.getLogger(__name__) -logger = logging.getLogger(__name__) - class MockRobotAdapter(RobotAdapter): def __init__( @@ -22,6 +22,8 @@ def __init__( self.max_linear_y = max_linear_y self.max_angular_z = max_angular_z self.last_velocity_command = None + self.navigation_service = None + self._nav_task = None async def connect(self) -> bool: return True @@ -34,7 +36,6 @@ def is_connected(self) -> bool: def send_velocity(self, command: TeleopCommand) -> None: self.last_velocity_command = command.to_dict() - logger.info( "[MOCK] teleop.move linear_x=%s linear_y=%s angular_z=%s", command.linear_x, @@ -44,3 +45,43 @@ def send_velocity(self, command: TeleopCommand) -> None: def emergency_stop(self) -> None: logger.warning("[MOCK] emergency_stop") + + def send_goal(self, waypoint) -> None: + logger.info("[MOCK] nav.goto -> %s (x=%.2f y=%.2f)", waypoint.name, waypoint.x, waypoint.y) + self._cancel_sim() + try: + self._nav_task = asyncio.get_running_loop().create_task( + self._simulate_navigation(waypoint) + ) + except RuntimeError: + logger.warning("[MOCK] pas de boucle asyncio, nav non simulée") + + def cancel_goal(self) -> None: + logger.warning("[MOCK] nav.cancel") + self._cancel_sim() + if self.navigation_service: + self.navigation_service.on_nav_result(False, {"reason": "cancelled"}) + + def _cancel_sim(self) -> None: + if self._nav_task and not self._nav_task.done(): + self._nav_task.cancel() + + async def _simulate_navigation(self, waypoint, steps: int = 10, step_delay: float = 0.4): + total = math.hypot(waypoint.x, waypoint.y) + try: + for i in range(1, steps + 1): + await asyncio.sleep(step_delay) + t = i / steps + if self.navigation_service: + self.navigation_service.on_nav_feedback({ + "distance_remaining": round(total * (1 - t), 2), + "eta_seconds": round((steps - i) * step_delay, 1), + "navigation_time": round(i * step_delay, 1), + "recoveries": 0, + "current_x": round(waypoint.x * t, 2), + "current_y": round(waypoint.y * t, 2), + }) + if self.navigation_service: + self.navigation_service.on_nav_result(True) + except asyncio.CancelledError: + raise \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/adapters/rosbridge_adapter.py b/src/robocoop_backend/robocoop_backend/adapters/rosbridge_adapter.py index 6bd87f6..4cb8f43 100644 --- a/src/robocoop_backend/robocoop_backend/adapters/rosbridge_adapter.py +++ b/src/robocoop_backend/robocoop_backend/adapters/rosbridge_adapter.py @@ -85,6 +85,9 @@ def __init__( on_reconnected=self._on_bridge_reconnected, on_disconnected=self._on_bridge_disconnected, ) + nav_action: str = "/navigate_to_pose", + nav_action_type: str = "nav2_msgs/action/NavigateToPose", + nav_frame_id: str = "map", async def connect(self) -> bool: if not await self._client.connect(): @@ -253,4 +256,58 @@ async def _ping_loop(self) -> None: logger.error(f"Ping loop error: {e}") def is_connected(self) -> bool: - return self._client.is_connected() \ No newline at end of file + return self._client.is_connected() + + def send_goal(self, waypoint) -> None: + goal_id = f"nav_goal_{int(time.time() * 1000)}" + self._active_goal_id = goal_id + args = {"pose": waypoint.to_pose_stamped(self.nav_frame_id), "behavior_tree": ""} + self._schedule(self._client.send_action_goal( + action=self.nav_action, + action_type=self.nav_action_type, + args=args, + goal_id=goal_id, + on_feedback=self._on_nav_feedback_raw, + on_result=self._on_nav_result_raw, + )) + + def cancel_goal(self) -> None: + if self._active_goal_id: + self._schedule(self._client.cancel_action_goal(self.nav_action, self._active_goal_id)) + + def _schedule(self, coro) -> None: + try: + asyncio.get_running_loop().create_task(coro) + except RuntimeError: + logger.error("No asyncio loop for navigation action") + + @staticmethod + def _dur_to_s(d) -> float: + d = d or {} + return d.get("sec", 0) + d.get("nanosec", 0) / 1e9 + + def _on_nav_feedback_raw(self, values: Dict[str, Any]) -> None: + if not self.navigation_service: + return + pos = (values.get("current_pose") or {}).get("pose", {}).get("position", {}) + self.navigation_service.on_nav_feedback({ + "distance_remaining": float(values.get("distance_remaining", 0.0)), + "eta_seconds": self._dur_to_s(values.get("estimated_time_remaining")), + "navigation_time": self._dur_to_s(values.get("navigation_time")), + "recoveries": int(values.get("number_of_recoveries", 0)), + "current_x": float(pos.get("x", 0.0)), + "current_y": float(pos.get("y", 0.0)), + }) + + def _on_nav_result_raw(self, msg: Dict[str, Any]) -> None: + if not self.navigation_service: + return + status = msg.get("status", (msg.get("values") or {}).get("status")) + values = msg.get("values") or {} + self._active_goal_id = None + self.navigation_service.on_nav_result( + success=(status == 4), # 4 = SUCCEEDED + detail={"status": status, + "error_code": values.get("error_code"), + "error_msg": values.get("error_msg", "")}, + ) \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/adapters/rosbridge_client.py b/src/robocoop_backend/robocoop_backend/adapters/rosbridge_client.py index 9791bd3..281381e 100644 --- a/src/robocoop_backend/robocoop_backend/adapters/rosbridge_client.py +++ b/src/robocoop_backend/robocoop_backend/adapters/rosbridge_client.py @@ -40,6 +40,8 @@ def __init__( self._pending_pings: Dict[str, asyncio.Future[None]] = {} self._ping_seq = 0 + self._action_callbacks: Dict[str, Dict[str, Callable]] = {} + async def connect(self) -> bool: if not await self._connect_ws(): return False @@ -60,6 +62,7 @@ async def disconnect(self) -> None: if not future.done(): future.cancel() self._pending_pings.clear() + self._action_callbacks.clear() if self._websocket: await self._websocket.close() @@ -98,6 +101,36 @@ async def subscribe(self, topic: str, msg_type: str, callback: Callable) -> None logger.info(f"Subscribed to {topic}") + async def send_action_goal( + self, action: str, action_type: str, args: Dict[str, Any], goal_id: str, + on_feedback: Optional[Callable] = None, on_result: Optional[Callable] = None, + ) -> None: + if not self._websocket: + logger.error("Cannot send action goal: not connected") + return + self._action_callbacks[goal_id] = {"feedback": on_feedback, "result": on_result} + await self._send_json({ + "op": "send_action_goal", + "id": goal_id, + "action": action, + "action_type": action_type, + "args": args, + "feedback": True, + }) + + async def cancel_action_goal(self, action: str, goal_id: str) -> None: + await self._send_json({"op": "cancel_action_goal", "id": goal_id, "action": action}) + + def _dispatch_action(self, data: Dict[str, Any], kind: str, payload: Any) -> None: + callbacks = self._action_callbacks.get(data.get("id")) + if not callbacks: + return + cb = callbacks.get(kind) + if cb: + cb(payload) + if kind == "result": + self._action_callbacks.pop(data.get("id"), None) + def is_connected(self) -> bool: return self._is_connected and self._websocket is not None @@ -188,6 +221,14 @@ def _handle_message(self, data: Dict[str, Any]) -> None: if self._handle_pending_ping(data): return + op = data.get("op") + if op == "action_feedback": + self._dispatch_action(data, "feedback", data.get("values", {})) + return + if op == "action_result": + self._dispatch_action(data, "result", data) + return + topic = data.get("topic") if topic in self._subscribers: self._subscribers[topic](data.get("msg", {})) diff --git a/src/robocoop_backend/robocoop_backend/app/backend_context.py b/src/robocoop_backend/robocoop_backend/app/backend_context.py index 1a84adc..032addc 100644 --- a/src/robocoop_backend/robocoop_backend/app/backend_context.py +++ b/src/robocoop_backend/robocoop_backend/app/backend_context.py @@ -8,6 +8,9 @@ from robocoop_backend.modules.robot.state_store import RobotStateStore from robocoop_backend.modules.robot.telemetry_service import TelemetryService from robocoop_backend.modules.robot.teleop_watchdog import TeleopWatchdog +from robocoop_backend.modules.navigation.waypoint_store import WaypointStore +from robocoop_backend.modules.navigation.map_store import MapStore +from robocoop_backend.modules.navigation.navigation_service import NavigationService logger = logging.getLogger(__name__) @@ -58,6 +61,18 @@ def __init__(self, config: Dict[str, Any]): logger.info(f"Adapter: {type(self.adapter).__name__}") + self.waypoint_store = WaypointStore( + persist_path=config.get("waypoints_path", "waypoints.json") + ) + self.map_store = MapStore() + self.navigation_service = NavigationService( + waypoint_store=self.waypoint_store, + adapter=self.adapter, + audit_service=self.audit_service, + ) + + self.adapter.navigation_service = self.navigation_service + async def connect(self) -> bool: """Initialize and connect all services.""" try: @@ -84,4 +99,5 @@ async def disconnect(self) -> None: def set_websocket_handler(self, handler) -> None: """Register WebSocket handler for broadcasting events.""" self.telemetry_service.websocket_handler = handler - self.audit_service.websocket_handler = handler \ No newline at end of file + self.audit_service.websocket_handler = handler + self.navigation_service.websocket_handler = handler \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/app/contracts.py b/src/robocoop_backend/robocoop_backend/app/contracts.py index 4cc40db..107756f 100644 --- a/src/robocoop_backend/robocoop_backend/app/contracts.py +++ b/src/robocoop_backend/robocoop_backend/app/contracts.py @@ -32,6 +32,13 @@ get_health {"type": "get_health"} +waypoint.save {"type": "waypoint.save", "data": {"name": "Chambre B", "x": 2.0, "y": 1.5, "yaw": 0.0, "map_id": "default"}} +waypoint.list {"type": "waypoint.list", "map_id": "default"} # map_id optionnel +waypoint.delete {"type": "waypoint.delete", "data": {"id": "uuid"}} +nav.goto {"type": "nav.goto", "data": {"id": "uuid"}} # ou {"name": "Chambre B"} +nav.cancel {"type": "nav.cancel"} +get_map {"type": "get_map"} + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ BACKEND → FRONTEND ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -156,6 +163,12 @@ MSG_TELEOP_MOVE = "teleop.move" MSG_EMERGENCY_STOP = "emergency_stop" MSG_GET_HEALTH = "get_health" +MSG_WAYPOINT_SAVE = "waypoint.save" +MSG_WAYPOINT_LIST = "waypoint.list" +MSG_WAYPOINT_DELETE = "waypoint.delete" +MSG_NAV_GOTO = "nav.goto" +MSG_NAV_CANCEL = "nav.cancel" +MSG_GET_MAP = "get_map" # Backend → Frontend MSG_COMMAND_ACK = "command_ack" @@ -167,3 +180,10 @@ MSG_STATE_UPDATED = "robot_state_updated" MSG_ACTIVITY_EVENT = "activity_event" MSG_HEALTH_RESPONSE = "health_response" +MSG_WAYPOINT_SAVED = "waypoint.saved" +MSG_WAYPOINT_LIST_RESPONSE = "waypoint_list" +MSG_MAP_RESPONSE = "map_response" +MSG_NAV_STARTED = "nav.started" +MSG_NAV_PROGRESS = "nav.progress" +MSG_NAV_ARRIVED = "nav.arrived" +MSG_NAV_FAILED = "nav.failed" \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/app/handlers/__init__.py b/src/robocoop_backend/robocoop_backend/app/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/app/handlers/map.py b/src/robocoop_backend/robocoop_backend/app/handlers/map.py new file mode 100644 index 0000000..2012b29 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/app/handlers/map.py @@ -0,0 +1,15 @@ +from robocoop_backend.app.contracts import MSG_GET_MAP, MSG_MAP_RESPONSE +from robocoop_backend.app.handlers.utils import send_message + + +class MapHandlers: + """Handlers for map metadata.""" + + def __init__(self, context): + self.context = context + + def routes(self): + return {MSG_GET_MAP: self.get_map} + + async def get_map(self, websocket, message: dict) -> None: + await send_message(websocket, MSG_MAP_RESPONSE, self.context.map_store.to_dict()) diff --git a/src/robocoop_backend/robocoop_backend/app/handlers/navigation.py b/src/robocoop_backend/robocoop_backend/app/handlers/navigation.py new file mode 100644 index 0000000..084ce87 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/app/handlers/navigation.py @@ -0,0 +1,28 @@ +from robocoop_backend.app.contracts import MSG_NAV_CANCEL, MSG_NAV_GOTO +from robocoop_backend.app.handlers.utils import send_command_error + + +class NavigationHandlers: + """Handlers for autonomous navigation commands.""" + + def __init__(self, context): + self.context = context + + def routes(self): + return { + MSG_NAV_GOTO: self.goto, + MSG_NAV_CANCEL: self.cancel, + } + + async def goto(self, websocket, message: dict) -> None: + data = message.get("data") or {} + try: + self.context.navigation_service.go_to( + waypoint_id=data.get("id"), + name=data.get("name"), + ) + except ValueError as e: + await send_command_error(websocket, MSG_NAV_GOTO, str(e)) + + async def cancel(self, websocket, message: dict) -> None: + self.context.navigation_service.cancel() diff --git a/src/robocoop_backend/robocoop_backend/app/handlers/system.py b/src/robocoop_backend/robocoop_backend/app/handlers/system.py new file mode 100644 index 0000000..44956fa --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/app/handlers/system.py @@ -0,0 +1,56 @@ +import os + +from robocoop_backend.app.contracts import ( + MSG_ACTIVITY_HISTORY, + MSG_GET_ACTIVITY, + MSG_GET_HEALTH, + MSG_GET_STATE, + MSG_HEALTH_RESPONSE, + MSG_PING, + MSG_PONG, + MSG_STATE_RESPONSE, +) +from robocoop_backend.app.handlers.utils import send_message + + +class SystemHandlers: + """Handlers for generic/system WebSocket messages.""" + + def __init__(self, context): + self.context = context + + def routes(self): + return { + MSG_PING: self.ping, + MSG_GET_STATE: self.get_state, + MSG_GET_ACTIVITY: self.get_activity, + MSG_GET_HEALTH: self.get_health, + } + + async def ping(self, websocket, message: dict) -> None: + await send_message(websocket, MSG_PONG) + + async def get_state(self, websocket, message: dict) -> None: + state = self.context.robot_state_store.to_dict() + await send_message(websocket, MSG_STATE_RESPONSE, state) + + async def get_activity(self, websocket, message: dict) -> None: + history = self.context.audit_service.get_history( + limit=int(message.get("limit", 50)) + ) + await send_message(websocket, MSG_ACTIVITY_HISTORY, history) + + async def get_health(self, websocket, message: dict) -> None: + state = self.context.robot_state_store.to_dict() + adapter_type = type(self.context.adapter).__name__ + environment = os.environ.get("ROBOCOOP_ENV", "unknown") + await send_message( + websocket, + MSG_HEALTH_RESPONSE, + { + "status": "ok", + "adapter": adapter_type, + "environment": environment, + "robot_state": state, + }, + ) diff --git a/src/robocoop_backend/robocoop_backend/app/handlers/teleop.py b/src/robocoop_backend/robocoop_backend/app/handlers/teleop.py new file mode 100644 index 0000000..bbad209 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/app/handlers/teleop.py @@ -0,0 +1,64 @@ +import logging + +from robocoop_backend.app.contracts import ( + MSG_COMMAND_ACK, + MSG_EMERGENCY_STOP, + MSG_TELEOP_MOVE, +) +from robocoop_backend.app.handlers.utils import send_command_error, send_message +from robocoop_backend.modules.audit.audit_event import AuditEvent +from robocoop_backend.modules.robot.teleop import parse_teleop_command + +logger = logging.getLogger(__name__) + + +class TeleopHandlers: + """Handlers for manual robot control commands.""" + + def __init__(self, context): + self.context = context + + def routes(self): + return { + MSG_TELEOP_MOVE: self.move, + MSG_EMERGENCY_STOP: self.emergency_stop, + } + + async def move(self, websocket, message: dict) -> None: + try: + max_linear_x = self._numeric_adapter_attr("max_linear_x", 0.25) + max_linear_y = self._numeric_adapter_attr("max_linear_y", 0.25) + max_angular_z = self._numeric_adapter_attr("max_angular_z", 0.8) + + command = parse_teleop_command( + message.get("data"), + max_linear_x=max_linear_x, + max_linear_y=max_linear_y, + max_angular_z=max_angular_z, + ) + + self.context.adapter.send_velocity(command) + + watchdog = getattr(self.context, "teleop_watchdog", None) + if watchdog is not None: + watchdog.notify_command(command) + + await send_message( + websocket, + MSG_COMMAND_ACK, + {"command": MSG_TELEOP_MOVE, **command.to_dict()}, + ) + + except (TypeError, ValueError) as e: + await send_command_error(websocket, MSG_TELEOP_MOVE, str(e)) + + async def emergency_stop(self, websocket, message: dict) -> None: + logger.warning("Emergency stop via WebSocket") + self.context.adapter.emergency_stop() + self.context.audit_service.record( + AuditEvent(action=MSG_EMERGENCY_STOP, actor="dashboard", payload={}) + ) + + def _numeric_adapter_attr(self, name: str, default: float) -> float: + value = getattr(self.context.adapter, name, default) + return value if isinstance(value, (int, float)) else default diff --git a/src/robocoop_backend/robocoop_backend/app/handlers/utils.py b/src/robocoop_backend/robocoop_backend/app/handlers/utils.py new file mode 100644 index 0000000..06210ed --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/app/handlers/utils.py @@ -0,0 +1,19 @@ +import json +from typing import Any + + +async def send_message(websocket, msg_type: str, data: Any | None = None) -> None: + payload: dict[str, Any] = {"type": msg_type} + if data is not None: + payload["data"] = data + await websocket.send(json.dumps(payload)) + + +async def send_command_error(websocket, command: str, error: str) -> None: + from robocoop_backend.app.contracts import MSG_COMMAND_ERROR + + await send_message( + websocket, + MSG_COMMAND_ERROR, + {"command": command, "error": error}, + ) diff --git a/src/robocoop_backend/robocoop_backend/app/handlers/waypoints.py b/src/robocoop_backend/robocoop_backend/app/handlers/waypoints.py new file mode 100644 index 0000000..9e5470b --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/app/handlers/waypoints.py @@ -0,0 +1,55 @@ +from robocoop_backend.app.contracts import ( + MSG_WAYPOINT_DELETE, + MSG_WAYPOINT_LIST, + MSG_WAYPOINT_LIST_RESPONSE, + MSG_WAYPOINT_SAVE, + MSG_WAYPOINT_SAVED, +) +from robocoop_backend.app.handlers.utils import send_command_error, send_message +from robocoop_backend.modules.navigation.waypoint import Waypoint + + +class WaypointHandlers: + """Handlers for waypoint persistence and listing.""" + + def __init__(self, context): + self.context = context + + def routes(self): + return { + MSG_WAYPOINT_SAVE: self.save, + MSG_WAYPOINT_LIST: self.list, + MSG_WAYPOINT_DELETE: self.delete, + } + + async def save(self, websocket, message: dict) -> None: + data = message.get("data") or {} + try: + waypoint = Waypoint( + name=str(data["name"]), + x=float(data["x"]), + y=float(data["y"]), + yaw=float(data.get("yaw", 0.0)), + map_id=data.get("map_id", "default"), + ) + except (KeyError, TypeError, ValueError) as e: + await send_command_error(websocket, MSG_WAYPOINT_SAVE, str(e)) + return + + self.context.waypoint_store.add(waypoint) + await send_message(websocket, MSG_WAYPOINT_SAVED, waypoint.to_dict()) + + async def list(self, websocket, message: dict) -> None: + waypoints = [ + waypoint.to_dict() + for waypoint in self.context.waypoint_store.list(message.get("map_id")) + ] + await send_message(websocket, MSG_WAYPOINT_LIST_RESPONSE, waypoints) + + async def delete(self, websocket, message: dict) -> None: + self.context.waypoint_store.delete((message.get("data") or {}).get("id")) + waypoints = [ + waypoint.to_dict() + for waypoint in self.context.waypoint_store.list() + ] + await send_message(websocket, MSG_WAYPOINT_LIST_RESPONSE, waypoints) diff --git a/src/robocoop_backend/robocoop_backend/app/message_router.py b/src/robocoop_backend/robocoop_backend/app/message_router.py new file mode 100644 index 0000000..14f9434 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/app/message_router.py @@ -0,0 +1,40 @@ +import logging +from collections.abc import Awaitable, Callable +from typing import Any + +from robocoop_backend.app.handlers.map import MapHandlers +from robocoop_backend.app.handlers.navigation import NavigationHandlers +from robocoop_backend.app.handlers.system import SystemHandlers +from robocoop_backend.app.handlers.teleop import TeleopHandlers +from robocoop_backend.app.handlers.waypoints import WaypointHandlers + +logger = logging.getLogger(__name__) + +MessageHandler = Callable[[Any, dict], Awaitable[None]] + + +class MessageRouter: + """Route WebSocket messages to domain-specific handlers.""" + + def __init__(self, context): + self.context = context + self.routes: dict[str, MessageHandler] = {} + + for group in ( + SystemHandlers(context), + TeleopHandlers(context), + WaypointHandlers(context), + NavigationHandlers(context), + MapHandlers(context), + ): + self.routes.update(group.routes()) + + async def dispatch(self, websocket, message: dict) -> None: + msg_type = message.get("type") + handler = self.routes.get(msg_type) + + if handler is None: + logger.debug(f"Unhandled message type: {msg_type}") + return + + await handler(websocket, message) diff --git a/src/robocoop_backend/robocoop_backend/app/websocket_handler.py b/src/robocoop_backend/robocoop_backend/app/websocket_handler.py index b67a1c2..63a4d4d 100644 --- a/src/robocoop_backend/robocoop_backend/app/websocket_handler.py +++ b/src/robocoop_backend/robocoop_backend/app/websocket_handler.py @@ -1,37 +1,23 @@ import json import logging -import os from typing import Callable, Set import websockets -from robocoop_backend.app.contracts import ( - MSG_ACTIVITY_EVENT, - MSG_ACTIVITY_HISTORY, - MSG_COMMAND_ACK, - MSG_COMMAND_ERROR, - MSG_EMERGENCY_STOP, - MSG_GET_ACTIVITY, - MSG_GET_HEALTH, - MSG_GET_STATE, - MSG_HEALTH_RESPONSE, - MSG_INITIAL_STATE, - MSG_PING, - MSG_PONG, - MSG_STATE_RESPONSE, - MSG_STATE_UPDATED, - MSG_TELEOP_MOVE, -) -from robocoop_backend.modules.audit.audit_event import AuditEvent -from robocoop_backend.modules.robot.teleop import parse_teleop_command +from robocoop_backend.app.contracts import MSG_ACTIVITY_HISTORY, MSG_INITIAL_STATE +from robocoop_backend.app.handlers.utils import send_message +from robocoop_backend.app.message_router import MessageRouter logger = logging.getLogger(__name__) class WebSocketHandler: + """Manage WebSocket clients and delegate application messages to a router.""" + def __init__(self, context): self.context = context self.clients: Set = set() + self.router = MessageRouter(context) async def register(self, websocket) -> None: self.clients.add(websocket) @@ -45,13 +31,13 @@ async def unregister(self, websocket) -> None: async def _send_initial_state(self, websocket) -> None: try: state = self.context.robot_state_store.to_dict() - await websocket.send(json.dumps({"type": MSG_INITIAL_STATE, "data": state})) + await send_message(websocket, MSG_INITIAL_STATE, state) except Exception as e: logger.error(f"Error sending initial state: {e}") try: history = self.context.audit_service.get_history(limit=50) - await websocket.send(json.dumps({"type": MSG_ACTIVITY_HISTORY, "data": history})) + await send_message(websocket, MSG_ACTIVITY_HISTORY, history) except Exception as e: logger.error(f"Error sending activity history: {e}") @@ -72,103 +58,10 @@ async def broadcast(self, message: dict) -> None: async def handle_message(self, websocket, message: dict) -> None: try: - msg_type = message.get("type") - - if msg_type == MSG_PING: - await websocket.send(json.dumps({"type": MSG_PONG})) - - elif msg_type == MSG_GET_STATE: - state = self.context.robot_state_store.to_dict() - await websocket.send(json.dumps({"type": MSG_STATE_RESPONSE, "data": state})) - - elif msg_type == MSG_GET_ACTIVITY: - history = self.context.audit_service.get_history( - limit=int(message.get("limit", 50)) - ) - await websocket.send(json.dumps({"type": MSG_ACTIVITY_HISTORY, "data": history})) - - elif msg_type == MSG_GET_HEALTH: - state = self.context.robot_state_store.to_dict() - adapter_type = type(self.context.adapter).__name__ - environment = os.environ.get("ROBOCOOP_ENV", "unknown") - await websocket.send(json.dumps({ - "type": MSG_HEALTH_RESPONSE, - "data": { - "status": "ok", - "adapter": adapter_type, - "environment": environment, - "robot_state": state, - }, - })) - - elif msg_type == MSG_TELEOP_MOVE: - await self._handle_teleop_move(websocket, message) - - elif msg_type == MSG_EMERGENCY_STOP: - logger.warning("Emergency stop via WebSocket") - self.context.adapter.emergency_stop() - self.context.audit_service.record( - AuditEvent(action=MSG_EMERGENCY_STOP, actor="dashboard", payload={}) - ) - - else: - logger.debug(f"Unhandled message type: {msg_type}") - + await self.router.dispatch(websocket, message) except Exception as e: logger.error(f"Message error: {e}") - async def _handle_teleop_move(self, websocket, message: dict) -> None: - try: - raw_max_linear_x = getattr(self.context.adapter, "max_linear_x", 0.25) - raw_max_linear_y = getattr(self.context.adapter, "max_linear_y", 0.25) - raw_max_angular_z = getattr(self.context.adapter, "max_angular_z", 0.8) - - max_linear_x = ( - raw_max_linear_x - if isinstance(raw_max_linear_x, (int, float)) - else 0.25 - ) - max_linear_y = ( - raw_max_linear_y - if isinstance(raw_max_linear_y, (int, float)) - else 0.25 - ) - max_angular_z = ( - raw_max_angular_z - if isinstance(raw_max_angular_z, (int, float)) - else 0.8 - ) - - command = parse_teleop_command( - message.get("data"), - max_linear_x=max_linear_x, - max_linear_y=max_linear_y, - max_angular_z=max_angular_z, - ) - - self.context.adapter.send_velocity(command) - - watchdog = getattr(self.context, "teleop_watchdog", None) - if watchdog is not None: - watchdog.notify_command(command) - - await websocket.send(json.dumps({ - "type": MSG_COMMAND_ACK, - "data": { - "command": MSG_TELEOP_MOVE, - **command.to_dict(), - }, - })) - - except (TypeError, ValueError) as e: - await websocket.send(json.dumps({ - "type": MSG_COMMAND_ERROR, - "data": { - "command": MSG_TELEOP_MOVE, - "error": str(e), - }, - })) - def create_handler(context) -> Callable: handler_instance = WebSocketHandler(context) @@ -192,4 +85,4 @@ async def handler(websocket): await handler_instance.unregister(websocket) handler.instance = handler_instance - return handler \ No newline at end of file + return handler diff --git a/src/robocoop_backend/robocoop_backend/modules/navigation/__init__.py b/src/robocoop_backend/robocoop_backend/modules/navigation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/modules/navigation/map_store.py b/src/robocoop_backend/robocoop_backend/modules/navigation/map_store.py new file mode 100644 index 0000000..b0ade90 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/navigation/map_store.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass +from typing import Any, Dict, Optional + + +@dataclass +class MapMetadata: + map_id: str = "default" + resolution: float = 0.05 + width: int = 0 + height: int = 0 + origin_x: float = 0.0 + origin_y: float = 0.0 + image_url: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return { + "map_id": self.map_id, + "resolution": self.resolution, + "width": self.width, + "height": self.height, + "origin_x": self.origin_x, + "origin_y": self.origin_y, + "image_url": self.image_url, + } + + +class MapStore: + """PlaceHolder le temps d'avoir /map""" + + def __init__(self): + self._meta = MapMetadata() + + def get(self) -> MapMetadata: + return self._meta + + def set(self, meta: MapMetadata) -> None: + self._meta = meta + + def to_dict(self) -> Dict[str, Any]: + return self._meta.to_dict() \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/modules/navigation/navigation_service.py b/src/robocoop_backend/robocoop_backend/modules/navigation/navigation_service.py new file mode 100644 index 0000000..ac4ff27 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/navigation/navigation_service.py @@ -0,0 +1,89 @@ +import asyncio +import logging +from typing import Any, Dict, Optional + +from robocoop_backend.app.contracts import ( + MSG_NAV_ARRIVED, + MSG_NAV_FAILED, + MSG_NAV_PROGRESS, + MSG_NAV_STARTED, +) +from robocoop_backend.modules.audit.audit_event import AuditEvent +from robocoop_backend.modules.navigation.waypoint import Waypoint + +logger = logging.getLogger(__name__) + + +class NavigationService: + """Reçoit les ordres de nav, les passe à l'adapter, et relaie le feedback + vers le dashboard + l'audit. L'adapter rappelle on_nav_feedback / + on_nav_result, comme la télémétrie appelle on_telemetry_received.""" + + def __init__(self, waypoint_store, adapter, audit_service=None, websocket_handler=None): + self.waypoint_store = waypoint_store + self.adapter = adapter + self.audit_service = audit_service + self.websocket_handler = websocket_handler + self._active: Optional[Waypoint] = None + + # --- ordres du dashboard --- + def go_to(self, waypoint_id: Optional[str] = None, name: Optional[str] = None) -> Waypoint: + wp = self._resolve(waypoint_id, name) + if wp is None: + raise ValueError("waypoint introuvable") + + self._active = wp + self.adapter.send_goal(wp) + if self.audit_service: + self.audit_service.record(AuditEvent( + action="nav.started", actor="dashboard", + payload={"id": wp.id, "name": wp.name}, + )) + self._broadcast({"type": MSG_NAV_STARTED, "data": {"waypoint": wp.to_dict()}}) + return wp + + def cancel(self) -> None: + self.adapter.cancel_goal() + + # --- callbacks de l'adapter --- + def on_nav_feedback(self, feedback: Dict[str, Any]) -> None: + data = {"waypoint": self._active.to_dict() if self._active else None, **feedback} + self._broadcast({"type": MSG_NAV_PROGRESS, "data": data}) + + def on_nav_result(self, success: bool, detail: Optional[Dict[str, Any]] = None) -> None: + wp, self._active = self._active, None + action = "nav.arrived" if success else "nav.failed" + if self.audit_service and wp: + self.audit_service.record(AuditEvent( + action=action, actor="system", + payload={"id": wp.id, "name": wp.name, **(detail or {})}, + )) + msg_type = MSG_NAV_ARRIVED if success else MSG_NAV_FAILED + data = {"waypoint": wp.to_dict() if wp else None, **(detail or {})} + self._broadcast({"type": msg_type, "data": data}) + + # --- internes --- + def _resolve(self, waypoint_id, name) -> Optional[Waypoint]: + if waypoint_id: + return self.waypoint_store.get(waypoint_id) + if name: + return self.waypoint_store.get_by_name(name) + return None + + def _broadcast(self, message: Dict[str, Any]) -> None: + if not self.websocket_handler: + return + try: + asyncio.create_task(self._async_broadcast(message)) + except RuntimeError: + pass + + async def _async_broadcast(self, message: Dict[str, Any]) -> None: + try: + handler = self.websocket_handler + if hasattr(handler, "instance"): + await handler.instance.broadcast(message) + elif hasattr(handler, "broadcast"): + await handler.broadcast(message) + except Exception as e: + logger.error("Nav broadcast error: %s", e) \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/modules/navigation/waypoint.py b/src/robocoop_backend/robocoop_backend/modules/navigation/waypoint.py new file mode 100644 index 0000000..68f6e8c --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/navigation/waypoint.py @@ -0,0 +1,59 @@ +import math +import uuid +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict + + +@dataclass(frozen=True) +class Waypoint: + """Un point nommé sur une carte (ex. « Chambre B »).""" + + name: str + x: float # en mètres, repère "map" + y: float + yaw: float = 0.0 # orientation finale en radian (0 = peu importe) + map_id: str = "default" + id: str = field(default_factory=lambda: str(uuid.uuid4())) + created_at: datetime = field(default_factory=datetime.now) + + # --- sérialisation --- + def to_dict(self) -> Dict[str, Any]: + return { + "id": self.id, + "name": self.name, + "x": self.x, + "y": self.y, + "yaw": self.yaw, + "map_id": self.map_id, + "created_at": self.created_at.isoformat(), + } + + @staticmethod + def from_dict(d: Dict[str, Any]) -> "Waypoint": + return Waypoint( + name=d["name"], + x=float(d["x"]), + y=float(d["y"]), + yaw=float(d.get("yaw", 0.0)), + map_id=d.get("map_id", "default"), + id=d.get("id", str(uuid.uuid4())), + created_at=( + datetime.fromisoformat(d["created_at"]) + if d.get("created_at") + else datetime.now() + ), + ) + + # --- message ROS --- + def to_pose_stamped(self, frame_id: str = "map") -> Dict[str, Any]: + # yaw seul -> quaternion (rotation autour de Z) + qz = math.sin(self.yaw / 2.0) + qw = math.cos(self.yaw / 2.0) + return { + "header": {"frame_id": frame_id, "stamp": {"sec": 0, "nanosec": 0}}, + "pose": { + "position": {"x": self.x, "y": self.y, "z": 0.0}, + "orientation": {"x": 0.0, "y": 0.0, "z": qz, "w": qw}, + }, + } \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/modules/navigation/waypoint_store.py b/src/robocoop_backend/robocoop_backend/modules/navigation/waypoint_store.py new file mode 100644 index 0000000..0f391c5 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/navigation/waypoint_store.py @@ -0,0 +1,67 @@ +import json +import logging +from pathlib import Path +from typing import Dict, List, Optional + +from robocoop_backend.modules.navigation.waypoint import Waypoint + +logger = logging.getLogger(__name__) + + +class WaypointStore: + """Stocke les points et les persiste dans un fichier JSON.""" + + def __init__(self, persist_path: Optional[str] = None): + self._waypoints: Dict[str, Waypoint] = {} + self._path = Path(persist_path) if persist_path else None + self._load() + + # --- CRUD --- + def add(self, waypoint: Waypoint) -> Waypoint: + self._waypoints[waypoint.id] = waypoint + self._save() + return waypoint + + def get(self, waypoint_id: str) -> Optional[Waypoint]: + return self._waypoints.get(waypoint_id) + + def get_by_name(self, name: str, map_id: str = "default") -> Optional[Waypoint]: + return next( + (w for w in self._waypoints.values() + if w.name == name and w.map_id == map_id), + None, + ) + + def list(self, map_id: Optional[str] = None) -> List[Waypoint]: + points = self._waypoints.values() + if map_id is not None: + points = [w for w in points if w.map_id == map_id] + return list(points) + + def delete(self, waypoint_id: str) -> bool: + existed = self._waypoints.pop(waypoint_id, None) is not None + if existed: + self._save() + return existed + + # --- persistance --- + def _load(self) -> None: + if not self._path or not self._path.exists(): + return + try: + data = json.loads(self._path.read_text(encoding="utf-8")) + self._waypoints = {w["id"]: Waypoint.from_dict(w) for w in data} + except Exception as e: + logger.error("Lecture des waypoints impossible: %s", e) + + def _save(self) -> None: + if not self._path: + return + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + data = [w.to_dict() for w in self._waypoints.values()] + self._path.write_text( + json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" + ) + except Exception as e: + logger.error("Écriture des waypoints impossible: %s", e) \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/tests/integration/test_navigation_pipeline.py b/src/robocoop_backend/robocoop_backend/tests/integration/test_navigation_pipeline.py new file mode 100644 index 0000000..4f86060 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/integration/test_navigation_pipeline.py @@ -0,0 +1,78 @@ +import asyncio +import functools +import json + +import pytest + + +class _FakeWS: + def __init__(self): + self.sent = [] + + async def send(self, raw): + self.sent.append(json.loads(raw)) + + +@pytest.fixture +def wired(tmp_path, monkeypatch): + from robocoop_backend.app.backend_context import BackendContext + from robocoop_backend.app.websocket_handler import create_handler + + ctx = BackendContext({ + "adapter_type": "mock", + "waypoints_path": str(tmp_path / "wp.json"), + }) + sim = ctx.adapter._simulate_navigation + monkeypatch.setattr(ctx.adapter, "_simulate_navigation", + functools.partial(sim, steps=3, step_delay=0.01)) + + handler = create_handler(ctx) + ctx.set_websocket_handler(handler) + ws = _FakeWS() + handler.instance.clients.add(ws) + return ctx, handler.instance, ws + + +def _types(ws): + return [m["type"] for m in ws.sent] + + +@pytest.mark.integration +class TestNavigationPipeline: + + async def test_save_then_goto_streams_progress(self, wired): + ctx, h, ws = wired + await h.handle_message(ws, { + "type": "waypoint.save", + "data": {"name": "Chambre B", "x": 2.0, "y": 1.5}, + }) + await h.handle_message(ws, {"type": "nav.goto", "data": {"name": "Chambre B"}}) + await ctx.adapter._nav_task + await asyncio.sleep(0.02) + + types = _types(ws) + assert "waypoint.saved" in types + assert "nav.started" in types + assert types.count("nav.progress") >= 1 + assert "nav.arrived" in types + + async def test_arrived_carries_waypoint_name(self, wired): + ctx, h, ws = wired + await h.handle_message(ws, { + "type": "waypoint.save", + "data": {"name": "Chambre B", "x": 1.0, "y": 0.0}, + }) + await h.handle_message(ws, {"type": "nav.goto", "data": {"name": "Chambre B"}}) + await ctx.adapter._nav_task + await asyncio.sleep(0.02) + + arrived = next(m for m in ws.sent if m["type"] == "nav.arrived") + assert arrived["data"]["waypoint"]["name"] == "Chambre B" + + async def test_goto_unknown_waypoint_returns_error(self, wired): + _, h, ws = wired + await h.handle_message(ws, {"type": "nav.goto", "data": {"name": "Inexistant"}}) + + errors = [m for m in ws.sent if m["type"] == "command_error"] + assert errors + assert errors[0]["data"]["command"] == "nav.goto" \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_waypoint_store.py b/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_waypoint_store.py new file mode 100644 index 0000000..90b92e6 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_waypoint_store.py @@ -0,0 +1,65 @@ +import math + +import pytest + +from robocoop_backend.modules.navigation.waypoint import Waypoint +from robocoop_backend.modules.navigation.waypoint_store import WaypointStore + +pytestmark = pytest.mark.unit + + +# --- Waypoint --- +def test_dict_round_trip(): + wp = Waypoint(name="Chambre B", x=2.0, y=1.5, yaw=1.57) + assert Waypoint.from_dict(wp.to_dict()) == wp + + +def test_pose_stamped_sans_rotation(): + pose = Waypoint(name="x", x=2.0, y=1.5).to_pose_stamped() + assert pose["pose"]["position"] == {"x": 2.0, "y": 1.5, "z": 0.0} + o = pose["pose"]["orientation"] + assert o["z"] == pytest.approx(0.0) + assert o["w"] == pytest.approx(1.0) + + +def test_pose_stamped_quart_de_tour(): + o = Waypoint(name="x", x=0, y=0, yaw=math.pi / 2).to_pose_stamped() + o = o["pose"]["orientation"] + assert o["z"] == pytest.approx(math.sqrt(2) / 2) + assert o["w"] == pytest.approx(math.sqrt(2) / 2) + + +# --- WaypointStore --- +def test_add_and_get(tmp_path): + store = WaypointStore(str(tmp_path / "wp.json")) + wp = store.add(Waypoint(name="Chambre B", x=2.0, y=1.5)) + assert store.get(wp.id) == wp + + +def test_get_by_name(tmp_path): + store = WaypointStore(str(tmp_path / "wp.json")) + store.add(Waypoint(name="Accueil", x=0, y=0)) + assert store.get_by_name("Accueil") is not None + assert store.get_by_name("Inconnu") is None + + +def test_list_filtre_par_carte(tmp_path): + store = WaypointStore(str(tmp_path / "wp.json")) + store.add(Waypoint(name="A", x=0, y=0, map_id="etage1")) + store.add(Waypoint(name="B", x=1, y=1, map_id="etage2")) + assert len(store.list()) == 2 + assert len(store.list(map_id="etage1")) == 1 + + +def test_delete(tmp_path): + store = WaypointStore(str(tmp_path / "wp.json")) + wp = store.add(Waypoint(name="A", x=0, y=0)) + assert store.delete(wp.id) is True + assert store.get(wp.id) is None + assert store.delete("inexistant") is False + + +def test_persistance(tmp_path): + path = str(tmp_path / "wp.json") + wp = WaypointStore(path).add(Waypoint(name="Chambre B", x=2.0, y=1.5)) + assert WaypointStore(path).get(wp.id) == wp \ No newline at end of file