Reorganize docs and add mock device guide in sangam-io

Move hardware-specific docs (COMMANDS.md, SENSORSTATUS.md) into
src/devices/crl200s/ for better organization. Add comprehensive
mock device user guide and update README with UDP streaming and
simulation documentation.
This commit is contained in:
codetiger
2025-12-15 12:40:09 +05:30
parent 70d40f55dd
commit f0efaada53
11 changed files with 939 additions and 983 deletions
+238 -64
View File
@@ -1,21 +1,22 @@
# SangamIO
Hardware abstraction daemon for the CRL-200S robotic vacuum platform, providing real-time sensor streaming and command control via TCP.
Hardware abstraction daemon for the CRL-200S robotic vacuum platform, providing real-time sensor streaming via UDP and command control via TCP.
## Overview
SangamIO acts as a bridge between high-level clients (SLAM, visualization) and low-level hardware (GD32 motor controller, Delta-2D lidar). It runs as a standalone daemon on the robot, streaming sensor data at 110Hz (limited by 115200 baud serial) and accepting commands over TCP.
SangamIO acts as a bridge between high-level clients (SLAM, visualization) and low-level hardware (GD32 motor controller, Delta-2D lidar). It runs as a standalone daemon on the robot, streaming sensor data at 110Hz via UDP and accepting commands over TCP.
```
┌─────────────────────────────────────────────────┐
│ Client Applications (SLAM/Drishti) │
└─────────────┬───────────────┬───────────────────┘
│ TCP 5555 │
│ UDP 5555 │ TCP 5555
│ (sensors) │ (commands)
┌─────────────▼───────────────▼───────────────────┐
│ SangamIO Daemon (Robot) │
│ • Telemetry streaming @ 110Hz (sensors) │
│ • Telemetry streaming @ 110Hz (UDP unicast) │
│ • Lidar streaming @ 5Hz (360° scans) │
│ • Command processing (motion/actuators) │
│ • Command processing (TCP, reliable) │
│ • Real-time control loop (20ms heartbeat) │
└─────────────┬───────────────┬───────────────────┘
│ /dev/ttyS3 │ /dev/ttyS1
@@ -29,9 +30,9 @@ SangamIO acts as a bridge between high-level clients (SLAM, visualization) and l
| Property | Value |
|----------|-------|
| Language | Rust 2021 edition |
| Language | Rust 2024 edition |
| Version | 0.3.0 |
| Target | ARM (armv7-unknown-linux-musleabihf) |
| Target | ARM (armv7-unknown-linux-musleabihf) or host (simulation) |
| Binary Size | ~350KB (statically linked) |
| Memory Usage | <10MB RSS |
| CPU Usage | <1% on Allwinner A33 |
@@ -41,20 +42,21 @@ SangamIO acts as a bridge between high-level clients (SLAM, visualization) and l
### Development (Host Machine)
```bash
# Build
cargo build --release
# Build for real hardware
cargo build --release --target armv7-unknown-linux-musleabihf
# Run with default config
cargo run --release
# Build for simulation (mock device)
cargo build --release --features mock
# Run with custom config
cargo run --release -- sangamio.toml
# Run with configuration file
cargo run --release --features mock -- mock.toml
cargo run --release --features mock -- --config mock.toml
# Enable verbose logging
RUST_LOG=debug cargo run --release
RUST_LOG=debug cargo run --release --features mock
# Run tests
cargo test
cargo test --features mock
```
### Production (ARM Robot)
@@ -63,7 +65,7 @@ cargo test
# Add ARM target (one-time)
rustup target add armv7-unknown-linux-musleabihf
# Build for ARM
# Build for ARM (real hardware)
cargo build --release --target armv7-unknown-linux-musleabihf
# Strip debug symbols (reduces size ~40%)
@@ -71,6 +73,13 @@ arm-linux-gnueabihf-strip \
target/armv7-unknown-linux-musleabihf/release/sangam-io
```
### Feature Flags
| Feature | Description |
|---------|-------------|
| (default) | Real hardware support only |
| `mock` | Enable mock device simulation |
## Deployment
**SSH**: `root@vacuum` (see project docs for credentials)
@@ -148,11 +157,18 @@ All transforms default to identity (no change) if not specified. The CRL-200S re
| `imu_gyro` | AxisTransform3D | `[source_axis, sign]` | Remap + flip yaw |
| `imu_accel` | AxisTransform3D | `[source_axis, sign]` | Identity (default)
## TCP Protocol
## Network Protocol
SangamIO uses a hybrid UDP/TCP architecture for optimal performance:
| Channel | Protocol | Purpose | Why |
|---------|----------|---------|-----|
| Sensor data | UDP unicast | Low-latency streaming | No head-of-line blocking |
| Commands | TCP | Reliable control | Guaranteed delivery |
### Message Format
All messages use length-prefixed framing with Protobuf payloads:
Both UDP and TCP use length-prefixed Protobuf framing:
```
┌──────────────────┬─────────────────────┐
@@ -163,38 +179,70 @@ All messages use length-prefixed framing with Protobuf payloads:
See `proto/sangamio.proto` for the complete schema.
### Topics
### Topics (UDP Streaming)
| Direction | Topic | Rate | Description |
|-----------|-------|------|-------------|
| Out | `sensors/sensor_status` | 110Hz | All sensor data |
| Out | `sensors/device_version` | Once | Version info |
| Out | `sensors/lidar` | 5Hz | 360° point cloud |
| In | `command` | On-demand | Robot commands |
| Topic | Rate | Size | Description |
|-------|------|------|-------------|
| `sensors/sensor_status` | 110Hz | ~150B | Encoders, IMU, bumpers, cliffs, battery |
| `sensors/lidar` | 5Hz | ~2KB | 360° point cloud (angle, distance, quality) |
| `sensors/device_version` | Once | ~50B | Firmware version info |
### Commands (TCP)
| Command | Description |
|---------|-------------|
| `ComponentControl::drive` | Set linear/angular velocity |
| `ComponentControl::lidar` | Enable/disable lidar motor |
| `ComponentControl::vacuum` | Control vacuum motor (0-100%) |
| `ComponentControl::*_brush` | Control brushes (0-100%) |
| `Shutdown` | Graceful daemon shutdown |
### Client Registration
UDP uses unicast (not broadcast). Clients are registered automatically:
1. Client connects TCP to port 5555
2. Server records client IP address
3. UDP packets are sent to client on same port
4. When TCP disconnects, UDP streaming stops
### Python Client Example
```python
import socket
import struct
import threading
from proto import sangamio_pb2
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('192.168.68.101', 5555))
ROBOT_IP = '192.168.68.101'
PORT = 5555
while True:
# Read length prefix
length = struct.unpack('>I', sock.recv(4))[0]
# TCP for commands
tcp_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
tcp_sock.connect((ROBOT_IP, PORT))
# Read Protobuf message
data = sock.recv(length)
msg = sangamio_pb2.Message()
msg.ParseFromString(data)
# UDP for sensor data (same port)
udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
udp_sock.bind(('0.0.0.0', PORT))
if msg.topic == 'sensors/sensor_status':
sg = msg.sensor_group
charging = sg.values['is_charging'].bool_val
print(f"Charging: {charging}")
def receive_sensors():
while True:
data, addr = udp_sock.recvfrom(4096)
length = struct.unpack('>I', data[:4])[0]
msg = sangamio_pb2.Message()
msg.ParseFromString(data[4:4+length])
if msg.topic == 'sensors/sensor_status':
sg = msg.sensor_group
print(f"Battery: {sg.values['battery_level'].u8_val}%")
# Start receiving in background
threading.Thread(target=receive_sensors, daemon=True).start()
# Send commands via TCP
def send_command(cmd):
payload = cmd.SerializeToString()
tcp_sock.send(struct.pack('>I', len(payload)) + payload)
```
## Module Structure
@@ -202,7 +250,7 @@ while True:
```
sangam-io/
├── src/
│ ├── main.rs # Entry point, TCP listener
│ ├── main.rs # Entry point, TCP/UDP listener
│ ├── config.rs # TOML configuration loading
│ ├── error.rs # Error types
│ │
@@ -211,32 +259,48 @@ sangam-io/
│ │ └── types.rs # SensorValue, Command types
│ │
│ ├── devices/
│ │ ├── mod.rs # Device factory
│ │ └── crl200s/
│ │ ├── mod.rs # CRL200S orchestrator
│ │ ├── constants.rs # Hardware constants
│ │ ├── gd32/ # Motor controller driver
│ │ │ ├── mod.rs # Driver core
│ │ │ ├── commands.rs # Command handlers
│ │ │ ├── heartbeat.rs # 20ms watchdog
│ │ │ ├── reader.rs # Status parsing
│ │ │ ├── packet.rs # Packet encoding
│ │ │ ├── protocol.rs # Packet framing
│ │ │ └── state.rs # Atomic state
│ │ └── delta2d/ # Lidar driver
│ │ ├── mod.rs # Driver core
│ │ └── protocol.rs # Packet parsing
│ │ ├── mod.rs # Device factory (crl200s, mock)
│ │ ├── crl200s/ # Real hardware driver
│ │ │ ├── mod.rs # CRL200S orchestrator
│ │ │ ├── constants.rs # Hardware constants
│ │ │ ├── COMMANDS.md # GD32 command reference
│ │ │ ├── SENSORSTATUS.md # Sensor packet docs
│ │ │ ├── gd32/ # Motor controller driver
│ │ │ │ ├── mod.rs # Driver core
│ │ │ │ ├── commands.rs # Command handlers
│ │ │ │ ├── heartbeat.rs # 20ms watchdog
│ │ │ │ ├── reader.rs # Status parsing
│ │ │ │ ├── packet.rs # Packet encoding
│ │ │ │ ├── protocol.rs # Packet framing
│ │ │ │ └── state.rs # Atomic state
│ │ │ └── delta2d/ # Lidar driver
│ │ │ ├── mod.rs # Driver core
│ │ │ └── protocol.rs # Packet parsing
│ │ │
│ │ └── mock/ # Simulation driver (--features mock)
│ │ ├── mod.rs # MockDriver, simulation loop
│ │ ├── config.rs # SimulationConfig structs
│ │ ├── physics.rs # Differential drive kinematics
│ │ ├── lidar_sim.rs # Ray-casting lidar
│ │ ├── imu_sim.rs # IMU with noise
│ │ ├── encoder_sim.rs # Encoder with slip
│ │ ├── sensor_sim.rs # Bumpers, cliffs
│ │ ├── map_loader.rs # PGM+YAML loader
│ │ └── noise.rs # Noise generator
│ │
│ └── streaming/
│ ├── mod.rs # Streaming module docs
│ ├── wire.rs # Protobuf serialization
│ ├── tcp_publisher.rs # Outbound streaming
│ └── tcp_receiver.rs # Command handling
│ ├── udp_publisher.rs # UDP sensor streaming
│ └── tcp_receiver.rs # TCP command handling
│
├── proto/
│ └── sangamio.proto # Protobuf schema
├── sangamio.toml # Default configuration
├── COMMANDS.md # GD32 command reference
└── SENSORSTATUS.md # Sensor packet documentation
├── maps/ # Example simulation maps
│ ├── example.yaml # Map metadata
│ └── example.pgm # Occupancy grid
├── sangamio.toml # Hardware configuration
└── mock.toml # Simulation configuration
```
## Supported Components
@@ -277,13 +341,24 @@ The `sensor_status` group includes:
## Thread Model
### Real Hardware (crl200s)
| Thread | Purpose | Timing |
|--------|---------|--------|
| Main | TCP listener | - |
| Main | TCP listener, client accept | - |
| GD32 Heartbeat | Safety watchdog | 20ms ±2ms |
| GD32 Reader | Status parsing | Continuous |
| GD32 Reader | Status parsing → channel | Continuous |
| Lidar Reader | Scan accumulation | Continuous |
| TCP Publisher | Per-client streaming | 1ms loop |
| UDP Publisher | Sensor streaming | <1ms loop |
| TCP Receiver | Per-client commands | On-demand |
### Simulation (mock)
| Thread | Purpose | Timing |
|--------|---------|--------|
| Main | TCP listener, client accept | - |
| Simulation Loop | Physics + sensors | 9ms (110Hz) |
| UDP Publisher | Sensor streaming | <1ms loop |
| TCP Receiver | Per-client commands | On-demand |
## Hardware Constraints
@@ -301,10 +376,109 @@ The `sensor_status` group includes:
| Command latency | ~30ms |
| Protobuf bandwidth | ~95KB/s |
## Mock Device Simulation
The mock device driver enables algorithm development and testing without physical hardware.
### Quick Start
```bash
# Build with mock feature
cargo build --release --features mock
# Run simulation
cargo run --release --features mock -- mock.toml
```
### Map Format (ROS Standard)
Maps use PGM + YAML format compatible with ROS Navigation:
**maps/example.yaml:**
```yaml
image: example.pgm # Grayscale occupancy grid
resolution: 0.02 # meters per pixel
origin: [-5.0, -5.0, 0.0] # [x, y, yaw] of bottom-left
occupied_thresh: 0.65 # Darker = occupied
cliff_mask: cliffs.pgm # Optional cliff layer
```
**PGM pixel values:**
- White (255) = Free space
- Black (0) = Wall/obstacle
- Gray (205) = Unknown
### Configuration
**mock.toml:**
```toml
[device]
type = "mock"
name = "Mock CRL-200S"
[device.simulation]
map_file = "maps/example.yaml"
start_x = 1.5 # meters
start_y = 3.5 # meters
start_theta = 0.0 # radians
speed_factor = 1.0 # 2.0 = 2x speed
random_seed = 42 # 0 = random each run
[device.simulation.robot]
wheel_base = 0.233 # meters
ticks_per_meter = 4464.0
collision_mode = "stop" # "stop", "slide", "passthrough"
[device.simulation.lidar]
num_rays = 360
scan_rate_hz = 5.0
max_range = 8.0
[device.simulation.lidar.noise]
range_stddev = 0.005 # 5mm noise
miss_rate = 0.01 # 1% invalid readings
[network]
bind_address = "0.0.0.0:5555"
```
### Simulated Components
| Component | Method | Configurability |
|-----------|--------|-----------------|
| Lidar | Ray-casting | Noise, miss rate, quality |
| Encoders | Kinematics | Slip, quantization |
| IMU | Physics-based | Per-axis noise, drift |
| Bumpers | Collision detect | Zone angles, trigger distance |
| Cliffs | Mask lookup | Sensor positions |
| Battery | Fixed values | Voltage, level, charging |
### Speed Factor
Accelerate simulation for faster testing:
| Factor | sensor_status | lidar | Wall time for 1 min sim |
|--------|---------------|-------|-------------------------|
| 1.0 | 110 Hz | 5 Hz | 60 sec |
| 2.0 | 220 Hz | 10 Hz | 30 sec |
| 5.0 | 550 Hz | 25 Hz | 12 sec |
### Creating Maps
1. **GIMP/Photoshop**: Draw black (walls) on white (free space)
2. **ROS map_server**: Save maps from SLAM runs
3. **Cartographer**: Export as PGM
4. Save as 8-bit grayscale PGM with YAML metadata
## Documentation
- [COMMANDS.md](COMMANDS.md) - GD32 command reference (27 commands)
- [SENSORSTATUS.md](SENSORSTATUS.md) - Status packet byte layout (96 bytes)
### CRL-200S Hardware
- [src/devices/crl200s/COMMANDS.md](src/devices/crl200s/COMMANDS.md) - GD32 command reference (27 commands)
- [src/devices/crl200s/SENSORSTATUS.md](src/devices/crl200s/SENSORSTATUS.md) - Status packet byte layout (96 bytes)
### Mock Device Simulation
- [docs/mock-device-guide.md](docs/mock-device-guide.md) - Mock device user guide
- [docs/proposal-mock-device.md](docs/proposal-mock-device.md) - Mock device design document
## Debugging
+299
View File
@@ -0,0 +1,299 @@
# Mock Device User Guide
This guide explains how to use SangamIO's mock device driver for simulation-based development and testing.
## Overview
The mock device driver provides a complete simulation of CRL-200S hardware, enabling:
- **Algorithm development** without physical robots
- **Unit testing** with deterministic sensor data
- **CI/CD integration** for automated testing
- **Accelerated testing** with speed factor
## Quick Start
### 1. Build with Mock Feature
```bash
cd sangam-io
cargo build --release --features mock
```
### 2. Create or Use an Existing Map
Use the example map or create your own:
```bash
ls maps/
# example.yaml example.pgm
```
### 3. Run Simulation
```bash
# Using default mock.toml
cargo run --release --features mock -- mock.toml
# Or with explicit config flag
cargo run --release --features mock -- --config mock.toml
# With debug logging
RUST_LOG=debug cargo run --release --features mock -- mock.toml
```
### 4. Connect Your Client
Connect to `localhost:5555` using the same protocol as real hardware:
- **UDP** for sensor data (automatic when TCP connects)
- **TCP** for commands
## Map Creation
### PGM + YAML Format
Maps use the ROS-standard format for maximum compatibility.
**Step 1: Create PGM Image**
Use any image editor (GIMP, Photoshop, or programmatically):
| Color | Value | Meaning |
|-------|-------|---------|
| White | 255 | Free space |
| Black | 0 | Wall/obstacle |
| Gray | 205 | Unknown |
Save as 8-bit grayscale PGM (P5 binary or P2 ASCII).
**Step 2: Create YAML Metadata**
```yaml
# maps/my_map.yaml
image: my_map.pgm # PGM filename (relative to YAML)
resolution: 0.02 # meters per pixel
origin: [-5.0, -5.0, 0.0] # [x, y, yaw] of bottom-left pixel
occupied_thresh: 0.65 # Pixels darker than this are occupied
# Optional: cliff mask (same dimensions as main map)
cliff_mask: my_map_cliffs.pgm
```
**Step 3: Calculate Origin**
The origin is the world coordinate of the bottom-left pixel:
```
origin_x = -width_pixels * resolution / 2
origin_y = -height_pixels * resolution / 2
```
For a 500x500 pixel map at 0.02 m/px:
```yaml
origin: [-5.0, -5.0, 0.0] # Centers the map
```
### Using ROS Maps
Maps saved by ROS tools work directly:
```bash
# From ROS map_server
ros2 run nav2_map_server map_saver_cli -f my_map
# Produces my_map.pgm and my_map.yaml
# Copy to sangam-io/maps/
```
### Creating Cliff Masks
Cliff masks define areas where cliff sensors should trigger:
1. Create a PGM with same dimensions as main map
2. Paint black (0) where cliffs exist
3. Leave white (255) for safe floor
4. Reference in YAML: `cliff_mask: my_map_cliffs.pgm`
## Configuration Reference
### Minimal Configuration
```toml
[device]
type = "mock"
name = "Mock Robot"
[device.simulation]
map_file = "maps/example.yaml"
[network]
bind_address = "0.0.0.0:5555"
```
### Full Configuration
See `mock.toml` for all available options. Key sections:
#### Simulation Control
```toml
[device.simulation]
map_file = "maps/example.yaml"
start_x = 1.5 # Initial X position (meters)
start_y = 3.5 # Initial Y position (meters)
start_theta = 0.0 # Initial heading (radians, 0 = +X)
speed_factor = 1.0 # Time multiplier (2.0 = 2x speed)
random_seed = 42 # 0 = random, >0 = deterministic
log_level = "standard" # "minimal", "standard", "verbose"
```
#### Robot Parameters
```toml
[device.simulation.robot]
wheel_base = 0.233 # Distance between wheels (meters)
ticks_per_meter = 4464.0 # Encoder resolution
max_linear_speed = 0.3 # Speed limit (m/s)
max_angular_speed = 1.0 # Turn rate limit (rad/s)
robot_radius = 0.17 # Collision radius (meters)
collision_mode = "stop" # "stop", "slide", "passthrough"
```
#### Lidar Simulation
```toml
[device.simulation.lidar]
num_rays = 360 # Points per scan
scan_rate_hz = 5.0 # Scans per second
min_range = 0.15 # Minimum detection (meters)
max_range = 8.0 # Maximum detection (meters)
mounting_x = -0.110 # Lidar offset from center
angle_offset = 0.2182 # Angular offset (radians)
[device.simulation.lidar.noise]
range_stddev = 0.005 # Distance noise (meters)
angle_stddev = 0.001 # Angular noise (radians)
miss_rate = 0.01 # Invalid reading probability
quality_base = 200 # Base quality (0-255)
```
#### IMU Simulation
```toml
[device.simulation.imu.gyro_noise]
stddev = [5.0, 5.0, 10.0] # Per-axis noise
bias = [0.0, 0.0, 0.0] # Constant bias
drift_rate = 0.0 # Bias drift (units/sec)
[device.simulation.imu.accel_noise]
stddev = [10.0, 10.0, 20.0]
bias = [0.0, 0.0, 0.0]
```
#### Encoder Simulation
```toml
[device.simulation.encoder.noise]
slip_stddev = 0.002 # Wheel slip noise (0-1)
slip_bias = 0.0 # Systematic slip
quantization_noise = true # ±0.5 tick jitter
```
## Speed Factor Usage
The `speed_factor` setting accelerates simulation time:
| Factor | sensor_status | lidar | Use Case |
|--------|---------------|-------|----------|
| 1.0 | 110 Hz | 5 Hz | Real-time development |
| 2.0 | 220 Hz | 10 Hz | Faster iteration |
| 5.0 | 550 Hz | 25 Hz | Quick integration tests |
| 10.0 | 1100 Hz | 50 Hz | Stress testing |
**Note:** Higher speeds increase CPU usage and may not be sustainable on all systems.
## Deterministic Testing
For reproducible tests, set a fixed random seed:
```toml
[device.simulation]
random_seed = 12345 # Same seed = same sensor noise sequence
```
This ensures:
- Identical lidar noise patterns
- Same encoder slip variations
- Reproducible IMU readings
## Collision Modes
Three collision handling modes are available:
| Mode | Behavior | Use Case |
|------|----------|----------|
| `stop` | Robot stops on collision | Realistic behavior |
| `slide` | Robot slides along walls | Wall-following tests |
| `passthrough` | No collision | Debugging, path planning |
## Troubleshooting
### "Map file not found"
```
Error: Failed to read map YAML: No such file or directory
```
Check that `map_file` path is relative to where you run the command, or use absolute path.
### "Mock device not available"
```
Error: Mock device not available: rebuild with --features mock
```
The mock feature is not enabled. Rebuild:
```bash
cargo build --release --features mock
```
### High CPU Usage
If CPU usage is high:
1. Reduce `speed_factor`
2. Check `log_level` (verbose logging is expensive)
3. Ensure release build: `--release`
### No Lidar Data
Lidar must be explicitly enabled:
```python
# Send lidar enable command via TCP
cmd = ComponentControl(id="lidar", action=Enable())
```
### Client Not Receiving UDP
1. Ensure TCP connection is established first
2. Check firewall allows UDP on port 5555
3. Verify client is binding to correct port
## Integration with SLAM
The mock device produces identical sensor data format to real hardware:
```python
# Same code works for both real and simulated robot
client = SangamClient(host="localhost", port=5555)
# or
client = SangamClient(host="192.168.68.101", port=5555)
```
## Example Workflow
1. **Develop algorithm** using mock device on laptop
2. **Write unit tests** with deterministic random seed
3. **Run CI tests** with mock device (no hardware needed)
4. **Deploy to robot** - same client code works
5. **Record real data** using bag files
6. **Replay and compare** mock vs real performance
-879
View File
@@ -1,879 +0,0 @@
# Proposal: Mock Device Driver for SangamIO
## Overview
This proposal outlines the implementation of a **mock device driver** for SangamIO that simulates CRL-200S robot hardware using a pre-recorded map file. This enables algorithm development, testing, and debugging without physical robot hardware.
## Goals
1. Enable SLAM/navigation algorithm testing in simulation
2. Use **ROS-standard PGM + YAML map format** for interoperability
3. Match CRL-200S physical characteristics exactly
4. Provide configurable noise for realistic sensor simulation
5. Support deterministic/repeatable testing scenarios
6. Full CRL-200S sensor parity from initial release
## Requirements Summary
Based on clarification discussions, the following decisions were made:
| Topic | Decision |
|-------|----------|
| **Collision handling** | Stop or slide based on config; slide friction configurable |
| **Noise parameters** | Start with datasheet estimates; calibrate later based on SLAM performance |
| **Actuator commands** | Log state changes only (no simulation effect) |
| **Map source** | Manual creation (GIMP) or external sources; no DhruvaSLAM export initially |
| **Cliff sensors** | Separate mask file (`map_cliffs.pgm`) |
| **Time scaling** | Configurable speed factor (1x, 2x, 5x, 10x) |
| **Bumper geometry** | Configurable trigger zones (angles + distances) |
| **Logging level** | Standard: log velocity commands, collisions, sensor triggers |
| **Button simulation** | Fixed state (never pressed) |
| **Fast mode behavior** | Increase packet rate (220Hz at 2x, 550Hz at 5x) |
| **Time source** | Wall-clock multiplied by speed_factor |
| **MVP scope** | Full CRL-200S parity (all sensors from day one) |
## Map File Format (ROS Standard)
### PGM + YAML Format
Maps use the ROS-standard two-file format:
**YAML Metadata File** (`map.yaml`):
```yaml
image: map.pgm
resolution: 0.02 # meters per pixel (matches dhruva-slam default)
origin: [-10.0, -10.0, 0.0] # [x, y, yaw] of bottom-left pixel in world frame
occupied_thresh: 0.65 # Pixel value > 65% = occupied
free_thresh: 0.196 # Pixel value < 19.6% = free
negate: 0 # 0 = white is free, 1 = white is occupied
# Optional: cliff mask file (same dimensions as main map)
cliff_mask: map_cliffs.pgm # White = safe, Black = cliff
```
**PGM Image File** (`map.pgm`):
- Grayscale image (8-bit, 0-255)
- **White (255)** = free space
- **Black (0)** = occupied
- **Gray (205)** = unknown
**Cliff Mask File** (`map_cliffs.pgm`) - Optional:
- Grayscale image (same dimensions as main map)
- **White (255)** = safe floor
- **Black (0)** = cliff/drop-off
- If not specified, cliffs only trigger at map boundary
### Map Compatibility
This format is compatible with:
- ROS `map_server` / Nav2 `map_server`
- Cartographer
- SLAM Toolbox
- gmapping
- Any standard image editor (GIMP, Photoshop)
## Configuration
### Mock Configuration File (`mock.toml`)
This configuration mirrors CRL-200S as closely as possible:
```toml
# Mock Device Configuration for SangamIO
# Simulates CRL-200S hardware for algorithm testing
[device]
type = "mock"
name = "Mock CRL-200S"
# ============================================================================
# Simulation Environment
# ============================================================================
[device.simulation]
# Map file (ROS-standard PGM+YAML format)
# Point to the .yaml file, PGM is referenced inside
map_file = "maps/apartment.yaml"
# Initial robot pose in world coordinates (ROS REP-103)
# X = forward, Y = left, Theta = CCW positive
start_x = 0.0 # meters
start_y = 0.0 # meters
start_theta = 0.0 # radians (0 = facing +X direction)
# Simulation speed multiplier (1.0 = real-time)
# Higher values increase packet rate proportionally:
# 1.0 = 110Hz sensor_status, 5Hz lidar
# 2.0 = 220Hz sensor_status, 10Hz lidar
# 5.0 = 550Hz sensor_status, 25Hz lidar
# 10.0 = 1100Hz sensor_status, 50Hz lidar
speed_factor = 1.0
# Random seed for reproducible noise (0 = random seed each run)
random_seed = 0
# Logging level: "minimal", "standard", "verbose"
# - minimal: errors and warnings only
# - standard: velocity commands, collisions, sensor triggers
# - verbose: pose and sensor values at configurable interval
log_level = "standard"
# ============================================================================
# Robot Physical Parameters (CRL-200S Specifications)
# ============================================================================
[device.simulation.robot]
# Differential drive parameters
wheel_base = 0.233 # meters (distance between wheel centers)
wheel_radius = 0.033 # meters
ticks_per_meter = 4464.0 # encoder ticks per meter of travel
# Velocity limits
max_linear_speed = 0.3 # m/s
max_angular_speed = 1.0 # rad/s
# Robot physical dimensions (for collision detection)
robot_radius = 0.17 # meters (approximate circular footprint)
# Collision behavior: "stop", "slide", "passthrough"
# - stop: robot velocity becomes zero on collision
# - slide: robot slides along obstacle surface
# - passthrough: ignore collisions (for debugging)
collision_mode = "stop"
# Slide friction coefficient (only used when collision_mode = "slide")
# 0.0 = frictionless sliding, 1.0 = high friction (slow slide)
slide_friction = 0.3
# ============================================================================
# Lidar Configuration (Delta-2D Specifications)
# ============================================================================
[device.simulation.lidar]
# Scan parameters
num_rays = 360 # points per 360° scan
scan_rate_hz = 5.0 # scans per second (Delta-2D native rate)
# Range limits
min_range = 0.15 # meters (matches dhruva-slam preprocessing)
max_range = 8.0 # meters (Delta-2D max range)
# Lidar mounting position (relative to robot center)
# CRL-200S: lidar is mounted behind center
mounting_x = -0.110 # meters (behind robot center)
mounting_y = 0.0 # meters (centered on axis)
optical_offset = 0.025 # meters (radial offset to laser origin)
angle_offset = 0.2182 # radians (~12.5°, lidar's 0° vs robot forward)
# Noise configuration (initial estimates - calibrate based on SLAM performance)
[device.simulation.lidar.noise]
# Distance noise (Gaussian)
range_stddev = 0.005 # meters (5mm standard deviation)
range_bias = 0.0 # meters (systematic offset)
# Angular noise (Gaussian)
angle_stddev = 0.001 # radians (~0.06°)
# Quality simulation
quality_base = 200 # base quality value (0-255)
quality_distance_decay = 10 # quality reduction per meter distance
# Miss rate (probability of invalid reading)
miss_rate = 0.01 # 1% chance of invalid point
# ============================================================================
# IMU Configuration (GD32 Specifications)
# ============================================================================
[device.simulation.imu]
update_rate_hz = 110.0 # matches GD32 status packet rate
# Gyroscope noise (raw i16 units, same as hardware)
# Initial estimates based on typical MPU6050 - calibrate later
[device.simulation.imu.gyro_noise]
stddev = [5, 5, 10] # [x, y, z] standard deviation
bias = [0, 0, 0] # [x, y, z] constant bias
drift_rate = 0.0 # bias drift per second
# Accelerometer noise (raw i16 units)
[device.simulation.imu.accel_noise]
stddev = [10, 10, 20] # [x, y, z] standard deviation
bias = [0, 0, 0] # [x, y, z] constant bias
# Tilt sensor noise (low-pass filtered gravity vector)
[device.simulation.imu.tilt_noise]
stddev = [5, 5, 5] # [x, y, z] standard deviation
# ============================================================================
# Encoder Configuration
# ============================================================================
[device.simulation.encoder]
update_rate_hz = 110.0 # matches GD32 status packet rate
# Encoder noise (initial estimates - calibrate later)
[device.simulation.encoder.noise]
# Slip simulation (multiplicative noise on tick count)
slip_stddev = 0.002 # 0.2% standard deviation
slip_bias = 0.0 # systematic over/under counting
# Quantization (encoder resolution)
quantization_noise = true # add ±0.5 tick quantization noise
# ============================================================================
# Bumper Configuration (Configurable Zones)
# ============================================================================
[device.simulation.bumpers]
# Bumper trigger distance from robot edge
trigger_distance = 0.01 # meters (1cm)
# Left bumper arc (angles relative to robot forward, CCW positive)
[device.simulation.bumpers.left]
start_angle = 0.3 # radians (~17°)
end_angle = 1.57 # radians (90°, robot left side)
# Right bumper arc
[device.simulation.bumpers.right]
start_angle = -1.57 # radians (-90°, robot right side)
end_angle = -0.3 # radians (~-17°)
# ============================================================================
# Cliff Sensor Configuration
# ============================================================================
[device.simulation.cliffs]
# Cliff detection sources:
# - Map boundary (always active)
# - Cliff mask file (if specified in map YAML)
enabled = true
# Cliff sensor positions (relative to robot center)
# CRL-200S has 4 cliff sensors
[[device.simulation.cliffs.sensors]]
name = "left_side"
x = 0.12 # meters forward from center
y = 0.10 # meters left from center
[[device.simulation.cliffs.sensors]]
name = "left_front"
x = 0.15
y = 0.05
[[device.simulation.cliffs.sensors]]
name = "right_front"
x = 0.15
y = -0.05
[[device.simulation.cliffs.sensors]]
name = "right_side"
x = 0.12
y = -0.10
# ============================================================================
# Binary Sensors Configuration
# ============================================================================
[device.simulation.sensors]
# Battery simulation (fixed values)
[device.simulation.sensors.battery]
voltage = 14.8 # volts (between 13.5-15.5V range)
level = 85 # percentage (0-100)
is_charging = false
# Fixed sensor states
dustbox_attached = true
dock_connected = false
# Buttons - always fixed state (never pressed)
start_button = 0
dock_button = 0
# ============================================================================
# Actuator Logging (no simulation effect)
# ============================================================================
[device.simulation.actuators]
# When actuator commands are received, log state changes
# Supported actuators: vacuum, main_brush, side_brush, water_pump, led, lidar
log_state_changes = true
# ============================================================================
# Coordinate Frame Transforms (matches CRL-200S sangamio.toml)
# ============================================================================
[device.hardware.frame_transforms]
# Lidar angle transform: output = scale * input + offset
# CRL-200S: mounted backward (offset=π), clockwise angles (scale=-1)
[device.hardware.frame_transforms.lidar]
scale = -1.0
offset = 3.14159265
# IMU gyroscope axis remap: [source_axis, sign]
# CRL-200S: x=yaw, y=pitch, z=roll -> remap to ROS standard
[device.hardware.frame_transforms.imu_gyro]
x = [2, 1] # output_x (roll) = input_z * 1
y = [1, 1] # output_y (pitch) = input_y * 1
z = [0, -1] # output_z (yaw) = input_x * -1
# IMU accelerometer axis remap (identity for CRL-200S)
[device.hardware.frame_transforms.imu_accel]
x = [0, 1]
y = [1, 1]
z = [2, 1]
# ============================================================================
# Network Configuration
# ============================================================================
[network]
bind_address = "0.0.0.0:5555"
udp_streaming_port = 5556
```
## Architecture
### Module Structure
```
sangam-io/src/devices/
├── mod.rs # Add "mock" to device factory
└── mock/
├── mod.rs # MockDriver struct + DeviceDriver impl
├── config.rs # SimulationConfig deserialization
├── map_loader.rs # PGM+YAML parser + cliff mask
├── physics.rs # Differential drive kinematics + collision
├── noise.rs # Configurable noise generator
├── lidar_sim.rs # Ray-casting lidar simulation
├── imu_sim.rs # IMU data generation with noise
├── encoder_sim.rs # Encoder simulation with slip/noise
└── sensor_sim.rs # Bumpers, cliffs, battery (binary sensors)
```
### Component Diagram
```
┌─────────────────────────────────────────────────────────────────────┐
│ MockDriver │
├─────────────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────────────┐│
│ │ Configuration (TOML) ││
│ │ • Robot params (wheel_base, ticks_per_meter, limits) ││
│ │ • Sensor noise configs (stddev, bias, drift) ││
│ │ • Map file path + cliff mask + initial pose ││
│ │ • Bumper zones, cliff sensor positions ││
│ │ • Speed factor, collision mode, logging level ││
│ └─────────────────────────────────────────────────────────────────┘│
│ │ │
│ ▼ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ MapLoader │ │ PhysicsEngine│ │ Sensor Simulators │ │
│ │ (PGM+YAML) │ │ │ │ │ │
│ │ │ │ • pose │ │ • LidarSim (ray-cast) │ │
│ │ • load() │──▶│ • velocity │──▶│ • ImuSim (gyro/accel) │ │
│ │ • ray_cast() │ │ • collision │ │ • EncoderSim (ticks) │ │
│ │ • is_cliff() │ │ • slide │ │ • BumperSim (zones) │ │
│ │ • is_free() │ │ • update() │ │ • CliffSim (mask) │ │
│ └──────────────┘ └──────────────┘ └──────────────────────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌─────────────────────────────────────────────────────────────────┐│
│ │ Sensor Groups (identical to CRL-200S output) ││
│ │ ││
│ │ sensor_status (~110Hz × speed_factor): ││
│ │ • wheel_left, wheel_right (U16 encoder ticks) ││
│ │ • gyro_x/y/z, accel_x/y/z, tilt_x/y/z (I16 raw) ││
│ │ • bumper_left, bumper_right (Bool) ││
│ │ • cliff_left_side/front, cliff_right_front/side (Bool) ││
│ │ • battery_voltage (F32), battery_level (U8), is_charging (Bool)││
│ │ • dustbox_attached, is_dock_connected (Bool) ││
│ │ • start_button, dock_button (U16) - always 0 ││
│ │ ││
│ │ lidar (5Hz × speed_factor): ││
│ │ • scan: PointCloud2D [(angle_rad, distance_m, quality)] ││
│ │ ││
│ │ device_version (one-time): ││
│ │ • version_string: "MockDevice v1.0" ││
│ │ • version_code: 100 ││
│ └─────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────┘
```
### Simulation Loop
```
┌─────────────────────────────────────────────────────────────────────┐
│ Simulation Thread (~110Hz × speed_factor) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ Time source: wall_clock × speed_factor │
│ At 2x speed: loop runs every ~4.5ms instead of ~9ms │
│ │
│ Every tick (110Hz × speed_factor): │
│ │
│ 1. Compute dt = (wall_time_delta × speed_factor) │
│ │
│ 2. Read velocity command (from atomic state, set by TCP) │
│ └─ linear_vel (m/s), angular_vel (rad/s) │
│ │
│ 3. Update physics (differential drive kinematics) │
│ ├─ Compute wheel velocities from (v, ω) │
│ ├─ Integrate pose: x, y, theta │
│ ├─ Check collision against map │
│ │ ├─ "stop": zero velocity, log collision │
│ │ ├─ "slide": project velocity along surface │
│ │ └─ "passthrough": ignore collision │
│ └─ Update encoder tick counters (with slip noise) │
│ │
│ 4. Generate sensor_status data: │
│ ├─ Encoders: wheel_left, wheel_right (U16 + noise) │
│ ├─ IMU: gyro_x/y/z (from ω + noise) │
│ │ accel_x/y/z (gravity + centripetal + noise) │
│ │ tilt_x/y/z (gravity vector + noise) │
│ ├─ Bumpers: check configurable arc zones against map │
│ ├─ Cliffs: check sensor positions against cliff mask │
│ ├─ Battery: constant from config │
│ └─ Buttons: always 0 │
│ │
│ 5. Publish to streaming channel │
│ │
│ 6. Log state changes (if log_level >= "standard"): │
│ ├─ Velocity commands received │
│ ├─ Collision events │
│ ├─ Bumper/cliff triggers │
│ └─ Actuator state changes │
│ │
│ Every (200ms / speed_factor) - lidar tick: │
│ │
│ 7. Generate lidar scan: │
│ ├─ For each of 360 rays: │
│ │ ├─ Transform ray origin by lidar mount offset │
│ │ ├─ Ray-cast against occupancy grid │
│ │ ├─ Add range noise (Gaussian) │
│ │ ├─ Apply miss rate (random invalid points) │
│ │ └─ Compute quality from distance │
│ └─ Apply frame transform (scale, offset) │
│ │
│ 8. Update lidar sensor group │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
## Implementation Details
### 1. Map Loader with Cliff Mask (`map_loader.rs`)
```rust
use std::path::Path;
use image::GrayImage;
/// Parsed map YAML metadata
#[derive(Debug, Deserialize)]
pub struct MapMetadata {
pub image: String,
pub resolution: f32,
pub origin: [f32; 3],
pub occupied_thresh: f32,
pub free_thresh: f32,
#[serde(default)]
pub negate: u8,
#[serde(default)]
pub cliff_mask: Option<String>, // Optional cliff mask file
}
/// Simulation map loaded from PGM+YAML
pub struct SimulationMap {
pixels: GrayImage,
cliff_mask: Option<GrayImage>, // Optional cliff layer
resolution: f32,
origin: (f32, f32),
occupied_thresh: u8,
free_thresh: u8,
}
impl SimulationMap {
pub fn load<P: AsRef<Path>>(yaml_path: P) -> Result<Self> {
let yaml_content = std::fs::read_to_string(&yaml_path)?;
let metadata: MapMetadata = serde_yaml::from_str(&yaml_content)?;
let yaml_dir = yaml_path.as_ref().parent().unwrap_or(Path::new("."));
let pgm_path = yaml_dir.join(&metadata.image);
let img = image::open(&pgm_path)?.into_luma8();
// Load optional cliff mask
let cliff_mask = if let Some(cliff_file) = &metadata.cliff_mask {
let cliff_path = yaml_dir.join(cliff_file);
Some(image::open(&cliff_path)?.into_luma8())
} else {
None
};
let occupied_thresh = (metadata.occupied_thresh * 255.0) as u8;
let free_thresh = ((1.0 - metadata.free_thresh) * 255.0) as u8;
Ok(Self {
pixels: img,
cliff_mask,
resolution: metadata.resolution,
origin: (metadata.origin[0], metadata.origin[1]),
occupied_thresh,
free_thresh,
})
}
/// Check if position is a cliff (from mask or map boundary)
pub fn is_cliff(&self, x: f32, y: f32) -> bool {
match self.world_to_pixel(x, y) {
Some((px, py)) => {
if let Some(mask) = &self.cliff_mask {
let pixel = mask.get_pixel(px, py).0[0];
pixel < 128 // Black = cliff
} else {
false // No mask, not a cliff
}
}
None => true, // Out of bounds = cliff
}
}
// ... rest of implementation (is_occupied, is_free, ray_cast, world_to_pixel)
}
```
### 2. Physics Engine with Collision Modes (`physics.rs`)
```rust
pub enum CollisionMode {
Stop,
Slide { friction: f32 },
Passthrough,
}
pub struct PhysicsState {
pub x: f32,
pub y: f32,
pub theta: f32,
pub linear_vel: f32,
pub angular_vel: f32,
pub left_ticks: u16,
pub right_ticks: u16,
collision_mode: CollisionMode,
robot_radius: f32,
}
impl PhysicsState {
pub fn update(&mut self, dt: f32, map: &SimulationMap, config: &RobotConfig) -> bool {
// Compute new position
let new_x = self.x + self.linear_vel * self.theta.cos() * dt;
let new_y = self.y + self.linear_vel * self.theta.sin() * dt;
let new_theta = self.theta + self.angular_vel * dt;
// Check collision
let would_collide = self.check_collision(new_x, new_y, map);
match &self.collision_mode {
CollisionMode::Stop => {
if would_collide {
// Stop movement, log collision
log::info!("Collision at ({:.3}, {:.3})", new_x, new_y);
return true; // Collision occurred
}
self.x = new_x;
self.y = new_y;
}
CollisionMode::Slide { friction } => {
if would_collide {
// Find collision normal and slide along surface
let (slide_x, slide_y) = self.compute_slide_vector(
new_x, new_y, *friction, map
);
self.x += slide_x * dt;
self.y += slide_y * dt;
log::info!("Sliding at ({:.3}, {:.3})", self.x, self.y);
return true;
}
self.x = new_x;
self.y = new_y;
}
CollisionMode::Passthrough => {
self.x = new_x;
self.y = new_y;
}
}
self.theta = normalize_angle(new_theta);
false // No collision
}
fn check_collision(&self, x: f32, y: f32, map: &SimulationMap) -> bool {
// Check multiple points around robot circumference
let num_checks = 8;
for i in 0..num_checks {
let angle = (i as f32 / num_checks as f32) * TAU;
let check_x = x + self.robot_radius * angle.cos();
let check_y = y + self.robot_radius * angle.sin();
if map.is_occupied(check_x, check_y) {
return true;
}
}
false
}
fn compute_slide_vector(&self, x: f32, y: f32, friction: f32, map: &SimulationMap) -> (f32, f32) {
// Simplified: find surface normal and project velocity
// Full implementation would use gradient of distance field
let vel_mag = self.linear_vel * (1.0 - friction);
// Try perpendicular directions
let perp1 = (self.theta + FRAC_PI_2).sin();
let perp2 = (self.theta + FRAC_PI_2).cos();
// Return the direction that doesn't collide
if !map.is_occupied(x + perp1 * 0.01, y + perp2 * 0.01) {
(perp1 * vel_mag, perp2 * vel_mag)
} else {
(-perp1 * vel_mag, -perp2 * vel_mag)
}
}
}
```
### 3. Bumper Simulator with Configurable Zones (`sensor_sim.rs`)
```rust
pub struct BumperZone {
pub start_angle: f32, // radians, relative to robot forward
pub end_angle: f32,
}
pub struct BumperSimulator {
left_zone: BumperZone,
right_zone: BumperZone,
trigger_distance: f32,
}
impl BumperSimulator {
pub fn check(&self, map: &SimulationMap, x: f32, y: f32, theta: f32, robot_radius: f32)
-> (bool, bool)
{
let left = self.check_zone(&self.left_zone, map, x, y, theta, robot_radius);
let right = self.check_zone(&self.right_zone, map, x, y, theta, robot_radius);
(left, right)
}
fn check_zone(&self, zone: &BumperZone, map: &SimulationMap,
x: f32, y: f32, theta: f32, robot_radius: f32) -> bool
{
// Check multiple rays within the bumper arc
let num_rays = 5;
let angle_range = zone.end_angle - zone.start_angle;
for i in 0..num_rays {
let local_angle = zone.start_angle + (i as f32 / (num_rays - 1) as f32) * angle_range;
let world_angle = theta + local_angle;
// Ray from robot edge in this direction
let start_x = x + robot_radius * world_angle.cos();
let start_y = y + robot_radius * world_angle.sin();
let check_x = start_x + self.trigger_distance * world_angle.cos();
let check_y = start_y + self.trigger_distance * world_angle.sin();
if map.is_occupied(check_x, check_y) {
return true;
}
}
false
}
}
```
### 4. Cliff Simulator (`sensor_sim.rs`)
```rust
pub struct CliffSensor {
pub name: String,
pub x: f32, // position relative to robot center
pub y: f32,
}
pub struct CliffSimulator {
sensors: Vec<CliffSensor>,
}
impl CliffSimulator {
pub fn check(&self, map: &SimulationMap, robot_x: f32, robot_y: f32, robot_theta: f32)
-> HashMap<String, bool>
{
let mut results = HashMap::new();
for sensor in &self.sensors {
// Transform sensor position to world frame
let world_x = robot_x
+ sensor.x * robot_theta.cos()
- sensor.y * robot_theta.sin();
let world_y = robot_y
+ sensor.x * robot_theta.sin()
+ sensor.y * robot_theta.cos();
let is_cliff = map.is_cliff(world_x, world_y);
results.insert(sensor.name.clone(), is_cliff);
}
results
}
}
```
### 5. Actuator Logger (`mod.rs`)
```rust
pub struct ActuatorState {
vacuum: bool,
vacuum_speed: u8,
main_brush: bool,
main_brush_speed: u8,
side_brush: bool,
side_brush_speed: u8,
water_pump: bool,
water_pump_speed: u8,
led_state: u8,
lidar_enabled: bool,
lidar_pwm: u8,
}
impl ActuatorState {
pub fn handle_command(&mut self, id: &str, action: &ComponentAction) {
match (id, action) {
("vacuum", ComponentAction::Enable { .. }) => {
self.vacuum = true;
log::info!("Actuator: vacuum ENABLED");
}
("vacuum", ComponentAction::Disable { .. }) => {
self.vacuum = false;
log::info!("Actuator: vacuum DISABLED");
}
("vacuum", ComponentAction::Configure { config }) => {
if let Some(SensorValue::U8(speed)) = config.get("speed") {
self.vacuum_speed = *speed;
log::info!("Actuator: vacuum speed = {}%", speed);
}
}
// ... similar for other actuators
_ => {
log::debug!("Actuator: ignoring command for '{}'", id);
}
}
}
}
```
## Device Factory Integration
Update `src/devices/mod.rs`:
```rust
pub mod crl200s;
pub mod mock;
use crl200s::CRL200SDriver;
use mock::MockDriver;
pub fn create_device(config: &Config) -> Result<Box<dyn DeviceDriver>> {
match config.device.device_type.as_str() {
"crl200s" => {
let driver = CRL200SDriver::new(config.device.clone())?;
Ok(Box::new(driver))
}
"mock" => {
let driver = MockDriver::new(config)?;
Ok(Box::new(driver))
}
_ => Err(Error::UnknownDevice(config.device.device_type.clone())),
}
}
```
## Usage Workflow
### 1. Create a Map
```bash
# Create map in GIMP or other image editor
# - White (255) = free space
# - Black (0) = walls/obstacles
# - Gray (205) = unknown
# Save as PGM (P5 binary or P2 ASCII)
# Create YAML metadata file
```
Example `maps/apartment.yaml`:
```yaml
image: apartment.pgm
resolution: 0.02
origin: [-5.0, -5.0, 0.0]
occupied_thresh: 0.65
free_thresh: 0.196
negate: 0
cliff_mask: apartment_cliffs.pgm # Optional
```
### 2. Create Mock Config
Copy the template from this proposal and customize:
- Set `map_file` to your map YAML path
- Adjust `start_x`, `start_y`, `start_theta` for initial pose
- Set `speed_factor` for faster testing (if needed)
- Tune noise parameters based on SLAM performance
### 3. Run Simulation
```bash
# Terminal 1: Start mock SangamIO
cd sangam-io
cargo run --release -- --config mock.toml
# Terminal 2: Connect SLAM/client
# (configure client to connect to 127.0.0.1:5555)
```
## Dependencies
**New dependencies** (add to `Cargo.toml`):
```toml
[dependencies]
image = "0.25" # PGM loading
serde_yaml = "0.9" # YAML parsing
rand = "0.8" # Noise generation
rand_distr = "0.4" # Normal distribution
```
## Testing Strategy
### Unit Tests
- Map loader: PGM+YAML parsing, cliff mask loading
- Physics: Differential drive kinematics, collision modes
- Noise: Statistical properties (mean, stddev match config)
- Bumpers: Zone detection accuracy
- Cliffs: Mask lookup, boundary detection
### Integration Tests
- **Protocol compatibility**: Mock sensor groups match CRL-200S format exactly
- **Determinism**: Same `random_seed` produces identical sensor streams
- **Speed factor**: Verify packet rates scale correctly (110Hz × factor)
### Manual Testing
- Connect Drishti and verify all sensor displays work
- Test collision modes (stop, slide, passthrough)
- Verify bumper/cliff triggers at expected positions
## Future Enhancements
1. **DhruvaSLAM map export**: Add PGM+YAML export to DhruvaSLAM for record→replay workflow
2. **Dynamic obstacles**: Moving obstacles in simulation
3. **Multiple robots**: Simulate multi-robot scenarios
4. **Sensor failures**: Inject failures for resilience testing
5. **GUI visualization**: Optional debug visualization window
6. **Virtual clock**: Support pause/step/resume for debugging
@@ -1,8 +1,11 @@
# GD32 Command Reference
# CRL-200S GD32 Command Reference
> **Device-specific documentation for the CRL-200S robotic vacuum platform.**
> This document describes the serial protocol between SangamIO and the GD32F103 motor controller MCU.
This document lists all known GD32 commands discovered from reverse-engineering the AuxCtrl binary, captured logs, and current SangamIO implementation.
**Source Reference:** `src/devices/crl200s/constants.rs`, `src/devices/crl200s/gd32/packet.rs`, `src/devices/crl200s/gd32/commands.rs`
**Source Code:** [`constants.rs`](constants.rs), [`gd32/packet.rs`](gd32/packet.rs), [`gd32/commands.rs`](gd32/commands.rs)
## Command Format
@@ -14,7 +17,7 @@ All commands use the packet format: `[0xFA 0xFB] [LEN] [CMD] [PAYLOAD] [CRC_H] [
- **PAYLOAD**: Variable length data
- **CRC**: 16-bit big-endian word sum checksum (except 0x08)
### CRC Calculation (from `packet.rs`)
### CRC Calculation (from [`gd32/packet.rs`](gd32/packet.rs))
```rust
fn checksum(data: &[u8]) -> u16 {
@@ -177,3 +180,10 @@ RobotCommand {
**Note:** The `Configure` action for lidar is ignored. PWM is controlled exclusively by `sangamio.toml`, not by upstream clients.
---
## Related Documentation
- [SENSORSTATUS.md](SENSORSTATUS.md) - Status packet byte layout (96 bytes)
- [`gd32/reader.rs`](gd32/reader.rs) - Status packet parsing implementation
- [`delta2d/protocol.rs`](delta2d/protocol.rs) - Lidar protocol (separate from GD32)
@@ -1,6 +1,7 @@
# GD32 Sensor Status Packet (0x15)
# CRL-200S GD32 Sensor Status Packet (0x15)
This document details the 96-byte sensor status packet sent by the GD32 motor controller at approximately 110Hz (limited by 115200 baud serial).
> **Device-specific documentation for the CRL-200S robotic vacuum platform.**
> This document describes the 96-byte sensor status packet sent by the GD32 motor controller.
## Packet Structure
@@ -60,7 +61,7 @@ Frequency: ~110 Hz (every ~9ms, limited by 115200 baud)
| 2 | 0x04 | Bumper Left | 1 = left bumper triggered |
| 3-7 | - | Reserved | - |
**Code Reference:** `constants.rs:97-98`
**Code Reference:** [`constants.rs`](constants.rs) lines 97-98
### Byte 0x03: Cliff Flags
@@ -72,7 +73,7 @@ Frequency: ~110 Hz (every ~9ms, limited by 115200 baud)
| 3 | 0x08 | Cliff Right Side | 1 = drop detected |
| 4-7 | - | Reserved | - |
**Code Reference:** `constants.rs:99-102`
**Code Reference:** [`constants.rs`](constants.rs) lines 99-102
### Byte 0x04: Dustbox Flags
@@ -84,7 +85,7 @@ Frequency: ~110 Hz (every ~9ms, limited by 115200 baud)
**Note:** This byte may also indicate box type (0x00 = normal dustbox, 0x04 = 2-in-1 mop box) based on MITM observations.
**Code Reference:** `constants.rs:103`
**Code Reference:** [`constants.rs`](constants.rs) line 103
### Byte 0x07: Charging Flags
@@ -94,7 +95,7 @@ Frequency: ~110 Hz (every ~9ms, limited by 115200 baud)
| 1 | 0x02 | Charging | 1 = actively charging |
| 2-7 | - | Reserved | - |
**Code Reference:** `constants.rs:95-96`
**Code Reference:** [`constants.rs`](constants.rs) lines 95-96
### Byte 0x08: Battery Voltage
@@ -109,25 +110,25 @@ BATTERY_VOLTAGE_MAX = 15.5V (100%)
percentage = (voltage - 13.5) / (15.5 - 13.5) * 100
```
**Code Reference:** `constants.rs:91-92`
**Code Reference:** [`constants.rs`](constants.rs) lines 91-92
### Bytes 0x10-0x11: Left Wheel Encoder
- **Type:** u16 little-endian
- **Description:** Encoder tick count for left wheel
- **Implementation:** Only lower 2 bytes are read by `reader.rs`
- **Implementation:** Only lower 2 bytes are read by [`gd32/reader.rs`](gd32/reader.rs)
- **Note:** Bytes 0x12-0x17 may contain upper bytes of a larger counter, but are currently unused
**Code Reference:** `constants.rs:67`, `reader.rs`
**Code Reference:** [`constants.rs`](constants.rs) line 67
### Bytes 0x18-0x19: Right Wheel Encoder
- **Type:** u16 little-endian
- **Description:** Encoder tick count for right wheel
- **Implementation:** Only lower 2 bytes are read by `reader.rs`
- **Implementation:** Only lower 2 bytes are read by [`gd32/reader.rs`](gd32/reader.rs)
- **Note:** Bytes 0x1A-0x1F may contain upper bytes of a larger counter, but are currently unused
**Code Reference:** `constants.rs:68`, `reader.rs`
**Code Reference:** [`constants.rs`](constants.rs) line 68
### IMU Data (0x28-0x33)
@@ -163,7 +164,7 @@ z = [0, -1] # output_z (Yaw) = input_x * -1 (sign flip)
- `gyro_y` = Pitch rate (Y axis rotation, nose up/down)
- `gyro_z` = Yaw rate (Z axis rotation, CCW positive)
**Code Reference:** `constants.rs:73-88`, `reader.rs:313-323`
**Code Reference:** [`constants.rs`](constants.rs) lines 73-88, [`gd32/reader.rs`](gd32/reader.rs) lines 313-323
### LP-Filtered Tilt Vector (0x34-0x39)
@@ -175,7 +176,7 @@ Low-pass filtered gravity vector for tilt correction. Useful for determining rob
| 0x36-0x37 | 2 | Tilt Y | i16 LE - LP gravity vector Y |
| 0x38-0x39 | 2 | Tilt Z | i16 LE - LP gravity vector Z |
**Code Reference:** `constants.rs:85-88`, `reader.rs:334-355`
**Code Reference:** [`constants.rs`](constants.rs) lines 85-88, [`gd32/reader.rs`](gd32/reader.rs) lines 334-355
### Bytes 0x3A-0x3B: Start Button
@@ -183,7 +184,7 @@ Low-pass filtered gravity vector for tilt correction. Useful for determining rob
- **Description:** Start button press state
- **Implementation:** Read as u16 from bytes 0x3A-0x3B
**Code Reference:** `constants.rs:70`, `reader.rs:223-229`
**Code Reference:** [`constants.rs`](constants.rs) line 70, [`gd32/reader.rs`](gd32/reader.rs) lines 223-229
### Bytes 0x3E-0x3F: Dock Button
@@ -191,7 +192,7 @@ Low-pass filtered gravity vector for tilt correction. Useful for determining rob
- **Description:** Dock/home button press state
- **Implementation:** Read as u16 from bytes 0x3E-0x3F
**Code Reference:** `constants.rs:71`, `reader.rs:230-236`
**Code Reference:** [`constants.rs`](constants.rs) line 71, [`gd32/reader.rs`](gd32/reader.rs) lines 230-236
### Byte 0x46: Water Tank Level
@@ -239,7 +240,7 @@ Legend:
The reader requires a minimum payload size of 80 bytes (`STATUS_PAYLOAD_MIN_SIZE`). Packets smaller than this are discarded.
**Code Reference:** `constants.rs:59`
**Code Reference:** [`constants.rs`](constants.rs) line 59
### Data Endianness
@@ -300,17 +301,18 @@ These may contain additional sensor data, error codes, or internal state. Furthe
---
## Related Files
## Related Documentation
- `src/devices/crl200s/constants.rs` - Offset and flag constants
- `src/devices/crl200s/gd32/reader.rs` - Status packet parsing logic
- `src/devices/crl200s/gd32/protocol.rs` - Packet framing and CRC
- `COMMANDS.md` - GD32 command reference
- [COMMANDS.md](COMMANDS.md) - GD32 command reference
- [`constants.rs`](constants.rs) - Offset and flag constants
- [`gd32/reader.rs`](gd32/reader.rs) - Status packet parsing logic
- [`gd32/protocol.rs`](gd32/protocol.rs) - Packet framing and CRC
---
## Changelog
- **2024-12-15:** Moved to `src/devices/crl200s/` directory, updated relative paths
- **2024-12-07:** Added coordinate frame transform documentation for IMU data (ROS REP-103 conversion)
- **2024-11-30:** Fixed encoder and button sizes to match actual reader.rs implementation (u16, not u64/u32)
- **2024-11-30:** Initial documentation based on reverse-engineered constants and MITM captures
@@ -63,7 +63,7 @@ const MODE_SWITCH_DELAY_MS: u64 = 100;
/// Interval between STM32 data requests in milliseconds.
///
/// The stock R2D firmware sends 0x0D requests approximately every 1.5 seconds,
/// as observed in MITM captures (see COMMANDS.md "Request STM32 Data" entry).
/// as observed in MITM captures (see [`../COMMANDS.md`](../COMMANDS.md) "Request STM32 Data" entry).
/// This appears to be a keep-alive/diagnostic query.
const STM32_REQUEST_INTERVAL_MS: u64 = 1500;
+51 -2
View File
@@ -1,7 +1,56 @@
//! Mock device simulation configuration
//!
//! Defines all configuration structures for simulating CRL-200S hardware.
//! All parameters have defaults matching the real hardware specifications.
//! This module defines all configuration structures for the mock device driver.
//! Every parameter has sensible defaults matching CRL-200S hardware specifications,
//! allowing minimal configuration for basic usage while enabling deep customization
//! for advanced testing scenarios.
//!
//! # Configuration Hierarchy
//!
//! ```text
//! SimulationConfig
//! ├── map_file, start_x/y/theta # Environment setup
//! ├── speed_factor, random_seed # Simulation control
//! ├── RobotConfig # Physical parameters
//! │ ├── wheel_base, ticks_per_meter
//! │ ├── max_linear/angular_speed
//! │ └── collision_mode, robot_radius
//! ├── LidarConfig # Lidar simulation
//! │ ├── num_rays, scan_rate_hz
//! │ ├── min/max_range, mounting offsets
//! │ └── LidarNoiseConfig
//! ├── ImuConfig # IMU simulation
//! │ ├── GyroNoiseConfig
//! │ ├── Noise3DConfig (accel)
//! │ └── Noise3DConfig (tilt)
//! ├── EncoderConfig # Encoder simulation
//! │ └── EncoderNoiseConfig
//! ├── BumpersConfig # Bumper zones
//! ├── CliffsConfig # Cliff sensor positions
//! ├── SensorsConfig # Battery, buttons, etc.
//! └── ActuatorsConfig # Logging options
//! ```
//!
//! # Default Values
//!
//! All defaults match CRL-200S hardware specifications:
//!
//! | Parameter | Default | Source |
//! |-----------|---------|--------|
//! | wheel_base | 0.233 m | Measured |
//! | ticks_per_meter | 4464 | Calibrated |
//! | max_linear_speed | 0.3 m/s | Hardware limit |
//! | max_angular_speed | 1.0 rad/s | Hardware limit |
//! | robot_radius | 0.17 m | Physical size |
//! | lidar_num_rays | 360 | Delta-2D spec |
//! | lidar_scan_rate | 5 Hz | Delta-2D spec |
//! | lidar_max_range | 8 m | Delta-2D spec |
//!
//! # Noise Configuration
//!
//! Noise parameters are initial estimates and should be calibrated based on
//! SLAM performance with real hardware data. Start with defaults, then adjust
//! if simulated performance differs significantly from real-world results.
use serde::Deserialize;
use std::f32::consts::PI;
+124 -3
View File
@@ -1,7 +1,128 @@
//! Mock device driver for simulation
//! Mock device driver for hardware-free robot simulation
//!
//! Provides a simulated CRL-200S robot for algorithm testing without hardware.
//! Uses PGM+YAML maps for environment simulation.
//! This module provides a complete simulation of CRL-200S robot hardware,
//! enabling SLAM and navigation algorithm development without physical robots.
//!
//! # Overview
//!
//! The mock driver simulates all sensors and actuators of the CRL-200S:
//!
//! | Component | Simulation Method |
//! |-----------|-------------------|
//! | Lidar (Delta-2D) | Ray-casting against occupancy grid |
//! | Wheel encoders | Differential drive kinematics + slip noise |
//! | IMU (gyro/accel) | Physics-based with configurable noise |
//! | Bumpers | Collision detection against map |
//! | Cliff sensors | Cliff mask lookup or boundary detection |
//! | Battery/buttons | Fixed configurable states |
//!
//! # Map Format
//!
//! Uses ROS-standard PGM + YAML format for maximum compatibility:
//!
//! ```yaml
//! # maps/example.yaml
//! image: example.pgm # Grayscale occupancy grid
//! resolution: 0.02 # meters per pixel
//! origin: [-5.0, -5.0, 0.0] # [x, y, yaw] of bottom-left pixel
//! occupied_thresh: 0.65 # Darker = occupied
//! cliff_mask: cliffs.pgm # Optional cliff layer
//! ```
//!
//! PGM pixel values:
//! - **White (255)**: Free space
//! - **Black (0)**: Occupied/wall
//! - **Gray (205)**: Unknown
//!
//! # Configuration
//!
//! Enable the `mock` feature to use this driver:
//!
//! ```bash
//! cargo build --features mock
//! cargo run --features mock -- mock.toml
//! ```
//!
//! Example configuration (`mock.toml`):
//!
//! ```toml
//! [device]
//! type = "mock"
//! name = "Mock CRL-200S"
//!
//! [device.simulation]
//! map_file = "maps/example.yaml"
//! start_x = 1.5
//! start_y = 3.5
//! start_theta = 0.0
//! speed_factor = 1.0 # 2.0 = 2x speed
//! random_seed = 42 # 0 = random each run
//!
//! [network]
//! bind_address = "0.0.0.0:5555"
//! ```
//!
//! # Simulation Loop
//!
//! The simulation runs at 110Hz (matching CRL-200S hardware):
//!
//! ```text
//! Every ~9ms:
//! 1. Read velocity commands from shared state
//! 2. Update physics (pose, collision detection)
//! 3. Generate encoder ticks from wheel velocities
//! 4. Generate IMU readings (gyro, accel, tilt)
//! 5. Check bumpers and cliff sensors
//! 6. Publish sensor_status via streaming channel
//!
//! Every ~200ms (5Hz):
//! 7. Generate lidar scan via ray-casting
//! 8. Update lidar sensor group
//! ```
//!
//! # Speed Factor
//!
//! The `speed_factor` setting accelerates simulation time:
//!
//! | Factor | sensor_status | lidar | Use Case |
//! |--------|---------------|-------|----------|
//! | 1.0 | 110 Hz | 5 Hz | Real-time testing |
//! | 2.0 | 220 Hz | 10 Hz | Faster algorithm iteration |
//! | 5.0 | 550 Hz | 25 Hz | Quick integration tests |
//!
//! # Noise Models
//!
//! All sensors include configurable noise for realistic testing:
//!
//! - **Lidar**: Range stddev, angle stddev, miss rate, quality decay
//! - **Encoders**: Wheel slip (multiplicative), quantization noise
//! - **IMU**: Per-axis stddev, bias, gyro drift rate
//!
//! # Thread Model
//!
//! ```text
//! ┌─────────────────┐
//! │ Main Thread │
//! │ (initialize) │
//! └────────┬────────┘
//! │ spawns
//! ▼
//! ┌─────────────────┐ ┌─────────────────┐
//! │ Simulation Loop │────▶│ Streaming Chan │
//! │ (mock-sim) │ │ (sensor_status) │
//! └─────────────────┘ └─────────────────┘
//! ```
//!
//! # Module Structure
//!
//! - [`config`]: Configuration structures for all simulation parameters
//! - [`physics`]: Differential drive kinematics and collision handling
//! - [`lidar_sim`]: Ray-casting lidar simulation
//! - [`imu_sim`]: IMU data generation with noise
//! - [`encoder_sim`]: Wheel encoder simulation with slip
//! - [`sensor_sim`]: Bumper, cliff, and binary sensor simulation
//! - [`map_loader`]: PGM + YAML map loading
//! - [`noise`]: Configurable noise generator
pub mod config;
mod encoder_sim;
+70 -3
View File
@@ -1,7 +1,74 @@
//! Streaming module for SangamIO
//! Network streaming module for SangamIO
//!
//! - **UDP**: Unicast sensor data to registered clients (fire-and-forget)
//! - **TCP**: Handles commands from clients (reliable, bidirectional)
//! This module provides the communication layer between SangamIO and client
//! applications (SLAM, visualization, etc.) using a hybrid UDP/TCP architecture.
//!
//! # Architecture Overview
//!
//! ```text
//! ┌───────────────────────────────────────────────────────────┐
//! │ SangamIO Daemon │
//! │ │
//! │ ┌─────────────────┐ ┌─────────────────┐ │
//! │ │ UDP Publisher │ │ TCP Receiver │ │
//! │ │ (port 5555) │ │ (port 5555) │ │
//! │ │ │ │ │ │
//! │ │ • sensor_status│ │ • Commands │ │
//! │ │ @ 110Hz │ │ • Client │ │
//! │ │ • lidar @ 5Hz │ │ registration │ │
//! │ └────────┬────────┘ └────────┬────────┘ │
//! │ │ │ │
//! └───────────────┼──────────────────────────┼────────────────┘
//! │ UDP unicast │ TCP stream
//! ▼ ▼
//! ┌───────────────────────────────────────────────────────────┐
//! │ Client Application │
//! │ (receives sensor data via UDP, sends commands via TCP) │
//! └───────────────────────────────────────────────────────────┘
//! ```
//!
//! # Protocol Design
//!
//! ## Why UDP for Sensor Data?
//!
//! - **Lower latency**: No TCP handshake overhead, no head-of-line blocking
//! - **Better for real-time**: Dropped packets are preferable to delayed ones
//! - **Reduced buffering**: No kernel TCP buffer delays
//! - **Fire-and-forget**: Server doesn't wait for ACKs
//!
//! ## Why TCP for Commands?
//!
//! - **Reliability**: Commands must not be lost or duplicated
//! - **Ordering**: Commands must execute in order
//! - **Acknowledgment**: Caller knows command was received
//!
//! # Wire Format
//!
//! Both UDP and TCP use the same length-prefixed Protobuf format for
//! client compatibility:
//!
//! ```text
//! ┌──────────────────┬─────────────────────┐
//! │ Length (4 bytes) │ Protobuf Payload │
//! │ Big-endian u32 │ (variable size) │
//! └──────────────────┴─────────────────────┘
//! ```
//!
//! # Client Registration
//!
//! UDP streaming uses unicast (not broadcast) for security and efficiency.
//! Clients must first connect via TCP to register their UDP endpoint:
//!
//! 1. Client connects to TCP port 5555
//! 2. Server records client's IP address
//! 3. UDP packets are sent to client's IP on same port
//! 4. When TCP disconnects, UDP streaming to that client stops
//!
//! # Module Contents
//!
//! - [`UdpPublisher`]: Streams sensor data to registered clients
//! - [`TcpReceiver`]: Handles incoming commands and client registration
//! - [`wire`]: Protobuf serialization/deserialization
pub mod tcp_receiver;
pub mod udp_publisher;
+54 -1
View File
@@ -1,4 +1,57 @@
//! TCP receiver thread - receives commands from client
//! TCP command receiver for client control
//!
//! This module handles incoming commands from connected clients over TCP.
//! Commands include robot motion control, actuator control, and component
//! enable/disable operations.
//!
//! # Purpose
//!
//! TCP is used for commands (not UDP) because:
//!
//! - **Reliability**: Commands must not be lost (e.g., "stop motors")
//! - **Ordering**: Commands must execute in sequence
//! - **Acknowledgment**: Sender knows the command was received
//! - **State sync**: TCP connection state tracks client presence
//!
//! # Wire Format
//!
//! Commands use length-prefixed Protobuf encoding:
//!
//! ```text
//! ┌──────────────────┬─────────────────────┐
//! │ Length (4 bytes) │ Protobuf Command │
//! │ Big-endian u32 │ (variable size) │
//! └──────────────────┴─────────────────────┘
//! ```
//!
//! # Command Types
//!
//! | Command | Description |
//! |---------|-------------|
//! | `ComponentControl::drive` | Set linear/angular velocity |
//! | `ComponentControl::lidar` | Enable/disable lidar motor |
//! | `ComponentControl::vacuum` | Control vacuum motor speed |
//! | `ComponentControl::main_brush` | Control main brush speed |
//! | `ComponentControl::side_brush` | Control side brush speed |
//! | `ComponentControl::water_pump` | Control water pump speed |
//! | `ComponentControl::led` | Set LED state |
//! | `Shutdown` | Graceful daemon shutdown |
//!
//! # Connection Lifecycle
//!
//! ```text
//! 1. Client connects to TCP port 5555
//! 2. Server spawns TcpReceiver thread for this client
//! 3. Client IP is registered for UDP streaming
//! 4. Receiver loop processes commands until disconnect
//! 5. On disconnect, UDP registration is cleared
//! ```
//!
//! # Safety Features
//!
//! - **Read timeout**: 500ms timeout allows periodic shutdown flag checks
//! - **Buffer limit**: Commands > 1MB are rejected (DoS protection)
//! - **Graceful shutdown**: Handles both global and per-connection flags
use crate::core::driver::DeviceDriver;
use crate::core::types::Command;
+66 -6
View File
@@ -1,11 +1,71 @@
//! UDP publisher thread - streams sensor data to registered clients
//! UDP publisher for real-time sensor data streaming
//!
//! Supports two modes for different data rates:
//! - **Streaming mode**: For high-rate sensors (GD32 @ 110Hz), consumes from channel
//! - **Polling mode**: For low-rate sensors (lidar @ 5Hz), polls shared mutex
//! This module implements the UDP unicast publisher that streams sensor data
//! to registered clients. It's optimized for low-latency, high-frequency
//! sensor delivery on embedded systems.
//!
//! Uses UDP unicast to registered clients only (not broadcast).
//! Clients are registered when they connect via TCP for commands.
//! # Design Principles
//!
//! ## Two Data Delivery Modes
//!
//! Different sensors have different update rates and delivery requirements:
//!
//! | Mode | Sensors | Rate | Mechanism |
//! |------|---------|------|-----------|
//! | Streaming | sensor_status (GD32) | 110Hz | Crossbeam channel |
//! | Polling | lidar, device_version | 5Hz | Shared mutex |
//!
//! **Streaming mode** uses lock-free channels for high-rate sensors where
//! every packet matters and we need to minimize latency.
//!
//! **Polling mode** uses shared mutexes for low-rate sensors where it's
//! acceptable to occasionally miss an update if the publisher is busy.
//!
//! ## Unicast vs Broadcast
//!
//! UDP streaming uses **unicast** (not broadcast) because:
//!
//! - **Security**: Only authorized clients receive data
//! - **Efficiency**: No processing overhead on non-listening hosts
//! - **Firewall-friendly**: Works across subnets with proper routing
//! - **Single client**: Robot typically has one SLAM client at a time
//!
//! # Client Registration
//!
//! Clients are automatically registered when they connect via TCP:
//!
//! ```text
//! 1. Client connects TCP to port 5555
//! 2. TcpReceiver extracts client IP from socket
//! 3. Client IP is stored in UdpClientRegistry
//! 4. UdpPublisher sends packets to registered IP
//! 5. When TCP disconnects, registration is cleared
//! ```
//!
//! # Wire Format
//!
//! Each UDP datagram uses the same format as TCP for client compatibility:
//!
//! ```text
//! ┌──────────────────┬──────────────────────┐
//! │ Length (4 bytes) │ Protobuf SensorGroup │
//! │ Big-endian u32 │ (~150 bytes typical) │
//! └──────────────────┴──────────────────────┘
//! ```
//!
//! # Performance Characteristics
//!
//! - **Buffer reuse**: Pre-allocated send buffer avoids allocation per packet
//! - **Non-blocking**: Channel draining and mutex polling are non-blocking
//! - **Rate limiting**: 500μs minimum sleep between packets to yield CPU
//! - **Graceful degradation**: Packet loss is logged but doesn't block sender
//!
//! # Example Packet Rates
//!
//! At default settings:
//! - sensor_status: ~110 packets/sec (~16.5 KB/s)
//! - lidar: ~5 packets/sec (~10 KB/s with 360 points)
//! - device_version: 1 packet (one-time)
use crate::core::types::{SensorGroupData, StreamReceiver};
use crate::error::Result;