refactor(devices): per-provider typed configs (phases 1-4)

Phase 1 — DeviceConfig hierarchy (device_config.py):
- 17 @dataclass(frozen=True) subclasses (WLEDConfig, AdalightConfig, …) sharing
  BaseDeviceConfig; DeviceConfig = Union[all 17]
- Device.to_config() in device_store.py: single flat→typed dispatch point

Phase 2+3 — Typed provider signatures + call-site migration:
- ProviderDeps(device_store) frozen dataclass in led_client.py
- LEDDeviceProvider.create_client(config, *, deps) abstract signature
- create_led_client(config, *, deps) factory dispatches via config.device_type
- All 17 providers narrowed to their specific config type; drop kwargs.get()
- GroupLEDClient.connect() uses device.to_config() + create_led_client()
- wled_target_processor: replaced 21-field DeviceInfo unpacking with to_config()
  + dataclasses.replace(config, use_ddp=…) for DDP override
- device_test_mode: build typed config via to_config() + ProviderDeps
- Deleted DeviceInfo dataclass, _get_device_info(), _DEVICE_FIELD_DEFAULTS
- TargetContext: replaced get_device_info callback with is_test_mode_active

Phase 4 — Test migration:
- 47-case test suite in tests/core/devices/test_device_config.py (100% coverage)
- test_group_device.py TestGroupLEDClient migrated to GroupConfig + ProviderDeps
- Removed legacy keyword-arg init path from GroupLEDClient
This commit is contained in:
2026-04-18 01:24:27 +03:00
parent 123da1b5c4
commit d3a6416a1d
29 changed files with 1192 additions and 328 deletions
@@ -0,0 +1,335 @@
"""Unit tests for the per-provider DeviceConfig hierarchy and Device.to_config()."""
import pytest
from ledgrab.core.devices.device_config import (
AdalightConfig,
AmbiLEDConfig,
BaseDeviceConfig,
BLEConfig,
ChromaConfig,
DemoConfig,
DMXConfig,
ESPNowConfig,
GameSenseConfig,
GroupConfig,
HueConfig,
MockConfig,
MQTTConfig,
OpenRGBConfig,
SPIConfig,
USBHIDConfig,
WLEDConfig,
WSConfig,
)
from ledgrab.storage.device_store import Device
def _make_device(**kwargs) -> Device:
defaults = dict(
device_id="dev_test01",
name="Test Device",
url="http://192.168.1.100",
led_count=60,
software_brightness=200,
auto_shutdown=True,
rgbw=True,
baud_rate=115200,
send_latency_ms=5,
zone_mode="separate",
dmx_protocol="sacn",
dmx_start_universe=1,
dmx_start_channel=2,
espnow_peer_mac="AA:BB:CC:DD:EE:FF",
espnow_channel=6,
hue_username="hue_user",
hue_client_key="hue_key",
hue_entertainment_group_id="grp_1",
spi_speed_hz=1600000,
spi_led_type="APA102",
chroma_device_type="keyboard",
gamesense_device_type="mouse",
ble_family="govee",
ble_govee_key="secret_key",
group_device_ids=["dev_a", "dev_b"],
group_mode="independent",
)
defaults.update(kwargs)
return Device(**defaults)
class TestBaseDeviceConfigDefaults:
def test_defaults(self):
cfg = BaseDeviceConfig(device_id="d1", device_url="http://x", led_count=10)
assert cfg.software_brightness == 255
assert cfg.test_mode_active is False
assert cfg.auto_shutdown is False
assert cfg.rgbw is False
def test_frozen(self):
cfg = BaseDeviceConfig(device_id="d1", device_url="http://x", led_count=10)
with pytest.raises(Exception):
cfg.led_count = 20 # type: ignore[misc]
class TestWLEDConfig:
def test_to_config_returns_wled(self):
device = _make_device(device_type="wled")
cfg = device.to_config()
assert isinstance(cfg, WLEDConfig)
assert cfg.device_type == "wled"
def test_base_fields_mapped(self):
device = _make_device(device_type="wled")
cfg = device.to_config()
assert cfg.device_id == "dev_test01"
assert cfg.device_url == "http://192.168.1.100"
assert cfg.led_count == 60
assert cfg.software_brightness == 200
assert cfg.auto_shutdown is True
assert cfg.rgbw is True
def test_use_ddp_defaults_false(self):
cfg = _make_device(device_type="wled").to_config()
assert cfg.use_ddp is False # type: ignore[union-attr]
def test_test_mode_active_defaults_false(self):
# test_mode_active is runtime state, not stored on Device
cfg = _make_device(device_type="wled").to_config()
assert cfg.test_mode_active is False
def test_wled_has_no_baud_rate(self):
cfg = _make_device(device_type="wled").to_config()
assert not hasattr(cfg, "baud_rate")
def test_wled_has_no_ble_fields(self):
cfg = _make_device(device_type="wled").to_config()
assert not hasattr(cfg, "ble_family")
assert not hasattr(cfg, "ble_govee_key")
class TestAdalightConfig:
def test_to_config_returns_adalight(self):
cfg = _make_device(device_type="adalight").to_config()
assert isinstance(cfg, AdalightConfig)
assert cfg.device_type == "adalight"
def test_baud_rate_mapped(self):
cfg = _make_device(device_type="adalight").to_config()
assert cfg.baud_rate == 115200 # type: ignore[union-attr]
def test_adalight_has_no_dmx_fields(self):
cfg = _make_device(device_type="adalight").to_config()
assert not hasattr(cfg, "dmx_protocol")
class TestAmbiLEDConfig:
def test_to_config_returns_ambiled(self):
cfg = _make_device(device_type="ambiled").to_config()
assert isinstance(cfg, AmbiLEDConfig)
assert cfg.device_type == "ambiled"
def test_baud_rate_mapped(self):
cfg = _make_device(device_type="ambiled").to_config()
assert cfg.baud_rate == 115200 # type: ignore[union-attr]
class TestDMXConfig:
def test_to_config_returns_dmx(self):
cfg = _make_device(device_type="dmx").to_config()
assert isinstance(cfg, DMXConfig)
assert cfg.device_type == "dmx"
def test_dmx_fields_mapped(self):
cfg = _make_device(device_type="dmx").to_config()
assert cfg.dmx_protocol == "sacn" # type: ignore[union-attr]
assert cfg.dmx_start_universe == 1 # type: ignore[union-attr]
assert cfg.dmx_start_channel == 2 # type: ignore[union-attr]
def test_dmx_has_no_baud_rate(self):
cfg = _make_device(device_type="dmx").to_config()
assert not hasattr(cfg, "baud_rate")
class TestESPNowConfig:
def test_to_config_returns_espnow(self):
cfg = _make_device(device_type="espnow").to_config()
assert isinstance(cfg, ESPNowConfig)
assert cfg.device_type == "espnow"
def test_espnow_fields_mapped(self):
cfg = _make_device(device_type="espnow").to_config()
assert cfg.baud_rate == 115200 # type: ignore[union-attr]
assert cfg.espnow_peer_mac == "AA:BB:CC:DD:EE:FF" # type: ignore[union-attr]
assert cfg.espnow_channel == 6 # type: ignore[union-attr]
class TestHueConfig:
def test_to_config_returns_hue(self):
cfg = _make_device(device_type="hue").to_config()
assert isinstance(cfg, HueConfig)
assert cfg.device_type == "hue"
def test_hue_fields_mapped(self):
cfg = _make_device(device_type="hue").to_config()
assert cfg.hue_username == "hue_user" # type: ignore[union-attr]
assert cfg.hue_client_key == "hue_key" # type: ignore[union-attr]
assert cfg.hue_entertainment_group_id == "grp_1" # type: ignore[union-attr]
class TestSPIConfig:
def test_to_config_returns_spi(self):
cfg = _make_device(device_type="spi").to_config()
assert isinstance(cfg, SPIConfig)
assert cfg.device_type == "spi"
def test_spi_fields_mapped(self):
cfg = _make_device(device_type="spi").to_config()
assert cfg.spi_speed_hz == 1600000 # type: ignore[union-attr]
assert cfg.spi_led_type == "APA102" # type: ignore[union-attr]
class TestChromaConfig:
def test_to_config_returns_chroma(self):
cfg = _make_device(device_type="chroma").to_config()
assert isinstance(cfg, ChromaConfig)
assert cfg.device_type == "chroma"
def test_chroma_device_type_mapped(self):
cfg = _make_device(device_type="chroma").to_config()
assert cfg.chroma_device_type == "keyboard" # type: ignore[union-attr]
class TestGameSenseConfig:
def test_to_config_returns_gamesense(self):
cfg = _make_device(device_type="gamesense").to_config()
assert isinstance(cfg, GameSenseConfig)
assert cfg.device_type == "gamesense"
def test_gamesense_device_type_mapped(self):
cfg = _make_device(device_type="gamesense").to_config()
assert cfg.gamesense_device_type == "mouse" # type: ignore[union-attr]
class TestBLEConfig:
def test_to_config_returns_ble(self):
cfg = _make_device(device_type="ble").to_config()
assert isinstance(cfg, BLEConfig)
assert cfg.device_type == "ble"
def test_ble_fields_mapped(self):
cfg = _make_device(device_type="ble").to_config()
assert cfg.ble_family == "govee" # type: ignore[union-attr]
assert cfg.ble_govee_key == "secret_key" # type: ignore[union-attr]
def test_ble_has_no_dmx_fields(self):
cfg = _make_device(device_type="ble").to_config()
assert not hasattr(cfg, "dmx_protocol")
class TestGroupConfig:
def test_to_config_returns_group(self):
cfg = _make_device(device_type="group").to_config()
assert isinstance(cfg, GroupConfig)
assert cfg.device_type == "group"
def test_group_fields_mapped(self):
cfg = _make_device(device_type="group").to_config()
assert cfg.group_mode == "independent" # type: ignore[union-attr]
assert cfg.group_device_ids == ["dev_a", "dev_b"] # type: ignore[union-attr]
def test_group_device_ids_is_copy(self):
device = _make_device(device_type="group")
cfg = device.to_config()
# Modifying the device's list should not affect the config snapshot
device.group_device_ids.append("dev_c")
assert "dev_c" not in cfg.group_device_ids # type: ignore[union-attr]
def test_group_has_no_ble_fields(self):
cfg = _make_device(device_type="group").to_config()
assert not hasattr(cfg, "ble_family")
class TestOpenRGBConfig:
def test_to_config_returns_openrgb(self):
cfg = _make_device(device_type="openrgb").to_config()
assert isinstance(cfg, OpenRGBConfig)
assert cfg.device_type == "openrgb"
def test_zone_mode_mapped(self):
cfg = _make_device(device_type="openrgb").to_config()
assert cfg.zone_mode == "separate" # type: ignore[union-attr]
class TestMockConfig:
def test_to_config_returns_mock(self):
cfg = _make_device(device_type="mock").to_config()
assert isinstance(cfg, MockConfig)
assert cfg.device_type == "mock"
def test_send_latency_ms_mapped(self):
cfg = _make_device(device_type="mock").to_config()
assert cfg.send_latency_ms == 5 # type: ignore[union-attr]
class TestDemoConfig:
def test_to_config_returns_demo(self):
cfg = _make_device(device_type="demo").to_config()
assert isinstance(cfg, DemoConfig)
assert cfg.device_type == "demo"
def test_send_latency_ms_mapped(self):
cfg = _make_device(device_type="demo").to_config()
assert cfg.send_latency_ms == 5 # type: ignore[union-attr]
class TestMQTTConfig:
def test_to_config_returns_mqtt(self):
cfg = _make_device(device_type="mqtt").to_config()
assert isinstance(cfg, MQTTConfig)
assert cfg.device_type == "mqtt"
class TestWSConfig:
def test_to_config_returns_ws(self):
cfg = _make_device(device_type="ws").to_config()
assert isinstance(cfg, WSConfig)
assert cfg.device_type == "ws"
class TestUSBHIDConfig:
def test_to_config_returns_usbhid(self):
cfg = _make_device(device_type="usbhid").to_config()
assert isinstance(cfg, USBHIDConfig)
assert cfg.device_type == "usbhid"
class TestUnknownDeviceType:
def test_raises_on_unknown_type(self):
device = _make_device(device_type="unknown_future_type")
with pytest.raises(ValueError, match="Unknown device type"):
device.to_config()
class TestFieldIsolation:
"""Irrelevant Device fields must not leak into the wrong config type."""
def test_wled_has_no_espnow_fields(self):
cfg = _make_device(device_type="wled").to_config()
assert not hasattr(cfg, "espnow_peer_mac")
assert not hasattr(cfg, "espnow_channel")
def test_hue_has_no_spi_fields(self):
cfg = _make_device(device_type="hue").to_config()
assert not hasattr(cfg, "spi_speed_hz")
assert not hasattr(cfg, "spi_led_type")
def test_adalight_has_no_group_fields(self):
cfg = _make_device(device_type="adalight").to_config()
assert not hasattr(cfg, "group_mode")
assert not hasattr(cfg, "group_device_ids")
def test_mqtt_has_no_hue_fields(self):
cfg = _make_device(device_type="mqtt").to_config()
assert not hasattr(cfg, "hue_username")
assert not hasattr(cfg, "hue_client_key")
+17 -38
View File
@@ -3,6 +3,8 @@
import numpy as np
import pytest
from ledgrab.core.devices.device_config import GroupConfig
from ledgrab.core.devices.led_client import ProviderDeps
from ledgrab.storage.database import Database
from ledgrab.storage.device_store import Device, DeviceStore
@@ -238,17 +240,22 @@ class TestGroupLEDClient:
d3 = _create_device(store, "d3", 30)
return store, [d1, d2, d3]
@pytest.mark.asyncio
async def test_connect_creates_children(self, mock_store):
def _make_client(self, store, devices, mode="sequence"):
from ledgrab.core.devices.group_client import GroupLEDClient
store, devices = mock_store
client = GroupLEDClient(
device_store=store,
config = GroupConfig(
device_id="test_group",
group_mode="sequence",
device_url="group://test_group",
led_count=sum(d.led_count for d in devices),
group_mode=mode,
group_device_ids=[d.id for d in devices],
)
return GroupLEDClient(config=config, deps=ProviderDeps(device_store=store))
@pytest.mark.asyncio
async def test_connect_creates_children(self, mock_store):
store, devices = mock_store
client = self._make_client(store, devices)
await client.connect()
assert client.is_connected
assert client.device_led_count == 60 # 10+20+30
@@ -257,15 +264,8 @@ class TestGroupLEDClient:
@pytest.mark.asyncio
async def test_sequence_mode_slices(self, mock_store):
from ledgrab.core.devices.group_client import GroupLEDClient
store, devices = mock_store
client = GroupLEDClient(
device_store=store,
device_id="test_group",
group_mode="sequence",
group_device_ids=[d.id for d in devices],
)
client = self._make_client(store, devices)
await client.connect()
# Capture what each child receives
@@ -292,15 +292,8 @@ class TestGroupLEDClient:
@pytest.mark.asyncio
async def test_independent_mode_resamples(self, mock_store):
from ledgrab.core.devices.group_client import GroupLEDClient
store, devices = mock_store
client = GroupLEDClient(
device_store=store,
device_id="test_group",
group_mode="independent",
group_device_ids=[d.id for d in devices],
)
client = self._make_client(store, devices, mode="independent")
await client.connect()
sent_pixels = []
@@ -328,15 +321,8 @@ class TestGroupLEDClient:
@pytest.mark.asyncio
async def test_close_cleans_up(self, mock_store):
from ledgrab.core.devices.group_client import GroupLEDClient
store, devices = mock_store
client = GroupLEDClient(
device_store=store,
device_id="test_group",
group_mode="sequence",
group_device_ids=[d.id for d in devices],
)
client = self._make_client(store, devices)
await client.connect()
assert client.is_connected
await client.close()
@@ -345,15 +331,8 @@ class TestGroupLEDClient:
@pytest.mark.asyncio
async def test_sequence_pads_short_pixels(self, mock_store):
from ledgrab.core.devices.group_client import GroupLEDClient
store, devices = mock_store
client = GroupLEDClient(
device_store=store,
device_id="test_group",
group_mode="sequence",
group_device_ids=[d.id for d in devices],
)
client = self._make_client(store, devices)
await client.connect()
sent_pixels = []