Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ WebSocket backend for the Robocoop medical assistance robot. Bridges the robot h

- Connects to the robot via **rosbridge** (WebSocket → ROS topics)
- Reads battery voltage from `/battery`, converts it to a percentage
- Runs an **RGB LED chenillard** on startup (`/rgb`, real env) — rainbow chase across 8 LEDs
- 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
Expand All @@ -31,7 +32,7 @@ Dashboard (frontend)
rosbridge server (ws://robot:9090)
ROS topics (/battery, ...)
ROS topics (/battery, /rgb, /cmd_vel, ...)
```

## Project structure
Expand All @@ -43,8 +44,9 @@ src/
app/ — server, websocket handler, contracts, DI context
adapters/ — robot adapter implementations + rosbridge client
modules/
robot/ — RobotState, RobotStateStore, TelemetryService
robot/ — RobotState, RobotStateStore, TelemetryService, RgbChenillard
audit/ — AuditEvent, AuditService, sinks (console, file)
scripts/ — standalone utilities (rgb chenillard test)
utils/ — Config loader (.env + YAML)
robocoop_bringup/
config/ — YAML config files per environment
Expand Down Expand Up @@ -95,6 +97,17 @@ bash run_backend.sh real

Server starts on `ws://0.0.0.0:8765`.

In **real** mode, once rosbridge is connected, the backend starts the RGB chenillard automatically (`rgb.enabled: true` in `real.params.yaml`). Logs should show `RGB chenillard started on /rgb`.

**Standalone LED test** (rosbridge only, no full backend):

```bash
./scripts/rgb_chenillard.sh
# or: ROBOCOOP_ENV=real ./scripts/rgb_chenillard.sh
```

> Do not use `ros2 topic pub` from the dev laptop unless ROS2 is on the same DDS network as the robot. This project publishes `/rgb` through **rosbridge**, like `/battery` and `/cmd_vel`.

## Quick connection test

Connect to `ws://localhost:8765` with any WebSocket client (Postman, WebSocket King, wscat) and send:
Expand Down Expand Up @@ -132,6 +145,7 @@ Expected response:
|---|---|---|
| `ROBOCOOP_ENV` | `mock` | Which config to load: `mock` or `real` |
| `ROBOCOOP_CONFIG_DIR` | auto-detected | Path to the YAML config directory |
| `ROBOCOOP_RGB_CHENILLARD` | _(from YAML)_ | Override LED chenillard: `1` on, `0` off (ignores `rgb.enabled`) |

## Configuration

Expand Down
46 changes: 46 additions & 0 deletions docs/topics.jetson.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
```bash
/arm6_joints
/arm_joint
/battery
/beep
/camera/color/camera_info
/camera/color/image_raw
/camera/color/image_raw/compressed
/camera/color/image_raw/compressedDepth
/camera/color/image_raw/theora
/camera/depth/camera_info
/camera/depth/image_raw
/camera/depth/image_raw/compressed
/camera/depth/image_raw/compressedDepth
/camera/depth/image_raw/theora
/camera/depth/points
/camera/depth_filter_status
/camera/depth_registered/points
/camera/depth_to_color
/camera/depth_to_ir
/camera/ir/camera_info
/camera/ir/image_raw
/camera/ir/image_raw/compressed
/camera/ir/image_raw/compressedDepth
/camera/ir/image_raw/theora
/camera/rgb/image_raw/compressed
/client_count
/cmd_vel
/connected_clients
/diagnostics
/imu/data
/imu/data_raw
/joint_states
/odom
/odom_raw
/parameter_events
/rgb
/robot_description
/rosout
/scan0
/scan1
/set_pose
/smoother_server/transition_event
/tf
/tf_static
```
2 changes: 2 additions & 0 deletions run_backend.sh
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export ROBOCOOP_ENV="$ENVIRONMENT"
export ROBOCOOP_CONFIG_DIR="$CONFIG_DIR"
export PYTHONPATH="$BACKEND_DIR:$PYTHONPATH"

# Chenillard RGB : géré par le backend via rosbridge (rgb.enabled dans real.params.yaml)

# Run server
cd "$BACKEND_DIR"
python3 -m robocoop_backend.app.server
16 changes: 16 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# scripts/

Utility scripts at the repo root. Not part of the Python package install.

## `rgb_chenillard.sh`

Runs the RGB LED rainbow chase **via rosbridge only** (no `ros2` CLI on the laptop).

```bash
./scripts/rgb_chenillard.sh
ROBOCOOP_ENV=real ./scripts/rgb_chenillard.sh
```

Uses `src/robocoop_bringup/config/` (`common.params.yaml` + `{ROBOCOOP_ENV}.params.yaml`) for the rosbridge URL and `rgb.*` settings.

For normal operation, prefer `bash run_backend.sh real` — the chenillard is started automatically inside `RosbridgeRobotAdapter` when `rgb.enabled: true`.
16 changes: 16 additions & 0 deletions scripts/rgb_chenillard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/bin/bash
# Chenillard RGB autonome via rosbridge (sans ros2 CLI).
# Utile pour tester ; en production, utiliser run_backend.sh real.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CONFIG_DIR="${ROBOCOOP_CONFIG_DIR:-$REPO_ROOT/src/robocoop_bringup/config}"
ENVIRONMENT="${ROBOCOOP_ENV:-real}"

export ROBOCOOP_CONFIG_DIR="$CONFIG_DIR"
export ROBOCOOP_ENV="$ENVIRONMENT"
export PYTHONPATH="$REPO_ROOT/src/robocoop_backend:${PYTHONPATH:-}"

exec python3 -m robocoop_backend.scripts.rgb_chenillard_standalone
5 changes: 4 additions & 1 deletion src/robocoop_backend/robocoop_backend.egg-info/SOURCES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,8 @@ 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/rgb_chenillard.py
robocoop_backend/modules/robot/state_store.py
robocoop_backend/modules/robot/telemetry_service.py
robocoop_backend/modules/robot/telemetry_service.py
robocoop_backend/scripts/__init__.py
robocoop_backend/scripts/rgb_chenillard_standalone.py
50 changes: 37 additions & 13 deletions src/robocoop_backend/robocoop_backend/adapters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ This layer is the **only part of the codebase that talks to the robot**. Everyth
| `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_adapter.py` | Real adapter. Subscribes to ROS topics, converts messages, notifies services; starts RGB chenillard on connect |
| `rosbridge_client.py` | Pure WebSocket transport for the rosbridge protocol. No business logic |

## Two-layer design
Expand Down Expand Up @@ -118,24 +118,48 @@ That's it. `TelemetryService.on_telemetry_received()` already handles any dict g

---

## RGB LED chenillard (publish only)

The adapter publishes `/rgb` at connect when `rgb.enabled` is true (see `real.params.yaml`). Logic lives in `modules/robot/rgb_chenillard.py`; `factory.py` passes topic, message type, and timing from config.

```
rosbridge_adapter.connect()
→ RgbChenillard.start() # asyncio loop: hue sweep + LED index 1→7
→ _client.publish("/rgb", "std_msgs/msg/ColorRGBA", {r, g, b, a})

rosbridge_adapter.disconnect()
→ RgbChenillard.stop() # publishes {r:0, g:0, b:0, a:0} (all off)
```

Wired in `factory.py`:

```python
rgb_cfg = config.get("rgb", {})
return RosbridgeRobotAdapter(
...
rgb_chenillard_enabled=_rgb_chenillard_enabled(rgb_cfg), # env ROBOCOOP_RGB_CHENILLARD overrides
rgb_topic=topics.get("rgb", "/rgb"),
rgb_msg_type=msg_types.get("rgb", "std_msgs/msg/ColorRGBA"),
...
)
```

**Important:** publish from the backend goes through **rosbridge WebSocket**, not `ros2 topic pub` on the dev machine (unless that machine shares the robot's ROS domain).

---

## 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
### Step 1 — Use `rosbridge_client.publish()`

`RosbridgeClient.publish()` is already implemented. From the adapter, schedule or await it:

`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}")
await self._client.publish(topic, msg_type, msg)
# or from a sync handler:
self._schedule_publish(topic, msg_type, msg) # creates asyncio task
```

### Step 2 — Implement the method in the adapter
Expand Down
12 changes: 12 additions & 0 deletions src/robocoop_backend/robocoop_backend/adapters/base_adapter.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import logging
from abc import ABC, abstractmethod
from typing import Any, Dict

logger = logging.getLogger(__name__)


class RobotAdapter(ABC):
Expand All @@ -16,3 +20,11 @@ async def disconnect(self) -> None:
def is_connected(self) -> bool:
"""Check if currently connected."""
...

def send_velocity(self, data: Dict[str, Any]) -> None:
"""Publish cmd_vel from teleop.move data. Override in subclasses."""
logger.debug("send_velocity not implemented for %s", type(self).__name__)

def emergency_stop(self) -> None:
"""Trigger emergency stop. Override in subclasses."""
logger.debug("emergency_stop not implemented for %s", type(self).__name__)
38 changes: 36 additions & 2 deletions src/robocoop_backend/robocoop_backend/adapters/factory.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import os

from robocoop_backend.adapters.base_adapter import RobotAdapter
from robocoop_backend.adapters.mock_adapter import MockRobotAdapter
Expand All @@ -7,6 +8,15 @@
logger = logging.getLogger(__name__)


def _rgb_chenillard_enabled(rgb_cfg: dict) -> bool:
override = os.environ.get("ROBOCOOP_RGB_CHENILLARD", "").lower()
if override in ("0", "false", "no", "off"):
return False
if override in ("1", "true", "yes", "on"):
return True
return bool(rgb_cfg.get("enabled", False))


def create_adapter(

adapter_type: str,
Expand All @@ -17,20 +27,44 @@ def create_adapter(
logger.info(f"Creating adapter: {adapter_type}")

if adapter_type == "mock":
return MockRobotAdapter()
return MockRobotAdapter(telemetry_service=telemetry_service)

if adapter_type == "rosbridge":
rb = config.get("rosbridge", {})
topics = rb.get("topics", {})
msg_types = config.get("rosbridge_msg_types", {})
teleop = config.get("teleop", {})
watchdog_timeout = config.get(
"battery_watchdog_timeout_seconds",
rb.get("battery_watchdog_timeout_seconds", 15.0),
)
rgb_cfg = config.get("rgb", {})
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),
battery_msg_type=msg_types.get("battery", "std_msgs/msg/Float32"),
cmd_vel_topic=topics.get("cmd_vel", "/cmd_vel"),
cmd_vel_msg_type=msg_types.get("cmd_vel", "geometry_msgs/msg/Twist"),
emergency_stop_topic=topics.get("emergency_stop", "/emergency_stop"),
emergency_stop_msg_type=msg_types.get("emergency_stop", "std_msgs/msg/Bool"),
odom_topic=topics.get("odom", "/odom"),
odom_msg_type=msg_types.get("odom", "nav_msgs/msg/Odometry"),
max_linear_x=float(teleop.get("max_linear_x", 0.5)),
max_angular_z=float(teleop.get("max_angular_z", 1.0)),
battery_watchdog_timeout=float(watchdog_timeout),
telemetry_service=telemetry_service,
rgb_chenillard_enabled=_rgb_chenillard_enabled(rgb_cfg),
rgb_topic=topics.get("rgb", rgb_cfg.get("topic", "/rgb")),
rgb_msg_type=msg_types.get("rgb", "std_msgs/msg/ColorRGBA"),
rgb_frame_delay=float(rgb_cfg.get("frame_delay_seconds", 0.07)),
rgb_hue_step=float(rgb_cfg.get("hue_step", 15)),
rgb_brightness=float(rgb_cfg.get("brightness", 0.85)),
rgb_led_min=int(rgb_cfg.get("led_min", 1)),
rgb_led_max=int(rgb_cfg.get("led_max", 7)),
)

raise ValueError(f"Unknown adapter type: '{adapter_type}'. Supported: mock, rosbridge")
16 changes: 15 additions & 1 deletion src/robocoop_backend/robocoop_backend/adapters/mock_adapter.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import logging
from typing import Any, Dict

from robocoop_backend.adapters.base_adapter import RobotAdapter

logger = logging.getLogger(__name__)


class MockRobotAdapter(RobotAdapter):
def __init__(self):
def __init__(self, telemetry_service=None):
self._is_connected = True
self.telemetry_service = telemetry_service

async def connect(self) -> bool:
"""Mock connect always succeeds."""
Expand All @@ -15,3 +21,11 @@ async def disconnect(self) -> None:

def is_connected(self) -> bool:
return self._is_connected

def send_velocity(self, data: Dict[str, Any]) -> None:
linear_x = data.get("linear_x", data.get("linear", 0.0))
angular_z = data.get("angular_z", data.get("angular", 0.0))
logger.info("[MOCK] teleop.move linear_x=%s angular_z=%s", linear_x, angular_z)

def emergency_stop(self) -> None:
logger.warning("[MOCK] emergency_stop")
Loading
Loading