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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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() || '';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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 адрес:",
|
||||
|
||||
@@ -280,6 +280,17 @@
|
||||
"device.govee.url.placeholder": "192.168.1.50",
|
||||
"device.govee_min_interval": "最小更新间隔:",
|
||||
"device.govee_min_interval.hint": "客户端命令速率限制(毫秒)。UDP 即发即忘可处理快速更新;默认 50 毫秒 ≈ 20 Hz。",
|
||||
"device.type.nanoleaf": "Nanoleaf",
|
||||
"device.type.nanoleaf.desc": "Nanoleaf Light Panels / Canvas / Shapes / Lines / Elements(HTTP REST,需要配对)",
|
||||
"device.nanoleaf.url": "IP 地址:",
|
||||
"device.nanoleaf.url.hint": "Nanoleaf 控制器的局域网 IP。HTTP 端口 16021 由协议固定。",
|
||||
"device.nanoleaf.url.placeholder": "192.168.1.50",
|
||||
"device.nanoleaf_min_interval": "最小更新间隔:",
|
||||
"device.nanoleaf_min_interval.hint": "客户端命令速率限制(毫秒)。默认 100 毫秒 ≈ 10 Hz;HTTP 请求开销将实际上限限制在约 20 Hz。",
|
||||
"device.nanoleaf.pair.instructions": "请按住 Nanoleaf 控制器上的电源按钮 5 秒,直到 LED 闪烁,然后点击「开始」。控制器将打开 30 秒的配对窗口。",
|
||||
"device.nanoleaf.pair_button": "配对设备",
|
||||
"device.nanoleaf.token.label": "认证令牌:",
|
||||
"device.nanoleaf.paired": "已配对",
|
||||
"device.type.ble": "BLE LED 控制器",
|
||||
"device.type.ble.desc": "Bluetooth LE 灯带:SP110E、Triones、Zengge、Govee(整条灯带同色)",
|
||||
"device.ble.url": "BLE 地址:",
|
||||
|
||||
@@ -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">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="document.getElementById('add-device-form').requestSubmit()" title="Add Device" data-i18n-aria-label="aria.save">✓</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">✓</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>
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user