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..3a8e551 100644
--- a/README.md
+++ b/README.md
@@ -1,164 +1,164 @@
-# 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 :
+```
+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 (development)
-- simulation (ROS2 + Gazebo)
-- real robot (Yahboom ROSMASTER)
+## Project structure
-## Architecture
+```
+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
+```
-
-

-
+## Setup
-## Execution Modes
+**Requirements:** Python 3.11+
-The backend supports three environments :
+```bash
+git clone
+cd robot-back
-### Mock Mode
+python -m venv .venv # or python3
+source .venv/bin/activate # macOS/Linux
+# .venv\Scripts\activate # Windows
-Simulated robot behavior.
+pip install -e src/robocoop_backend
+```
-Used for backend and dashboard development.
+Copy the environment file and configure it:
-### Simulation mode
-Connects to ROS2 simulation (Gazebo).
+```bash
+cp .env.example .env
+```
-Used to validate navigation and sensor integration.
+`.env` defaults to `ROBOCOOP_ENV=mock` — no robot needed to start.
-### Real mode
-Connects to the Yahboom ROSMASTER M3 Pro robot.
+## Running
+```bash
+# Mock mode (no robot required)
+ROBOCOOP_ENV=mock python -m robocoop_backend.app.server
-## Development Roadmap
+# Real robot (rosbridge must be running on the robot)
+ROBOCOOP_ENV=real python -m robocoop_backend.app.server
+```
-### Phase 1 - MVP
+Or use the launch script:
-#### Goal : Validate teleoperation and robot monitoring.
+```bash
+bash run_backend.sh mock
+bash run_backend.sh real
+```
-Features :
+Server starts on `ws://0.0.0.0:8765`.
-- dashboard communication
-- teleoperation
-- robot state monitoring
-- watchdog safety
-- mock adapter
+## Quick connection test
-### Phase 2 - Simulation
+Connect to `ws://localhost:8765` with any WebSocket client (Postman, WebSocket King, wscat) and send:
-#### Goal : Validate ROS2 integration and autonomous navigation.
+```json
+{"type": "ping"}
+```
-Features :
+Expected response:
-- ROS2 nodes
-- Gazebo simulation
-- Nav2 integration
-- telemetry bridge
+```json
+{"type": "pong"}
+```
-### Phase 3 - Real Robot
+> 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.
-#### Goal : Connect the backend to the Yahboom ROSMASTER M3 pro
-Features :
+## Connexion to the robot jetson
-- hardware topic mapping
-- real sensor telemetry
-- safety limits
-- emergency stop
+```bash
+ ssh jetson@**.**.***.** # follow instructions and write [ Yes ] or press enter
+ jetson@**.**.***.**'s password : < password_value >
+```
-### Phase 4 - Production features
+```bash
+ docker ps ## get into this container name : < m3pro >
+ docker exec -it m3pro /bon/bash ## for write command ( display topic list and whatever )
+```
-#### Goal : Improve reliability and traceability
-Features :
-- database
-- audit logs
-- authentication
-- mission history
-- monitoring
+## Environment variables
-## Repository Structure
+| Variable | Default | Description |
+|---|---|---|
+| `ROBOCOOP_ENV` | `mock` | Which config to load: `mock` or `real` |
+| `ROBOCOOP_CONFIG_DIR` | auto-detected | Path to the YAML config directory |
-```
-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
-```
+## Configuration
-## Running the backend
+YAML config lives in `src/robocoop_bringup/config/`. Two files are loaded on startup:
-### Requirements
+1. `common.params.yaml` — shared across all environments
+2. `{ROBOCOOP_ENV}.params.yaml` — environment-specific overrides
-- Python 3.11+
-- pip
+See [`src/robocoop_bringup/config/README.md`](src/robocoop_bringup/config/README.md) for details.
-### Setup
-```bash
-python -m venv .venv
-.venv\Scripts\activate # Windows
-source .venv/bin/activate # Linux / Mac
-pip install -r requirements.txt
-```
+## Testing
-### Start the server
```bash
-python src/robocoop_backend/robocoop_backend/app/server.py
-```
+# Install test dependencies
+pip install -e "src/robocoop_backend[test]"
-Server starts on `ws://localhost:8765`.
+# Run all tests (unit + integration)
+pytest -m "not real" -v
-### Test the connection
+# Unit tests only
+pytest -m unit -v
-Connect to `ws://localhost:8765` with any WebSocket client (Postman, WebSocket King…) and send:
-```json
-{"type": "ping"}
-```
+# Integration tests only
+pytest -m integration -v
-Expected response:
-```json
-{"type": "pong"}
+# Real rosbridge tests (requires a live rosbridge server)
+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`).
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..13d0cf3
--- /dev/null
+++ b/src/robocoop_backend/robocoop_backend/adapters/base_adapter.py
@@ -0,0 +1,18 @@
+from abc import ABC, abstractmethod
+
+
+class RobotAdapter(ABC):
+ @abstractmethod
+ 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/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..c299832
--- /dev/null
+++ b/src/robocoop_backend/robocoop_backend/adapters/mock_adapter.py
@@ -0,0 +1,17 @@
+from robocoop_backend.adapters.base_adapter import RobotAdapter
+
+
+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/adapters/rosbridge_adapter.py b/src/robocoop_backend/robocoop_backend/adapters/rosbridge_adapter.py
new file mode 100644
index 0000000..d92922c
--- /dev/null
+++ b/src/robocoop_backend/robocoop_backend/adapters/rosbridge_adapter.py
@@ -0,0 +1,134 @@
+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,
+ 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,
+ 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())
+ 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:
+ 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 / 12.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())
+ 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
new file mode 100644
index 0000000..763d531
--- /dev/null
+++ b/src/robocoop_backend/robocoop_backend/adapters/rosbridge_client.py
@@ -0,0 +1,150 @@
+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()
+
+ 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/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..e655dd7 100644
--- a/src/robocoop_backend/robocoop_backend/app/backend_context.py
+++ b/src/robocoop_backend/robocoop_backend/app/backend_context.py
@@ -1,17 +1,51 @@
-# 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
+
+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__)
+
+
+class BackendContext:
+ """Dependency injection container for backend services."""
+
+ 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:
+ """Initialize and connect all services."""
+ try:
+ return await self.adapter.connect()
+ except Exception as e:
+ logger.error(f"Connect error: {e}")
+ return False
+
+ async def disconnect(self) -> None:
+ """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
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..87b7537
--- /dev/null
+++ b/src/robocoop_backend/robocoop_backend/app/contracts.py
@@ -0,0 +1,114 @@
+"""
+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 (%)
+ "ping_ms": 0, # latency en millisecondes
+ "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": {
+ "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": {
+ "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)
+ {
+ "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..956f601 100644
--- a/src/robocoop_backend/robocoop_backend/app/server.py
+++ b/src/robocoop_backend/robocoop_backend/app/server.py
@@ -1,21 +1,92 @@
-# 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(config.to_dict())
+ self.server = None
+ self._shutdown_event = asyncio.Event()
+
+ async def initialize(self) -> bool:
+ try:
+ 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..cc6201a
--- /dev/null
+++ b/src/robocoop_backend/robocoop_backend/modules/robot/state_store.py
@@ -0,0 +1,44 @@
+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
+ 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(),
+ }
+
+
+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..cea6df0
--- /dev/null
+++ b/src/robocoop_backend/robocoop_backend/tests/conftest.py
@@ -0,0 +1,60 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+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 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..2f80e0a
--- /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({"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..51818dc
--- /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(90.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": 2.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..6bea9e9
--- /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_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(75.0, abs=0.1)
+
+ def test_12v_maps_to_100_percent(self):
+ svc = MagicMock()
+ adapter = make_adapter(telemetry_service=svc)
+ 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_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(90.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_12v_clamped_to_100(self):
+ svc = MagicMock()
+ adapter = make_adapter(telemetry_service=svc)
+ adapter._on_battery_received({"data": 15.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..847f9ee
--- /dev/null
+++ b/src/robocoop_backend/robocoop_backend/tests/unit/app/test_backend_context.py
@@ -0,0 +1,74 @@
+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 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_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_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
+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"