Metadata-Version: 2.4
Name: ai-teammate-ros2-bridge
Version: 2.6.62
Summary: AI Teammate ROS2 Device Bridge
Author: MobioLabs
License: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: websockets
Requires-Dist: psutil
Requires-Dist: scipy
Requires-Dist: paho-mqtt
Requires-Dist: PyYAML
Requires-Dist: python-socks
Requires-Dist: lz4>=4.0.0
Requires-Dist: zeroconf>=0.130
Requires-Dist: brotli>=1.2.0
Requires-Dist: cryptography>=42
Provides-Extra: qr
Requires-Dist: pyzbar>=0.1.9; extra == "qr"
Requires-Dist: Pillow>=10.0; extra == "qr"

# AI Teammate Robot Bridge

[![PyPI](https://img.shields.io/pypi/v/ai-teammate-ros2-bridge)](https://pypi.org/project/ai-teammate-ros2-bridge/)
[![Python](https://img.shields.io/pypi/pyversions/ai-teammate-ros2-bridge)](https://pypi.org/project/ai-teammate-ros2-bridge/)

Connects ROS2 robots to the [AI Teammate](https://ai-teammate.net) platform via WebSocket + MQTT.

## Features

- **rclpy native** — Direct DDS participation, no rosbridge required
- **Dual telemetry** — MQTT (primary, 1Hz) + WebSocket (fallback)
- **Auto-detection** — cmd_vel, camera, IMU, LiDAR topics discovered at startup
- **Camera streaming** — RealSense / USB camera auto-detect, on-demand JPEG streaming
- **SLAM mapping** — Trajectory collection (5Hz, 5cm spacing) + WiFi fingerprint auto-collection
- **WiFi fingerprinting** — Save/localize/list fingerprints with mobile AP blocklist
- **Plugin architecture** — Optional [AMR Skills](https://ai-teammate.net) for 54+ robot commands
- **Auto-reconnect** — WebSocket + MQTT reconnection with exponential backoff
- **Simulator mode** — Works without ROS2 for development/testing
- **systemd ready** — Auto-restart, boot start with linger

## Quick Install (Recommended)

```bash
curl -sL "https://ai-teammate.net/api/b2b/install/MY-ROBOT-01?key=YOUR_API_KEY" | bash
```

This single command will:
1. Detect ROS2 and auto-configure topics
2. Install bridge from PyPI + AMR skills (pre-built binary)
3. Configure MQTT telemetry
4. Register device with cloud gateway
5. Create systemd service (auto-restart + boot start)

Get your API key from [Control Tower](https://ai-teammate.net/control-tower) > Devices > + button.

## Step-by-Step Setup Guide

### Prerequisites

- Robot PC: **Ubuntu 22.04** + **ROS2 Humble** (or Jazzy)
- Robot hardware bringup running (motors, LiDAR, IMU topics publishing)
- Internet connection (WSS + MQTT to ai-teammate.net)

### Step 1. Get API Key

1. Open [Control Tower](https://ai-teammate.net/control-tower)
2. Go to **Devices** tab > click **+** button
3. Enter a Device ID (e.g. `my-robot-01`) and copy the generated API Key (`dk_...`)

### Step 2. Run Install Script

SSH into the robot and run:

```bash
curl -sL "https://ai-teammate.net/api/b2b/install/my-robot-01?key=dk_YOUR_API_KEY" | bash
```

The script automatically:
- Detects ROS2 and discovers topics (cmd_vel, odom, imu, lidar, camera)
- Installs bridge from PyPI + optional AMR skills binary
- Generates `.env` and `sensors.yaml` with auto-detected topic names
- Configures MQTT telemetry
- Registers device with cloud gateway
- Sets up sudoers for shutdown/reboot (NOPASSWD)
- Creates systemd service with boot-start enabled

For networks with a proxy:
```bash
curl -sL "..." | bash -s -- --proxy=http://proxy:8080
```

### Step 3. Verify Installation

```bash
# Check service status
systemctl --user status ai-teammate-bridge

# Watch live logs
tail -f ~/ai-teammate-bridge/bridge.log

# Review detected config
cat ~/ai-teammate-bridge/.env
cat ~/ai-teammate-bridge/sensors.yaml
```

### Step 4. Verify in Control Tower

1. Open [Control Tower](https://ai-teammate.net/control-tower) > **Devices** tab
2. Robot should show as **connected** with green indicator
3. Click the robot > **Health** section should show IMU, odom, lidar as OK
4. **SLAM** tab should display sensor data

### Troubleshooting

| Symptom | Cause | Fix |
|---------|-------|-----|
| `sensor: no data (never received)` | Topic name mismatch | Run `ros2 topic list` to find actual names, update `~/ai-teammate-bridge/.env`, then `systemctl --user restart ai-teammate-bridge` |
| SHUTDOWN/REBOOT not working | Missing sudoers | `echo "$USER ALL=(ALL) NOPASSWD: /usr/sbin/shutdown, /usr/sbin/reboot" \| sudo tee /etc/sudoers.d/ai-teammate-bridge && sudo chmod 440 /etc/sudoers.d/ai-teammate-bridge` |
| Bridge not starting after reboot | Linger not enabled | `loginctl enable-linger $USER` |
| WS connection failed | Firewall/proxy | Test with `curl https://ai-teammate.net/api/health`, use `--proxy` flag if needed |
| `ros2: command not found` in service | ROS2 env not sourced | Ensure `/etc/env.sh` exists and sources ROS2 setup, or add `source /opt/ros/humble/setup.bash` to service ExecStart |

### Manual Topic Override

If auto-detection picks the wrong topic:

```bash
# Check what's actually publishing
ros2 topic list | grep -E 'odom|imu|scan|cmd_vel'

# Edit .env
vi ~/ai-teammate-bridge/.env
# Example: ODOM_TOPIC=/base_controller/odom

# Restart
systemctl --user restart ai-teammate-bridge
```

Common non-standard topic names:

| Sensor | Standard | Variants |
|--------|----------|----------|
| Odometry | `/odom` | `/base_controller/odom`, `/odometry/filtered`, `/wheel/odometry` |
| IMU | `/imu/data` | `/imu/data_raw`, `/camera/imu`, `/front_lidar/imu` |
| LiDAR | `/scan` | `/scan_filtered`, `/front_lidar/scan` |
| cmd_vel | `/cmd_vel` | `/base_controller/cmd_vel_unstamped`, `/cmd_vel_mux/input/teleop` |

## Manual Install

```bash
pip3 install ai-teammate-ros2-bridge
```

### Configuration

```bash
mkdir -p ~/ai-teammate-bridge && cd ~/ai-teammate-bridge

ai-teammate-bridge init
# → Creates .env with interactive prompts

# Or manually:
cat > .env << EOF
DEVICE_ID=my-robot-01
GATEWAY_URL=wss://ai-teammate.net/gw
API_KEY=dk_YOUR_API_KEY
CONNECTION_MODE=rclpy
STATUS_REPORT_INTERVAL=1.0
CMD_VEL_TOPIC=/cmd_vel
IMU_TOPIC=/imu/data
ODOM_TOPIC=/odom
SCAN_TOPIC=/scan
MQTT_BROKER=ai-teammate.net
MQTT_PORT=1883
# MQTT credentials: provisioned by the one-line installer
# (or ask ops). Never commit real credentials to docs.
MQTT_USER=<ask-ops>
MQTT_PASS=<ask-ops>
EOF
```

### Run

```bash
# Source ROS2 environment first
source /opt/ros/humble/setup.bash

ai-teammate-bridge
```

## Environment Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `DEVICE_ID` | *required* | Unique device identifier |
| `GATEWAY_URL` | *required* | Device Gateway WebSocket URL |
| `API_KEY` | *required* | Device API key (`dk_xxx`) |
| `CONNECTION_MODE` | `rclpy` | `rclpy` for ROS2, `simulator` for testing |
| `STATUS_REPORT_INTERVAL` | `1.0` | Sensor update interval (seconds) |
| `CMD_VEL_TOPIC` | `/cmd_vel` | ROS2 velocity command topic |
| `IMU_TOPIC` | `/imu/data` | IMU subscription topic |
| `ODOM_TOPIC` | `/odom` | Odometry subscription topic |
| `SCAN_TOPIC` | `/scan` | LiDAR scan topic |
| `MQTT_BROKER` | *(empty=disabled)* | MQTT broker hostname |
| `MQTT_PORT` | `1883` | MQTT broker port |
| `MQTT_USER` | `robot_bridge` | MQTT username |
| `MQTT_PASS` | *(empty)* | MQTT password |
| `MQTT_TRANSPORT` | `tcp` | `tcp` or `websockets` |
| `MQTT_TLS` | *(off)* | `1` to wrap the connection in TLS |
| `MQTT_WS_PATH` | `/mqtt` | WebSocket path (only for `websockets`) |
| `COORD_ENABLED` | *(off)* | `1`/`true`/`yes`/`on` to obey fleet coordination hold leases |
| `COORD_DEGRADED_SPEED` | `0.15` | Forward cap (m/s) while no valid lease is held |
| `FLEET_TENANT` | *(empty)* | Tenant namespace for the **coordination** topic — not the same as `FLEET_ID`, see below |
| `FLEET_ID` | *(from gateway)* | Fleet id for the **broadcast e-stop** topic — not the same as `FLEET_TENANT`, see below |
| `MAP_INSTALL_ROOT` | `~/maps/installed` | Where `MAP_INSTALL` keeps installed map versions, the `current`/`previous` pointers and the receipt journal. Commands cannot override it |
| `MAP_INSTALL_ALLOWED_HOST_SUFFIXES` | *(empty = any https host)* | Comma-separated host suffixes the map bundle may be downloaded from (e.g. `.amazonaws.com`). IP-literal hosts, `localhost`, non-https and redirects are always refused |

### Fleet coordination (교차로 hold lease)

Off by default. When `COORD_ENABLED` is on, the bridge subscribes to
`fleet/<FLEET_TENANT>/coordination/hold/<DEVICE_ID>` and turns the leases it
receives into one forward-speed cap, applied both in-process and in the Nav2
`cmd_vel` relay.

This is **traffic management, not safety** — the edge hard-stop (`safety.py`)
runs after it and is never weakened by it. Three states: a live `clear` lease
uncaps, a live `hold` lease stops the robot, and *no valid lease* means
`COORD_DEGRADED_SPEED` — "if you don't know, go slow". That fallback is
deliberately not a full stop, so a dead cloud cannot brick the fleet; values
of `0` or below are rejected at startup (logged, replaced by the default).

`FLEET_TENANT` must match what the coordination service publishes under, or
the robot never receives its leases and stays capped at the degraded speed.

> **`FLEET_TENANT` and `FLEET_ID` are different identifiers.** They look alike,
> they both namespace a `fleet/...` topic, and setting one to the other's value
> fails in a way that is hard to trace:
>
> | | topic | carries | if wrong |
> |---|---|---|---|
> | `FLEET_TENANT` | `fleet/<tenant>/coordination/hold/<DEVICE_ID>` | traffic (hold/clear leases) | robot silently never receives a lease and just crawls at `COORD_DEGRADED_SPEED` — **no error anywhere** |
> | `FLEET_ID` | `fleet/<id>/broadcast/stop` | **emergency stop** | robot does not receive broadcast e-stop |
>
> They also default differently. `FLEET_ID` falls back to the gateway-supplied
> `project_id`/`fleet_id` (`ws_client._subscribe_fleet_topic`), so it usually
> works without being set — which is exactly why someone reaches for that same
> value when filling in `FLEET_TENANT`. `FLEET_TENANT` has **no** fallback: unset
> means the legacy un-namespaced topic, and any non-matching value means silence.
> Coordination silence is never read as permission (that is the point of the
> degraded fallback), so the symptom is a fleet that mysteriously crawls, not one
> that errors.

> **Release ordering — bridge before skills.** Robots update the skills package
> by extracting a `.so` tarball straight into `site-packages`, which bypasses
> pip entirely, so the bridge floor declared in `ai-teammate-ros2-skills`'
> `pyproject.toml` is **not enforced on the robot**. A skills build that
> imports a symbol this bridge lacks fails at import, and the failure disables
> *every* skill command, not just the new one. So: publish and roll out the
> bridge first, confirm robots picked it up, and only then publish skills. The
> auto-updater enforces the same order per cycle (`_defer_skills_update`), and
> skills carries a fallback for the coordination import specifically, but
> neither removes the need to ship in this order.

### MQTT over WebSocket (broker behind a CDN)

Raw TCP MQTT cannot be proxied by a CDN, so a broker that sits behind one is only
reachable over `wss` on 443. Point the bridge at it like this — the robot then needs
a single outbound 443, which most site firewalls already allow:

```
MQTT_BROKER=mqtt.example.com
MQTT_PORT=443
MQTT_TRANSPORT=websockets
MQTT_TLS=1
```

The default stays plain TCP on 1883, so bridges already in the field keep working
until their env is changed.

## Architecture

```
Robot                              Cloud (ai-teammate.net)
┌──────────────────────┐          ┌─────────────────────┐
│  ai-teammate-bridge  │          │  Device Gateway      │
��  (CLI entry point)   │          │  (port 8003)         │
│                      │          │                      │
│  ���────────────────┐  │  WSS    │  /ws/{device_id}     │
│  │  ws_client.py  │──┼────────▶│    commands ◀──────  │──▶ Control Tower
│  │  DeviceBridge   │◀─┼────────│    responses          │
│  └────────────────┘  │          │                      │
│                      │  MQTT   │  Mosquitto (1883)    │
│  ┌────────────────┐  │────────▶│    telemetry          │
│  │  MQTT publisher │  │         │    position           │
│  └────────────────┘  │         │    health              │
│                      │          └─────────────────────┘
│  ┌────────────────┐  │
│  │  ros2_node.py  │  │  ROS2 DDS (no rosbridge)
│  │  ├─ cmd_vel pub│  │
│  │  ├─ IMU sub    │  │
│  │  ├─ odom sub   │  │
│  │  ├─ scan sub   │  │
│  │  ├─ camera sub │  │  on-demand (auto-stop 30s)
│  │  └─ TF listener│  │  map→base_footprint (5Hz)
│  └────────────────┘  │
│                      │
│  ┌────────────────┐  │
│  │  Skills (opt)  │  │  ai-teammate-ros2-skills
│  │  54+ commands  │  │  (pre-built .so binary)
│  └────────────────┘  │
└──────────────────────┘
```

### Communication Channels

| Channel | Data | Direction | Frequency |
|---------|------|-----------|-----------|
| **WebSocket** | Commands, responses | Bidirectional | On-demand |
| **WebSocket** | Camera frames | Robot → Cloud | 2-5 FPS (on-demand) |
| **WebSocket** | SLAM map + trajectory | Robot → Cloud | Every 5s during mapping |
| **MQTT** | Telemetry (IMU, battery) | Robot → Cloud | 1 Hz |
| **MQTT** | Position (x, y, theta) | Robot → Cloud | 1 Hz (retained) |
| **MQTT** | Health alerts | Robot → Cloud | On-event (retained) |

## Package Structure

```
ai_teammate_ros2_bridge/
├── cli.py            # Entry point: ai-teammate-bridge command
├── config.py         # Shared state (_ros2_cache), env loading, MQTT config
├── ws_client.py      # DeviceBridge class, WS + MQTT event loop, health checks
├── device_bridge.py  # Command handlers (SLAM, camera, nav, fingerprint, etc.)
├── ros2_node.py      # rclpy spin thread, ROS2 subscriptions, TF listener
├── sensors.py        # Sensor data aggregation, battery fallback
├── sensors_config.py # Hardware abstraction (sensors.yaml)
├── lan_server.py     # LAN WebSocket server for local clients
└── utils.py          # Input sanitization helpers
```

## Systemd Service

The install script creates a systemd service automatically. For user-level services, the unit uses `After=basic.target` (not `network-online.target`, which is unavailable in user scope).

```bash
# System-level (if sudo available)
sudo systemctl status ai-teammate-bridge
sudo systemctl restart ai-teammate-bridge
sudo journalctl -u ai-teammate-bridge -f

# User-level (no sudo, with loginctl enable-linger)
systemctl --user status ai-teammate-bridge
systemctl --user restart ai-teammate-bridge
```

## Command Dispatch

Commands from the cloud are dispatched in two tiers:

1. **Bridge-first commands** — Camera commands (`CAPTURE_IMAGE`, `CAMERA_CAPTURE`, `START_CAMERA_STREAM`, `STOP_CAMERA_STREAM`) always go to `device_bridge.py` first because they require access to the shared `config._ros2_cache`. `GET_STATUS`, `UPDATE_BRIDGE`, `MAP_INSTALL` and `MAP_INSTALL_STATUS` are bridge-first too: the bridge owns that state, and a same-named handler in the skills plugin would silently shadow it.
2. **Skills-first** — All other commands try the optional `ai-teammate-ros2-skills` plugin first. If the plugin returns `unknown_command`, the bridge falls back to `device_bridge.py`.

### SLAM Commands

| Command | Description |
|---------|-------------|
| `SLAM_START` | Launch slam_toolbox, begin trajectory + WiFi fingerprint collection |
| `SLAM_STOP` | Stop SLAM, return trajectory/fingerprint counts |
| `SLAM_SAVE` | Save map, auto-link fingerprints to nearby locations, detect mobile APs |

During SLAM mapping, the bridge automatically:
- Collects trajectory points at 5Hz with 5cm minimum spacing (via TF listener)
- Scans WiFi fingerprints every 5s with 50cm minimum displacement
- On `SLAM_SAVE`: links fingerprints to saved locations within 1.5m as `"predicted"` source
- Detects mobile APs (same BSSID at distant locations) and adds them to `wifi_blocklist.json`

### WiFi Fingerprint Commands

| Command | Description |
|---------|-------------|
| `SAVE_FINGERPRINT` | Save WiFi scan at current pose. `source`: `verified` (default) or `predicted` |
| `LIST_FINGERPRINTS` | List all saved fingerprints with pose and AP count |
| `LOCALIZE_FINGERPRINT` | Match current WiFi scan against saved fingerprints (filters blocked BSSIDs) |

### Location Commands

| Command | Description |
|---------|-------------|
| `SAVE_LOCATION` | Save named location. Supports explicit `x`, `y` params (map pin) or uses current robot pose |

### Map Install Commands

| Command | Description |
|---------|-------------|
| `MAP_INSTALL` | Install a published map version. Params: `installation` (the map-api `MapInstallationResponse`, verbatim) and `raw_artifact` (the manifest's `raw_artifact`, verbatim). Returns immediately with `state: accepted` and runs in the background |
| `MAP_INSTALL_STATUS` | The active installed map, the last attempt (`phase`, `outcome`, `error_code`, `receipt`) and the number of receipts still waiting to be delivered |

`MAP_INSTALL` downloads the bundle from the signed https URL, verifies its SHA-256 against the installation target, unpacks it into `~/maps/installed/versions/<sha256>/`, and atomically switches the `current` pointer. It refuses to run while Nav2 is active. Every attempt that starts ends with exactly one receipt, posted to the gateway with the device API key (the gateway signs it for map-api); undelivered receipts are journaled and retried across restarts. `NAV2_START` without a `map` parameter picks whichever map action happened last — the installed map (time of the `current` pointer) or the newest `.yaml` saved under `~/maps` (e.g. by `SLAM_SAVE`); ties go to the installed map — and reports what it picked in `map` / `map_source`.

Re-issuing `MAP_INSTALL` for the installation that is already running answers `state: running` with the same `attempt_id` (the phone's retry does not start a second attempt); a *different* installation while one is running is refused with `install_busy`. A request is also refused before any attempt starts — so without a receipt — when the bundle is not `mobio-ros-map-bundle-v1` (`unsupported_format`), when `byte_length` is above the 256 MiB cap (`bundle_too_large`), when the manifest checksum differs from the installation target (`target_mismatch`), when the signed URL is not an acceptable https address (`url_rejected`) or has already expired (`url_expired`), when the installation targets another robot (`wrong_device`), or while Nav2 is running (`nav2_active`).

The install only switches the pointer — relocalization is not measured, so receipts always carry `relocalization_result: "not_attempted"`.

The bridge installs no SIGTERM handler, so a restart during an install — including one triggered by `UPDATE_BRIDGE` or the auto-updater — kills the attempt where it stands. The next boot closes it from the journal as `interrupted` (or as installed, if the `current` pointer had already been switched) and sends that receipt. Nothing is left half-installed, but the map is not installed either: re-issue the command.

## Camera (On-Demand)

Camera subscriptions are created on-demand and auto-stop after 30s of inactivity to save CPU (~10% reduction). Camera health is excluded from the `all_ok` flag and alert system — it only reports status when actively streaming.

Capture flow: `start_camera()` is always called first, then a 10s wait for a fresh frame before capturing.

## Supported Platforms

| Platform | Architecture | Python | ROS2 |
|----------|-------------|--------|------|
| Ubuntu 20.04+ | x86_64 | 3.10, 3.12 | Humble, Jazzy |
| Ubuntu 20.04+ | aarch64 (Jetson, RPi4+) | 3.10, 3.12 | Humble, Jazzy |
| Any Linux | x86_64/aarch64 | 3.10+ | *Simulator mode (no ROS2)* |

## Requirements

- Python 3.10+
- ROS2 Humble or Jazzy (optional — simulator mode works without)
- Internet access to ai-teammate.net (WSS + MQTT)

## Changelog (v2.6.14 — v2.6.24)

### v2.6.24
- **feat(radio):** Mobile AP detection — same BSSID seen at distant locations during SLAM is auto-blocklisted in `wifi_blocklist.json`
- **feat(place):** `SAVE_LOCATION` supports explicit `x`, `y` coordinates (map pin placement vs robot pose)
- **feat(place):** `SLAM_SAVE` auto-links collected fingerprints to nearby saved locations as `"predicted"` source

### v2.6.23
- **feat(slam):** Auto-collect WiFi fingerprints during SLAM mapping (5s interval, 50cm min displacement)

### v2.6.22
- **feat(slam):** Collect trajectory during mapping via TF listener (5Hz, 5cm spacing), include in map upload metadata

### v2.6.21
- **fix(slam):** Send initial SLAM map on WS connect regardless of age; flag-based dedup instead of age-only check

### v2.6.20
- **fix(stream):** `camera_stream` and `follow_state` use shared `config._ros2_cache` instead of local cache

### v2.6.19
- **fix(camera):** Route camera commands (`CAPTURE_IMAGE`, `CAMERA_CAPTURE`, `START_CAMERA_STREAM`, `STOP_CAMERA_STREAM`) to bridge first, skip skills — camera needs shared config cache

### v2.6.18
- **fix(camera):** Always call `start_camera()` + 10s wait for fresh frame before capture

### v2.6.17
- **fix(camera):** Use shared `config._ros2_cache` for `CAPTURE_IMAGE` instead of local cache

### v2.6.16
- **fix(camera):** Check frame presence instead of `_camera_active` flag for capture readiness

### v2.6.15
- **fix(camera):** Improved `capture_image` — check `start_camera` result, 5s wait, better logging

### v2.6.14
- **fix(health):** Camera (on-demand) excluded from `all_ok` and health alerts — only reports when active

## License

MIT - [MobioLabs](https://mobiolabs.net)
