From bb572040d270bf47aa43590ee3583ae78664be42 Mon Sep 17 00:00:00 2001 From: Antoine Mahassadi <127432580+damnthonyy@users.noreply.github.com> Date: Mon, 18 May 2026 13:53:08 +0200 Subject: [PATCH 1/6] Development (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sim): add Docker environment for ROS2 Humble + Gazebo Fortress with GUI access * docs(README): update launch instructions for lunch Docker Co-authored-by: Antoine * init(backend): initialize backend architecture * feat(init): Add backend's TODO * feat(init): update readme * feat(docs): adding structure diagram * fix(docs): update diagram's alt 👀 * feat(websockets): Create websocket server with Ping/Pong handler * feat(readme): update readme to explain websocket server * fix: guard asyncio.run behind __main__ to prevent server start on import * feat(archi): refactor into multi modules * refactor(readme): refactor repository structure Co-authored-by Antoine Mahassadi * feat(back): create packages for ROS2 exec * fix(back): resolve threads * resolve threads --------- Co-authored-by: Kelian Co-authored-by: Antoine --- .gitignore | 2 + README.md | 165 +++++++++++++++++- docs/architecture-diagram.webp | Bin 0 -> 29844 bytes requirements.txt | Bin 0 -> 38 bytes sim_env/docker-container-ros2/README.md | 60 +++++++ .../docker-container-ros2/docker-compose.yml | 13 ++ sim_env/docker-container-ros2/ros2/Dockerfile | 67 +++++++ .../docker-container-ros2/ros2/entrypoint.sh | 65 +++++++ .../robocoop_backend/__init__.py | 0 .../robocoop_backend/app/__init__.py | 0 .../robocoop_backend/app/auth.py | 9 + .../robocoop_backend/app/backend_context.py | 17 ++ .../robocoop_backend/app/message_router.py | 13 ++ .../robocoop_backend/app/rate_limiter.py | 9 + .../robocoop_backend/app/server.py | 21 +++ .../robocoop_backend/app/websocket_handler.py | 13 ++ .../infrastructure/__init__.py | 0 .../infrastructure/adapters/__init__.py | 0 .../adapters/adapter_factory.py | 10 ++ .../adapters/m3pro_robot_adapter.py | 8 + .../adapters/m3pro_topic_map.py | 13 ++ .../adapters/mock_robot_adapter.py | 7 + .../infrastructure/adapters/robot_adapter.py | 8 + .../adapters/sim_robot_adapter.py | 8 + .../infrastructure/ros/__init__.py | 0 .../infrastructure/ros/emergency_stop_node.py | 9 + .../infrastructure/ros/launch_manager.py | 8 + .../infrastructure/ros/mode_bridge_node.py | 8 + .../infrastructure/ros/robot_state_node.py | 8 + .../ros/telemetry_bridge_node.py | 11 ++ .../infrastructure/ros/teleop_bridge_node.py | 6 + .../infrastructure/ros/watchdog_node.py | 8 + .../infrastructure/schemas/__init__.py | 0 .../infrastructure/schemas/error_messages.py | 9 + .../infrastructure/schemas/events.py | 17 ++ .../infrastructure/schemas/inbound.py | 4 + .../infrastructure/schemas/outbound.py | 8 + .../infrastructure/schemas/validation.py | 7 + .../robocoop_backend/modules/__init__.py | 0 .../modules/audit/__init__.py | 0 .../modules/audit/audit_logger.py | 4 + .../modules/audit/audit_service.py | 8 + .../modules/audit/domain/__init__.py | 0 .../modules/audit/domain/audit_event.py | 8 + .../modules/audit/event_formatter.py | 9 + .../robocoop_backend/modules/audit/sinks.py | 8 + .../modules/mission/__init__.py | 0 .../modules/mission/domain/__init__.py | 0 .../modules/mission/domain/mission_failure.py | 9 + .../modules/mission/domain/mission_state.py | 9 + .../modules/mission/mission_service.py | 8 + .../modules/mission/mission_state_machine.py | 10 ++ .../modules/mission/mission_state_store.py | 9 + .../robocoop_backend/modules/mode/__init__.py | 0 .../modules/mode/mode_manager.py | 10 ++ .../modules/mode/mode_service.py | 8 + .../modules/robot/__init__.py | 0 .../modules/robot/domain/__init__.py | 0 .../modules/robot/domain/connection_state.py | 6 + .../modules/robot/domain/robot_mode.py | 7 + .../modules/robot/domain/robot_state.py | 10 ++ .../modules/robot/domain/teleop_command.py | 7 + .../modules/robot/robot_state_store.py | 8 + .../modules/robot/telemetry_service.py | 7 + .../modules/robot/teleop_service.py | 9 + .../modules/safety/__init__.py | 0 .../modules/safety/domain/__init__.py | 0 .../modules/safety/domain/alert.py | 11 ++ .../modules/safety/emergency_service.py | 10 ++ .../modules/safety/watchdog_service.py | 9 + .../robocoop_backend/tests/__init__.py | 0 .../tests/fixtures/__init__.py | 0 .../tests/fixtures/fake_messages.py | 9 + .../tests/fixtures/fake_mission_data.py | 6 + .../tests/fixtures/fake_robot_state.py | 9 + .../tests/integration/__init__.py | 0 .../integration/test_emergency_stop_flow.py | 12 ++ .../integration/test_mock_adapter_flow.py | 10 ++ .../integration/test_sim_adapter_flow.py | 10 ++ .../integration/test_websocket_teleop_flow.py | 11 ++ .../robocoop_backend/tests/unit/__init__.py | 0 .../tests/unit/test_message_router.py | 7 + .../tests/unit/test_mission_state_machine.py | 9 + .../tests/unit/test_mode_manager.py | 9 + .../tests/unit/test_robot_state_store.py | 7 + .../tests/unit/test_schema_validation.py | 8 + .../tests/unit/test_teleop_service.py | 8 + .../tests/unit/test_watchdog_service.py | 7 + .../robocoop_backend/utils/config.py | 8 + .../robocoop_backend/utils/enums.py | 5 + .../robocoop_backend/utils/ids.py | 6 + .../robocoop_backend/utils/logger.py | 8 + .../utils/thread_safe_lock.py | 6 + .../robocoop_backend/utils/time.py | 6 + src/robocoop_backend/setup.py | 17 ++ src/robocoop_bringup/CMakeLists.txt | 11 ++ .../config/backend.params.yaml | 6 + .../config/common.params.yaml | 7 + src/robocoop_bringup/config/m3pro_topics.yaml | 10 ++ src/robocoop_bringup/config/mock.params.yaml | 6 + src/robocoop_bringup/config/real.params.yaml | 10 ++ .../config/security.params.yaml | 6 + src/robocoop_bringup/config/sim.params.yaml | 6 + .../launch/backend_debug.launch.py | 8 + .../launch/backend_mock.launch.py | 8 + .../launch/backend_real.launch.py | 11 ++ .../launch/backend_sim.launch.py | 11 ++ .../launch/includes/monitoring.launch.py | 7 + .../launch/includes/robot_runtime.launch.py | 7 + .../launch/includes/ros_bridges.launch.py | 6 + .../launch/includes/websocket.launch.py | 5 + src/robocoop_bringup/package.xml | 18 ++ src/robocoop_bringup/scripts/run_mock.sh | 7 + src/robocoop_bringup/scripts/run_real.sh | 8 + src/robocoop_bringup/scripts/run_sim.sh | 7 + 115 files changed, 1137 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 docs/architecture-diagram.webp create mode 100644 requirements.txt create mode 100644 sim_env/docker-container-ros2/README.md create mode 100644 sim_env/docker-container-ros2/docker-compose.yml create mode 100644 sim_env/docker-container-ros2/ros2/Dockerfile create mode 100644 sim_env/docker-container-ros2/ros2/entrypoint.sh create mode 100644 src/robocoop_backend/robocoop_backend/__init__.py create mode 100644 src/robocoop_backend/robocoop_backend/app/__init__.py create mode 100644 src/robocoop_backend/robocoop_backend/app/auth.py create mode 100644 src/robocoop_backend/robocoop_backend/app/backend_context.py create mode 100644 src/robocoop_backend/robocoop_backend/app/message_router.py create mode 100644 src/robocoop_backend/robocoop_backend/app/rate_limiter.py create mode 100644 src/robocoop_backend/robocoop_backend/app/server.py create mode 100644 src/robocoop_backend/robocoop_backend/app/websocket_handler.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/__init__.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/adapters/__init__.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/adapters/adapter_factory.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_robot_adapter.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_topic_map.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/adapters/mock_robot_adapter.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/adapters/robot_adapter.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/adapters/sim_robot_adapter.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/__init__.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/emergency_stop_node.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/launch_manager.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/mode_bridge_node.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/robot_state_node.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/telemetry_bridge_node.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/teleop_bridge_node.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/watchdog_node.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/schemas/__init__.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/schemas/error_messages.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/schemas/events.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/schemas/inbound.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/schemas/outbound.py create mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/schemas/validation.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/__init__.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/audit/__init__.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/audit/audit_logger.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/audit/audit_service.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/audit/domain/__init__.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/audit/domain/audit_event.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/audit/event_formatter.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/audit/sinks.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/mission/__init__.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/mission/domain/__init__.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_failure.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_state.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/mission/mission_service.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/mission/mission_state_machine.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/mission/mission_state_store.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/mode/__init__.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/mode/mode_manager.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/mode/mode_service.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/__init__.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/domain/__init__.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/domain/connection_state.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_mode.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_state.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/domain/teleop_command.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/robot_state_store.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/telemetry_service.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/teleop_service.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/safety/__init__.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/safety/domain/__init__.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/safety/domain/alert.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/safety/emergency_service.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/safety/watchdog_service.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/__init__.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/fixtures/__init__.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/fixtures/fake_messages.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/fixtures/fake_mission_data.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/fixtures/fake_robot_state.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/integration/__init__.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/integration/test_emergency_stop_flow.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/integration/test_mock_adapter_flow.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/integration/test_sim_adapter_flow.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/integration/test_websocket_teleop_flow.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/__init__.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_message_router.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_mission_state_machine.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_mode_manager.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_robot_state_store.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_schema_validation.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_teleop_service.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_watchdog_service.py create mode 100644 src/robocoop_backend/robocoop_backend/utils/config.py create mode 100644 src/robocoop_backend/robocoop_backend/utils/enums.py create mode 100644 src/robocoop_backend/robocoop_backend/utils/ids.py create mode 100644 src/robocoop_backend/robocoop_backend/utils/logger.py create mode 100644 src/robocoop_backend/robocoop_backend/utils/thread_safe_lock.py create mode 100644 src/robocoop_backend/robocoop_backend/utils/time.py create mode 100644 src/robocoop_backend/setup.py create mode 100644 src/robocoop_bringup/CMakeLists.txt create mode 100644 src/robocoop_bringup/config/backend.params.yaml create mode 100644 src/robocoop_bringup/config/common.params.yaml create mode 100644 src/robocoop_bringup/config/m3pro_topics.yaml create mode 100644 src/robocoop_bringup/config/mock.params.yaml create mode 100644 src/robocoop_bringup/config/real.params.yaml create mode 100644 src/robocoop_bringup/config/security.params.yaml create mode 100644 src/robocoop_bringup/config/sim.params.yaml create mode 100644 src/robocoop_bringup/launch/backend_debug.launch.py create mode 100644 src/robocoop_bringup/launch/backend_mock.launch.py create mode 100644 src/robocoop_bringup/launch/backend_real.launch.py create mode 100644 src/robocoop_bringup/launch/backend_sim.launch.py create mode 100644 src/robocoop_bringup/launch/includes/monitoring.launch.py create mode 100644 src/robocoop_bringup/launch/includes/robot_runtime.launch.py create mode 100644 src/robocoop_bringup/launch/includes/ros_bridges.launch.py create mode 100644 src/robocoop_bringup/launch/includes/websocket.launch.py create mode 100644 src/robocoop_bringup/package.xml create mode 100644 src/robocoop_bringup/scripts/run_mock.sh create mode 100644 src/robocoop_bringup/scripts/run_real.sh create mode 100644 src/robocoop_bringup/scripts/run_sim.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e5ac79 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.venv +__pycache__ \ No newline at end of file diff --git a/README.md b/README.md index 0816b17..f25038c 100644 --- a/README.md +++ b/README.md @@ -1 +1,164 @@ -# robot-back \ No newline at end of file +# RoboC00p Backend + + +Backend system controlling the RoboC00p medical assistance robot. + +The backend manages : + +- teleoperation +- autonomous missions +- robot state monitoring +- safety mechanisms +- communication with the dashboard + +It supports three execution modes : + +- mock (development) +- simulation (ROS2 + Gazebo) +- real robot (Yahboom ROSMASTER) + +## Architecture + +
+ Diagramme de structure +
+ +## Execution Modes + +The backend supports three environments : + +### Mock Mode + +Simulated robot behavior. + +Used for backend and dashboard development. + +### Simulation mode +Connects to ROS2 simulation (Gazebo). + +Used to validate navigation and sensor integration. + +### Real mode +Connects to the Yahboom ROSMASTER M3 Pro robot. + + +## Development Roadmap + +### Phase 1 - MVP + +#### Goal : Validate teleoperation and robot monitoring. + +Features : + +- dashboard communication +- teleoperation +- robot state monitoring +- watchdog safety +- mock adapter + +### Phase 2 - Simulation + +#### Goal : Validate ROS2 integration and autonomous navigation. + +Features : + +- ROS2 nodes +- Gazebo simulation +- Nav2 integration +- telemetry bridge + +### Phase 3 - Real Robot + +#### Goal : Connect the backend to the Yahboom ROSMASTER M3 pro + +Features : + +- hardware topic mapping +- real sensor telemetry +- safety limits +- emergency stop + +### Phase 4 - Production features + +#### Goal : Improve reliability and traceability + +Features : +- database +- audit logs +- authentication +- mission history +- monitoring + + +## Repository Structure + +``` +src/ + robocoop_backend/ + robocoop_backend/ # Python backend package (runtime) + app/ # WebSocket server + routing + startup context + server.py + websocket_handler.py + message_router.py + backend_context.py + auth.py + rate_limiter.py + infrastructure/ # External integrations (adapters, ROS2, WS schemas) + adapters/ # RobotAdapter implementations (mock / sim / real) + ros/ # ROS2 nodes (bridges, watchdog, emergency stop, ...) + schemas/ # WebSocket message schemas + validation + modules/ # Business modules (one folder per domain) + robot/ # Robot domain: state, telemetry, teleop + mission/ # Mission domain: state machine + mission service + mode/ # Mode management + safety/ # Emergency stop + watchdog + audit/ # Audit logging + utils/ # Shared helpers (config, logger, ids, time, ...) + tests/ # Unit + integration tests + robocoop_bringup/ # ROS2 launch/config/scripts + config/ + launch/ + includes/ + scripts/ +docs/ +requirements.txt +``` + +## Running the backend + +### Requirements + +- Python 3.11+ +- pip + +### Setup +```bash +python -m venv .venv +.venv\Scripts\activate # Windows +source .venv/bin/activate # Linux / Mac +pip install -r requirements.txt +``` + +### Start the server +```bash +python src/robocoop_backend/robocoop_backend/app/server.py +``` + +Server starts on `ws://localhost:8765`. + +### Test the connection + +Connect to `ws://localhost:8765` with any WebSocket client (Postman, WebSocket King…) and send: +```json +{"type": "ping"} +``` + +Expected response: +```json +{"type": "pong"} +``` + + diff --git a/docs/architecture-diagram.webp b/docs/architecture-diagram.webp new file mode 100644 index 0000000000000000000000000000000000000000..c5185c90e90ca2a6b81efc0aa8b7492ef08d5468 GIT binary patch literal 29844 zcmcG#byOZ%&^P!1!QEYhdvFq5gS!*lJ-EBOySux)yF+ky_u#T5Gntup_ucQD{bTz% z-Cf;%``)U$CBM22vLZr4p|${kk|3Y7qBNT_6aWAqc;CH20lpvr5q^H@L7?|b0MOvC z;|F>Gz}&*tT2`2kKv_kV0DJ=g^#1$3Y3tfp{YCw+$M;sZbAPp+q4{51{GV?^>gn6) zzBjpe--)f?2Y=@Y<()<`{EH_4MeF=UbNr$mY^~nq0+RkkTg%Jxztei}G`Zn_Xr2Gi zx>nY|+K0Zk;V?I|`!&|D?U!^g`W6au?`Mej9S2|okOc?>_oegFW*(!ckJGXVg2UjU$f;@^A!>cmRhTKji*AKs6^1_l7YRRI71s{#NZ zPXYi?YQLYoAO2r>BY1y=^Ujy$`_B+y2G9o(07L*509^p>JH-H?2Ydpsyw5N}x-|fR zpaq-;MCk+S2f}5}5-&nblubx*71Zbn8)RtlOvVIDaR6$LMlt`|@Kh1p9dW=uDl?3?t%NdW1!;DJ^Gd8vSa$K#(l`c`laK(?lq!6 zfbVVM2Kj#dhh>w8`ODDj`P1T^?oG#;$1Jy9N8TgVx!m2`Nybvgfrrxr@U!Lp`tA8~ z#+t|WoAJx#9ng!^)qGcjzCJtdrc0y6^~L6+c%e&r?3(}U^LnCrTabna&VIvEyn)BXI(iGk2WU#s>EyAhVEF_Z7cwK7_F+j;(odz)A3+l=`MqA{7cY@ z1qu=hGARWCjIdh>bQPB`OU__9#3jC$aibO=X4f^=UHtcO?a8z4>gVQ z<_U#ryZp=DyBf+J(qSK-DKkL*E=i%k^^8Y-&{~Bng?fb z_`jZ3=&~#6wkEjGvvy^5jCzOn9pu9`6f>|udNpFNrIv<#o070t`y_E_Au={x!y?28 zq)_Q%^-Z2-HH2eqx&uS^%1l$+6~-k9V{Fw9<;qJk+HOCJz0ukD!;FDovs;=sAQgYS?kjA|wzjxM?Ro}> zfS_R&7!^}->UPquABIEW$nkslih>YZooykj>bfd2;{71P|JQupHO6LGtD=@YOa7OI z^?@^i7r{nb%yE-=1)gLb-+Pdb<}Ib5Vc#ZF_ZttyfAw>vxisFvIKwIe`g#@diu^> z9_?9JTpPHDR*n2!778|sf{e=Spuk%Elp2>kpOJq?RaaPoMwb9TA9T()z(3K#@RXk5 zFV~7hZ+v&qKfL}52Dr;QNe5ImPS0`x6P=P4$nv{cQzMp#|T!Sue%m@SlPpTYjonLyjJY);ZlkKjwD5s}zB%?a@kEcYp9mduS`nciPV4`0tS!5tw|Z)Y zAUQooaZc>I`)Rr@>%Ha*f9U*wh>gb!MG|wr{FDKOQ+Kv9GlbwJ%ZRTZnDx;Nkj{yj z{6R8~R`$!mU?{}YFzkBarvc0$J;RI{_51yFLFS*I$`Nj&r`N)#z1O@V1we=L=<`v! z6Xl?^#VP#$Au&KBqgP{tzudGpoBPb*PQN#qDSR1O zK%AmOkmQA^Oc5GsJ?gVBYXI4YRaicRR3PuyNmf31)$ye@fgxir4GIsN^+|0C!rNW} zBaHAw=<|RsiOUUn&7SU}0VF*FRWy0EVvNUln$S)6-*HC0n!i7{KAdht1?|p37)|ps zOkpZX#{G6Hdv#O%Mt_P=>&t!S_Ir>nkfOf%@&*sp@K?OHosI=q!Tu9i8TNO=L|$>i zUOvWCa}q+{At|*OxRT*8ZVCX0!F{R}`$h*~Y8Qv#VnS}DxX@vSz5s zQ_QO|$1h>Fkf2;7yQp?_g8eO&N7w{EOJc+;lUw(@cvCuk$DiZ_w>#HUcsXT4 z;!6m7H8*>JSVevfRpy6gkqCuC8HHM?kiVrVM&B|%=Q&&6tC7}73N%KF+YZTE8M+xo9)O^6 zFp1peS0aew`}xNt0vp+GrXSf*#-|sAh2kSFpow4cx*q)6>BxtXtHy@hwtS- z;GhV(sscFsb1~36)BuA8{W}4gd}v-g`)d0Rk<#Y&xQ^(oG9Z9-zFbvr#33%=Gi31w zcHf@X=7IhVAcW)7m^&}WPEz_TLZn^(WYItAfZA_6cBShz5+-xMmDAUUvnjR9r2z1s zgnt=7ltR+_=<7vufCV8e@9*|&3_VS6_Zea5=uO;&FB3D1@>Eb8Fe}s;#U{W zsr-%WF~b&79xLeoAQY@O|6kZ=rDhkjt~Ks2u=5^GL}h;^d0A2)D#NUTFk({u`N{xe zI4xf0);}6I5x8)wxF4MT+R-101M&w*{SGg-rlmB0g&~svr>Vn+X0N&N{sX4^!2ejr zFR+=$QT>|hss1XD8rHpahbG%Wy08rbC(oj zXjK!LMl(rou3LFuPr-*nBkyJOEq)`l+8ue1ov#FtptS-87vAQN0E*x6HgY2n*nEdn znt$XsAcR}%28JT}Z&Lja>JexyMg2?Hr3~Z3K=2razpZLDPw+2{{0FH0!Dt~Lyy&W? z{-DU(e3y6dWcfA27&CPScq_MA?k@6A%YU%sFVwo`Dzvd6@DK2&_yygTCz2b?0zHjaxs7lm*Ss{ZLrhv3(mBN{&Bef0;~5z0rhNG9_@O= zqk{7DU-<3a4F8#<{Ns<(-(f%2_fBCE7Mxh=>d#lnrj-8A*qETvdE(#r4?c(rV2OjG z_9aq5`&EUc&1be*6ul=kH3t>{mWZqoY;O1jHDg}Tck zUQ3>{dDltW4=NrN$+BURIgCElIezWQB_3Uo&ji{yjz78Hp1hf5Pb-f_2c5r*anT$7 znP|nyU!DA_QpyWw4?oI!m;BD@e`C3vZ*mE|JBB+4GFL+{qXMktDyWL+PD|ZcyK&9G z=kE&m?w=_)+B7%hxlCnT`juh-R4m2IXn9%XV_*Tdgs#$Mq}(>n#?`966N zu{@JAw=8u$TY?<}7?ooPQ?DHLZuD9AI`s>UMS(QFFJaT1%l93Of6A;s0PwxE2)?r} zhIaZ1+70at##_5n?o55rU&1TZW-=Rfz{Ypb=(`pX4L`3hIjzY4ptduf*LrV#j*FO$ z<^Btd8(9mB!f_Zv-~OotDLtFe=|7JG1|`yG9wu5AhMwhtI#r94m54|Z9rLlXV~^^K zn2o$gaZf$^z=yt7=6ASoTLGdwcmQGn44R^SdLEux!1)`rS=;kYhZUUyvu<_G#=ZGY zJ+8G^`G^lu4+GSOm*NT4IDLB_<$nEwFkjehVfl{{xRj~V1MZZT0p5C`sO{Ia)s zQx?)BjGMQHXYI^xsMQ9}{l;XM`LXru7uHw~Xli1|%jf!bW|G?P|HG*iryyz;70ewA zNj{eu*aUnAG56Zb*^j&&)kuP#w*{B!=gS0ZY&r(9^Y_x}eM@UX)7S~ko5&v`WJ|*B zgb`7_ZHK3Ukk*ql`+av#MQ1ez>%E*+f&Ndy z%>3h$Z;tuLD;`;mq&|LKJO8G|PqIf267G$k4b~`(_31$-xmV)AV@@j#Ii=g{XaT4c zc6QoZsFR;3QQCmIZm(NYZyd^gwiy)l7xe4)dxBMH^jhv)(>n&Ns*`j?skdkVt>WvK z=i~2hBb5y+hmUiK!?!VmF$m!hH6ImRRjxQrzzL)?1K0@j39Or_zE#U9ZTZYukQ)#3t zX)EUT{REmlngC;(E)AfyWAN;Jmk79r%V_HDL)#No42`RJ6MQFVduE`YY+mGSlQV+- zGBu;UIM%c^zhwf z974#IIB;db{{92~x(^=8oiyR&%C{m7f$mQ6- zB%$-GN5}V!hb|r^^hUzZmgk?m+yHBaPMjGS7h7>{y9Q+HLaaTMU3h7ro9H|%1$_vv zS0EsG&Eh5#suB>= z1}JJhkkeYRC8g-``J6m&`&>bAX!{ZJL>vbGl056uR z2tcjKBGh{gae%?T4{w1)7J5w-vomWPxC$Gxkt z)-Tpbe-q|TmW24p9jsn>0bIn^Uu__<84of9=%+!LYKd)+P_P#(mU*N?e{DEQ6uhIn z7@ais=3V&0eNRed=>Il@40?gVzURWCVIY~lM@~^BzL9WF;_G)9vwffRYh@ca>_mJ6 zkOl+LeK<$#guI)&BY~H$D)-E*@v&$aq@A6@S*t0*j?XCOi7N$Ym|PuZ&h(nPVplQZu);&iMTuW0TZFg7VS&|HDnGcjTKV+tunV}%d@O@orTJaXii6Vs{ zYWMAv7*J|FFd*X^E?FdCj*u$Skm-G;*%I#dKajLX7Bn0L${~@WS0DmIqMs~xO@z`D z1=PfiV07ySdJ}iIeZxJIwbS#IUbD~A-+BAM(NIPGaHO4$JWK^@^F$7H830(J8+dJq z=)w{5VzUfM%hm{hvi;hg)gt1QytbSni-1gEN%H=hnsF&8X5&xonm&XlxY6|l*|0>O z(&)l9fuU4O>2gh+H8c0tN!G_l?k-A5+8QkR7dm4`2;ozt@Mej|QlEN{?U(U&wN|J6 zic4j*&gwsM6SIG(U@n)lbjFQ0NvmthKW>}Y^~LHwm~}Ro znjc#Ne5f<}P~FpD;=9vEoPA%OG$)9m0rfK&5|Hvp$^%;hL$rG?kl*sTd%g@UaCA=xrYUq zatJAfXvWYWB%koMUaX+G9@R{Q}3nvV*{K_8F2uyQ8g znE-l><7M&a@RFQZFi zvgJG(^3!FTpIvG;@2|*DkFX=4G$=)!-TZj00FWZ2^fQ()6d34cJtfk>FmXICX_ZN` zBT5C(YnhDrZ&(#9y`vz!DTg$XETH7LR~#xN;3ws_?yV6lPr)h4HK*?L4a0>og&UFE z4ut7_RD~~x?l&AiQi71@Rc255y>w*QEgcL8FSMQ2TWy*cq=P#Wd_$ClY15$!Rn=-J z7(*B|fJda4)eX?HKu>dBYR|5oy4EF^L1_aQr~R~h+ddBI5xOvKep9qOJQ2)reQN)j z&be%vSaN??lV}#LG+u%Iy3AgSxp7Ow5s9LTHE`MA>!J;0?xa#1Plx^$#SN?G$Ez+> z?^^DQk=9NliCjZhTTJ)A}$^k zvKu930b;PWi!asNEQK$F`eh`r0p7GfM*$!T8S_P|eaeJv-{n})b=+RofqY9J#i|vmX6}O`h~i*t!K_V zAIv8%JW^{^QWz%84@nFp9k?}nqx0#~F}mUo=pBIF^e_tBxKE35oIh2F2Kf2<>F26Z zXY^^aT!gOSX{)U>qgU^XqjT2-2Tz3+vs#-Te?EcW1I%ab<49NUuO5klucxM{QS`&@S5p_{eU{=@gM{a>Pfv{WBkB42v*)mCa zwkJQR=2UJCFjw3fV?^K`9JNRlX|Z43vykf&wrw~Bg+MO#Inp?lR#zR5u3j#n&`@c@ zC%#&_kI!03-{J4!yPf+msCNWHf_Oe zEW-?80LICnHZYX2(#;w>@@<0R?2tBu}T7;bk)D5rPbd*<>rlMujApryxW8xW8F zp|`KrR94Q73$Ku}D}}-4%iANy;^0hNv1IvBx99W+S;S71XM#gd5L2zs5E2NT_jIxG zE$O-!#|kAByIe@S#Wu?p0tbb}n8KE&kMTZbq!sRKF`Mi@(7BgN50sfFT`O4|PQ~59 zvFn~Gnr!AqJQFqcX5RzCjX|B#n6P96qMQ&j)=hB{q0p4sRD7h~n$^5}h}H(xWa6lB z&t(TA_c_Rzfe6l}cI(D6KqnhHU(z*7^|R4M@}V_-mZI^qK*UKz_xC@<3vM6~8Bwc> zH>g~KkxxpdLb2r0H|r$cvjKyaXlUnUtTr6ovTFC#pi$`+5$SU2f0x~m>(O#$c8Jlo z<4EoG5WFfpe`d#}IM{Ve&>_(<62sbALA5hG@GEhjW7E|L;9B|+h@)De&9()lKWT%q z^9dcv)rS=Uq#l=#cNIGRjR0bR+Ke^0&I8`d;1GJ7hfo|JzltS^y~N|nF)V=lxgK_s zcDNFF1=?eJQ=dt>9|5VeT}4nx;M|1BX>v!2hD0hj`;<;xsjscH9)H^m+oX;}5mlY0 z2{^!SIVdGMMNxVYI~XjM+lN;5=QP?P>_)%`!R0R`(# zVBaV;GAP@s;0nY7;?TQ6pZm#P3y3e1{0e4RAzvyniKAyi#MITC_t=MI~i+$z{8*warLoV z{>zw(0Cs)Wk%G&GW)AzqPIy|{&9xQ5riqu=HOP)ASu3(6z&B+%8?>O?4KabZzd}P* z0?HK;5V6mw0hjyqmN*_q7Mb`tDHI53OYVm-$MDjFqwS=zw3f}W7a}2e*6H5|PYZz0}euK+4e zVZYiUyUH!RnXS4#)`*XuT?>r`$?U%+hQHU?#NS9VxznQ~=&>ciSth?x2`zlHAJ<9V z0_rIoPlY*#2gGCDTMxE09?zAZoU6kW)?*{Gt@O0x{iRjfc8ee#z~qd|#>3dxQLR%@ z@U!&-iHZhd^^!v%R3W9wfE|i{WpdZP2~*1eXzj;h93*ULH;HcnIq;|Y z;rVqR_$uXgI>Hr3bF(M|Oauht=_t)4TsiTA@6k)ftTqy-KX)VU))ps7FSs&=mcCaZ zVdQ=+1gy0pV-)ecZMHhJ9HL_cwiQa(Jd(Ba!WliKNvU_`Zw69*SJ$?hvY>oD>#`r% zysUi?6KKcj9$_2-sx=#jm1Nqe&ly+F!p!D^3Qw%Neta8bn~u{f>`uaRJZVEt!ft11 z=07@{CflzKDk>?N7`kckrMkV-FdihBBTZ@~+nWmI%odZTJw{{_Lt)`>t50gy_9tND zUDgpXUfzAWE7=<~#1FEBD(VT;@)0lJv^LEW=sRiL7`Qm&+j|OPvqn4ClUw~fD459{ z15|5^2Fz%&e^RZ7x$6!<_L{F*>~90-O(|*x$D%yRU*%BhN9;tX%gm8djqzjF3kKpz z6?VLpxkn}bgzH~1yw-Sv6Sbo;A_`B*&t{id%pjG-GMfpa^F#=7yCTrj=Rvb+HZg6nl1R6*B34O2)T}^0I`OXJzqcx$}PA6Qx3R8oqaC-f`7k-XHhV)r%l z@v*)IK-KLFq*F5v0M3A@2I1VuHB$;B_syJdvqDQ&tTvjSxbfkcBtv0x_4-@0vYZl4 zqtnc@#&WFme!CHg{WAr~HB}{iLv0mKru)h?>P<^K*5t0YjTL49%Dj%DhMQ;KXDrVKtAQEob`YNR>Au@u6m_(ErwkGI>O_?9XHnjGcI z&0pZ`|0*gw)4X9K5#+~lvL8?{OGQ_)gbic@vV@uj$8Rrq;$VJ}&?)1WWbX*RMIR?r zYP0%;YGV5wtD%odUDIi-JvbA|V-TJb2i!twTZX*w2v-4iDOLvWJ&uAMFIH%_ z!d%u4f=otp%ND%Q$#U*Q2Xc|gXEC;rcPBVG6b3dk@|w23?o*}XHoC7}^3xI+xzZHm z^aOVCZ2@fuPd1~W=JG~AY<(PLnq zouf5EczD10G=RW?PzYOkkaMWZPQQJ;B4SnC{8#}dj_rfAc{}OK=m;FfGik^CAYvzL zvMgBw@VEZS>bAB@Hf0lP(7P>~;~M>GNUYinj;L|Ez@%gWX900nH?%RpDYy8EI7)$~ z{6_JKR4HW>_`65nrtY6g~ow$51rnX^XWyAWSk+!_Ih`dK*w1oPD^2=<9nFcqI z4Kf$AYPaEnVqkj_;#zurU({u!p4o;=9c&E8>+=<6E09p8k>ImTC7M2ZiKn35Vl)F&@$!3NGdGaFth3RY|0lf713)&l|seNoM zI4Ag;)?s;2%t5u!;7e;%%EP{}AhPtjJ>)MPIFGK#wnb3kewr#&CSW#ZdhL>1PMD*g z#d(J^t9xFculGFqvqM{=$hk04O^-{1ma2}cb)^1m0z#r>^Bj6qsLQv;aDOdL>P>Yr z;o4TTllAZ@qNBFdkDigXU(j;STzK$DZ1v_>iMc9_D^4F>$$EzXM!VP3$?b|QZMZsm zM^>Dv%o%4$GIBh(2V32&94$o)qVu9R0ko&}kTq2a$>e?>v*n#mZzxd-WjftT8I2iL zOU6oeSoRb7Lm$Ud27dx}4Tgtlg-n@1W;ZF~<0g)Z9^6&cb^ezySs~tPmyTSbqjFcy zjEQvYHU%0prnS&Y$`+io_!#S>AD^-ya&wa+1X}FFZUkY8kXTd9iCE^axwlosr=*J> z#ShtSHbigycM)zw=c0Om31+!)`}O_7dtmyTbA44iXJl(8B{tq5`2>gu^U9S<;Nj04 zY{nv3D>oZK2VM2Uvr>>E_>?R&WmadhoI+Yf2}PA5fq223BI4`{n|o-2thLf2++C;l zr9e^<7?-ww8rz*KkVl0bc=Hv8S9z;!?d+)i5)Uw+-VJGsZk^mUQkD(kM2=q@|TCkCAvD z7Yr>Q^YI2y3iHG*OtKZ$P)G0C5tYfln+8^nn40EQ>bst?7KQ(CfWFmq6Vw!2SioQ( zePTz}j?P1dw+DGmQQVBWt5%@HY;u+eU)+iG;C&*=h}m^W01E!nVsRr4ik^^cmr~_f zm#3qcZ+DQnHvJXi%6{!xUBhszfX)GFwJP4WUHVCiZ$77qh}^>-BK^5-{b__69=qXG z0BP6xA{oYO)R#SxAW_R`pu+GOY7@~j!@K(i?!qwTX>&J9W4=OWz(^Jeyd1I&T#BPH z`fgEB&((6O@a_X>sRoP(j4H^JywV6%=m2N<1<8=mBG6h3Y+L)H7Xo@J9ZxfrZu>xKbn!Vk491o!b+sw-e!H0G@rQAU*>pG!7gJSWdgn7t* zq}kzHzb4xs8_rXiBp-jMb;?!H=GR=a@}$tJ+BH?{_+H0t-=FvZ2N9OTn<_ETfsWOJ z*g~bb*w)QRcv3v;5Uso-a3Xlt7}03ZauQIO}UEN*0iYY5=4dG7+v-M z`Q(@l+>{3Mz+mwrm6k5U+t1~UmPBcwSa>83JS^tzunKm9O)#2v)kj7ya0A$NCNpAx z={V1IykNG~R<_4jy8bao*%Ieq(^h9Ma^=a`a@$wr#(*^e8#)!hoOKoWOn!6+DYmJi zt~zYx7aS+?U5*DSF`Tyxma4KM2#17I>c`rLC$6R9Ox6vi4M3;k&m{&ZKf~0)p^{Z4 z@-;bic4^qdtB_5dOG$%s0~}WwI%?R|SqagpBxFvWnegL;u)QL=89^8N{IMq(tl_1m z_-||w%jad4H)qk#Lbl@(!7R~Bgj+&y?Gpzn!X#)m53vP=tqAbJ``SJix}W8(>~+{$ ztMp+<#slp$R`#dkO(4~l<=+}B^vJ`BvH_)XQk&yDWdW=t-#K-3jH-Sx zWhEnyLSBnhSqUv`=tS>!=2t7FIDZWq_oIr}WA~0{Aj0UprESSKM^Ucaku43bi$=Mx znMHkGZT|vc6hU{=WL6nX_-vM6lcTKkrO1_gx{7JPc9sI>j7Yhgo9h7IMVEQvaO3;% z8vF!}B1>ROl!N&(gX*eIYx>a|2eEArvmqhN+W2G!YTHpeXvkL+3>Hzlh=|S}H#z{s zR$2>@|FF6Sr@YnFM>e&1y^ON;g49&13>5khkj**?ilyxLmt-&lTa zD3W$efYqPKm(FQ7Wa9oy#*Ner8_beqQ*GaKa;d$S#@~OE|DDWwiFA?KE zp938c7el3~l~9X_`miRjZ8 zx&e=KW!Rt!X7b2k*HOt7x1(l$fsiue9J^0;Z*$xh9S|{KZug^)>pG?8oxdz`VULDLS48~GF zsz|MylX-Sp>2!(Bs#*L17vL4av?;7%J+AT&j4;HtKP5Q3!N`-P+ACt`9$0#bNp7(|lfWG(2K2}>hpPgV(lbzT1GWnVSA)w{4L2fl?BJHl4C>w$zcs)5aZ zt4Ud}&yJn1seFfI7XtdDtlDMT9p2XCDi@y2Wv(N z5nmZ6u@#h7Izi*b0sZ*^tq zBok$-_8e>QM8GRV{<=1yCK$+Xh#U@7OQtHZqP8Xs{Jdb`XuD*X4+n#9Me9Ijw=>X% zZuCDNL|{=Wc>~_imzIKwHJ>#Z2AQpC}lq1ntyI~6fDE-;c6m5M4rj5F}Ipj_r@ zs}t=m0aHaA+aZ2H?Lgc@A&x}&q?^i6@@SGOZY6Xpj6jNiHcS*@i&ov0dvZ`ex&viR zSQ%;fB-Xj+o}Y2eX?!gKnryB_PN(I#Nxrg~%u~uLd*J46$5jgeVz53YEClV+H=cYA z>1F3j9!mDreL6*+H(dQz%0<64IrEtd7T&e89a0+I+6}U6$apC}vUk@3gv5zXu{0dM z*NPxUR?;GLdXF{t>NC0Vj1F7&86!&DAfaLN(WTNDu`&=Rxg~i68QE;Tq39%LhAMy1 zpndDqj3zQ_U1qQr1bO~hjSQ;-OXrav%n@bP%Bws5i(p*?0SE0nwqpw|%`ul6~ zstS8Elc&fy7N>2>*K76n-^^CLDBnNE^292;%7fD*oFi0}bPdl!Ov<0)?4<|^)H;iY zrL8Q6UmmZVKpJ`lJV#e_YTFjt^AU~^0D$SVwOj^~v)m&PwWolZ9Yow9C96%4sb+i`;8-IJ`Rqsd0fYGx491x_txqi z6(ujjBh*>v1E{bFY{xOERig3agX-@$w+l=^g;^4D>zK<4IL3b_)Xo7{yx&xT`S}*H zd!D%+If2tbwga0Vj?fQ{16(BdO4F3a=^NUISe7W`mgUf}xjaA1mS;n%+_x>{|K-?J zYthUUhMws=X2Wh$rd0JyYr5bRho~kNrCSZYyxVd~N^->TP1uJ=!wG+*DI$r7@#K6w z$jj^iI%-7*))a|J`wY`lqumk04Jh*&FEzSJ#)Jj$yu19=3>M5iZDkcrlfCm<^7Yv4NnuhwAb5z@Oy?Hmq7amEDIjRIOTahc+942v`1<-lYafvku%bSu3 z{BoU}8f-1o=lp^t5B1DdwT2Yp)qOwu1dF_7=T9pjfmm_)q816Q7X?U(3sfM|Jg-w~ zSvdoS588&wn-Uk1!*=8po@4~N5{rQgsGpfxP!W9%DZo@sSz?|helTJ9m7D|Z(Zb5KdA$Fo6eBE@pZ5oP!VN0l$42zB z_|gc!;9JPMpr#wC8&9ho$m$@lsi|bsQjzZOE0HYKidD(}syBB$KyXw$?Ya;pWQhFU zjXhB~-vsL+1V$JSL+Ge=^NGlWN@rkDi5xm+nV!vC(iX&8cZ{H$cO!>BQ|^Yu0DlWB zXj_c+87JBfZ-kU#lAD~gzeAosJBDha|1v*~pDaSj`t4r8Ik!t|RUwGa!M#L3e4L=y zVdBfj`vYG%yug&CnlzP14|7sm+~D@y9ys(eir zl}mvSv0}k5wxD?+n~vgGVa=-FrmY#pJDzBsfIFkG1@dZ`j&55q&HTpk?#rP@}iKFOz|A9cgB^L$r_tCCNtLJz5ijK(6^Am_r(p z*G0Hit%O2EQWW0sr(t^ZcjZ2TpgR<1`PUzYs-ELsKsi;lv!VZWy8gGdhEgT}1tdiRwSSt(jclr+&wO=e_q^SS%J zK<>A)eaEGK(k2pW6yj+EUdIiw0A>j#x09M78r15qX?AyrHMpe#?fEDl`!GINWoGFX zD56rhhu1A>alAZNvQeG{z51e@wkCm!Q1)gXa`OCLPOh(zvx|twXE}ptwk1SvoY|q4 zhFpTVN`X{0w_I!NtGq{jyUV6ZQo4#hl$>*EmD9bWaNU>kiE~}0AFW@p7$vmo`6m`5 zVx{?J-*&WIzv=c)Mn)Q-o$BA}U}=(N#16^%bq^w=J1v8aNr3YSf) zpA6rWmx#2oPm!@^&pRS|gt{S7ZLhKv5GR4~t{1(Ajwb{&HR+;=&$1JyA{=mRK8~5M z=MbM`%**1MNmB+@7T6qf1+z_(!OrZfm9|C_0ME9VFJ2w4-e}y;Pl%gO_Ol=4%$a$>8IRWZH5N zhXoJCH=uO1(Z#EakouS(fRPXZsH2Bg_q+-3FRXjtLa5+;SUcBmG?BAPpxK~8tV#Rb zjl(WUm>#5NsaWNJAK3#CL9vMWM~%rsLM8DIdgrfr_LrfA;HIp z!4>Cc;I$%5C>8e8U&}eGlyOw>`6j%}JYWAbjse)j&i*BbbsOR{=sMXv=vU)Z2BJBF zW9PgqEwCQq9LYfxu!vsQJYzj33#oYq3onQ#*ofrRvzy55VR9K^HrzESK*`dWToVF# zbJVz=I|H$OWU9)YV0hH%5+orrY)ZhHps8>t;z%O`dq{+;3$;PMmV&DXmQJR)TZ1l6HL|A;}Ga^ zv7<6DL`8}@mg=;+n19ee6bUAyB2f>`XH!!LDc-k8w0B+0A}1L$tWoxu!OJJshoEjT za|Oe$q#_DwAZt~BaW5Lh3t=71Q8Nbsi4@xj8-f8i0&CiL-BGWUPxFpFszSH z)7lh-tXd3OE;v(DGOSpE9YmG){K!iQ+`oe3G{LvAX!rSv<7AC1}+N-5&~t3A&CSE&S=p*l&SzT7jkrzn(}bb2301y($*OODYH8_lV^)ZJJzoK1XfTBZ^x7v znn8*yl&U2#*r7jo*j8o=^-arn3A{r{ZjBpKXKB~f$FnzgF0}^`<}mE^fw>%a>IO@WmTfZQiTV^PGYCa2CNs$|w{zPq+^uzR%&FW@=ypS-Glc~*xDulk<6Ixv9mhU2` zv9XS`^`!jL@h*GJxVzdt1P^V@^A>Aihycy2rGtb>VZyXuikcIHAlx$n>UJUH6Y-@h~`N$T8V9wpUDzgu- z4Q1l-uF6!nPet>~SeWRufBN_$_gC79HszF5Igwfw9=w3y%fT-!as}p00+wlf93@LA zHG;-Y{X!DALRY}onPuAjcY{zn~dm~5u=3;5A!M)ZwUl2y{WtE_;t|GlGW zxmOrXJngd`tb}V;LDX)5hASl6;M*&@fY%J|)GOLMX?j(^($DJwiD~Vw_~doJleKT9 za`XP1HOmQvJ3oLk3sSfQ3wmka2RY>W4uc!yI1%X1y&9uF+cwCmc-J^82;pj9&1E+e z<7o(ZiY`r3s|UfM*GOOv zj<9GeIi!e+(rWRuIgcyKhnlFCsFU%#r?Rdt66;nHJCYHyfED6l*b9!{x#`E!0@IRG z2THM7S8DE0Ld7(RFn1nfWV)~vk!L?g)M{o$>FXHY- zK5Icxu+aakZZ|3Dyg|)IE!DxfrZ3dEsS4T-1NdW9Qfolz%&vuegXD!4b1~t>iSE&A zg;5$@OE76=FLc%f-ZlRw`4?l}O&SMD|5w=Q<%CCAj4XG0&*j4~j2!L;n+-u_3=aY0 z@6QU^hQ1!mZ)^&p?P&ZQZMT~@IB(_hjY=2A7oM$#z~GU(Q$GIIfWHk%7teO$4fU3~ zFnlUKGDM?JrDKln?kKhxa_6gn%tu9tdoZFD9`s3&6W6MbZ+8GiNzBTrx6g=@=0g!M#>PSs!Tn=5*BgT+WhO_qUSy zA3K+C3Ot1?#cU7z0+Qa3&qx6A@P`M88wn>4wbA|y?rB5 zA6R_n)IfV__$=UW>N#7WTTdg)K8PkxHXEC5DrWSDojDCObvyC#uV&5{o$95)8Q%VJ zX&{|WNOGmMI(6OQ(dh)~@nb3z3DMl3(&L>!DX4T0C6x?;?DLvzH<+8Nwfl1ppPig# zzo$KWxu=u|qXomjj((S(;Nrz+?pGX32+2ZmMYVN*4=&nJI#9!gS^HG%dSLazHTh@` zRBa^pAMDjXm!-}zA0;1qhUA#EW9qw$c|bX#Q_DTivx+YfUuw`@!DTNunru`s-ODG%BElT=1hZywS$0FR3dkq?R)P>C1l}rR1i6>rX z(Aw_oqa1ss9QK7XQDv|sSVU4WTZA0j)1~sPraj#wEVpHl)iMo}lk4P>x!bKcEM^0S zW82IIA}k8GVv<~`%1l8zHv`e(fCjQTUh+TEJZ7d`Y2fp~KUL+xl;dSa|2uTy)kLek3gQ5jjar3;Awf^~t6 z&IfTZ%aCwRj#g5g#OVv&GX&X>kZVxxq8SGv+r3RtKIvUl59Z8Sa${|8=b{N-pt~m) zzNoPAX~JY51BQ|(QX?})U~v&Q_0tHTZBa{;r_@?@=VSrs9vm#-j@=v7nsZ@BS60h^ zBU)&Ax_5tV5{6T0ZKNIblk^|Tz^L0QL!@&n;#lIC>0{G>o7*Eu-Xd}wKuWXB^&wvJ zt?I+8C-!(62$Ykye~5sG^YsC`@_2-qmJhLfOurk@{F`&>mF6V|oO-G2IxKXvXlPHY z(vGZjwHvZLH;|adzQh^HaE3ihi15od)iCyf{c+k=x?I+b{DREZwGRjY=TpR=!M8#l zhVSljux?8oBm6l6ojN?SYdxhz01}s3;%X}IM953&76^S2{Q#}@_drH)1QeRCma;h8 z3x3g(SB|(M5>+1MwilHl%sIyqMe|qeWj^iZgZ;KYce zvV@ggh$kXl5r5M6|BYms`tuOjc?+nyL5(QBZm63-aX&n5CNd8PkEL`d2UMrgQ^%k8 zN|Bo--c}?Tsr#i_R>2>nh3!2gck>gk<@CJPQme!|gfJLN<*h6l)S9d{cF!6p%W(E0 zAnE5i=F!%&l?E*X5b>I3`;*)Z{zV&wk31_-_vacleUyIpQruM^{3Ops%(_0vCc~zd zmO&H+S|niF)%ML9i8i@u(+H)Dr*wnHrAm5+Z5(mg!SdW$3G!o;_Ez=s<-xt5Fi4l+ ziEJH3YFOkfQ>-cL+B_5&&JUKN0>j!yib>@iGJ2K1^tx6e6M+hE#5#dBD|aF9n>H%> zB<+QcH#~sl%}X|oETfODgJS;7poViVSg~}49QYQ*30LvI8V13rVNUyo)DEJT7!c^@ z5f(NyC&A|k22jy(4T6w$D4I0EIK4k4w3iE6_f_ZMmT?A=@Nn{-V^{a`^>%o`j^~wot;`A}URY{j`zCidv3>hXs;GIdqBo&giqU(W_N&F-8b}H^+6uRI zq&-_vMVRfW%Ok&KxY|R#7y02++29EOezh9k+qMnhat;zKDuf5YFvFZ;ocw*lv=t{uos)0BE}8h*SFaMNtm z^$!Q(RC8djgj(HT`i)#$aVPtYi*&EW$@uz)*)_>Bos1^5n|hFMr#1_~USkI}be{i7 zlIVsUb1T0~v-wZ{!rcPobSCzEKS-Ywjcou#4G69{I#0<-oDq|u=7zw}QC3gPfStH< zX|E$ojb_g7L9)tuMHC7 zfutD@G$j9;;C1Zc_t)Ukd#v3~YZwLO@MfcILl6!wbT$z<#YB#WGo!3$6jW#yb~4Yu zMOcYsw?PaznjkpkwE%i|W4H8&dii(gaBz%0RkiQ<=xWe(o29n=QHTOMEt>wLE8xZy z1mwdT=JYx0P0wKx-Tg%5eNe}z_GXQSqKlI@L4lF(8(OkISoa|0-Vm8dR1rh;8KTd7)Eqdh2 zY>i6kTJs3q!j`I3*|0fQhBI!{Iu+<%iEj0&v(%f-a~+6FAt6?UAB<`~;HwL@O!>zz z%tR~D!u>gKv0Ygy*k#7MoyTq_{RYJy`X1y={Vp3oj#WUPF0qJBk15bl0thUV3N563NM-!9KY3%kTZO zgA#g6wOe~(N(#`5Gvip+t^VnKg$^Yf;!d+gT3`E?41D|M|GA?(D8aKQ$&;+Z2>1a2 zgJVo~TUjde+*n9u1n3MP%QFNtCapphu}wjKTys#w*IPH0t+@iR%K7g903k--o>mXW znO!=b{Qs(wtUw!<@>oiwjC?B!La;wU{0}`TfOBoomsrmscv3BmKR&*_=X6rcT%{`) zOU%&Jd~-97?O{vrZ6%emL7y|OP1ij^RB`0D22rEs&2Uuf#>3~OG6W{MK04~Wrpe2!9LZbkbsST)r{su(9UHxdyn;cFC-2=;J87%;nX!)eBtMt9B-of zPO=;zJeZW*OXH>EO%Xk~!WS0u{qnKHctGcCI!{N#^$Plz~~VzfJA z^BBTigBd{aa6fnDM8hDU>hx+B=N)fGa+OrBK(Kd^7*;)Q!Xo#&Lq9vnm{JN7#&l5e zHfA9<0Y)@lP#qkdUWcY_o?a;MeMvoekM=bTN;TUtC~sL*zAzTHZ0yirMf{XGJ)S`3 zfuA{g`F+BtqG3yE3l0oisxqSR^}JuQpi4am+sB(4f8>+6t2p<^0I~Xfpgph zWq*V>6S66V-8~RG3*`?qc{9;%^c{be(vO!?LQRYD`U62Qlf~vd9owPy**IJyrINqo zAu{H%5uY*dWOV#`bRvn!lLXRfZ3ED`lgoRG+6+iiP*KR!w_2v|B#X8u-Ehcyl0>s9DwFWN~$!3}L+dUDgxIW}#4+-#V85Z)Gua@y*)L%dv(t!ZZ^ENqBB{ zbJKo@2>@W39?v+F_v7H~h{B}Q6mWe>{r_qq=oNdx!UX!6xe-Q4sSXsw3|LfS$!epB z6CE^2_;C#>br$ES2SdTnXcaKBGZGPC)QRs49a*Js%kte8v@7}W3BB%+P=1Ms%VDDZ z0Mfr38L_rSlnt_*)Rx>xw;`@PCo!^(j)zq9lj@UweyoZHzL1yLF4qwVvT1#g00^lb zY+47m0ryW{=mrXBh{Wa@40*^ar7|(H%b5--U!wP5+(viBmpq~&m6}YRevDyU#L{{2 z-ch1#rbnL%zGlsmi{JQO6{r|~vwBf+%<=Q1Rc)f5$&AB2Y!oR(0hQF*@JWiSE``RX zLYW|5cHbTk-P8Y@omTxje7|BC5wdd3Vp8H2tByWQ_Xg(vM{)uupwrR;jKagzGQF&D zgl4)^)pe103XSoG>?HBcD{@Mi)z_tY%W>3`PqEc$s_t6*r8uETdVpCmNA*~k4p8Km zq4!O~xv%A#AA{F@c3+JsyyKHRy|1p+9M2)} z1@^Gb{yM+gAHg&JsV{oKfdGfb*;hxL1+)U>WJz3VLLV|lrB~1s8pTJW+ST`@epaN~ zw3xO{IL7046&nT5eb=k^e`-PQvZ@T0H{X!s8uY922sNb=Sfm#AnJD=<5A3(#V7(Rn0iAZEYX8rP} z2;ytIScVe@dwr=Z+;jITf2?;d+UtlT4|2Lo_>b7EbjDnX0=9F}%N$n}8ARC9H>q)* zK*G~4Cl#qKu^6Y1W59zNX<1niTC+?-Ks6sIFP<^?eiG%7;N#kzHhTI_h0CNAsB{`? zc?!$bIJ~@Q3CL=OYS-kiLc~z0-I(8)u$8(Untx>ESRDBJ6W3v5HU~W`o=53dn;W~4 zK3tV*Vkzz>W6ECM(#zkLr7!f>MOx0DG^hGDkl9z32^L?27H#dQ<3q{GKox$xFtdL$ z_FaazUN6JmykP2+Z{;MoDUWqd-^~r7PE$e}yG|Aeox4%lm1|?gI!T(NJHQRHhZF}qqGwr<5Z%>EE8wMNp&R|3V7q?0;Z4iDb{^Tv ztJ}@sD(-7ju#lCOYs>aY{&-USi5)0f!wea&Yk&YT=%8;IT49LkRTfa-F6oi+@THz= zCu#A*WU+GI!Ac>t86TY>_q<`$I*U9B<;M94}rrx$S&F*w$w(Eav}G6Ak!x z1gE7w9eo}P068h1DwxJam)AXd?Xx7YWKu_&+dawk~ZlX)8y6fmV z@25JMnzI$ojwOHV)~ zeJ%}SRKn&0q;uhqt7lZcN?%Eb!&~!kSXoWIm3aSGx0`+nyEc&)vN9H1Z9KW}m38{l zY6~lrT>9VF=mwb_BnoM@dS50gqIm_b@AeV5+1jDa()TYEN$b$fUIt2tlwLXT4e3Hz|piD$vL=`aW~lm zDQ%;@{MW%F<|X#M-H_#Ae|fk~q?D<@wnn+j#c`t11dH>0Z|2z&be&@P-QsrSB_vT; z1d-5}!T77@JC=1OYMfBUO(>HH=a7JZ2=XnnJN`+<0XP(`i_dt^rBgMOs)1x6f>R?&S1DqiQlcjG*b24T<6w5t-N(=$<&4B^h%s^`7?nz+q{O*utLpBW z2DOd}H){~g7Vgsa_mQqsofwI83NrD&8^b5>nv~lfUb3C8KZ@VUz8Z}Zg6TUmr4d3G z&k;qyJFAiz^H?q-FI)!pLunj+P7wEP8xU)hS0MrrKW6?)l@r%-=&n z+D_9=bgr$}^8l#b@d{AYd3q4$Gw9AD4HYjNnf_8k`Qdw0G4G1;t_k1x!4?KwB7gkL0x+Dl zO3fDFY5lMd zBse|$O{t+fc5hY=kTIcbEh#81#bBFR}q5$_R&nQ%ppJ%zj$*86}| zQPH{c{b;VKk9fXQT@v#52cE(6OBlCC{&*N0Fz4A8rscu@)yKW$wNbVZUy|6P#zOoRfhdk5mi&ch#hmI@#Hwg)B zFFRFmDE(ayzfZ7%lcdkCEQ`#X!>KHWyZyzbbR-_BFz6|+25}Z!D+@5nds2>C$LO5D zW4TY_v(!L-6mD5`$0+MDp_G9ha$i5%DjjT5t9zl|>V{jJDN~hUHDVl)TtpAEZA;Kj zams+v;dkjx-3Piu$r|yTa=?t+-pv`A>Lj_lfgq$&M#N9RvY?l3)+s#-3Ebu_T00~I z7OW@+F3~s+fxSKwGwerMsxC`gk;e01drp@l$4JE0^#^C~Q~WD&k8t2ZH6(`;WafQW zAp3S+ufKm{R`FLmW%v$l0yN4ko`qEPpW2i~Zo6M_RGB{gV%-(dIJ=;qT{g~RP@wz7 z_V8ZTp)&zp^xXxIvFnHC)VV&{DawxS~O130{v# zzMfM2m@fmu{LW7UxGa5BET~KMN*_(n_$;elO}c-T&?zdKVpkn<(|R9Nmdb-+sN z{3;S*bvO_Y)t>5^K*s@-(}i}G%<0put_QH(2M(n8=_ui!l=TSAF*2vpjE6SnZ8}vY zS=KZig#Cj92G`=A=Pa6c(-9z@Dgm|LO;b$q;Kg%r%ivlWh_LpdNfNw#)4-6n%Y6T8a#FT=c}`!C zg{+)eF-7o1%#BYaVJL95L25FPZcLht;AV<QJLW}$;t*`$0^|!N7&vID0m-)>AUy*TI8ajw6KU$c(fn2gOCWdWXuOFAp|Lvzdr2AM7ei*G6x63q2fG&20?;2q9U}F8IY& zIcay<#jD22RFpG-ao_o6`onk5bhe z#&~3r4r4L2brts>(05Pk?c44`XN*r@!24m$)7t3|T6yPg3~3H}S+=j(TO-wgNsfr1 zKf?%HQ+*%+Z3k5)C)p|__3FDL1irpHN9*JqZp!E)4YM%;J-aZgx5LDDvU$5ba;kxb z<#G5DEoK#YFsQlA$|SU^f`$DHq80UMbM@b}`3Z)EygtR3df ziyd7`fVQRu!X#%84@!N;fAi?u6X#)Wi&06hNU)S0uul!;wiTYxredSe>HIEsdx_MAD%)emgR?XAp~H((8~>9ZrkRvDW2xnKXR zQh37bwSm}-CUxnzX@ZT}W6nj>W5NP{wiY6aBuKL;dEvS5#DeMOTma~cY`IPIXA!uBY+*LDqnjiO>SI9|j1ir75sN+DpWlUr`l5Ul-{xNS zn4YW32~(^b%S)#HeyYTx>Ij-Q5<){cCIB0jbh$bM&dG&7Gl@`zrKb`G)^_<%t!l8J zp9Jk#tS~tsaE3vx2Yethhl$c1jRKqaqT;mG66Sr#US`_)e<6^=OGA2SX%?AyRhMVinJGL;NW`EqTs3@i2w|b0x;x^v0w#QO;y3844hAd zeDLri<0S}G4)r_J!no;z)~j_BXFuyrM~q>Is973U-+K;cfqjwOqc=N8k$Ydf#rEn1 zd3qkS5SpbMKByekAQ39D@H2KzZ(fPvAn}iF;eVM@8hw6lnpEN;m0$%!NnY8k9+}IQ z=C0F!7&YHlT3pJx5UZU{Z|k@b2Za!iWpi@A&Q|q9bQ3w>o7C#kc+Oe(7FDU<*(2JN z{{p;~Xja~2?%?XpSaSbx_1KOkTSgG}>$R)JZ3%n@5nw9CTsgqg?}2wiP&lQs(*z`3 zN5_TpF(O_ZmJxzzHKcFu;M0Y!bG2Y~PGJ z^jtrxL~KUADzm=^hfs#0wUe~0Imm9=1YvUd*2u#YFuh!2yDh;T^g` z`C%gQwIQKvSmdq5{R<_NSXzX<7h5X!3oZ$W?2ZXXIHXk&hiP(-bqZZNZ+KGR!)ZEk z7fASm{_^%!SLb``HKRaP;8qeh`X+5UcxAjlRzm^CYW=)CYEG2JgvHs_hW&DY&1s_1 zwHUmV@a>=h%oi1|r&gJ;mm4gB#($Lyq#dk`F2?4`!t6)w4b8z+P*&Zxbb;{XwAcf2 zGM~L??T>;GK2*v6(2gaH4iEdB^DmS(Fi7oB{bC=5v!f_E>!nNJAK!CP;2f|Kmy)1A zg1}R@z;k0v^M$5ggYc(^4}63*Th(-HS|F{kSWfbNrWoaJoU7|e>Ih!!B8_;f^0P#hA)OTCW5UtV7E&6>0^d8o55ujFE+QdgBCO`DL>fq}84md$V*3nxwXVYcJ=*Ckn-M4*x(Z^lYPpU0aM z*YZ$(HYb2?{E@be;$H{a4qPG-u||+p+q$i#YCsg*`~*dC%dx(!dlu3%)B9qY{#*99 zWXSHtO|VoJiXi+Mtz^%7D|`LfJRD>p1fqqd(E-6}S_D?P&iP_d049lE^h1=-G*TQ6x1UE2F(VeO(k58VF zyP*I?;#w2zE^xl-t?=G5guD2jZLLfH;3&Z|l+w5{dDzZBE@5OFwl{6VOJ42}%>@d9Am2xlA9% zodoasF5OkXHOe^ERXv9umOBJE6qb2 z=S&p+U&b~TJpHf|7exn^|HcvYlOR8QJz8H&^ zo&E9MoQL+Y;2&k!c#=QcmDG;zbZdwksMkq zoZp+?{LsJIqTr^EHYAFRO)gI}cNZgBnhkA(*jEYCotdG2s=V$hbPcM{B1SGPDLTDB zF`EjBgtj{^&W29~X?}_|r8*x05yr=2yY#z(PuvyTaJ&Jb1qYQi!^LdYUtAO~KI>}{ zi_Qb7sm#D}z};`OHK5$Gv4y;HcE1qx%hQPAHn4AlE3ecn)Ae21ibg?LJWlKAG(M1bDF=4oM`*`y zJVZ=J8^`f00uHiwd&ZS+@QsWS+x0c@U& zZD$aFA-%wtX+NxMA!R#AO1b7f)a}*-#nZfZ)p+sFxE8NUIh=2x-n#P8%ix-m1~vlM zmaF_{P|&#-c;IhHeS>PyDJDQH;`0O}PZNM8`)n|RjHbx1PHgA1Nn57{!M}BRCBnn) zCv)OQDa=IE!cQm5_}NNpI)!#0hYI-)vk0&r6n3E5Z%$u?+aLvba z%L77x(-77Ct>Mav)KQ%?@F1mM#ENoAg!N?jSUk&o4vredSPm;oCuICcw(s&jFYlhH zbRS`aSHsY1)47>;u$fVaq503IyEWIRe@fF|-f}8-L2z$wD0H_wT$6O@uFBB#FN5V6 zJ*A1Iws$~gkC{>w5LB2w{q0UDFc(iw;Va9By2woz*Vh=ag^6>k$n#1e*1&0K^&Xi6 zZ#1^1MU|c^jPkS6STDx?somh?Malu0pA)RC0@IXR`P!!l+)z(lw65BEQmp)qLi#8t zpt~Ah=^9`LMlsl#`wMAZ+I5>W^z%o;JIee%TXk zwI>&me6G~u zRhtpM4R&{5E3@QPbjVVJF#Tc^Q?P`qgaNyh@UB}7dUH9VKlQ~;R%QYjX#TZvdD=PKfwP1LothR literal 0 HcmV?d00001 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..42512d3dad1ecece1c16ae2b148d57c54b450130 GIT binary patch literal 38 pcmezWubd&3A&H@wA)g_cAsfgp0kUm@(2&85L65 /etc/apt/sources.list.d/gazebo-stable.list + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ignition-fortress \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# -------- ROS 2 Humble repo + ros_gz (bridge) -------- +RUN mkdir -p /etc/apt/keyrings && \ + curl -fsSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key \ + | gpg --dearmor -o /etc/apt/keyrings/ros-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/ros-archive-keyring.gpg] \ + http://packages.ros.org/ros2/ubuntu $(. /etc/os-release && echo $UBUNTU_CODENAME) main" \ + > /etc/apt/sources.list.d/ros2.list + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ros-humble-desktop \ + ros-humble-ros-gz \ + python3-colcon-common-extensions \ + python3-rosdep \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +RUN rosdep init || true && rosdep update || true + +# Utilisateur +RUN useradd -m -s /bin/bash user && \ + echo "user:user" | chpasswd && \ + usermod -aG sudo user && \ + mkdir -p /home/user/.vnc && chown -R user:user /home/user + +# Affichage virtuel +ENV DISPLAY=:1 \ + VNC_PORT=5900 \ + NOVNC_PORT=6080 \ + VNC_PASSWORD=user \ + SCREEN_WIDTH=1280 \ + SCREEN_HEIGHT=800 \ + SCREEN_DEPTH=24 + +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 5900 6080 +CMD ["/entrypoint.sh"] \ No newline at end of file diff --git a/sim_env/docker-container-ros2/ros2/entrypoint.sh b/sim_env/docker-container-ros2/ros2/entrypoint.sh new file mode 100644 index 0000000..f3f6247 --- /dev/null +++ b/sim_env/docker-container-ros2/ros2/entrypoint.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -e + +echo "Starting ROS 2 Humble + Gazebo Fortress environment..." + +source /opt/ros/humble/setup.bash + +DISPLAY=${DISPLAY:-:1} +VNC_PORT=${VNC_PORT:-5900} +NOVNC_PORT=${NOVNC_PORT:-6080} +VNC_PASSWORD=${VNC_PASSWORD:-user} +SCREEN_WIDTH=${SCREEN_WIDTH:-1280} +SCREEN_HEIGHT=${SCREEN_HEIGHT:-800} +SCREEN_DEPTH=${SCREEN_DEPTH:-24} + +export DISPLAY + +echo "Display: $DISPLAY" +echo "Resolution: ${SCREEN_WIDTH}x${SCREEN_HEIGHT}x${SCREEN_DEPTH}" +echo "VNC port: $VNC_PORT" +echo "noVNC port: $NOVNC_PORT" + +mkdir -p /home/user/.vnc +chown -R user:user /home/user/.vnc + +PASSFILE=/home/user/.vnc/passwd +if [ ! -f "$PASSFILE" ]; then + x11vnc -storepasswd "$VNC_PASSWORD" "$PASSFILE" + chown user:user "$PASSFILE" + chmod 600 "$PASSFILE" +fi + +Xvfb $DISPLAY -screen 0 ${SCREEN_WIDTH}x${SCREEN_HEIGHT}x${SCREEN_DEPTH} \ + -ac +extension GLX +render -noreset & + +sleep 1 + +dbus-daemon --system --fork || true + +sudo -u user bash -c "export DISPLAY=$DISPLAY; startxfce4" & + +sleep 2 + +x11vnc \ + -display $DISPLAY \ + -rfbport $VNC_PORT \ + -rfbauth $PASSFILE \ + -forever \ + -shared \ + -repeat \ + -noxdamage \ + -bg + +websockify --web=/usr/share/novnc/ \ + $NOVNC_PORT localhost:$VNC_PORT & + +echo "" +echo "==============================================" +echo "Open in browser:" +echo "http://localhost:${NOVNC_PORT}/vnc.html" +echo "Password: ${VNC_PASSWORD}" +echo "==============================================" +echo "" + +tail -f /dev/null \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/__init__.py b/src/robocoop_backend/robocoop_backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/app/__init__.py b/src/robocoop_backend/robocoop_backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/app/auth.py b/src/robocoop_backend/robocoop_backend/app/auth.py new file mode 100644 index 0000000..fe75a0d --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/app/auth.py @@ -0,0 +1,9 @@ +# TODO: implement WebSocket token authentication. + +# Expected behavior: +# - client sends token in connection header or first message +# - validate against secret from security.params.yaml +# - reject connection (close 4001) if token invalid + +# TODO(SECURITY): use constant-time comparison to prevent timing attacks. +# TODO(SECURITY): log failed auth attempts with client IP. diff --git a/src/robocoop_backend/robocoop_backend/app/backend_context.py b/src/robocoop_backend/robocoop_backend/app/backend_context.py new file mode 100644 index 0000000..806a6d3 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/app/backend_context.py @@ -0,0 +1,17 @@ +# TODO: implement BackendContext (dependency container). + +# Expected attributes: +# adapter: RobotAdapter +# robot_state_store: RobotStateStore +# mission_state_store: MissionStateStore +# mode_manager: ModeManager +# teleop_service: TeleopService +# emergency_service: EmergencyService +# mission_service: MissionService +# mode_service: ModeService +# telemetry_service: TelemetryService +# watchdog_service: WatchdogService +# audit_service: AuditService + +# TODO: build context from config at startup (via adapter_factory). +# TODO: expose as singleton — one context per process. 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..9d6c2cf --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/app/message_router.py @@ -0,0 +1,13 @@ +# TODO: implement MessageRouter. + +# Expected behavior: +# - route inbound message by "type" field to correct service method +# - unknown type -> send ERR_INVALID_MESSAGE + +# Routing table: +# "teleop.move" -> teleop_service.handle_move() +# "mission.start" -> mission_service.start() +# "mission.cancel" -> mission_service.cancel() +# "mode.change" -> mode_service.request_transition() +# "emergency_stop" -> emergency_service.trigger() +# "ping" -> reply pong diff --git a/src/robocoop_backend/robocoop_backend/app/rate_limiter.py b/src/robocoop_backend/robocoop_backend/app/rate_limiter.py new file mode 100644 index 0000000..7d748c7 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/app/rate_limiter.py @@ -0,0 +1,9 @@ +# TODO: implement RateLimiter for inbound WS messages. + +# Expected behavior: +# - sliding window counter per client +# - configurable max messages/second (see security.params.yaml) +# - return True if allowed, False if rate exceeded +# - send ERR_RATE_LIMITED to client on rejection + +# Note: teleop.move is high-frequency — set limit accordingly (e.g. 50/s). diff --git a/src/robocoop_backend/robocoop_backend/app/server.py b/src/robocoop_backend/robocoop_backend/app/server.py new file mode 100644 index 0000000..dd8a996 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/app/server.py @@ -0,0 +1,21 @@ +# TODO: implement WebSocket server entrypoint. + +# Expected behavior: +# - init BackendContext from config +# - start websocket_handler on configured host/port +# - start watchdog_service timer +# - handle graceful shutdown (SIGTERM -> emergency_stop -> cleanup) + +# TODO(SAFETY): on any unhandled exception -> trigger emergency_stop before exit. + +import asyncio +import websockets +from websocket_handler import handler + +async def main(): + async with websockets.serve(handler, "localhost", 8765): + print("Server started on ws://localhost:8765") + await asyncio.Future() + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/app/websocket_handler.py b/src/robocoop_backend/robocoop_backend/app/websocket_handler.py new file mode 100644 index 0000000..8515156 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/app/websocket_handler.py @@ -0,0 +1,13 @@ +# TODO(SECURITY): implement websocket authentication. + +# TODO(SECURITY): validate incoming messages using schemas. + +# TODO: handle dashboard disconnect events. + +import json + +async def handler(websocket): + async for message in websocket: + data = json.loads(message) + if data.get("type") == "ping": + await websocket.send(json.dumps({"type": "pong"})) diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/__init__.py b/src/robocoop_backend/robocoop_backend/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/__init__.py b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/adapter_factory.py b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/adapter_factory.py new file mode 100644 index 0000000..6965842 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/adapter_factory.py @@ -0,0 +1,10 @@ +# TODO: implement AdapterFactory. + +# Expected behavior: +# - read adapter type from config (mock | sim | real) +# - return corresponding RobotAdapter instance + +# Example: +# "mock" -> MockRobotAdapter() +# "sim" -> SimRobotAdapter() +# "real" -> M3ProRobotAdapter() diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_robot_adapter.py b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_robot_adapter.py new file mode 100644 index 0000000..f7db56c --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_robot_adapter.py @@ -0,0 +1,8 @@ +# TODO: implement M3ProRobotAdapter (real hardware). + +# Expected behavior: +# - publish TeleopCommand to /cmd_vel as Twist +# - subscribe to /odom, /battery_state for state updates +# - call emergency_stop via dedicated ROS2 service or zero Twist + +# TODO(M3PRO): verify topic names against m3pro_topic_map.py before use. diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_topic_map.py b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_topic_map.py new file mode 100644 index 0000000..7ee2e3f --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_topic_map.py @@ -0,0 +1,13 @@ +# TODO(M3PRO): confirm real topic names on hardware. + +# Expected candidates (to verify): +# /cmd_vel -> geometry_msgs/msg/Twist +# /odom -> nav_msgs/msg/Odometry +# /scan -> sensor_msgs/msg/LaserScan +# /imu/data -> sensor_msgs/msg/Imu +# /battery_state -> sensor_msgs/msg/BatteryState + +# Verify with: +# ros2 topic list +# ros2 topic info +# ros2 interface show \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/mock_robot_adapter.py b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/mock_robot_adapter.py new file mode 100644 index 0000000..5d08e57 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/mock_robot_adapter.py @@ -0,0 +1,7 @@ +# TODO: implement MockRobotAdapter (in-memory, no ROS). + +# Expected behavior: +# - store state in memory +# - simulate battery drain over time +# - simulate obstacle detection randomly (configurable rate) +# - log all received commands diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/robot_adapter.py b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/robot_adapter.py new file mode 100644 index 0000000..d590545 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/robot_adapter.py @@ -0,0 +1,8 @@ +# TODO: define RobotAdapter abstract interface. + +# Expected abstract methods: +# send_velocity(command: TeleopCommand) -> None +# emergency_stop() -> None +# navigate_to(x: float, y: float) -> None +# get_state() -> RobotState +# is_connected() -> bool diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/sim_robot_adapter.py b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/sim_robot_adapter.py new file mode 100644 index 0000000..d721cf1 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/sim_robot_adapter.py @@ -0,0 +1,8 @@ +# TODO: implement SimRobotAdapter (Gazebo via ROS2 topics). + +# Expected behavior: +# - same interface as M3ProRobotAdapter +# - connect to simulated topics in Gazebo +# - useful for integration tests without hardware + +# Note: topic names should match real robot (see m3pro_topic_map.py). diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/__init__.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/emergency_stop_node.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/emergency_stop_node.py new file mode 100644 index 0000000..63f940b --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/infrastructure/ros/emergency_stop_node.py @@ -0,0 +1,9 @@ +# TODO: implement EmergencyStopNode (ROS2 node). + +# Expected behavior: +# - subscribe to internal /emergency_stop topic +# - on message: publish zero Twist to /cmd_vel immediately +# - this node is the last safety net — must be as simple as possible + +# TODO(SAFETY): this node must NOT depend on any service or store. +# Direct ROS2 publish only. diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/launch_manager.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/launch_manager.py new file mode 100644 index 0000000..e58fd60 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/infrastructure/ros/launch_manager.py @@ -0,0 +1,8 @@ +# TODO: implement LaunchManager. + +# Expected behavior: +# - programmatically start/stop ROS2 nodes at runtime +# - used to activate navigation stack on AUTONOMOUS mode +# - used to teardown nodes on shutdown + +# TODO: wrap ros2launch API or use subprocess with proper cleanup. diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/mode_bridge_node.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/mode_bridge_node.py new file mode 100644 index 0000000..c5db8c4 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/infrastructure/ros/mode_bridge_node.py @@ -0,0 +1,8 @@ +# TODO: implement ModeBridgeNode (ROS2 node). + +# Expected behavior: +# - subscribe to /robot_mode topic +# - forward mode change to mode_manager (state layer) +# - publish current mode on /robot_mode when mode_manager updates + +# Note: this node only bridges — no transition logic here. diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/robot_state_node.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/robot_state_node.py new file mode 100644 index 0000000..4ba2935 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/infrastructure/ros/robot_state_node.py @@ -0,0 +1,8 @@ +# TODO: implement RobotStateNode (ROS2 node). + +# Expected behavior: +# - subscribe to /connection_state or ping robot periodically +# - update robot_state_store.is_connected on change +# - trigger alert on disconnect + +# TODO: publish connection state changes to watchdog_node. diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/telemetry_bridge_node.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/telemetry_bridge_node.py new file mode 100644 index 0000000..9a77a91 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/infrastructure/ros/telemetry_bridge_node.py @@ -0,0 +1,11 @@ +# TODO(M3PRO): subscribe to robot telemetry topics. + +# Expected: +# /odom +# /battery_state +# /scan +# /imu/data + +# TODO: forward telemetry to telemetry_service. + +# TODO: update robot_state_store with latest data. \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/teleop_bridge_node.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/teleop_bridge_node.py new file mode 100644 index 0000000..098e501 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/infrastructure/ros/teleop_bridge_node.py @@ -0,0 +1,6 @@ +# TODO(M3PRO): confirm velocity command topic (likely /cmd_vel). + +# TODO: convert TeleopCommand -> Twist message. + +# TODO(SAFETY): stop robot if no command received for X seconds +# (dead man's switch). \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/watchdog_node.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/watchdog_node.py new file mode 100644 index 0000000..243d475 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/infrastructure/ros/watchdog_node.py @@ -0,0 +1,8 @@ +# TODO: implement WatchdogNode (ROS2 node). + +# Expected behavior: +# - timer at configurable interval (e.g. 1s) +# - check last_heartbeat from robot_state_store +# - if delta > timeout -> call emergency_service.trigger("watchdog_timeout") + +# TODO(SAFETY): watchdog timer must survive WS handler exceptions. diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/__init__.py b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/error_messages.py b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/error_messages.py new file mode 100644 index 0000000..26c2bed --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/error_messages.py @@ -0,0 +1,9 @@ +# TODO: define error code constants. + +# Expected codes: +# ERR_INVALID_MESSAGE = "invalid_message" +# ERR_UNAUTHORIZED = "unauthorized" +# ERR_MODE_FORBIDDEN = "mode_forbidden" +# ERR_MISSION_ACTIVE = "mission_already_active" +# ERR_EMERGENCY_STOP = "robot_in_emergency_stop" +# ERR_RATE_LIMITED = "rate_limited" diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/events.py b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/events.py new file mode 100644 index 0000000..c358f39 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/events.py @@ -0,0 +1,17 @@ +# TODO: define WebSocket event type constants. + +# Inbound event types: +# "teleop.move" +# "mission.start" +# "mission.cancel" +# "mode.change" +# "emergency_stop" +# "ping" + +# Outbound event types: +# "robot_state_update" +# "mission_update" +# "alert_event" +# "mode_changed" +# "error" +# "pong" diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/inbound.py b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/inbound.py new file mode 100644 index 0000000..1fe60bd --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/inbound.py @@ -0,0 +1,4 @@ +# TODO: define schema for mission.start message. + + +# TODO: define schema for teleop.move message. \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/outbound.py b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/outbound.py new file mode 100644 index 0000000..a5457b9 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/outbound.py @@ -0,0 +1,8 @@ +# TODO: define outbound WebSocket message schemas (server -> dashboard). + +# Expected schemas: +# robot_state_update { mode, battery, position, velocity, is_connected } +# mission_update { mission_id, state, failure_reason? } +# alert_event { id, severity, message, location, timestamp } +# mode_changed { previous_mode, new_mode, actor } +# error { code, message } diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/validation.py b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/validation.py new file mode 100644 index 0000000..bc9dd89 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/validation.py @@ -0,0 +1,7 @@ +# TODO: implement validate_inbound(message: dict) -> bool. + +# Expected behavior: +# - check "type" field exists and is a known event type +# - validate payload against matching schema +# - return False + log warning on invalid message +# - never raise — caller decides what to do diff --git a/src/robocoop_backend/robocoop_backend/modules/__init__.py b/src/robocoop_backend/robocoop_backend/modules/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/modules/audit/__init__.py b/src/robocoop_backend/robocoop_backend/modules/audit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/modules/audit/audit_logger.py b/src/robocoop_backend/robocoop_backend/modules/audit/audit_logger.py new file mode 100644 index 0000000..0067ce6 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/audit/audit_logger.py @@ -0,0 +1,4 @@ +# TODO(AUDIT): log critical actions: +# - mission start +# - mode change +# - emergency stop \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/modules/audit/audit_service.py b/src/robocoop_backend/robocoop_backend/modules/audit/audit_service.py new file mode 100644 index 0000000..68daa7e --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/audit/audit_service.py @@ -0,0 +1,8 @@ +# TODO: implement AuditService. + +# Expected methods: +# record(event: AuditEvent) -> None +# - forward to audit_logger +# - non-blocking (fire and forget) + +# TODO(AUDIT): do not let audit failures block critical operations. diff --git a/src/robocoop_backend/robocoop_backend/modules/audit/domain/__init__.py b/src/robocoop_backend/robocoop_backend/modules/audit/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/modules/audit/domain/audit_event.py b/src/robocoop_backend/robocoop_backend/modules/audit/domain/audit_event.py new file mode 100644 index 0000000..7cb6a6d --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/audit/domain/audit_event.py @@ -0,0 +1,8 @@ +# TODO: define AuditEvent dataclass. + +# Expected fields: +# id: str +# action: str # e.g. "mission.start", "mode.change", "emergency_stop" +# actor: str # "dashboard" | "watchdog" | "system" +# payload: dict # action-specific data +# timestamp: datetime diff --git a/src/robocoop_backend/robocoop_backend/modules/audit/event_formatter.py b/src/robocoop_backend/robocoop_backend/modules/audit/event_formatter.py new file mode 100644 index 0000000..c2b310c --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/audit/event_formatter.py @@ -0,0 +1,9 @@ +# TODO: implement EventFormatter. + +# Expected methods: +# format(event: AuditEvent) -> dict +# - serialize AuditEvent to JSON-serializable dict +# - include iso timestamp +# - flatten payload fields at top level + +# TODO: ensure no sensitive data leaks into audit log (e.g. auth tokens). diff --git a/src/robocoop_backend/robocoop_backend/modules/audit/sinks.py b/src/robocoop_backend/robocoop_backend/modules/audit/sinks.py new file mode 100644 index 0000000..9d5be35 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/audit/sinks.py @@ -0,0 +1,8 @@ +# TODO: define AuditSink abstract interface + implementations. + +# Expected sinks: +# FileSink -> append JSON lines to rotating log file +# ConsoleSink -> print to stdout (dev/debug only) + +# TODO: FileSink path configurable via backend.params.yaml. +# TODO: sinks must be non-blocking (write in background thread). diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/__init__.py b/src/robocoop_backend/robocoop_backend/modules/mission/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/domain/__init__.py b/src/robocoop_backend/robocoop_backend/modules/mission/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_failure.py b/src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_failure.py new file mode 100644 index 0000000..f136191 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_failure.py @@ -0,0 +1,9 @@ +# TODO: define MissionFailureReason enum. + +# Expected values: +# OBSTACLE_DETECTED +# BATTERY_LOW +# TIMEOUT +# NAVIGATION_ERROR +# EMERGENCY_STOP +# MANUAL_CANCEL diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_state.py b/src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_state.py new file mode 100644 index 0000000..70b9567 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_state.py @@ -0,0 +1,9 @@ +# TODO: define MissionState enum. + +# Expected values: +# IDLE +# RUNNING +# BLOCKED +# COMPLETED +# FAILED +# CANCELLED diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/mission_service.py b/src/robocoop_backend/robocoop_backend/modules/mission/mission_service.py new file mode 100644 index 0000000..94d6b77 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/mission/mission_service.py @@ -0,0 +1,8 @@ +# TODO(NAV): implement mission type "navigate_to". +# Should send goal to robot_adapter.navigate_to(). + + +# TODO(NAV): define mission lifecycle events +# (STARTED, BLOCKED, COMPLETED, FAILED). + +# TODO(SAFETY): prevent mission start if robot is in EMERGENCY_STOP. \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/mission_state_machine.py b/src/robocoop_backend/robocoop_backend/modules/mission/mission_state_machine.py new file mode 100644 index 0000000..7484950 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/mission/mission_state_machine.py @@ -0,0 +1,10 @@ +# TODO: implement mission state transitions. + +# Example: +# IDLE -> RUNNING +# RUNNING -> COMPLETED +# RUNNING -> FAILED +# RUNNING -> BLOCKED + + +# TODO: define failure reasons (obstacle, battery_low, timeout). \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/mission_state_store.py b/src/robocoop_backend/robocoop_backend/modules/mission/mission_state_store.py new file mode 100644 index 0000000..8c3b27c --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/mission/mission_state_store.py @@ -0,0 +1,9 @@ +# TODO: implement MissionStateStore. + +# Expected methods: +# get_current() -> MissionState +# set(state: MissionState) -> None +# get_active_mission() -> dict | None +# clear() -> None + +# TODO(CONCURRENCY): use thread_safe_lock for all access. diff --git a/src/robocoop_backend/robocoop_backend/modules/mode/__init__.py b/src/robocoop_backend/robocoop_backend/modules/mode/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/modules/mode/mode_manager.py b/src/robocoop_backend/robocoop_backend/modules/mode/mode_manager.py new file mode 100644 index 0000000..ee072ac --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/mode/mode_manager.py @@ -0,0 +1,10 @@ +# TODO: define allowed robot mode transitions. + +# Example: +# IDLE -> MANUAL +# MANUAL -> AUTONOMOUS +# ANY -> EMERGENCY_STOP + +# TODO(SAFETY): watchdog must be able to force EMERGENCY_STOP. + +# TODO: ensure thread-safe access to mode state. \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/modules/mode/mode_service.py b/src/robocoop_backend/robocoop_backend/modules/mode/mode_service.py new file mode 100644 index 0000000..4efbf20 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/mode/mode_service.py @@ -0,0 +1,8 @@ +# TODO: implement ModeService. + +# Expected methods: +# request_transition(target: RobotMode, actor: str) -> bool +# - validate transition via mode_manager +# - apply if valid +# - emit audit event on success +# - return False if transition not allowed diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/__init__.py b/src/robocoop_backend/robocoop_backend/modules/robot/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/domain/__init__.py b/src/robocoop_backend/robocoop_backend/modules/robot/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/domain/connection_state.py b/src/robocoop_backend/robocoop_backend/modules/robot/domain/connection_state.py new file mode 100644 index 0000000..43bfd85 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/robot/domain/connection_state.py @@ -0,0 +1,6 @@ +# TODO: define ConnectionState enum. + +# Expected values: +# CONNECTED +# DISCONNECTED +# RECONNECTING diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_mode.py b/src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_mode.py new file mode 100644 index 0000000..5f62a34 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_mode.py @@ -0,0 +1,7 @@ +# TODO: define RobotMode enum. + +# Expected values: +# IDLE +# MANUAL +# AUTONOMOUS +# EMERGENCY_STOP diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_state.py b/src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_state.py new file mode 100644 index 0000000..91e662f --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_state.py @@ -0,0 +1,10 @@ +# TODO: define RobotState dataclass. + +# Expected fields: +# mode: RobotMode +# battery_level: float # 0.0 - 100.0 +# position: tuple[float, float] +# linear_velocity: float +# angular_velocity: float +# is_connected: bool +# last_updated: datetime diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/domain/teleop_command.py b/src/robocoop_backend/robocoop_backend/modules/robot/domain/teleop_command.py new file mode 100644 index 0000000..2a56550 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/robot/domain/teleop_command.py @@ -0,0 +1,7 @@ +# TODO: define TeleopCommand dataclass. + +# Expected fields: +# linear_x: float # forward/backward -1.0 to 1.0 +# linear_y: float # strafe -1.0 to 1.0 +# angular_z: float # rotation -1.0 to 1.0 +# speed_factor: float # global multiplier 0.0 to 1.0 diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/robot_state_store.py b/src/robocoop_backend/robocoop_backend/modules/robot/robot_state_store.py new file mode 100644 index 0000000..6a43d3e --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/robot/robot_state_store.py @@ -0,0 +1,8 @@ +# TODO: implement RobotStateStore (single source of truth for robot state). + +# Expected methods: +# get() -> RobotState +# update(partial: dict) -> None +# reset() -> None + +# TODO(CONCURRENCY): use thread_safe_lock for all read/write access. diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/telemetry_service.py b/src/robocoop_backend/robocoop_backend/modules/robot/telemetry_service.py new file mode 100644 index 0000000..2a98dd4 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/robot/telemetry_service.py @@ -0,0 +1,7 @@ +# TODO: implement TelemetryService. + +# Expected methods: +# on_telemetry_received(data: dict) -> None +# - update robot_state_store +# - check battery threshold -> emit WARNING alert if < 20% +# - broadcast updated state to connected dashboard via websocket diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/teleop_service.py b/src/robocoop_backend/robocoop_backend/modules/robot/teleop_service.py new file mode 100644 index 0000000..e422f55 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/robot/teleop_service.py @@ -0,0 +1,9 @@ +# TODO: implement TeleopService. + +# Expected methods: +# handle_move(command: TeleopCommand) -> None +# - reject if mode != MANUAL +# - forward to robot_adapter.send_velocity() + +# TODO(SAFETY): reject commands if robot is in EMERGENCY_STOP. +# TODO(SAFETY): validate speed_factor is within [0.0, 1.0]. diff --git a/src/robocoop_backend/robocoop_backend/modules/safety/__init__.py b/src/robocoop_backend/robocoop_backend/modules/safety/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/modules/safety/domain/__init__.py b/src/robocoop_backend/robocoop_backend/modules/safety/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/modules/safety/domain/alert.py b/src/robocoop_backend/robocoop_backend/modules/safety/domain/alert.py new file mode 100644 index 0000000..36d9e02 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/safety/domain/alert.py @@ -0,0 +1,11 @@ +# TODO: define Alert dataclass. + +# Expected fields: +# id: str +# severity: AlertSeverity # INFO | WARNING | CRITICAL +# message: str +# location: str | None # e.g. "Couloir B" +# timestamp: datetime +# resolved: bool + +# TODO: define AlertSeverity enum. diff --git a/src/robocoop_backend/robocoop_backend/modules/safety/emergency_service.py b/src/robocoop_backend/robocoop_backend/modules/safety/emergency_service.py new file mode 100644 index 0000000..471008f --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/safety/emergency_service.py @@ -0,0 +1,10 @@ +# TODO: implement EmergencyService. + +# Expected methods: +# trigger(reason: str, actor: str) -> None +# - call robot_adapter.emergency_stop() +# - force mode to EMERGENCY_STOP via mode_manager +# - cancel active mission via mission_state_store +# - emit audit event + +# TODO(SAFETY): this must never fail silently — log + re-raise on error. diff --git a/src/robocoop_backend/robocoop_backend/modules/safety/watchdog_service.py b/src/robocoop_backend/robocoop_backend/modules/safety/watchdog_service.py new file mode 100644 index 0000000..f55ea16 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/safety/watchdog_service.py @@ -0,0 +1,9 @@ +# TODO: implement WatchdogService. + +# Expected behavior: +# - monitor last heartbeat timestamp from dashboard +# - if no message received for X seconds -> trigger emergency_service +# - monitor robot connection state -> alert on disconnect + +# TODO(SAFETY): watchdog timeout must be configurable (see common.params.yaml). +# TODO(SAFETY): watchdog must run in its own thread/timer, independent of WS loop. diff --git a/src/robocoop_backend/robocoop_backend/tests/__init__.py b/src/robocoop_backend/robocoop_backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/tests/fixtures/__init__.py b/src/robocoop_backend/robocoop_backend/tests/fixtures/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_messages.py b/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_messages.py new file mode 100644 index 0000000..94b1f07 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_messages.py @@ -0,0 +1,9 @@ +# TODO: define fake inbound WebSocket messages for tests. + +# Expected: +# MSG_TELEOP_MOVE -> type="teleop.move", linear_x=0.5, angular_z=0.0 +# MSG_TELEOP_INVALID -> type="teleop.move", linear_x=5.0 (out of range) +# MSG_EMERGENCY_STOP -> type="emergency_stop" +# MSG_PING -> type="ping" +# MSG_UNKNOWN_TYPE -> type="unknown.event" +# MSG_MISSING_TYPE -> no "type" field diff --git a/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_mission_data.py b/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_mission_data.py new file mode 100644 index 0000000..6adba05 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_mission_data.py @@ -0,0 +1,6 @@ +# TODO: define fake mission payloads for tests. + +# Expected: +# MISSION_DELIVERY -> type=delivery, target="Chambre 302", content="Médicaments" +# MISSION_GUIDANCE -> type=guidance, target="Patient A" +# MISSION_INVALID -> missing required fields (for validation tests) diff --git a/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_robot_state.py b/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_robot_state.py new file mode 100644 index 0000000..3c48903 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_robot_state.py @@ -0,0 +1,9 @@ +# TODO: define fake RobotState instances for tests. + +# Expected: +# ROBOT_IDLE -> mode=IDLE, battery=80.0, connected=True +# ROBOT_MANUAL -> mode=MANUAL, battery=60.0, connected=True +# ROBOT_AUTONOMOUS -> mode=AUTONOMOUS, battery=55.0, connected=True +# ROBOT_EMERGENCY -> mode=EMERGENCY_STOP, battery=30.0, connected=True +# ROBOT_DISCONNECTED -> mode=IDLE, battery=0.0, connected=False +# ROBOT_LOW_BATTERY -> mode=AUTONOMOUS, battery=15.0, connected=True diff --git a/src/robocoop_backend/robocoop_backend/tests/integration/__init__.py b/src/robocoop_backend/robocoop_backend/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/tests/integration/test_emergency_stop_flow.py b/src/robocoop_backend/robocoop_backend/tests/integration/test_emergency_stop_flow.py new file mode 100644 index 0000000..648c1f9 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/integration/test_emergency_stop_flow.py @@ -0,0 +1,12 @@ +# TODO: integration test — emergency stop full flow. + +# Scenario: +# 1. robot in AUTONOMOUS mode, mission RUNNING +# 2. emergency_stop triggered (from dashboard or watchdog) +# 3. assert: adapter.emergency_stop() called +# 4. assert: mode -> EMERGENCY_STOP +# 5. assert: mission -> FAILED (reason: EMERGENCY_STOP) +# 6. assert: audit event recorded +# 7. assert: dashboard receives mode_changed + mission_update + +# Use MockRobotAdapter + fake websocket client. diff --git a/src/robocoop_backend/robocoop_backend/tests/integration/test_mock_adapter_flow.py b/src/robocoop_backend/robocoop_backend/tests/integration/test_mock_adapter_flow.py new file mode 100644 index 0000000..ab31915 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/integration/test_mock_adapter_flow.py @@ -0,0 +1,10 @@ +# TODO: integration test — full flow with MockRobotAdapter. + +# Scenario: +# 1. start server with mock adapter +# 2. connect dashboard client +# 3. send mode.change -> MANUAL +# 4. send teleop.move commands +# 5. assert: mock adapter received velocity commands +# 6. assert: robot_state_store updated after each telemetry tick +# 7. assert: battery drain simulation progresses diff --git a/src/robocoop_backend/robocoop_backend/tests/integration/test_sim_adapter_flow.py b/src/robocoop_backend/robocoop_backend/tests/integration/test_sim_adapter_flow.py new file mode 100644 index 0000000..9ae1c04 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/integration/test_sim_adapter_flow.py @@ -0,0 +1,10 @@ +# TODO: integration test — flow with SimRobotAdapter (Gazebo). + +# Note: requires running Gazebo instance — skip in CI by default. +# Mark with @pytest.mark.requires_sim + +# Scenario: +# 1. connect to simulated /cmd_vel, /odom topics +# 2. send teleop commands via websocket +# 3. assert: robot moves in simulation (odom changes) +# 4. assert: telemetry flows back to dashboard diff --git a/src/robocoop_backend/robocoop_backend/tests/integration/test_websocket_teleop_flow.py b/src/robocoop_backend/robocoop_backend/tests/integration/test_websocket_teleop_flow.py new file mode 100644 index 0000000..fb7e3df --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/integration/test_websocket_teleop_flow.py @@ -0,0 +1,11 @@ +# TODO: integration test — WebSocket teleop end-to-end. + +# Scenario: +# 1. start server (mock adapter) +# 2. authenticate websocket client +# 3. switch to MANUAL mode +# 4. send teleop.move at 20Hz for 1 second +# 5. assert: all commands received by adapter +# 6. assert: no ERR_RATE_LIMITED (within allowed rate) +# 7. disconnect client +# 8. assert: watchdog triggers emergency_stop after timeout diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/__init__.py b/src/robocoop_backend/robocoop_backend/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_message_router.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_message_router.py new file mode 100644 index 0000000..1c6796f --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/test_message_router.py @@ -0,0 +1,7 @@ +# TODO: unit tests for MessageRouter. + +# Cases to cover: +# - known type routes to correct service method +# - unknown type returns ERR_INVALID_MESSAGE +# - missing "type" field returns ERR_INVALID_MESSAGE +# - each route handler called with correct parsed payload diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_mission_state_machine.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_mission_state_machine.py new file mode 100644 index 0000000..ef989f2 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/test_mission_state_machine.py @@ -0,0 +1,9 @@ +# TODO: unit tests for MissionStateMachine. + +# Cases to cover: +# - IDLE -> RUNNING on valid start +# - RUNNING -> COMPLETED on success +# - RUNNING -> FAILED with correct reason (obstacle, battery_low, timeout) +# - RUNNING -> BLOCKED and resume +# - reject invalid transition (e.g. IDLE -> COMPLETED) +# - EMERGENCY_STOP cancels active mission diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_mode_manager.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_mode_manager.py new file mode 100644 index 0000000..6331814 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/test_mode_manager.py @@ -0,0 +1,9 @@ +# TODO: unit tests for ModeManager. + +# Cases to cover: +# - IDLE -> MANUAL allowed +# - MANUAL -> AUTONOMOUS allowed +# - AUTONOMOUS -> MANUAL allowed +# - ANY -> EMERGENCY_STOP always allowed +# - EMERGENCY_STOP -> IDLE not allowed without explicit reset +# - concurrent transition requests (thread safety) diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_robot_state_store.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_robot_state_store.py new file mode 100644 index 0000000..f451aec --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/test_robot_state_store.py @@ -0,0 +1,7 @@ +# TODO: unit tests for RobotStateStore. + +# Cases to cover: +# - get() returns default state on init +# - update() partial fields only +# - concurrent read/write does not corrupt state +# - reset() returns to default diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_schema_validation.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_schema_validation.py new file mode 100644 index 0000000..d934ff3 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/test_schema_validation.py @@ -0,0 +1,8 @@ +# TODO: unit tests for schemas/validation.py. + +# Cases to cover: +# - valid teleop.move message passes +# - valid mission.start message passes +# - linear_x out of [-1.0, 1.0] fails +# - missing required field fails +# - extra unknown fields -> accepted or rejected (define policy) diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_teleop_service.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_teleop_service.py new file mode 100644 index 0000000..7afec69 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/test_teleop_service.py @@ -0,0 +1,8 @@ +# TODO: unit tests for TeleopService. + +# Cases to cover: +# - valid move command in MANUAL mode -> forwarded to adapter +# - move command rejected if mode != MANUAL +# - move command rejected if EMERGENCY_STOP +# - speed_factor out of range -> rejected +# - adapter call verified (mock adapter) diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_watchdog_service.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_watchdog_service.py new file mode 100644 index 0000000..c81dbb5 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/test_watchdog_service.py @@ -0,0 +1,7 @@ +# TODO: unit tests for WatchdogService. + +# Cases to cover: +# - no timeout if heartbeat received within window +# - triggers emergency_stop after timeout +# - resumes monitoring after reconnect +# - timeout threshold is read from config diff --git a/src/robocoop_backend/robocoop_backend/utils/config.py b/src/robocoop_backend/robocoop_backend/utils/config.py new file mode 100644 index 0000000..9d2d9be --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/utils/config.py @@ -0,0 +1,8 @@ +# TODO: implement Config loader. + +# Expected behavior: +# - load YAML params file based on ROBOCOOP_ENV env var (mock | sim | real) +# - merge with common.params.yaml +# - expose typed getters: get_str(), get_int(), get_float(), get_bool() + +# TODO: raise clear error on missing required key at startup. diff --git a/src/robocoop_backend/robocoop_backend/utils/enums.py b/src/robocoop_backend/robocoop_backend/utils/enums.py new file mode 100644 index 0000000..6d93efd --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/utils/enums.py @@ -0,0 +1,5 @@ +# TODO: shared enum utilities if needed. + +# Example: +# - safe_parse(enum_class, value) -> enum member | None +# (avoid KeyError on unknown values from ROS messages) diff --git a/src/robocoop_backend/robocoop_backend/utils/ids.py b/src/robocoop_backend/robocoop_backend/utils/ids.py new file mode 100644 index 0000000..d65ec67 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/utils/ids.py @@ -0,0 +1,6 @@ +# TODO: implement ID generation helpers. + +# Expected: +# generate_mission_id() -> str # e.g. "mission_" +# generate_alert_id() -> str # e.g. "alert_" +# generate_event_id() -> str # e.g. "event_" diff --git a/src/robocoop_backend/robocoop_backend/utils/logger.py b/src/robocoop_backend/robocoop_backend/utils/logger.py new file mode 100644 index 0000000..f5ed6d0 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/utils/logger.py @@ -0,0 +1,8 @@ +# TODO: implement structured logger wrapper. + +# Expected behavior: +# - wrap Python logging with consistent format: [LEVEL] [module] message +# - log to stdout + optional file sink +# - expose get_logger(name: str) -> Logger + +# TODO: log level configurable via common.params.yaml. diff --git a/src/robocoop_backend/robocoop_backend/utils/thread_safe_lock.py b/src/robocoop_backend/robocoop_backend/utils/thread_safe_lock.py new file mode 100644 index 0000000..50d65a4 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/utils/thread_safe_lock.py @@ -0,0 +1,6 @@ +# TODO: implement ThreadSafeLock context manager wrapper. + +# Expected behavior: +# - wrap threading.RLock +# - expose acquire/release via context manager (__enter__/__exit__) +# - log warning if lock held > threshold (e.g. 100ms) diff --git a/src/robocoop_backend/robocoop_backend/utils/time.py b/src/robocoop_backend/robocoop_backend/utils/time.py new file mode 100644 index 0000000..c9d215e --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/utils/time.py @@ -0,0 +1,6 @@ +# TODO: implement time helpers. + +# Expected: +# now_utc() -> datetime # timezone-aware UTC datetime +# now_iso() -> str # ISO 8601 string for serialization +# elapsed_seconds(since: datetime) -> float diff --git a/src/robocoop_backend/setup.py b/src/robocoop_backend/setup.py new file mode 100644 index 0000000..94003c0 --- /dev/null +++ b/src/robocoop_backend/setup.py @@ -0,0 +1,17 @@ +from setuptools import find_packages, setup + +setup( + name="robocoop_backend", + version="0.1.0", + packages=find_packages(exclude=["robocoop_backend.tests*"]), + install_requires=[ + "websockets", + "pyyaml", + ], + extras_require={ + "ros": ["rclpy"], + }, + python_requires=">=3.10", + description="Robocoop WebSocket backend.", + license="Apache-2.0", +) diff --git a/src/robocoop_bringup/CMakeLists.txt b/src/robocoop_bringup/CMakeLists.txt new file mode 100644 index 0000000..151b872 --- /dev/null +++ b/src/robocoop_bringup/CMakeLists.txt @@ -0,0 +1,11 @@ +cmake_minimum_required(VERSION 3.8) +project(robocoop_bringup) + +find_package(ament_cmake REQUIRED) + +install( + DIRECTORY launch config scripts + DESTINATION share/${PROJECT_NAME} +) + +ament_package() diff --git a/src/robocoop_bringup/config/backend.params.yaml b/src/robocoop_bringup/config/backend.params.yaml new file mode 100644 index 0000000..857909f --- /dev/null +++ b/src/robocoop_bringup/config/backend.params.yaml @@ -0,0 +1,6 @@ +# TODO: define WebSocket server parameters. + +# Expected keys: +# host: "0.0.0.0" +# port: 8765 +# max_connections: 5 diff --git a/src/robocoop_bringup/config/common.params.yaml b/src/robocoop_bringup/config/common.params.yaml new file mode 100644 index 0000000..66b543a --- /dev/null +++ b/src/robocoop_bringup/config/common.params.yaml @@ -0,0 +1,7 @@ +# TODO: define common parameters shared across all environments. + +# Expected keys: +# watchdog_timeout_seconds: 3 +# battery_warning_threshold: 20.0 +# log_level: "INFO" +# audit_log_path: "/var/log/robocoop/audit.jsonl" diff --git a/src/robocoop_bringup/config/m3pro_topics.yaml b/src/robocoop_bringup/config/m3pro_topics.yaml new file mode 100644 index 0000000..9afc628 --- /dev/null +++ b/src/robocoop_bringup/config/m3pro_topics.yaml @@ -0,0 +1,10 @@ +# TODO: map semantic names to real M3Pro topic names. +# Update after running: ros2 topic list on hardware. + +# candidates: +# cmd_vel: "/cmd_vel" +# odom: "/odom" +# scan: "/scan" +# imu: "/imu/data" +# battery: "/battery_state" +# camera: "/camera/image_raw" diff --git a/src/robocoop_bringup/config/mock.params.yaml b/src/robocoop_bringup/config/mock.params.yaml new file mode 100644 index 0000000..774e28c --- /dev/null +++ b/src/robocoop_bringup/config/mock.params.yaml @@ -0,0 +1,6 @@ +# TODO: mock adapter parameters. + +# Expected keys: +# adapter: "mock" +# mock_battery_drain_rate: 0.1 # % per second +# mock_obstacle_rate: 0.01 # probability per telemetry tick diff --git a/src/robocoop_bringup/config/real.params.yaml b/src/robocoop_bringup/config/real.params.yaml new file mode 100644 index 0000000..47aa875 --- /dev/null +++ b/src/robocoop_bringup/config/real.params.yaml @@ -0,0 +1,10 @@ +# TODO: real M3Pro hardware parameters. + +# Expected keys: +# adapter: "real" +# ros_domain_id: 42 +# cmd_vel_topic: "/cmd_vel" +# odom_topic: "/odom" +# battery_topic: "/battery_state" + +# TODO(M3PRO): confirm topic names after ros2 topic list on hardware. diff --git a/src/robocoop_bringup/config/security.params.yaml b/src/robocoop_bringup/config/security.params.yaml new file mode 100644 index 0000000..8ae69d3 --- /dev/null +++ b/src/robocoop_bringup/config/security.params.yaml @@ -0,0 +1,6 @@ +# TODO: define security parameters. + +# Expected keys: +# auth_token: "" # override via env var ROBOCOOP_AUTH_TOKEN +# rate_limit_teleop: 50 # max teleop.move messages/second +# rate_limit_default: 10 # max other messages/second diff --git a/src/robocoop_bringup/config/sim.params.yaml b/src/robocoop_bringup/config/sim.params.yaml new file mode 100644 index 0000000..b401728 --- /dev/null +++ b/src/robocoop_bringup/config/sim.params.yaml @@ -0,0 +1,6 @@ +# TODO: Gazebo simulation parameters. + +# Expected keys: +# adapter: "sim" +# ros_domain_id: 0 +# gazebo_world: "hospital_corridor.world" diff --git a/src/robocoop_bringup/launch/backend_debug.launch.py b/src/robocoop_bringup/launch/backend_debug.launch.py new file mode 100644 index 0000000..b256f57 --- /dev/null +++ b/src/robocoop_bringup/launch/backend_debug.launch.py @@ -0,0 +1,8 @@ +# TODO: launch backend in debug mode. + +# Same as backend_mock but with: +# - log_level: DEBUG +# - additional console output +# - no auth required (dev only) + +# TODO(SECURITY): never use debug launch in production. diff --git a/src/robocoop_bringup/launch/backend_mock.launch.py b/src/robocoop_bringup/launch/backend_mock.launch.py new file mode 100644 index 0000000..342dc45 --- /dev/null +++ b/src/robocoop_bringup/launch/backend_mock.launch.py @@ -0,0 +1,8 @@ +# TODO: launch backend with mock adapter (no ROS nodes, no hardware). + +# Steps: +# 1. load mock.params.yaml + common.params.yaml +# 2. start websocket server node +# 3. start watchdog node + +# Use for: local dev, unit tests, dashboard UI testing. diff --git a/src/robocoop_bringup/launch/backend_real.launch.py b/src/robocoop_bringup/launch/backend_real.launch.py new file mode 100644 index 0000000..41f7972 --- /dev/null +++ b/src/robocoop_bringup/launch/backend_real.launch.py @@ -0,0 +1,11 @@ +# TODO: launch backend for real M3Pro hardware. + +# Steps: +# 1. load real.params.yaml + common.params.yaml + security.params.yaml +# 2. include robot_runtime.launch.py +# 3. include ros_bridges.launch.py +# 4. include monitoring.launch.py +# 5. start websocket server node +# 6. start watchdog node + emergency_stop_node + +# TODO(SAFETY): emergency_stop_node must start before websocket server. diff --git a/src/robocoop_bringup/launch/backend_sim.launch.py b/src/robocoop_bringup/launch/backend_sim.launch.py new file mode 100644 index 0000000..551d2e6 --- /dev/null +++ b/src/robocoop_bringup/launch/backend_sim.launch.py @@ -0,0 +1,11 @@ +# TODO: launch backend with Gazebo simulation. + +# Steps: +# 1. load sim.params.yaml + common.params.yaml +# 2. include ros_bridges.launch.py +# 3. start websocket server node +# 4. start watchdog node +# 5. start telemetry_bridge_node +# 6. start teleop_bridge_node + +# Requires: Gazebo running with hospital_corridor.world. diff --git a/src/robocoop_bringup/launch/includes/monitoring.launch.py b/src/robocoop_bringup/launch/includes/monitoring.launch.py new file mode 100644 index 0000000..8f1c313 --- /dev/null +++ b/src/robocoop_bringup/launch/includes/monitoring.launch.py @@ -0,0 +1,7 @@ +# TODO: start monitoring/observability nodes. + +# Nodes: +# - watchdog_node + +# Optional (if available): +# - ROS2 topic logger for post-mission replay diff --git a/src/robocoop_bringup/launch/includes/robot_runtime.launch.py b/src/robocoop_bringup/launch/includes/robot_runtime.launch.py new file mode 100644 index 0000000..e075750 --- /dev/null +++ b/src/robocoop_bringup/launch/includes/robot_runtime.launch.py @@ -0,0 +1,7 @@ +# TODO: start core robot runtime nodes (real hardware only). + +# Nodes: +# - robot_state_node +# - emergency_stop_node + +# TODO(SAFETY): these nodes must restart automatically on crash (respawn=True). diff --git a/src/robocoop_bringup/launch/includes/ros_bridges.launch.py b/src/robocoop_bringup/launch/includes/ros_bridges.launch.py new file mode 100644 index 0000000..d7f0dd4 --- /dev/null +++ b/src/robocoop_bringup/launch/includes/ros_bridges.launch.py @@ -0,0 +1,6 @@ +# TODO: start ROS2 bridge nodes. + +# Nodes: +# - teleop_bridge_node +# - telemetry_bridge_node +# - mode_bridge_node diff --git a/src/robocoop_bringup/launch/includes/websocket.launch.py b/src/robocoop_bringup/launch/includes/websocket.launch.py new file mode 100644 index 0000000..6c123f7 --- /dev/null +++ b/src/robocoop_bringup/launch/includes/websocket.launch.py @@ -0,0 +1,5 @@ +# TODO: start WebSocket server node. + +# Parameters: +# - host, port from backend.params.yaml +# - auth_token from security.params.yaml (real/sim only) diff --git a/src/robocoop_bringup/package.xml b/src/robocoop_bringup/package.xml new file mode 100644 index 0000000..87771a7 --- /dev/null +++ b/src/robocoop_bringup/package.xml @@ -0,0 +1,18 @@ + + + robocoop_bringup + 0.1.0 + Launch files and parameter configs for Robocoop. + Robocoop Team + Apache-2.0 + + ament_cmake + + rclpy + geometry_msgs + nav_msgs + sensor_msgs + std_msgs + launch + launch_ros + diff --git a/src/robocoop_bringup/scripts/run_mock.sh b/src/robocoop_bringup/scripts/run_mock.sh new file mode 100644 index 0000000..85d8371 --- /dev/null +++ b/src/robocoop_bringup/scripts/run_mock.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# TODO: convenience script to launch mock backend. + +# Expected: +# export ROBOCOOP_ENV=mock +# source ROS2 workspace +# ros2 launch robocoop_bringup backend_mock.launch.py diff --git a/src/robocoop_bringup/scripts/run_real.sh b/src/robocoop_bringup/scripts/run_real.sh new file mode 100644 index 0000000..60045e4 --- /dev/null +++ b/src/robocoop_bringup/scripts/run_real.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# TODO: convenience script to launch real hardware backend. + +# Expected: +# export ROBOCOOP_ENV=real +# export ROBOCOOP_AUTH_TOKEN= +# source ROS2 workspace +# ros2 launch robocoop_bringup backend_real.launch.py diff --git a/src/robocoop_bringup/scripts/run_sim.sh b/src/robocoop_bringup/scripts/run_sim.sh new file mode 100644 index 0000000..31a8446 --- /dev/null +++ b/src/robocoop_bringup/scripts/run_sim.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# TODO: convenience script to launch sim backend. + +# Expected: +# export ROBOCOOP_ENV=sim +# source ROS2 workspace +# ros2 launch robocoop_bringup backend_sim.launch.py From 351d7b6093cec5953378b9fd1f700d50364cf0d4 Mon Sep 17 00:00:00 2001 From: damnthonyy Date: Mon, 18 May 2026 20:58:47 +0200 Subject: [PATCH 2/6] Feat : - Add unit tests for event formatting, sinks, state store, and telemetry service - Implemented unit tests for EventFormatter to ensure required fields, action, actor, ID, timestamp, and payload flattening. - Added tests for ConsoleSink and FileSink to verify logging behavior and file writing. - Created tests for RobotState and RobotStateStore to validate state management and updates. - Developed tests for TelemetryService to check state updates and connection event emissions. - Removed outdated test files for MessageRouter, MissionStateMachine, ModeManager, RobotStateStore, Schema Validation, TeleopService, and WatchdogService. - Introduced configuration loading tests to validate YAML parameter handling and environment overrides. - Implemented Config class to manage configuration loading and access with typed getters. - Updated setup.py to include testing dependencies. - Added configuration files for backend, common, mock, and real environments. Assisted-by : Claude :) --- .env.example | 10 + .github/PR_TEMPLATE.md | 36 +++ .github/workflows/ci.yml | 34 +++ .gitignore | 220 +++++++++++++++- README.md | 235 +++++++++--------- pyproject.toml | 8 + run_backend.sh | 51 ++++ server.log | 27 ++ .../robocoop_backend/requirements.txt | Bin .../robocoop_backend.egg-info/PKG-INFO | 19 ++ .../robocoop_backend.egg-info/SOURCES.txt | 28 +++ .../dependency_links.txt | 1 + .../robocoop_backend.egg-info/requires.txt | 10 + .../robocoop_backend.egg-info/top_level.txt | 1 + .../robocoop_backend/adapters/README.md | 183 ++++++++++++++ .../{infrastructure => adapters}/__init__.py | 0 .../robocoop_backend/adapters/base_adapter.py | 6 + .../robocoop_backend/adapters/factory.py | 36 +++ .../robocoop_backend/adapters/mock_adapter.py | 9 + .../adapters/rosbridge_adapter.py | 107 ++++++++ .../adapters/rosbridge_client.py | 125 ++++++++++ .../robocoop_backend/app/README.md | 93 +++++++ .../robocoop_backend/app/auth.py | 9 - .../robocoop_backend/app/backend_context.py | 82 ++++-- .../robocoop_backend/app/contracts.py | 97 ++++++++ .../robocoop_backend/app/message_router.py | 13 - .../robocoop_backend/app/rate_limiter.py | 9 - .../robocoop_backend/app/server.py | 100 ++++++-- .../robocoop_backend/app/websocket_handler.py | 104 +++++++- .../adapters/adapter_factory.py | 10 - .../adapters/m3pro_robot_adapter.py | 8 - .../adapters/m3pro_topic_map.py | 13 - .../adapters/mock_robot_adapter.py | 7 - .../infrastructure/adapters/robot_adapter.py | 8 - .../adapters/sim_robot_adapter.py | 8 - .../infrastructure/ros/emergency_stop_node.py | 9 - .../infrastructure/ros/launch_manager.py | 8 - .../infrastructure/ros/mode_bridge_node.py | 8 - .../infrastructure/ros/robot_state_node.py | 8 - .../ros/telemetry_bridge_node.py | 11 - .../infrastructure/ros/teleop_bridge_node.py | 6 - .../infrastructure/ros/watchdog_node.py | 8 - .../infrastructure/schemas/error_messages.py | 9 - .../infrastructure/schemas/events.py | 17 -- .../infrastructure/schemas/inbound.py | 4 - .../infrastructure/schemas/outbound.py | 8 - .../infrastructure/schemas/validation.py | 7 - .../robocoop_backend/modules/audit/README.md | 96 +++++++ .../modules/audit/audit_event.py | 13 + .../modules/audit/audit_logger.py | 36 ++- .../modules/audit/audit_service.py | 89 ++++++- .../modules/audit/domain/audit_event.py | 8 - .../modules/audit/event_formatter.py | 33 ++- .../robocoop_backend/modules/audit/sinks.py | 56 ++++- .../modules/mission/domain/mission_failure.py | 9 - .../modules/mission/domain/mission_state.py | 9 - .../modules/mission/mission_service.py | 8 - .../modules/mission/mission_state_machine.py | 10 - .../modules/mission/mission_state_store.py | 9 - .../robocoop_backend/modules/mode/__init__.py | 0 .../modules/mode/mode_manager.py | 10 - .../modules/mode/mode_service.py | 8 - .../robocoop_backend/modules/robot/README.md | 107 ++++++++ .../modules/robot/domain/__init__.py | 0 .../modules/robot/domain/connection_state.py | 6 - .../modules/robot/domain/robot_mode.py | 7 - .../modules/robot/domain/robot_state.py | 10 - .../modules/robot/domain/teleop_command.py | 7 - .../modules/robot/robot_state_store.py | 8 - .../modules/robot/state_store.py | 42 ++++ .../modules/robot/telemetry_service.py | 88 ++++++- .../modules/robot/teleop_service.py | 9 - .../modules/safety/__init__.py | 0 .../modules/safety/domain/__init__.py | 0 .../modules/safety/domain/alert.py | 11 - .../modules/safety/emergency_service.py | 10 - .../modules/safety/watchdog_service.py | 9 - .../__init__.py => services/.gitkeep} | 0 .../robocoop_backend/tests/conftest.py | 68 +++++ .../tests/fixtures/__init__.py | 0 .../tests/fixtures/fake_messages.py | 9 - .../tests/fixtures/fake_mission_data.py | 6 - .../tests/fixtures/fake_robot_state.py | 9 - .../integration/test_context_lifecycle.py | 44 ++++ .../integration/test_emergency_stop_flow.py | 12 - .../integration/test_mock_adapter_flow.py | 10 - .../integration/test_sim_adapter_flow.py | 10 - .../integration/test_telemetry_pipeline.py | 74 ++++++ .../integration/test_websocket_teleop_flow.py | 11 - .../ros => tests/real}/__init__.py | 0 .../tests/real/test_rosbridge_live.py | 64 +++++ .../unit/adapters}/__init__.py | 0 .../tests/unit/adapters/test_factory.py | 65 +++++ .../tests/unit/adapters/test_mock_adapter.py | 23 ++ .../unit/adapters/test_rosbridge_adapter.py | 169 +++++++++++++ .../unit/adapters/test_rosbridge_client.py | 198 +++++++++++++++ .../domain => tests/unit/app}/__init__.py | 0 .../tests/unit/app/test_backend_context.py | 76 ++++++ .../tests/unit/app/test_websocket_handler.py | 142 +++++++++++ .../unit/modules}/__init__.py | 0 .../tests/unit/modules/test_audit_event.py | 38 +++ .../tests/unit/modules/test_audit_logger.py | 43 ++++ .../tests/unit/modules/test_audit_service.py | 70 ++++++ .../unit/modules/test_event_formatter.py | 48 ++++ .../tests/unit/modules/test_sinks.py | 61 +++++ .../tests/unit/modules/test_state_store.py | 75 ++++++ .../unit/modules/test_telemetry_service.py | 92 +++++++ .../tests/unit/test_message_router.py | 7 - .../tests/unit/test_mission_state_machine.py | 9 - .../tests/unit/test_mode_manager.py | 9 - .../tests/unit/test_robot_state_store.py | 7 - .../tests/unit/test_schema_validation.py | 8 - .../tests/unit/test_teleop_service.py | 8 - .../tests/unit/test_watchdog_service.py | 7 - .../domain => tests/unit/utils}/__init__.py | 0 .../tests/unit/utils/test_config.py | 130 ++++++++++ .../robocoop_backend/utils/config.py | 95 ++++++- .../robocoop_backend/utils/enums.py | 5 - .../robocoop_backend/utils/ids.py | 6 - .../robocoop_backend/utils/logger.py | 8 - .../utils/thread_safe_lock.py | 6 - .../robocoop_backend/utils/time.py | 6 - src/robocoop_backend/setup.py | 5 + src/robocoop_bringup/config/README.md | 108 ++++++++ .../config/backend.params.yaml | 6 - .../config/common.params.yaml | 17 +- src/robocoop_bringup/config/m3pro_topics.yaml | 10 - src/robocoop_bringup/config/mock.params.yaml | 7 +- src/robocoop_bringup/config/real.params.yaml | 18 +- .../config/security.params.yaml | 6 - src/robocoop_bringup/config/sim.params.yaml | 6 - 131 files changed, 3695 insertions(+), 734 deletions(-) create mode 100644 .env.example create mode 100644 .github/PR_TEMPLATE.md create mode 100644 .github/workflows/ci.yml create mode 100644 pyproject.toml create mode 100755 run_backend.sh create mode 100644 server.log rename requirements.txt => src/robocoop_backend/requirements.txt (100%) create mode 100644 src/robocoop_backend/robocoop_backend.egg-info/PKG-INFO create mode 100644 src/robocoop_backend/robocoop_backend.egg-info/SOURCES.txt create mode 100644 src/robocoop_backend/robocoop_backend.egg-info/dependency_links.txt create mode 100644 src/robocoop_backend/robocoop_backend.egg-info/requires.txt create mode 100644 src/robocoop_backend/robocoop_backend.egg-info/top_level.txt create mode 100644 src/robocoop_backend/robocoop_backend/adapters/README.md rename src/robocoop_backend/robocoop_backend/{infrastructure => adapters}/__init__.py (100%) create mode 100644 src/robocoop_backend/robocoop_backend/adapters/base_adapter.py create mode 100644 src/robocoop_backend/robocoop_backend/adapters/factory.py create mode 100644 src/robocoop_backend/robocoop_backend/adapters/mock_adapter.py create mode 100644 src/robocoop_backend/robocoop_backend/adapters/rosbridge_adapter.py create mode 100644 src/robocoop_backend/robocoop_backend/adapters/rosbridge_client.py create mode 100644 src/robocoop_backend/robocoop_backend/app/README.md delete mode 100644 src/robocoop_backend/robocoop_backend/app/auth.py create mode 100644 src/robocoop_backend/robocoop_backend/app/contracts.py delete mode 100644 src/robocoop_backend/robocoop_backend/app/message_router.py delete mode 100644 src/robocoop_backend/robocoop_backend/app/rate_limiter.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/adapters/adapter_factory.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_robot_adapter.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_topic_map.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/adapters/mock_robot_adapter.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/adapters/robot_adapter.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/adapters/sim_robot_adapter.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/emergency_stop_node.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/launch_manager.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/mode_bridge_node.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/robot_state_node.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/telemetry_bridge_node.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/teleop_bridge_node.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/watchdog_node.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/schemas/error_messages.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/schemas/events.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/schemas/inbound.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/schemas/outbound.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/schemas/validation.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/audit/README.md create mode 100644 src/robocoop_backend/robocoop_backend/modules/audit/audit_event.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/audit/domain/audit_event.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_failure.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_state.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/mission/mission_service.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/mission/mission_state_machine.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/mission/mission_state_store.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/mode/__init__.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/mode/mode_manager.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/mode/mode_service.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/README.md delete mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/domain/__init__.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/domain/connection_state.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_mode.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_state.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/domain/teleop_command.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/robot_state_store.py create mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/state_store.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/teleop_service.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/safety/__init__.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/safety/domain/__init__.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/safety/domain/alert.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/safety/emergency_service.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/safety/watchdog_service.py rename src/robocoop_backend/robocoop_backend/{infrastructure/adapters/__init__.py => services/.gitkeep} (100%) create mode 100644 src/robocoop_backend/robocoop_backend/tests/conftest.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/fixtures/__init__.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/fixtures/fake_messages.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/fixtures/fake_mission_data.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/fixtures/fake_robot_state.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/integration/test_context_lifecycle.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/integration/test_emergency_stop_flow.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/integration/test_mock_adapter_flow.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/integration/test_sim_adapter_flow.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/integration/test_telemetry_pipeline.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/integration/test_websocket_teleop_flow.py rename src/robocoop_backend/robocoop_backend/{infrastructure/ros => tests/real}/__init__.py (100%) create mode 100644 src/robocoop_backend/robocoop_backend/tests/real/test_rosbridge_live.py rename src/robocoop_backend/robocoop_backend/{infrastructure/schemas => tests/unit/adapters}/__init__.py (100%) create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/adapters/test_factory.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/adapters/test_mock_adapter.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/adapters/test_rosbridge_adapter.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/adapters/test_rosbridge_client.py rename src/robocoop_backend/robocoop_backend/{modules/audit/domain => tests/unit/app}/__init__.py (100%) create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/app/test_backend_context.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/app/test_websocket_handler.py rename src/robocoop_backend/robocoop_backend/{modules/mission => tests/unit/modules}/__init__.py (100%) create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/modules/test_audit_event.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/modules/test_audit_logger.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/modules/test_audit_service.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/modules/test_event_formatter.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/modules/test_sinks.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/modules/test_state_store.py create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/modules/test_telemetry_service.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_message_router.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_mission_state_machine.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_mode_manager.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_robot_state_store.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_schema_validation.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_teleop_service.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_watchdog_service.py rename src/robocoop_backend/robocoop_backend/{modules/mission/domain => tests/unit/utils}/__init__.py (100%) create mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/utils/test_config.py delete mode 100644 src/robocoop_backend/robocoop_backend/utils/enums.py delete mode 100644 src/robocoop_backend/robocoop_backend/utils/ids.py delete mode 100644 src/robocoop_backend/robocoop_backend/utils/logger.py delete mode 100644 src/robocoop_backend/robocoop_backend/utils/thread_safe_lock.py delete mode 100644 src/robocoop_backend/robocoop_backend/utils/time.py create mode 100644 src/robocoop_bringup/config/README.md delete mode 100644 src/robocoop_bringup/config/backend.params.yaml delete mode 100644 src/robocoop_bringup/config/m3pro_topics.yaml delete mode 100644 src/robocoop_bringup/config/security.params.yaml delete mode 100644 src/robocoop_bringup/config/sim.params.yaml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b4683d1 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Copier ce fichier en .env et adapter les valeurs + +# Environnement actif : mock | real +ROBOCOOP_ENV=mock + +# Chemin vers les fichiers de config YAML (optionnel) +# ROBOCOOP_CONFIG_DIR=./src/robocoop_bringup/config + +# URL rosbridge pour dev local (override real.params.yaml) +# ROSBRIDGE_URL=ws://localhost:9090 diff --git a/.github/PR_TEMPLATE.md b/.github/PR_TEMPLATE.md new file mode 100644 index 0000000..6283871 --- /dev/null +++ b/.github/PR_TEMPLATE.md @@ -0,0 +1,36 @@ +## TroubleShooting + + + +## Summary + + +## Description + + +## What's Changed + + +**Affected areas:** +- User authentication & validation layer +- Email handling utilities +- Related middleware + + +**Key files:** `UserService.ts`, `AuthMiddleware.ts`, `EmailValidator.ts` (new) + + +**Details:** See commits or ask in thread for specific file breakdown + +## How to Test + + +## Screen + + +## Related + + +--- + +Closes #XX or Fixes #XX \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..906eba7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: ["main", "development"] + pull_request: + branches: ["main", "development"] + +jobs: + test: + name: Unit & Integration Tests + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.11, 3.12, 3.13, 3.14] + defaults: + run: + working-directory: src/robocoop_backend + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: "pip" + + - name: Install package + test dependencies + run: pip install -e ".[test]" + + - name: Run tests + run: pytest -m "not real" -v --tb=short diff --git a/.gitignore b/.gitignore index 0e5ac79..b85f2b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,220 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +*.lcov +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi/* +!.pixi/config.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule* +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc .venv -__pycache__ \ No newline at end of file +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +# .idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ +# Temporary file for partial code execution +tempCodeRunnerFile.py + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml \ No newline at end of file diff --git a/README.md b/README.md index f25038c..b5595da 100644 --- a/README.md +++ b/README.md @@ -1,164 +1,159 @@ -# RoboC00p Backend +# Robocoop Backend +WebSocket backend for the Robocoop medical assistance robot. Bridges the robot hardware (via ROS/rosbridge) to the dashboard frontend. -Backend system controlling the RoboC00p medical assistance robot. +## What it does -The backend manages : +- Connects to the robot via **rosbridge** (WebSocket → ROS topics) +- Reads battery voltage from `/battery`, converts it to a percentage +- Broadcasts real-time robot state to all connected dashboard clients +- Records audit events (robot connected/disconnected, battery low, emergency stop) +- Supports **mock mode** for frontend development without a physical robot -- teleoperation -- autonomous missions -- robot state monitoring -- safety mechanisms -- communication with the dashboard +## Architecture overview -It supports three execution modes : - -- mock (development) -- simulation (ROS2 + Gazebo) -- real robot (Yahboom ROSMASTER) - -## Architecture - -
- Diagramme de structure -
- -## Execution Modes - -The backend supports three environments : +``` +Dashboard (frontend) + │ WebSocket ws://host:8765 + ▼ + app/server.py — lifecycle, signal handling + app/websocket_handler.py — client connections, message routing + app/backend_context.py — dependency container (single init point) + │ + ├── modules/robot/ — state store + telemetry pipeline + ├── modules/audit/ — event logging + history + └── adapters/ — robot communication layer + │ + ├── mock_adapter.py — no-op, always connected (dev) + └── rosbridge_adapter.py — real robot via rosbridge WebSocket + │ + └── rosbridge_client.py — pure WebSocket transport + │ + rosbridge server (ws://robot:9090) + │ + ROS topics (/battery, ...) +``` -### Mock Mode +## Project structure -Simulated robot behavior. +``` +src/ + robocoop_backend/ + robocoop_backend/ + app/ — server, websocket handler, contracts, DI context + adapters/ — robot adapter implementations + rosbridge client + modules/ + robot/ — RobotState, RobotStateStore, TelemetryService + audit/ — AuditEvent, AuditService, sinks (console, file) + utils/ — Config loader (.env + YAML) + robocoop_bringup/ + config/ — YAML config files per environment + launch/ — ROS2 launch files (future use) +.env — local environment variables (not committed) +.env.example — template for .env +``` -Used for backend and dashboard development. +## Setup -### Simulation mode -Connects to ROS2 simulation (Gazebo). +**Requirements:** Python 3.11+ -Used to validate navigation and sensor integration. +```bash +git clone +cd robot-back -### Real mode -Connects to the Yahboom ROSMASTER M3 Pro robot. +python -m venv .venv # or python3 +source .venv/bin/activate # macOS/Linux +# .venv\Scripts\activate # Windows +pip install -e src/robocoop_backend +``` -## Development Roadmap +Copy the environment file and configure it: -### Phase 1 - MVP +```bash +cp .env.example .env +``` -#### Goal : Validate teleoperation and robot monitoring. +`.env` defaults to `ROBOCOOP_ENV=mock` — no robot needed to start. -Features : +## Running -- dashboard communication -- teleoperation -- robot state monitoring -- watchdog safety -- mock adapter +```bash +# Mock mode (no robot required) +ROBOCOOP_ENV=mock python -m robocoop_backend.app.server -### Phase 2 - Simulation +# Real robot (rosbridge must be running on the robot) +ROBOCOOP_ENV=real python -m robocoop_backend.app.server +``` -#### Goal : Validate ROS2 integration and autonomous navigation. +Or use the launch script: -Features : +```bash +bash run_backend.sh mock +bash run_backend.sh real +``` -- ROS2 nodes -- Gazebo simulation -- Nav2 integration -- telemetry bridge +Server starts on `ws://0.0.0.0:8765`. -### Phase 3 - Real Robot +## Quick connection test -#### Goal : Connect the backend to the Yahboom ROSMASTER M3 pro +Connect to `ws://localhost:8765` with any WebSocket client (Postman, WebSocket King, wscat) and send: -Features : +```json +{"type": "ping"} +``` -- hardware topic mapping -- real sensor telemetry -- safety limits -- emergency stop +Expected response: -### Phase 4 - Production features +```json +{"type": "pong"} +``` -#### Goal : Improve reliability and traceability +On connection, the server automatically pushes the current robot state and the last 50 audit events. See [`app/contracts.py`](src/robocoop_backend/robocoop_backend/app/contracts.py) for the full message reference. -Features : -- database -- audit logs -- authentication -- mission history -- monitoring +## Environment variables +| Variable | Default | Description | +|---|---|---| +| `ROBOCOOP_ENV` | `mock` | Which config to load: `mock` or `real` | +| `ROBOCOOP_CONFIG_DIR` | auto-detected | Path to the YAML config directory | -## Repository Structure +## Configuration -``` -src/ - robocoop_backend/ - robocoop_backend/ # Python backend package (runtime) - app/ # WebSocket server + routing + startup context - server.py - websocket_handler.py - message_router.py - backend_context.py - auth.py - rate_limiter.py - infrastructure/ # External integrations (adapters, ROS2, WS schemas) - adapters/ # RobotAdapter implementations (mock / sim / real) - ros/ # ROS2 nodes (bridges, watchdog, emergency stop, ...) - schemas/ # WebSocket message schemas + validation - modules/ # Business modules (one folder per domain) - robot/ # Robot domain: state, telemetry, teleop - mission/ # Mission domain: state machine + mission service - mode/ # Mode management - safety/ # Emergency stop + watchdog - audit/ # Audit logging - utils/ # Shared helpers (config, logger, ids, time, ...) - tests/ # Unit + integration tests - robocoop_bringup/ # ROS2 launch/config/scripts - config/ - launch/ - includes/ - scripts/ -docs/ -requirements.txt -``` +YAML config lives in `src/robocoop_bringup/config/`. Two files are loaded on startup: -## Running the backend +1. `common.params.yaml` — shared across all environments +2. `{ROBOCOOP_ENV}.params.yaml` — environment-specific overrides -### Requirements +See [`src/robocoop_bringup/config/README.md`](src/robocoop_bringup/config/README.md) for details. -- Python 3.11+ -- pip +## Testing -### Setup ```bash -python -m venv .venv -.venv\Scripts\activate # Windows -source .venv/bin/activate # Linux / Mac -pip install -r requirements.txt -``` +# Install test dependencies +pip install -e "src/robocoop_backend[test]" -### Start the server -```bash -python src/robocoop_backend/robocoop_backend/app/server.py -``` +# Run all tests (unit + integration) +pytest -m "not real" -v -Server starts on `ws://localhost:8765`. +# Unit tests only +pytest -m unit -v -### Test the connection +# Integration tests only +pytest -m integration -v -Connect to `ws://localhost:8765` with any WebSocket client (Postman, WebSocket King…) and send: -```json -{"type": "ping"} +# Real rosbridge tests (requires a live rosbridge server) +ROSBRIDGE_URL=ws://localhost:9090 pytest -m real -v ``` -Expected response: -```json -{"type": "pong"} -``` +Tests live in `src/robocoop_backend/robocoop_backend/tests/`. CI runs automatically on push and pull requests via GitHub Actions (`.github/workflows/ci.yml`). +## Roadmap +| Phase | Goal | Status | +|---|---|---| +| 1 — Battery monitoring | `/battery` topic → dashboard | ✅ Done | +| 2 — Teleoperation | `teleop.move` → `/cmd_vel` publish | 🔜 Next | +| 3 — Navigation | `navigate_to` → Nav2 goal | 🔜 Planned | +| 4 — Full sensor suite | `/odom`, `/scan`, `/imu` | 🔜 Planned | +| 5 — Auth + rate limiting | WebSocket token auth | 🔜 Planned | diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..c00814c --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[tool.pytest.ini_options] +testpaths = ["src/robocoop_backend/robocoop_backend/tests"] +asyncio_mode = "auto" +markers = [ + "unit: fast, no I/O, no network", + "integration: real components wired together, no network", + "real: requires a live rosbridge (set ROSBRIDGE_URL env var)", +] diff --git a/run_backend.sh b/run_backend.sh new file mode 100755 index 0000000..c73a4e3 --- /dev/null +++ b/run_backend.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Launch script for Robocoop backend server with ros-bridge integration + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BACKEND_DIR="$SCRIPT_DIR/src/robocoop_backend" + +echo "=========================================" +echo "Robocoop Backend - ROS-Bridge Integration" +echo "=========================================" +echo "" + +# Parse arguments +ENVIRONMENT="${ROBOCOOP_ENV:-mock}" +CONFIG_DIR="${ROBOCOOP_CONFIG_DIR:-$SCRIPT_DIR/src/robocoop_bringup/config}" + +# Check if user provided an environment +if [ $# -gt 0 ]; then + ENVIRONMENT="$1" +fi + +echo "Environment: $ENVIRONMENT" +echo "Config dir: $CONFIG_DIR" +echo "" + +# Validate environment +case "$ENVIRONMENT" in + mock|sim|real) + echo "✓ Valid environment: $ENVIRONMENT" + ;; + *) + echo "❌ Invalid environment: $ENVIRONMENT" + echo "Supported: mock, sim, real" + exit 1 + ;; +esac + +echo "" +echo "Starting backend server..." +echo "Press Ctrl+C to stop" +echo "" + +# Export environment variables +export ROBOCOOP_ENV="$ENVIRONMENT" +export ROBOCOOP_CONFIG_DIR="$CONFIG_DIR" +export PYTHONPATH="$BACKEND_DIR:$PYTHONPATH" + +# Run server +cd "$BACKEND_DIR" +python3 -m robocoop_backend.app.server diff --git a/server.log b/server.log new file mode 100644 index 0000000..8293bb9 --- /dev/null +++ b/server.log @@ -0,0 +1,27 @@ +2026-05-18 16:26:25,731 - __main__ - INFO - Loading config from: /Users/user/Documents/robot-back/src/robocoop_bringup/config +2026-05-18 16:26:25,731 - robocoop_backend.utils.config - INFO - Loading configuration for environment: real +2026-05-18 16:26:25,731 - robocoop_backend.utils.config - INFO - Loading common config: /Users/user/Documents/robot-back/src/robocoop_bringup/config/common.params.yaml +2026-05-18 16:26:25,733 - robocoop_backend.utils.config - INFO - Loading environment config: /Users/user/Documents/robot-back/src/robocoop_bringup/config/real.params.yaml +2026-05-18 16:26:25,733 - robocoop_backend.utils.config - INFO - Configuration loaded successfully +2026-05-18 16:26:25,733 - __main__ - INFO - Initializing BackendContext... +2026-05-18 16:26:25,733 - robocoop_backend.app.backend_context - INFO - Initializing BackendContext +2026-05-18 16:26:25,733 - robocoop_backend.app.backend_context - INFO - Initializing RobotStateStore +2026-05-18 16:26:25,733 - robocoop_backend.app.backend_context - INFO - Initializing TelemetryService +2026-05-18 16:26:25,734 - robocoop_backend.app.backend_context - INFO - Creating robot adapter +2026-05-18 16:26:25,734 - robocoop_backend.adapters.factory - INFO - Creating adapter: rosbridge +2026-05-18 16:26:25,734 - robocoop_backend.adapters.factory - INFO - ROS-Bridge URL: ws://10.10.220.79:9090 +2026-05-18 16:26:25,734 - robocoop_backend.app.backend_context - INFO - Adapter created: RosbridgeRobotAdapter +2026-05-18 16:26:25,734 - __main__ - INFO - Connecting to robot... +2026-05-18 16:26:25,734 - robocoop_backend.app.backend_context - INFO - Connecting adapter to robot... +2026-05-18 16:26:25,734 - robocoop_backend.adapters.rosbridge - INFO - Connecting to ros-bridge at ws://10.10.220.79:9090 +2026-05-18 16:26:25,774 - robocoop_backend.adapters.rosbridge - INFO - Connected to ros-bridge +2026-05-18 16:26:25,775 - robocoop_backend.adapters.rosbridge - INFO - Subscribed to /battery +2026-05-18 16:26:25,775 - robocoop_backend.app.backend_context - INFO - Adapter connected successfully +2026-05-18 16:26:25,775 - __main__ - INFO - Backend initialized successfully +2026-05-18 16:26:25,775 - __main__ - INFO - Starting WebSocket server on 0.0.0.0:8765 +2026-05-18 16:26:25,775 - robocoop_backend.app.backend_context - INFO - WebSocket handler registered for telemetry broadcast +2026-05-18 16:26:25,777 - websockets.server - INFO - server listening on 0.0.0.0:8765 +2026-05-18 16:26:25,777 - __main__ - INFO - WebSocket server running on ws://0.0.0.0:8765 +2026-05-18 16:26:36,120 - websockets.server - INFO - connection open +2026-05-18 16:26:36,120 - robocoop_backend.app.websocket_handler - INFO - Client connected. Total clients: 1 +2026-05-18 16:26:46,141 - robocoop_backend.app.websocket_handler - INFO - Client disconnected. Total clients: 0 diff --git a/requirements.txt b/src/robocoop_backend/requirements.txt similarity index 100% rename from requirements.txt rename to src/robocoop_backend/requirements.txt diff --git a/src/robocoop_backend/robocoop_backend.egg-info/PKG-INFO b/src/robocoop_backend/robocoop_backend.egg-info/PKG-INFO new file mode 100644 index 0000000..5d68dd6 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend.egg-info/PKG-INFO @@ -0,0 +1,19 @@ +Metadata-Version: 2.4 +Name: robocoop_backend +Version: 0.1.0 +Summary: Robocoop WebSocket backend. +License: Apache-2.0 +Requires-Python: >=3.10 +Requires-Dist: websockets +Requires-Dist: pyyaml +Provides-Extra: ros +Requires-Dist: rclpy; extra == "ros" +Provides-Extra: test +Requires-Dist: pytest>=8.0; extra == "test" +Requires-Dist: pytest-asyncio>=0.23; extra == "test" +Requires-Dist: pytest-mock>=3.12; extra == "test" +Dynamic: license +Dynamic: provides-extra +Dynamic: requires-dist +Dynamic: requires-python +Dynamic: summary diff --git a/src/robocoop_backend/robocoop_backend.egg-info/SOURCES.txt b/src/robocoop_backend/robocoop_backend.egg-info/SOURCES.txt new file mode 100644 index 0000000..f08ede4 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend.egg-info/SOURCES.txt @@ -0,0 +1,28 @@ +setup.py +robocoop_backend/__init__.py +robocoop_backend.egg-info/PKG-INFO +robocoop_backend.egg-info/SOURCES.txt +robocoop_backend.egg-info/dependency_links.txt +robocoop_backend.egg-info/requires.txt +robocoop_backend.egg-info/top_level.txt +robocoop_backend/adapters/__init__.py +robocoop_backend/adapters/base_adapter.py +robocoop_backend/adapters/factory.py +robocoop_backend/adapters/mock_adapter.py +robocoop_backend/adapters/rosbridge_adapter.py +robocoop_backend/adapters/rosbridge_client.py +robocoop_backend/app/__init__.py +robocoop_backend/app/backend_context.py +robocoop_backend/app/contracts.py +robocoop_backend/app/server.py +robocoop_backend/app/websocket_handler.py +robocoop_backend/modules/__init__.py +robocoop_backend/modules/audit/__init__.py +robocoop_backend/modules/audit/audit_event.py +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/robot/__init__.py +robocoop_backend/modules/robot/state_store.py +robocoop_backend/modules/robot/telemetry_service.py \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend.egg-info/dependency_links.txt b/src/robocoop_backend/robocoop_backend.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/robocoop_backend/robocoop_backend.egg-info/requires.txt b/src/robocoop_backend/robocoop_backend.egg-info/requires.txt new file mode 100644 index 0000000..f67d279 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend.egg-info/requires.txt @@ -0,0 +1,10 @@ +websockets +pyyaml + +[ros] +rclpy + +[test] +pytest>=8.0 +pytest-asyncio>=0.23 +pytest-mock>=3.12 diff --git a/src/robocoop_backend/robocoop_backend.egg-info/top_level.txt b/src/robocoop_backend/robocoop_backend.egg-info/top_level.txt new file mode 100644 index 0000000..c7892f5 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend.egg-info/top_level.txt @@ -0,0 +1 @@ +robocoop_backend diff --git a/src/robocoop_backend/robocoop_backend/adapters/README.md b/src/robocoop_backend/robocoop_backend/adapters/README.md new file mode 100644 index 0000000..76ee671 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/adapters/README.md @@ -0,0 +1,183 @@ +# adapters/ + +This layer is the **only part of the codebase that talks to the robot**. Everything else (websocket handler, telemetry service, audit) is completely unaware that ROS or rosbridge exists. + +## Files + +| File | Role | +|---|---| +| `base_adapter.py` | Abstract interface — any adapter must implement `is_connected()` | +| `factory.py` | `create_adapter()` — reads `adapter_type` from config, returns the right instance | +| `mock_adapter.py` | No-op adapter, always connected. Used when `ROBOCOOP_ENV=mock` | +| `rosbridge_adapter.py` | Real adapter. Subscribes to ROS topics, converts messages, notifies services | +| `rosbridge_client.py` | Pure WebSocket transport for the rosbridge protocol. No business logic | + +## Two-layer design + +``` +rosbridge_adapter.py — knows about battery, topics, watchdog, telemetry_service + │ + └── rosbridge_client.py — knows how to open a socket and read/write JSON +``` + +`rosbridge_client` is protocol-level. It speaks the rosbridge wire format (`{"op": "subscribe", ...}`). +`rosbridge_adapter` is domain-level. It knows what `/battery` means and what to do with the data. + +If you ever replace rosbridge with another transport (MQTT, gRPC), you only rewrite `rosbridge_client.py`. The adapter logic stays intact. + +--- + +## How to subscribe to a new ROS topic + +Example: you want to read `/odom` (robot position) and push it to the dashboard. + +### Step 1 — Add the topic to config + +`src/robocoop_bringup/config/real.params.yaml`: +```yaml +rosbridge: + topics: + battery: "/battery" + odom: "/odom" # add this +``` + +### Step 2 — Pass the topic to the adapter via factory + +`adapters/factory.py`: +```python +return RosbridgeRobotAdapter( + ... + battery_topic=topics.get("battery", "/battery"), + odom_topic=topics.get("odom", "/odom"), # add this + ... +) +``` + +### Step 3 — Add the parameter and subscribe in the adapter + +`adapters/rosbridge_adapter.py`: +```python +def __init__(self, ..., odom_topic: str = "/odom", ...): + self.odom_topic = odom_topic + # existing fields... + +async def connect(self) -> bool: + if not await self._client.connect(): + return False + await self._subscribe_battery() + await self._subscribe_odom() # add this + self._watchdog_task = asyncio.create_task(self._battery_watchdog()) + return True + +async def _subscribe_odom(self) -> None: + await self._client.subscribe(self.odom_topic, "nav_msgs/msg/Odometry", self._on_odom_received) + +def _on_odom_received(self, msg_data: dict) -> None: + try: + pos = msg_data.get("pose", {}).get("pose", {}).get("position", {}) + if self.telemetry_service: + self.telemetry_service.on_telemetry_received({ + "position_x": pos.get("x", 0.0), + "position_y": pos.get("y", 0.0), + }) + except Exception as e: + logger.error(f"[ODOM] Error: {e}") +``` + +Also re-subscribe in `_on_bridge_reconnected()`: +```python +def _on_bridge_reconnected(self) -> None: + self._last_battery_time = None + asyncio.create_task(self._subscribe_battery()) + asyncio.create_task(self._subscribe_odom()) # add this +``` + +### Step 4 — Add the new fields to the state store + +`modules/robot/state_store.py`: +```python +@dataclass +class RobotState: + is_connected: bool = False + battery_level: float = 0.0 + position_x: float = 0.0 # add + position_y: float = 0.0 # add + last_updated: datetime = field(default_factory=datetime.now) + + def to_dict(self): + return { + "is_connected": self.is_connected, + "battery_level": self.battery_level, + "position_x": self.position_x, # add + "position_y": self.position_y, # add + "last_updated": self.last_updated.isoformat(), + } +``` + +That's it. `TelemetryService.on_telemetry_received()` already handles any dict generically — it updates the store and broadcasts the full state to all connected clients automatically. + +--- + +## How to publish to a ROS topic + +Example: you want to send a navigation goal to `/move_base_simple/goal`. + +### Step 1 — Add `publish()` to rosbridge_client + +`adapters/rosbridge_client.py`: +```python +async def publish(self, topic: str, msg_type: str, msg: dict) -> None: + if not self._websocket: + logger.error("Cannot publish: not connected") + return + try: + await self._websocket.send(json.dumps({ + "op": "publish", "topic": topic, "type": msg_type, "msg": msg, + })) + except Exception as e: + logger.error(f"Publish failed on {topic}: {e}") +``` + +### Step 2 — Implement the method in the adapter + +`adapters/rosbridge_adapter.py`: +```python +async def navigate_to(self, x: float, y: float) -> None: + await self._client.publish( + topic="/move_base_simple/goal", + msg_type="geometry_msgs/PoseStamped", + msg={ + "header": {"frame_id": "map"}, + "pose": { + "position": {"x": x, "y": y, "z": 0.0}, + "orientation": {"x": 0.0, "y": 0.0, "z": 0.0, "w": 1.0}, + }, + }, + ) +``` + +### Step 3 — Handle the WebSocket message in the handler + +`app/websocket_handler.py` — add a constant in `contracts.py` and handle it: +```python +elif msg_type == MSG_NAVIGATE_TO: + data = message.get("data", {}) + await self.context.adapter.navigate_to(data["x"], data["y"]) +``` + +### Step 4 — Update contracts.py + +```python +MSG_NAVIGATE_TO = "navigate_to" +# Frontend sends: {"type": "navigate_to", "data": {"x": 1.5, "y": 2.0}} +``` + +--- + +## Adding a new adapter type + +If you need a completely different transport (not rosbridge): + +1. Create `your_adapter.py` implementing `RobotAdapter` from `base_adapter.py` +2. Add it to `factory.py` with a new `adapter_type` string +3. Add the env config in `src/robocoop_bringup/config/your_env.params.yaml` diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/__init__.py b/src/robocoop_backend/robocoop_backend/adapters/__init__.py similarity index 100% rename from src/robocoop_backend/robocoop_backend/infrastructure/__init__.py rename to src/robocoop_backend/robocoop_backend/adapters/__init__.py diff --git a/src/robocoop_backend/robocoop_backend/adapters/base_adapter.py b/src/robocoop_backend/robocoop_backend/adapters/base_adapter.py new file mode 100644 index 0000000..52e68ab --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/adapters/base_adapter.py @@ -0,0 +1,6 @@ +from abc import ABC, abstractmethod + + +class RobotAdapter(ABC): + @abstractmethod + def is_connected(self) -> bool: ... diff --git a/src/robocoop_backend/robocoop_backend/adapters/factory.py b/src/robocoop_backend/robocoop_backend/adapters/factory.py new file mode 100644 index 0000000..9b7a16f --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/adapters/factory.py @@ -0,0 +1,36 @@ +import logging + +from robocoop_backend.adapters.base_adapter import RobotAdapter +from robocoop_backend.adapters.mock_adapter import MockRobotAdapter +from robocoop_backend.adapters.rosbridge_adapter import RosbridgeRobotAdapter + +logger = logging.getLogger(__name__) + + +def create_adapter( + + adapter_type: str, + config: dict, + telemetry_service=None, +) -> RobotAdapter: + adapter_type = adapter_type.lower() + logger.info(f"Creating adapter: {adapter_type}") + + if adapter_type == "mock": + return MockRobotAdapter() + + if adapter_type == "rosbridge": + rb = config.get("rosbridge", {}) + topics = rb.get("topics", {}) + return RosbridgeRobotAdapter( + url_primary=rb.get("url_primary", "ws://localhost:9090"), + url_secondary=rb.get("url_secondary"), + connection_timeout=rb.get("connection_timeout_seconds", 5.0), + reconnect_interval=rb.get("reconnect_interval_seconds", 2.0), + max_reconnect_attempts=rb.get("max_reconnect_attempts", 5), + battery_topic=topics.get("battery", "/battery"), + battery_watchdog_timeout=rb.get("battery_watchdog_timeout_seconds", 15.0), + telemetry_service=telemetry_service, + ) + + raise ValueError(f"Unknown adapter type: '{adapter_type}'. Supported: mock, rosbridge") diff --git a/src/robocoop_backend/robocoop_backend/adapters/mock_adapter.py b/src/robocoop_backend/robocoop_backend/adapters/mock_adapter.py new file mode 100644 index 0000000..b5f6d3f --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/adapters/mock_adapter.py @@ -0,0 +1,9 @@ +from robocoop_backend.adapters.base_adapter import RobotAdapter + + +class MockRobotAdapter(RobotAdapter): + def __init__(self): + self._is_connected = True + + def is_connected(self) -> bool: + return self._is_connected diff --git a/src/robocoop_backend/robocoop_backend/adapters/rosbridge_adapter.py b/src/robocoop_backend/robocoop_backend/adapters/rosbridge_adapter.py new file mode 100644 index 0000000..8cbc463 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/adapters/rosbridge_adapter.py @@ -0,0 +1,107 @@ +import asyncio +import logging +from datetime import datetime +from typing import Any, Dict, Optional + +from robocoop_backend.adapters.base_adapter import RobotAdapter +from robocoop_backend.adapters.rosbridge_client import RosbridgeClient + +logger = logging.getLogger(__name__) + + +class RosbridgeRobotAdapter(RobotAdapter): + def __init__( + self, + url_primary: str, + url_secondary: Optional[str] = None, + connection_timeout: float = 5.0, + reconnect_interval: float = 2.0, + max_reconnect_attempts: int = 5, + battery_topic: str = "/battery", + battery_watchdog_timeout: float = 15.0, + telemetry_service=None, + ): + self.battery_topic = battery_topic + self.battery_watchdog_timeout = battery_watchdog_timeout + self.telemetry_service = telemetry_service + self._last_battery_time: Optional[datetime] = None + self._watchdog_task: Optional[asyncio.Task] = None + self._client = RosbridgeClient( + url_primary=url_primary, + url_secondary=url_secondary, + connection_timeout=connection_timeout, + reconnect_interval=reconnect_interval, + max_reconnect_attempts=max_reconnect_attempts, + on_reconnected=self._on_bridge_reconnected, + on_disconnected=self._on_bridge_disconnected, + ) + + async def connect(self) -> bool: + if not await self._client.connect(): + return False + await self._subscribe_battery() + self._watchdog_task = asyncio.create_task(self._battery_watchdog()) + return True + + async def disconnect(self) -> None: + if self._watchdog_task: + self._watchdog_task.cancel() + try: + await self._watchdog_task + except asyncio.CancelledError: + pass + await self._client.disconnect() + + async def _subscribe_battery(self) -> None: + await self._client.subscribe(self.battery_topic, "std_msgs/msg/Float32", self._on_battery_received) + + def _on_battery_received(self, msg_data: Dict[str, Any]) -> None: + try: + battery_level = None + if "data" in msg_data: + voltage = float(msg_data["data"]) + battery_level = max(0, min(100, (voltage - 9.0) / (12.6 - 9.0) * 100)) + logger.info(f"[BATTERY] {voltage:.2f}V -> {battery_level:.1f}%") + elif "percentage" in msg_data: + battery_level = float(msg_data["percentage"]) + logger.info(f"[BATTERY] {battery_level:.1f}%") + + if battery_level is not None: + self._last_battery_time = datetime.now() + if self.telemetry_service: + self.telemetry_service.on_telemetry_received( + {"battery_level": float(battery_level), "is_connected": True} + ) + except Exception as e: + logger.error(f"[BATTERY] Error: {e}") + + async def _battery_watchdog(self) -> None: + try: + while True: + await asyncio.sleep(5) + if self._last_battery_time is None: + continue + elapsed = (datetime.now() - self._last_battery_time).total_seconds() + if elapsed > self.battery_watchdog_timeout: + logger.warning(f"No battery for {elapsed:.1f}s — marking DISCONNECTED") + self._last_battery_time = None + self._notify_disconnected() + except asyncio.CancelledError: + pass + except Exception as e: + logger.error(f"Watchdog error: {e}") + + def _notify_disconnected(self) -> None: + if self.telemetry_service: + self.telemetry_service.on_telemetry_received({"is_connected": False}) + + def _on_bridge_reconnected(self) -> None: + self._last_battery_time = None + asyncio.create_task(self._subscribe_battery()) + + def _on_bridge_disconnected(self) -> None: + logger.error("rosbridge disconnected (max attempts reached)") + self._notify_disconnected() + + def is_connected(self) -> bool: + return self._client.is_connected() diff --git a/src/robocoop_backend/robocoop_backend/adapters/rosbridge_client.py b/src/robocoop_backend/robocoop_backend/adapters/rosbridge_client.py new file mode 100644 index 0000000..5f61f1a --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/adapters/rosbridge_client.py @@ -0,0 +1,125 @@ +import asyncio +import json +import logging +from typing import Callable, Dict, Any, Optional + +import websockets +from websockets.client import WebSocketClientProtocol + +logger = logging.getLogger(__name__) + + +class RosbridgeClient: + def __init__( + self, + url_primary: str, + url_secondary: Optional[str] = None, + connection_timeout: float = 5.0, + reconnect_interval: float = 2.0, + max_reconnect_attempts: int = 5, + on_reconnected: Optional[Callable] = None, + on_disconnected: Optional[Callable] = None, + ): + self.url_primary = url_primary + self.url_secondary = url_secondary + self.connection_timeout = connection_timeout + self.reconnect_interval = reconnect_interval + self.max_reconnect_attempts = max_reconnect_attempts + self._on_reconnected = on_reconnected + self._on_disconnected = on_disconnected + self._websocket: Optional[WebSocketClientProtocol] = None + self._is_connected = False + self._reconnect_count = 0 + self._listener_task: Optional[asyncio.Task] = None + self._subscribers: Dict[str, Callable[[Dict[str, Any]], None]] = {} + self._subscription_ids: Dict[str, str] = {} + + async def connect(self) -> bool: + if not await self._connect_ws(): + return False + self._listener_task = asyncio.create_task(self._listen_for_messages()) + return True + + async def disconnect(self) -> None: + if self._listener_task: + self._listener_task.cancel() + try: + await self._listener_task + except asyncio.CancelledError: + pass + if self._websocket: + await self._websocket.close() + self._websocket = None + self._is_connected = False + logger.info("Disconnected from rosbridge") + + async def subscribe(self, topic: str, msg_type: str, callback: Callable) -> None: + if not self._websocket: + logger.error("Cannot subscribe: not connected") + return + sub_id = f"sub_{topic.replace('/', '_')}" + self._subscribers[topic] = callback + self._subscription_ids[topic] = sub_id + try: + await self._websocket.send(json.dumps({ + "op": "subscribe", "topic": topic, "type": msg_type, "id": sub_id, + })) + logger.info(f"Subscribed to {topic}") + except Exception as e: + logger.error(f"Subscribe failed for {topic}: {e}") + + def is_connected(self) -> bool: + return self._is_connected and self._websocket is not None + + async def _connect_ws(self) -> bool: + urls = [self.url_primary] + ([self.url_secondary] if self.url_secondary else []) + for url in urls: + try: + logger.info(f"Connecting to rosbridge at {url}") + self._websocket = await asyncio.wait_for( + websockets.connect(url), timeout=self.connection_timeout + ) + self._is_connected = True + self._reconnect_count = 0 + logger.info(f"Connected to {url}") + return True + except (asyncio.TimeoutError, OSError, websockets.WebSocketException) as e: + logger.warning(f"Failed to connect to {url}: {e}") + self._is_connected = False + return False + + async def _listen_for_messages(self) -> None: + try: + async for message in self._websocket: + try: + data = json.loads(message) + if "topic" in data and data["topic"] in self._subscribers: + self._subscribers[data["topic"]](data.get("msg", {})) + except Exception as e: + logger.error(f"Message processing error: {e}") + except websockets.exceptions.ConnectionClosed: + self._is_connected = False + await self._attempt_reconnect() + except asyncio.CancelledError: + pass + except Exception as e: + logger.error(f"Listener error: {e}") + self._is_connected = False + await self._attempt_reconnect() + + async def _attempt_reconnect(self) -> None: + if self._reconnect_count >= self.max_reconnect_attempts: + logger.error(f"Max reconnection attempts reached ({self.max_reconnect_attempts})") + if self._on_disconnected: + self._on_disconnected() + return + self._reconnect_count += 1 + wait = self.reconnect_interval * (2 ** (self._reconnect_count - 1)) + logger.info(f"Reconnecting in {wait:.1f}s ({self._reconnect_count}/{self.max_reconnect_attempts})") + await asyncio.sleep(wait) + if await self._connect_ws(): + self._listener_task = asyncio.create_task(self._listen_for_messages()) + if self._on_reconnected: + self._on_reconnected() + else: + await self._attempt_reconnect() diff --git a/src/robocoop_backend/robocoop_backend/app/README.md b/src/robocoop_backend/robocoop_backend/app/README.md new file mode 100644 index 0000000..554693e --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/app/README.md @@ -0,0 +1,93 @@ +# app/ + +The application layer. Handles server lifecycle, WebSocket connections, message routing, and dependency wiring. No business logic lives here — this layer delegates everything to `modules/` and `adapters/`. + +## Files + +| File | Role | +|---|---| +| `server.py` | Entry point. Starts the WebSocket server, handles signals (SIGTERM/SIGINT), manages shutdown | +| `backend_context.py` | Dependency container. Creates and wires all services at startup | +| `websocket_handler.py` | Manages client connections and routes inbound messages to services | +| `contracts.py` | Message type constants + full documentation of the WebSocket API | + +--- + +## `backend_context.py` — the wiring + +`BackendContext` is a singleton that builds the entire service graph once at startup: + +``` +BackendContext + ├── RobotStateStore — robot state + ├── AuditService — event log + ├── TelemetryService — wired to store + audit + └── Adapter — wired to telemetry (mock or rosbridge) +``` + +After `server.py` starts the WebSocket server, it calls `context.set_websocket_handler(handler)` to wire the WebSocket layer into telemetry and audit for live broadcasts. + +**You should never instantiate services directly in other files.** Always go through `BackendContext`: + +```python +ctx = BackendContext.get_instance() +ctx.robot_state_store.to_dict() +ctx.audit_service.record(event) +``` + +--- + +## `websocket_handler.py` — message routing + +Each inbound message is dispatched by its `type` field. The routing table: + +| `type` received | Action | +|---|---| +| `ping` | Reply `pong` | +| `get_state` | Read `RobotStateStore`, reply with `state_response` | +| `get_activity` | Read `AuditService.get_history()`, reply with `activity_history` | +| `teleop.move` | Call `adapter.send_velocity(data)` | +| `emergency_stop` | Call `adapter.emergency_stop()` + record audit event | + +**To add a new inbound message type:** + +1. Add the constant to `contracts.py` +2. Add an `elif msg_type == MSG_YOUR_TYPE:` block in `WebSocketHandler.handle_message()` +3. Call the appropriate service or adapter method + +--- + +## `contracts.py` — the WebSocket API reference + +This file is the **single source of truth** for the message format between backend and frontend. It serves two purposes: + +1. **Documentation** — full examples of every message in both directions, in the module docstring +2. **Constants** — string constants used throughout the code instead of raw strings + +**Frontend developers should read this file first.** + +```python +# Use constants in code, never raw strings: +await websocket.send(json.dumps({"type": MSG_PONG})) +# not: +await websocket.send(json.dumps({"type": "pong"})) +``` + +When you add a new message type (inbound or outbound), always: +1. Add the constant to `contracts.py` +2. Document it in the module docstring with a JSON example + +--- + +## `server.py` — startup sequence + +``` +1. Load .env +2. Load common.params.yaml + {ROBOCOOP_ENV}.params.yaml +3. BackendContext.initialize(config) — builds all services +4. context.connect() — connects adapter to rosbridge (or mock) +5. websockets.serve() — starts WebSocket server on port 8765 +6. context.set_websocket_handler() — wires broadcasts +7. await shutdown_event — blocks until SIGTERM/SIGINT +8. context.disconnect() + server.close() +``` diff --git a/src/robocoop_backend/robocoop_backend/app/auth.py b/src/robocoop_backend/robocoop_backend/app/auth.py deleted file mode 100644 index fe75a0d..0000000 --- a/src/robocoop_backend/robocoop_backend/app/auth.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: implement WebSocket token authentication. - -# Expected behavior: -# - client sends token in connection header or first message -# - validate against secret from security.params.yaml -# - reject connection (close 4001) if token invalid - -# TODO(SECURITY): use constant-time comparison to prevent timing attacks. -# TODO(SECURITY): log failed auth attempts with client IP. diff --git a/src/robocoop_backend/robocoop_backend/app/backend_context.py b/src/robocoop_backend/robocoop_backend/app/backend_context.py index 806a6d3..e434821 100644 --- a/src/robocoop_backend/robocoop_backend/app/backend_context.py +++ b/src/robocoop_backend/robocoop_backend/app/backend_context.py @@ -1,17 +1,65 @@ -# TODO: implement BackendContext (dependency container). - -# Expected attributes: -# adapter: RobotAdapter -# robot_state_store: RobotStateStore -# mission_state_store: MissionStateStore -# mode_manager: ModeManager -# teleop_service: TeleopService -# emergency_service: EmergencyService -# mission_service: MissionService -# mode_service: ModeService -# telemetry_service: TelemetryService -# watchdog_service: WatchdogService -# audit_service: AuditService - -# TODO: build context from config at startup (via adapter_factory). -# TODO: expose as singleton — one context per process. +import logging +from typing import Dict, Any, Optional + +from robocoop_backend.adapters.factory import create_adapter +from robocoop_backend.modules.audit.audit_logger import AuditLogger +from robocoop_backend.modules.audit.audit_service import AuditService +from robocoop_backend.modules.audit.sinks import ConsoleSink +from robocoop_backend.modules.robot.state_store import RobotStateStore +from robocoop_backend.modules.robot.telemetry_service import TelemetryService + +logger = logging.getLogger(__name__) +_instance: Optional["BackendContext"] = None + + +class BackendContext: + def __init__(self, config: Dict[str, Any]): + self.config = config + self.robot_state_store = RobotStateStore() + self.audit_service = AuditService(AuditLogger(sinks=[ConsoleSink()])) + self.telemetry_service = TelemetryService( + robot_state_store=self.robot_state_store, + audit_service=self.audit_service, + ) + adapter_type = config.get("adapter_type", "mock") + self.adapter = create_adapter( + adapter_type=adapter_type, + config=config, + telemetry_service=self.telemetry_service, + ) + logger.info(f"Adapter: {type(self.adapter).__name__}") + + async def connect(self) -> bool: + if hasattr(self.adapter, "connect"): + return await self.adapter.connect() + return True + + async def disconnect(self) -> None: + if hasattr(self.adapter, "disconnect"): + try: + await self.adapter.disconnect() + except Exception as e: + logger.error(f"Disconnect error: {e}") + + def set_websocket_handler(self, handler) -> None: + self.telemetry_service.websocket_handler = handler + self.audit_service.websocket_handler = handler + + @staticmethod + def initialize(config: Dict[str, Any]) -> "BackendContext": + global _instance + if _instance is None: + _instance = BackendContext(config) + return _instance + + @staticmethod + def get_instance() -> "BackendContext": + global _instance + if _instance is None: + raise RuntimeError("BackendContext not initialized") + return _instance + + @staticmethod + def reset() -> None: + global _instance + _instance = None diff --git a/src/robocoop_backend/robocoop_backend/app/contracts.py b/src/robocoop_backend/robocoop_backend/app/contracts.py new file mode 100644 index 0000000..3a3c363 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/app/contracts.py @@ -0,0 +1,97 @@ +""" +WebSocket message contracts between backend and frontend. + +Connection: ws://:8765 + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +FRONTEND → BACKEND +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +ping + {"type": "ping"} + +get_state + {"type": "get_state"} + +get_activity + {"type": "get_activity", "limit": 50} # limit optional, default 50 + +teleop.move + {"type": "teleop.move", "data": {"linear_x": 0.5, "angular_z": 0.0}} + +emergency_stop + {"type": "emergency_stop"} + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +BACKEND → FRONTEND +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +pong (réponse à ping) + {"type": "pong"} + +initial_state (envoyé automatiquement à la connexion) + { + "type": "initial_state", + "data": { + "is_connected": false, + "battery_level": 0.0, # 0–100 (%) + "last_updated": "2025-05-18T12:00:00" + } + } + +activity_history (envoyé à la connexion + en réponse à get_activity) + { + "type": "activity_history", + "data": [ + { + "id": "uuid", + "action": "robot.connected", # voir actions ci-dessous + "actor": "system", + "timestamp": "2025-05-18T12:00:00" + } + ] + } + +state_response (réponse à get_state) + {"type": "state_response", "data": { }} + +robot_state_updated (push automatique sur chaque update telemetry) + {"type": "robot_state_updated", "data": { }} + +activity_event (push automatique sur chaque événement audit) + { + "type": "activity_event", + "data": { + "id": "uuid", + "action": "battery.low", + "actor": "system", + "timestamp": "2025-05-18T12:00:00", + "battery_level": 18.5, # champs du payload aplatis ici + "threshold": 20.0 + } + } + +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +ACTIONS AUDIT (activity_event.data.action) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + robot.connected robot.disconnected + battery.low emergency_stop +""" + +# Message type constants — utiliser dans le code au lieu de strings brutes + +# Frontend → Backend +MSG_PING = "ping" +MSG_GET_STATE = "get_state" +MSG_GET_ACTIVITY = "get_activity" +MSG_TELEOP_MOVE = "teleop.move" +MSG_EMERGENCY_STOP = "emergency_stop" + +# Backend → Frontend +MSG_PONG = "pong" +MSG_INITIAL_STATE = "initial_state" +MSG_ACTIVITY_HISTORY = "activity_history" +MSG_STATE_RESPONSE = "state_response" +MSG_STATE_UPDATED = "robot_state_updated" +MSG_ACTIVITY_EVENT = "activity_event" diff --git a/src/robocoop_backend/robocoop_backend/app/message_router.py b/src/robocoop_backend/robocoop_backend/app/message_router.py deleted file mode 100644 index 9d6c2cf..0000000 --- a/src/robocoop_backend/robocoop_backend/app/message_router.py +++ /dev/null @@ -1,13 +0,0 @@ -# TODO: implement MessageRouter. - -# Expected behavior: -# - route inbound message by "type" field to correct service method -# - unknown type -> send ERR_INVALID_MESSAGE - -# Routing table: -# "teleop.move" -> teleop_service.handle_move() -# "mission.start" -> mission_service.start() -# "mission.cancel" -> mission_service.cancel() -# "mode.change" -> mode_service.request_transition() -# "emergency_stop" -> emergency_service.trigger() -# "ping" -> reply pong diff --git a/src/robocoop_backend/robocoop_backend/app/rate_limiter.py b/src/robocoop_backend/robocoop_backend/app/rate_limiter.py deleted file mode 100644 index 7d748c7..0000000 --- a/src/robocoop_backend/robocoop_backend/app/rate_limiter.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: implement RateLimiter for inbound WS messages. - -# Expected behavior: -# - sliding window counter per client -# - configurable max messages/second (see security.params.yaml) -# - return True if allowed, False if rate exceeded -# - send ERR_RATE_LIMITED to client on rejection - -# Note: teleop.move is high-frequency — set limit accordingly (e.g. 50/s). diff --git a/src/robocoop_backend/robocoop_backend/app/server.py b/src/robocoop_backend/robocoop_backend/app/server.py index dd8a996..8dc1292 100644 --- a/src/robocoop_backend/robocoop_backend/app/server.py +++ b/src/robocoop_backend/robocoop_backend/app/server.py @@ -1,21 +1,93 @@ -# TODO: implement WebSocket server entrypoint. +import asyncio +import logging +import signal +import sys +import os +from pathlib import Path -# Expected behavior: -# - init BackendContext from config -# - start websocket_handler on configured host/port -# - start watchdog_service timer -# - handle graceful shutdown (SIGTERM -> emergency_stop -> cleanup) +import websockets -# TODO(SAFETY): on any unhandled exception -> trigger emergency_stop before exit. +from robocoop_backend.app.backend_context import BackendContext +from robocoop_backend.app.websocket_handler import create_handler +from robocoop_backend.utils.config import Config + +logger = logging.getLogger(__name__) + + +class RobocopServer: + def __init__(self, config: Config): + self.config = config + self.context: BackendContext = None + self.server = None + self._shutdown_event = asyncio.Event() + + async def initialize(self) -> bool: + try: + self.context = BackendContext.initialize(self.config.to_dict()) + return await self.context.connect() + except Exception as e: + logger.error(f"Init error: {e}") + return False + + async def start(self) -> None: + if not self.context: + raise RuntimeError("Call initialize() first") + host = self.config.get_str("websocket.host", "0.0.0.0") + port = self.config.get_int("websocket.port", 8765) + handler = create_handler(self.context) + self.context.set_websocket_handler(handler) + self.server = await websockets.serve(handler, host, port) + logger.info(f"WebSocket server on ws://{host}:{port}") + await self._shutdown_event.wait() + + async def shutdown(self) -> None: + logger.info("Shutting down...") + try: + if self.context: + await self.context.disconnect() + if self.server: + self.server.close() + await self.server.wait_closed() + except Exception as e: + logger.error(f"Shutdown error: {e}") + finally: + self._shutdown_event.set() + + def signal_handler(self, signum: int, frame) -> None: + logger.warning(f"Signal {signum} received") + asyncio.create_task(self.shutdown()) -import asyncio -import websockets -from websocket_handler import handler async def main(): - async with websockets.serve(handler, "localhost", 8765): - print("Server started on ws://localhost:8765") - await asyncio.Future() + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + ) + config_dir = os.environ.get( + "ROBOCOOP_CONFIG_DIR", + str(Path(__file__).parent.parent.parent.parent / "robocoop_bringup" / "config"), + ) + try: + config = Config.load(config_dir=config_dir) + except Exception as e: + logger.error(f"Config load failed: {e}") + sys.exit(1) + + server = RobocopServer(config) + try: + if not await server.initialize(): + sys.exit(1) + loop = asyncio.get_event_loop() + for sig in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(sig, server.signal_handler, sig, None) + await server.start() + except KeyboardInterrupt: + await server.shutdown() + except Exception as e: + logger.error(f"Unexpected error: {e}") + await server.shutdown() + sys.exit(1) + if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + asyncio.run(main()) diff --git a/src/robocoop_backend/robocoop_backend/app/websocket_handler.py b/src/robocoop_backend/robocoop_backend/app/websocket_handler.py index 8515156..89e6b43 100644 --- a/src/robocoop_backend/robocoop_backend/app/websocket_handler.py +++ b/src/robocoop_backend/robocoop_backend/app/websocket_handler.py @@ -1,13 +1,99 @@ -# TODO(SECURITY): implement websocket authentication. +import json +import logging +from typing import Callable, Set -# TODO(SECURITY): validate incoming messages using schemas. +import websockets -# TODO: handle dashboard disconnect events. +from robocoop_backend.app.contracts import ( + MSG_PING, MSG_GET_STATE, MSG_GET_ACTIVITY, MSG_TELEOP_MOVE, MSG_EMERGENCY_STOP, + MSG_PONG, MSG_INITIAL_STATE, MSG_ACTIVITY_HISTORY, MSG_STATE_RESPONSE, + MSG_STATE_UPDATED, MSG_ACTIVITY_EVENT, +) +from robocoop_backend.modules.audit.audit_event import AuditEvent -import json +logger = logging.getLogger(__name__) + + +class WebSocketHandler: + def __init__(self, context): + self.context = context + self.clients: Set = set() + + async def register(self, websocket) -> None: + self.clients.add(websocket) + logger.info(f"Client connected ({len(self.clients)} total)") + await self._send_initial_state(websocket) + + async def unregister(self, websocket) -> None: + self.clients.discard(websocket) + logger.info(f"Client disconnected ({len(self.clients)} total)") + + 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})) + 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})) + except Exception as e: + logger.error(f"Error sending activity history: {e}") + + async def broadcast(self, message: dict) -> None: + if not self.clients: + return + disconnected = set() + for ws in self.clients: + try: + await ws.send(json.dumps(message)) + except Exception: + disconnected.add(ws) + for ws in disconnected: + await self.unregister(ws) + + 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_TELEOP_MOVE: + self.context.adapter.send_velocity(message.get("data")) + 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}") + except Exception as e: + logger.error(f"Message error: {e}") + + +def create_handler(context) -> Callable: + handler_instance = WebSocketHandler(context) + + async def handler(websocket): + await handler_instance.register(websocket) + try: + async for msg_str in websocket: + try: + await handler_instance.handle_message(websocket, json.loads(msg_str)) + except json.JSONDecodeError: + logger.warning("Invalid JSON from client") + except Exception as e: + logger.error(f"Message error: {e}") + except websockets.exceptions.ConnectionClosed: + pass + finally: + await handler_instance.unregister(websocket) -async def handler(websocket): - async for message in websocket: - data = json.loads(message) - if data.get("type") == "ping": - await websocket.send(json.dumps({"type": "pong"})) + handler.instance = handler_instance + return handler diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/adapter_factory.py b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/adapter_factory.py deleted file mode 100644 index 6965842..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/adapter_factory.py +++ /dev/null @@ -1,10 +0,0 @@ -# TODO: implement AdapterFactory. - -# Expected behavior: -# - read adapter type from config (mock | sim | real) -# - return corresponding RobotAdapter instance - -# Example: -# "mock" -> MockRobotAdapter() -# "sim" -> SimRobotAdapter() -# "real" -> M3ProRobotAdapter() diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_robot_adapter.py b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_robot_adapter.py deleted file mode 100644 index f7db56c..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_robot_adapter.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: implement M3ProRobotAdapter (real hardware). - -# Expected behavior: -# - publish TeleopCommand to /cmd_vel as Twist -# - subscribe to /odom, /battery_state for state updates -# - call emergency_stop via dedicated ROS2 service or zero Twist - -# TODO(M3PRO): verify topic names against m3pro_topic_map.py before use. diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_topic_map.py b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_topic_map.py deleted file mode 100644 index 7ee2e3f..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_topic_map.py +++ /dev/null @@ -1,13 +0,0 @@ -# TODO(M3PRO): confirm real topic names on hardware. - -# Expected candidates (to verify): -# /cmd_vel -> geometry_msgs/msg/Twist -# /odom -> nav_msgs/msg/Odometry -# /scan -> sensor_msgs/msg/LaserScan -# /imu/data -> sensor_msgs/msg/Imu -# /battery_state -> sensor_msgs/msg/BatteryState - -# Verify with: -# ros2 topic list -# ros2 topic info -# ros2 interface show \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/mock_robot_adapter.py b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/mock_robot_adapter.py deleted file mode 100644 index 5d08e57..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/mock_robot_adapter.py +++ /dev/null @@ -1,7 +0,0 @@ -# TODO: implement MockRobotAdapter (in-memory, no ROS). - -# Expected behavior: -# - store state in memory -# - simulate battery drain over time -# - simulate obstacle detection randomly (configurable rate) -# - log all received commands diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/robot_adapter.py b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/robot_adapter.py deleted file mode 100644 index d590545..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/robot_adapter.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: define RobotAdapter abstract interface. - -# Expected abstract methods: -# send_velocity(command: TeleopCommand) -> None -# emergency_stop() -> None -# navigate_to(x: float, y: float) -> None -# get_state() -> RobotState -# is_connected() -> bool diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/sim_robot_adapter.py b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/sim_robot_adapter.py deleted file mode 100644 index d721cf1..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/sim_robot_adapter.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: implement SimRobotAdapter (Gazebo via ROS2 topics). - -# Expected behavior: -# - same interface as M3ProRobotAdapter -# - connect to simulated topics in Gazebo -# - useful for integration tests without hardware - -# Note: topic names should match real robot (see m3pro_topic_map.py). diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/emergency_stop_node.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/emergency_stop_node.py deleted file mode 100644 index 63f940b..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/ros/emergency_stop_node.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: implement EmergencyStopNode (ROS2 node). - -# Expected behavior: -# - subscribe to internal /emergency_stop topic -# - on message: publish zero Twist to /cmd_vel immediately -# - this node is the last safety net — must be as simple as possible - -# TODO(SAFETY): this node must NOT depend on any service or store. -# Direct ROS2 publish only. diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/launch_manager.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/launch_manager.py deleted file mode 100644 index e58fd60..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/ros/launch_manager.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: implement LaunchManager. - -# Expected behavior: -# - programmatically start/stop ROS2 nodes at runtime -# - used to activate navigation stack on AUTONOMOUS mode -# - used to teardown nodes on shutdown - -# TODO: wrap ros2launch API or use subprocess with proper cleanup. diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/mode_bridge_node.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/mode_bridge_node.py deleted file mode 100644 index c5db8c4..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/ros/mode_bridge_node.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: implement ModeBridgeNode (ROS2 node). - -# Expected behavior: -# - subscribe to /robot_mode topic -# - forward mode change to mode_manager (state layer) -# - publish current mode on /robot_mode when mode_manager updates - -# Note: this node only bridges — no transition logic here. diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/robot_state_node.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/robot_state_node.py deleted file mode 100644 index 4ba2935..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/ros/robot_state_node.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: implement RobotStateNode (ROS2 node). - -# Expected behavior: -# - subscribe to /connection_state or ping robot periodically -# - update robot_state_store.is_connected on change -# - trigger alert on disconnect - -# TODO: publish connection state changes to watchdog_node. diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/telemetry_bridge_node.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/telemetry_bridge_node.py deleted file mode 100644 index 9a77a91..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/ros/telemetry_bridge_node.py +++ /dev/null @@ -1,11 +0,0 @@ -# TODO(M3PRO): subscribe to robot telemetry topics. - -# Expected: -# /odom -# /battery_state -# /scan -# /imu/data - -# TODO: forward telemetry to telemetry_service. - -# TODO: update robot_state_store with latest data. \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/teleop_bridge_node.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/teleop_bridge_node.py deleted file mode 100644 index 098e501..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/ros/teleop_bridge_node.py +++ /dev/null @@ -1,6 +0,0 @@ -# TODO(M3PRO): confirm velocity command topic (likely /cmd_vel). - -# TODO: convert TeleopCommand -> Twist message. - -# TODO(SAFETY): stop robot if no command received for X seconds -# (dead man's switch). \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/watchdog_node.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/watchdog_node.py deleted file mode 100644 index 243d475..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/ros/watchdog_node.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: implement WatchdogNode (ROS2 node). - -# Expected behavior: -# - timer at configurable interval (e.g. 1s) -# - check last_heartbeat from robot_state_store -# - if delta > timeout -> call emergency_service.trigger("watchdog_timeout") - -# TODO(SAFETY): watchdog timer must survive WS handler exceptions. diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/error_messages.py b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/error_messages.py deleted file mode 100644 index 26c2bed..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/error_messages.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: define error code constants. - -# Expected codes: -# ERR_INVALID_MESSAGE = "invalid_message" -# ERR_UNAUTHORIZED = "unauthorized" -# ERR_MODE_FORBIDDEN = "mode_forbidden" -# ERR_MISSION_ACTIVE = "mission_already_active" -# ERR_EMERGENCY_STOP = "robot_in_emergency_stop" -# ERR_RATE_LIMITED = "rate_limited" diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/events.py b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/events.py deleted file mode 100644 index c358f39..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/events.py +++ /dev/null @@ -1,17 +0,0 @@ -# TODO: define WebSocket event type constants. - -# Inbound event types: -# "teleop.move" -# "mission.start" -# "mission.cancel" -# "mode.change" -# "emergency_stop" -# "ping" - -# Outbound event types: -# "robot_state_update" -# "mission_update" -# "alert_event" -# "mode_changed" -# "error" -# "pong" diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/inbound.py b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/inbound.py deleted file mode 100644 index 1fe60bd..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/inbound.py +++ /dev/null @@ -1,4 +0,0 @@ -# TODO: define schema for mission.start message. - - -# TODO: define schema for teleop.move message. \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/outbound.py b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/outbound.py deleted file mode 100644 index a5457b9..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/outbound.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: define outbound WebSocket message schemas (server -> dashboard). - -# Expected schemas: -# robot_state_update { mode, battery, position, velocity, is_connected } -# mission_update { mission_id, state, failure_reason? } -# alert_event { id, severity, message, location, timestamp } -# mode_changed { previous_mode, new_mode, actor } -# error { code, message } diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/validation.py b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/validation.py deleted file mode 100644 index bc9dd89..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/validation.py +++ /dev/null @@ -1,7 +0,0 @@ -# TODO: implement validate_inbound(message: dict) -> bool. - -# Expected behavior: -# - check "type" field exists and is a known event type -# - validate payload against matching schema -# - return False + log warning on invalid message -# - never raise — caller decides what to do diff --git a/src/robocoop_backend/robocoop_backend/modules/audit/README.md b/src/robocoop_backend/robocoop_backend/modules/audit/README.md new file mode 100644 index 0000000..f7fa9f0 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/audit/README.md @@ -0,0 +1,96 @@ +# modules/audit/ + +Records and broadcasts significant events in the system — robot lifecycle, safety alerts, operator actions. Think of it as a structured activity log, not a debug log. + +## Files + +| File | Role | +|---|---| +| `audit_event.py` | `AuditEvent` dataclass — the unit of record | +| `audit_service.py` | Public API — `record()`, `get_history()`, broadcasts to WebSocket | +| `audit_logger.py` | Multiplexes events to one or more sinks | +| `event_formatter.py` | Serializes an `AuditEvent` to a JSON-serializable dict | +| `sinks.py` | Output destinations: `ConsoleSink` (stdout), `FileSink` (JSON lines file) | + +## Data model + +```python +@dataclass +class AuditEvent: + action: str # e.g. "battery.low", "robot.connected" + actor: str # "system" | "dashboard" | "watchdog" + payload: dict # optional context — flattened in the output + id: str # UUID, auto-generated + timestamp: datetime # auto-generated +``` + +The `payload` is flattened at the top level when serialized. For example: + +```python +AuditEvent(action="battery.low", actor="system", payload={"battery_level": 18.5, "threshold": 20.0}) +``` + +Serializes to: +```json +{ + "id": "...", + "action": "battery.low", + "actor": "system", + "timestamp": "2025-05-18T12:00:00", + "battery_level": 18.5, + "threshold": 20.0 +} +``` + +## Current event types + +| Action | Actor | Trigger | Payload | +|---|---|---|---| +| `robot.connected` | `system` | Battery message received after a disconnection | — | +| `robot.disconnected` | `system` | No battery message for 15s (watchdog) | — | +| `battery.low` | `system` | Battery drops below threshold (default 20%) | `battery_level`, `threshold` | +| `emergency_stop` | `dashboard` | Frontend sends `emergency_stop` message | — | + +## How to record a new event + +Call `audit_service.record()` from anywhere that has access to the service: + +```python +from robocoop_backend.modules.audit.audit_event import AuditEvent + +self.audit_service.record(AuditEvent( + action="navigation.started", + actor="dashboard", + payload={"target_x": 1.5, "target_y": 2.0}, +)) +``` + +`record()` is fire-and-forget — it never blocks and swallows its own errors so it cannot break a critical operation. + +## How the event reaches the frontend + +``` +audit_service.record(event) + → stored in in-memory deque (last 100 events) + → written to sinks (console + file) + → broadcast WebSocket push to all clients: + {"type": "activity_event", "data": { ...serialized event... }} +``` + +New clients receive the last 50 events automatically on connection (`activity_history` message). + +## Adding a new sink + +Implement `AuditSink.write()` and register it when creating `AuditLogger`: + +```python +class DatabaseSink(AuditSink): + def write(self, event_dict: dict) -> None: + # insert into DB + ... + +# In backend_context.py: +AuditLogger(sinks=[ConsoleSink(), DatabaseSink()]) +``` + +The logger calls all sinks for every event. Sink failures are isolated — one broken sink does not affect the others. diff --git a/src/robocoop_backend/robocoop_backend/modules/audit/audit_event.py b/src/robocoop_backend/robocoop_backend/modules/audit/audit_event.py new file mode 100644 index 0000000..170ef4e --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/audit/audit_event.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict +import uuid + + +@dataclass +class AuditEvent: + action: str # e.g. "robot.connected", "battery.low", "emergency_stop" + actor: str # "dashboard" | "watchdog" | "system" + payload: Dict[str, Any] = field(default_factory=dict) + id: str = field(default_factory=lambda: str(uuid.uuid4())) + timestamp: datetime = field(default_factory=datetime.now) diff --git a/src/robocoop_backend/robocoop_backend/modules/audit/audit_logger.py b/src/robocoop_backend/robocoop_backend/modules/audit/audit_logger.py index 0067ce6..55d7cbf 100644 --- a/src/robocoop_backend/robocoop_backend/modules/audit/audit_logger.py +++ b/src/robocoop_backend/robocoop_backend/modules/audit/audit_logger.py @@ -1,4 +1,32 @@ -# TODO(AUDIT): log critical actions: -# - mission start -# - mode change -# - emergency stop \ No newline at end of file +import logging +from typing import List + +from robocoop_backend.modules.audit.audit_event import AuditEvent +from robocoop_backend.modules.audit.event_formatter import EventFormatter +from robocoop_backend.modules.audit.sinks import AuditSink + +logger = logging.getLogger(__name__) + + +class AuditLogger: + """Forwards AuditEvents to one or more AuditSinks. + + Logs critical actions: robot connected/disconnected, battery low, + emergency stop, mode change, mission lifecycle. + """ + + def __init__(self, sinks: List[AuditSink] = None): + self._sinks = sinks or [] + self._formatter = EventFormatter() + + def log(self, event: AuditEvent) -> None: + """Format and dispatch event to all registered sinks. + + Sink failures are swallowed so audit never blocks critical operations. + """ + event_dict = self._formatter.format(event) + for sink in self._sinks: + try: + sink.write(event_dict) + except Exception as e: + logger.error("AuditLogger sink error: %s", e) diff --git a/src/robocoop_backend/robocoop_backend/modules/audit/audit_service.py b/src/robocoop_backend/robocoop_backend/modules/audit/audit_service.py index 68daa7e..9fe666a 100644 --- a/src/robocoop_backend/robocoop_backend/modules/audit/audit_service.py +++ b/src/robocoop_backend/robocoop_backend/modules/audit/audit_service.py @@ -1,8 +1,85 @@ -# TODO: implement AuditService. +import asyncio +import logging +from collections import deque +from typing import List, Optional -# Expected methods: -# record(event: AuditEvent) -> None -# - forward to audit_logger -# - non-blocking (fire and forget) +from robocoop_backend.modules.audit.audit_logger import AuditLogger +from robocoop_backend.modules.audit.audit_event import AuditEvent +from robocoop_backend.modules.audit.event_formatter import EventFormatter -# TODO(AUDIT): do not let audit failures block critical operations. +logger = logging.getLogger(__name__) + + +class AuditService: + """High-level service for recording and querying recent activity. + + - record(event) stores the event in-memory, writes to sinks, and + broadcasts an "activity_event" WebSocket message (fire-and-forget). + - get_history(limit) returns the N most-recent events as dicts. + + Failures inside record() are swallowed so audit never blocks critical ops. + """ + + def __init__( + self, + audit_logger: AuditLogger, + max_history: int = 100, + websocket_handler=None, + ): + self._logger = audit_logger + self._history: deque = deque(maxlen=max_history) + self._formatter = EventFormatter() + self.websocket_handler = websocket_handler + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def record(self, event: AuditEvent) -> None: + """Record an audit event (non-blocking, fire-and-forget).""" + try: + self._history.append(event) + self._logger.log(event) + self._broadcast_async(event) + except Exception as e: + logger.error("AuditService.record error: %s", e) + + def get_history(self, limit: int = 50) -> List[dict]: + """Return the *limit* most-recent events, newest-first. + + Args: + limit: Maximum number of events to return + + Returns: + List of serialized event dicts, newest first + """ + events = list(self._history) + return [self._formatter.format(e) for e in reversed(events[-limit:])] + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _broadcast_async(self, event: AuditEvent) -> None: + """Schedule a WebSocket broadcast without blocking the caller.""" + if not self.websocket_handler: + return + try: + asyncio.create_task(self._async_broadcast(event)) + except RuntimeError: + # No running event loop (e.g. during tests) + pass + + async def _async_broadcast(self, event: AuditEvent) -> None: + try: + message = { + "type": "activity_event", + "data": self._formatter.format(event), + } + 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("AuditService broadcast error: %s", e) diff --git a/src/robocoop_backend/robocoop_backend/modules/audit/domain/audit_event.py b/src/robocoop_backend/robocoop_backend/modules/audit/domain/audit_event.py deleted file mode 100644 index 7cb6a6d..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/audit/domain/audit_event.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: define AuditEvent dataclass. - -# Expected fields: -# id: str -# action: str # e.g. "mission.start", "mode.change", "emergency_stop" -# actor: str # "dashboard" | "watchdog" | "system" -# payload: dict # action-specific data -# timestamp: datetime diff --git a/src/robocoop_backend/robocoop_backend/modules/audit/event_formatter.py b/src/robocoop_backend/robocoop_backend/modules/audit/event_formatter.py index c2b310c..1d4788b 100644 --- a/src/robocoop_backend/robocoop_backend/modules/audit/event_formatter.py +++ b/src/robocoop_backend/robocoop_backend/modules/audit/event_formatter.py @@ -1,9 +1,28 @@ -# TODO: implement EventFormatter. +from robocoop_backend.modules.audit.audit_event import AuditEvent -# Expected methods: -# format(event: AuditEvent) -> dict -# - serialize AuditEvent to JSON-serializable dict -# - include iso timestamp -# - flatten payload fields at top level -# TODO: ensure no sensitive data leaks into audit log (e.g. auth tokens). +class EventFormatter: + """Serializes AuditEvent to a JSON-serializable dict.""" + + @staticmethod + def format(event: AuditEvent) -> dict: + """Serialize an AuditEvent. + + Payload fields are flattened at the top level. + No sensitive data (auth tokens, passwords) should appear in payloads. + + Args: + event: AuditEvent to serialize + + Returns: + JSON-serializable dictionary + """ + result = { + "id": event.id, + "action": event.action, + "actor": event.actor, + "timestamp": event.timestamp.isoformat(), + } + # Flatten payload fields at top level + result.update(event.payload) + return result diff --git a/src/robocoop_backend/robocoop_backend/modules/audit/sinks.py b/src/robocoop_backend/robocoop_backend/modules/audit/sinks.py index 9d5be35..407fd78 100644 --- a/src/robocoop_backend/robocoop_backend/modules/audit/sinks.py +++ b/src/robocoop_backend/robocoop_backend/modules/audit/sinks.py @@ -1,8 +1,52 @@ -# TODO: define AuditSink abstract interface + implementations. +import json +import logging +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path -# Expected sinks: -# FileSink -> append JSON lines to rotating log file -# ConsoleSink -> print to stdout (dev/debug only) -# TODO: FileSink path configurable via backend.params.yaml. -# TODO: sinks must be non-blocking (write in background thread). +class AuditSink: + """Abstract base for audit output sinks.""" + + def write(self, event_dict: dict) -> None: + raise NotImplementedError + + +class ConsoleSink(AuditSink): + """Prints audit events to stdout via the audit logger (dev/debug only).""" + + def __init__(self): + self._log = logging.getLogger("robocoop.audit") + + def write(self, event_dict: dict) -> None: + self._log.info("[AUDIT] %s", json.dumps(event_dict)) + + +class FileSink(AuditSink): + """Appends audit events as JSON lines to a rotating log file. + + Writes are executed in a background thread so they never block the + async event loop. + """ + + def __init__(self, path: str): + self._path = Path(path) + self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="audit-file") + self._log = logging.getLogger("robocoop.audit") + + def write(self, event_dict: dict) -> None: + """Schedule a non-blocking write.""" + import asyncio + try: + loop = asyncio.get_running_loop() + loop.run_in_executor(self._executor, self._write_sync, event_dict) + except RuntimeError: + # No running loop – write synchronously (startup / tests) + self._write_sync(event_dict) + + def _write_sync(self, event_dict: dict) -> None: + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + with open(self._path, "a", encoding="utf-8") as f: + f.write(json.dumps(event_dict) + "\n") + except Exception as e: + self._log.error("FileSink write error: %s", e) diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_failure.py b/src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_failure.py deleted file mode 100644 index f136191..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_failure.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: define MissionFailureReason enum. - -# Expected values: -# OBSTACLE_DETECTED -# BATTERY_LOW -# TIMEOUT -# NAVIGATION_ERROR -# EMERGENCY_STOP -# MANUAL_CANCEL diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_state.py b/src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_state.py deleted file mode 100644 index 70b9567..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_state.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: define MissionState enum. - -# Expected values: -# IDLE -# RUNNING -# BLOCKED -# COMPLETED -# FAILED -# CANCELLED diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/mission_service.py b/src/robocoop_backend/robocoop_backend/modules/mission/mission_service.py deleted file mode 100644 index 94d6b77..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/mission/mission_service.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO(NAV): implement mission type "navigate_to". -# Should send goal to robot_adapter.navigate_to(). - - -# TODO(NAV): define mission lifecycle events -# (STARTED, BLOCKED, COMPLETED, FAILED). - -# TODO(SAFETY): prevent mission start if robot is in EMERGENCY_STOP. \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/mission_state_machine.py b/src/robocoop_backend/robocoop_backend/modules/mission/mission_state_machine.py deleted file mode 100644 index 7484950..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/mission/mission_state_machine.py +++ /dev/null @@ -1,10 +0,0 @@ -# TODO: implement mission state transitions. - -# Example: -# IDLE -> RUNNING -# RUNNING -> COMPLETED -# RUNNING -> FAILED -# RUNNING -> BLOCKED - - -# TODO: define failure reasons (obstacle, battery_low, timeout). \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/mission_state_store.py b/src/robocoop_backend/robocoop_backend/modules/mission/mission_state_store.py deleted file mode 100644 index 8c3b27c..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/mission/mission_state_store.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: implement MissionStateStore. - -# Expected methods: -# get_current() -> MissionState -# set(state: MissionState) -> None -# get_active_mission() -> dict | None -# clear() -> None - -# TODO(CONCURRENCY): use thread_safe_lock for all access. diff --git a/src/robocoop_backend/robocoop_backend/modules/mode/__init__.py b/src/robocoop_backend/robocoop_backend/modules/mode/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/robocoop_backend/robocoop_backend/modules/mode/mode_manager.py b/src/robocoop_backend/robocoop_backend/modules/mode/mode_manager.py deleted file mode 100644 index ee072ac..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/mode/mode_manager.py +++ /dev/null @@ -1,10 +0,0 @@ -# TODO: define allowed robot mode transitions. - -# Example: -# IDLE -> MANUAL -# MANUAL -> AUTONOMOUS -# ANY -> EMERGENCY_STOP - -# TODO(SAFETY): watchdog must be able to force EMERGENCY_STOP. - -# TODO: ensure thread-safe access to mode state. \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/modules/mode/mode_service.py b/src/robocoop_backend/robocoop_backend/modules/mode/mode_service.py deleted file mode 100644 index 4efbf20..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/mode/mode_service.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: implement ModeService. - -# Expected methods: -# request_transition(target: RobotMode, actor: str) -> bool -# - validate transition via mode_manager -# - apply if valid -# - emit audit event on success -# - return False if transition not allowed diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/README.md b/src/robocoop_backend/robocoop_backend/modules/robot/README.md new file mode 100644 index 0000000..83b8f96 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/robot/README.md @@ -0,0 +1,107 @@ +# modules/robot/ + +Handles robot state and the telemetry pipeline. Two files, two responsibilities. + +## Files + +### `state_store.py` + +Single source of truth for the robot's current state. Every part of the app reads from and writes to this store — never to a local variable. + +**Current fields** (only what is actually fed by a live ROS topic): + +| Field | Type | Source | +|---|---|---| +| `is_connected` | `bool` | `/battery` watchdog | +| `battery_level` | `float` (0–100%) | `/battery` topic | +| `last_updated` | `datetime` | set automatically on every `update()` | + +**API:** + +```python +store = RobotStateStore() + +store.get() # returns RobotState dataclass +store.to_dict() # returns JSON-serializable dict (sent to frontend) +store.update({ # partial update — only pass what changed + "battery_level": 72.5, + "is_connected": True, +}) +store.reset() # back to defaults +``` + +**Rule:** only add a field to `RobotState` when a ROS topic actually provides it. Do not add fields "in advance". + +--- + +### `telemetry_service.py` + +The pipeline between the adapter and the rest of the system. Called every time the adapter receives data from the robot. + +**Flow when `on_telemetry_received(data)` is called:** + +``` +1. Read previous is_connected (to detect state change) +2. Update RobotStateStore with the new data +3. If is_connected changed → record audit event (robot.connected / robot.disconnected) +4. If battery_level present and below threshold → record audit event (battery.low) once per episode +5. Broadcast updated state to all WebSocket clients +``` + +**The battery alert is edge-triggered:** it fires once when the battery drops below the threshold, and resets when it recovers above it. It will not spam the dashboard. + +--- + +## How to add a new ROS topic to the state + +**Example:** you want to add robot speed from `/odom`. + +### 1. Add the field to `RobotState` + +```python +@dataclass +class RobotState: + is_connected: bool = False + battery_level: float = 0.0 + linear_velocity: float = 0.0 # add + last_updated: datetime = ... + + def to_dict(self): + return { + "is_connected": self.is_connected, + "battery_level": self.battery_level, + "linear_velocity": self.linear_velocity, # add + "last_updated": self.last_updated.isoformat(), + } +``` + +### 2. Feed it from the adapter + +In `rosbridge_adapter.py`, your new callback calls: +```python +self.telemetry_service.on_telemetry_received({ + "linear_velocity": twist.linear.x, +}) +``` + +`TelemetryService` handles the rest — store update, broadcast, threshold checks. You do not touch `telemetry_service.py`. + +### 3. Add a threshold alert (optional) + +If the new field needs a threshold check (e.g., overspeed alert), add a `_check_velocity()` method in `TelemetryService` following the exact same pattern as `_check_battery()`: + +```python +def _check_velocity(self, velocity: float) -> None: + if velocity > self.velocity_warning_threshold: + if self.audit_service and not self._velocity_alert_sent: + self._velocity_alert_sent = True + self.audit_service.record(AuditEvent( + action="velocity.high", + actor="system", + payload={"velocity": round(velocity, 2)}, + )) + else: + self._velocity_alert_sent = False +``` + +Then call it from `on_telemetry_received()` alongside `_check_battery()`. diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/domain/__init__.py b/src/robocoop_backend/robocoop_backend/modules/robot/domain/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/domain/connection_state.py b/src/robocoop_backend/robocoop_backend/modules/robot/domain/connection_state.py deleted file mode 100644 index 43bfd85..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/robot/domain/connection_state.py +++ /dev/null @@ -1,6 +0,0 @@ -# TODO: define ConnectionState enum. - -# Expected values: -# CONNECTED -# DISCONNECTED -# RECONNECTING diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_mode.py b/src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_mode.py deleted file mode 100644 index 5f62a34..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_mode.py +++ /dev/null @@ -1,7 +0,0 @@ -# TODO: define RobotMode enum. - -# Expected values: -# IDLE -# MANUAL -# AUTONOMOUS -# EMERGENCY_STOP diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_state.py b/src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_state.py deleted file mode 100644 index 91e662f..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_state.py +++ /dev/null @@ -1,10 +0,0 @@ -# TODO: define RobotState dataclass. - -# Expected fields: -# mode: RobotMode -# battery_level: float # 0.0 - 100.0 -# position: tuple[float, float] -# linear_velocity: float -# angular_velocity: float -# is_connected: bool -# last_updated: datetime diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/domain/teleop_command.py b/src/robocoop_backend/robocoop_backend/modules/robot/domain/teleop_command.py deleted file mode 100644 index 2a56550..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/robot/domain/teleop_command.py +++ /dev/null @@ -1,7 +0,0 @@ -# TODO: define TeleopCommand dataclass. - -# Expected fields: -# linear_x: float # forward/backward -1.0 to 1.0 -# linear_y: float # strafe -1.0 to 1.0 -# angular_z: float # rotation -1.0 to 1.0 -# speed_factor: float # global multiplier 0.0 to 1.0 diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/robot_state_store.py b/src/robocoop_backend/robocoop_backend/modules/robot/robot_state_store.py deleted file mode 100644 index 6a43d3e..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/robot/robot_state_store.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: implement RobotStateStore (single source of truth for robot state). - -# Expected methods: -# get() -> RobotState -# update(partial: dict) -> None -# reset() -> None - -# TODO(CONCURRENCY): use thread_safe_lock for all read/write access. diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/state_store.py b/src/robocoop_backend/robocoop_backend/modules/robot/state_store.py new file mode 100644 index 0000000..707f878 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/modules/robot/state_store.py @@ -0,0 +1,42 @@ +import logging +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any, Dict + +logger = logging.getLogger(__name__) + + +@dataclass +class RobotState: + is_connected: bool = False + battery_level: float = 0.0 + last_updated: datetime = field(default_factory=datetime.now) + + def to_dict(self) -> Dict[str, Any]: + return { + "is_connected": self.is_connected, + "battery_level": self.battery_level, + "last_updated": self.last_updated.isoformat(), + } + + +class RobotStateStore: + def __init__(self): + self._state = RobotState() + + def get(self) -> RobotState: + return self._state + + def update(self, partial: Dict[str, Any]) -> None: + for key, value in partial.items(): + if hasattr(self._state, key): + setattr(self._state, key, value) + else: + logger.warning(f"Unknown state field: {key}") + self._state.last_updated = datetime.now() + + def reset(self) -> None: + self._state = RobotState() + + def to_dict(self) -> Dict[str, Any]: + return self._state.to_dict() diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/telemetry_service.py b/src/robocoop_backend/robocoop_backend/modules/robot/telemetry_service.py index 2a98dd4..2e2d374 100644 --- a/src/robocoop_backend/robocoop_backend/modules/robot/telemetry_service.py +++ b/src/robocoop_backend/robocoop_backend/modules/robot/telemetry_service.py @@ -1,7 +1,83 @@ -# TODO: implement TelemetryService. +import logging +import asyncio +from typing import Dict, Any, Optional -# Expected methods: -# on_telemetry_received(data: dict) -> None -# - update robot_state_store -# - check battery threshold -> emit WARNING alert if < 20% -# - broadcast updated state to connected dashboard via websocket +from robocoop_backend.modules.audit.audit_event import AuditEvent + +logger = logging.getLogger(__name__) + + +class TelemetryService: + def __init__( + self, + robot_state_store=None, + websocket_handler=None, + audit_service=None, + battery_warning_threshold: float = 20.0, + ): + self.robot_state_store = robot_state_store + self.websocket_handler = websocket_handler + self.audit_service = audit_service + self.battery_warning_threshold = battery_warning_threshold + self._battery_alert_sent = False + + def on_telemetry_received(self, data: Dict[str, Any]) -> None: + try: + prev_connected: Optional[bool] = None + if self.robot_state_store and "is_connected" in data: + prev_connected = self.robot_state_store.get().is_connected + + if self.robot_state_store: + self.robot_state_store.update(data) + state = self.robot_state_store.to_dict() + else: + state = data + + new_connected = data.get("is_connected") + if new_connected is not None and prev_connected != new_connected: + self._emit_connection_event(new_connected) + + battery = data.get("battery_level") + if battery is not None: + self._check_battery(battery) + + self._broadcast({"type": "robot_state_updated", "data": state}) + except Exception as e: + logger.error(f"Telemetry error: {e}") + + def _emit_connection_event(self, is_connected: bool) -> None: + if not self.audit_service: + return + action = "robot.connected" if is_connected else "robot.disconnected" + self.audit_service.record(AuditEvent(action=action, actor="system", payload={})) + + def _check_battery(self, battery_level: float) -> None: + if battery_level < self.battery_warning_threshold: + logger.warning(f"Battery low: {battery_level:.1f}%") + if self.audit_service and not self._battery_alert_sent: + self._battery_alert_sent = True + self.audit_service.record(AuditEvent( + action="battery.low", + actor="system", + payload={"battery_level": round(battery_level, 1), "threshold": self.battery_warning_threshold}, + )) + else: + self._battery_alert_sent = False + + 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(f"Broadcast error: {e}") diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/teleop_service.py b/src/robocoop_backend/robocoop_backend/modules/robot/teleop_service.py deleted file mode 100644 index e422f55..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/robot/teleop_service.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: implement TeleopService. - -# Expected methods: -# handle_move(command: TeleopCommand) -> None -# - reject if mode != MANUAL -# - forward to robot_adapter.send_velocity() - -# TODO(SAFETY): reject commands if robot is in EMERGENCY_STOP. -# TODO(SAFETY): validate speed_factor is within [0.0, 1.0]. diff --git a/src/robocoop_backend/robocoop_backend/modules/safety/__init__.py b/src/robocoop_backend/robocoop_backend/modules/safety/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/robocoop_backend/robocoop_backend/modules/safety/domain/__init__.py b/src/robocoop_backend/robocoop_backend/modules/safety/domain/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/robocoop_backend/robocoop_backend/modules/safety/domain/alert.py b/src/robocoop_backend/robocoop_backend/modules/safety/domain/alert.py deleted file mode 100644 index 36d9e02..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/safety/domain/alert.py +++ /dev/null @@ -1,11 +0,0 @@ -# TODO: define Alert dataclass. - -# Expected fields: -# id: str -# severity: AlertSeverity # INFO | WARNING | CRITICAL -# message: str -# location: str | None # e.g. "Couloir B" -# timestamp: datetime -# resolved: bool - -# TODO: define AlertSeverity enum. diff --git a/src/robocoop_backend/robocoop_backend/modules/safety/emergency_service.py b/src/robocoop_backend/robocoop_backend/modules/safety/emergency_service.py deleted file mode 100644 index 471008f..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/safety/emergency_service.py +++ /dev/null @@ -1,10 +0,0 @@ -# TODO: implement EmergencyService. - -# Expected methods: -# trigger(reason: str, actor: str) -> None -# - call robot_adapter.emergency_stop() -# - force mode to EMERGENCY_STOP via mode_manager -# - cancel active mission via mission_state_store -# - emit audit event - -# TODO(SAFETY): this must never fail silently — log + re-raise on error. diff --git a/src/robocoop_backend/robocoop_backend/modules/safety/watchdog_service.py b/src/robocoop_backend/robocoop_backend/modules/safety/watchdog_service.py deleted file mode 100644 index f55ea16..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/safety/watchdog_service.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: implement WatchdogService. - -# Expected behavior: -# - monitor last heartbeat timestamp from dashboard -# - if no message received for X seconds -> trigger emergency_service -# - monitor robot connection state -> alert on disconnect - -# TODO(SAFETY): watchdog timeout must be configurable (see common.params.yaml). -# TODO(SAFETY): watchdog must run in its own thread/timer, independent of WS loop. diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/__init__.py b/src/robocoop_backend/robocoop_backend/services/.gitkeep similarity index 100% rename from src/robocoop_backend/robocoop_backend/infrastructure/adapters/__init__.py rename to src/robocoop_backend/robocoop_backend/services/.gitkeep diff --git a/src/robocoop_backend/robocoop_backend/tests/conftest.py b/src/robocoop_backend/robocoop_backend/tests/conftest.py new file mode 100644 index 0000000..60f39a0 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/conftest.py @@ -0,0 +1,68 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from robocoop_backend.app.backend_context import BackendContext +from robocoop_backend.modules.audit.audit_logger import AuditLogger +from robocoop_backend.modules.audit.audit_service import AuditService +from robocoop_backend.modules.robot.state_store import RobotStateStore +from robocoop_backend.modules.robot.telemetry_service import TelemetryService + + +@pytest.fixture(autouse=True) +def reset_backend_context(): + BackendContext.reset() + yield + BackendContext.reset() + + +@pytest.fixture +def state_store(): + return RobotStateStore() + + +@pytest.fixture +def null_audit_logger(): + return AuditLogger(sinks=[]) + + +@pytest.fixture +def audit_service(null_audit_logger): + return AuditService(audit_logger=null_audit_logger) + + +@pytest.fixture +def telemetry_service(state_store, audit_service): + return TelemetryService( + robot_state_store=state_store, + audit_service=audit_service, + ) + + +@pytest.fixture +def mock_websocket(): + ws = AsyncMock() + ws.send = AsyncMock() + ws.closed = False + return ws + + +@pytest.fixture +def mock_ws_connection(): + ws = AsyncMock() + ws.send = AsyncMock() + ws.close = AsyncMock() + # Make "async for msg in ws" stop immediately + ws.__anext__ = AsyncMock(side_effect=StopAsyncIteration) + return ws + + +@pytest.fixture +def patch_ws_connect(mock_ws_connection): + # websockets.connect must be an AsyncMock so calling it returns a coroutine, + # which asyncio.wait_for can then await. + with patch( + "robocoop_backend.adapters.rosbridge_client.websockets.connect", + new=AsyncMock(return_value=mock_ws_connection), + ): + yield mock_ws_connection diff --git a/src/robocoop_backend/robocoop_backend/tests/fixtures/__init__.py b/src/robocoop_backend/robocoop_backend/tests/fixtures/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_messages.py b/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_messages.py deleted file mode 100644 index 94b1f07..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_messages.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: define fake inbound WebSocket messages for tests. - -# Expected: -# MSG_TELEOP_MOVE -> type="teleop.move", linear_x=0.5, angular_z=0.0 -# MSG_TELEOP_INVALID -> type="teleop.move", linear_x=5.0 (out of range) -# MSG_EMERGENCY_STOP -> type="emergency_stop" -# MSG_PING -> type="ping" -# MSG_UNKNOWN_TYPE -> type="unknown.event" -# MSG_MISSING_TYPE -> no "type" field diff --git a/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_mission_data.py b/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_mission_data.py deleted file mode 100644 index 6adba05..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_mission_data.py +++ /dev/null @@ -1,6 +0,0 @@ -# TODO: define fake mission payloads for tests. - -# Expected: -# MISSION_DELIVERY -> type=delivery, target="Chambre 302", content="Médicaments" -# MISSION_GUIDANCE -> type=guidance, target="Patient A" -# MISSION_INVALID -> missing required fields (for validation tests) diff --git a/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_robot_state.py b/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_robot_state.py deleted file mode 100644 index 3c48903..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_robot_state.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: define fake RobotState instances for tests. - -# Expected: -# ROBOT_IDLE -> mode=IDLE, battery=80.0, connected=True -# ROBOT_MANUAL -> mode=MANUAL, battery=60.0, connected=True -# ROBOT_AUTONOMOUS -> mode=AUTONOMOUS, battery=55.0, connected=True -# ROBOT_EMERGENCY -> mode=EMERGENCY_STOP, battery=30.0, connected=True -# ROBOT_DISCONNECTED -> mode=IDLE, battery=0.0, connected=False -# ROBOT_LOW_BATTERY -> mode=AUTONOMOUS, battery=15.0, connected=True diff --git a/src/robocoop_backend/robocoop_backend/tests/integration/test_context_lifecycle.py b/src/robocoop_backend/robocoop_backend/tests/integration/test_context_lifecycle.py new file mode 100644 index 0000000..4e13d76 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/integration/test_context_lifecycle.py @@ -0,0 +1,44 @@ +""" +Integration tests: BackendContext wires all services correctly. +""" +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from robocoop_backend.app.backend_context import BackendContext +from robocoop_backend.app.websocket_handler import WebSocketHandler + + +@pytest.mark.integration +class TestContextLifecycle: + async def test_mock_lifecycle_connect_disconnect(self): + ctx = BackendContext.initialize({"adapter_type": "mock"}) + result = await ctx.connect() + assert result is True + await ctx.disconnect() + + def test_services_are_wired_to_each_other(self): + ctx = BackendContext({"adapter_type": "mock"}) + assert ctx.telemetry_service.robot_state_store is ctx.robot_state_store + assert ctx.telemetry_service.audit_service is ctx.audit_service + + async def test_websocket_handler_wired_after_set(self): + ctx = BackendContext({"adapter_type": "mock"}) + mock_ws = AsyncMock() + mock_ws.send = AsyncMock() + handler = WebSocketHandler(ctx) + ctx.set_websocket_handler(handler) + assert ctx.telemetry_service.websocket_handler is handler + assert ctx.audit_service.websocket_handler is handler + + def test_telemetry_update_visible_in_state_store(self): + ctx = BackendContext({"adapter_type": "mock"}) + ctx.telemetry_service.on_telemetry_received({"battery_level": 55.0, "is_connected": True}) + assert ctx.robot_state_store.get().battery_level == 55.0 + + def test_audit_record_visible_in_history(self): + from robocoop_backend.modules.audit.audit_event import AuditEvent + ctx = BackendContext({"adapter_type": "mock"}) + ctx.audit_service.record(AuditEvent(action="test.event", actor="test")) + history = ctx.audit_service.get_history() + assert any(e["action"] == "test.event" for e in history) diff --git a/src/robocoop_backend/robocoop_backend/tests/integration/test_emergency_stop_flow.py b/src/robocoop_backend/robocoop_backend/tests/integration/test_emergency_stop_flow.py deleted file mode 100644 index 648c1f9..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/integration/test_emergency_stop_flow.py +++ /dev/null @@ -1,12 +0,0 @@ -# TODO: integration test — emergency stop full flow. - -# Scenario: -# 1. robot in AUTONOMOUS mode, mission RUNNING -# 2. emergency_stop triggered (from dashboard or watchdog) -# 3. assert: adapter.emergency_stop() called -# 4. assert: mode -> EMERGENCY_STOP -# 5. assert: mission -> FAILED (reason: EMERGENCY_STOP) -# 6. assert: audit event recorded -# 7. assert: dashboard receives mode_changed + mission_update - -# Use MockRobotAdapter + fake websocket client. diff --git a/src/robocoop_backend/robocoop_backend/tests/integration/test_mock_adapter_flow.py b/src/robocoop_backend/robocoop_backend/tests/integration/test_mock_adapter_flow.py deleted file mode 100644 index ab31915..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/integration/test_mock_adapter_flow.py +++ /dev/null @@ -1,10 +0,0 @@ -# TODO: integration test — full flow with MockRobotAdapter. - -# Scenario: -# 1. start server with mock adapter -# 2. connect dashboard client -# 3. send mode.change -> MANUAL -# 4. send teleop.move commands -# 5. assert: mock adapter received velocity commands -# 6. assert: robot_state_store updated after each telemetry tick -# 7. assert: battery drain simulation progresses diff --git a/src/robocoop_backend/robocoop_backend/tests/integration/test_sim_adapter_flow.py b/src/robocoop_backend/robocoop_backend/tests/integration/test_sim_adapter_flow.py deleted file mode 100644 index 9ae1c04..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/integration/test_sim_adapter_flow.py +++ /dev/null @@ -1,10 +0,0 @@ -# TODO: integration test — flow with SimRobotAdapter (Gazebo). - -# Note: requires running Gazebo instance — skip in CI by default. -# Mark with @pytest.mark.requires_sim - -# Scenario: -# 1. connect to simulated /cmd_vel, /odom topics -# 2. send teleop commands via websocket -# 3. assert: robot moves in simulation (odom changes) -# 4. assert: telemetry flows back to dashboard diff --git a/src/robocoop_backend/robocoop_backend/tests/integration/test_telemetry_pipeline.py b/src/robocoop_backend/robocoop_backend/tests/integration/test_telemetry_pipeline.py new file mode 100644 index 0000000..b67ad59 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/integration/test_telemetry_pipeline.py @@ -0,0 +1,74 @@ +""" +Integration tests: full pipeline without any network I/O. + +Wires real objects (RosbridgeRobotAdapter → TelemetryService → AuditService → RobotStateStore) +to verify data flows end-to-end. +""" +import pytest + +from robocoop_backend.adapters.rosbridge_adapter import RosbridgeRobotAdapter +from robocoop_backend.modules.audit.audit_logger import AuditLogger +from robocoop_backend.modules.audit.audit_service import AuditService +from robocoop_backend.modules.robot.state_store import RobotStateStore +from robocoop_backend.modules.robot.telemetry_service import TelemetryService + + +@pytest.fixture +def pipeline(): + store = RobotStateStore() + logger = AuditLogger(sinks=[]) + audit = AuditService(audit_logger=logger) + telemetry = TelemetryService(robot_state_store=store, audit_service=audit) + adapter = RosbridgeRobotAdapter( + url_primary="ws://localhost:9090", + telemetry_service=telemetry, + ) + return adapter, telemetry, audit, store + + +@pytest.mark.integration +class TestBatteryFlow: + def test_voltage_flows_to_state_store(self, pipeline): + adapter, _, _, store = pipeline + adapter._on_battery_received({"data": 10.8}) + assert store.get().battery_level == pytest.approx(50.0, abs=1.0) + + def test_battery_marks_robot_connected(self, pipeline): + adapter, _, _, store = pipeline + adapter._on_battery_received({"data": 11.0}) + assert store.get().is_connected is True + + def test_low_battery_creates_audit_event(self, pipeline): + adapter, _, audit, _ = pipeline + adapter._on_battery_received({"data": 5.0}) + actions = [e.action for e in audit._history] + assert "battery.low" in actions + + def test_connection_recovery_creates_robot_connected_event(self, pipeline): + adapter, telemetry, audit, _ = pipeline + telemetry.on_telemetry_received({"is_connected": False}) + adapter._on_battery_received({"data": 11.0}) + actions = [e.action for e in audit._history] + assert "robot.connected" in actions + + def test_disconnected_creates_robot_disconnected_event(self, pipeline): + adapter, telemetry, audit, store = pipeline + store.update({"is_connected": True}) + adapter._notify_disconnected() + actions = [e.action for e in audit._history] + assert "robot.disconnected" in actions + + def test_full_cycle_state_transitions(self, pipeline): + adapter, _, audit, store = pipeline + adapter._on_battery_received({"data": 11.0}) + assert store.get().is_connected is True + + adapter._notify_disconnected() + assert store.get().is_connected is False + + adapter._on_battery_received({"data": 11.0}) + assert store.get().is_connected is True + + connection_events = [e.action for e in audit._history if "robot." in e.action] + assert "robot.connected" in connection_events + assert "robot.disconnected" in connection_events diff --git a/src/robocoop_backend/robocoop_backend/tests/integration/test_websocket_teleop_flow.py b/src/robocoop_backend/robocoop_backend/tests/integration/test_websocket_teleop_flow.py deleted file mode 100644 index fb7e3df..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/integration/test_websocket_teleop_flow.py +++ /dev/null @@ -1,11 +0,0 @@ -# TODO: integration test — WebSocket teleop end-to-end. - -# Scenario: -# 1. start server (mock adapter) -# 2. authenticate websocket client -# 3. switch to MANUAL mode -# 4. send teleop.move at 20Hz for 1 second -# 5. assert: all commands received by adapter -# 6. assert: no ERR_RATE_LIMITED (within allowed rate) -# 7. disconnect client -# 8. assert: watchdog triggers emergency_stop after timeout diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/__init__.py b/src/robocoop_backend/robocoop_backend/tests/real/__init__.py similarity index 100% rename from src/robocoop_backend/robocoop_backend/infrastructure/ros/__init__.py rename to src/robocoop_backend/robocoop_backend/tests/real/__init__.py diff --git a/src/robocoop_backend/robocoop_backend/tests/real/test_rosbridge_live.py b/src/robocoop_backend/robocoop_backend/tests/real/test_rosbridge_live.py new file mode 100644 index 0000000..c4c14f6 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/real/test_rosbridge_live.py @@ -0,0 +1,64 @@ +""" +Real rosbridge integration tests. + +These tests require a live rosbridge server. Set the ROSBRIDGE_URL environment variable +to enable them, e.g.: + + ROSBRIDGE_URL=ws://localhost:9090 pytest -m real -v + +They are excluded from CI by `-m "not real"`. +""" +import asyncio +import os + +import pytest + +from robocoop_backend.adapters.rosbridge_client import RosbridgeClient + +pytestmark = pytest.mark.real + +ROSBRIDGE_URL = os.environ.get("ROSBRIDGE_URL") + + +@pytest.fixture(autouse=True) +def require_rosbridge_url(): + if not ROSBRIDGE_URL: + pytest.skip("ROSBRIDGE_URL not set — skipping real rosbridge tests") + + +async def test_rosbridge_client_connects_to_live_server(): + client = RosbridgeClient(url_primary=ROSBRIDGE_URL, connection_timeout=5.0) + result = await client.connect() + await client.disconnect() + assert result is True + + +async def test_rosbridge_client_is_connected_after_connect(): + client = RosbridgeClient(url_primary=ROSBRIDGE_URL, connection_timeout=5.0) + await client.connect() + assert client.is_connected() is True + await client.disconnect() + + +async def test_rosbridge_client_subscribe_and_receive_battery(): + client = RosbridgeClient(url_primary=ROSBRIDGE_URL, connection_timeout=5.0) + received = [] + + await client.connect() + await client.subscribe("/battery", "std_msgs/msg/Float32", lambda msg: received.append(msg)) + + try: + await asyncio.wait_for( + _wait_until(lambda: len(received) > 0), + timeout=5.0, + ) + except asyncio.TimeoutError: + pass + + await client.disconnect() + assert len(received) > 0, "No battery message received within 5 seconds" + + +async def _wait_until(condition, interval=0.1): + while not condition(): + await asyncio.sleep(interval) diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/__init__.py b/src/robocoop_backend/robocoop_backend/tests/unit/adapters/__init__.py similarity index 100% rename from src/robocoop_backend/robocoop_backend/infrastructure/schemas/__init__.py rename to src/robocoop_backend/robocoop_backend/tests/unit/adapters/__init__.py diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/adapters/test_factory.py b/src/robocoop_backend/robocoop_backend/tests/unit/adapters/test_factory.py new file mode 100644 index 0000000..455fc78 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/adapters/test_factory.py @@ -0,0 +1,65 @@ +import pytest + +from robocoop_backend.adapters.factory import create_adapter +from robocoop_backend.adapters.mock_adapter import MockRobotAdapter +from robocoop_backend.adapters.rosbridge_adapter import RosbridgeRobotAdapter + +ROSBRIDGE_CONFIG = { + "rosbridge": { + "url_primary": "ws://localhost:9090", + "url_secondary": "ws://localhost:9091", + "topics": {"battery": "/robot/battery"}, + } +} + + +@pytest.mark.unit +class TestCreateAdapter: + def test_mock_returns_mock_adapter(self): + adapter = create_adapter("mock", {}, None) + assert isinstance(adapter, MockRobotAdapter) + + def test_mock_case_insensitive(self): + for variant in ("MOCK", "Mock", "mOcK"): + assert isinstance(create_adapter(variant, {}, None), MockRobotAdapter) + + def test_rosbridge_returns_rosbridge_adapter(self): + adapter = create_adapter("rosbridge", ROSBRIDGE_CONFIG, None) + assert isinstance(adapter, RosbridgeRobotAdapter) + + def test_rosbridge_uses_config_url(self): + adapter = create_adapter("rosbridge", ROSBRIDGE_CONFIG, None) + assert adapter._client.url_primary == "ws://localhost:9090" + + def test_rosbridge_uses_secondary_url(self): + adapter = create_adapter("rosbridge", ROSBRIDGE_CONFIG, None) + assert adapter._client.url_secondary == "ws://localhost:9091" + + def test_rosbridge_uses_config_battery_topic(self): + adapter = create_adapter("rosbridge", ROSBRIDGE_CONFIG, None) + assert adapter.battery_topic == "/robot/battery" + + def test_rosbridge_applies_connection_timeout_default(self): + adapter = create_adapter("rosbridge", {"rosbridge": {}}, None) + assert adapter._client.connection_timeout == 5.0 + + def test_rosbridge_applies_watchdog_timeout_default(self): + adapter = create_adapter("rosbridge", {"rosbridge": {}}, None) + assert adapter.battery_watchdog_timeout == 15.0 + + def test_rosbridge_applies_battery_topic_default(self): + adapter = create_adapter("rosbridge", {"rosbridge": {}}, None) + assert adapter.battery_topic == "/battery" + + def test_rosbridge_passes_telemetry_service(self): + svc = object() + adapter = create_adapter("rosbridge", {"rosbridge": {}}, svc) + assert adapter.telemetry_service is svc + + def test_unknown_type_raises_value_error(self): + with pytest.raises(ValueError, match="unknown_type"): + create_adapter("unknown_type", {}, None) + + def test_empty_string_raises_value_error(self): + with pytest.raises(ValueError): + create_adapter("", {}, None) diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/adapters/test_mock_adapter.py b/src/robocoop_backend/robocoop_backend/tests/unit/adapters/test_mock_adapter.py new file mode 100644 index 0000000..d3913b2 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/adapters/test_mock_adapter.py @@ -0,0 +1,23 @@ +import pytest + +from robocoop_backend.adapters.base_adapter import RobotAdapter +from robocoop_backend.adapters.mock_adapter import MockRobotAdapter + + +@pytest.mark.unit +class TestMockRobotAdapter: + def test_is_always_connected(self): + assert MockRobotAdapter().is_connected() is True + + def test_is_robot_adapter_subclass(self): + assert isinstance(MockRobotAdapter(), RobotAdapter) + + def test_connected_flag_true_on_init(self): + adapter = MockRobotAdapter() + assert adapter._is_connected is True + + def test_multiple_instances_are_independent(self): + a = MockRobotAdapter() + b = MockRobotAdapter() + a._is_connected = False + assert b.is_connected() is True diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/adapters/test_rosbridge_adapter.py b/src/robocoop_backend/robocoop_backend/tests/unit/adapters/test_rosbridge_adapter.py new file mode 100644 index 0000000..441ee8d --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/adapters/test_rosbridge_adapter.py @@ -0,0 +1,169 @@ +import asyncio +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from robocoop_backend.adapters.rosbridge_adapter import RosbridgeRobotAdapter + + +def make_adapter(telemetry_service=None, battery_watchdog_timeout=15.0): + return RosbridgeRobotAdapter( + url_primary="ws://localhost:9090", + battery_watchdog_timeout=battery_watchdog_timeout, + telemetry_service=telemetry_service, + ) + + +@pytest.mark.unit +class TestVoltageConversion: + def test_9v_maps_to_0_percent(self): + svc = MagicMock() + adapter = make_adapter(telemetry_service=svc) + adapter._on_battery_received({"data": 9.0}) + call_data = svc.on_telemetry_received.call_args[0][0] + assert call_data["battery_level"] == pytest.approx(0.0, abs=0.1) + + def test_12_6v_maps_to_100_percent(self): + svc = MagicMock() + adapter = make_adapter(telemetry_service=svc) + adapter._on_battery_received({"data": 12.6}) + call_data = svc.on_telemetry_received.call_args[0][0] + assert call_data["battery_level"] == pytest.approx(100.0, abs=0.1) + + def test_10_8v_maps_to_50_percent(self): + svc = MagicMock() + adapter = make_adapter(telemetry_service=svc) + adapter._on_battery_received({"data": 10.8}) + call_data = svc.on_telemetry_received.call_args[0][0] + assert call_data["battery_level"] == pytest.approx(50.0, abs=0.5) + + def test_below_9v_clamped_to_0(self): + svc = MagicMock() + adapter = make_adapter(telemetry_service=svc) + adapter._on_battery_received({"data": 0.0}) + call_data = svc.on_telemetry_received.call_args[0][0] + assert call_data["battery_level"] == 0.0 + + def test_above_12_6v_clamped_to_100(self): + svc = MagicMock() + adapter = make_adapter(telemetry_service=svc) + adapter._on_battery_received({"data": 99.0}) + call_data = svc.on_telemetry_received.call_args[0][0] + assert call_data["battery_level"] == 100.0 + + def test_percentage_field_used_when_data_absent(self): + svc = MagicMock() + adapter = make_adapter(telemetry_service=svc) + adapter._on_battery_received({"percentage": 75.0}) + call_data = svc.on_telemetry_received.call_args[0][0] + assert call_data["battery_level"] == 75.0 + + def test_battery_message_sets_is_connected_true(self): + svc = MagicMock() + adapter = make_adapter(telemetry_service=svc) + adapter._on_battery_received({"data": 11.0}) + call_data = svc.on_telemetry_received.call_args[0][0] + assert call_data["is_connected"] is True + + def test_malformed_message_does_not_raise(self): + adapter = make_adapter() + adapter._on_battery_received({"data": "not-a-number"}) + + def test_empty_message_does_not_call_telemetry(self): + svc = MagicMock() + adapter = make_adapter(telemetry_service=svc) + adapter._on_battery_received({}) + svc.on_telemetry_received.assert_not_called() + + def test_battery_received_updates_last_battery_time(self): + adapter = make_adapter() + assert adapter._last_battery_time is None + adapter._on_battery_received({"data": 11.0}) + assert adapter._last_battery_time is not None + + +@pytest.mark.unit +class TestWatchdog: + def test_notify_disconnected_sends_is_connected_false(self): + svc = MagicMock() + adapter = make_adapter(telemetry_service=svc) + adapter._notify_disconnected() + svc.on_telemetry_received.assert_called_once_with({"is_connected": False}) + + def test_notify_disconnected_noop_when_no_service(self): + adapter = make_adapter() + adapter._notify_disconnected() + + def test_watchdog_triggers_disconnected_after_timeout(self): + svc = MagicMock() + adapter = make_adapter(telemetry_service=svc, battery_watchdog_timeout=15.0) + adapter._last_battery_time = datetime.now() - timedelta(seconds=20) + elapsed = (datetime.now() - adapter._last_battery_time).total_seconds() + if elapsed > adapter.battery_watchdog_timeout: + adapter._last_battery_time = None + adapter._notify_disconnected() + svc.on_telemetry_received.assert_called_once_with({"is_connected": False}) + + def test_watchdog_resets_last_battery_time_after_timeout(self): + adapter = make_adapter() + adapter._last_battery_time = datetime.now() - timedelta(seconds=20) + elapsed = (datetime.now() - adapter._last_battery_time).total_seconds() + if elapsed > adapter.battery_watchdog_timeout: + adapter._last_battery_time = None + adapter._notify_disconnected() + assert adapter._last_battery_time is None + + def test_watchdog_does_not_fire_before_timeout(self): + svc = MagicMock() + adapter = make_adapter(telemetry_service=svc, battery_watchdog_timeout=15.0) + adapter._last_battery_time = datetime.now() - timedelta(seconds=5) + elapsed = (datetime.now() - adapter._last_battery_time).total_seconds() + if elapsed > adapter.battery_watchdog_timeout: + adapter._notify_disconnected() + svc.on_telemetry_received.assert_not_called() + + def test_watchdog_does_not_fire_when_last_battery_time_is_none(self): + svc = MagicMock() + adapter = make_adapter(telemetry_service=svc) + assert adapter._last_battery_time is None + svc.on_telemetry_received.assert_not_called() + + async def test_connect_starts_watchdog_task(self, patch_ws_connect): + adapter = make_adapter() + await adapter.connect() + assert adapter._watchdog_task is not None + assert not adapter._watchdog_task.done() + adapter._watchdog_task.cancel() + try: + await adapter._watchdog_task + except asyncio.CancelledError: + pass + + async def test_disconnect_cancels_watchdog_task(self, patch_ws_connect): + adapter = make_adapter() + await adapter.connect() + task = adapter._watchdog_task + await adapter.disconnect() + assert task.cancelled() or task.done() + + +@pytest.mark.unit +class TestCallbacks: + def test_on_bridge_reconnected_resets_last_battery_time(self): + adapter = make_adapter() + adapter._last_battery_time = datetime.now() + with patch.object(adapter, "_subscribe_battery", new_callable=AsyncMock): + with patch("asyncio.create_task"): + adapter._on_bridge_reconnected() + assert adapter._last_battery_time is None + + def test_on_bridge_disconnected_notifies_telemetry(self): + svc = MagicMock() + adapter = make_adapter(telemetry_service=svc) + adapter._on_bridge_disconnected() + svc.on_telemetry_received.assert_called_once_with({"is_connected": False}) + + def test_is_connected_delegates_to_client(self): + adapter = make_adapter() + assert adapter.is_connected() == adapter._client.is_connected() diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/adapters/test_rosbridge_client.py b/src/robocoop_backend/robocoop_backend/tests/unit/adapters/test_rosbridge_client.py new file mode 100644 index 0000000..9073140 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/adapters/test_rosbridge_client.py @@ -0,0 +1,198 @@ +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +import websockets + +from robocoop_backend.adapters.rosbridge_client import RosbridgeClient + + +def make_client(**kwargs): + defaults = dict( + url_primary="ws://localhost:9090", + connection_timeout=1.0, + reconnect_interval=0.1, + max_reconnect_attempts=2, + ) + defaults.update(kwargs) + return RosbridgeClient(**defaults) + + +@pytest.mark.unit +class TestConnection: + def test_is_connected_false_before_connect(self): + assert make_client().is_connected() is False + + async def test_is_connected_true_after_successful_connect(self, patch_ws_connect): + client = make_client() + result = await client.connect() + assert result is True + assert client.is_connected() is True + + async def test_is_connected_false_after_disconnect(self, patch_ws_connect): + client = make_client() + await client.connect() + await client.disconnect() + assert client.is_connected() is False + + async def test_connect_returns_false_when_all_urls_fail(self): + with patch( + "robocoop_backend.adapters.rosbridge_client.websockets.connect", + side_effect=OSError("refused"), + ): + client = make_client() + result = await client.connect() + assert result is False + assert client.is_connected() is False + + async def test_secondary_url_tried_on_primary_failure(self): + mock_ws = AsyncMock() + mock_ws.send = AsyncMock() + call_count = 0 + + async def connect_side_effect(url, **kwargs): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise OSError("primary refused") + return mock_ws + + with patch( + "robocoop_backend.adapters.rosbridge_client.websockets.connect", + side_effect=connect_side_effect, + ): + client = make_client(url_secondary="ws://localhost:9091") + result = await client.connect() + assert result is True + assert call_count == 2 + + +@pytest.mark.unit +class TestSubscribe: + async def test_subscribe_sends_correct_json(self, patch_ws_connect): + client = make_client() + await client.connect() + cb = MagicMock() + await client.subscribe("/battery", "std_msgs/msg/Float32", cb) + sent = json.loads(patch_ws_connect.send.call_args[0][0]) + assert sent["op"] == "subscribe" + assert sent["topic"] == "/battery" + assert sent["type"] == "std_msgs/msg/Float32" + + async def test_subscribe_without_connect_logs_no_exception(self): + client = make_client() + await client.subscribe("/battery", "std_msgs/msg/Float32", MagicMock()) + + async def test_subscribe_stores_callback(self, patch_ws_connect): + client = make_client() + await client.connect() + cb = MagicMock() + await client.subscribe("/battery", "std_msgs/msg/Float32", cb) + assert client._subscribers["/battery"] is cb + + +@pytest.mark.unit +class TestMessageDispatch: + async def test_message_dispatched_to_subscriber(self): + msg_payload = {"data": 11.5} + raw_message = json.dumps({"topic": "/battery", "msg": msg_payload}) + received = [] + + async def fake_ws_context_manager(*args, **kwargs): + ws = AsyncMock() + ws.send = AsyncMock() + + async def aiter_messages(): + yield raw_message + + ws.__aiter__ = lambda self: aiter_messages() + return ws + + with patch( + "robocoop_backend.adapters.rosbridge_client.websockets.connect", + side_effect=fake_ws_context_manager, + ): + client = make_client() + await client.connect() + client._subscribers["/battery"] = lambda msg: received.append(msg) + await asyncio.sleep(0.05) + + assert len(received) == 1 + assert received[0] == msg_payload + + async def test_unknown_topic_message_ignored(self): + raw_message = json.dumps({"topic": "/other", "msg": {"data": 1}}) + called = [] + + async def fake_ws(*args, **kwargs): + ws = AsyncMock() + ws.send = AsyncMock() + + async def aiter_messages(): + yield raw_message + + ws.__aiter__ = lambda self: aiter_messages() + return ws + + with patch( + "robocoop_backend.adapters.rosbridge_client.websockets.connect", + side_effect=fake_ws, + ): + client = make_client() + await client.connect() + client._subscribers["/battery"] = lambda msg: called.append(msg) + await asyncio.sleep(0.05) + + assert called == [] + + +@pytest.mark.unit +class TestReconnect: + async def test_reconnect_calls_on_disconnected_after_max_attempts(self): + on_disconnected = MagicMock() + call_count = 0 + + async def fail_connect(*args, **kwargs): + nonlocal call_count + call_count += 1 + raise OSError("refused") + + with patch( + "robocoop_backend.adapters.rosbridge_client.websockets.connect", + side_effect=fail_connect, + ): + with patch("asyncio.sleep", new_callable=AsyncMock): + client = make_client( + max_reconnect_attempts=2, + on_disconnected=on_disconnected, + ) + await client._attempt_reconnect() + + on_disconnected.assert_called_once() + + async def test_reconnect_calls_on_reconnected_on_success(self): + on_reconnected = MagicMock() + attempt = 0 + + async def connect_eventually(*args, **kwargs): + nonlocal attempt + attempt += 1 + if attempt < 2: + raise OSError("refused") + ws = AsyncMock() + ws.send = AsyncMock() + return ws + + with patch( + "robocoop_backend.adapters.rosbridge_client.websockets.connect", + side_effect=connect_eventually, + ): + with patch("asyncio.sleep", new_callable=AsyncMock): + client = make_client( + max_reconnect_attempts=3, + on_reconnected=on_reconnected, + ) + await client._attempt_reconnect() + + on_reconnected.assert_called_once() diff --git a/src/robocoop_backend/robocoop_backend/modules/audit/domain/__init__.py b/src/robocoop_backend/robocoop_backend/tests/unit/app/__init__.py similarity index 100% rename from src/robocoop_backend/robocoop_backend/modules/audit/domain/__init__.py rename to src/robocoop_backend/robocoop_backend/tests/unit/app/__init__.py diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/app/test_backend_context.py b/src/robocoop_backend/robocoop_backend/tests/unit/app/test_backend_context.py new file mode 100644 index 0000000..d5035fd --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/app/test_backend_context.py @@ -0,0 +1,76 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from robocoop_backend.adapters.mock_adapter import MockRobotAdapter +from robocoop_backend.app.backend_context import BackendContext + + +@pytest.mark.unit +class TestBackendContextSingleton: + def test_initialize_creates_instance(self): + ctx = BackendContext.initialize({"adapter_type": "mock"}) + assert ctx is not None + + def test_get_instance_raises_before_initialize(self): + with pytest.raises(RuntimeError): + BackendContext.get_instance() + + def test_initialize_idempotent_returns_same_instance(self): + ctx1 = BackendContext.initialize({"adapter_type": "mock"}) + ctx2 = BackendContext.initialize({"adapter_type": "mock"}) + assert ctx1 is ctx2 + + def test_reset_clears_singleton(self): + BackendContext.initialize({"adapter_type": "mock"}) + BackendContext.reset() + with pytest.raises(RuntimeError): + BackendContext.get_instance() + + def test_get_instance_returns_initialized_instance(self): + ctx = BackendContext.initialize({"adapter_type": "mock"}) + assert BackendContext.get_instance() is ctx + + +@pytest.mark.unit +class TestBackendContextServices: + def test_uses_mock_adapter_for_mock_type(self): + ctx = BackendContext({"adapter_type": "mock"}) + assert isinstance(ctx.adapter, MockRobotAdapter) + + def test_has_robot_state_store(self): + ctx = BackendContext({"adapter_type": "mock"}) + assert ctx.robot_state_store is not None + + def test_has_audit_service(self): + ctx = BackendContext({"adapter_type": "mock"}) + assert ctx.audit_service is not None + + def test_has_telemetry_service(self): + ctx = BackendContext({"adapter_type": "mock"}) + assert ctx.telemetry_service is not None + + def test_set_websocket_handler_propagates_to_telemetry(self): + ctx = BackendContext({"adapter_type": "mock"}) + handler = MagicMock() + ctx.set_websocket_handler(handler) + assert ctx.telemetry_service.websocket_handler is handler + + def test_set_websocket_handler_propagates_to_audit(self): + ctx = BackendContext({"adapter_type": "mock"}) + handler = MagicMock() + ctx.set_websocket_handler(handler) + assert ctx.audit_service.websocket_handler is handler + + +@pytest.mark.unit +class TestBackendContextLifecycle: + async def test_connect_returns_true_for_mock_adapter(self): + ctx = BackendContext({"adapter_type": "mock"}) + result = await ctx.connect() + assert result is True + + async def test_disconnect_does_not_raise(self): + ctx = BackendContext({"adapter_type": "mock"}) + await ctx.connect() + await ctx.disconnect() diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/app/test_websocket_handler.py b/src/robocoop_backend/robocoop_backend/tests/unit/app/test_websocket_handler.py new file mode 100644 index 0000000..275d91d --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/app/test_websocket_handler.py @@ -0,0 +1,142 @@ +import json +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from robocoop_backend.app.contracts import ( + MSG_ACTIVITY_HISTORY, + MSG_EMERGENCY_STOP, + MSG_GET_ACTIVITY, + MSG_GET_STATE, + MSG_INITIAL_STATE, + MSG_PING, + MSG_PONG, + MSG_STATE_RESPONSE, + MSG_TELEOP_MOVE, +) +from robocoop_backend.app.websocket_handler import WebSocketHandler +from robocoop_backend.modules.audit.audit_event import AuditEvent + + +def make_context(state_store=None, audit_service=None, adapter=None): + ctx = MagicMock() + ctx.robot_state_store = state_store or MagicMock() + ctx.robot_state_store.to_dict.return_value = {"is_connected": False, "battery_level": 0.0} + ctx.audit_service = audit_service or MagicMock() + ctx.audit_service.get_history.return_value = [] + ctx.adapter = adapter or MagicMock() + return ctx + + +@pytest.mark.unit +class TestRegister: + async def test_register_adds_client(self, mock_websocket): + handler = WebSocketHandler(make_context()) + await handler.register(mock_websocket) + assert mock_websocket in handler.clients + + async def test_register_sends_initial_state(self, mock_websocket): + handler = WebSocketHandler(make_context()) + await handler.register(mock_websocket) + sent_types = [json.loads(c[0][0])["type"] for c in mock_websocket.send.call_args_list] + assert MSG_INITIAL_STATE in sent_types + + async def test_register_sends_activity_history(self, mock_websocket): + handler = WebSocketHandler(make_context()) + await handler.register(mock_websocket) + sent_types = [json.loads(c[0][0])["type"] for c in mock_websocket.send.call_args_list] + assert MSG_ACTIVITY_HISTORY in sent_types + + async def test_register_sends_exactly_two_messages(self, mock_websocket): + handler = WebSocketHandler(make_context()) + await handler.register(mock_websocket) + assert mock_websocket.send.call_count == 2 + + async def test_unregister_removes_client(self, mock_websocket): + handler = WebSocketHandler(make_context()) + await handler.register(mock_websocket) + await handler.unregister(mock_websocket) + assert mock_websocket not in handler.clients + + async def test_unregister_idempotent(self, mock_websocket): + handler = WebSocketHandler(make_context()) + await handler.unregister(mock_websocket) + + +@pytest.mark.unit +class TestHandleMessage: + async def test_ping_returns_pong(self, mock_websocket): + handler = WebSocketHandler(make_context()) + await handler.handle_message(mock_websocket, {"type": MSG_PING}) + sent = json.loads(mock_websocket.send.call_args[0][0]) + assert sent["type"] == MSG_PONG + + async def test_get_state_returns_state_response(self, mock_websocket): + handler = WebSocketHandler(make_context()) + await handler.handle_message(mock_websocket, {"type": MSG_GET_STATE}) + sent = json.loads(mock_websocket.send.call_args[0][0]) + assert sent["type"] == MSG_STATE_RESPONSE + + async def test_get_activity_returns_history(self, mock_websocket): + handler = WebSocketHandler(make_context()) + await handler.handle_message(mock_websocket, {"type": MSG_GET_ACTIVITY}) + sent = json.loads(mock_websocket.send.call_args[0][0]) + assert sent["type"] == MSG_ACTIVITY_HISTORY + + async def test_get_activity_uses_provided_limit(self, mock_websocket): + ctx = make_context() + handler = WebSocketHandler(ctx) + await handler.handle_message(mock_websocket, {"type": MSG_GET_ACTIVITY, "limit": 10}) + ctx.audit_service.get_history.assert_called_once_with(limit=10) + + async def test_emergency_stop_records_audit_event(self, mock_websocket): + ctx = make_context() + handler = WebSocketHandler(ctx) + await handler.handle_message(mock_websocket, {"type": MSG_EMERGENCY_STOP}) + ctx.audit_service.record.assert_called_once() + event = ctx.audit_service.record.call_args[0][0] + assert isinstance(event, AuditEvent) + assert event.action == MSG_EMERGENCY_STOP + + async def test_teleop_move_calls_send_velocity(self, mock_websocket): + ctx = make_context() + handler = WebSocketHandler(ctx) + data = {"linear": 1.0, "angular": 0.0} + await handler.handle_message(mock_websocket, {"type": MSG_TELEOP_MOVE, "data": data}) + ctx.adapter.send_velocity.assert_called_once_with(data) + + async def test_unknown_type_silently_ignored(self, mock_websocket): + handler = WebSocketHandler(make_context()) + await handler.handle_message(mock_websocket, {"type": "does_not_exist"}) + mock_websocket.send.assert_not_called() + + async def test_none_type_silently_ignored(self, mock_websocket): + handler = WebSocketHandler(make_context()) + await handler.handle_message(mock_websocket, {}) + mock_websocket.send.assert_not_called() + + +@pytest.mark.unit +class TestBroadcast: + async def test_broadcast_sends_to_all_clients(self): + ws1 = AsyncMock() + ws2 = AsyncMock() + handler = WebSocketHandler(make_context()) + handler.clients = {ws1, ws2} + await handler.broadcast({"type": "robot_state_updated", "data": {}}) + ws1.send.assert_called_once() + ws2.send.assert_called_once() + + async def test_broadcast_removes_disconnected_client(self): + good = AsyncMock() + bad = AsyncMock() + bad.send.side_effect = Exception("connection closed") + handler = WebSocketHandler(make_context()) + handler.clients = {good, bad} + await handler.broadcast({"type": "x"}) + assert bad not in handler.clients + assert good in handler.clients + + async def test_broadcast_noop_when_no_clients(self): + handler = WebSocketHandler(make_context()) + await handler.broadcast({"type": "x"}) diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/__init__.py b/src/robocoop_backend/robocoop_backend/tests/unit/modules/__init__.py similarity index 100% rename from src/robocoop_backend/robocoop_backend/modules/mission/__init__.py rename to src/robocoop_backend/robocoop_backend/tests/unit/modules/__init__.py diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_audit_event.py b/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_audit_event.py new file mode 100644 index 0000000..2484e43 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_audit_event.py @@ -0,0 +1,38 @@ +import uuid +from datetime import datetime + +import pytest + +from robocoop_backend.modules.audit.audit_event import AuditEvent + + +@pytest.mark.unit +class TestAuditEvent: + def test_auto_generates_uuid(self): + a = AuditEvent(action="x", actor="y") + b = AuditEvent(action="x", actor="y") + assert a.id != b.id + + def test_id_is_valid_uuid(self): + event = AuditEvent(action="x", actor="y") + uuid.UUID(event.id) + + def test_auto_generates_timestamp(self): + before = datetime.now() + event = AuditEvent(action="x", actor="y") + after = datetime.now() + assert before <= event.timestamp <= after + + def test_default_payload_is_empty_dict(self): + event = AuditEvent(action="x", actor="y") + assert event.payload == {} + + def test_custom_payload_stored(self): + payload = {"key": "value", "level": 15.0} + event = AuditEvent(action="x", actor="y", payload=payload) + assert event.payload == payload + + def test_action_and_actor_stored(self): + event = AuditEvent(action="battery.low", actor="system") + assert event.action == "battery.low" + assert event.actor == "system" diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_audit_logger.py b/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_audit_logger.py new file mode 100644 index 0000000..aea42e4 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_audit_logger.py @@ -0,0 +1,43 @@ +from unittest.mock import MagicMock + +import pytest + +from robocoop_backend.modules.audit.audit_event import AuditEvent +from robocoop_backend.modules.audit.audit_logger import AuditLogger +from robocoop_backend.modules.audit.sinks import AuditSink + + +@pytest.mark.unit +class TestAuditLogger: + def test_dispatches_to_all_sinks(self): + sink1 = MagicMock(spec=AuditSink) + sink2 = MagicMock(spec=AuditSink) + logger = AuditLogger(sinks=[sink1, sink2]) + event = AuditEvent(action="test", actor="system") + logger.log(event) + sink1.write.assert_called_once() + sink2.write.assert_called_once() + + def test_failing_sink_does_not_block_other_sinks(self): + failing = MagicMock(spec=AuditSink) + failing.write.side_effect = RuntimeError("boom") + good = MagicMock(spec=AuditSink) + logger = AuditLogger(sinks=[failing, good]) + logger.log(AuditEvent(action="test", actor="system")) + good.write.assert_called_once() + + def test_no_sinks_does_not_raise(self): + AuditLogger(sinks=[]).log(AuditEvent(action="test", actor="system")) + + def test_sink_receives_formatted_dict(self): + sink = MagicMock(spec=AuditSink) + logger = AuditLogger(sinks=[sink]) + event = AuditEvent(action="robot.connected", actor="system") + logger.log(event) + passed = sink.write.call_args[0][0] + assert isinstance(passed, dict) + assert passed["action"] == "robot.connected" + + def test_default_sinks_is_empty_list(self): + logger = AuditLogger() + logger.log(AuditEvent(action="x", actor="y")) diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_audit_service.py b/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_audit_service.py new file mode 100644 index 0000000..049af96 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_audit_service.py @@ -0,0 +1,70 @@ +from unittest.mock import MagicMock + +import pytest + +from robocoop_backend.modules.audit.audit_event import AuditEvent +from robocoop_backend.modules.audit.audit_logger import AuditLogger +from robocoop_backend.modules.audit.audit_service import AuditService + + +def make_event(action="test", actor="system"): + return AuditEvent(action=action, actor=actor) + + +@pytest.mark.unit +class TestAuditServiceRecord: + def test_record_stores_event_in_history(self, audit_service): + audit_service.record(make_event("robot.connected")) + actions = [e["action"] for e in audit_service.get_history()] + assert "robot.connected" in actions + + def test_record_calls_audit_logger(self, null_audit_logger, audit_service): + null_audit_logger.log = MagicMock() + audit_service._logger = null_audit_logger + event = make_event() + audit_service.record(event) + null_audit_logger.log.assert_called_once_with(event) + + def test_record_failure_is_swallowed(self, audit_service): + audit_service._history = MagicMock() + audit_service._history.append.side_effect = RuntimeError("boom") + audit_service.record(make_event()) + + +@pytest.mark.unit +class TestAuditServiceGetHistory: + def test_returns_newest_first(self, audit_service): + audit_service.record(make_event("event_a")) + audit_service.record(make_event("event_b")) + history = audit_service.get_history() + assert history[0]["action"] == "event_b" + assert history[1]["action"] == "event_a" + + def test_respects_limit(self, audit_service): + for i in range(10): + audit_service.record(make_event(f"event_{i}")) + assert len(audit_service.get_history(limit=5)) == 5 + + def test_default_limit_is_50(self, audit_service): + for _ in range(60): + audit_service.record(make_event()) + assert len(audit_service.get_history()) == 50 + + def test_maxlen_100_enforced(self, null_audit_logger): + svc = AuditService(audit_logger=null_audit_logger, max_history=100) + for _ in range(110): + svc.record(make_event()) + assert len(svc.get_history(limit=200)) == 100 + + def test_empty_history_returns_empty_list(self, audit_service): + assert audit_service.get_history() == [] + + def test_history_entries_are_dicts(self, audit_service): + audit_service.record(make_event()) + history = audit_service.get_history() + assert isinstance(history[0], dict) + + def test_limit_larger_than_history_returns_all(self, audit_service): + for i in range(3): + audit_service.record(make_event(f"e{i}")) + assert len(audit_service.get_history(limit=100)) == 3 diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_event_formatter.py b/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_event_formatter.py new file mode 100644 index 0000000..c035e8b --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_event_formatter.py @@ -0,0 +1,48 @@ +from datetime import datetime + +import pytest + +from robocoop_backend.modules.audit.audit_event import AuditEvent +from robocoop_backend.modules.audit.event_formatter import EventFormatter + + +@pytest.mark.unit +class TestEventFormatter: + def test_includes_required_fields(self): + event = AuditEvent(action="robot.connected", actor="system") + result = EventFormatter.format(event) + for key in ("id", "action", "actor", "timestamp"): + assert key in result + + def test_action_matches_event(self): + event = AuditEvent(action="battery.low", actor="system") + assert EventFormatter.format(event)["action"] == "battery.low" + + def test_actor_matches_event(self): + event = AuditEvent(action="x", actor="dashboard") + assert EventFormatter.format(event)["actor"] == "dashboard" + + def test_id_matches_event(self): + event = AuditEvent(action="x", actor="y") + assert EventFormatter.format(event)["id"] == event.id + + def test_timestamp_is_iso_string(self): + event = AuditEvent(action="x", actor="y") + ts = EventFormatter.format(event)["timestamp"] + assert isinstance(ts, str) + datetime.fromisoformat(ts) + + def test_payload_fields_flattened(self): + event = AuditEvent( + action="battery.low", + actor="system", + payload={"battery_level": 15.0, "threshold": 20.0}, + ) + result = EventFormatter.format(event) + assert result["battery_level"] == 15.0 + assert result["threshold"] == 20.0 + + def test_empty_payload_produces_no_extra_keys(self): + event = AuditEvent(action="x", actor="y", payload={}) + result = EventFormatter.format(event) + assert set(result.keys()) == {"id", "action", "actor", "timestamp"} diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_sinks.py b/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_sinks.py new file mode 100644 index 0000000..1e39e67 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_sinks.py @@ -0,0 +1,61 @@ +import json +import logging +from unittest.mock import patch + +import pytest + +from robocoop_backend.modules.audit.sinks import ConsoleSink, FileSink + + +@pytest.mark.unit +class TestConsoleSink: + def test_write_calls_logger_info(self): + sink = ConsoleSink() + with patch.object(sink._log, "info") as mock_info: + sink.write({"action": "test"}) + mock_info.assert_called_once() + + def test_write_includes_json_in_output(self): + sink = ConsoleSink() + captured = [] + with patch.object(sink._log, "info", side_effect=lambda fmt, *args: captured.append(args)): + sink.write({"action": "battery.low"}) + output = captured[0][0] + data = json.loads(output) + assert data["action"] == "battery.low" + + +@pytest.mark.unit +class TestFileSink: + def test_write_creates_file(self, tmp_path): + path = tmp_path / "audit.jsonl" + FileSink(str(path)).write({"action": "test"}) + assert path.exists() + + def test_write_appends_json_line(self, tmp_path): + path = tmp_path / "audit.jsonl" + FileSink(str(path)).write({"action": "test", "actor": "system"}) + line = path.read_text().strip() + data = json.loads(line) + assert data["action"] == "test" + + def test_write_appends_multiple_lines(self, tmp_path): + path = tmp_path / "audit.jsonl" + sink = FileSink(str(path)) + sink.write({"action": "a"}) + sink.write({"action": "b"}) + lines = path.read_text().strip().splitlines() + assert len(lines) == 2 + + def test_each_line_is_valid_json(self, tmp_path): + path = tmp_path / "audit.jsonl" + sink = FileSink(str(path)) + for i in range(3): + sink.write({"action": f"event_{i}"}) + for line in path.read_text().strip().splitlines(): + json.loads(line) + + def test_creates_parent_directory(self, tmp_path): + nested = tmp_path / "sub" / "deep" / "audit.jsonl" + FileSink(str(nested)).write({"action": "test"}) + assert nested.exists() diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_state_store.py b/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_state_store.py new file mode 100644 index 0000000..0f315c8 --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_state_store.py @@ -0,0 +1,75 @@ +import json +from datetime import datetime + +import pytest + +from robocoop_backend.modules.robot.state_store import RobotState, RobotStateStore + + +@pytest.mark.unit +class TestRobotState: + def test_default_is_connected_is_false(self): + assert RobotState().is_connected is False + + def test_default_battery_level_is_zero(self): + assert RobotState().battery_level == 0.0 + + def test_to_dict_contains_required_keys(self): + d = RobotState().to_dict() + assert "is_connected" in d + assert "battery_level" in d + assert "last_updated" in d + + def test_to_dict_last_updated_is_iso_string(self): + d = RobotState().to_dict() + datetime.fromisoformat(d["last_updated"]) + + +@pytest.mark.unit +class TestRobotStateStore: + def test_initial_state_defaults(self, state_store): + state = state_store.get() + assert state.is_connected is False + assert state.battery_level == 0.0 + + def test_update_sets_battery_level(self, state_store): + state_store.update({"battery_level": 75.0}) + assert state_store.get().battery_level == 75.0 + + def test_update_sets_is_connected(self, state_store): + state_store.update({"is_connected": True}) + assert state_store.get().is_connected is True + + def test_update_multiple_fields(self, state_store): + state_store.update({"battery_level": 80.0, "is_connected": True}) + assert state_store.get().battery_level == 80.0 + assert state_store.get().is_connected is True + + def test_update_unknown_field_does_not_raise(self, state_store): + state_store.update({"nonexistent_field": 42}) + + def test_update_unknown_field_does_not_change_known_fields(self, state_store): + state_store.update({"battery_level": 50.0}) + state_store.update({"nonexistent_field": 42}) + assert state_store.get().battery_level == 50.0 + + def test_update_refreshes_last_updated(self, state_store): + before = state_store.get().last_updated + state_store.update({"battery_level": 10.0}) + after = state_store.get().last_updated + assert after >= before + + def test_to_dict_is_json_serializable(self, state_store): + json.dumps(state_store.to_dict()) + + def test_to_dict_reflects_current_state(self, state_store): + state_store.update({"battery_level": 42.0, "is_connected": True}) + d = state_store.to_dict() + assert d["battery_level"] == 42.0 + assert d["is_connected"] is True + + def test_reset_returns_to_defaults(self, state_store): + state_store.update({"battery_level": 99.0, "is_connected": True}) + state_store.reset() + assert state_store.get().is_connected is False + assert state_store.get().battery_level == 0.0 diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_telemetry_service.py b/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_telemetry_service.py new file mode 100644 index 0000000..fe799ad --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/modules/test_telemetry_service.py @@ -0,0 +1,92 @@ +from unittest.mock import MagicMock, patch + +import pytest + +from robocoop_backend.modules.audit.audit_event import AuditEvent +from robocoop_backend.modules.robot.telemetry_service import TelemetryService + + +@pytest.mark.unit +class TestTelemetryOnReceived: + def test_updates_state_store_battery_level(self, telemetry_service, state_store): + telemetry_service.on_telemetry_received({"battery_level": 60.0, "is_connected": True}) + assert state_store.get().battery_level == 60.0 + + def test_updates_state_store_is_connected(self, telemetry_service, state_store): + telemetry_service.on_telemetry_received({"is_connected": True}) + assert state_store.get().is_connected is True + + def test_no_state_store_does_not_raise(self, audit_service): + svc = TelemetryService(robot_state_store=None, audit_service=audit_service) + svc.on_telemetry_received({"battery_level": 50.0}) + + def test_exception_is_swallowed(self, telemetry_service): + telemetry_service.robot_state_store = None + telemetry_service.robot_state_store = MagicMock(side_effect=RuntimeError("boom")) + telemetry_service.on_telemetry_received({"battery_level": 50.0}) + + +@pytest.mark.unit +class TestConnectionEvents: + def test_false_to_true_emits_robot_connected(self, telemetry_service, audit_service): + telemetry_service.on_telemetry_received({"is_connected": True}) + actions = [e.action for e in audit_service._history] + assert "robot.connected" in actions + + def test_true_to_false_emits_robot_disconnected(self, telemetry_service, state_store, audit_service): + state_store.update({"is_connected": True}) + telemetry_service.on_telemetry_received({"is_connected": False}) + actions = [e.action for e in audit_service._history] + assert "robot.disconnected" in actions + + def test_no_event_when_state_unchanged(self, telemetry_service, state_store, audit_service): + state_store.update({"is_connected": True}) + telemetry_service.on_telemetry_received({"is_connected": True}) + connection_events = [e for e in audit_service._history if e.action in ("robot.connected", "robot.disconnected")] + assert len(connection_events) == 0 + + def test_no_connection_key_emits_no_connection_event(self, telemetry_service, audit_service): + telemetry_service.on_telemetry_received({"battery_level": 50.0}) + connection_events = [e for e in audit_service._history if e.action in ("robot.connected", "robot.disconnected")] + assert len(connection_events) == 0 + + def test_no_audit_service_connection_change_does_not_raise(self, state_store): + svc = TelemetryService(robot_state_store=state_store, audit_service=None) + svc.on_telemetry_received({"is_connected": True}) + + +@pytest.mark.unit +class TestBatteryThreshold: + def test_battery_low_event_fires_below_threshold(self, telemetry_service, audit_service): + telemetry_service.on_telemetry_received({"battery_level": 15.0}) + actions = [e.action for e in audit_service._history] + assert "battery.low" in actions + + def test_battery_low_event_fires_only_once(self, telemetry_service, audit_service): + telemetry_service.on_telemetry_received({"battery_level": 15.0}) + telemetry_service.on_telemetry_received({"battery_level": 10.0}) + low_events = [e for e in audit_service._history if e.action == "battery.low"] + assert len(low_events) == 1 + + def test_battery_low_payload_contains_level_and_threshold(self, telemetry_service, audit_service): + telemetry_service.on_telemetry_received({"battery_level": 15.0}) + event = next(e for e in audit_service._history if e.action == "battery.low") + assert event.payload["battery_level"] == 15.0 + assert event.payload["threshold"] == 20.0 + + def test_battery_low_resets_after_recovery(self, telemetry_service, audit_service): + telemetry_service.on_telemetry_received({"battery_level": 15.0}) + telemetry_service.on_telemetry_received({"battery_level": 25.0}) + telemetry_service.on_telemetry_received({"battery_level": 10.0}) + low_events = [e for e in audit_service._history if e.action == "battery.low"] + assert len(low_events) == 2 + + def test_battery_at_exact_threshold_does_not_trigger(self, telemetry_service, audit_service): + telemetry_service.on_telemetry_received({"battery_level": 20.0}) + actions = [e.action for e in audit_service._history] + assert "battery.low" not in actions + + def test_battery_above_threshold_does_not_trigger(self, telemetry_service, audit_service): + telemetry_service.on_telemetry_received({"battery_level": 80.0}) + actions = [e.action for e in audit_service._history] + assert "battery.low" not in actions diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_message_router.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_message_router.py deleted file mode 100644 index 1c6796f..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/unit/test_message_router.py +++ /dev/null @@ -1,7 +0,0 @@ -# TODO: unit tests for MessageRouter. - -# Cases to cover: -# - known type routes to correct service method -# - unknown type returns ERR_INVALID_MESSAGE -# - missing "type" field returns ERR_INVALID_MESSAGE -# - each route handler called with correct parsed payload diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_mission_state_machine.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_mission_state_machine.py deleted file mode 100644 index ef989f2..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/unit/test_mission_state_machine.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: unit tests for MissionStateMachine. - -# Cases to cover: -# - IDLE -> RUNNING on valid start -# - RUNNING -> COMPLETED on success -# - RUNNING -> FAILED with correct reason (obstacle, battery_low, timeout) -# - RUNNING -> BLOCKED and resume -# - reject invalid transition (e.g. IDLE -> COMPLETED) -# - EMERGENCY_STOP cancels active mission diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_mode_manager.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_mode_manager.py deleted file mode 100644 index 6331814..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/unit/test_mode_manager.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: unit tests for ModeManager. - -# Cases to cover: -# - IDLE -> MANUAL allowed -# - MANUAL -> AUTONOMOUS allowed -# - AUTONOMOUS -> MANUAL allowed -# - ANY -> EMERGENCY_STOP always allowed -# - EMERGENCY_STOP -> IDLE not allowed without explicit reset -# - concurrent transition requests (thread safety) diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_robot_state_store.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_robot_state_store.py deleted file mode 100644 index f451aec..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/unit/test_robot_state_store.py +++ /dev/null @@ -1,7 +0,0 @@ -# TODO: unit tests for RobotStateStore. - -# Cases to cover: -# - get() returns default state on init -# - update() partial fields only -# - concurrent read/write does not corrupt state -# - reset() returns to default diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_schema_validation.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_schema_validation.py deleted file mode 100644 index d934ff3..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/unit/test_schema_validation.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: unit tests for schemas/validation.py. - -# Cases to cover: -# - valid teleop.move message passes -# - valid mission.start message passes -# - linear_x out of [-1.0, 1.0] fails -# - missing required field fails -# - extra unknown fields -> accepted or rejected (define policy) diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_teleop_service.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_teleop_service.py deleted file mode 100644 index 7afec69..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/unit/test_teleop_service.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: unit tests for TeleopService. - -# Cases to cover: -# - valid move command in MANUAL mode -> forwarded to adapter -# - move command rejected if mode != MANUAL -# - move command rejected if EMERGENCY_STOP -# - speed_factor out of range -> rejected -# - adapter call verified (mock adapter) diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_watchdog_service.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_watchdog_service.py deleted file mode 100644 index c81dbb5..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/unit/test_watchdog_service.py +++ /dev/null @@ -1,7 +0,0 @@ -# TODO: unit tests for WatchdogService. - -# Cases to cover: -# - no timeout if heartbeat received within window -# - triggers emergency_stop after timeout -# - resumes monitoring after reconnect -# - timeout threshold is read from config diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/domain/__init__.py b/src/robocoop_backend/robocoop_backend/tests/unit/utils/__init__.py similarity index 100% rename from src/robocoop_backend/robocoop_backend/modules/mission/domain/__init__.py rename to src/robocoop_backend/robocoop_backend/tests/unit/utils/__init__.py diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/utils/test_config.py b/src/robocoop_backend/robocoop_backend/tests/unit/utils/test_config.py new file mode 100644 index 0000000..4d4115c --- /dev/null +++ b/src/robocoop_backend/robocoop_backend/tests/unit/utils/test_config.py @@ -0,0 +1,130 @@ +import os + +import pytest +import yaml + +from robocoop_backend.utils.config import Config + + +def write_yaml(path, data): + path.write_text(yaml.dump(data)) + + +@pytest.mark.unit +class TestConfigLoad: + def test_load_from_common_yaml_only(self, tmp_path): + write_yaml(tmp_path / "common.params.yaml", {"adapter_type": "mock"}) + cfg = Config.load(str(tmp_path), env="mock") + assert cfg.get("adapter_type") == "mock" + + def test_env_yaml_overrides_common_scalar(self, tmp_path): + write_yaml(tmp_path / "common.params.yaml", {"websocket": {"port": 8765}}) + write_yaml(tmp_path / "test.params.yaml", {"websocket": {"port": 9000}}) + cfg = Config.load(str(tmp_path), env="test") + assert cfg.get_int("websocket.port") == 9000 + + def test_deep_merge_preserves_sibling_keys(self, tmp_path): + write_yaml( + tmp_path / "common.params.yaml", + {"rosbridge": {"url_primary": "ws://a:9090", "url_secondary": "ws://b:9090"}}, + ) + write_yaml( + tmp_path / "test.params.yaml", + {"rosbridge": {"url_primary": "ws://c:9090"}}, + ) + cfg = Config.load(str(tmp_path), env="test") + assert cfg.get("rosbridge.url_primary") == "ws://c:9090" + assert cfg.get("rosbridge.url_secondary") == "ws://b:9090" + + def test_missing_env_yaml_falls_back_to_common(self, tmp_path): + write_yaml(tmp_path / "common.params.yaml", {"adapter_type": "mock"}) + cfg = Config.load(str(tmp_path), env="nonexistent") + assert cfg.get("adapter_type") == "mock" + + def test_empty_config_dir_returns_empty_config(self, tmp_path): + cfg = Config.load(str(tmp_path), env="mock") + assert cfg.to_dict() == {} + + +@pytest.mark.unit +class TestConfigGetters: + def test_get_returns_value(self): + cfg = Config({"key": "value"}) + assert cfg.get("key") == "value" + + def test_get_returns_default_for_missing_key(self): + assert Config({}).get("missing", "fallback") == "fallback" + + def test_get_returns_none_for_missing_without_default(self): + assert Config({}).get("missing") is None + + def test_get_nested_dot_notation(self): + cfg = Config({"rosbridge": {"url_primary": "ws://x"}}) + assert cfg.get("rosbridge.url_primary") == "ws://x" + + def test_get_str_converts_int(self): + assert Config({"port": 8765}).get_str("port") == "8765" + + def test_get_int_converts_string(self): + assert Config({"port": "8765"}).get_int("port") == 8765 + + def test_get_int_invalid_string_returns_default(self): + assert Config({"port": "abc"}).get_int("port", default=0) == 0 + + def test_get_float_converts_string(self): + assert Config({"v": "20.5"}).get_float("v") == pytest.approx(20.5) + + def test_get_bool_true_string_variants(self): + for val in ("true", "yes", "1"): + assert Config({"flag": val}).get_bool("flag") is True + + def test_get_bool_false_string_variants(self): + for val in ("false", "no", "0", "anything"): + assert Config({"flag": val}).get_bool("flag") is False + + def test_get_bool_bool_true_passthrough(self): + assert Config({"flag": True}).get_bool("flag") is True + + def test_get_bool_bool_false_passthrough(self): + assert Config({"flag": False}).get_bool("flag") is False + + def test_to_dict_returns_copy(self): + d = {"a": 1} + cfg = Config(d) + result = cfg.to_dict() + result["b"] = 2 + assert "b" not in cfg._config + + +@pytest.mark.unit +class TestDotenv: + def test_dotenv_does_not_overwrite_existing_env_vars(self, tmp_path): + env_file = tmp_path / ".env" + env_file.write_text("MY_TEST_KEY=from_file\n") + os.environ["MY_TEST_KEY"] = "from_env" + try: + Config._load_dotenv(str(env_file)) + assert os.environ["MY_TEST_KEY"] == "from_env" + finally: + del os.environ["MY_TEST_KEY"] + + def test_dotenv_sets_missing_key(self, tmp_path): + env_file = tmp_path / ".env" + key = "MY_UNIQUE_TEST_KEY_XYZ" + env_file.write_text(f"{key}=hello\n") + os.environ.pop(key, None) + try: + Config._load_dotenv(str(env_file)) + assert os.environ[key] == "hello" + finally: + os.environ.pop(key, None) + + def test_dotenv_skips_comments(self, tmp_path): + env_file = tmp_path / ".env" + env_file.write_text("# THIS_KEY=should_not_be_set\n") + os.environ.pop("THIS_KEY", None) + Config._load_dotenv(str(env_file)) + assert "THIS_KEY" not in os.environ + + def test_dotenv_nonexistent_file_does_not_raise(self, tmp_path): + Config._load_dotenv(str(tmp_path / "nonexistent.env")) diff --git a/src/robocoop_backend/robocoop_backend/utils/config.py b/src/robocoop_backend/robocoop_backend/utils/config.py index 9d2d9be..4c7abba 100644 --- a/src/robocoop_backend/robocoop_backend/utils/config.py +++ b/src/robocoop_backend/robocoop_backend/utils/config.py @@ -1,8 +1,91 @@ -# TODO: implement Config loader. +import os +import logging +from typing import Any, Dict, Optional +import yaml -# Expected behavior: -# - load YAML params file based on ROBOCOOP_ENV env var (mock | sim | real) -# - merge with common.params.yaml -# - expose typed getters: get_str(), get_int(), get_float(), get_bool() +logger = logging.getLogger(__name__) -# TODO: raise clear error on missing required key at startup. + +class Config: + def __init__(self, config_dict: Dict[str, Any]): + self._config = config_dict + + @staticmethod + def _load_dotenv(path: str = ".env") -> None: + if not os.path.exists(path): + return + with open(path) as f: + for line in f: + line = line.strip() + if line and not line.startswith("#") and "=" in line: + key, _, value = line.partition("=") + os.environ.setdefault(key.strip(), value.strip()) + + @staticmethod + def load(config_dir: str = "./config", env: Optional[str] = None) -> "Config": + Config._load_dotenv() + env = env or os.environ.get("ROBOCOOP_ENV", "mock").lower() + logger.info(f"Loading config for env: {env}") + config_dict = {} + + common_path = os.path.join(config_dir, "common.params.yaml") + if os.path.exists(common_path): + with open(common_path) as f: + config_dict.update(yaml.safe_load(f) or {}) + + env_path = os.path.join(config_dir, f"{env}.params.yaml") + if os.path.exists(env_path): + with open(env_path) as f: + Config._deep_merge(config_dict, yaml.safe_load(f) or {}) + else: + logger.warning(f"Env config not found: {env_path}") + + return Config(config_dict) + + @staticmethod + def _deep_merge(base: Dict, override: Dict) -> None: + for key, value in override.items(): + if key in base and isinstance(base[key], dict) and isinstance(value, dict): + Config._deep_merge(base[key], value) + else: + base[key] = value + + def get(self, key: str, default: Any = None) -> Any: + keys = key.split(".") + value = self._config + for k in keys: + if not isinstance(value, dict): + return default + value = value.get(k) + if value is None: + return default + return value + + def get_str(self, key: str, default: str = "") -> str: + v = self.get(key, default) + return str(v) if v is not None else default + + def get_int(self, key: str, default: int = 0) -> int: + v = self.get(key, default) + try: + return int(v) if v is not None else default + except (ValueError, TypeError): + return default + + def get_float(self, key: str, default: float = 0.0) -> float: + v = self.get(key, default) + try: + return float(v) if v is not None else default + except (ValueError, TypeError): + return default + + def get_bool(self, key: str, default: bool = False) -> bool: + v = self.get(key, default) + if isinstance(v, bool): + return v + if isinstance(v, str): + return v.lower() in ("true", "yes", "1") + return default + + def to_dict(self) -> Dict[str, Any]: + return self._config.copy() diff --git a/src/robocoop_backend/robocoop_backend/utils/enums.py b/src/robocoop_backend/robocoop_backend/utils/enums.py deleted file mode 100644 index 6d93efd..0000000 --- a/src/robocoop_backend/robocoop_backend/utils/enums.py +++ /dev/null @@ -1,5 +0,0 @@ -# TODO: shared enum utilities if needed. - -# Example: -# - safe_parse(enum_class, value) -> enum member | None -# (avoid KeyError on unknown values from ROS messages) diff --git a/src/robocoop_backend/robocoop_backend/utils/ids.py b/src/robocoop_backend/robocoop_backend/utils/ids.py deleted file mode 100644 index d65ec67..0000000 --- a/src/robocoop_backend/robocoop_backend/utils/ids.py +++ /dev/null @@ -1,6 +0,0 @@ -# TODO: implement ID generation helpers. - -# Expected: -# generate_mission_id() -> str # e.g. "mission_" -# generate_alert_id() -> str # e.g. "alert_" -# generate_event_id() -> str # e.g. "event_" diff --git a/src/robocoop_backend/robocoop_backend/utils/logger.py b/src/robocoop_backend/robocoop_backend/utils/logger.py deleted file mode 100644 index f5ed6d0..0000000 --- a/src/robocoop_backend/robocoop_backend/utils/logger.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: implement structured logger wrapper. - -# Expected behavior: -# - wrap Python logging with consistent format: [LEVEL] [module] message -# - log to stdout + optional file sink -# - expose get_logger(name: str) -> Logger - -# TODO: log level configurable via common.params.yaml. diff --git a/src/robocoop_backend/robocoop_backend/utils/thread_safe_lock.py b/src/robocoop_backend/robocoop_backend/utils/thread_safe_lock.py deleted file mode 100644 index 50d65a4..0000000 --- a/src/robocoop_backend/robocoop_backend/utils/thread_safe_lock.py +++ /dev/null @@ -1,6 +0,0 @@ -# TODO: implement ThreadSafeLock context manager wrapper. - -# Expected behavior: -# - wrap threading.RLock -# - expose acquire/release via context manager (__enter__/__exit__) -# - log warning if lock held > threshold (e.g. 100ms) diff --git a/src/robocoop_backend/robocoop_backend/utils/time.py b/src/robocoop_backend/robocoop_backend/utils/time.py deleted file mode 100644 index c9d215e..0000000 --- a/src/robocoop_backend/robocoop_backend/utils/time.py +++ /dev/null @@ -1,6 +0,0 @@ -# TODO: implement time helpers. - -# Expected: -# now_utc() -> datetime # timezone-aware UTC datetime -# now_iso() -> str # ISO 8601 string for serialization -# elapsed_seconds(since: datetime) -> float diff --git a/src/robocoop_backend/setup.py b/src/robocoop_backend/setup.py index 94003c0..ad34a56 100644 --- a/src/robocoop_backend/setup.py +++ b/src/robocoop_backend/setup.py @@ -10,6 +10,11 @@ ], extras_require={ "ros": ["rclpy"], + "test":[ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "pytest-mock>=3.12", + ] }, python_requires=">=3.10", description="Robocoop WebSocket backend.", diff --git a/src/robocoop_bringup/config/README.md b/src/robocoop_bringup/config/README.md new file mode 100644 index 0000000..d0ba9ce --- /dev/null +++ b/src/robocoop_bringup/config/README.md @@ -0,0 +1,108 @@ +# config/ + +YAML configuration files loaded by `utils/config.py` at startup. + +## Loading order + +Two files are merged on every startup: + +``` +common.params.yaml — always loaded (shared base) +{ROBOCOOP_ENV}.params.yaml — loaded on top (environment overrides) +``` + +`ROBOCOOP_ENV` is read from the `.env` file at the project root (default: `mock`). + +Nested keys are deep-merged — the environment file only needs to contain what it overrides. + +## Current files + +| File | Purpose | +|---|---| +| `common.params.yaml` | Shared config: WebSocket port, battery thresholds, audit log path | +| `mock.params.yaml` | Sets `adapter_type: mock` — no robot connection needed | +| `real.params.yaml` | Sets `adapter_type: rosbridge` + real robot IP + rosbridge settings | + +## Key configuration values + +### `common.params.yaml` + +```yaml +websocket: + host: "0.0.0.0" + port: 8765 # dashboard connects here + +battery_warning_threshold: 20.0 # % — triggers battery.low audit event +battery_critical_threshold: 10.0 # % — reserved for future critical alert +battery_watchdog_timeout_seconds: 15 # seconds without /battery → robot disconnected + +audit_log_path: "/var/log/robocoop/audit.jsonl" +``` + +### `real.params.yaml` + +```yaml +adapter_type: "rosbridge" + +rosbridge: + url_primary: "ws://10.10.220.79:9090" # robot IP + url_secondary: "ws://10.10.220.79:9091" # fallback + connection_timeout_seconds: 8.0 + reconnect_interval_seconds: 2.0 + max_reconnect_attempts: 10 + topics: + battery: "/battery" +``` + +## How to add a new environment + +1. Create `your_env.params.yaml` with only the values that differ from `common.params.yaml` +2. Set `ROBOCOOP_ENV=your_env` in your `.env` file +3. At minimum, set `adapter_type` + +Example for a second physical robot: + +```yaml +# robot2.params.yaml +adapter_type: "rosbridge" + +rosbridge: + url_primary: "ws://192.168.1.42:9090" + max_reconnect_attempts: 5 + topics: + battery: "/robot2/battery" +``` + +## How to add a new ROS topic to config + +Add the topic name under `rosbridge.topics` in the relevant env file: + +```yaml +rosbridge: + topics: + battery: "/battery" + odom: "/odom" # add this + scan: "/scan" # add this +``` + +Then read it in `adapters/factory.py`: + +```python +topics = rb.get("topics", {}) +odom_topic = topics.get("odom", "/odom") +``` + +And pass it to the adapter constructor. + +## Accessing config values in code + +Use dot notation via `Config.get()`: + +```python +config.get("rosbridge.url_primary") # "ws://10.10.220.79:9090" +config.get("battery_warning_threshold") # 20.0 +config.get("websocket.port") # 8765 +config.get_float("battery_warning_threshold", 20.0) +config.get_int("websocket.port", 8765) +config.get_str("rosbridge.url_primary", "ws://localhost:9090") +``` diff --git a/src/robocoop_bringup/config/backend.params.yaml b/src/robocoop_bringup/config/backend.params.yaml deleted file mode 100644 index 857909f..0000000 --- a/src/robocoop_bringup/config/backend.params.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# TODO: define WebSocket server parameters. - -# Expected keys: -# host: "0.0.0.0" -# port: 8765 -# max_connections: 5 diff --git a/src/robocoop_bringup/config/common.params.yaml b/src/robocoop_bringup/config/common.params.yaml index 66b543a..238b8fb 100644 --- a/src/robocoop_bringup/config/common.params.yaml +++ b/src/robocoop_bringup/config/common.params.yaml @@ -1,7 +1,12 @@ -# TODO: define common parameters shared across all environments. +# === WebSocket Server === +websocket: + host: "0.0.0.0" + port: 8765 -# Expected keys: -# watchdog_timeout_seconds: 3 -# battery_warning_threshold: 20.0 -# log_level: "INFO" -# audit_log_path: "/var/log/robocoop/audit.jsonl" +# === Safety & Telemetry === +battery_warning_threshold: 20.0 +battery_critical_threshold: 10.0 +battery_watchdog_timeout_seconds: 15.0 + +# === Audit Logging === +audit_log_path: "/var/log/robocoop/audit.jsonl" diff --git a/src/robocoop_bringup/config/m3pro_topics.yaml b/src/robocoop_bringup/config/m3pro_topics.yaml deleted file mode 100644 index 9afc628..0000000 --- a/src/robocoop_bringup/config/m3pro_topics.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# TODO: map semantic names to real M3Pro topic names. -# Update after running: ros2 topic list on hardware. - -# candidates: -# cmd_vel: "/cmd_vel" -# odom: "/odom" -# scan: "/scan" -# imu: "/imu/data" -# battery: "/battery_state" -# camera: "/camera/image_raw" diff --git a/src/robocoop_bringup/config/mock.params.yaml b/src/robocoop_bringup/config/mock.params.yaml index 774e28c..4196042 100644 --- a/src/robocoop_bringup/config/mock.params.yaml +++ b/src/robocoop_bringup/config/mock.params.yaml @@ -1,6 +1 @@ -# TODO: mock adapter parameters. - -# Expected keys: -# adapter: "mock" -# mock_battery_drain_rate: 0.1 # % per second -# mock_obstacle_rate: 0.01 # probability per telemetry tick +adapter_type: "mock" diff --git a/src/robocoop_bringup/config/real.params.yaml b/src/robocoop_bringup/config/real.params.yaml index 47aa875..4539d41 100644 --- a/src/robocoop_bringup/config/real.params.yaml +++ b/src/robocoop_bringup/config/real.params.yaml @@ -1,10 +1,10 @@ -# TODO: real M3Pro hardware parameters. +adapter_type: "rosbridge" -# Expected keys: -# adapter: "real" -# ros_domain_id: 42 -# cmd_vel_topic: "/cmd_vel" -# odom_topic: "/odom" -# battery_topic: "/battery_state" - -# TODO(M3PRO): confirm topic names after ros2 topic list on hardware. +rosbridge: + url_primary: "ws://10.10.220.79:9090" + url_secondary: "ws://10.10.220.79:9091" + connection_timeout_seconds: 8.0 + reconnect_interval_seconds: 2.0 + max_reconnect_attempts: 10 + topics: + battery: "/battery" diff --git a/src/robocoop_bringup/config/security.params.yaml b/src/robocoop_bringup/config/security.params.yaml deleted file mode 100644 index 8ae69d3..0000000 --- a/src/robocoop_bringup/config/security.params.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# TODO: define security parameters. - -# Expected keys: -# auth_token: "" # override via env var ROBOCOOP_AUTH_TOKEN -# rate_limit_teleop: 50 # max teleop.move messages/second -# rate_limit_default: 10 # max other messages/second diff --git a/src/robocoop_bringup/config/sim.params.yaml b/src/robocoop_bringup/config/sim.params.yaml deleted file mode 100644 index b401728..0000000 --- a/src/robocoop_bringup/config/sim.params.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# TODO: Gazebo simulation parameters. - -# Expected keys: -# adapter: "sim" -# ros_domain_id: 0 -# gazebo_world: "hospital_corridor.world" From 77b37826d86865aab10f499742ba22013516c0a6 Mon Sep 17 00:00:00 2001 From: damnthonyy Date: Mon, 18 May 2026 21:52:47 +0200 Subject: [PATCH 3/6] Revert "Merge branch 'development' into feat/robot-monitoring" This reverts commit dd388fc99205464a83f592c9fe1d07acf9c0f115, reversing changes made to 351d7b6093cec5953378b9fd1f700d50364cf0d4. --- .gitignore | 2 +- requirements.txt | Bin 38 -> 0 bytes .../robocoop_backend/app/auth.py | 9 --------- .../robocoop_backend/app/message_router.py | 13 ------------- .../robocoop_backend/app/rate_limiter.py | 9 --------- .../infrastructure/__init__.py | 0 .../infrastructure/adapters/__init__.py | 0 .../infrastructure/adapters/adapter_factory.py | 10 ---------- .../adapters/m3pro_robot_adapter.py | 8 -------- .../infrastructure/adapters/m3pro_topic_map.py | 13 ------------- .../adapters/mock_robot_adapter.py | 7 ------- .../infrastructure/adapters/robot_adapter.py | 8 -------- .../adapters/sim_robot_adapter.py | 8 -------- .../infrastructure/ros/__init__.py | 0 .../infrastructure/ros/emergency_stop_node.py | 9 --------- .../infrastructure/ros/launch_manager.py | 8 -------- .../infrastructure/ros/mode_bridge_node.py | 8 -------- .../infrastructure/ros/robot_state_node.py | 8 -------- .../ros/telemetry_bridge_node.py | 11 ----------- .../infrastructure/ros/teleop_bridge_node.py | 6 ------ .../infrastructure/ros/watchdog_node.py | 8 -------- .../infrastructure/schemas/__init__.py | 0 .../infrastructure/schemas/error_messages.py | 9 --------- .../infrastructure/schemas/events.py | 17 ----------------- .../infrastructure/schemas/inbound.py | 4 ---- .../infrastructure/schemas/outbound.py | 8 -------- .../infrastructure/schemas/validation.py | 7 ------- .../modules/audit/domain/__init__.py | 0 .../modules/audit/domain/audit_event.py | 8 -------- .../modules/mission/__init__.py | 0 .../modules/mission/domain/__init__.py | 0 .../modules/mission/domain/mission_failure.py | 9 --------- .../modules/mission/domain/mission_state.py | 9 --------- .../modules/mission/mission_service.py | 8 -------- .../modules/mission/mission_state_machine.py | 10 ---------- .../modules/mission/mission_state_store.py | 9 --------- .../robocoop_backend/modules/mode/__init__.py | 0 .../modules/mode/mode_manager.py | 10 ---------- .../modules/mode/mode_service.py | 8 -------- .../modules/robot/domain/__init__.py | 0 .../modules/robot/domain/connection_state.py | 6 ------ .../modules/robot/domain/robot_mode.py | 7 ------- .../modules/robot/domain/robot_state.py | 10 ---------- .../modules/robot/domain/teleop_command.py | 7 ------- .../modules/robot/robot_state_store.py | 8 -------- .../modules/robot/teleop_service.py | 9 --------- .../modules/safety/__init__.py | 0 .../modules/safety/domain/__init__.py | 0 .../modules/safety/domain/alert.py | 11 ----------- .../modules/safety/emergency_service.py | 10 ---------- .../modules/safety/watchdog_service.py | 9 --------- .../tests/fixtures/__init__.py | 0 .../tests/fixtures/fake_messages.py | 9 --------- .../tests/fixtures/fake_mission_data.py | 6 ------ .../tests/fixtures/fake_robot_state.py | 9 --------- .../integration/test_emergency_stop_flow.py | 12 ------------ .../integration/test_mock_adapter_flow.py | 10 ---------- .../tests/integration/test_sim_adapter_flow.py | 10 ---------- .../integration/test_websocket_teleop_flow.py | 11 ----------- .../tests/unit/test_message_router.py | 7 ------- .../tests/unit/test_mission_state_machine.py | 9 --------- .../tests/unit/test_mode_manager.py | 9 --------- .../tests/unit/test_robot_state_store.py | 7 ------- .../tests/unit/test_schema_validation.py | 8 -------- .../tests/unit/test_teleop_service.py | 8 -------- .../tests/unit/test_watchdog_service.py | 7 ------- .../robocoop_backend/utils/enums.py | 5 ----- .../robocoop_backend/utils/ids.py | 6 ------ .../robocoop_backend/utils/logger.py | 8 -------- .../robocoop_backend/utils/thread_safe_lock.py | 6 ------ .../robocoop_backend/utils/time.py | 6 ------ .../config/backend.params.yaml | 6 ------ src/robocoop_bringup/config/m3pro_topics.yaml | 10 ---------- .../config/security.params.yaml | 6 ------ src/robocoop_bringup/config/sim.params.yaml | 6 ------ 75 files changed, 1 insertion(+), 518 deletions(-) delete mode 100644 requirements.txt delete mode 100644 src/robocoop_backend/robocoop_backend/app/auth.py delete mode 100644 src/robocoop_backend/robocoop_backend/app/message_router.py delete mode 100644 src/robocoop_backend/robocoop_backend/app/rate_limiter.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/__init__.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/adapters/__init__.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/adapters/adapter_factory.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_robot_adapter.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_topic_map.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/adapters/mock_robot_adapter.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/adapters/robot_adapter.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/adapters/sim_robot_adapter.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/__init__.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/emergency_stop_node.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/launch_manager.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/mode_bridge_node.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/robot_state_node.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/telemetry_bridge_node.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/teleop_bridge_node.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/ros/watchdog_node.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/schemas/__init__.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/schemas/error_messages.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/schemas/events.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/schemas/inbound.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/schemas/outbound.py delete mode 100644 src/robocoop_backend/robocoop_backend/infrastructure/schemas/validation.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/audit/domain/__init__.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/audit/domain/audit_event.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/mission/__init__.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/mission/domain/__init__.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_failure.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_state.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/mission/mission_service.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/mission/mission_state_machine.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/mission/mission_state_store.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/mode/__init__.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/mode/mode_manager.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/mode/mode_service.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/domain/__init__.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/domain/connection_state.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_mode.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_state.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/domain/teleop_command.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/robot_state_store.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/robot/teleop_service.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/safety/__init__.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/safety/domain/__init__.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/safety/domain/alert.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/safety/emergency_service.py delete mode 100644 src/robocoop_backend/robocoop_backend/modules/safety/watchdog_service.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/fixtures/__init__.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/fixtures/fake_messages.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/fixtures/fake_mission_data.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/fixtures/fake_robot_state.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/integration/test_emergency_stop_flow.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/integration/test_mock_adapter_flow.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/integration/test_sim_adapter_flow.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/integration/test_websocket_teleop_flow.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_message_router.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_mission_state_machine.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_mode_manager.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_robot_state_store.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_schema_validation.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_teleop_service.py delete mode 100644 src/robocoop_backend/robocoop_backend/tests/unit/test_watchdog_service.py delete mode 100644 src/robocoop_backend/robocoop_backend/utils/enums.py delete mode 100644 src/robocoop_backend/robocoop_backend/utils/ids.py delete mode 100644 src/robocoop_backend/robocoop_backend/utils/logger.py delete mode 100644 src/robocoop_backend/robocoop_backend/utils/thread_safe_lock.py delete mode 100644 src/robocoop_backend/robocoop_backend/utils/time.py delete mode 100644 src/robocoop_bringup/config/backend.params.yaml delete mode 100644 src/robocoop_bringup/config/m3pro_topics.yaml delete mode 100644 src/robocoop_bringup/config/security.params.yaml delete mode 100644 src/robocoop_bringup/config/sim.params.yaml diff --git a/.gitignore b/.gitignore index b3ec7d5..b85f2b7 100644 --- a/.gitignore +++ b/.gitignore @@ -217,4 +217,4 @@ marimo/_lsp/ __marimo__/ # Streamlit -.streamlit/secrets.toml +.streamlit/secrets.toml \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 42512d3dad1ecece1c16ae2b148d57c54b450130..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38 pcmezWubd&3A&H@wA)g_cAsfgp0kUm@(2&85L65 send ERR_INVALID_MESSAGE - -# Routing table: -# "teleop.move" -> teleop_service.handle_move() -# "mission.start" -> mission_service.start() -# "mission.cancel" -> mission_service.cancel() -# "mode.change" -> mode_service.request_transition() -# "emergency_stop" -> emergency_service.trigger() -# "ping" -> reply pong diff --git a/src/robocoop_backend/robocoop_backend/app/rate_limiter.py b/src/robocoop_backend/robocoop_backend/app/rate_limiter.py deleted file mode 100644 index 7d748c7..0000000 --- a/src/robocoop_backend/robocoop_backend/app/rate_limiter.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: implement RateLimiter for inbound WS messages. - -# Expected behavior: -# - sliding window counter per client -# - configurable max messages/second (see security.params.yaml) -# - return True if allowed, False if rate exceeded -# - send ERR_RATE_LIMITED to client on rejection - -# Note: teleop.move is high-frequency — set limit accordingly (e.g. 50/s). diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/__init__.py b/src/robocoop_backend/robocoop_backend/infrastructure/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/__init__.py b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/adapter_factory.py b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/adapter_factory.py deleted file mode 100644 index 6965842..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/adapter_factory.py +++ /dev/null @@ -1,10 +0,0 @@ -# TODO: implement AdapterFactory. - -# Expected behavior: -# - read adapter type from config (mock | sim | real) -# - return corresponding RobotAdapter instance - -# Example: -# "mock" -> MockRobotAdapter() -# "sim" -> SimRobotAdapter() -# "real" -> M3ProRobotAdapter() diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_robot_adapter.py b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_robot_adapter.py deleted file mode 100644 index f7db56c..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_robot_adapter.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: implement M3ProRobotAdapter (real hardware). - -# Expected behavior: -# - publish TeleopCommand to /cmd_vel as Twist -# - subscribe to /odom, /battery_state for state updates -# - call emergency_stop via dedicated ROS2 service or zero Twist - -# TODO(M3PRO): verify topic names against m3pro_topic_map.py before use. diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_topic_map.py b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_topic_map.py deleted file mode 100644 index 7ee2e3f..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/m3pro_topic_map.py +++ /dev/null @@ -1,13 +0,0 @@ -# TODO(M3PRO): confirm real topic names on hardware. - -# Expected candidates (to verify): -# /cmd_vel -> geometry_msgs/msg/Twist -# /odom -> nav_msgs/msg/Odometry -# /scan -> sensor_msgs/msg/LaserScan -# /imu/data -> sensor_msgs/msg/Imu -# /battery_state -> sensor_msgs/msg/BatteryState - -# Verify with: -# ros2 topic list -# ros2 topic info -# ros2 interface show \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/mock_robot_adapter.py b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/mock_robot_adapter.py deleted file mode 100644 index 5d08e57..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/mock_robot_adapter.py +++ /dev/null @@ -1,7 +0,0 @@ -# TODO: implement MockRobotAdapter (in-memory, no ROS). - -# Expected behavior: -# - store state in memory -# - simulate battery drain over time -# - simulate obstacle detection randomly (configurable rate) -# - log all received commands diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/robot_adapter.py b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/robot_adapter.py deleted file mode 100644 index d590545..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/robot_adapter.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: define RobotAdapter abstract interface. - -# Expected abstract methods: -# send_velocity(command: TeleopCommand) -> None -# emergency_stop() -> None -# navigate_to(x: float, y: float) -> None -# get_state() -> RobotState -# is_connected() -> bool diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/sim_robot_adapter.py b/src/robocoop_backend/robocoop_backend/infrastructure/adapters/sim_robot_adapter.py deleted file mode 100644 index d721cf1..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/adapters/sim_robot_adapter.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: implement SimRobotAdapter (Gazebo via ROS2 topics). - -# Expected behavior: -# - same interface as M3ProRobotAdapter -# - connect to simulated topics in Gazebo -# - useful for integration tests without hardware - -# Note: topic names should match real robot (see m3pro_topic_map.py). diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/__init__.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/emergency_stop_node.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/emergency_stop_node.py deleted file mode 100644 index 63f940b..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/ros/emergency_stop_node.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: implement EmergencyStopNode (ROS2 node). - -# Expected behavior: -# - subscribe to internal /emergency_stop topic -# - on message: publish zero Twist to /cmd_vel immediately -# - this node is the last safety net — must be as simple as possible - -# TODO(SAFETY): this node must NOT depend on any service or store. -# Direct ROS2 publish only. diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/launch_manager.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/launch_manager.py deleted file mode 100644 index e58fd60..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/ros/launch_manager.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: implement LaunchManager. - -# Expected behavior: -# - programmatically start/stop ROS2 nodes at runtime -# - used to activate navigation stack on AUTONOMOUS mode -# - used to teardown nodes on shutdown - -# TODO: wrap ros2launch API or use subprocess with proper cleanup. diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/mode_bridge_node.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/mode_bridge_node.py deleted file mode 100644 index c5db8c4..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/ros/mode_bridge_node.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: implement ModeBridgeNode (ROS2 node). - -# Expected behavior: -# - subscribe to /robot_mode topic -# - forward mode change to mode_manager (state layer) -# - publish current mode on /robot_mode when mode_manager updates - -# Note: this node only bridges — no transition logic here. diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/robot_state_node.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/robot_state_node.py deleted file mode 100644 index 4ba2935..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/ros/robot_state_node.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: implement RobotStateNode (ROS2 node). - -# Expected behavior: -# - subscribe to /connection_state or ping robot periodically -# - update robot_state_store.is_connected on change -# - trigger alert on disconnect - -# TODO: publish connection state changes to watchdog_node. diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/telemetry_bridge_node.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/telemetry_bridge_node.py deleted file mode 100644 index 9a77a91..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/ros/telemetry_bridge_node.py +++ /dev/null @@ -1,11 +0,0 @@ -# TODO(M3PRO): subscribe to robot telemetry topics. - -# Expected: -# /odom -# /battery_state -# /scan -# /imu/data - -# TODO: forward telemetry to telemetry_service. - -# TODO: update robot_state_store with latest data. \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/teleop_bridge_node.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/teleop_bridge_node.py deleted file mode 100644 index 098e501..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/ros/teleop_bridge_node.py +++ /dev/null @@ -1,6 +0,0 @@ -# TODO(M3PRO): confirm velocity command topic (likely /cmd_vel). - -# TODO: convert TeleopCommand -> Twist message. - -# TODO(SAFETY): stop robot if no command received for X seconds -# (dead man's switch). \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/ros/watchdog_node.py b/src/robocoop_backend/robocoop_backend/infrastructure/ros/watchdog_node.py deleted file mode 100644 index 243d475..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/ros/watchdog_node.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: implement WatchdogNode (ROS2 node). - -# Expected behavior: -# - timer at configurable interval (e.g. 1s) -# - check last_heartbeat from robot_state_store -# - if delta > timeout -> call emergency_service.trigger("watchdog_timeout") - -# TODO(SAFETY): watchdog timer must survive WS handler exceptions. diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/__init__.py b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/error_messages.py b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/error_messages.py deleted file mode 100644 index 26c2bed..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/error_messages.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: define error code constants. - -# Expected codes: -# ERR_INVALID_MESSAGE = "invalid_message" -# ERR_UNAUTHORIZED = "unauthorized" -# ERR_MODE_FORBIDDEN = "mode_forbidden" -# ERR_MISSION_ACTIVE = "mission_already_active" -# ERR_EMERGENCY_STOP = "robot_in_emergency_stop" -# ERR_RATE_LIMITED = "rate_limited" diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/events.py b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/events.py deleted file mode 100644 index c358f39..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/events.py +++ /dev/null @@ -1,17 +0,0 @@ -# TODO: define WebSocket event type constants. - -# Inbound event types: -# "teleop.move" -# "mission.start" -# "mission.cancel" -# "mode.change" -# "emergency_stop" -# "ping" - -# Outbound event types: -# "robot_state_update" -# "mission_update" -# "alert_event" -# "mode_changed" -# "error" -# "pong" diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/inbound.py b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/inbound.py deleted file mode 100644 index 1fe60bd..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/inbound.py +++ /dev/null @@ -1,4 +0,0 @@ -# TODO: define schema for mission.start message. - - -# TODO: define schema for teleop.move message. \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/outbound.py b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/outbound.py deleted file mode 100644 index a5457b9..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/outbound.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: define outbound WebSocket message schemas (server -> dashboard). - -# Expected schemas: -# robot_state_update { mode, battery, position, velocity, is_connected } -# mission_update { mission_id, state, failure_reason? } -# alert_event { id, severity, message, location, timestamp } -# mode_changed { previous_mode, new_mode, actor } -# error { code, message } diff --git a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/validation.py b/src/robocoop_backend/robocoop_backend/infrastructure/schemas/validation.py deleted file mode 100644 index bc9dd89..0000000 --- a/src/robocoop_backend/robocoop_backend/infrastructure/schemas/validation.py +++ /dev/null @@ -1,7 +0,0 @@ -# TODO: implement validate_inbound(message: dict) -> bool. - -# Expected behavior: -# - check "type" field exists and is a known event type -# - validate payload against matching schema -# - return False + log warning on invalid message -# - never raise — caller decides what to do diff --git a/src/robocoop_backend/robocoop_backend/modules/audit/domain/__init__.py b/src/robocoop_backend/robocoop_backend/modules/audit/domain/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/robocoop_backend/robocoop_backend/modules/audit/domain/audit_event.py b/src/robocoop_backend/robocoop_backend/modules/audit/domain/audit_event.py deleted file mode 100644 index 7cb6a6d..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/audit/domain/audit_event.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: define AuditEvent dataclass. - -# Expected fields: -# id: str -# action: str # e.g. "mission.start", "mode.change", "emergency_stop" -# actor: str # "dashboard" | "watchdog" | "system" -# payload: dict # action-specific data -# timestamp: datetime diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/__init__.py b/src/robocoop_backend/robocoop_backend/modules/mission/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/domain/__init__.py b/src/robocoop_backend/robocoop_backend/modules/mission/domain/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_failure.py b/src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_failure.py deleted file mode 100644 index f136191..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_failure.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: define MissionFailureReason enum. - -# Expected values: -# OBSTACLE_DETECTED -# BATTERY_LOW -# TIMEOUT -# NAVIGATION_ERROR -# EMERGENCY_STOP -# MANUAL_CANCEL diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_state.py b/src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_state.py deleted file mode 100644 index 70b9567..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/mission/domain/mission_state.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: define MissionState enum. - -# Expected values: -# IDLE -# RUNNING -# BLOCKED -# COMPLETED -# FAILED -# CANCELLED diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/mission_service.py b/src/robocoop_backend/robocoop_backend/modules/mission/mission_service.py deleted file mode 100644 index 94d6b77..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/mission/mission_service.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO(NAV): implement mission type "navigate_to". -# Should send goal to robot_adapter.navigate_to(). - - -# TODO(NAV): define mission lifecycle events -# (STARTED, BLOCKED, COMPLETED, FAILED). - -# TODO(SAFETY): prevent mission start if robot is in EMERGENCY_STOP. \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/mission_state_machine.py b/src/robocoop_backend/robocoop_backend/modules/mission/mission_state_machine.py deleted file mode 100644 index 7484950..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/mission/mission_state_machine.py +++ /dev/null @@ -1,10 +0,0 @@ -# TODO: implement mission state transitions. - -# Example: -# IDLE -> RUNNING -# RUNNING -> COMPLETED -# RUNNING -> FAILED -# RUNNING -> BLOCKED - - -# TODO: define failure reasons (obstacle, battery_low, timeout). \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/modules/mission/mission_state_store.py b/src/robocoop_backend/robocoop_backend/modules/mission/mission_state_store.py deleted file mode 100644 index 8c3b27c..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/mission/mission_state_store.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: implement MissionStateStore. - -# Expected methods: -# get_current() -> MissionState -# set(state: MissionState) -> None -# get_active_mission() -> dict | None -# clear() -> None - -# TODO(CONCURRENCY): use thread_safe_lock for all access. diff --git a/src/robocoop_backend/robocoop_backend/modules/mode/__init__.py b/src/robocoop_backend/robocoop_backend/modules/mode/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/robocoop_backend/robocoop_backend/modules/mode/mode_manager.py b/src/robocoop_backend/robocoop_backend/modules/mode/mode_manager.py deleted file mode 100644 index ee072ac..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/mode/mode_manager.py +++ /dev/null @@ -1,10 +0,0 @@ -# TODO: define allowed robot mode transitions. - -# Example: -# IDLE -> MANUAL -# MANUAL -> AUTONOMOUS -# ANY -> EMERGENCY_STOP - -# TODO(SAFETY): watchdog must be able to force EMERGENCY_STOP. - -# TODO: ensure thread-safe access to mode state. \ No newline at end of file diff --git a/src/robocoop_backend/robocoop_backend/modules/mode/mode_service.py b/src/robocoop_backend/robocoop_backend/modules/mode/mode_service.py deleted file mode 100644 index 4efbf20..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/mode/mode_service.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: implement ModeService. - -# Expected methods: -# request_transition(target: RobotMode, actor: str) -> bool -# - validate transition via mode_manager -# - apply if valid -# - emit audit event on success -# - return False if transition not allowed diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/domain/__init__.py b/src/robocoop_backend/robocoop_backend/modules/robot/domain/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/domain/connection_state.py b/src/robocoop_backend/robocoop_backend/modules/robot/domain/connection_state.py deleted file mode 100644 index 43bfd85..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/robot/domain/connection_state.py +++ /dev/null @@ -1,6 +0,0 @@ -# TODO: define ConnectionState enum. - -# Expected values: -# CONNECTED -# DISCONNECTED -# RECONNECTING diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_mode.py b/src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_mode.py deleted file mode 100644 index 5f62a34..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_mode.py +++ /dev/null @@ -1,7 +0,0 @@ -# TODO: define RobotMode enum. - -# Expected values: -# IDLE -# MANUAL -# AUTONOMOUS -# EMERGENCY_STOP diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_state.py b/src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_state.py deleted file mode 100644 index 91e662f..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/robot/domain/robot_state.py +++ /dev/null @@ -1,10 +0,0 @@ -# TODO: define RobotState dataclass. - -# Expected fields: -# mode: RobotMode -# battery_level: float # 0.0 - 100.0 -# position: tuple[float, float] -# linear_velocity: float -# angular_velocity: float -# is_connected: bool -# last_updated: datetime diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/domain/teleop_command.py b/src/robocoop_backend/robocoop_backend/modules/robot/domain/teleop_command.py deleted file mode 100644 index 2a56550..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/robot/domain/teleop_command.py +++ /dev/null @@ -1,7 +0,0 @@ -# TODO: define TeleopCommand dataclass. - -# Expected fields: -# linear_x: float # forward/backward -1.0 to 1.0 -# linear_y: float # strafe -1.0 to 1.0 -# angular_z: float # rotation -1.0 to 1.0 -# speed_factor: float # global multiplier 0.0 to 1.0 diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/robot_state_store.py b/src/robocoop_backend/robocoop_backend/modules/robot/robot_state_store.py deleted file mode 100644 index 6a43d3e..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/robot/robot_state_store.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: implement RobotStateStore (single source of truth for robot state). - -# Expected methods: -# get() -> RobotState -# update(partial: dict) -> None -# reset() -> None - -# TODO(CONCURRENCY): use thread_safe_lock for all read/write access. diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/teleop_service.py b/src/robocoop_backend/robocoop_backend/modules/robot/teleop_service.py deleted file mode 100644 index e422f55..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/robot/teleop_service.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: implement TeleopService. - -# Expected methods: -# handle_move(command: TeleopCommand) -> None -# - reject if mode != MANUAL -# - forward to robot_adapter.send_velocity() - -# TODO(SAFETY): reject commands if robot is in EMERGENCY_STOP. -# TODO(SAFETY): validate speed_factor is within [0.0, 1.0]. diff --git a/src/robocoop_backend/robocoop_backend/modules/safety/__init__.py b/src/robocoop_backend/robocoop_backend/modules/safety/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/robocoop_backend/robocoop_backend/modules/safety/domain/__init__.py b/src/robocoop_backend/robocoop_backend/modules/safety/domain/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/robocoop_backend/robocoop_backend/modules/safety/domain/alert.py b/src/robocoop_backend/robocoop_backend/modules/safety/domain/alert.py deleted file mode 100644 index 36d9e02..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/safety/domain/alert.py +++ /dev/null @@ -1,11 +0,0 @@ -# TODO: define Alert dataclass. - -# Expected fields: -# id: str -# severity: AlertSeverity # INFO | WARNING | CRITICAL -# message: str -# location: str | None # e.g. "Couloir B" -# timestamp: datetime -# resolved: bool - -# TODO: define AlertSeverity enum. diff --git a/src/robocoop_backend/robocoop_backend/modules/safety/emergency_service.py b/src/robocoop_backend/robocoop_backend/modules/safety/emergency_service.py deleted file mode 100644 index 471008f..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/safety/emergency_service.py +++ /dev/null @@ -1,10 +0,0 @@ -# TODO: implement EmergencyService. - -# Expected methods: -# trigger(reason: str, actor: str) -> None -# - call robot_adapter.emergency_stop() -# - force mode to EMERGENCY_STOP via mode_manager -# - cancel active mission via mission_state_store -# - emit audit event - -# TODO(SAFETY): this must never fail silently — log + re-raise on error. diff --git a/src/robocoop_backend/robocoop_backend/modules/safety/watchdog_service.py b/src/robocoop_backend/robocoop_backend/modules/safety/watchdog_service.py deleted file mode 100644 index f55ea16..0000000 --- a/src/robocoop_backend/robocoop_backend/modules/safety/watchdog_service.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: implement WatchdogService. - -# Expected behavior: -# - monitor last heartbeat timestamp from dashboard -# - if no message received for X seconds -> trigger emergency_service -# - monitor robot connection state -> alert on disconnect - -# TODO(SAFETY): watchdog timeout must be configurable (see common.params.yaml). -# TODO(SAFETY): watchdog must run in its own thread/timer, independent of WS loop. diff --git a/src/robocoop_backend/robocoop_backend/tests/fixtures/__init__.py b/src/robocoop_backend/robocoop_backend/tests/fixtures/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_messages.py b/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_messages.py deleted file mode 100644 index 94b1f07..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_messages.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: define fake inbound WebSocket messages for tests. - -# Expected: -# MSG_TELEOP_MOVE -> type="teleop.move", linear_x=0.5, angular_z=0.0 -# MSG_TELEOP_INVALID -> type="teleop.move", linear_x=5.0 (out of range) -# MSG_EMERGENCY_STOP -> type="emergency_stop" -# MSG_PING -> type="ping" -# MSG_UNKNOWN_TYPE -> type="unknown.event" -# MSG_MISSING_TYPE -> no "type" field diff --git a/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_mission_data.py b/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_mission_data.py deleted file mode 100644 index 6adba05..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_mission_data.py +++ /dev/null @@ -1,6 +0,0 @@ -# TODO: define fake mission payloads for tests. - -# Expected: -# MISSION_DELIVERY -> type=delivery, target="Chambre 302", content="Médicaments" -# MISSION_GUIDANCE -> type=guidance, target="Patient A" -# MISSION_INVALID -> missing required fields (for validation tests) diff --git a/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_robot_state.py b/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_robot_state.py deleted file mode 100644 index 3c48903..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/fixtures/fake_robot_state.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: define fake RobotState instances for tests. - -# Expected: -# ROBOT_IDLE -> mode=IDLE, battery=80.0, connected=True -# ROBOT_MANUAL -> mode=MANUAL, battery=60.0, connected=True -# ROBOT_AUTONOMOUS -> mode=AUTONOMOUS, battery=55.0, connected=True -# ROBOT_EMERGENCY -> mode=EMERGENCY_STOP, battery=30.0, connected=True -# ROBOT_DISCONNECTED -> mode=IDLE, battery=0.0, connected=False -# ROBOT_LOW_BATTERY -> mode=AUTONOMOUS, battery=15.0, connected=True diff --git a/src/robocoop_backend/robocoop_backend/tests/integration/test_emergency_stop_flow.py b/src/robocoop_backend/robocoop_backend/tests/integration/test_emergency_stop_flow.py deleted file mode 100644 index 648c1f9..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/integration/test_emergency_stop_flow.py +++ /dev/null @@ -1,12 +0,0 @@ -# TODO: integration test — emergency stop full flow. - -# Scenario: -# 1. robot in AUTONOMOUS mode, mission RUNNING -# 2. emergency_stop triggered (from dashboard or watchdog) -# 3. assert: adapter.emergency_stop() called -# 4. assert: mode -> EMERGENCY_STOP -# 5. assert: mission -> FAILED (reason: EMERGENCY_STOP) -# 6. assert: audit event recorded -# 7. assert: dashboard receives mode_changed + mission_update - -# Use MockRobotAdapter + fake websocket client. diff --git a/src/robocoop_backend/robocoop_backend/tests/integration/test_mock_adapter_flow.py b/src/robocoop_backend/robocoop_backend/tests/integration/test_mock_adapter_flow.py deleted file mode 100644 index ab31915..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/integration/test_mock_adapter_flow.py +++ /dev/null @@ -1,10 +0,0 @@ -# TODO: integration test — full flow with MockRobotAdapter. - -# Scenario: -# 1. start server with mock adapter -# 2. connect dashboard client -# 3. send mode.change -> MANUAL -# 4. send teleop.move commands -# 5. assert: mock adapter received velocity commands -# 6. assert: robot_state_store updated after each telemetry tick -# 7. assert: battery drain simulation progresses diff --git a/src/robocoop_backend/robocoop_backend/tests/integration/test_sim_adapter_flow.py b/src/robocoop_backend/robocoop_backend/tests/integration/test_sim_adapter_flow.py deleted file mode 100644 index 9ae1c04..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/integration/test_sim_adapter_flow.py +++ /dev/null @@ -1,10 +0,0 @@ -# TODO: integration test — flow with SimRobotAdapter (Gazebo). - -# Note: requires running Gazebo instance — skip in CI by default. -# Mark with @pytest.mark.requires_sim - -# Scenario: -# 1. connect to simulated /cmd_vel, /odom topics -# 2. send teleop commands via websocket -# 3. assert: robot moves in simulation (odom changes) -# 4. assert: telemetry flows back to dashboard diff --git a/src/robocoop_backend/robocoop_backend/tests/integration/test_websocket_teleop_flow.py b/src/robocoop_backend/robocoop_backend/tests/integration/test_websocket_teleop_flow.py deleted file mode 100644 index fb7e3df..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/integration/test_websocket_teleop_flow.py +++ /dev/null @@ -1,11 +0,0 @@ -# TODO: integration test — WebSocket teleop end-to-end. - -# Scenario: -# 1. start server (mock adapter) -# 2. authenticate websocket client -# 3. switch to MANUAL mode -# 4. send teleop.move at 20Hz for 1 second -# 5. assert: all commands received by adapter -# 6. assert: no ERR_RATE_LIMITED (within allowed rate) -# 7. disconnect client -# 8. assert: watchdog triggers emergency_stop after timeout diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_message_router.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_message_router.py deleted file mode 100644 index 1c6796f..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/unit/test_message_router.py +++ /dev/null @@ -1,7 +0,0 @@ -# TODO: unit tests for MessageRouter. - -# Cases to cover: -# - known type routes to correct service method -# - unknown type returns ERR_INVALID_MESSAGE -# - missing "type" field returns ERR_INVALID_MESSAGE -# - each route handler called with correct parsed payload diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_mission_state_machine.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_mission_state_machine.py deleted file mode 100644 index ef989f2..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/unit/test_mission_state_machine.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: unit tests for MissionStateMachine. - -# Cases to cover: -# - IDLE -> RUNNING on valid start -# - RUNNING -> COMPLETED on success -# - RUNNING -> FAILED with correct reason (obstacle, battery_low, timeout) -# - RUNNING -> BLOCKED and resume -# - reject invalid transition (e.g. IDLE -> COMPLETED) -# - EMERGENCY_STOP cancels active mission diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_mode_manager.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_mode_manager.py deleted file mode 100644 index 6331814..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/unit/test_mode_manager.py +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: unit tests for ModeManager. - -# Cases to cover: -# - IDLE -> MANUAL allowed -# - MANUAL -> AUTONOMOUS allowed -# - AUTONOMOUS -> MANUAL allowed -# - ANY -> EMERGENCY_STOP always allowed -# - EMERGENCY_STOP -> IDLE not allowed without explicit reset -# - concurrent transition requests (thread safety) diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_robot_state_store.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_robot_state_store.py deleted file mode 100644 index f451aec..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/unit/test_robot_state_store.py +++ /dev/null @@ -1,7 +0,0 @@ -# TODO: unit tests for RobotStateStore. - -# Cases to cover: -# - get() returns default state on init -# - update() partial fields only -# - concurrent read/write does not corrupt state -# - reset() returns to default diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_schema_validation.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_schema_validation.py deleted file mode 100644 index d934ff3..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/unit/test_schema_validation.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: unit tests for schemas/validation.py. - -# Cases to cover: -# - valid teleop.move message passes -# - valid mission.start message passes -# - linear_x out of [-1.0, 1.0] fails -# - missing required field fails -# - extra unknown fields -> accepted or rejected (define policy) diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_teleop_service.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_teleop_service.py deleted file mode 100644 index 7afec69..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/unit/test_teleop_service.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: unit tests for TeleopService. - -# Cases to cover: -# - valid move command in MANUAL mode -> forwarded to adapter -# - move command rejected if mode != MANUAL -# - move command rejected if EMERGENCY_STOP -# - speed_factor out of range -> rejected -# - adapter call verified (mock adapter) diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/test_watchdog_service.py b/src/robocoop_backend/robocoop_backend/tests/unit/test_watchdog_service.py deleted file mode 100644 index c81dbb5..0000000 --- a/src/robocoop_backend/robocoop_backend/tests/unit/test_watchdog_service.py +++ /dev/null @@ -1,7 +0,0 @@ -# TODO: unit tests for WatchdogService. - -# Cases to cover: -# - no timeout if heartbeat received within window -# - triggers emergency_stop after timeout -# - resumes monitoring after reconnect -# - timeout threshold is read from config diff --git a/src/robocoop_backend/robocoop_backend/utils/enums.py b/src/robocoop_backend/robocoop_backend/utils/enums.py deleted file mode 100644 index 6d93efd..0000000 --- a/src/robocoop_backend/robocoop_backend/utils/enums.py +++ /dev/null @@ -1,5 +0,0 @@ -# TODO: shared enum utilities if needed. - -# Example: -# - safe_parse(enum_class, value) -> enum member | None -# (avoid KeyError on unknown values from ROS messages) diff --git a/src/robocoop_backend/robocoop_backend/utils/ids.py b/src/robocoop_backend/robocoop_backend/utils/ids.py deleted file mode 100644 index d65ec67..0000000 --- a/src/robocoop_backend/robocoop_backend/utils/ids.py +++ /dev/null @@ -1,6 +0,0 @@ -# TODO: implement ID generation helpers. - -# Expected: -# generate_mission_id() -> str # e.g. "mission_" -# generate_alert_id() -> str # e.g. "alert_" -# generate_event_id() -> str # e.g. "event_" diff --git a/src/robocoop_backend/robocoop_backend/utils/logger.py b/src/robocoop_backend/robocoop_backend/utils/logger.py deleted file mode 100644 index f5ed6d0..0000000 --- a/src/robocoop_backend/robocoop_backend/utils/logger.py +++ /dev/null @@ -1,8 +0,0 @@ -# TODO: implement structured logger wrapper. - -# Expected behavior: -# - wrap Python logging with consistent format: [LEVEL] [module] message -# - log to stdout + optional file sink -# - expose get_logger(name: str) -> Logger - -# TODO: log level configurable via common.params.yaml. diff --git a/src/robocoop_backend/robocoop_backend/utils/thread_safe_lock.py b/src/robocoop_backend/robocoop_backend/utils/thread_safe_lock.py deleted file mode 100644 index 50d65a4..0000000 --- a/src/robocoop_backend/robocoop_backend/utils/thread_safe_lock.py +++ /dev/null @@ -1,6 +0,0 @@ -# TODO: implement ThreadSafeLock context manager wrapper. - -# Expected behavior: -# - wrap threading.RLock -# - expose acquire/release via context manager (__enter__/__exit__) -# - log warning if lock held > threshold (e.g. 100ms) diff --git a/src/robocoop_backend/robocoop_backend/utils/time.py b/src/robocoop_backend/robocoop_backend/utils/time.py deleted file mode 100644 index c9d215e..0000000 --- a/src/robocoop_backend/robocoop_backend/utils/time.py +++ /dev/null @@ -1,6 +0,0 @@ -# TODO: implement time helpers. - -# Expected: -# now_utc() -> datetime # timezone-aware UTC datetime -# now_iso() -> str # ISO 8601 string for serialization -# elapsed_seconds(since: datetime) -> float diff --git a/src/robocoop_bringup/config/backend.params.yaml b/src/robocoop_bringup/config/backend.params.yaml deleted file mode 100644 index 857909f..0000000 --- a/src/robocoop_bringup/config/backend.params.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# TODO: define WebSocket server parameters. - -# Expected keys: -# host: "0.0.0.0" -# port: 8765 -# max_connections: 5 diff --git a/src/robocoop_bringup/config/m3pro_topics.yaml b/src/robocoop_bringup/config/m3pro_topics.yaml deleted file mode 100644 index 9afc628..0000000 --- a/src/robocoop_bringup/config/m3pro_topics.yaml +++ /dev/null @@ -1,10 +0,0 @@ -# TODO: map semantic names to real M3Pro topic names. -# Update after running: ros2 topic list on hardware. - -# candidates: -# cmd_vel: "/cmd_vel" -# odom: "/odom" -# scan: "/scan" -# imu: "/imu/data" -# battery: "/battery_state" -# camera: "/camera/image_raw" diff --git a/src/robocoop_bringup/config/security.params.yaml b/src/robocoop_bringup/config/security.params.yaml deleted file mode 100644 index 8ae69d3..0000000 --- a/src/robocoop_bringup/config/security.params.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# TODO: define security parameters. - -# Expected keys: -# auth_token: "" # override via env var ROBOCOOP_AUTH_TOKEN -# rate_limit_teleop: 50 # max teleop.move messages/second -# rate_limit_default: 10 # max other messages/second diff --git a/src/robocoop_bringup/config/sim.params.yaml b/src/robocoop_bringup/config/sim.params.yaml deleted file mode 100644 index b401728..0000000 --- a/src/robocoop_bringup/config/sim.params.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# TODO: Gazebo simulation parameters. - -# Expected keys: -# adapter: "sim" -# ros_domain_id: 0 -# gazebo_world: "hospital_corridor.world" From ab23cc5f03fff49c5db72465e77461413f54f4d0 Mon Sep 17 00:00:00 2001 From: damnthonyy Date: Tue, 19 May 2026 09:56:25 +0200 Subject: [PATCH 4/6] fix/refactor : - enhance battery level calcul - update tests - update readme Co-authored-by: Kelian --- README.md | 27 +++++++++++-------- .../adapters/rosbridge_adapter.py | 2 +- .../integration/test_telemetry_pipeline.py | 4 +-- .../unit/adapters/test_rosbridge_adapter.py | 16 +++++------ 4 files changed, 27 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index b5595da..3a8e551 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,22 @@ Expected response: {"type": "pong"} ``` -On connection, the server automatically pushes the current robot state and the last 50 audit events. See [`app/contracts.py`](src/robocoop_backend/robocoop_backend/app/contracts.py) for the full message reference. +> On connection, the server automatically pushes the current robot state and the last 50 audit events. See [`app/contracts.py`](src/robocoop_backend/robocoop_backend/app/contracts.py) for the full message reference. + + +## Connexion to the robot jetson + +```bash + ssh jetson@**.**.***.** # follow instructions and write [ Yes ] or press enter + jetson@**.**.***.**'s password : < password_value > +``` + +```bash + docker ps ## get into this container name : < m3pro > + docker exec -it m3pro /bon/bash ## for write command ( display topic list and whatever ) +``` + + ## Environment variables @@ -147,13 +162,3 @@ ROSBRIDGE_URL=ws://localhost:9090 pytest -m real -v ``` Tests live in `src/robocoop_backend/robocoop_backend/tests/`. CI runs automatically on push and pull requests via GitHub Actions (`.github/workflows/ci.yml`). - -## Roadmap - -| Phase | Goal | Status | -|---|---|---| -| 1 — Battery monitoring | `/battery` topic → dashboard | ✅ Done | -| 2 — Teleoperation | `teleop.move` → `/cmd_vel` publish | 🔜 Next | -| 3 — Navigation | `navigate_to` → Nav2 goal | 🔜 Planned | -| 4 — Full sensor suite | `/odom`, `/scan`, `/imu` | 🔜 Planned | -| 5 — Auth + rate limiting | WebSocket token auth | 🔜 Planned | diff --git a/src/robocoop_backend/robocoop_backend/adapters/rosbridge_adapter.py b/src/robocoop_backend/robocoop_backend/adapters/rosbridge_adapter.py index 8cbc463..13ce01e 100644 --- a/src/robocoop_backend/robocoop_backend/adapters/rosbridge_adapter.py +++ b/src/robocoop_backend/robocoop_backend/adapters/rosbridge_adapter.py @@ -60,7 +60,7 @@ def _on_battery_received(self, msg_data: Dict[str, Any]) -> None: battery_level = None if "data" in msg_data: voltage = float(msg_data["data"]) - battery_level = max(0, min(100, (voltage - 9.0) / (12.6 - 9.0) * 100)) + battery_level = max(0, min(100, voltage / 12.0 * 100)) logger.info(f"[BATTERY] {voltage:.2f}V -> {battery_level:.1f}%") elif "percentage" in msg_data: battery_level = float(msg_data["percentage"]) diff --git a/src/robocoop_backend/robocoop_backend/tests/integration/test_telemetry_pipeline.py b/src/robocoop_backend/robocoop_backend/tests/integration/test_telemetry_pipeline.py index b67ad59..51818dc 100644 --- a/src/robocoop_backend/robocoop_backend/tests/integration/test_telemetry_pipeline.py +++ b/src/robocoop_backend/robocoop_backend/tests/integration/test_telemetry_pipeline.py @@ -31,7 +31,7 @@ class TestBatteryFlow: def test_voltage_flows_to_state_store(self, pipeline): adapter, _, _, store = pipeline adapter._on_battery_received({"data": 10.8}) - assert store.get().battery_level == pytest.approx(50.0, abs=1.0) + assert store.get().battery_level == pytest.approx(90.0, abs=1.0) def test_battery_marks_robot_connected(self, pipeline): adapter, _, _, store = pipeline @@ -40,7 +40,7 @@ def test_battery_marks_robot_connected(self, pipeline): def test_low_battery_creates_audit_event(self, pipeline): adapter, _, audit, _ = pipeline - adapter._on_battery_received({"data": 5.0}) + adapter._on_battery_received({"data": 2.0}) actions = [e.action for e in audit._history] assert "battery.low" in actions diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/adapters/test_rosbridge_adapter.py b/src/robocoop_backend/robocoop_backend/tests/unit/adapters/test_rosbridge_adapter.py index 441ee8d..6bea9e9 100644 --- a/src/robocoop_backend/robocoop_backend/tests/unit/adapters/test_rosbridge_adapter.py +++ b/src/robocoop_backend/robocoop_backend/tests/unit/adapters/test_rosbridge_adapter.py @@ -17,26 +17,26 @@ def make_adapter(telemetry_service=None, battery_watchdog_timeout=15.0): @pytest.mark.unit class TestVoltageConversion: - def test_9v_maps_to_0_percent(self): + def test_9v_maps_to_75_percent(self): svc = MagicMock() adapter = make_adapter(telemetry_service=svc) adapter._on_battery_received({"data": 9.0}) call_data = svc.on_telemetry_received.call_args[0][0] - assert call_data["battery_level"] == pytest.approx(0.0, abs=0.1) + assert call_data["battery_level"] == pytest.approx(75.0, abs=0.1) - def test_12_6v_maps_to_100_percent(self): + def test_12v_maps_to_100_percent(self): svc = MagicMock() adapter = make_adapter(telemetry_service=svc) - adapter._on_battery_received({"data": 12.6}) + adapter._on_battery_received({"data": 12.0}) call_data = svc.on_telemetry_received.call_args[0][0] assert call_data["battery_level"] == pytest.approx(100.0, abs=0.1) - def test_10_8v_maps_to_50_percent(self): + def test_10_8v_maps_to_90_percent(self): svc = MagicMock() adapter = make_adapter(telemetry_service=svc) adapter._on_battery_received({"data": 10.8}) call_data = svc.on_telemetry_received.call_args[0][0] - assert call_data["battery_level"] == pytest.approx(50.0, abs=0.5) + assert call_data["battery_level"] == pytest.approx(90.0, abs=0.5) def test_below_9v_clamped_to_0(self): svc = MagicMock() @@ -45,10 +45,10 @@ def test_below_9v_clamped_to_0(self): call_data = svc.on_telemetry_received.call_args[0][0] assert call_data["battery_level"] == 0.0 - def test_above_12_6v_clamped_to_100(self): + def test_above_12v_clamped_to_100(self): svc = MagicMock() adapter = make_adapter(telemetry_service=svc) - adapter._on_battery_received({"data": 99.0}) + adapter._on_battery_received({"data": 15.0}) call_data = svc.on_telemetry_received.call_args[0][0] assert call_data["battery_level"] == 100.0 From 10433c3391db2b137d5c4c5cf0320b8d18ebb49a Mon Sep 17 00:00:00 2001 From: damnthonyy Date: Tue, 19 May 2026 10:21:11 +0200 Subject: [PATCH 5/6] feat : add measure_ping( ) --- .../adapters/rosbridge_adapter.py | 27 +++++++++++++++++++ .../adapters/rosbridge_client.py | 25 +++++++++++++++++ .../robocoop_backend/app/contracts.py | 21 +++++++++++++-- .../modules/robot/state_store.py | 2 ++ 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/src/robocoop_backend/robocoop_backend/adapters/rosbridge_adapter.py b/src/robocoop_backend/robocoop_backend/adapters/rosbridge_adapter.py index 13ce01e..d92922c 100644 --- a/src/robocoop_backend/robocoop_backend/adapters/rosbridge_adapter.py +++ b/src/robocoop_backend/robocoop_backend/adapters/rosbridge_adapter.py @@ -19,13 +19,16 @@ def __init__( max_reconnect_attempts: int = 5, battery_topic: str = "/battery", battery_watchdog_timeout: float = 15.0, + ping_interval: float = 5.0, telemetry_service=None, ): self.battery_topic = battery_topic self.battery_watchdog_timeout = battery_watchdog_timeout + self.ping_interval = ping_interval self.telemetry_service = telemetry_service self._last_battery_time: Optional[datetime] = None self._watchdog_task: Optional[asyncio.Task] = None + self._ping_task: Optional[asyncio.Task] = None self._client = RosbridgeClient( url_primary=url_primary, url_secondary=url_secondary, @@ -41,9 +44,16 @@ async def connect(self) -> bool: return False await self._subscribe_battery() self._watchdog_task = asyncio.create_task(self._battery_watchdog()) + self._ping_task = asyncio.create_task(self._ping_loop()) return True async def disconnect(self) -> None: + if self._ping_task: + self._ping_task.cancel() + try: + await self._ping_task + except asyncio.CancelledError: + pass if self._watchdog_task: self._watchdog_task.cancel() try: @@ -98,10 +108,27 @@ def _notify_disconnected(self) -> None: def _on_bridge_reconnected(self) -> None: self._last_battery_time = None asyncio.create_task(self._subscribe_battery()) + if self._ping_task and self._ping_task.done(): + self._ping_task = asyncio.create_task(self._ping_loop()) def _on_bridge_disconnected(self) -> None: logger.error("rosbridge disconnected (max attempts reached)") self._notify_disconnected() + async def _ping_loop(self) -> None: + """Measure and report latency periodically.""" + try: + while True: + await asyncio.sleep(self.ping_interval) + if not self._client.is_connected(): + continue + ping_ms = await self._client.measure_ping() + if ping_ms is not None and self.telemetry_service: + self.telemetry_service.on_telemetry_received({"ping_ms": ping_ms}) + except asyncio.CancelledError: + pass + except Exception as e: + logger.error(f"Ping loop error: {e}") + def is_connected(self) -> bool: return self._client.is_connected() diff --git a/src/robocoop_backend/robocoop_backend/adapters/rosbridge_client.py b/src/robocoop_backend/robocoop_backend/adapters/rosbridge_client.py index 5f61f1a..763d531 100644 --- a/src/robocoop_backend/robocoop_backend/adapters/rosbridge_client.py +++ b/src/robocoop_backend/robocoop_backend/adapters/rosbridge_client.py @@ -123,3 +123,28 @@ async def _attempt_reconnect(self) -> None: self._on_reconnected() else: await self._attempt_reconnect() + + async def measure_ping(self) -> Optional[int]: + """Measure latency to rosbridge in milliseconds.""" + if not self._websocket or not self._is_connected: + return None + try: + import time + start = time.time() + ping_id = "ping_" + str(int(start * 1000)) + await self._websocket.send(json.dumps({"op": "ping", "id": ping_id})) + # Wait for pong response with timeout + async def wait_for_pong(): + async for message in self._websocket: + data = json.loads(message) + if data.get("op") == "pong" and data.get("id") == ping_id: + return int((time.time() - start) * 1000) # milliseconds + pong_task = asyncio.create_task(wait_for_pong()) + result = await asyncio.wait_for(pong_task, timeout=2.0) + return result + except asyncio.TimeoutError: + logger.warning("Ping timeout") + return None + except Exception as e: + logger.error(f"Ping error: {e}") + return None diff --git a/src/robocoop_backend/robocoop_backend/app/contracts.py b/src/robocoop_backend/robocoop_backend/app/contracts.py index 3a3c363..87b7537 100644 --- a/src/robocoop_backend/robocoop_backend/app/contracts.py +++ b/src/robocoop_backend/robocoop_backend/app/contracts.py @@ -35,6 +35,7 @@ "data": { "is_connected": false, "battery_level": 0.0, # 0–100 (%) + "ping_ms": 0, # latency en millisecondes "last_updated": "2025-05-18T12:00:00" } } @@ -53,10 +54,26 @@ } state_response (réponse à get_state) - {"type": "state_response", "data": { }} + { + "type": "state_response", + "data": { + "is_connected": false, + "battery_level": 0.0, + "ping_ms": 0, + "last_updated": "2025-05-18T12:00:00" + } + } robot_state_updated (push automatique sur chaque update telemetry) - {"type": "robot_state_updated", "data": { }} + { + "type": "robot_state_updated", + "data": { + "is_connected": false, + "battery_level": 0.0, + "ping_ms": 0, + "last_updated": "2025-05-18T12:00:00" + } + } activity_event (push automatique sur chaque événement audit) { diff --git a/src/robocoop_backend/robocoop_backend/modules/robot/state_store.py b/src/robocoop_backend/robocoop_backend/modules/robot/state_store.py index 707f878..cc6201a 100644 --- a/src/robocoop_backend/robocoop_backend/modules/robot/state_store.py +++ b/src/robocoop_backend/robocoop_backend/modules/robot/state_store.py @@ -10,12 +10,14 @@ class RobotState: is_connected: bool = False battery_level: float = 0.0 + ping_ms: int = 0 last_updated: datetime = field(default_factory=datetime.now) def to_dict(self) -> Dict[str, Any]: return { "is_connected": self.is_connected, "battery_level": self.battery_level, + "ping_ms": self.ping_ms, "last_updated": self.last_updated.isoformat(), } From 5b480144459c2f1aae4f00a449924d3c446a34c2 Mon Sep 17 00:00:00 2001 From: damnthonyy Date: Tue, 19 May 2026 10:39:36 +0200 Subject: [PATCH 6/6] feat: implement connection methods in RobotAdapter and MockRobotAdapter, update BackendContext for dependency injection Co-authored-by: Kelian --- .../robocoop_backend/adapters/base_adapter.py | 14 ++++++- .../robocoop_backend/adapters/mock_adapter.py | 8 ++++ .../robocoop_backend/app/backend_context.py | 42 +++++++------------ .../robocoop_backend/app/server.py | 3 +- .../robocoop_backend/tests/conftest.py | 8 ---- .../integration/test_context_lifecycle.py | 2 +- .../tests/unit/app/test_backend_context.py | 38 ++++++++--------- 7 files changed, 55 insertions(+), 60 deletions(-) diff --git a/src/robocoop_backend/robocoop_backend/adapters/base_adapter.py b/src/robocoop_backend/robocoop_backend/adapters/base_adapter.py index 52e68ab..13d0cf3 100644 --- a/src/robocoop_backend/robocoop_backend/adapters/base_adapter.py +++ b/src/robocoop_backend/robocoop_backend/adapters/base_adapter.py @@ -3,4 +3,16 @@ class RobotAdapter(ABC): @abstractmethod - def is_connected(self) -> bool: ... + async def connect(self) -> bool: + """Connect to the robot. Return True on success.""" + ... + + @abstractmethod + async def disconnect(self) -> None: + """Disconnect from the robot.""" + ... + + @abstractmethod + def is_connected(self) -> bool: + """Check if currently connected.""" + ... diff --git a/src/robocoop_backend/robocoop_backend/adapters/mock_adapter.py b/src/robocoop_backend/robocoop_backend/adapters/mock_adapter.py index b5f6d3f..c299832 100644 --- a/src/robocoop_backend/robocoop_backend/adapters/mock_adapter.py +++ b/src/robocoop_backend/robocoop_backend/adapters/mock_adapter.py @@ -5,5 +5,13 @@ class MockRobotAdapter(RobotAdapter): def __init__(self): self._is_connected = True + async def connect(self) -> bool: + """Mock connect always succeeds.""" + return True + + async def disconnect(self) -> None: + """Mock disconnect is a no-op.""" + pass + def is_connected(self) -> bool: return self._is_connected diff --git a/src/robocoop_backend/robocoop_backend/app/backend_context.py b/src/robocoop_backend/robocoop_backend/app/backend_context.py index e434821..e655dd7 100644 --- a/src/robocoop_backend/robocoop_backend/app/backend_context.py +++ b/src/robocoop_backend/robocoop_backend/app/backend_context.py @@ -1,5 +1,5 @@ import logging -from typing import Dict, Any, Optional +from typing import Dict, Any from robocoop_backend.adapters.factory import create_adapter from robocoop_backend.modules.audit.audit_logger import AuditLogger @@ -9,10 +9,11 @@ from robocoop_backend.modules.robot.telemetry_service import TelemetryService logger = logging.getLogger(__name__) -_instance: Optional["BackendContext"] = None class BackendContext: + """Dependency injection container for backend services.""" + def __init__(self, config: Dict[str, Any]): self.config = config self.robot_state_store = RobotStateStore() @@ -30,36 +31,21 @@ def __init__(self, config: Dict[str, Any]): logger.info(f"Adapter: {type(self.adapter).__name__}") async def connect(self) -> bool: - if hasattr(self.adapter, "connect"): + """Initialize and connect all services.""" + try: return await self.adapter.connect() - return True + except Exception as e: + logger.error(f"Connect error: {e}") + return False async def disconnect(self) -> None: - if hasattr(self.adapter, "disconnect"): - try: - await self.adapter.disconnect() - except Exception as e: - logger.error(f"Disconnect error: {e}") + """Gracefully shutdown all services.""" + try: + await self.adapter.disconnect() + except Exception as e: + logger.error(f"Disconnect error: {e}") 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 - - @staticmethod - def initialize(config: Dict[str, Any]) -> "BackendContext": - global _instance - if _instance is None: - _instance = BackendContext(config) - return _instance - - @staticmethod - def get_instance() -> "BackendContext": - global _instance - if _instance is None: - raise RuntimeError("BackendContext not initialized") - return _instance - - @staticmethod - def reset() -> None: - global _instance - _instance = None diff --git a/src/robocoop_backend/robocoop_backend/app/server.py b/src/robocoop_backend/robocoop_backend/app/server.py index 8dc1292..956f601 100644 --- a/src/robocoop_backend/robocoop_backend/app/server.py +++ b/src/robocoop_backend/robocoop_backend/app/server.py @@ -17,13 +17,12 @@ class RobocopServer: def __init__(self, config: Config): self.config = config - self.context: BackendContext = None + self.context = BackendContext(config.to_dict()) self.server = None self._shutdown_event = asyncio.Event() async def initialize(self) -> bool: try: - self.context = BackendContext.initialize(self.config.to_dict()) return await self.context.connect() except Exception as e: logger.error(f"Init error: {e}") diff --git a/src/robocoop_backend/robocoop_backend/tests/conftest.py b/src/robocoop_backend/robocoop_backend/tests/conftest.py index 60f39a0..cea6df0 100644 --- a/src/robocoop_backend/robocoop_backend/tests/conftest.py +++ b/src/robocoop_backend/robocoop_backend/tests/conftest.py @@ -2,20 +2,12 @@ import pytest -from robocoop_backend.app.backend_context import BackendContext from robocoop_backend.modules.audit.audit_logger import AuditLogger from robocoop_backend.modules.audit.audit_service import AuditService from robocoop_backend.modules.robot.state_store import RobotStateStore from robocoop_backend.modules.robot.telemetry_service import TelemetryService -@pytest.fixture(autouse=True) -def reset_backend_context(): - BackendContext.reset() - yield - BackendContext.reset() - - @pytest.fixture def state_store(): return RobotStateStore() diff --git a/src/robocoop_backend/robocoop_backend/tests/integration/test_context_lifecycle.py b/src/robocoop_backend/robocoop_backend/tests/integration/test_context_lifecycle.py index 4e13d76..2f80e0a 100644 --- a/src/robocoop_backend/robocoop_backend/tests/integration/test_context_lifecycle.py +++ b/src/robocoop_backend/robocoop_backend/tests/integration/test_context_lifecycle.py @@ -12,7 +12,7 @@ @pytest.mark.integration class TestContextLifecycle: async def test_mock_lifecycle_connect_disconnect(self): - ctx = BackendContext.initialize({"adapter_type": "mock"}) + ctx = BackendContext({"adapter_type": "mock"}) result = await ctx.connect() assert result is True await ctx.disconnect() diff --git a/src/robocoop_backend/robocoop_backend/tests/unit/app/test_backend_context.py b/src/robocoop_backend/robocoop_backend/tests/unit/app/test_backend_context.py index d5035fd..847f9ee 100644 --- a/src/robocoop_backend/robocoop_backend/tests/unit/app/test_backend_context.py +++ b/src/robocoop_backend/robocoop_backend/tests/unit/app/test_backend_context.py @@ -7,29 +7,27 @@ @pytest.mark.unit -class TestBackendContextSingleton: - def test_initialize_creates_instance(self): - ctx = BackendContext.initialize({"adapter_type": "mock"}) +class TestBackendContextInjection: + def test_init_creates_instance_with_dependencies(self): + ctx = BackendContext({"adapter_type": "mock"}) assert ctx is not None + assert ctx.robot_state_store is not None + assert ctx.audit_service is not None + assert ctx.telemetry_service is not None + assert ctx.adapter is not None - def test_get_instance_raises_before_initialize(self): - with pytest.raises(RuntimeError): - BackendContext.get_instance() - - def test_initialize_idempotent_returns_same_instance(self): - ctx1 = BackendContext.initialize({"adapter_type": "mock"}) - ctx2 = BackendContext.initialize({"adapter_type": "mock"}) - assert ctx1 is ctx2 - - def test_reset_clears_singleton(self): - BackendContext.initialize({"adapter_type": "mock"}) - BackendContext.reset() - with pytest.raises(RuntimeError): - BackendContext.get_instance() + def test_each_instance_has_separate_services(self): + """Verify no global state — each context is independent.""" + ctx1 = BackendContext({"adapter_type": "mock"}) + ctx2 = BackendContext({"adapter_type": "mock"}) + assert ctx1.robot_state_store is not ctx2.robot_state_store + assert ctx1.audit_service is not ctx2.audit_service + assert ctx1.adapter is not ctx2.adapter - def test_get_instance_returns_initialized_instance(self): - ctx = BackendContext.initialize({"adapter_type": "mock"}) - assert BackendContext.get_instance() is ctx + def test_telemetry_service_references_correct_dependencies(self): + ctx = BackendContext({"adapter_type": "mock"}) + assert ctx.telemetry_service.robot_state_store is ctx.robot_state_store + assert ctx.telemetry_service.audit_service is ctx.audit_service @pytest.mark.unit