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.
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user