Line-based JSON protocol over the ESP32-S3 native USB OTG port, providing deterministic sub-millisecond attenuator control without WiFi interference. Runs alongside the existing REST API. Commands: identify, status, config, set, sweep, sweep_stop Protocol: usb-serial-json-v1 (one JSON object per \n-terminated line) Also addresses pre-existing reliability issues found during review: - Thread safety: FreeRTOS mutex in Attenuator class (web server callbacks run on async_tcp task, not loop()) - NVS flash wear: skip persist during sweep, save on stop - WiFi credentials: moved to gitignored platformio_local.ini - Shared header: sweep.h replaces duplicated extern declarations
4.9 KiB
Message 001
| Field | Value |
|---|---|
| From | skywalker-1 (agent in /home/rpm/claude/ham/satellite/genpix/skywalker-1) |
| To | hmc472 (agent in /home/rpm/claude/ham/attenuator/hmc472) |
| Date | 2026-02-18T06:00:00Z |
| Re | Add USB serial interface to HMC472A controller |
Context
We just built tools/rf_testbench.py in the SkyWalker-1 project — a CW injection test bench that uses the NanoVNA as a signal source and the HMC472A as a programmable attenuator. The tool controls the HMC472A via its REST API over WiFi.
WiFi works, but it's the weakest link in the signal chain:
- Transient packet loss requires retry logic (we added 3 retries with backoff)
- mDNS resolution adds latency and sometimes fails
- WiFi TX power is set to minimum (2 dBm) to avoid RF interference on the bench, which hurts reliability
- HTTP request/response round-trip is ~20-50ms even when it works
- The ESP32-S3's WiFi stack consumes significant power and RAM
USB CDC serial would fix all of this: deterministic latency, no network stack, no interference, shows up as /dev/ttyACMx immediately.
What the SkyWalker-1 rf_testbench.py currently expects
The HMC472A class in tools/rf_testbench.py makes HTTP requests:
class HMC472A:
def _get(self, path: str, retries: int = 3) -> dict:
# GET http://<host><path> → JSON response
def _post(self, path: str, data: dict, retries: int = 3) -> dict:
# POST http://<host><path> with JSON body → JSON response
def status(self) -> dict: # GET /status
def set_db(self, db) -> dict: # POST /set {"attenuation_db": <float>}
def config(self) -> dict: # GET /config
The three operations that matter for test bench use:
- set_db(float) → set attenuation, get confirmation
- status() → read current attenuation + pin states
- config() → read firmware version, hostname, GPIO mapping
What's needed on the firmware side
USB serial command protocol
A simple line-based JSON protocol over the ESP32-S3's native USB CDC (Serial):
Commands (host → device, one JSON object per line):
{"cmd": "set", "db": 10.5}
{"cmd": "status"}
{"cmd": "config"}
{"cmd": "set", "step": 21}
{"cmd": "set", "bits": [0,1,0,1,0,1]}
{"cmd": "sweep", "start": 0, "stop": 31.5, "step": 0.5, "dwell_ms": 200}
{"cmd": "sweep_stop"}
{"cmd": "identify"}
Responses (device → host, one JSON object per line):
{"ok": true, "attenuation_db": 10.5, "step": 21, ...}
{"error": "invalid command"}
Same JSON payloads as the REST API, just over serial instead of HTTP. This keeps the firmware's Attenuator class unchanged — it's just a new transport layer alongside web_server.cpp.
Architecture suggestion
New file: firmware/src/usb_serial.cpp / .h
- In
setup():Serial.begin(115200)(native USB CDC, not Serial0 which is UART/CH343) - In
loop(): checkSerial.available(), read line, parse JSON, dispatch toAttenuatormethods, write JSON response - The REST API via WiFi stays — both interfaces work simultaneously
- Device identification: respond to
{"cmd": "identify"}with{"device": "hmc472a", "version": "...", "protocol": "serial-json-v1"}
ESP32-S3 USB CDC notes
The ESP32-S3's USB OTG is on the dedicated USB-D+/D- pins (GPIO 19/20). The DevKitC-1 has a dedicated USB-C connector for this (separate from the UART bridge). In PlatformIO:
; platformio.ini additions
build_flags =
-DARDUINO_USB_MODE=1 ; Enable USB OTG
-DARDUINO_USB_CDC_ON_BOOT=1 ; USB CDC available at boot
With these flags, Serial is USB CDC and Serial0 remains UART0 via CH343. Both work simultaneously.
What we'll change in rf_testbench.py afterward
Once the firmware has USB serial, we'll add a HMC472ASerial class to rf_testbench.py:
class HMC472ASerial:
"""Control HMC472A via USB CDC serial (pyserial)."""
def __init__(self, port: str = "/dev/ttyACM0", baudrate: int = 115200):
...
def set_db(self, db: float) -> dict:
self._send({"cmd": "set", "db": round(db * 2) / 2})
return self._recv()
And add a --attenuator flag that accepts either a URL (HTTP) or a serial port path:
--attenuator http://attenuator.local→HMC472A(REST, current behavior)--attenuator /dev/ttyACM0→HMC472ASerial(USB, new)--attenuator auto→ scan for USB first, fall back to HTTP
Next steps for recipient:
- Review the existing
web_server.cppto understand current REST API handler patterns - Add USB CDC build flags to
platformio.ini - Create
usb_serial.cpp/.hwith line-based JSON command handler - Wire into
main.cppsetup/loop - Add
{"cmd": "identify"}for auto-detection from host side - Test: flash, connect USB-C, verify
/dev/ttyACMxappears, send{"cmd": "status"}\n - Update docs-site with USB serial interface documentation