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 +``` -
- Diagramme de structure -
+## 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"