# HansonServo Protocol Migration Plan ## Overview The firmware has been updated from a simple XOR-checksum protocol to a more robust CRC16 tagged packet protocol. This document describes the changes needed in the desktop software. --- ## Protocol Changes Summary | Aspect | Old Protocol | New Protocol | |--------|--------------|--------------| | Sync bytes | `0xAA 0x55` | `0xA5 0x5A` | | Checksum | XOR (1 byte) | CRC16-CCITT (2 bytes) | | Command ID | 1 byte numeric | 4 byte ASCII tag | | Sequence | None | 2 byte counter | | Baud rate | 1,000,000 | 1,000,000 (unchanged) | --- ## New Packet Format ``` ┌──────┬──────┬─────────┬─────────┬─────────┬───────────┬─────────┐ │ SYNC │ SYNC │ TAG │ LENGTH │ SEQ │ PAYLOAD │ CRC16 │ │ 0xA5 │ 0x5A │ 4 bytes │ 2 bytes │ 2 bytes │ N bytes │ 2 bytes │ └──────┴──────┴─────────┴─────────┴─────────┴───────────┴─────────┘ ``` ### Field Details | Field | Size | Description | |-------|------|-------------| | SYNC0 | 1 | Always `0xA5` | | SYNC1 | 1 | Always `0x5A` | | TAG | 4 | ASCII identifier (e.g., "IDNT", "MSET") | | LENGTH | 2 | Payload length, little-endian | | SEQ | 2 | Sequence number, little-endian | | PAYLOAD | N | Command-specific data | | CRC16 | 2 | CRC16-CCITT over TAG+LENGTH+SEQ+PAYLOAD, little-endian | ### CRC16-CCITT Implementation ```python def crc16_ccitt(data: bytes, init: int = 0xFFFF) -> int: crc = init for byte in data: crc ^= byte << 8 for _ in range(8): if crc & 0x8000: crc = (crc << 1) ^ 0x1021 else: crc <<= 1 crc &= 0xFFFF return crc ``` ```csharp // C# implementation ushort Crc16Ccitt(byte[] data) { ushort crc = 0xFFFF; foreach (byte b in data) { crc ^= (ushort)(b << 8); for (int i = 0; i < 8; i++) { if ((crc & 0x8000) != 0) crc = (ushort)((crc << 1) ^ 0x1021); else crc <<= 1; } } return crc; } ``` --- ## Command Tag Mapping ### Old → New Command Mapping | Old Command | Old ID | New Tag | Notes | |-------------|--------|---------|-------| | CMD_ID_REQUEST | 0x01 | `IDNT` | Identity request | | CMD_FILE_LIST | 0x02 | `FLST` | List files | | CMD_LOAD_FILE | 0x03 | `FLOD` | Load file content | | CMD_DELETE_FILE | 0x04 | `FDEL` | Delete file | | CMD_SAVE_FILE | 0x05 | `FSAV` | Save animation | | CMD_MESSAGE | 0x06 | `MSGE` | Log/debug message | | CMD_SET_POSITION | 0x07 | `MSET` | Set motor positions | | CMD_PLAY_FILE | 0x08 | `FPLY` | Play animation | | CMD_STOP_FILE | 0x09 | `FSTP` | Stop animation | | CMD_SCAN_CHANNEL | 0x09 | `MSCN` | Scan for motors | | CMD_WRITE_DATA | 0x10 | `MWRT` | Write motor register | | CMD_WRITE_CONFIG_UPDATE | 0x12 | `CONF` | Update config | | CMD_START_POSITION_STREAM | 0x14 | `MSTM` | Motor stream control | | POSITION_STREAM | 0x15 | `MPOS` | Motor position data | ### New Tags (not in old protocol) | Tag | Description | |-----|-------------| | `IMU0` | IMU data (accel x,y,z) | | `RDAR` | Radar target data | | `BHVR` | Behavior control (enable/disable) | | `BLST` | Behavior list (list all behaviors and states) | | `VLST` | List all visemes with names and motor positions | | `VADD` | Add a new viseme with name | | `VDEL` | Delete a viseme by ID | | `VSET` | Set motor positions for a viseme | | `VSME` | Trigger viseme (fire-and-forget) | | `STAT` | System state/heartbeat | | `FACE` | Face detection data (Radxa → host/robot, via WiFi) | | `ALIV` | Remote component alive/heartbeat (component ID + status) | | `SSET` | Settings set/dump (read/write individual settings by ID) | | `ACK!` | Acknowledge (success) | | `NACK` | Negative acknowledge (failure) | | `BOOT` | Enter bootloader | --- ## Detailed Command Reference ### Identity & Configuration #### `IDNT` - Get Robot Identity **Request:** Empty payload **Response:** Robot config serialized bytes (same format as before) #### `CONF` - Update Configuration **Request:** Same payload format as old CMD_WRITE_CONFIG_UPDATE **Response:** `ACK!` on success, `NACK` with reason on failure --- ### File Operations #### `FLST` - List Files **Request:** Empty payload **Response:** Newline-separated filename list (UTF-8 string) #### `FLOD` - Load File **Request:** Filename as raw bytes (no length prefix) **Response:** File contents as raw bytes, or `NACK` if not found #### `FSAV` - Save Animation **Request:** Same format as old CMD_SAVE_FILE: ``` [filename_len: 2 bytes LE] [filename: N bytes] [animation_header: 18 bytes] [curve_segments: variable] [node_graph: variable] ``` **Response:** `ACK!` on success, `NACK` on failure #### `FDEL` - Delete File **Request:** ``` [filename_len: 2 bytes LE] [filename: N bytes] ``` **Response:** `ACK!` on success #### `FPLY` - Play Animation **Request:** ``` [filename_len: 2 bytes LE] [filename: N bytes] [play_mode: 1 byte] // 0=idle, 1=once, 2=loop, 3=repeat [repeat_count: 1 byte] [start_frame: 2 bytes LE] // Frame number to start playback from (0-based) ``` **Response:** `ACK!` on success, `NACK` if file not found **Notes:** - `start_frame` allows resuming playback from a specific frame - FRAME packets report actual frame numbers (i.e., if start_frame=163, FRAME packets will show 163, 164, 165...) #### `FSTP` - Stop Animation **Request:** Empty payload (0 bytes) **Response:** `ACK!` on success **Notes:** - Immediately stops the currently playing animation regardless of play mode - Motors remain in their current positions (torque not disabled) - No FRAME completion packet is sent --- ### Motor Control #### `MSET` - Set Motor Positions **Request:** Array of motor commands: ``` [motor_id: 1 byte][position: 2 bytes LE] × N motors ``` **Response:** `ACK!` #### `MPOS` - Motor Position Stream (device → host) **Payload:** Same format as MSET request ``` [motor_id: 1 byte][position: 2 bytes LE] × N motors ``` *Sent automatically when streaming is enabled* #### `MSCN` - Scan for Motors **Request:** ``` [channel: 1 byte] // 0 or 1 ``` **Response:** Multiple packets, one per found motor: ``` [channel: 1][motor_id: 1][model: 2][min_angle: 2][max_angle: 2] [position: 2][cw_dead: 1][ccw_dead: 1][offset: 2][mode: 1] [torque_enable: 1][acceleration: 1][goal_pos: 2][goal_time: 2] [goal_speed: 2][lock: 1][speed: 2][load: 2][temp: 1][moving: 1] [current: 2][voltage: 1] ``` Final packet has `motor_id = 255` to signal scan complete. #### `MWRT` - Write Motor Register **Request:** ``` [channel: 1 byte] [motor_id: 1 byte] [register: 1 byte] [data_len: 1 byte] // 1 or 2 [data: 1-2 bytes] ``` **Response:** Register read-back value (1 or 2 bytes) *Special case:* Register 5 with 1 byte changes the motor ID. #### `MSTM` - Motor Stream Control **Request:** ``` [enable: 1 byte] // 0=disable, 1=enable ``` **Response:** `ACK!` When enabled, device streams `MPOS` packets every 50ms. --- ### Sensors #### `IMU0` - IMU Data (device → host) **Payload:** ``` [accelX: 2 bytes LE, signed] // g-forces × 100 [accelY: 2 bytes LE, signed] // g-forces × 100 [accelZ: 2 bytes LE, signed] // g-forces × 100 [pitch: 2 bytes LE, signed] // degrees × 100 [roll: 2 bytes LE, signed] // degrees × 100 ``` *Sent automatically when IMU streaming is enabled* **Coordinate System:** - X: left/right axis (affects roll) - Y: front/back axis (affects pitch) - Z: up/down axis **Notes:** - Acceleration values scaled by 100 (not 1000 as before) - Euler angles calculated from accelerometer data - Heading/yaw not available (accelerometer only) #### `RDAR` - Radar Data (device → host) **Payload:** ``` [target_count: 1 byte] For each of 3 targets: [valid: 1 byte] // 0 or 1 [x: 2 bytes LE] // cm × 10, signed [y: 2 bytes LE] // cm × 10, signed [speed: 2 bytes LE] // cm/s × 10, signed ``` *Sent automatically when radar streaming is enabled* #### `FACE` - Face Detection Data (Radxa → host/robot via WiFi) **Payload:** ``` [face_count: 1 byte] // 0–N faces For each face: [x: 2 bytes LE, signed] // center-relative pixels (0,0 = image center) [y: 2 bytes LE, signed] [w: 2 bytes LE] // bounding box width [h: 2 bytes LE] // bounding box height [conf: 1 byte] // confidence × 255 (0–255) ``` **Notes:** - Sent from Radxa over WebSocket when faces are detected - Radxa runs `face_server.py` (WebSocket server); robot/host connects to receive packets - x, y are offset from image center: positive = right/down, negative = left/up - Same packet format as serial (0xA5 0x5A + TAG + LENGTH + SEQ + PAYLOAD + CRC16) #### `ALIV` - Remote Component Alive (Radxa/other → host/robot via WiFi) **Payload:** ``` [component_id: 1 byte] // Assigned ID for each remote component [alive: 1 byte] // 0 = not alive / stream disconnected, 1 = alive // Future: extended payload for other devices ``` **Component IDs:** - `3` = Face detection (Radxa `face_server.py` – alive = MJPEG stream connected) **Notes:** - Sent every 2 seconds by each remote component - Use `alive` to detect when a component has lost its connection and is retrying --- ### Behaviors #### `BHVR` - Behavior Control (host → device) **Request:** ``` [behaviorID: 1 byte] // Behavior ID (1 = Focus) [enable: 1 byte] // 0 = disable, non-zero = enable ``` **Response:** `ACK!` on success, `NACK` on failure **Behavior IDs:** - `1` = Focus (face tracking with eye motors 14 & 15) - `2` = Idle (perlin noise motion for all motors, ±500 range from center) - `3` = Viseme (mouth motor control for speech) #### `BLST` - Behavior List (host → device) **Request:** Empty payload **Response:** ``` [count: 1 byte] // Number of registered behaviors [behaviorID1: 1 byte][enabled1: 1 byte] // First behavior [behaviorID2: 1 byte][enabled2: 1 byte] // Second behavior ... ``` - `enabled`: 1 = enabled, 0 = disabled --- ### Visemes #### `VLST` - List Visemes (host → device) **Request:** Empty payload **Response:** ``` [count: 1 byte] // Number of visemes For each viseme: [viseme_id: 1 byte] [label: 3 bytes] // 3-character label (e.g., "AA ", "SIL") [motor_count: 1 byte] For each motor: [motor_id: 1 byte] [position_low: 1 byte] [position_high: 1 byte] ``` Position = `position_low | (position_high << 8)`, range 0-4095 #### `VADD` - Add Viseme (host → device) **Request:** ``` [label: 3 bytes] // 3-character label (e.g., "AA ", "SIL") ``` **Response:** `ACK!` with payload `[new_viseme_id: 1 byte]` on success, `NACK` on failure #### `VDEL` - Delete Viseme (host → device) **Request:** ``` [viseme_id: 1 byte] ``` **Response:** `ACK!` on success, `NACK` if viseme not found #### `VSET` - Set Viseme Motor Positions (host → device) **Request:** ``` [viseme_id: 1 byte] [motor_count: 1 byte] For each motor: [motor_id: 1 byte] [position_low: 1 byte] [position_high: 1 byte] ``` **Response:** `ACK!` on success, `NACK` if viseme not found #### `VSME` - Trigger Viseme (host → device) **Request:** ``` [viseme_id: 1 byte] ``` **Response:** None (fire-and-forget) **Notes:** - Triggers the viseme behavior which controls the motors defined for that viseme - The behavior activates and holds the motor positions - After 3 seconds without a new viseme trigger, the behavior deactivates and releases motor control - Continuously sending viseme IDs will keep the mouth animated - Viseme behavior has higher priority than Idle behavior, lower than Focus **Default Viseme IDs (loaded at startup):** - `0` = Neutral/rest (sil) - motors 40, 43, 44 - `1` = AA (as in "father") - `2` = AE (as in "cat") - `3` = AH (as in "but") - `4` = AO (as in "bought") - `5` = EH (as in "bed") - `6` = IH (as in "bit") - `7` = IY (as in "beat") - `8` = OW (as in "boat") - `9` = UH (as in "book") - `10` = UW (as in "boot") --- ### Settings #### `SSET` - Settings Set/Dump (host ↔ device) **Write a single setting:** **Request:** ``` [setting_id: 2 bytes LE] [data: N bytes] // 2 bytes for numeric, variable for strings ``` **Response:** `ACK!` on success, `NACK` if unknown setting ID **Notes:** Writing a WiFi/WebSocket setting triggers an automatic reconnect. **Dump all settings:** **Request:** Empty payload (0 bytes) **Response:** `SSET` packet: ``` [count: 2 bytes LE] For each setting: [setting_id: 2 bytes LE] [data_len: 2 bytes LE] [data: data_len bytes] ``` **Value encoding:** - `uint8`/`uint16`/`bool`: `data_len=2`, stored as uint16 LE - `float` (0.0–65.535): `data_len=2`, stored as `value × 1000` (e.g., 0.15 → 150) - `int16` (signed): `data_len=2`, stored as uint16 reinterpret (e.g., -140 → 0xFF74) - `string`: `data_len=N`, raw UTF-8 bytes (no null terminator) **Focus Behavior Setting IDs (0x0500–0x0512):** | ID | Name | Type | Default | Description | |----|------|------|---------|-------------| | `0x0500` | FOCUS_EYE_MOTOR_1 | uint8 | 14 | Eye motor 1 ID | | `0x0501` | FOCUS_EYE_MOTOR_2 | uint8 | 15 | Eye motor 2 ID | | `0x0502` | FOCUS_NECK_MOTOR | uint8 | 27 | Neck yaw motor ID | | `0x0503` | FOCUS_EYE_CENTER | uint16 | 2200 | Eye servo center position | | `0x0504` | FOCUS_EYE_MIN | uint16 | 1700 | Eye servo min position | | `0x0505` | FOCUS_EYE_MAX | uint16 | 2500 | Eye servo max position | | `0x0506` | FOCUS_NECK_CENTER | uint16 | 2000 | Neck servo center position | | `0x0507` | FOCUS_NECK_MIN | uint16 | 1000 | Neck servo min position | | `0x0508` | FOCUS_NECK_MAX | uint16 | 3000 | Neck servo max position | | `0x0509` | FOCUS_FACE_X_MIN | int16 | -140 | Face detection X min (pixels) | | `0x050A` | FOCUS_FACE_X_MAX | int16 | 140 | Face detection X max (pixels) | | `0x050B` | FOCUS_EYE_SPEED | float×1000 | 150 | Eye dart speed (0–1) | | `0x050C` | FOCUS_NECK_SPEED | float×1000 | 20 | Neck follow speed (0–1) | | `0x050D` | FOCUS_EYE_RETURN_SPEED | float×1000 | 50 | Eye return-to-center speed | | `0x050E` | FOCUS_NECK_DELAY_MS | uint16 | 500 | Neck start delay (ms) | | `0x050F` | FOCUS_NECK_CONTRIBUTION | float×1000 | 700 | Neck offset coverage (0–1) | | `0x0510` | FOCUS_NECK_INVERT | bool | 1 | Invert neck motor direction | | `0x0511` | FOCUS_EYE_CENTERING | float×1000 | 30 | Eye centering speed (no face) | | `0x0512` | FOCUS_NECK_CENTERING | float×1000 | 20 | Neck centering speed (no face) | **WiFi / WebSocket Setting IDs (0x0600–0x0604):** | ID | Name | Type | Default | Description | |----|------|------|---------|-------------| | `0x0600` | WIFI_SSID | string | "Police Surveillance Van" | WiFi network name (max 32 chars) | | `0x0601` | WIFI_PASSWORD | string | "ourpassword" | WiFi password (max 64 chars) | | `0x0602` | WIFI_HOST | string | "192.168.1.206" | WebSocket server IP/hostname (max 63 chars) | | `0x0603` | WIFI_PORT | uint16 | 5001 | WebSocket server port | | `0x0604` | WIFI_PATH | string | "/" | WebSocket URL path (max 31 chars) | **Notes:** - All settings are persisted to flash on write - The dump includes all registered settings with a length prefix per entry - Setting ranges are extensible: `0x0500` = Focus, `0x0600` = WiFi, future = `0x0700+` --- ### System #### `STAT` - System State/Heartbeat (device → host) **Payload:** ``` [uptime: 4 bytes LE] // seconds since boot [flags: 2 bytes LE] // bit flags ``` **Flags:** - Bit 0: IMU ready - Bit 1: Animation playing - Bit 2: Motor streaming active - Bit 3: IMU streaming active - Bit 4: Radar streaming active - Bit 5: Face streaming active - Bit 6: Face detection remote alive *Sent automatically every 1 second* #### `MSGE` - Log Message (device → host) **Payload:** UTF-8 string (no null terminator) #### `ACK!` - Acknowledge **Payload:** ``` [original_tag: 4 bytes] // The tag being acknowledged ``` #### `NACK` - Negative Acknowledge **Payload:** ``` [original_tag: 4 bytes] [reason: N bytes, optional UTF-8 string] ``` #### `BOOT` - Enter Bootloader **Request:** Empty payload **Response:** `MSGE` "Entering bootloader...", then device resets --- ## Implementation Checklist ### 1. Protocol Layer Changes - [ ] Update sync byte detection from `0xAA 0x55` to `0xA5 0x5A` - [ ] Implement CRC16-CCITT calculation - [ ] Update packet parsing to handle new format: - [ ] Read 4-byte tag instead of 1-byte command - [ ] Read 2-byte sequence number (can ignore for now, or use for debugging) - [ ] Verify CRC16 instead of XOR checksum - [ ] Update packet building: - [ ] Use 4-byte tags - [ ] Add sequence counter (increment per packet) - [ ] Calculate and append CRC16 ### 2. Command Handler Updates - [ ] Replace command ID constants with tag strings - [ ] Update request builders for each command - [ ] Update response parsers for each command - [ ] Add handlers for new response types: - [ ] `ACK!` - generic success - [ ] `NACK` - generic failure with reason - [ ] `STAT` - heartbeat (can use to detect connection) - [ ] `IMU0` - IMU data (if needed) - [ ] `RDAR` - radar data (if needed) ### 3. UI/UX Improvements (Optional) - [ ] Show connection status based on `STAT` heartbeat - [ ] Display IMU orientation if sensor is available - [ ] Show radar targets if sensor is available --- ## Example: Sending a Motor Position Command ### Old Code (pseudocode) ```python def send_motor_positions(motors): payload = b'' for motor_id, position in motors: payload += bytes([motor_id]) payload += struct.pack('> 8) ^ (length & 0xFF) for b in payload: checksum ^= b packet = bytes([0xAA, 0x55, CMD_SET_POSITION]) packet += struct.pack('>H', length) # big-endian length packet += payload packet += bytes([checksum]) serial.write(packet) ``` ### New Code (pseudocode) ```python def send_motor_positions(motors): tag = b'MSET' payload = b'' for motor_id, position in motors: payload += bytes([motor_id]) payload += struct.pack(':81/` All commands from the serial protocol are supported over WebSocket: `VSME`, `MSET`, `BHVR`, `SSET`, `FPLY`, `FSTP`, etc. Packets must use the same binary framing as serial: `0xA5 0x5A + TAG(4) + LEN(2) + SEQ(2) + PAYLOAD(N) + CRC16(2)` Up to 2 simultaneous WebSocket client connections are supported.