feat(devices): Nanoleaf OpenAPI target type + first pair-flow user

Adds support for Nanoleaf controllers (Light Panels / Canvas / Shapes /
Lines / Elements) via the documented HTTP REST API on port 16021.
First concrete consumer of the pair-UX scaffold from commit 2f31680 --
the abstraction is no longer speculative.

Backend:
- NanoleafClient is a single-pixel HTTP adapter: averages the strip to
  one RGB triple, converts to Nanoleaf's HSB scale (H 0-360 / S 0-100 /
  B 0-100), and PUTs to /api/v1/<token>/state with duration:0 so
  transitions are instant for ambilight. Brightness is clamped to >=1
  because Nanoleaf rejects brightness=0.
- pair_nanoleaf(host) implements the two-step handshake: POST
  /api/v1/new during the 30-second pairing window the controller opens
  after the user holds the power button for 5 s.
    200 -> {auth_token: "..."}
    403 -> raises PairingNotReady ("Hold the power button...")
    other / transport error -> RuntimeError wrapping the cause
- NanoleafDeviceProvider.pair_device returns {nanoleaf_token: ...}
  forwarded by POST /api/v1/devices/pair to the frontend for inclusion
  in the subsequent create payload.
- mDNS discovery via _nanoleafapi._tcp (and the v1 variant); failures
  yield [] rather than raising.
- Health check probes /api/v1 without a token (401/403 still proves
  the host is alive).
- NanoleafConfig has nanoleaf_token + nanoleaf_min_interval_ms
  (default 100 ms = ~10 Hz; HTTP overhead caps practical max ~20 Hz).
- Auth token encrypted at rest via _enc/_dec, matching Hue / BLE-Govee.
- 42 unit tests cover URL parsing, RGB->HSB conversion, pairing
  handshake (200 / 403 / 500 / missing-token / transport-error),
  state mutations, brightness clamp, set_power / set_brightness /
  set_color, connection lifecycle, provider validate / pair /
  discover / capabilities, and Device.to_config round-trip including
  the encrypted-token roundtrip via to_dict + from_dict.

Frontend:
- 'nanoleaf' in DEVICE_TYPE_KEYS (next to 'govee'), HEXAGON icon
  (deliberate departure from the smart-bulb lightbulb family --
  Nanoleaf is panels, not bulbs, and the brand identity is hexagonal).
- isNanoleafDevice predicate + per-type field show/hide.
- Pair flow integration: when the device type is Nanoleaf, the add-
  device modal retitles its submit button to "Pair Device" and
  intercepts the submit. handleAddDevice awaits
  runPairingFlow({deviceType: 'nanoleaf', url}), merges result.fields
  ({nanoleaf_token}) into the create body, then POSTs. On
  PairingCancelled the user stays on the modal silently.
- Settings modal exposes the rate-limit field and a read-only
  "Paired" indicator reusing the pair-modal success badge. The token
  itself is never rendered to the DOM and never sent on update --
  re-pairing requires delete + re-add.
- Per-type pairing instructions in en/ru/zh
  (device.nanoleaf.pair.instructions) that the scaffold's i18n lookup
  resolves automatically.
- Bundle: +6.4 KiB (pairing-flow.ts was tree-shaken before this
  commit; now both it and the Nanoleaf branches are baked in).

The pair-UX scaffold is now proven, not speculative. Tuya and Twinkly
can follow the same shape when their phases arrive.
This commit is contained in:
2026-05-16 03:59:38 +03:00
parent 2f31680823
commit 426484adf8
20 changed files with 1209 additions and 4 deletions
+9
View File
@@ -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
+10
View File
@@ -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,
+20
View File
@@ -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")
@@ -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,
@@ -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
@@ -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://<host>``. 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/<token>/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),
)
@@ -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
+4
View File
@@ -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';
}
@@ -23,6 +23,7 @@ export const flaskConical = '<path d="M14 2v6a2 2 0 0 0 .245.96l5.51 10.08A2 2 0
export const pencil = '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>';
export const play = '<path d="M5 5a2 2 0 0 1 3.008-1.728l11.997 6.998a2 2 0 0 1 .003 3.458l-12 7A2 2 0 0 1 5 19z"/>';
export const square = '<rect width="18" height="18" x="3" y="3" rx="2"/>';
export const hexagon = '<path d="M21 16.5c0 .38-.21.71-.53.88l-7.9 4.44a1.13 1.13 0 0 1-1.14 0l-7.9-4.44A1 1 0 0 1 3 16.5v-9c0-.38.21-.71.53-.88l7.9-4.44a1.13 1.13 0 0 1 1.14 0l7.9 4.44c.32.17.53.5.53.88z"/>';
export const circle = '<circle cx="12" cy="12" r="9"/>';
export const pause = '<rect x="14" y="3" width="5" height="18" rx="1"/><rect x="5" y="3" width="5" height="18" rx="1"/>';
export const settings = '<path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/>';
@@ -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),
@@ -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://<address>), 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;
@@ -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() || '';
+3
View File
@@ -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;
+11
View File
@@ -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:",
+11
View File
@@ -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 адрес:",
+11
View File
@@ -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 / ElementsHTTP 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 地址:",
@@ -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,
@@ -51,6 +51,7 @@
<option value="wiz">WiZ</option>
<option value="lifx">LIFX</option>
<option value="govee">Govee</option>
<option value="nanoleaf">Nanoleaf</option>
<option value="ble">BLE LED Controller</option>
<option value="usbhid">USB HID</option>
<option value="spi">SPI Direct</option>
@@ -265,6 +266,15 @@
<small class="input-hint" style="display:none" data-i18n="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.</small>
<input type="number" id="device-govee-min-interval" min="0" max="10000" step="10" value="50">
</div>
<!-- Nanoleaf fields -->
<div class="form-group" id="device-nanoleaf-min-interval-group" style="display: none;">
<div class="label-row">
<label for="device-nanoleaf-min-interval" data-i18n="device.nanoleaf_min_interval">Min Update Interval:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="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.</small>
<input type="number" id="device-nanoleaf-min-interval" min="0" max="10000" step="10" value="100">
</div>
<!-- ESP-NOW fields -->
<div class="form-group" id="device-espnow-peer-mac-group" style="display: none;">
<div class="label-row">
@@ -435,7 +445,7 @@
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeAddDeviceModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="document.getElementById('add-device-form').requestSubmit()" title="Add Device" data-i18n-aria-label="aria.save">&#x2713;</button>
<button id="add-device-submit-btn" class="btn btn-icon btn-primary" onclick="document.getElementById('add-device-form').requestSubmit()" title="Add Device" data-i18n-aria-label="aria.save" data-i18n-title="aria.save">&#x2713;</button>
</div>
</div>
</div>
@@ -295,6 +295,31 @@
<input type="number" id="settings-govee-min-interval" min="0" max="10000" step="10" value="50">
</div>
<div class="form-group" id="settings-nanoleaf-min-interval-group" style="display: none;">
<div class="label-row">
<label for="settings-nanoleaf-min-interval" data-i18n="device.nanoleaf_min_interval">Min Update Interval:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="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.</small>
<input type="number" id="settings-nanoleaf-min-interval" min="0" max="10000" step="10" value="100">
</div>
<!-- Read-only pairing indicator. We never show the
token value itself (it's encrypted at rest and
must never round-trip through the settings
modal); just a checkmark confirming the device
is authenticated. To re-pair, delete and
re-add the device. -->
<div class="form-group" id="settings-nanoleaf-paired-group" style="display: none;">
<div class="label-row">
<label data-i18n="device.nanoleaf.token.label">Auth token:</label>
</div>
<div class="pair-status pair-status-ok" style="margin-top: 4px;">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>
<span data-i18n="device.nanoleaf.paired">Paired</span>
</div>
</div>
<div class="form-group" id="settings-send-latency-group" style="display: none;">
<div class="label-row">
<label for="settings-send-latency" data-i18n="device.send_latency">Send Latency (ms):</label>
+460
View File
@@ -0,0 +1,460 @@
"""Tests for the Nanoleaf OpenAPI LED client + provider."""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import httpx
import numpy as np
import pytest
from ledgrab.core.devices.device_config import NanoleafConfig
from ledgrab.core.devices.led_client import PairingNotReady, ProviderDeps
from ledgrab.core.devices.nanoleaf_client import (
NANOLEAF_PORT,
NanoleafClient,
_average_color,
pair_nanoleaf,
parse_nanoleaf_url,
rgb_to_hsb,
)
from ledgrab.core.devices.nanoleaf_provider import NanoleafDeviceProvider
# ============================================================================
# URL parsing
# ============================================================================
@pytest.mark.parametrize(
"url,expected",
[
("nanoleaf://192.168.1.50", "192.168.1.50"),
("nanoleaf://192.168.1.50:16021", "192.168.1.50"),
("192.168.1.50", "192.168.1.50"),
("nanoleaf://controller.local", "controller.local"),
],
)
def test_parse_nanoleaf_url(url, expected):
assert parse_nanoleaf_url(url) == expected
@pytest.mark.parametrize("url", ["", " ", "nanoleaf://", "://1.2.3.4"])
def test_parse_nanoleaf_url_rejects_empty(url):
with pytest.raises(ValueError):
parse_nanoleaf_url(url)
# ============================================================================
# RGB → HSB conversion (Nanoleaf scale: H 0-360, S 0-100, B 0-100)
# ============================================================================
def test_rgb_to_hsb_pure_red():
h, s, b = rgb_to_hsb(255, 0, 0)
assert h == 0
assert s == 100
assert b == 100
def test_rgb_to_hsb_pure_green():
h, s, b = rgb_to_hsb(0, 255, 0)
assert h == 120
assert s == 100
assert b == 100
def test_rgb_to_hsb_pure_blue():
h, s, b = rgb_to_hsb(0, 0, 255)
assert h == 240
assert s == 100
assert b == 100
def test_rgb_to_hsb_white_zero_saturation():
h, s, b = rgb_to_hsb(255, 255, 255)
assert s == 0
assert b == 100
# Hue is undefined for pure white; we deterministically pick 0
assert h == 0
def test_rgb_to_hsb_black_zero_brightness():
h, s, b = rgb_to_hsb(0, 0, 0)
assert b == 0
assert s == 0
assert h == 0
def test_rgb_to_hsb_clamps_out_of_range():
h, s, b = rgb_to_hsb(-10, 999, 128)
assert 0 <= h < 360
assert 0 <= s <= 100
assert 0 <= b <= 100
# ============================================================================
# _average_color
# ============================================================================
def test_average_color_numpy():
pixels = np.array([[10, 20, 30], [40, 50, 60], [70, 80, 90]], dtype=np.uint8)
assert _average_color(pixels) == (40, 50, 60)
def test_average_color_list_and_empty():
assert _average_color([(10, 0, 0), (20, 0, 0), (30, 0, 0)]) == (20, 0, 0)
assert _average_color([]) == (0, 0, 0)
# ============================================================================
# pair_nanoleaf — HTTP handshake
# ============================================================================
@pytest.mark.asyncio
async def test_pair_nanoleaf_returns_token_on_200(respx_mock):
respx_mock.post(f"http://1.2.3.4:{NANOLEAF_PORT}/api/v1/new").mock(
return_value=httpx.Response(200, json={"auth_token": "tok-abc"}),
)
token = await pair_nanoleaf("1.2.3.4")
assert token == "tok-abc"
@pytest.mark.asyncio
async def test_pair_nanoleaf_raises_pairing_not_ready_on_403(respx_mock):
respx_mock.post(f"http://1.2.3.4:{NANOLEAF_PORT}/api/v1/new").mock(
return_value=httpx.Response(403, text="not pairing"),
)
with pytest.raises(PairingNotReady) as exc:
await pair_nanoleaf("1.2.3.4")
# The user-facing message must mention the physical action.
assert "power button" in str(exc.value).lower()
@pytest.mark.asyncio
async def test_pair_nanoleaf_raises_runtime_error_on_500(respx_mock):
respx_mock.post(f"http://1.2.3.4:{NANOLEAF_PORT}/api/v1/new").mock(
return_value=httpx.Response(500, text="boom"),
)
with pytest.raises(RuntimeError, match="HTTP 500"):
await pair_nanoleaf("1.2.3.4")
@pytest.mark.asyncio
async def test_pair_nanoleaf_rejects_missing_token(respx_mock):
respx_mock.post(f"http://1.2.3.4:{NANOLEAF_PORT}/api/v1/new").mock(
return_value=httpx.Response(200, json={"not_a_token": "x"}),
)
with pytest.raises(RuntimeError, match="no auth_token"):
await pair_nanoleaf("1.2.3.4")
@pytest.mark.asyncio
async def test_pair_nanoleaf_wraps_transport_error(respx_mock):
respx_mock.post(f"http://1.2.3.4:{NANOLEAF_PORT}/api/v1/new").mock(
side_effect=httpx.ConnectError("refused", request=None),
)
with pytest.raises(RuntimeError, match="transport failure"):
await pair_nanoleaf("1.2.3.4")
# ============================================================================
# NanoleafClient (mocked HTTP)
# ============================================================================
def _make_connected_client(token: str = "tok-abc") -> NanoleafClient:
"""Build a NanoleafClient with a mocked httpx.AsyncClient."""
client = NanoleafClient(
"nanoleaf://1.2.3.4", led_count=10, auth_token=token, min_interval_s=0.0
)
http_mock = MagicMock()
http_mock.put = AsyncMock(return_value=httpx.Response(204))
http_mock.aclose = AsyncMock()
client._http = http_mock
client._connected = True
return client
def _last_put_body(client: NanoleafClient) -> dict:
"""Pull the JSON body out of the most recent put() call."""
assert client._http.put.called
return client._http.put.call_args.kwargs["json"]
def _last_put_url(client: NanoleafClient) -> str:
return client._http.put.call_args.args[0]
@pytest.mark.asyncio
async def test_send_pixels_emits_hsb_state_put():
client = _make_connected_client(token="abc123")
pixels = np.array([[255, 0, 0]], dtype=np.uint8)
await client.send_pixels(pixels)
body = _last_put_body(client)
assert "hue" in body
assert "sat" in body
assert "brightness" in body
assert body["hue"]["value"] == 0
assert body["sat"]["value"] == 100
assert body["brightness"]["value"] == 100
# duration: 0 keeps the transition instant — critical for ambilight
assert body["brightness"]["duration"] == 0
assert "/api/v1/abc123/state" in _last_put_url(client)
@pytest.mark.asyncio
async def test_send_pixels_clamps_brightness_to_at_least_1():
"""Nanoleaf rejects brightness=0; clamping to 1 keeps the API happy."""
client = _make_connected_client()
pixels = np.array([[0, 0, 0]], dtype=np.uint8)
await client.send_pixels(pixels)
body = _last_put_body(client)
assert body["brightness"]["value"] == 1
@pytest.mark.asyncio
async def test_send_pixels_applies_brightness_scale():
client = _make_connected_client()
pixels = np.array([[255, 0, 0]], dtype=np.uint8)
await client.send_pixels(pixels, brightness=128)
body = _last_put_body(client)
# 255 * 128/255 = 128 → brightness ~128/255 of full = ~50% of HSB scale
assert 40 <= body["brightness"]["value"] <= 60
@pytest.mark.asyncio
async def test_send_pixels_when_not_connected_raises():
client = NanoleafClient("nanoleaf://1.2.3.4", led_count=1, auth_token="abc")
with pytest.raises(RuntimeError, match="not connected"):
await client.send_pixels([(1, 2, 3)])
@pytest.mark.asyncio
async def test_set_power_emits_on_value():
client = _make_connected_client()
await client.set_power(True)
await client.set_power(False)
bodies = [call.kwargs["json"] for call in client._http.put.call_args_list]
assert bodies[0] == {"on": {"value": True}}
assert bodies[1] == {"on": {"value": False}}
@pytest.mark.asyncio
async def test_set_brightness_clamps_to_0_100():
client = _make_connected_client()
await client.set_brightness(-10)
await client.set_brightness(50)
await client.set_brightness(200)
bodies = [call.kwargs["json"] for call in client._http.put.call_args_list]
assert bodies[0]["brightness"]["value"] == 0
assert bodies[1]["brightness"]["value"] == 50
assert bodies[2]["brightness"]["value"] == 100
@pytest.mark.asyncio
async def test_set_color_emits_hsb_put():
client = _make_connected_client()
await client.set_color(255, 0, 0)
body = _last_put_body(client)
assert body["hue"]["value"] == 0
assert body["sat"]["value"] == 100
assert body["brightness"]["value"] == 100
@pytest.mark.asyncio
async def test_send_pixels_raises_on_non_2xx_response():
client = _make_connected_client()
client._http.put = AsyncMock(return_value=httpx.Response(401, text="unauthorized"))
with pytest.raises(RuntimeError, match="rejected state update"):
await client.send_pixels(np.array([[1, 2, 3]], dtype=np.uint8))
@pytest.mark.asyncio
async def test_connect_requires_token():
client = NanoleafClient("nanoleaf://1.2.3.4", led_count=1, auth_token="")
with pytest.raises(RuntimeError, match="requires an auth_token"):
await client.connect()
@pytest.mark.asyncio
async def test_close_releases_http_client():
client = _make_connected_client()
http = client._http
await client.close()
http.aclose.assert_awaited()
assert client._http is None
assert client.is_connected is False
# ============================================================================
# Provider
# ============================================================================
def test_provider_device_type_and_capabilities():
provider = NanoleafDeviceProvider()
assert provider.device_type == "nanoleaf"
caps = provider.capabilities
assert "manual_led_count" in caps
assert "requires_pairing" in caps
assert "power_control" in caps
assert "brightness_control" in caps
assert "single_pixel" in caps
@pytest.mark.asyncio
async def test_provider_pair_device_returns_nanoleaf_token(respx_mock):
respx_mock.post(f"http://1.2.3.4:{NANOLEAF_PORT}/api/v1/new").mock(
return_value=httpx.Response(200, json={"auth_token": "ttoken"}),
)
provider = NanoleafDeviceProvider()
fields = await provider.pair_device("nanoleaf://1.2.3.4")
assert fields == {"nanoleaf_token": "ttoken"}
@pytest.mark.asyncio
async def test_provider_pair_device_propagates_pairing_not_ready(respx_mock):
respx_mock.post(f"http://1.2.3.4:{NANOLEAF_PORT}/api/v1/new").mock(
return_value=httpx.Response(403),
)
provider = NanoleafDeviceProvider()
with pytest.raises(PairingNotReady):
await provider.pair_device("nanoleaf://1.2.3.4")
@pytest.mark.asyncio
async def test_provider_pair_device_rejects_invalid_url():
provider = NanoleafDeviceProvider()
with pytest.raises(ValueError, match="Invalid Nanoleaf URL"):
await provider.pair_device("")
@pytest.mark.asyncio
async def test_provider_validate_accepts_bare_host():
provider = NanoleafDeviceProvider()
assert await provider.validate_device("192.168.1.50") == {}
@pytest.mark.asyncio
async def test_provider_validate_rejects_empty():
provider = NanoleafDeviceProvider()
with pytest.raises(ValueError, match="Invalid Nanoleaf URL"):
await provider.validate_device("")
def test_provider_create_client_threads_config():
provider = NanoleafDeviceProvider()
config = NanoleafConfig(
device_id="device_test",
device_url="nanoleaf://192.168.1.50",
led_count=30,
nanoleaf_token="abc-token",
nanoleaf_min_interval_ms=200,
)
client = provider.create_client(config, deps=ProviderDeps())
assert isinstance(client, NanoleafClient)
assert client.host == "192.168.1.50"
assert client._token == "abc-token"
assert client._min_interval_s == pytest.approx(0.2)
# ============================================================================
# Device.to_config() round-trip
# ============================================================================
def test_device_to_config_round_trip_nanoleaf():
from ledgrab.storage.device_store import Device
device = Device(
device_id="device_abc12345",
name="Office Panels",
url="nanoleaf://192.168.1.42",
led_count=9,
device_type="nanoleaf",
nanoleaf_token="tok-xyz",
nanoleaf_min_interval_ms=150,
)
config = device.to_config()
assert isinstance(config, NanoleafConfig)
assert config.device_url == "nanoleaf://192.168.1.42"
assert config.nanoleaf_token == "tok-xyz"
assert config.nanoleaf_min_interval_ms == 150
def test_device_to_dict_omits_nanoleaf_defaults():
from ledgrab.storage.device_store import Device
device = Device(
device_id="device_abc12345",
name="Default",
url="nanoleaf://192.168.1.42",
led_count=1,
device_type="nanoleaf",
)
payload = device.to_dict()
assert "nanoleaf_token" not in payload
assert "nanoleaf_min_interval_ms" not in payload
def test_device_to_dict_encrypts_nanoleaf_token():
"""The auth token must not appear in cleartext in storage."""
from ledgrab.storage.device_store import Device
device = Device(
device_id="device_abc12345",
name="Encrypted",
url="nanoleaf://192.168.1.42",
led_count=1,
device_type="nanoleaf",
nanoleaf_token="cleartext-secret-token",
)
payload = device.to_dict()
assert "nanoleaf_token" in payload
assert payload["nanoleaf_token"] != "cleartext-secret-token"
def test_device_from_dict_decrypts_nanoleaf_token():
"""from_dict + to_dict on an encrypted token must round-trip back to cleartext."""
from ledgrab.storage.device_store import Device
original = Device(
device_id="device_abc12345",
name="Roundtrip",
url="nanoleaf://10.0.0.1",
led_count=1,
device_type="nanoleaf",
nanoleaf_token="cleartext-secret-token",
nanoleaf_min_interval_ms=200,
)
restored = Device.from_dict(original.to_dict() | {"id": original.id})
assert restored.nanoleaf_token == "cleartext-secret-token"
assert restored.nanoleaf_min_interval_ms == 200