refactor(devices): route ESP-NOW client through SerialTransport
Drops the direct pyserial imports from espnow_client/espnow_provider in favor of open_transport/list_serial_ports/port_exists. The gateway protocol is write-only, so no read() extension was needed. ESP-NOW gateways are now reachable via usb:VID:PID URLs on Android.
This commit is contained in:
@@ -34,19 +34,18 @@ Build the Android APK automatically on push/tag.
|
|||||||
|
|
||||||
## Android Root Capture (No Permission Dialog, No System Indicator)
|
## Android Root Capture (No Permission Dialog, No System Indicator)
|
||||||
|
|
||||||
MediaProjection shows a mandatory system overlay/indicator while capturing — unavoidable on stock Android. Many cheap Android TV boxes ship pre-rooted, so an alternative root-only path would give much better UX.
|
MediaProjection shows a mandatory system overlay/indicator while capturing — unavoidable on stock Android. Many cheap Android TV boxes ship pre-rooted, so an alternative root-only path gives much better UX.
|
||||||
|
|
||||||
- [ ] Detect root at runtime: check for `su` binary, `Superuser.apk`, etc.
|
- [x] Root detection — `Root.kt` checks common `su` binary paths and, on demand, runs `su -c id` to actually prove UID 0. First call triggers Magisk's grant dialog; grant is cached per session. Exposed to Python via Chaquopy.
|
||||||
- [ ] Implement `SurfaceControlCaptureEngine` (new capture engine) using hidden `SurfaceControl.screenshot()` API via reflection
|
- [x] `RootScreenrecord.kt` — spawns `su -c screenrecord --output-format=h264 --size=WxH -`, feeds the H.264 stdout through a MediaCodec decoder whose output Surface is wired into an ImageReader (RGBA_8888, row-stride-aware). Decoded frames reach the Python pipeline via `PythonBridge.pushRootFrame`.
|
||||||
- No permission dialog
|
- [x] Python-side `RootScreenrecordEngine` (`core/capture_engines/root_screenrecord_engine.py`) mirrors `MediaProjectionEngine` with `ENGINE_PRIORITY=110` (> MediaProjection's 100) so the factory picks it automatically when available.
|
||||||
- No system capture indicator
|
- [x] `MainActivity` tries `Root.requestGrant()` before launching the MediaProjection consent flow — on rooted devices the consent dialog is skipped entirely. `CaptureService` has a `createRootIntent()` entry point that bypasses the MediaProjection path.
|
||||||
- Direct bitmap output (no encoder/decoder roundtrip)
|
- [x] Fallback: if `Root.requestGrant()` returns false (no root, user denied, or `su` timeout) the existing MediaProjection flow runs unchanged.
|
||||||
- [ ] Engine priority: higher than MediaProjection when root detected
|
- [ ] Real-hardware test pending — need to verify on the user's Magisk'd TV box that: (1) grant dialog appears once, (2) frames actually flow through MediaCodec without the Android 14 capture indicator showing, (3) stop/start cycle terminates the `su` process cleanly.
|
||||||
- [ ] Fallback chain: `SurfaceControl` (root) → `MediaProjection` (stock) → `adb screencap` (last resort)
|
- [WONTDO] `SurfaceControl.screenshot()` via reflection — renamed/moved across API 28/29/30/33, hidden-API blocklist varies by release, even rooted apps hit it; days of maintenance for a marginal latency win over the screenrecord path. Not worth it.
|
||||||
- [ ] Handle Android version differences in `SurfaceControl` API surface (renamed/moved across API 29, 30, 33)
|
- [WONTDO] `adb screencap` fallback — full-PNG-per-frame pipeline is slower than MediaProjection, no value as a last resort.
|
||||||
- [ ] Alternative: shell out to `screenrecord --output-format=h264 -` as root (same H.264 decode as scrcpy_client_engine, but local instead of remote ADB)
|
|
||||||
|
|
||||||
Known projects using this approach for reference: scrcpy-hidden-api, shizuku, commercial scrcpy-derived apps.
|
Known projects using the screenrecord approach for reference: scrcpy (over ADB), scrcpy-hidden-api, shizuku.
|
||||||
|
|
||||||
## Android USB Serial Support
|
## Android USB Serial Support
|
||||||
|
|
||||||
@@ -60,7 +59,7 @@ Drive USB LED controllers (APA102, WS2812) connected directly to the Android TV
|
|||||||
- [ ] Real-device test pending — no USB-serial hardware on dev machine. Need to verify on a TV box with CH340, CP2102, or FTDI adapter.
|
- [ ] Real-device test pending — no USB-serial hardware on dev machine. Need to verify on a TV box with CH340, CP2102, or FTDI adapter.
|
||||||
- [ ] Document supported USB LED controllers in README (once real-device test passes).
|
- [ ] Document supported USB LED controllers in README (once real-device test passes).
|
||||||
- [ ] Optional: auto-launch the app when a known USB-serial adapter is plugged in (intent-filter on `USB_DEVICE_ATTACHED` + `res/xml/device_filter.xml`). Skipped in v1 — users can just open LedGrab and hit "Discover".
|
- [ ] Optional: auto-launch the app when a known USB-serial adapter is plugged in (intent-filter on `USB_DEVICE_ATTACHED` + `res/xml/device_filter.xml`). Skipped in v1 — users can just open LedGrab and hit "Discover".
|
||||||
- [ ] ESP-NOW client (`espnow_client.py` / `espnow_provider.py`) still imports `pyserial` directly and needs bidirectional reads — separate refactor to extend the transport with `read()` if ESP-NOW-via-USB on Android is needed.
|
- [x] ESP-NOW client (`espnow_client.py` / `espnow_provider.py`) now routes through `SerialTransport` — `open_transport()` for the gateway serial link, `list_serial_ports()` + `port_exists()` for discovery/validation. Works transparently with `usb:VID:PID` URLs on Android. (Gateway protocol is write-only, so no `read()` extension was needed after all.)
|
||||||
|
|
||||||
## Performance Metrics Abstraction
|
## Performance Metrics Abstraction
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ from typing import List, Optional, Tuple, Union
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
|
from ledgrab.core.devices.led_client import DeviceHealth, LEDClient
|
||||||
|
from ledgrab.core.devices.serial_transport import (
|
||||||
|
open_transport,
|
||||||
|
parse_serial_url,
|
||||||
|
port_exists,
|
||||||
|
)
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -15,6 +20,7 @@ logger = get_logger(__name__)
|
|||||||
# Gateway serial protocol constants
|
# Gateway serial protocol constants
|
||||||
FRAME_START = 0xEE
|
FRAME_START = 0xEE
|
||||||
FRAME_END = 0xEF
|
FRAME_END = 0xEF
|
||||||
|
DEFAULT_ESPNOW_BAUD = 921600
|
||||||
|
|
||||||
|
|
||||||
def _mac_str_to_bytes(mac: str) -> bytes:
|
def _mac_str_to_bytes(mac: str) -> bytes:
|
||||||
@@ -53,14 +59,14 @@ class ESPNowClient(LEDClient):
|
|||||||
self,
|
self,
|
||||||
url: str = "",
|
url: str = "",
|
||||||
led_count: int = 0,
|
led_count: int = 0,
|
||||||
baud_rate: int = 921600,
|
baud_rate: Optional[int] = None,
|
||||||
espnow_peer_mac: str = "FF:FF:FF:FF:FF:FF",
|
espnow_peer_mac: str = "FF:FF:FF:FF:FF:FF",
|
||||||
espnow_channel: int = 1,
|
espnow_channel: int = 1,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
self._port = url
|
self._port, url_baud = parse_serial_url(url)
|
||||||
|
self._baud_rate = baud_rate or url_baud or DEFAULT_ESPNOW_BAUD
|
||||||
self._led_count = led_count
|
self._led_count = led_count
|
||||||
self._baud_rate = baud_rate
|
|
||||||
self._peer_mac = _mac_str_to_bytes(espnow_peer_mac)
|
self._peer_mac = _mac_str_to_bytes(espnow_peer_mac)
|
||||||
self._channel = espnow_channel
|
self._channel = espnow_channel
|
||||||
self._serial = None
|
self._serial = None
|
||||||
@@ -69,15 +75,12 @@ class ESPNowClient(LEDClient):
|
|||||||
|
|
||||||
async def connect(self) -> bool:
|
async def connect(self) -> bool:
|
||||||
try:
|
try:
|
||||||
import serial as pyserial
|
self._serial = open_transport(self._port, baud_rate=self._baud_rate, timeout=1)
|
||||||
except ImportError:
|
await asyncio.to_thread(self._serial.open)
|
||||||
raise RuntimeError("pyserial is required for ESP-NOW devices: pip install pyserial")
|
except Exception as e:
|
||||||
|
logger.error("Failed to open ESP-NOW gateway %s: %s", self._port, e)
|
||||||
|
raise RuntimeError(f"Failed to open ESP-NOW gateway {self._port}: {e}")
|
||||||
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
self._serial = await loop.run_in_executor(
|
|
||||||
None,
|
|
||||||
lambda: pyserial.Serial(self._port, self._baud_rate, timeout=1),
|
|
||||||
)
|
|
||||||
self._connected = True
|
self._connected = True
|
||||||
logger.info(
|
logger.info(
|
||||||
"ESP-NOW client connected: port=%s baud=%d peer=%s channel=%d leds=%d",
|
"ESP-NOW client connected: port=%s baud=%d peer=%s channel=%d leds=%d",
|
||||||
@@ -90,9 +93,8 @@ class ESPNowClient(LEDClient):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
if self._serial and self._serial.is_open:
|
if self._serial is not None and self._serial.is_open:
|
||||||
loop = asyncio.get_event_loop()
|
await asyncio.to_thread(self._serial.close)
|
||||||
await loop.run_in_executor(None, self._serial.close)
|
|
||||||
self._serial = None
|
self._serial = None
|
||||||
self._connected = False
|
self._connected = False
|
||||||
logger.info("ESP-NOW client closed: port=%s", self._port)
|
logger.info("ESP-NOW client closed: port=%s", self._port)
|
||||||
@@ -150,14 +152,21 @@ class ESPNowClient(LEDClient):
|
|||||||
http_client,
|
http_client,
|
||||||
prev_health: Optional[DeviceHealth] = None,
|
prev_health: Optional[DeviceHealth] = None,
|
||||||
) -> DeviceHealth:
|
) -> DeviceHealth:
|
||||||
"""Check if the serial port is available."""
|
"""Check if the serial port is available without opening it."""
|
||||||
|
port, _baud = parse_serial_url(url)
|
||||||
try:
|
try:
|
||||||
import serial as pyserial
|
if port_exists(port):
|
||||||
|
return DeviceHealth(
|
||||||
s = pyserial.Serial(url, timeout=0.1)
|
online=True,
|
||||||
s.close()
|
latency_ms=0.0,
|
||||||
|
last_checked=datetime.now(timezone.utc),
|
||||||
|
device_name=prev_health.device_name if prev_health else None,
|
||||||
|
device_led_count=prev_health.device_led_count if prev_health else None,
|
||||||
|
)
|
||||||
return DeviceHealth(
|
return DeviceHealth(
|
||||||
online=True, latency_ms=0.0, last_checked=datetime.now(timezone.utc)
|
online=False,
|
||||||
|
last_checked=datetime.now(timezone.utc),
|
||||||
|
error=f"Serial port {port} not found",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return DeviceHealth(
|
return DeviceHealth(
|
||||||
|
|||||||
@@ -2,24 +2,33 @@
|
|||||||
|
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
|
from ledgrab.core.devices.espnow_client import ESPNowClient
|
||||||
from ledgrab.core.devices.led_client import (
|
from ledgrab.core.devices.led_client import (
|
||||||
DeviceHealth,
|
DeviceHealth,
|
||||||
DiscoveredDevice,
|
DiscoveredDevice,
|
||||||
LEDClient,
|
LEDClient,
|
||||||
LEDDeviceProvider,
|
LEDDeviceProvider,
|
||||||
)
|
)
|
||||||
from ledgrab.core.devices.espnow_client import ESPNowClient
|
from ledgrab.core.devices.serial_transport import (
|
||||||
|
list_serial_ports,
|
||||||
|
parse_serial_url,
|
||||||
|
port_exists,
|
||||||
|
)
|
||||||
from ledgrab.utils import get_logger
|
from ledgrab.utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# Description fragments that commonly indicate an ESP32 gateway's USB bridge.
|
||||||
|
_ESP_DESC_FRAGMENTS = ("cp210", "ch340", "esp", "ftdi", "usb serial")
|
||||||
|
|
||||||
|
|
||||||
class ESPNowDeviceProvider(LEDDeviceProvider):
|
class ESPNowDeviceProvider(LEDDeviceProvider):
|
||||||
"""Provider for ESP-NOW LED devices via serial ESP32 gateway.
|
"""Provider for ESP-NOW LED devices via serial ESP32 gateway.
|
||||||
|
|
||||||
URL = serial port of the gateway ESP32 (e.g. COM3, /dev/ttyUSB0).
|
URL = serial port of the gateway ESP32 (COM3, /dev/ttyUSB0, or
|
||||||
Each device represents one remote ESP32 peer identified by MAC address.
|
usb:VID:PID on Android). Each device represents one remote ESP32
|
||||||
Multiple devices can share the same gateway (serial port).
|
peer identified by MAC address. Multiple devices can share the same
|
||||||
|
gateway (serial port).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -34,7 +43,7 @@ class ESPNowDeviceProvider(LEDDeviceProvider):
|
|||||||
return ESPNowClient(
|
return ESPNowClient(
|
||||||
url,
|
url,
|
||||||
led_count=kwargs.get("led_count", 0),
|
led_count=kwargs.get("led_count", 0),
|
||||||
baud_rate=kwargs.get("baud_rate", 921600),
|
baud_rate=kwargs.get("baud_rate"),
|
||||||
espnow_peer_mac=kwargs.get("espnow_peer_mac", "FF:FF:FF:FF:FF:FF"),
|
espnow_peer_mac=kwargs.get("espnow_peer_mac", "FF:FF:FF:FF:FF:FF"),
|
||||||
espnow_channel=kwargs.get("espnow_channel", 1),
|
espnow_channel=kwargs.get("espnow_channel", 1),
|
||||||
)
|
)
|
||||||
@@ -44,43 +53,45 @@ class ESPNowDeviceProvider(LEDDeviceProvider):
|
|||||||
|
|
||||||
async def validate_device(self, url: str) -> dict:
|
async def validate_device(self, url: str) -> dict:
|
||||||
"""Validate serial port is accessible. LED count is manual."""
|
"""Validate serial port is accessible. LED count is manual."""
|
||||||
|
port, _baud = parse_serial_url(url)
|
||||||
try:
|
try:
|
||||||
import serial as pyserial
|
if not port_exists(port):
|
||||||
|
available = ", ".join(p.device for p in list_serial_ports()) or "none"
|
||||||
s = pyserial.Serial(url, timeout=0.5)
|
raise ValueError(f"Serial port {port} not found. Available ports: {available}")
|
||||||
s.close()
|
except ValueError:
|
||||||
except ImportError:
|
raise
|
||||||
raise ValueError("pyserial is required for ESP-NOW devices: pip install pyserial")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ValueError(f"Cannot open serial port {url}: {e}")
|
raise ValueError(f"Failed to enumerate serial ports: {e}")
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]:
|
||||||
"""Discover available serial ports that could be ESP32 gateways."""
|
"""Discover serial ports that could be ESP32 gateways.
|
||||||
try:
|
|
||||||
import serial.tools.list_ports
|
|
||||||
|
|
||||||
ports = serial.tools.list_ports.comports()
|
Filters the host's serial-port list by description fragments
|
||||||
results = []
|
typical of ESP32 USB bridges (CP210x, CH340, FTDI, generic
|
||||||
|
"USB Serial"). On Android, the USB bridge already reports
|
||||||
|
descriptive names, so the same filter applies.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ports = list_serial_ports()
|
||||||
|
results: List[DiscoveredDevice] = []
|
||||||
for port in ports:
|
for port in ports:
|
||||||
# Look for ESP32 USB descriptors
|
|
||||||
desc = (port.description or "").lower()
|
desc = (port.description or "").lower()
|
||||||
vid = port.vid or 0
|
if not any(frag in desc for frag in _ESP_DESC_FRAGMENTS):
|
||||||
# Common ESP32 USB VIDs: Espressif (0x303A), Silicon Labs CP210x (0x10C4),
|
continue
|
||||||
# FTDI (0x0403), WCH CH340 (0x1A86)
|
results.append(
|
||||||
esp_vids = {0x303A, 0x10C4, 0x0403, 0x1A86}
|
DiscoveredDevice(
|
||||||
if vid in esp_vids or "cp210" in desc or "ch340" in desc or "esp" in desc:
|
name=f"ESP-NOW Gateway ({port.description})",
|
||||||
results.append(
|
url=port.device,
|
||||||
DiscoveredDevice(
|
device_type="espnow",
|
||||||
name=f"ESP-NOW Gateway ({port.description})",
|
ip="",
|
||||||
url=port.device,
|
mac="",
|
||||||
device_type="espnow",
|
led_count=None,
|
||||||
ip="",
|
version=None,
|
||||||
mac="",
|
|
||||||
led_count=None,
|
|
||||||
version=None,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
logger.info("ESP-NOW gateway scan found %d candidate port(s)", len(results))
|
||||||
return results
|
return results
|
||||||
except ImportError:
|
except Exception as e:
|
||||||
|
logger.error("ESP-NOW gateway discovery failed: %s", e)
|
||||||
return []
|
return []
|
||||||
|
|||||||
Reference in New Issue
Block a user