diff --git a/README.md b/README.md index 8302bc9..ca943f7 100644 --- a/README.md +++ b/README.md @@ -457,6 +457,10 @@ services: # USB devices. Uncomment/change only if needed. # - /dev/bus/usb/002:/dev/bus/usb/002 + # USB serial modem. See "USB serial modems" below before using a + # /dev/serial/by-id/ path here. + # - /dev/openhop-modem:/dev/openhop-modem + cap_add: - SYS_RAWIO @@ -475,6 +479,52 @@ volumes: openhop-repeater-data: ``` +### USB serial modems + +A `/dev/serial/by-id/` path is the stable way to name a USB modem, but it cannot +always be handed to a container. ESP32-S3 boards that use the chip's native +USB-Serial-JTAG peripheral report their factory MAC address as the USB serial +number, colons included, so udev produces a name like: + +``` +/dev/serial/by-id/usb-Espressif_USB_JTAG_serial_debug_unit_60:55:F9:C0:27:18-if00 +``` + +Docker parses `devices:` entries as `host:container[:permissions]` and has no way +to escape a colon inside the path, so that name is rejected. `/dev/serial/by-path/` +does not help either, since those contain PCI addresses with colons of their own. + +Two further things to know: `/dev/serial/by-id` is built by udev on the host, and +udev does not run inside the container, so the directory is not visible in there +unless you bind-mount it. And a bare `/dev/ttyACM0` is not stable across re-plugs +or reboots. + +Install the udev rule shipped with openHop Core, which creates a colon-free +symlink that survives re-plugs: + +```bash +sudo cp 99-openhop-modem.rules /etc/udev/rules.d/ +sudo udevadm control --reload-rules +sudo udevadm trigger --subsystem-match=tty --action=change +``` + +Then map the symlink and point the config at the same path: + +```yaml +devices: + - /dev/openhop-modem:/dev/openhop-modem +``` + +```yaml +kiss: + port: "/dev/openhop-modem" +``` + +The rule matches USB ID `303a:1001`, which every ESP32-S3 board using native USB +shares. With more than one attached, pin the rule to a single board's serial +number — the rules file has a commented example and the `udevadm` command to read +it. + ## Roadmap - [ ] **Public map integration**: submit repeater location and details to a diff --git a/config.yaml.example b/config.yaml.example index 923c55e..d82623b 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -528,7 +528,10 @@ radio: # KISS modem (when radio_type: kiss). Requires openhop-core with KISS support. # kiss: -# port: "/dev/ttyUSB0" +# port: "/dev/ttyUSB0" # 99-openhop-modem.rules symlinks to /dev/openhop-modem. +# # Prefer that over /dev/serial/by-id/ in Docker: an +# # ESP32-S3 by-id name contains colons and Docker reads +# # those as its host:container separator. # baud_rate: 9600 # # Optional KISS key-up / CSMA tuning, forwarded to the modem firmware. # # Omit to keep the wrapper/firmware defaults. For a host-managed repeater the diff --git a/docker-compose.yml b/docker-compose.yml index 5ada43e..ebdb3cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,12 @@ services: - /dev/gpiochip0 # USB DEVICES (Your path may differ) - /dev/bus/usb/002:/dev/bus/usb/002 + # USB SERIAL MODEM (KISS / openHop USB). Do NOT use a /dev/serial/by-id/ + # path here: ESP32-S3 boards report their MAC as the USB serial number, so + # the name contains colons and Docker reads those as the host:container + # separator. Install 99-openhop-modem.rules on the host and map the + # symlink it creates instead. See README, "USB serial modems". + # - /dev/openhop-modem:/dev/openhop-modem # SPI DEVICES PERMISSIONS cap_add: - SYS_RAWIO diff --git a/manage.sh b/manage.sh index fc11f5d..a4cffe8 100755 --- a/manage.sh +++ b/manage.sh @@ -651,6 +651,13 @@ install_repeater() { udevadm trigger 2>/dev/null || true fi + echo "59"; echo "# Installing udev rules for USB modems..." + if [ -f "$SCRIPT_DIR/../openhop-core/99-openhop-modem.rules" ]; then + cp "$SCRIPT_DIR/../openhop-core/99-openhop-modem.rules" /etc/udev/rules.d/99-openhop-modem.rules + udevadm control --reload-rules 2>/dev/null || true + udevadm trigger --subsystem-match=tty --action=change 2>/dev/null || true + fi + echo "65"; echo "# Setting permissions..." # Venv stays root-owned (pip runs as root); service user only needs read+execute chown -R "$SERVICE_USER:$SERVICE_USER" "$CONFIG_DIR" "$LOG_DIR" "$DATA_DIR" @@ -1148,6 +1155,14 @@ upgrade_repeater() { elif [ -f /etc/udev/rules.d/99-ch341.rules ]; then echo " ✓ CH341 udev rules already present" fi + if [ -f "$SCRIPT_DIR/../openhop-core/99-openhop-modem.rules" ]; then + cp "$SCRIPT_DIR/../openhop-core/99-openhop-modem.rules" /etc/udev/rules.d/99-openhop-modem.rules + udevadm control --reload-rules 2>/dev/null || true + udevadm trigger --subsystem-match=tty --action=change 2>/dev/null || true + echo " ✓ USB modem udev rules updated" + elif [ -f /etc/udev/rules.d/99-openhop-modem.rules ]; then + echo " ✓ USB modem udev rules already present" + fi echo " ✓ User groups updated" echo "[7/9] Fixing permissions and helper files..." diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index d09abdc..a4a463a 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -1436,6 +1436,11 @@ class APIEndpoints: for item in devices: dev = item.get("device") if dev and dev not in dedup: + # Docker reads `devices:` entries as host:container[:perms] and + # cannot escape a colon inside the path, so a /dev/serial/by-id/ + # name carrying an ESP32-S3 MAC serial is unusable in a + # container. Flag it so the UI can offer a symlink instead. + item["docker_safe"] = ":" not in dev dedup[dev] = item sorted_devices = sorted(dedup.values(), key=lambda x: x["device"]) diff --git a/tests/test_api_endpoints_core_coverage.py b/tests/test_api_endpoints_core_coverage.py index af74685..e076775 100644 --- a/tests/test_api_endpoints_core_coverage.py +++ b/tests/test_api_endpoints_core_coverage.py @@ -535,6 +535,39 @@ def test_serial_ports_dedupes_duplicate_devices(cherrypy_ctx): assert "first" in result["data"][0]["description"] +def test_serial_ports_flags_paths_docker_cannot_map(cherrypy_ctx): + del cherrypy_ctx + api = _make_api() + + # ESP32-S3 boards on the native USB-Serial-JTAG peripheral report their MAC + # as the USB serial number, colons included. Docker reads `devices:` as + # host:container, so this path cannot be passed into a container. + by_id = SimpleNamespace( + device="/dev/serial/by-id/usb-Espressif_USB_JTAG_serial_debug_unit_60:55:F9:C0:27:18-if00", + description="USB JTAG/serial debug unit", + hwid="USB VID:PID=303A:1001", + ) + + with patch("serial.tools.list_ports.comports", return_value=[by_id]): + result = api.serial_ports() + + assert result["success"] is True + assert result["data"][0]["docker_safe"] is False + + +def test_serial_ports_marks_ordinary_paths_docker_safe(cherrypy_ctx): + del cherrypy_ctx + api = _make_api() + + p1 = SimpleNamespace(device="/dev/ttyACM0", description="USB CDC", hwid="n/a") + + with patch("serial.tools.list_ports.comports", return_value=[p1]): + result = api.serial_ports() + + assert result["success"] is True + assert result["data"][0]["docker_safe"] is True + + def test_config_export_redacts_secrets_and_identity_keys(cherrypy_ctx): request, _ = cherrypy_ctx request.method = "GET"