diff --git a/TODO.md b/TODO.md index 4c6d571..1fc00c3 100644 --- a/TODO.md +++ b/TODO.md @@ -747,6 +747,15 @@ After phase 1 the codebase will have 3 fresh examples of "ping the LAN, listen f Single-pixel `colorwc` command with `colorTemInKelvin=0` for RGB mode. **Per-device "LAN Control" toggle required in Govee Home app.** 40 unit tests. Frontend wired via subagent. +- [x] **Nanoleaf OpenAPI** — Light Panels / Canvas / Shapes / Lines / + Elements via HTTP REST on port 16021. **First concrete user of + the pairing-UX scaffold from Phase 2.** mDNS discovery via + `_nanoleafapi._tcp`. Single-pixel adapter (averaged strip → HSB + `PUT /state`). Auth token encrypted at rest via `_enc`/`_dec`. + 42 unit tests covering URL parsing, RGB→HSB conversion, pairing + handshake (200/403/500/missing-token/transport-error), state + mutations, brightness clamping, Device.to_config round-trip + including encrypted-token roundtrip. - [ ] Twinkly - [ ] Nanoleaf OpenAPI - [ ] Mi-Light / MiBoxer UDP gateway diff --git a/server/src/ledgrab/api/routes/devices.py b/server/src/ledgrab/api/routes/devices.py index b1d43e2..936266d 100644 --- a/server/src/ledgrab/api/routes/devices.py +++ b/server/src/ledgrab/api/routes/devices.py @@ -74,6 +74,8 @@ def _device_to_response(device) -> DeviceResponse: lifx_min_interval_ms=device.lifx_min_interval_ms, govee_min_interval_ms=device.govee_min_interval_ms, opc_channel=device.opc_channel, + nanoleaf_token=device.nanoleaf_token, + nanoleaf_min_interval_ms=device.nanoleaf_min_interval_ms, spi_speed_hz=device.spi_speed_hz, spi_led_type=device.spi_led_type, chroma_device_type=device.chroma_device_type, @@ -250,6 +252,12 @@ async def create_device( else 50 ), opc_channel=(device_data.opc_channel if device_data.opc_channel is not None else 0), + nanoleaf_token=device_data.nanoleaf_token or "", + nanoleaf_min_interval_ms=( + device_data.nanoleaf_min_interval_ms + if device_data.nanoleaf_min_interval_ms is not None + else 100 + ), spi_speed_hz=device_data.spi_speed_hz or 800000, spi_led_type=device_data.spi_led_type or "WS2812B", chroma_device_type=device_data.chroma_device_type or "chromalink", @@ -583,6 +591,8 @@ async def update_device( lifx_min_interval_ms=update_data.lifx_min_interval_ms, govee_min_interval_ms=update_data.govee_min_interval_ms, opc_channel=update_data.opc_channel, + nanoleaf_token=update_data.nanoleaf_token, + nanoleaf_min_interval_ms=update_data.nanoleaf_min_interval_ms, spi_speed_hz=update_data.spi_speed_hz, spi_led_type=update_data.spi_led_type, chroma_device_type=update_data.chroma_device_type, diff --git a/server/src/ledgrab/api/schemas/devices.py b/server/src/ledgrab/api/schemas/devices.py index 9f98147..3e8bd9f 100644 --- a/server/src/ledgrab/api/schemas/devices.py +++ b/server/src/ledgrab/api/schemas/devices.py @@ -98,6 +98,18 @@ class DeviceCreate(BaseModel): le=255, description="OPC channel (0 = broadcast to all channels on the server)", ) + # Nanoleaf fields + nanoleaf_token: Optional[str] = Field( + None, + max_length=512, + description="Nanoleaf auth token returned by the pairing handshake", + ) + nanoleaf_min_interval_ms: Optional[int] = Field( + None, + ge=0, + le=10000, + description="Nanoleaf client-side rate limit between commands in ms (default 100)", + ) # SPI Direct fields spi_speed_hz: Optional[int] = Field( None, ge=100000, le=4000000, description="SPI clock speed in Hz" @@ -201,6 +213,10 @@ class DeviceUpdate(BaseModel): opc_channel: Optional[int] = Field( None, ge=0, le=255, description="OPC channel (0 = broadcast)" ) + nanoleaf_token: Optional[str] = Field(None, max_length=512, description="Nanoleaf auth token") + nanoleaf_min_interval_ms: Optional[int] = Field( + None, ge=0, le=10000, description="Nanoleaf client-side rate limit in ms" + ) spi_speed_hz: Optional[int] = Field(None, ge=100000, le=4000000, description="SPI clock speed") spi_led_type: Optional[str] = Field(None, description="LED chipset type") chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral type") @@ -403,6 +419,10 @@ class DeviceResponse(BaseModel): lifx_min_interval_ms: int = Field(default=50, description="LIFX client-side rate limit in ms") govee_min_interval_ms: int = Field(default=50, description="Govee client-side rate limit in ms") opc_channel: int = Field(default=0, description="OPC channel (0 = broadcast to all)") + nanoleaf_token: str = Field(default="", description="Nanoleaf auth token") + nanoleaf_min_interval_ms: int = Field( + default=100, description="Nanoleaf client-side rate limit in ms" + ) spi_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz") spi_led_type: str = Field(default="WS2812B", description="LED chipset type") chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type") diff --git a/server/src/ledgrab/core/devices/device_config.py b/server/src/ledgrab/core/devices/device_config.py index e6af135..1d25e20 100644 --- a/server/src/ledgrab/core/devices/device_config.py +++ b/server/src/ledgrab/core/devices/device_config.py @@ -136,6 +136,20 @@ class OPCConfig(BaseDeviceConfig): opc_channel: int = 0 +@dataclass(frozen=True) +class NanoleafConfig(BaseDeviceConfig): + """Nanoleaf controller (Light Panels / Canvas / Shapes / Lines / Elements). + + ``nanoleaf_token`` is the long-lived auth token returned by the pairing + handshake. Without it the controller rejects every state-mutating call, + so device creation should be preceded by a successful pairing flow. + """ + + device_type: Literal["nanoleaf"] = "nanoleaf" + nanoleaf_token: str = "" + nanoleaf_min_interval_ms: int = 100 + + @dataclass(frozen=True) class SPIConfig(BaseDeviceConfig): device_type: Literal["spi"] = "spi" @@ -211,6 +225,7 @@ DeviceConfig = Union[ LIFXConfig, GoveeConfig, OPCConfig, + NanoleafConfig, AdalightConfig, AmbiLEDConfig, DMXConfig, diff --git a/server/src/ledgrab/core/devices/led_client.py b/server/src/ledgrab/core/devices/led_client.py index e0c49fb..4e28d8c 100644 --- a/server/src/ledgrab/core/devices/led_client.py +++ b/server/src/ledgrab/core/devices/led_client.py @@ -391,6 +391,10 @@ def _register_builtin_providers(): register_provider(OPCDeviceProvider()) + from ledgrab.core.devices.nanoleaf_provider import NanoleafDeviceProvider + + register_provider(NanoleafDeviceProvider()) + # BLE support is optional — only register the provider if the ``bleak`` # extra is installed. Importing the provider itself is safe (it doesn't # import bleak at module load), but we still want a clean skip on diff --git a/server/src/ledgrab/core/devices/nanoleaf_client.py b/server/src/ledgrab/core/devices/nanoleaf_client.py new file mode 100644 index 0000000..4d67264 --- /dev/null +++ b/server/src/ledgrab/core/devices/nanoleaf_client.py @@ -0,0 +1,298 @@ +"""Nanoleaf OpenAPI LED client. + +Nanoleaf controllers (Light Panels, Canvas, Shapes, Lines, Elements) expose +an HTTP REST API on port 16021. Pairing follows the documented two-step +handshake: the user holds the controller's power button for 5 seconds to +open a 30-second pairing window, then we POST to ``/api/v1/new`` to claim +an auth token. The token is long-lived and gets stored on the device. + +Once paired, color control is a simple ``PUT /api/v1/{token}/state`` with +HSBT (hue / saturation / brightness; kelvin only matters when sat=0). +LedGrab averages the incoming strip to one HSB triple. Per-panel streaming +mode (``extControl`` UDP, ~60 Hz, addresses each panel individually) is +documented but not implemented here — the MVP keeps the device acting as +a single-pixel target like Yeelight / Hue. + +URL scheme: ``nanoleaf://``. Port is fixed at 16021 on the protocol +side. The auth token is stored separately on the device config, not in +the URL — putting it in the URL would leak the token into log files. + +Reference: https://forum.nanoleaf.me/docs/openapi +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime, timezone +from typing import List, Optional, Tuple, Union +from urllib.parse import urlparse + +import httpx +import numpy as np + +from ledgrab.core.devices.led_client import DeviceHealth, LEDClient, PairingNotReady +from ledgrab.utils import get_logger + +logger = get_logger(__name__) + +NANOLEAF_PORT = 16021 +DEFAULT_MIN_INTERVAL_S = 0.1 # 10 Hz; HTTP per frame, plenty for averaged ambilight + + +def parse_nanoleaf_url(url: str) -> str: + """Pull the host out of ``nanoleaf://host`` or accept a bare host. + + The TCP port is fixed at 16021 on the protocol side; we ignore any + port specifier rather than silently accept one the controller won't + answer on. + """ + if not url: + raise ValueError("Nanoleaf URL is empty") + raw = url.strip() + if "://" in raw: + parsed = urlparse(raw) + host = parsed.hostname or "" + else: + parsed = urlparse(f"nanoleaf://{raw}") + host = parsed.hostname or "" + if not host: + raise ValueError(f"Nanoleaf URL has no host: {url!r}") + return host + + +def _average_color( + pixels: Union[List[Tuple[int, int, int]], np.ndarray], +) -> Tuple[int, int, int]: + """Reduce an N-pixel strip to one average RGB triple.""" + if isinstance(pixels, np.ndarray): + if pixels.size == 0: + return (0, 0, 0) + arr = pixels.reshape(-1, 3) if pixels.ndim > 1 else pixels[:3].reshape(1, 3) + mean = arr.mean(axis=0) + return int(mean[0]), int(mean[1]), int(mean[2]) + if not pixels: + return (0, 0, 0) + total_r = total_g = total_b = 0 + for r, g, b in pixels: + total_r += r + total_g += g + total_b += b + n = len(pixels) + return total_r // n, total_g // n, total_b // n + + +def rgb_to_hsb(r: int, g: int, b: int) -> Tuple[int, int, int]: + """Convert 8-bit RGB to Nanoleaf HSB (hue 0-360, sat 0-100, bri 0-100). + + All outputs are integers; the Nanoleaf API rejects fractional values. + """ + r_n = max(0, min(255, r)) / 255.0 + g_n = max(0, min(255, g)) / 255.0 + b_n = max(0, min(255, b)) / 255.0 + c_max = max(r_n, g_n, b_n) + c_min = min(r_n, g_n, b_n) + delta = c_max - c_min + + if delta == 0: + h = 0.0 + elif c_max == r_n: + h = 60.0 * (((g_n - b_n) / delta) % 6) + elif c_max == g_n: + h = 60.0 * (((b_n - r_n) / delta) + 2) + else: + h = 60.0 * (((r_n - g_n) / delta) + 4) + hue = int(round(h)) % 360 + sat = 0 if c_max == 0 else int(round((delta / c_max) * 100)) + bri = int(round(c_max * 100)) + return hue, sat, bri + + +async def pair_nanoleaf(host: str, timeout_s: float = 4.0) -> str: + """POST to ``/api/v1/new``; user must have just held the power button 5s. + + Returns the auth token on success. Raises ``PairingNotReady`` if the + controller responds 403 (not in pairing mode) — the caller surfaces + that as a 409 to the UI with a retry prompt. + """ + base = f"http://{host}:{NANOLEAF_PORT}/api/v1/new" + try: + async with httpx.AsyncClient(timeout=timeout_s) as http_client: + response = await http_client.post(base) + except (httpx.HTTPError, httpx.TimeoutException) as exc: + raise RuntimeError(f"Pairing transport failure for {host}: {exc}") from exc + + if response.status_code == 403: + raise PairingNotReady( + "Hold the power button on your Nanoleaf controller for 5 seconds " + "until the LEDs flash, then click Try again." + ) + if response.status_code != 200: + raise RuntimeError( + f"Nanoleaf at {host} returned HTTP {response.status_code} during pairing: " + f"{response.text[:200]}" + ) + try: + token = response.json().get("auth_token") + except ValueError as exc: + raise RuntimeError(f"Malformed pairing response from {host}: {exc}") from exc + if not token or not isinstance(token, str): + raise RuntimeError(f"Nanoleaf at {host} returned no auth_token in pairing response") + return token + + +class NanoleafClient(LEDClient): + """LEDClient for a single Nanoleaf controller (Panels / Canvas / Shapes).""" + + def __init__( + self, + url: str, + led_count: int = 1, + *, + auth_token: str = "", + min_interval_s: float = DEFAULT_MIN_INTERVAL_S, + request_timeout_s: float = 3.0, + ): + self._host = parse_nanoleaf_url(url) + self._token = auth_token + self._led_count = led_count + self._min_interval_s = max(0.0, min_interval_s) + self._request_timeout_s = request_timeout_s + self._http: Optional[httpx.AsyncClient] = None + self._connected = False + self._next_tx_at: float = 0.0 + + @property + def host(self) -> str: + return self._host + + @property + def is_connected(self) -> bool: + return self._connected and self._http is not None + + @property + def device_led_count(self) -> Optional[int]: + return self._led_count or None + + def _state_url(self) -> str: + return f"http://{self._host}:{NANOLEAF_PORT}/api/v1/{self._token}/state" + + async def connect(self) -> bool: + if self._connected and self._http is not None: + return True + if not self._token: + raise RuntimeError("NanoleafClient requires an auth_token; pair the device first") + self._http = httpx.AsyncClient(timeout=self._request_timeout_s) + self._connected = True + logger.info("NanoleafClient connected to %s:%d", self._host, NANOLEAF_PORT) + return True + + async def close(self) -> None: + if self._http is not None: + try: + await self._http.aclose() + except (httpx.HTTPError, RuntimeError): + pass + self._http = None + self._connected = False + + async def _put_state(self, body: dict) -> None: + if self._http is None: + raise RuntimeError("NanoleafClient not connected") + response = await self._http.put(self._state_url(), json=body) + # 204 No Content is the documented success; 200 also acceptable in practice. + if response.status_code not in (200, 204): + raise RuntimeError( + f"Nanoleaf rejected state update ({response.status_code}): " + f"{response.text[:200]}" + ) + + async def send_pixels( + self, + pixels: Union[List[Tuple[int, int, int]], np.ndarray], + brightness: int = 255, + ) -> bool: + """Average the strip and PUT a single HSB state update.""" + if not self.is_connected: + raise RuntimeError("NanoleafClient not connected") + loop_now = asyncio.get_event_loop().time() + if loop_now < self._next_tx_at: + return True + r, g, b = _average_color(pixels) + if brightness < 255: + scale = max(0, min(255, brightness)) / 255.0 + r = int(r * scale) + g = int(g * scale) + b = int(b * scale) + hue, sat, bri = rgb_to_hsb(r, g, b) + # Nanoleaf rejects brightness=0; clamp to 1 so the user can still see + # "almost off" without falling off the API. + bri = max(1, bri) + await self._put_state( + { + "hue": {"value": hue}, + "sat": {"value": sat}, + "brightness": {"value": bri, "duration": 0}, + } + ) + self._next_tx_at = loop_now + self._min_interval_s + return True + + async def set_color(self, r: int, g: int, b: int) -> None: + if not self.is_connected: + raise RuntimeError("NanoleafClient not connected") + hue, sat, bri = rgb_to_hsb(r, g, b) + bri = max(1, bri) + await self._put_state( + { + "hue": {"value": hue}, + "sat": {"value": sat}, + "brightness": {"value": bri, "duration": 0}, + } + ) + + async def set_power(self, on: bool) -> None: + if not self.is_connected: + raise RuntimeError("NanoleafClient not connected") + await self._put_state({"on": {"value": on}}) + + async def set_brightness(self, brightness_0_100: int) -> None: + if not self.is_connected: + raise RuntimeError("NanoleafClient not connected") + clamped = max(0, min(100, brightness_0_100)) + await self._put_state({"brightness": {"value": clamped, "duration": 0}}) + + @classmethod + async def check_health( + cls, + url: str, + http_client, + prev_health: Optional[DeviceHealth] = None, + ) -> DeviceHealth: + """GET ``/api/v1//info``. Without a token we can't authenticate, + so we fall back to GET ``/api/v1`` which returns 401 when the host is + a real Nanoleaf controller and connection-error otherwise.""" + now = datetime.now(timezone.utc) + try: + host = parse_nanoleaf_url(url) + except ValueError as exc: + return DeviceHealth(online=False, last_checked=now, error=str(exc)) + probe_url = f"http://{host}:{NANOLEAF_PORT}/api/v1" + loop = asyncio.get_running_loop() + start = loop.time() + try: + response = await http_client.get(probe_url, timeout=2.0) + except (httpx.HTTPError, httpx.TimeoutException) as exc: + return DeviceHealth( + online=False, + last_checked=now, + error=f"Nanoleaf unreachable at {host}: {exc}", + ) + latency_ms = (loop.time() - start) * 1000.0 + # Real Nanoleaf responds 401/403 on /api/v1 without an auth token; + # anything else may still be alive. Both signal "host is up". + return DeviceHealth( + online=True, + latency_ms=latency_ms, + last_checked=now, + device_version=str(response.status_code), + ) diff --git a/server/src/ledgrab/core/devices/nanoleaf_provider.py b/server/src/ledgrab/core/devices/nanoleaf_provider.py new file mode 100644 index 0000000..2ab40d9 --- /dev/null +++ b/server/src/ledgrab/core/devices/nanoleaf_provider.py @@ -0,0 +1,158 @@ +"""Nanoleaf device provider — Light Panels / Canvas / Shapes / Lines / Elements.""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, List + +from ledgrab.core.devices.led_client import ( + DeviceHealth, + DiscoveredDevice, + LEDClient, + LEDDeviceProvider, + ProviderDeps, +) +from ledgrab.core.devices.nanoleaf_client import ( + NanoleafClient, + pair_nanoleaf, + parse_nanoleaf_url, +) +from ledgrab.utils import get_logger + +if TYPE_CHECKING: + from ledgrab.core.devices.device_config import NanoleafConfig + +logger = get_logger(__name__) + + +class NanoleafDeviceProvider(LEDDeviceProvider): + """Provider for Nanoleaf controllers. + + Treats the controller as a single-pixel target (averaged strip → one + HSB color via ``PUT /state``). Per-panel addressing via the + ``extControl`` UDP streaming mode is a follow-up — the MVP keeps it + simple and matches the shape of every other consumer-bulb driver. + + Requires pairing: the user holds the controller's power button for + five seconds to open a 30-second window, then the frontend pair + modal POSTs to ``/api/v1/new`` via ``pair_device``. + """ + + @property + def device_type(self) -> str: + return "nanoleaf" + + @property + def capabilities(self) -> set: + return { + "manual_led_count", + "requires_pairing", + "power_control", + "brightness_control", + "static_color", + "health_check", + "single_pixel", + } + + def create_client(self, config: "NanoleafConfig", *, deps: ProviderDeps) -> LEDClient: + return NanoleafClient( + config.device_url, + led_count=config.led_count, + auth_token=config.nanoleaf_token, + min_interval_s=max(0.0, config.nanoleaf_min_interval_ms / 1000.0), + ) + + async def pair_device(self, url: str) -> dict: + """Claim an auth token after the user has held the power button.""" + try: + host = parse_nanoleaf_url(url) + except ValueError as exc: + raise ValueError(f"Invalid Nanoleaf URL: {exc}") from exc + token = await pair_nanoleaf(host) + return {"nanoleaf_token": token} + + async def check_health(self, url: str, http_client, prev_health=None) -> DeviceHealth: + return await NanoleafClient.check_health(url, http_client, prev_health) + + async def validate_device(self, url: str) -> dict: + """Resolve the host. LED-count is user-supplied (matches the + existing single-pixel pattern) — Nanoleaf reports panel count + through ``panelLayout``, but for the single-color streaming + shape it's not needed.""" + try: + host = parse_nanoleaf_url(url) + except ValueError as exc: + raise ValueError(f"Invalid Nanoleaf URL: {exc}") from exc + logger.info("Nanoleaf URL validated: host=%s", host) + return {} + + async def discover(self, timeout: float = 3.0) -> List[DiscoveredDevice]: + """Scan via mDNS ``_nanoleafapi._tcp``. + + Both the newer ``_nanoleafapi._tcp`` and older ``_nanoleaf._tcp`` + service types appear in the field. We query both in parallel and + deduplicate by IP. + """ + try: + from zeroconf import ServiceStateChange + from zeroconf.asyncio import AsyncServiceBrowser, AsyncServiceInfo, AsyncZeroconf + except ImportError: + logger.warning("zeroconf unavailable; skipping Nanoleaf mDNS discovery") + return [] + + service_types = ("_nanoleafapi._tcp.local.", "_nanoleafapi_v1._tcp.local.") + discovered: dict[str, AsyncServiceInfo] = {} + + def _on_state_change(**kwargs): + service_type = kwargs.get("service_type", "") + name = kwargs.get("name", "") + state_change = kwargs.get("state_change") + if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated): + discovered[name] = AsyncServiceInfo(service_type, name) + + try: + aiozc = AsyncZeroconf() + except OSError as exc: + logger.warning("Nanoleaf discovery: zeroconf init failed (%s)", exc) + return [] + + try: + browsers = [ + AsyncServiceBrowser(aiozc.zeroconf, st, handlers=[_on_state_change]) + for st in service_types + ] + await asyncio.sleep(timeout) + for info in discovered.values(): + await info.async_request(aiozc.zeroconf, timeout=2000) + for browser in browsers: + await browser.async_cancel() + finally: + try: + await aiozc.async_close() + except (OSError, RuntimeError): + pass + + results: List[DiscoveredDevice] = [] + seen_ips: set[str] = set() + for name, info in discovered.items(): + addrs = info.parsed_addresses() if info else [] + if not addrs: + continue + ip = addrs[0] + if ip in seen_ips: + continue + seen_ips.add(ip) + service_name = name.rsplit(".", 4)[0] if "." in name else name + results.append( + DiscoveredDevice( + name=service_name or "Nanoleaf", + url=f"nanoleaf://{ip}", + device_type="nanoleaf", + ip=ip, + mac="", + led_count=None, + version=None, + ) + ) + logger.info("Nanoleaf mDNS scan found %d controller(s)", len(results)) + return results diff --git a/server/src/ledgrab/static/js/core/api.ts b/server/src/ledgrab/static/js/core/api.ts index 99a1f29..f186ec1 100644 --- a/server/src/ledgrab/static/js/core/api.ts +++ b/server/src/ledgrab/static/js/core/api.ts @@ -179,6 +179,10 @@ export function isGoveeDevice(type: string) { return type === 'govee'; } +export function isNanoleafDevice(type: string) { + return type === 'nanoleaf'; +} + export function isUsbhidDevice(type: string) { return type === 'usbhid'; } diff --git a/server/src/ledgrab/static/js/core/icon-paths.ts b/server/src/ledgrab/static/js/core/icon-paths.ts index ee12c74..9a196a4 100644 --- a/server/src/ledgrab/static/js/core/icon-paths.ts +++ b/server/src/ledgrab/static/js/core/icon-paths.ts @@ -23,6 +23,7 @@ export const flaskConical = ''; export const play = ''; export const square = ''; +export const hexagon = ''; export const circle = ''; export const pause = ''; export const settings = ''; diff --git a/server/src/ledgrab/static/js/core/icons.ts b/server/src/ledgrab/static/js/core/icons.ts index 96985d8..5bb4ad5 100644 --- a/server/src/ledgrab/static/js/core/icons.ts +++ b/server/src/ledgrab/static/js/core/icons.ts @@ -49,6 +49,7 @@ const _deviceTypeIcons = { mqtt: _svg(P.send), ws: _svg(P.globe), openrgb: _svg(P.palette), dmx: _svg(P.radio), ddp: _svg(P.send), opc: _svg(P.send), mock: _svg(P.wrench), espnow: _svg(P.radio), hue: _svg(P.lightbulb), yeelight: _svg(P.lightbulb), wiz: _svg(P.lightbulb), lifx: _svg(P.lightbulb), govee: _svg(P.lightbulb), + nanoleaf: _svg(P.hexagon), usbhid: _svg(P.usb), spi: _svg(P.plug), chroma: _svg(P.zap), gamesense: _svg(P.target), ble: _svg(P.bluetooth), diff --git a/server/src/ledgrab/static/js/features/device-discovery.ts b/server/src/ledgrab/static/js/features/device-discovery.ts index 546de28..7d9d17b 100644 --- a/server/src/ledgrab/static/js/features/device-discovery.ts +++ b/server/src/ledgrab/static/js/features/device-discovery.ts @@ -7,12 +7,13 @@ import { _discoveryCache, set_discoveryCache, csptCache, } from '../core/state.ts'; -import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts'; +import { API_BASE, fetchWithAuth, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isEspnowDevice, isHueDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isUsbhidDevice, isSpiDevice, isChromaDevice, isGameSenseDevice, isGroupDevice, escapeHtml } from '../core/api.ts'; import { devicesCache } from '../core/state.ts'; import { t } from '../core/i18n.ts'; import { showToast, desktopFocus } from '../core/ui.ts'; import { Modal } from '../core/modal.ts'; import { _computeMaxFps, _renderFpsHint } from './devices.ts'; +import { runPairingFlow, PairingCancelled } from './pairing-flow.ts'; import { getDeviceTypeIcon, ICON_RADIO, ICON_GLOBE, ICON_CPU, ICON_KEYBOARD, ICON_MOUSE, ICON_HEADPHONES, ICON_PLUG, ICON_TARGET_ICON, ICON_ACTIVITY, ICON_TEMPLATE, ICON_CHEVRON_UP, ICON_CHEVRON_DOWN, ICON_PLUS, ICON_TRASH, ICON_GIT_MERGE, ICON_COPY, ICON_BLUETOOTH, ICON_LIGHTBULB, ICON_SPARKLES, ICON_PALETTE } from '../core/icons.ts'; import { EntitySelect, EntityPalette } from '../core/entity-palette.ts'; import { IconSelect, showTypePicker } from '../core/icon-select.ts'; @@ -46,6 +47,7 @@ class AddDeviceModal extends Modal { wizMinInterval: (document.getElementById('device-wiz-min-interval') as HTMLInputElement)?.value || '50', lifxMinInterval: (document.getElementById('device-lifx-min-interval') as HTMLInputElement)?.value || '50', goveeMinInterval: (document.getElementById('device-govee-min-interval') as HTMLInputElement)?.value || '50', + nanoleafMinInterval: (document.getElementById('device-nanoleaf-min-interval') as HTMLInputElement)?.value || '100', groupChildren: JSON.stringify(_getGroupChildIds('device')), groupMode: (document.getElementById('device-group-mode-select') as HTMLSelectElement)?.value || 'sequence', }; @@ -56,7 +58,7 @@ const addDeviceModal = new AddDeviceModal(); /* ── Icon-grid type selector ──────────────────────────────────── */ -const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'ddp', 'opc', 'espnow', 'hue', 'yeelight', 'wiz', 'lifx', 'govee', 'ble', 'usbhid', 'spi', 'chroma', 'gamesense', 'group', 'mock']; +const DEVICE_TYPE_KEYS = ['wled', 'adalight', 'ambiled', 'mqtt', 'ws', 'openrgb', 'dmx', 'ddp', 'opc', 'espnow', 'hue', 'yeelight', 'wiz', 'lifx', 'govee', 'nanoleaf', 'ble', 'usbhid', 'spi', 'chroma', 'gamesense', 'group', 'mock']; function _buildDeviceTypeItems() { return DEVICE_TYPE_KEYS.map(key => ({ @@ -287,6 +289,7 @@ export function onDeviceTypeChanged() { _showWizFields(false); _showLifxFields(false); _showGoveeFields(false); + _showNanoleafFields(false); _showBleFields(false); _showSpiFields(false); _showChromaFields(false); @@ -575,6 +578,30 @@ export function onDeviceTypeChanged() { } else { scanForDevices(); } + } else if (isNanoleafDevice(deviceType)) { + // Nanoleaf: HTTP REST over fixed port 16021. The controller requires + // a one-time pairing handshake — the user holds the power button for + // 5 seconds until the LEDs flash, then the backend POSTs to /new and + // gets back an auth token. We delegate the handshake to runPairingFlow + // (see handleAddDevice below); discovery uses mDNS (_nanoleafapi._tcp). + urlGroup.style.display = ''; + urlInput.setAttribute('required', ''); + serialGroup.style.display = 'none'; + serialSelect.removeAttribute('required'); + ledCountGroup.style.display = ''; + baudRateGroup.style.display = 'none'; + if (ledTypeGroup) ledTypeGroup.style.display = 'none'; + if (sendLatencyGroup) sendLatencyGroup.style.display = 'none'; + if (scanBtn) scanBtn.style.display = ''; + _showNanoleafFields(true); + if (urlLabel) urlLabel.textContent = t('device.nanoleaf.url') || 'IP Address:'; + if (urlHint) urlHint.textContent = t('device.nanoleaf.url.hint') || 'LAN IP of the Nanoleaf controller. HTTP port 16021 is fixed in the protocol.'; + urlInput.placeholder = t('device.nanoleaf.url.placeholder') || '192.168.1.50'; + if (deviceType in _discoveryCache) { + _renderDiscoveryList(); + } else { + scanForDevices(); + } } else if (isBleDevice(deviceType)) { // BLE: show URL (ble://
), LED count, protocol family picker, // and a Govee-only AES key field that toggles with the family selection. @@ -940,6 +967,15 @@ export function showAddDevice(presetType: any = null, cloneData: any = null) { lmi.value = String(cloneData.lifx_min_interval_ms); } } + // Prefill Nanoleaf fields (clone only carries the rate limit — the + // token is not exposed in /devices responses, so a cloned device + // must re-pair to get its own auth token). + if (isNanoleafDevice(presetType)) { + const nmi = document.getElementById('device-nanoleaf-min-interval') as HTMLInputElement; + if (nmi && cloneData.nanoleaf_min_interval_ms != null) { + nmi.value = String(cloneData.nanoleaf_min_interval_ms); + } + } // Prefill Govee fields if (isGoveeDevice(presetType)) { const gmi = document.getElementById('device-govee-min-interval') as HTMLInputElement; @@ -1095,6 +1131,12 @@ export async function handleAddDevice(event: any) { url = 'mqtt://' + url; } + // Nanoleaf: ensure nanoleaf:// prefix so the URL the pair flow + the + // create endpoint see is identical and provider-resolvable. + if (isNanoleafDevice(deviceType) && url && !url.startsWith('nanoleaf://')) { + url = 'nanoleaf://' + url; + } + // OpenRGB: append selected zones to URL const checkedZones = isOpenrgbDevice(deviceType) ? _getCheckedZones('device-zone-list') : []; if (isOpenrgbDevice(deviceType) && checkedZones.length > 0) { @@ -1173,6 +1215,11 @@ export async function handleAddDevice(event: any) { const parsed = parseInt(raw || '50', 10); body.govee_min_interval_ms = Number.isFinite(parsed) ? parsed : 50; } + if (isNanoleafDevice(deviceType)) { + const raw = (document.getElementById('device-nanoleaf-min-interval') as HTMLInputElement)?.value; + const parsed = parseInt(raw || '100', 10); + body.nanoleaf_min_interval_ms = Number.isFinite(parsed) ? parsed : 100; + } if (isBleDevice(deviceType)) { body.ble_family = (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || 'sp110e'; const goveeKey = (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value?.trim(); @@ -1201,6 +1248,25 @@ export async function handleAddDevice(event: any) { if (csptId) body.default_css_processing_template_id = csptId; if (lastTemplateId) body.capture_template_id = lastTemplateId; + // Nanoleaf — and any future driver that advertises `requires_pairing` — + // needs an out-of-band handshake before the device row can be created. + // We open the shared pair modal (features/pairing-flow.ts), wait for + // the backend to negotiate a token with the controller, then merge the + // returned fields into the create body. A soft cancel (user closed the + // pair modal) bails out silently — the add-device modal is still open + // for them to retry or pick a different type. + if (isNanoleafDevice(deviceType)) { + try { + const pairResult = await runPairingFlow({ deviceType: 'nanoleaf', url }); + Object.assign(body, pairResult.fields); + } catch (pairErr: unknown) { + if (pairErr instanceof PairingCancelled) return; + const msg = pairErr instanceof Error ? pairErr.message : t('pairing.failed_prefix'); + showToast(msg, 'error'); + return; + } + } + const response = await fetchWithAuth('/devices', { method: 'POST', body: JSON.stringify(body) @@ -1551,6 +1617,33 @@ function _showGoveeFields(show: boolean) { if (el) el.style.display = show ? '' : 'none'; } +/** + * Toggle Nanoleaf-specific rows AND swap the footer submit button into + * "Pair Device" mode. Nanoleaf is the first driver that requires an + * out-of-band handshake (hold power button → backend POSTs /new for a + * token), so the submit button's tooltip changes to signal that the + * next click runs pairing rather than just creating the row. The actual + * pair flow is invoked from handleAddDevice when device_type is 'nanoleaf'. + */ +function _showNanoleafFields(show: boolean) { + const el = document.getElementById('device-nanoleaf-min-interval-group') as HTMLElement | null; + if (el) el.style.display = show ? '' : 'none'; + const submitBtn = document.getElementById('add-device-submit-btn') as HTMLButtonElement | null; + if (submitBtn) { + if (show) { + const label = t('device.nanoleaf.pair_button') || 'Pair Device'; + submitBtn.title = label; + submitBtn.setAttribute('aria-label', label); + submitBtn.setAttribute('data-pair-mode', 'nanoleaf'); + } else { + const label = t('aria.save') || 'Add Device'; + submitBtn.title = label; + submitBtn.setAttribute('aria-label', label); + submitBtn.removeAttribute('data-pair-mode'); + } + } +} + // Tracks whether the BLE fields are currently shown — avoids reading // style.display strings in _updateBleGoveeKeyVisibility. let _bleFieldsVisible = false; diff --git a/server/src/ledgrab/static/js/features/devices.ts b/server/src/ledgrab/static/js/features/devices.ts index 49ae3ae..7b1ff30 100644 --- a/server/src/ledgrab/static/js/features/devices.ts +++ b/server/src/ledgrab/static/js/features/devices.ts @@ -6,7 +6,7 @@ import { _deviceBrightnessCache, updateDeviceBrightness, csptCache, } from '../core/state.ts'; -import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isBleDevice, isGroupDevice } from '../core/api.ts'; +import { API_BASE, getHeaders, fetchWithAuth, escapeHtml, isSerialDevice, isMockDevice, isMqttDevice, isWsDevice, isOpenrgbDevice, isDmxDevice, isDdpDevice, isOpcDevice, isYeelightDevice, isWizDevice, isLifxDevice, isGoveeDevice, isNanoleafDevice, isBleDevice, isGroupDevice } from '../core/api.ts'; import { devicesCache } from '../core/state.ts'; import { _fetchOpenrgbZones, _getCheckedZones, _splitOpenrgbZone, _getZoneMode, ensureDmxProtocolIconSelect, destroyDmxProtocolIconSelect, ensureDdpColorOrderIconSelect, destroyDdpColorOrderIconSelect, ensureSpiLedTypeIconSelect, destroySpiLedTypeIconSelect, ensureGameSenseDeviceTypeIconSelect, destroyGameSenseDeviceTypeIconSelect, addGroupChildSettingsWithId as _addGroupChildSettingsWithId, ensureGroupModeIconSelect, destroyGroupModeIconSelect, ensureBleFamilyIconSelect, destroyBleFamilyIconSelect } from './device-discovery.ts'; import { t } from '../core/i18n.ts'; @@ -100,6 +100,7 @@ class DeviceSettingsModal extends Modal { wizMinInterval: (document.getElementById('settings-wiz-min-interval') as HTMLInputElement | null)?.value || '50', lifxMinInterval: (document.getElementById('settings-lifx-min-interval') as HTMLInputElement | null)?.value || '50', goveeMinInterval: (document.getElementById('settings-govee-min-interval') as HTMLInputElement | null)?.value || '50', + nanoleafMinInterval: (document.getElementById('settings-nanoleaf-min-interval') as HTMLInputElement | null)?.value || '100', csptId: (document.getElementById('settings-css-processing-template') as HTMLSelectElement | null)?.value || '', }; } @@ -723,6 +724,32 @@ export async function showSettings(deviceId: any) { if (goveeMinIntervalGroup) (goveeMinIntervalGroup as HTMLElement).style.display = 'none'; } + // Nanoleaf-specific fields — HTTP REST on fixed port 16021. Same + // URL-relabel pattern as the rest of the LAN-bulb family. The + // `nanoleaf_token` is set once at pair time and is encrypted at + // rest; we show a read-only "Paired ✓" indicator (no input) so the + // user can confirm the device is authenticated without ever seeing + // or being able to overwrite the secret. To re-pair, delete and + // re-add the device. + const nanoleafMinIntervalGroup = document.getElementById('settings-nanoleaf-min-interval-group'); + const nanoleafPairedGroup = document.getElementById('settings-nanoleaf-paired-group'); + if (isNanoleafDevice(device.device_type)) { + if (nanoleafMinIntervalGroup) (nanoleafMinIntervalGroup as HTMLElement).style.display = ''; + const nmi = device.nanoleaf_min_interval_ms ?? 100; + (document.getElementById('settings-nanoleaf-min-interval') as HTMLInputElement).value = String(nmi); + if (nanoleafPairedGroup) { + (nanoleafPairedGroup as HTMLElement).style.display = device.nanoleaf_token ? '' : 'none'; + } + const urlLabel8 = urlGroup.querySelector('label[for="settings-device-url"]') as HTMLElement | null; + const urlHint8 = urlGroup.querySelector('.input-hint') as HTMLElement | null; + if (urlLabel8) urlLabel8.textContent = t('device.nanoleaf.url'); + if (urlHint8) urlHint8.textContent = t('device.nanoleaf.url.hint'); + urlInput.placeholder = t('device.nanoleaf.url.placeholder') || '192.168.1.50'; + } else { + if (nanoleafMinIntervalGroup) (nanoleafMinIntervalGroup as HTMLElement).style.display = 'none'; + if (nanoleafPairedGroup) (nanoleafPairedGroup as HTMLElement).style.display = 'none'; + } + // BLE-specific fields — exposed in the settings modal so the user // can fix a wrong protocol family pick without deleting+recreating // the device. Uses the shared IconSelect grid (project rule bans @@ -884,6 +911,15 @@ export async function saveDeviceSettings() { const parsed = parseInt(raw || '50', 10); body.govee_min_interval_ms = Number.isFinite(parsed) ? parsed : 50; } + if (isNanoleafDevice(settingsModal.deviceType)) { + const raw = (document.getElementById('settings-nanoleaf-min-interval') as HTMLInputElement | null)?.value; + const parsed = parseInt(raw || '100', 10); + body.nanoleaf_min_interval_ms = Number.isFinite(parsed) ? parsed : 100; + // Intentionally do NOT include nanoleaf_token here — the token + // is set once at pair time, encrypted at rest, and never + // re-emitted from the settings modal. Re-pairing means + // delete + re-add. + } if (isBleDevice(settingsModal.deviceType)) { body.ble_family = (document.getElementById('settings-ble-family') as HTMLSelectElement | null)?.value || 'sp110e'; const goveeKey = (document.getElementById('settings-ble-govee-key') as HTMLInputElement | null)?.value?.trim() || ''; diff --git a/server/src/ledgrab/static/js/types.ts b/server/src/ledgrab/static/js/types.ts index 444c62c..9fe76f7 100644 --- a/server/src/ledgrab/static/js/types.ts +++ b/server/src/ledgrab/static/js/types.ts @@ -48,6 +48,7 @@ export function bindableColorSourceId(b: BindableColor | undefined): string { export type DeviceType = | 'wled' | 'adalight' | 'ambiled' | 'mock' | 'mqtt' | 'ws' | 'openrgb' | 'dmx' | 'ddp' | 'opc' | 'espnow' | 'hue' | 'yeelight' | 'wiz' | 'lifx' | 'govee' + | 'nanoleaf' | 'ble' | 'usbhid' | 'spi' | 'chroma' | 'gamesense' | 'group'; @@ -81,6 +82,8 @@ export interface Device { wiz_min_interval_ms: number; lifx_min_interval_ms: number; govee_min_interval_ms: number; + nanoleaf_token: string; + nanoleaf_min_interval_ms: number; spi_speed_hz: number; spi_led_type: string; chroma_device_type: string; diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index a36ded5..3d5987a 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -227,6 +227,17 @@ "device.govee.url.placeholder": "192.168.1.50", "device.govee_min_interval": "Min Update Interval:", "device.govee_min_interval.hint": "Client-side rate limit between commands in ms. UDP fire-and-forget tolerates fast updates; default 50 ms ≈ 20 Hz.", + "device.type.nanoleaf": "Nanoleaf", + "device.type.nanoleaf.desc": "Nanoleaf Light Panels / Canvas / Shapes / Lines / Elements (HTTP REST, requires pairing)", + "device.nanoleaf.url": "IP Address:", + "device.nanoleaf.url.hint": "LAN IP of the Nanoleaf controller. HTTP port 16021 is fixed in the protocol.", + "device.nanoleaf.url.placeholder": "192.168.1.50", + "device.nanoleaf_min_interval": "Min Update Interval:", + "device.nanoleaf_min_interval.hint": "Client-side rate limit between commands in ms. Default 100 ms ≈ 10 Hz; HTTP request overhead caps the practical max around 20 Hz.", + "device.nanoleaf.pair.instructions": "Press and hold the power button on your Nanoleaf controller for 5 seconds until the LEDs flash, then click Start. The controller opens a 30-second pairing window.", + "device.nanoleaf.pair_button": "Pair Device", + "device.nanoleaf.token.label": "Auth token:", + "device.nanoleaf.paired": "Paired", "device.type.ble": "BLE LED Controller", "device.type.ble.desc": "Bluetooth LE strips: SP110E, Triones, Zengge, Govee (whole-strip color)", "device.ble.url": "BLE Address:", diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index 33792fe..310759c 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -282,6 +282,17 @@ "device.govee.url.placeholder": "192.168.1.50", "device.govee_min_interval": "Мин. интервал обновления:", "device.govee_min_interval.hint": "Локальный лимит частоты команд (мс). UDP fire-and-forget справляется с быстрыми обновлениями; по умолчанию 50 мс ≈ 20 Гц.", + "device.type.nanoleaf": "Nanoleaf", + "device.type.nanoleaf.desc": "Nanoleaf Light Panels / Canvas / Shapes / Lines / Elements (HTTP REST, требует сопряжения)", + "device.nanoleaf.url": "IP-адрес:", + "device.nanoleaf.url.hint": "IP-адрес контроллера Nanoleaf в локальной сети. HTTP-порт 16021 зафиксирован в протоколе.", + "device.nanoleaf.url.placeholder": "192.168.1.50", + "device.nanoleaf_min_interval": "Мин. интервал обновления:", + "device.nanoleaf_min_interval.hint": "Локальный лимит частоты команд (мс). По умолчанию 100 мс ≈ 10 Гц; накладные расходы HTTP ограничивают практический максимум ~20 Гц.", + "device.nanoleaf.pair.instructions": "Нажмите и удерживайте кнопку питания на контроллере Nanoleaf в течение 5 секунд, пока светодиоды не мигнут, затем нажмите «Начать». Контроллер откроет окно сопряжения на 30 секунд.", + "device.nanoleaf.pair_button": "Сопряжение", + "device.nanoleaf.token.label": "Токен авторизации:", + "device.nanoleaf.paired": "Сопряжено", "device.type.ble": "BLE LED контроллер", "device.type.ble.desc": "Bluetooth LE ленты: SP110E, Triones, Zengge, Govee (один цвет на всю ленту)", "device.ble.url": "BLE адрес:", diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index 19b27c6..1edefe8 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -280,6 +280,17 @@ "device.govee.url.placeholder": "192.168.1.50", "device.govee_min_interval": "最小更新间隔:", "device.govee_min_interval.hint": "客户端命令速率限制(毫秒)。UDP 即发即忘可处理快速更新;默认 50 毫秒 ≈ 20 Hz。", + "device.type.nanoleaf": "Nanoleaf", + "device.type.nanoleaf.desc": "Nanoleaf Light Panels / Canvas / Shapes / Lines / Elements(HTTP REST,需要配对)", + "device.nanoleaf.url": "IP 地址:", + "device.nanoleaf.url.hint": "Nanoleaf 控制器的局域网 IP。HTTP 端口 16021 由协议固定。", + "device.nanoleaf.url.placeholder": "192.168.1.50", + "device.nanoleaf_min_interval": "最小更新间隔:", + "device.nanoleaf_min_interval.hint": "客户端命令速率限制(毫秒)。默认 100 毫秒 ≈ 10 Hz;HTTP 请求开销将实际上限限制在约 20 Hz。", + "device.nanoleaf.pair.instructions": "请按住 Nanoleaf 控制器上的电源按钮 5 秒,直到 LED 闪烁,然后点击「开始」。控制器将打开 30 秒的配对窗口。", + "device.nanoleaf.pair_button": "配对设备", + "device.nanoleaf.token.label": "认证令牌:", + "device.nanoleaf.paired": "已配对", "device.type.ble": "BLE LED 控制器", "device.type.ble.desc": "Bluetooth LE 灯带:SP110E、Triones、Zengge、Govee(整条灯带同色)", "device.ble.url": "BLE 地址:", diff --git a/server/src/ledgrab/storage/device_store.py b/server/src/ledgrab/storage/device_store.py index 4b31268..9243dc4 100644 --- a/server/src/ledgrab/storage/device_store.py +++ b/server/src/ledgrab/storage/device_store.py @@ -72,6 +72,9 @@ class Device: govee_min_interval_ms: int = 50, # OPC fields opc_channel: int = 0, + # Nanoleaf fields + nanoleaf_token: str = "", + nanoleaf_min_interval_ms: int = 100, # SPI Direct fields spi_speed_hz: int = 800000, spi_led_type: str = "WS2812B", @@ -124,6 +127,8 @@ class Device: self.lifx_min_interval_ms = lifx_min_interval_ms self.govee_min_interval_ms = govee_min_interval_ms self.opc_channel = opc_channel + self.nanoleaf_token = nanoleaf_token + self.nanoleaf_min_interval_ms = nanoleaf_min_interval_ms self.spi_speed_hz = spi_speed_hz self.spi_led_type = spi_led_type self.chroma_device_type = chroma_device_type @@ -164,6 +169,7 @@ class Device: SPIConfig, GoveeConfig, LIFXConfig, + NanoleafConfig, OPCConfig, USBHIDConfig, WiZConfig, @@ -240,6 +246,12 @@ class Device: **base, opc_channel=self.opc_channel, ) + if dt == "nanoleaf": + return NanoleafConfig( + **base, + nanoleaf_token=self.nanoleaf_token, + nanoleaf_min_interval_ms=self.nanoleaf_min_interval_ms, + ) if dt == "spi": return SPIConfig(**base, spi_speed_hz=self.spi_speed_hz, spi_led_type=self.spi_led_type) if dt == "chroma": @@ -328,6 +340,11 @@ class Device: d["govee_min_interval_ms"] = self.govee_min_interval_ms if self.opc_channel: d["opc_channel"] = self.opc_channel + if self.nanoleaf_token: + # Long-lived auth token — encrypt at rest like Hue / MQTT creds. + d["nanoleaf_token"] = _enc(self.nanoleaf_token) + if self.nanoleaf_min_interval_ms != 100: + d["nanoleaf_min_interval_ms"] = self.nanoleaf_min_interval_ms if self.spi_speed_hz != 800000: d["spi_speed_hz"] = self.spi_speed_hz if self.spi_led_type != "WS2812B": @@ -388,6 +405,8 @@ class Device: lifx_min_interval_ms=data.get("lifx_min_interval_ms", 50), govee_min_interval_ms=data.get("govee_min_interval_ms", 50), opc_channel=data.get("opc_channel", 0), + nanoleaf_token=_dec(data.get("nanoleaf_token", "")), + nanoleaf_min_interval_ms=data.get("nanoleaf_min_interval_ms", 100), 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"), @@ -440,6 +459,8 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset( "lifx_min_interval_ms", "govee_min_interval_ms", "opc_channel", + "nanoleaf_token", + "nanoleaf_min_interval_ms", "spi_speed_hz", "spi_led_type", "chroma_device_type", @@ -545,6 +566,8 @@ class DeviceStore(BaseSqliteStore[Device]): lifx_min_interval_ms: int = 50, govee_min_interval_ms: int = 50, opc_channel: int = 0, + nanoleaf_token: str = "", + nanoleaf_min_interval_ms: int = 100, spi_speed_hz: int = 800000, spi_led_type: str = "WS2812B", chroma_device_type: str = "chromalink", @@ -593,6 +616,8 @@ class DeviceStore(BaseSqliteStore[Device]): lifx_min_interval_ms=lifx_min_interval_ms, govee_min_interval_ms=govee_min_interval_ms, opc_channel=opc_channel, + nanoleaf_token=nanoleaf_token, + nanoleaf_min_interval_ms=nanoleaf_min_interval_ms, spi_speed_hz=spi_speed_hz, spi_led_type=spi_led_type, chroma_device_type=chroma_device_type, diff --git a/server/src/ledgrab/templates/modals/add-device.html b/server/src/ledgrab/templates/modals/add-device.html index b7ce95d..bddaf73 100644 --- a/server/src/ledgrab/templates/modals/add-device.html +++ b/server/src/ledgrab/templates/modals/add-device.html @@ -51,6 +51,7 @@ + @@ -265,6 +266,15 @@ + + diff --git a/server/src/ledgrab/templates/modals/device-settings.html b/server/src/ledgrab/templates/modals/device-settings.html index bce6f52..00c4896 100644 --- a/server/src/ledgrab/templates/modals/device-settings.html +++ b/server/src/ledgrab/templates/modals/device-settings.html @@ -295,6 +295,31 @@ + + + + +