426484adf8
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.
461 lines
14 KiB
Python
461 lines
14 KiB
Python
"""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
|