Add LED device abstraction layer for multi-controller support

Introduce abstract LEDClient base class with factory pattern so new
LED controller types can plug in alongside WLED. ProcessorManager is
now fully type-agnostic — all device-specific logic (health checks,
state snapshot/restore, fast send) lives behind the LEDClient interface.

- New led_client.py: LEDClient ABC, DeviceHealth, factory functions
- WLEDClient inherits LEDClient, encapsulates WLED health checks and state management
- device_type field on Device storage model (defaults to "wled")
- Rename target_type "wled" → "led" with backward-compat migration
- Frontend: "WLED" tab → "LED", device type badge, type selector in
  add-device modal, device type shown in target device dropdown
- All wled_* API fields renamed to device_* for generic naming

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 12:41:02 +03:00
parent afce183f79
commit b5a6885126
18 changed files with 667 additions and 346 deletions

View File

@@ -31,6 +31,7 @@ class Device:
url: str,
led_count: int,
enabled: bool = True,
device_type: str = "wled",
calibration: Optional[CalibrationConfig] = None,
created_at: Optional[datetime] = None,
updated_at: Optional[datetime] = None,
@@ -40,6 +41,7 @@ class Device:
self.url = url
self.led_count = led_count
self.enabled = enabled
self.device_type = device_type
self.calibration = calibration or create_default_calibration(led_count)
self.created_at = created_at or datetime.utcnow()
self.updated_at = updated_at or datetime.utcnow()
@@ -52,6 +54,7 @@ class Device:
"url": self.url,
"led_count": self.led_count,
"enabled": self.enabled,
"device_type": self.device_type,
"calibration": calibration_to_dict(self.calibration),
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
@@ -77,6 +80,7 @@ class Device:
url=data["url"],
led_count=data["led_count"],
enabled=data.get("enabled", True),
device_type=data.get("device_type", "wled"),
calibration=calibration,
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
@@ -160,6 +164,7 @@ class DeviceStore:
name: str,
url: str,
led_count: int,
device_type: str = "wled",
calibration: Optional[CalibrationConfig] = None,
) -> Device:
"""Create a new device."""
@@ -170,6 +175,7 @@ class DeviceStore:
name=name,
url=url,
led_count=led_count,
device_type=device_type,
calibration=calibration,
)

View File

@@ -31,7 +31,8 @@ class PictureTarget:
def from_dict(cls, data: dict) -> "PictureTarget":
"""Create from dictionary, dispatching to the correct subclass."""
target_type = data.get("target_type", "wled")
if target_type == "wled":
# "wled" and "led" both map to WledPictureTarget (backward compat)
if target_type in ("wled", "led"):
from wled_controller.storage.wled_picture_target import WledPictureTarget
return WledPictureTarget.from_dict(data)
if target_type == "key_colors":

View File

@@ -119,8 +119,11 @@ class PictureTargetStore:
Raises:
ValueError: If validation fails
"""
if target_type not in ("wled", "key_colors"):
if target_type not in ("led", "wled", "key_colors"):
raise ValueError(f"Invalid target type: {target_type}")
# Normalize legacy "wled" to "led"
if target_type == "wled":
target_type = "led"
# Check for duplicate name
for target in self._targets.values():
@@ -130,11 +133,11 @@ class PictureTargetStore:
target_id = f"pt_{uuid.uuid4().hex[:8]}"
now = datetime.utcnow()
if target_type == "wled":
if target_type == "led":
target: PictureTarget = WledPictureTarget(
id=target_id,
name=name,
target_type="wled",
target_type="led",
device_id=device_id,
picture_source_id=picture_source_id,
settings=settings or ProcessingSettings(),

View File

@@ -1,4 +1,4 @@
"""WLED picture target — streams a picture source to a WLED device."""
"""LED picture target — streams a picture source to an LED device."""
from dataclasses import dataclass, field
from datetime import datetime
@@ -10,7 +10,7 @@ from wled_controller.storage.picture_target import PictureTarget
@dataclass
class WledPictureTarget(PictureTarget):
"""WLED picture target — streams a picture source to a WLED device."""
"""LED picture target — streams a picture source to an LED device."""
device_id: str = ""
picture_source_id: str = ""
@@ -24,7 +24,6 @@ class WledPictureTarget(PictureTarget):
d["settings"] = {
"display_index": self.settings.display_index,
"fps": self.settings.fps,
"border_width": self.settings.border_width,
"brightness": self.settings.brightness,
"gamma": self.settings.gamma,
"saturation": self.settings.saturation,
@@ -44,7 +43,6 @@ class WledPictureTarget(PictureTarget):
settings = ProcessingSettings(
display_index=settings_data.get("display_index", 0),
fps=settings_data.get("fps", 30),
border_width=settings_data.get("border_width", 10),
brightness=settings_data.get("brightness", 1.0),
gamma=settings_data.get("gamma", 2.2),
saturation=settings_data.get("saturation", 1.0),
@@ -57,7 +55,7 @@ class WledPictureTarget(PictureTarget):
return cls(
id=data["id"],
name=data["name"],
target_type=data.get("target_type", "wled"),
target_type="led",
device_id=data.get("device_id", ""),
picture_source_id=data.get("picture_source_id", ""),
settings=settings,