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 Single-pixel `colorwc` command with `colorTemInKelvin=0` for RGB
mode. **Per-device "LAN Control" toggle required in Govee Home mode. **Per-device "LAN Control" toggle required in Govee Home
app.** 40 unit tests. Frontend wired via subagent. 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 - [ ] Twinkly
- [ ] Nanoleaf OpenAPI - [ ] Nanoleaf OpenAPI
- [ ] Mi-Light / MiBoxer UDP gateway - [ ] 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, lifx_min_interval_ms=device.lifx_min_interval_ms,
govee_min_interval_ms=device.govee_min_interval_ms, govee_min_interval_ms=device.govee_min_interval_ms,
opc_channel=device.opc_channel, 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_speed_hz=device.spi_speed_hz,
spi_led_type=device.spi_led_type, spi_led_type=device.spi_led_type,
chroma_device_type=device.chroma_device_type, chroma_device_type=device.chroma_device_type,
@@ -250,6 +252,12 @@ async def create_device(
else 50 else 50
), ),
opc_channel=(device_data.opc_channel if device_data.opc_channel is not None else 0), 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_speed_hz=device_data.spi_speed_hz or 800000,
spi_led_type=device_data.spi_led_type or "WS2812B", spi_led_type=device_data.spi_led_type or "WS2812B",
chroma_device_type=device_data.chroma_device_type or "chromalink", 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, lifx_min_interval_ms=update_data.lifx_min_interval_ms,
govee_min_interval_ms=update_data.govee_min_interval_ms, govee_min_interval_ms=update_data.govee_min_interval_ms,
opc_channel=update_data.opc_channel, 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_speed_hz=update_data.spi_speed_hz,
spi_led_type=update_data.spi_led_type, spi_led_type=update_data.spi_led_type,
chroma_device_type=update_data.chroma_device_type, chroma_device_type=update_data.chroma_device_type,
+20
View File
@@ -98,6 +98,18 @@ class DeviceCreate(BaseModel):
le=255, le=255,
description="OPC channel (0 = broadcast to all channels on the server)", 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 Direct fields
spi_speed_hz: Optional[int] = Field( spi_speed_hz: Optional[int] = Field(
None, ge=100000, le=4000000, description="SPI clock speed in Hz" None, ge=100000, le=4000000, description="SPI clock speed in Hz"
@@ -201,6 +213,10 @@ class DeviceUpdate(BaseModel):
opc_channel: Optional[int] = Field( opc_channel: Optional[int] = Field(
None, ge=0, le=255, description="OPC channel (0 = broadcast)" 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_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") spi_led_type: Optional[str] = Field(None, description="LED chipset type")
chroma_device_type: Optional[str] = Field(None, description="Chroma peripheral 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") 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") 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)") 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_speed_hz: int = Field(default=800000, description="SPI clock speed in Hz")
spi_led_type: str = Field(default="WS2812B", description="LED chipset type") spi_led_type: str = Field(default="WS2812B", description="LED chipset type")
chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type") chroma_device_type: str = Field(default="chromalink", description="Chroma peripheral type")
@@ -136,6 +136,20 @@ class OPCConfig(BaseDeviceConfig):
opc_channel: int = 0 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) @dataclass(frozen=True)
class SPIConfig(BaseDeviceConfig): class SPIConfig(BaseDeviceConfig):
device_type: Literal["spi"] = "spi" device_type: Literal["spi"] = "spi"
@@ -211,6 +225,7 @@ DeviceConfig = Union[
LIFXConfig, LIFXConfig,
GoveeConfig, GoveeConfig,
OPCConfig, OPCConfig,
NanoleafConfig,
AdalightConfig, AdalightConfig,
AmbiLEDConfig, AmbiLEDConfig,
DMXConfig, DMXConfig,
@@ -391,6 +391,10 @@ def _register_builtin_providers():
register_provider(OPCDeviceProvider()) 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`` # BLE support is optional — only register the provider if the ``bleak``
# extra is installed. Importing the provider itself is safe (it doesn't # 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 # 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'; return type === 'govee';
} }
export function isNanoleafDevice(type: string) {
return type === 'nanoleaf';
}
export function isUsbhidDevice(type: string) { export function isUsbhidDevice(type: string) {
return type === 'usbhid'; 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 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 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 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 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 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"/>'; 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), 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), 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), 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), usbhid: _svg(P.usb),
spi: _svg(P.plug), chroma: _svg(P.zap), gamesense: _svg(P.target), spi: _svg(P.plug), chroma: _svg(P.zap), gamesense: _svg(P.target),
ble: _svg(P.bluetooth), ble: _svg(P.bluetooth),
@@ -7,12 +7,13 @@ import {
_discoveryCache, set_discoveryCache, _discoveryCache, set_discoveryCache,
csptCache, csptCache,
} from '../core/state.ts'; } 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 { devicesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts'; import { t } from '../core/i18n.ts';
import { showToast, desktopFocus } from '../core/ui.ts'; import { showToast, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts'; import { Modal } from '../core/modal.ts';
import { _computeMaxFps, _renderFpsHint } from './devices.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 { 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 { EntitySelect, EntityPalette } from '../core/entity-palette.ts';
import { IconSelect, showTypePicker } from '../core/icon-select.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', wizMinInterval: (document.getElementById('device-wiz-min-interval') as HTMLInputElement)?.value || '50',
lifxMinInterval: (document.getElementById('device-lifx-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', 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')), groupChildren: JSON.stringify(_getGroupChildIds('device')),
groupMode: (document.getElementById('device-group-mode-select') as HTMLSelectElement)?.value || 'sequence', groupMode: (document.getElementById('device-group-mode-select') as HTMLSelectElement)?.value || 'sequence',
}; };
@@ -56,7 +58,7 @@ const addDeviceModal = new AddDeviceModal();
/* ── Icon-grid type selector ──────────────────────────────────── */ /* ── 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() { function _buildDeviceTypeItems() {
return DEVICE_TYPE_KEYS.map(key => ({ return DEVICE_TYPE_KEYS.map(key => ({
@@ -287,6 +289,7 @@ export function onDeviceTypeChanged() {
_showWizFields(false); _showWizFields(false);
_showLifxFields(false); _showLifxFields(false);
_showGoveeFields(false); _showGoveeFields(false);
_showNanoleafFields(false);
_showBleFields(false); _showBleFields(false);
_showSpiFields(false); _showSpiFields(false);
_showChromaFields(false); _showChromaFields(false);
@@ -575,6 +578,30 @@ export function onDeviceTypeChanged() {
} else { } else {
scanForDevices(); 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)) { } else if (isBleDevice(deviceType)) {
// BLE: show URL (ble://<address>), LED count, protocol family picker, // BLE: show URL (ble://<address>), LED count, protocol family picker,
// and a Govee-only AES key field that toggles with the family selection. // 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); 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 // Prefill Govee fields
if (isGoveeDevice(presetType)) { if (isGoveeDevice(presetType)) {
const gmi = document.getElementById('device-govee-min-interval') as HTMLInputElement; const gmi = document.getElementById('device-govee-min-interval') as HTMLInputElement;
@@ -1095,6 +1131,12 @@ export async function handleAddDevice(event: any) {
url = 'mqtt://' + url; 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 // OpenRGB: append selected zones to URL
const checkedZones = isOpenrgbDevice(deviceType) ? _getCheckedZones('device-zone-list') : []; const checkedZones = isOpenrgbDevice(deviceType) ? _getCheckedZones('device-zone-list') : [];
if (isOpenrgbDevice(deviceType) && checkedZones.length > 0) { if (isOpenrgbDevice(deviceType) && checkedZones.length > 0) {
@@ -1173,6 +1215,11 @@ export async function handleAddDevice(event: any) {
const parsed = parseInt(raw || '50', 10); const parsed = parseInt(raw || '50', 10);
body.govee_min_interval_ms = Number.isFinite(parsed) ? parsed : 50; 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)) { if (isBleDevice(deviceType)) {
body.ble_family = (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || 'sp110e'; body.ble_family = (document.getElementById('device-ble-family') as HTMLSelectElement)?.value || 'sp110e';
const goveeKey = (document.getElementById('device-ble-govee-key') as HTMLInputElement)?.value?.trim(); 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 (csptId) body.default_css_processing_template_id = csptId;
if (lastTemplateId) body.capture_template_id = lastTemplateId; 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', { const response = await fetchWithAuth('/devices', {
method: 'POST', method: 'POST',
body: JSON.stringify(body) body: JSON.stringify(body)
@@ -1551,6 +1617,33 @@ function _showGoveeFields(show: boolean) {
if (el) el.style.display = show ? '' : 'none'; 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 // Tracks whether the BLE fields are currently shown — avoids reading
// style.display strings in _updateBleGoveeKeyVisibility. // style.display strings in _updateBleGoveeKeyVisibility.
let _bleFieldsVisible = false; let _bleFieldsVisible = false;
@@ -6,7 +6,7 @@ import {
_deviceBrightnessCache, updateDeviceBrightness, _deviceBrightnessCache, updateDeviceBrightness,
csptCache, csptCache,
} from '../core/state.ts'; } 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 { 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 { _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'; 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', wizMinInterval: (document.getElementById('settings-wiz-min-interval') as HTMLInputElement | null)?.value || '50',
lifxMinInterval: (document.getElementById('settings-lifx-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', 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 || '', 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'; 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 // BLE-specific fields — exposed in the settings modal so the user
// can fix a wrong protocol family pick without deleting+recreating // can fix a wrong protocol family pick without deleting+recreating
// the device. Uses the shared IconSelect grid (project rule bans // the device. Uses the shared IconSelect grid (project rule bans
@@ -884,6 +911,15 @@ export async function saveDeviceSettings() {
const parsed = parseInt(raw || '50', 10); const parsed = parseInt(raw || '50', 10);
body.govee_min_interval_ms = Number.isFinite(parsed) ? parsed : 50; 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)) { if (isBleDevice(settingsModal.deviceType)) {
body.ble_family = (document.getElementById('settings-ble-family') as HTMLSelectElement | null)?.value || 'sp110e'; 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() || ''; 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 = export type DeviceType =
| 'wled' | 'adalight' | 'ambiled' | 'mock' | 'mqtt' | 'ws' | 'wled' | 'adalight' | 'ambiled' | 'mock' | 'mqtt' | 'ws'
| 'openrgb' | 'dmx' | 'ddp' | 'opc' | 'espnow' | 'hue' | 'yeelight' | 'wiz' | 'lifx' | 'govee' | 'openrgb' | 'dmx' | 'ddp' | 'opc' | 'espnow' | 'hue' | 'yeelight' | 'wiz' | 'lifx' | 'govee'
| 'nanoleaf'
| 'ble' | 'usbhid' | 'spi' | 'ble' | 'usbhid' | 'spi'
| 'chroma' | 'gamesense' | 'group'; | 'chroma' | 'gamesense' | 'group';
@@ -81,6 +82,8 @@ export interface Device {
wiz_min_interval_ms: number; wiz_min_interval_ms: number;
lifx_min_interval_ms: number; lifx_min_interval_ms: number;
govee_min_interval_ms: number; govee_min_interval_ms: number;
nanoleaf_token: string;
nanoleaf_min_interval_ms: number;
spi_speed_hz: number; spi_speed_hz: number;
spi_led_type: string; spi_led_type: string;
chroma_device_type: string; chroma_device_type: string;
+11
View File
@@ -227,6 +227,17 @@
"device.govee.url.placeholder": "192.168.1.50", "device.govee.url.placeholder": "192.168.1.50",
"device.govee_min_interval": "Min Update Interval:", "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.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": "BLE LED Controller",
"device.type.ble.desc": "Bluetooth LE strips: SP110E, Triones, Zengge, Govee (whole-strip color)", "device.type.ble.desc": "Bluetooth LE strips: SP110E, Triones, Zengge, Govee (whole-strip color)",
"device.ble.url": "BLE Address:", "device.ble.url": "BLE Address:",
+11
View File
@@ -282,6 +282,17 @@
"device.govee.url.placeholder": "192.168.1.50", "device.govee.url.placeholder": "192.168.1.50",
"device.govee_min_interval": "Мин. интервал обновления:", "device.govee_min_interval": "Мин. интервал обновления:",
"device.govee_min_interval.hint": "Локальный лимит частоты команд (мс). UDP fire-and-forget справляется с быстрыми обновлениями; по умолчанию 50 мс ≈ 20 Гц.", "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": "BLE LED контроллер",
"device.type.ble.desc": "Bluetooth LE ленты: SP110E, Triones, Zengge, Govee (один цвет на всю ленту)", "device.type.ble.desc": "Bluetooth LE ленты: SP110E, Triones, Zengge, Govee (один цвет на всю ленту)",
"device.ble.url": "BLE адрес:", "device.ble.url": "BLE адрес:",
+11
View File
@@ -280,6 +280,17 @@
"device.govee.url.placeholder": "192.168.1.50", "device.govee.url.placeholder": "192.168.1.50",
"device.govee_min_interval": "最小更新间隔:", "device.govee_min_interval": "最小更新间隔:",
"device.govee_min_interval.hint": "客户端命令速率限制(毫秒)。UDP 即发即忘可处理快速更新;默认 50 毫秒 ≈ 20 Hz。", "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": "BLE LED 控制器",
"device.type.ble.desc": "Bluetooth LE 灯带:SP110E、Triones、Zengge、Govee(整条灯带同色)", "device.type.ble.desc": "Bluetooth LE 灯带:SP110E、Triones、Zengge、Govee(整条灯带同色)",
"device.ble.url": "BLE 地址:", "device.ble.url": "BLE 地址:",
@@ -72,6 +72,9 @@ class Device:
govee_min_interval_ms: int = 50, govee_min_interval_ms: int = 50,
# OPC fields # OPC fields
opc_channel: int = 0, opc_channel: int = 0,
# Nanoleaf fields
nanoleaf_token: str = "",
nanoleaf_min_interval_ms: int = 100,
# SPI Direct fields # SPI Direct fields
spi_speed_hz: int = 800000, spi_speed_hz: int = 800000,
spi_led_type: str = "WS2812B", spi_led_type: str = "WS2812B",
@@ -124,6 +127,8 @@ class Device:
self.lifx_min_interval_ms = lifx_min_interval_ms self.lifx_min_interval_ms = lifx_min_interval_ms
self.govee_min_interval_ms = govee_min_interval_ms self.govee_min_interval_ms = govee_min_interval_ms
self.opc_channel = opc_channel 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_speed_hz = spi_speed_hz
self.spi_led_type = spi_led_type self.spi_led_type = spi_led_type
self.chroma_device_type = chroma_device_type self.chroma_device_type = chroma_device_type
@@ -164,6 +169,7 @@ class Device:
SPIConfig, SPIConfig,
GoveeConfig, GoveeConfig,
LIFXConfig, LIFXConfig,
NanoleafConfig,
OPCConfig, OPCConfig,
USBHIDConfig, USBHIDConfig,
WiZConfig, WiZConfig,
@@ -240,6 +246,12 @@ class Device:
**base, **base,
opc_channel=self.opc_channel, 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": if dt == "spi":
return SPIConfig(**base, spi_speed_hz=self.spi_speed_hz, spi_led_type=self.spi_led_type) return SPIConfig(**base, spi_speed_hz=self.spi_speed_hz, spi_led_type=self.spi_led_type)
if dt == "chroma": if dt == "chroma":
@@ -328,6 +340,11 @@ class Device:
d["govee_min_interval_ms"] = self.govee_min_interval_ms d["govee_min_interval_ms"] = self.govee_min_interval_ms
if self.opc_channel: if self.opc_channel:
d["opc_channel"] = 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: if self.spi_speed_hz != 800000:
d["spi_speed_hz"] = self.spi_speed_hz d["spi_speed_hz"] = self.spi_speed_hz
if self.spi_led_type != "WS2812B": if self.spi_led_type != "WS2812B":
@@ -388,6 +405,8 @@ class Device:
lifx_min_interval_ms=data.get("lifx_min_interval_ms", 50), lifx_min_interval_ms=data.get("lifx_min_interval_ms", 50),
govee_min_interval_ms=data.get("govee_min_interval_ms", 50), govee_min_interval_ms=data.get("govee_min_interval_ms", 50),
opc_channel=data.get("opc_channel", 0), 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_speed_hz=data.get("spi_speed_hz", 800000),
spi_led_type=data.get("spi_led_type", "WS2812B"), spi_led_type=data.get("spi_led_type", "WS2812B"),
chroma_device_type=data.get("chroma_device_type", "chromalink"), chroma_device_type=data.get("chroma_device_type", "chromalink"),
@@ -440,6 +459,8 @@ _UPDATABLE_FIELDS: frozenset[str] = frozenset(
"lifx_min_interval_ms", "lifx_min_interval_ms",
"govee_min_interval_ms", "govee_min_interval_ms",
"opc_channel", "opc_channel",
"nanoleaf_token",
"nanoleaf_min_interval_ms",
"spi_speed_hz", "spi_speed_hz",
"spi_led_type", "spi_led_type",
"chroma_device_type", "chroma_device_type",
@@ -545,6 +566,8 @@ class DeviceStore(BaseSqliteStore[Device]):
lifx_min_interval_ms: int = 50, lifx_min_interval_ms: int = 50,
govee_min_interval_ms: int = 50, govee_min_interval_ms: int = 50,
opc_channel: int = 0, opc_channel: int = 0,
nanoleaf_token: str = "",
nanoleaf_min_interval_ms: int = 100,
spi_speed_hz: int = 800000, spi_speed_hz: int = 800000,
spi_led_type: str = "WS2812B", spi_led_type: str = "WS2812B",
chroma_device_type: str = "chromalink", chroma_device_type: str = "chromalink",
@@ -593,6 +616,8 @@ class DeviceStore(BaseSqliteStore[Device]):
lifx_min_interval_ms=lifx_min_interval_ms, lifx_min_interval_ms=lifx_min_interval_ms,
govee_min_interval_ms=govee_min_interval_ms, govee_min_interval_ms=govee_min_interval_ms,
opc_channel=opc_channel, opc_channel=opc_channel,
nanoleaf_token=nanoleaf_token,
nanoleaf_min_interval_ms=nanoleaf_min_interval_ms,
spi_speed_hz=spi_speed_hz, spi_speed_hz=spi_speed_hz,
spi_led_type=spi_led_type, spi_led_type=spi_led_type,
chroma_device_type=chroma_device_type, chroma_device_type=chroma_device_type,
@@ -51,6 +51,7 @@
<option value="wiz">WiZ</option> <option value="wiz">WiZ</option>
<option value="lifx">LIFX</option> <option value="lifx">LIFX</option>
<option value="govee">Govee</option> <option value="govee">Govee</option>
<option value="nanoleaf">Nanoleaf</option>
<option value="ble">BLE LED Controller</option> <option value="ble">BLE LED Controller</option>
<option value="usbhid">USB HID</option> <option value="usbhid">USB HID</option>
<option value="spi">SPI Direct</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> <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"> <input type="number" id="device-govee-min-interval" min="0" max="10000" step="10" value="50">
</div> </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 --> <!-- ESP-NOW fields -->
<div class="form-group" id="device-espnow-peer-mac-group" style="display: none;"> <div class="form-group" id="device-espnow-peer-mac-group" style="display: none;">
<div class="label-row"> <div class="label-row">
@@ -435,7 +445,7 @@
</div> </div>
<div class="modal-footer"> <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-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> </div>
</div> </div>
@@ -295,6 +295,31 @@
<input type="number" id="settings-govee-min-interval" min="0" max="10000" step="10" value="50"> <input type="number" id="settings-govee-min-interval" min="0" max="10000" step="10" value="50">
</div> </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="form-group" id="settings-send-latency-group" style="display: none;">
<div class="label-row"> <div class="label-row">
<label for="settings-send-latency" data-i18n="device.send_latency">Send Latency (ms):</label> <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