Files
ledgrab/server/tests/test_serial_transport.py
T
alexei.dolgolyov 7fcb8dd346
Build Android APK / build-android (push) Failing after 1m41s
Lint & Test / test (push) Successful in 4m51s
feat(devices): Android USB-serial support for Adalight/AmbiLED controllers
Adds end-to-end support for driving USB-connected Adalight / AmbiLED
LED controllers from Android TV boxes. Android's security model blocks
direct USB access from Python, so writes route through a Kotlin
UsbSerialBridge singleton via Chaquopy.

Python side:
- New SerialTransport Protocol (serial_transport.py) with open / write /
  flush / close. Desktop uses PySerialTransport (wraps pyserial),
  Android uses AndroidSerialTransport (wraps the Kotlin bridge).
- list_serial_ports() factory returns desktop COM ports on desktop,
  USB devices on Android — callers don't branch.
- URL scheme extended: existing COM3[:baud] and /dev/ttyUSB0[:baud]
  unchanged; new usb:VID:PID[:serial][@baud] for Android (@ is the
  baud separator since : is already used between VID and PID).
- AdalightClient and SerialDeviceProvider refactored to go through
  the transport — no more direct pyserial imports in hot paths.
- 17 new unit tests cover URL parsing, PySerial transport, factory
  selection, platform-branching discovery. Full suite 750 passing.

Kotlin side:
- UsbSerialBridge.kt singleton uses com.hoho.android.usbserial (mik3y)
  which ships drivers for CH340, CP2102, FTDI, Prolific, and CDC-ACM
  (Arduino). Exposes listDevices, open, write, close via @JvmStatic
  for Chaquopy. First open() attempt without permission triggers the
  system USB permission dialog; next call succeeds once user grants.
- usb-serial-for-android is distributed via JitPack — added that repo
  in settings.gradle.kts and the dependency in app/build.gradle.kts.
- AndroidManifest declares uses-feature android.hardware.usb.host
  (required=false so non-USB-host phones still install).
- LedGrabApp.onCreate calls UsbSerialBridge.init(this) so the bridge
  resolves the UsbManager without needing an Activity ref.

Verified: ./gradlew compileDebugKotlin succeeds; off-Android import
of android_serial_transport works. Real-hardware smoke test on a
TV box with a CH340/CP2102/FTDI adapter still pending.

ESP-NOW (espnow_client / espnow_provider) still imports pyserial
directly because it needs bidirectional reads — separate refactor
to extend the transport with read() if that path ever needs Android
USB support.
2026-04-14 16:34:09 +03:00

154 lines
5.2 KiB
Python

"""Tests for the serial transport abstraction."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from ledgrab.core.devices.serial_transport import (
DEFAULT_BAUD_RATE,
PySerialTransport,
SerialPortInfo,
list_serial_ports,
open_transport,
parse_serial_url,
port_exists,
)
# ── URL parsing ────────────────────────────────────────────────────
@pytest.mark.parametrize(
"url,expected",
[
("COM3", ("COM3", DEFAULT_BAUD_RATE)),
("COM3:230400", ("COM3", 230400)),
("/dev/ttyUSB0", ("/dev/ttyUSB0", DEFAULT_BAUD_RATE)),
("/dev/ttyUSB0:230400", ("/dev/ttyUSB0", 230400)),
# USB URLs use @ for baud since `:` separates VID:PID:serial
("usb:1a86:7523", ("usb:1a86:7523", DEFAULT_BAUD_RATE)),
("usb:1a86:7523:AB01", ("usb:1a86:7523:AB01", DEFAULT_BAUD_RATE)),
("usb:1a86:7523@230400", ("usb:1a86:7523", 230400)),
("usb:1a86:7523:AB01@500000", ("usb:1a86:7523:AB01", 500000)),
],
)
def test_parse_serial_url(url, expected):
assert parse_serial_url(url) == expected
def test_parse_serial_url_strips_whitespace():
assert parse_serial_url(" COM3 ") == ("COM3", DEFAULT_BAUD_RATE)
# ── PySerialTransport ──────────────────────────────────────────────
def test_pyserial_transport_open_and_write(monkeypatch):
fake_serial_module = MagicMock()
fake_handle = MagicMock(is_open=True)
fake_serial_module.Serial.return_value = fake_handle
monkeypatch.setitem(__import__("sys").modules, "serial", fake_serial_module)
t = PySerialTransport("COM3", 115200)
assert not t.is_open
t.open()
fake_serial_module.Serial.assert_called_once_with(port="COM3", baudrate=115200, timeout=1.0)
assert t.is_open
t.write(b"hello")
fake_handle.write.assert_called_once_with(b"hello")
t.flush()
fake_handle.flush.assert_called_once()
t.close()
fake_handle.close.assert_called_once()
def test_pyserial_transport_write_before_open_raises():
t = PySerialTransport("COM3", 115200)
with pytest.raises(RuntimeError, match="not open"):
t.write(b"x")
# ── Factory ────────────────────────────────────────────────────────
def test_open_transport_picks_pyserial_for_com_url():
t = open_transport("COM3:230400")
assert isinstance(t, PySerialTransport)
assert t._device == "COM3"
assert t._baud_rate == 230400
def test_open_transport_explicit_baud_overrides_url():
t = open_transport("COM3:230400", baud_rate=500000)
assert t._baud_rate == 500000
def test_open_transport_picks_android_for_usb_url(monkeypatch):
"""usb: URLs should route to the Android transport even off-Android."""
# Importing AndroidSerialTransport itself works on any host; only the
# bridge call inside .open() fails when not on Android.
t = open_transport("usb:1a86:7523@230400")
assert type(t).__name__ == "AndroidSerialTransport"
assert t._baud_rate == 230400
# ── Discovery ──────────────────────────────────────────────────────
def test_list_serial_ports_uses_pyserial_on_desktop(monkeypatch):
monkeypatch.setattr("ledgrab.core.devices.serial_transport.is_android", lambda: False)
fake_module = MagicMock()
fake_module.tools.list_ports.comports.return_value = [
MagicMock(device="COM3", description="USB Serial CH340"),
MagicMock(device="COM4", description=None),
]
with patch.dict(
"sys.modules",
{
"serial": fake_module,
"serial.tools": fake_module.tools,
"serial.tools.list_ports": fake_module.tools.list_ports,
},
):
ports = list_serial_ports()
assert len(ports) == 2
assert ports[0] == SerialPortInfo(device="COM3", description="USB Serial CH340")
# Falls back to device id when description is None
assert ports[1].description == "COM4"
def test_list_serial_ports_routes_to_android_when_on_android(monkeypatch):
monkeypatch.setattr("ledgrab.core.devices.serial_transport.is_android", lambda: True)
fake_devices = [SerialPortInfo(device="usb:1a86:7523", description="CH340 LED")]
fake_mod = MagicMock()
fake_mod.list_android_usb_devices.return_value = fake_devices
monkeypatch.setitem(
__import__("sys").modules,
"ledgrab.core.devices.android_serial_transport",
fake_mod,
)
assert list_serial_ports() == fake_devices
def test_port_exists_is_case_insensitive(monkeypatch):
fake_ports = [
SerialPortInfo(device="COM3", description="x"),
SerialPortInfo(device="/dev/ttyUSB0", description="y"),
]
monkeypatch.setattr(
"ledgrab.core.devices.serial_transport.list_serial_ports", lambda: fake_ports
)
assert port_exists("com3") is True
assert port_exists("/dev/ttyusb0") is True
assert port_exists("COM99") is False