"""Device storage using JSON files.""" import json import uuid from datetime import datetime, timezone from pathlib import Path from typing import Dict, List, Optional from wled_controller.storage.base_store import BaseJsonStore from wled_controller.utils import get_logger logger = get_logger(__name__) class Device: """Represents a WLED device configuration. A device holds connection state and output settings. Calibration, processing settings, and picture source assignments now live on ColorStripSource and WledOutputTarget respectively. """ def __init__( self, device_id: str, name: str, url: str, led_count: int, enabled: bool = True, device_type: str = "wled", baud_rate: Optional[int] = None, software_brightness: int = 255, auto_shutdown: bool = False, send_latency_ms: int = 0, rgbw: bool = False, zone_mode: str = "combined", tags: List[str] = None, # DMX (Art-Net / sACN) fields dmx_protocol: str = "artnet", dmx_start_universe: int = 0, dmx_start_channel: int = 1, # ESP-NOW fields espnow_peer_mac: str = "", espnow_channel: int = 1, # Philips Hue fields hue_username: str = "", hue_client_key: str = "", hue_entertainment_group_id: str = "", # SPI Direct fields spi_speed_hz: int = 800000, spi_led_type: str = "WS2812B", # Razer Chroma fields chroma_device_type: str = "chromalink", # SteelSeries GameSense fields gamesense_device_type: str = "keyboard", # Default color strip processing template default_css_processing_template_id: str = "", created_at: Optional[datetime] = None, updated_at: Optional[datetime] = None, ): self.id = device_id self.name = name self.url = url self.led_count = led_count self.enabled = enabled self.device_type = device_type self.baud_rate = baud_rate self.software_brightness = software_brightness self.auto_shutdown = auto_shutdown self.send_latency_ms = send_latency_ms self.rgbw = rgbw self.zone_mode = zone_mode self.tags = tags or [] self.dmx_protocol = dmx_protocol self.dmx_start_universe = dmx_start_universe self.dmx_start_channel = dmx_start_channel self.espnow_peer_mac = espnow_peer_mac self.espnow_channel = espnow_channel self.hue_username = hue_username self.hue_client_key = hue_client_key self.hue_entertainment_group_id = hue_entertainment_group_id self.spi_speed_hz = spi_speed_hz self.spi_led_type = spi_led_type self.chroma_device_type = chroma_device_type self.gamesense_device_type = gamesense_device_type self.default_css_processing_template_id = default_css_processing_template_id self.created_at = created_at or datetime.now(timezone.utc) self.updated_at = updated_at or datetime.now(timezone.utc) def to_dict(self) -> dict: """Convert device to dictionary.""" d = { "id": self.id, "name": self.name, "url": self.url, "led_count": self.led_count, "enabled": self.enabled, "device_type": self.device_type, "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), } if self.baud_rate is not None: d["baud_rate"] = self.baud_rate if self.software_brightness != 255: d["software_brightness"] = self.software_brightness if self.auto_shutdown: d["auto_shutdown"] = True if self.send_latency_ms: d["send_latency_ms"] = self.send_latency_ms if self.rgbw: d["rgbw"] = True if self.zone_mode != "combined": d["zone_mode"] = self.zone_mode if self.tags: d["tags"] = self.tags if self.dmx_protocol != "artnet": d["dmx_protocol"] = self.dmx_protocol if self.dmx_start_universe != 0: d["dmx_start_universe"] = self.dmx_start_universe if self.dmx_start_channel != 1: d["dmx_start_channel"] = self.dmx_start_channel if self.espnow_peer_mac: d["espnow_peer_mac"] = self.espnow_peer_mac if self.espnow_channel != 1: d["espnow_channel"] = self.espnow_channel if self.hue_username: d["hue_username"] = self.hue_username if self.hue_client_key: d["hue_client_key"] = self.hue_client_key if self.hue_entertainment_group_id: d["hue_entertainment_group_id"] = self.hue_entertainment_group_id if self.spi_speed_hz != 800000: d["spi_speed_hz"] = self.spi_speed_hz if self.spi_led_type != "WS2812B": d["spi_led_type"] = self.spi_led_type if self.chroma_device_type != "chromalink": d["chroma_device_type"] = self.chroma_device_type if self.gamesense_device_type != "keyboard": d["gamesense_device_type"] = self.gamesense_device_type if self.default_css_processing_template_id: d["default_css_processing_template_id"] = self.default_css_processing_template_id return d @classmethod def from_dict(cls, data: dict) -> "Device": """Create device from dictionary.""" return cls( device_id=data["id"], name=data["name"], url=data["url"], led_count=data["led_count"], enabled=data.get("enabled", True), device_type=data.get("device_type", "wled"), baud_rate=data.get("baud_rate"), software_brightness=data.get("software_brightness", 255), auto_shutdown=data.get("auto_shutdown", False), send_latency_ms=data.get("send_latency_ms", 0), rgbw=data.get("rgbw", False), zone_mode=data.get("zone_mode", "combined"), tags=data.get("tags", []), dmx_protocol=data.get("dmx_protocol", "artnet"), dmx_start_universe=data.get("dmx_start_universe", 0), dmx_start_channel=data.get("dmx_start_channel", 1), espnow_peer_mac=data.get("espnow_peer_mac", ""), espnow_channel=data.get("espnow_channel", 1), hue_username=data.get("hue_username", ""), hue_client_key=data.get("hue_client_key", ""), hue_entertainment_group_id=data.get("hue_entertainment_group_id", ""), spi_speed_hz=data.get("spi_speed_hz", 800000), spi_led_type=data.get("spi_led_type", "WS2812B"), chroma_device_type=data.get("chroma_device_type", "chromalink"), gamesense_device_type=data.get("gamesense_device_type", "keyboard"), default_css_processing_template_id=data.get("default_css_processing_template_id", ""), created_at=datetime.fromisoformat(data.get("created_at", datetime.now(timezone.utc).isoformat())), updated_at=datetime.fromisoformat(data.get("updated_at", datetime.now(timezone.utc).isoformat())), ) # Fields that can be updated (all Device.__init__ params except identity/timestamps) _UPDATABLE_FIELDS = { k for k in Device.__init__.__code__.co_varnames if k not in ('self', 'device_id', 'created_at', 'updated_at') } class DeviceStore(BaseJsonStore[Device]): """Persistent storage for WLED devices.""" _json_key = "devices" _entity_name = "Device" def __init__(self, storage_file: str | Path): super().__init__(file_path=str(storage_file), deserializer=Device.from_dict) logger.info(f"Device store initialized with {len(self._items)} devices") # ── Backward-compat aliases ────────────────────────────────── def get_device(self, device_id: str) -> Device: """Get device by ID. Raises ValueError if not found.""" return self.get(device_id) def get_all_devices(self) -> List[Device]: """Get all devices.""" return self.get_all() def delete_device(self, device_id: str) -> None: """Delete device. Raises ValueError if not found.""" self.delete(device_id) # ── Create / Update ────────────────────────────────────────── def create_device( self, name: str, url: str, led_count: int, device_type: str = "wled", baud_rate: Optional[int] = None, auto_shutdown: bool = False, send_latency_ms: int = 0, rgbw: bool = False, zone_mode: str = "combined", tags: Optional[List[str]] = None, dmx_protocol: str = "artnet", dmx_start_universe: int = 0, dmx_start_channel: int = 1, espnow_peer_mac: str = "", espnow_channel: int = 1, hue_username: str = "", hue_client_key: str = "", hue_entertainment_group_id: str = "", spi_speed_hz: int = 800000, spi_led_type: str = "WS2812B", chroma_device_type: str = "chromalink", gamesense_device_type: str = "keyboard", ) -> Device: """Create a new device.""" device_id = f"device_{uuid.uuid4().hex[:8]}" # Mock devices use their device ID as the URL authority if device_type == "mock": url = f"mock://{device_id}" device = Device( device_id=device_id, name=name, url=url, led_count=led_count, device_type=device_type, baud_rate=baud_rate, auto_shutdown=auto_shutdown, send_latency_ms=send_latency_ms, rgbw=rgbw, zone_mode=zone_mode, tags=tags or [], dmx_protocol=dmx_protocol, dmx_start_universe=dmx_start_universe, dmx_start_channel=dmx_start_channel, espnow_peer_mac=espnow_peer_mac, espnow_channel=espnow_channel, hue_username=hue_username, hue_client_key=hue_client_key, hue_entertainment_group_id=hue_entertainment_group_id, spi_speed_hz=spi_speed_hz, spi_led_type=spi_led_type, chroma_device_type=chroma_device_type, gamesense_device_type=gamesense_device_type, ) self._items[device_id] = device self._save() logger.info(f"Created device {device_id}: {name}") return device def update_device(self, device_id: str, **kwargs) -> Device: """Update device fields. Pass any updatable Device field as a keyword argument. ``None`` values are ignored (no change). """ device = self.get(device_id) # raises ValueError if not found for key, value in kwargs.items(): if value is not None and key in _UPDATABLE_FIELDS: setattr(device, key, value) device.updated_at = datetime.now(timezone.utc) self._save() logger.info(f"Updated device {device_id}") return device # ── Unique helpers ─────────────────────────────────────────── def device_exists(self, device_id: str) -> bool: """Check if device exists.""" return device_id in self._items def clear(self): """Clear all devices (for testing).""" self._items.clear() self._save() logger.warning("Cleared all devices from storage") def load_raw(self) -> dict: """Load raw JSON data from storage (for migration).""" if not self.file_path.exists(): return {} try: with open(self.file_path, "r") as f: return json.load(f) except Exception: return {}