"""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