7fcb8dd346
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.
154 lines
5.2 KiB
Python
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
|