diff --git a/custom_components/ninlight/api.py b/custom_components/ninlight/api.py new file mode 100644 index 0000000..90b8268 --- /dev/null +++ b/custom_components/ninlight/api.py @@ -0,0 +1,135 @@ +"""HTTP client for the ninlight lamp REST API. Deliberately free of Home Assistant imports.""" + +from __future__ import annotations + +import json +from typing import Any + +import aiohttp + +DEFAULT_PORT = 3333 +DEFAULT_TIMEOUT = 10.0 + +_STATE_REQUIRED = ("on", "brightness", "mode", "capabilities") +_INFO_REQUIRED = ("mac", "device_type", "fw_version") + + +class NinlightError(Exception): + """Base class for all lamp errors.""" + + +class NinlightConnectionError(NinlightError): + """The lamp could not be reached (timeout, refused, reset).""" + + +class NinlightAuthError(NinlightError): + """The lamp rejected the token (HTTP 401).""" + + +class NinlightResponseError(NinlightError): + """The lamp answered, but not like a ninlight lamp (no JSON, required field missing).""" + + +class NinlightRequestError(NinlightError): + """The lamp rejected the request (HTTP >= 400 other than 401/409).""" + + def __init__(self, status: int, message: str) -> None: + super().__init__(f"HTTP {status}: {message}") + self.status = status + self.message = message + + +class NinlightBusyError(NinlightRequestError): + """HTTP 409: an update is already running.""" + + +class NinlightClient: + """Talks to one lamp. Stateless apart from its address and token.""" + + def __init__( + self, + session: aiohttp.ClientSession, + host: str, + token: str, + *, + port: int = DEFAULT_PORT, + timeout: float = DEFAULT_TIMEOUT, + ) -> None: + self._session = session + self._host = host + self._token = token + self._port = port + self._timeout = timeout + + @property + def host(self) -> str: + return self._host + + async def get_state(self) -> dict[str, Any]: + return _require(await self._request("GET", "device/state"), _STATE_REQUIRED, "state") + + async def set_state(self, **fields: Any) -> dict[str, Any]: + """POST the fields; the lamp answers with its complete new state.""" + return _require( + await self._request("POST", "device/state", fields), _STATE_REQUIRED, "state" + ) + + async def get_info(self) -> dict[str, Any]: + return _require(await self._request("GET", "system/info"), _INFO_REQUIRED, "info") + + async def get_settings(self) -> dict[str, Any]: + """The one read that always requires a valid token (see spec ยง2).""" + return _require(await self._request("GET", "system/settings"), (), "settings") + + async def reboot(self) -> None: + await self._request("PUT", "system/command", {"command": {"type": "reboot"}}) + + async def fw_update(self, url: str) -> None: + await self._request("PUT", "system/command", {"command": {"type": "fw_update", "url": url}}) + + async def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> Any: + url = f"http://{self._host}:{self._port}/api/v1/{path}" + try: + async with self._session.request( + method, + url, + json=payload, + headers={"Authorization": self._token}, + timeout=aiohttp.ClientTimeout(total=self._timeout), + ) as resp: + status = resp.status + text = await resp.text() + except (TimeoutError, aiohttp.ClientError) as err: + raise NinlightConnectionError(f"{method} {path}: {err!r}") from err + + if status == 401: + raise NinlightAuthError(f"{method} {path}: token rejected") + if status == 409: + raise NinlightBusyError(status, _error_text(text)) + if status >= 400: + raise NinlightRequestError(status, _error_text(text)) + if not text: + return None + try: + return json.loads(text) + except ValueError as err: + raise NinlightResponseError(f"{method} {path}: no JSON: {text[:200]!r}") from err + + +def _error_text(text: str) -> str: + try: + body = json.loads(text) + except ValueError: + return text[:200] + if isinstance(body, dict) and isinstance(body.get("error"), str): + return body["error"] + return text[:200] + + +def _require(data: Any, fields: tuple[str, ...], what: str) -> dict[str, Any]: + if not isinstance(data, dict): + raise NinlightResponseError(f"{what}: expected a JSON object, got {type(data).__name__}") + missing = [name for name in fields if name not in data] + if missing: + raise NinlightResponseError(f"{what}: missing {', '.join(missing)}") + return data diff --git a/tests_unit/conftest.py b/tests_unit/conftest.py new file mode 100644 index 0000000..aaf7c28 --- /dev/null +++ b/tests_unit/conftest.py @@ -0,0 +1,86 @@ +"""A real local aiohttp server plays the lamp; no Home Assistant instance involved.""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncGenerator, Awaitable, Callable +from dataclasses import dataclass, field +import json +from typing import Any + +import aiohttp +from aiohttp import web +from aiohttp.test_utils import TestServer +import pytest + + +@dataclass +class Call: + """One request the fake lamp received.""" + + method: str + path: str + headers: dict[str, str] + body: Any + + +@dataclass +class FakeLamp: + """Canned responses per (METHOD, path); records every call.""" + + host: str + port: int + routes: dict[tuple[str, str], tuple[int, str, float]] = field(default_factory=dict) + calls: list[Call] = field(default_factory=list) + + def reply( + self, + method: str, + path: str, + status: int = 200, + payload: Any = None, + *, + raw: str | None = None, + delay: float = 0.0, + ) -> None: + body = raw if raw is not None else json.dumps(payload) + self.routes[(method.upper(), path)] = (status, body, delay) + + +@pytest.fixture(autouse=True) +def _enable_sockets(socket_enabled: None) -> None: + """pytest-homeassistant-custom-component blocks sockets; these tests need localhost.""" + + +@pytest.fixture +async def lamp(aiohttp_server: Callable[..., Awaitable[TestServer]]) -> FakeLamp: + holder: list[FakeLamp] = [] + + async def handler(request: web.Request) -> web.Response: + fake = holder[0] + text = await request.text() + try: + body: Any = json.loads(text) if text else None + except ValueError: + body = text + fake.calls.append(Call(request.method, request.path, dict(request.headers), body)) + route = fake.routes.get((request.method, request.path)) + if route is None: + return web.Response(status=404, text="no route") + status, text_out, delay = route + if delay: + await asyncio.sleep(delay) + return web.Response(status=status, text=text_out) + + app = web.Application() + app.router.add_route("*", "/{tail:.*}", handler) + server = await aiohttp_server(app) + fake = FakeLamp(server.host, server.port) + holder.append(fake) + return fake + + +@pytest.fixture +async def session() -> AsyncGenerator[aiohttp.ClientSession]: + async with aiohttp.ClientSession() as client_session: + yield client_session diff --git a/tests_unit/samples.py b/tests_unit/samples.py new file mode 100644 index 0000000..3e6ab42 --- /dev/null +++ b/tests_unit/samples.py @@ -0,0 +1,92 @@ +"""Realistic lamp payloads. Values are deliberately NOT boundary values (see plan constraints).""" + +from __future__ import annotations + +from typing import Any + +TOKEN = "3f9c2a71e6b04d58" + +HOST_RGB = "192.168.2.22" +HOST_CW = "192.168.2.122" +MAC_RGB = "28:6d:cd:79:06:b8" +MAC_CW = "28:6d:cd:4e:0a:16" + +STATE_RGB: dict[str, Any] = { + "on": True, + "brightness": 37, + "mode": "color", + "color": {"r": 12, "g": 200, "b": 90}, + "color_temp": 3100, + "power_on_behaviour": "last", + "fade_in": 450, + "fade_out": 1250, + "transition_default": 700, + "wake_on_change": True, + "capabilities": {"color": True, "color_temp": [2700, 6500]}, +} + +STATE_CW: dict[str, Any] = { + "on": False, + "brightness": 63, + "mode": "white", + "color": {"r": 0, "g": 0, "b": 0}, + "color_temp": 2350, + "power_on_behaviour": "on", + "fade_in": 300, + "fade_out": 900, + "transition_default": 250, + "wake_on_change": False, + "capabilities": {"color": False, "color_temp": [2000, 5000]}, +} + +INFO_RGB: dict[str, Any] = { + "device_type": "gu10_rgb", + "product_name": "Klyqa GU10 RGB CW/WW", + "fw_version": "0.3.8-36-gca9bb", + "build_date": "2026-09-14", + "mac": MAC_RGB, + "name": "", + "wifi": { + "mode": "sta", + "ssid": "ninIOT", + "rssi": -61, + "ip": HOST_RGB, + "sta_ip": HOST_RGB, + "error": None, + }, + "uptime": 129832, + "free_heap": 119872, + "last_ota_error": None, +} + +INFO_CW: dict[str, Any] = { + "device_type": "g95_cw", + "product_name": "Klyqa G95 CW/WW", + "fw_version": "0.3.8-36-gca9bb", + "build_date": "2026-09-14", + "mac": MAC_CW, + "name": "Wohnzimmer", + "wifi": { + "mode": "sta", + "ssid": "ninIOT", + "rssi": -70, + "ip": HOST_CW, + "sta_ip": HOST_CW, + "error": None, + }, + "uptime": 5421, + "free_heap": 121344, + "last_ota_error": None, +} + +SETTINGS_COMPILED: dict[str, Any] = { + "name": "", + "wifi": {"ssid": "", "source": "compiled"}, + "auth_all": False, +} + +SETTINGS_STORED: dict[str, Any] = { + "name": "", + "wifi": {"ssid": "ninIOT", "source": "stored"}, + "auth_all": False, +} diff --git a/tests_unit/test_api.py b/tests_unit/test_api.py new file mode 100644 index 0000000..24b4320 --- /dev/null +++ b/tests_unit/test_api.py @@ -0,0 +1,162 @@ +"""HTTP client against a real local server.""" + +from __future__ import annotations + +import aiohttp +import pytest + +from custom_components.ninlight.api import ( + NinlightAuthError, + NinlightBusyError, + NinlightClient, + NinlightConnectionError, + NinlightRequestError, + NinlightResponseError, +) +from tests_unit.conftest import FakeLamp +from tests_unit.samples import INFO_RGB, SETTINGS_COMPILED, STATE_RGB, TOKEN + + +def client_for(lamp: FakeLamp, session: aiohttp.ClientSession, **kwargs: float) -> NinlightClient: + return NinlightClient(session, lamp.host, TOKEN, port=lamp.port, **kwargs) + + +async def test_get_state_sends_raw_token_header(lamp: FakeLamp, session) -> None: + lamp.reply("GET", "/api/v1/device/state", payload=STATE_RGB) + state = await client_for(lamp, session).get_state() + assert state == STATE_RGB + call = lamp.calls[-1] + assert call.headers["Authorization"] == TOKEN # no "Bearer" + + +async def test_set_state_posts_fields_and_returns_lamp_answer(lamp: FakeLamp, session) -> None: + answer = {**STATE_RGB, "brightness": 55} # lamp answer differs from the request + lamp.reply("POST", "/api/v1/device/state", payload=answer) + result = await client_for(lamp, session).set_state(brightness=78, transition=2500) + assert result == answer + assert lamp.calls[-1].body == {"brightness": 78, "transition": 2500} + + +async def test_extra_fields_are_kept(lamp: FakeLamp, session) -> None: + lamp.reply("GET", "/api/v1/device/state", payload={**STATE_RGB, "future_field": 7}) + assert (await client_for(lamp, session).get_state())["future_field"] == 7 + + +async def test_info_and_settings(lamp: FakeLamp, session) -> None: + lamp.reply("GET", "/api/v1/system/info", payload=INFO_RGB) + lamp.reply("GET", "/api/v1/system/settings", payload=SETTINGS_COMPILED) + client = client_for(lamp, session) + assert await client.get_info() == INFO_RGB + assert await client.get_settings() == SETTINGS_COMPILED + + +async def test_commands_use_nested_payload(lamp: FakeLamp, session) -> None: + lamp.reply("PUT", "/api/v1/system/command", payload={"ok": True}) + client = client_for(lamp, session) + await client.reboot() + assert lamp.calls[-1].body == {"command": {"type": "reboot"}} + lamp.reply("PUT", "/api/v1/system/command", status=202, payload={"status": "updating"}) + await client.fw_update("http://192.168.1.50:8123/api/ninlight/firmware/k3y") + assert lamp.calls[-1].body == { + "command": { + "type": "fw_update", + "url": "http://192.168.1.50:8123/api/ninlight/firmware/k3y", + } + } + + +async def test_401_is_auth_error(lamp: FakeLamp, session) -> None: + lamp.reply("GET", "/api/v1/system/settings", status=401, payload={"error": "unauthorized"}) + with pytest.raises(NinlightAuthError): + await client_for(lamp, session).get_settings() + + +async def test_409_is_busy_error_with_message(lamp: FakeLamp, session) -> None: + lamp.reply( + "PUT", + "/api/v1/system/command", + status=409, + payload={"error": "an update is already running"}, + ) + with pytest.raises(NinlightBusyError) as err: + await client_for(lamp, session).fw_update("http://192.168.1.50:8123/x") + assert err.value.status == 409 + assert err.value.message == "an update is already running" + + +async def test_400_is_request_error_not_busy(lamp: FakeLamp, session) -> None: + lamp.reply("POST", "/api/v1/device/state", status=400, payload={"error": "invalid brightness"}) + with pytest.raises(NinlightRequestError) as err: + await client_for(lamp, session).set_state(brightness=0) + assert not isinstance(err.value, NinlightBusyError) + assert (err.value.status, err.value.message) == (400, "invalid brightness") + + +async def test_error_body_without_json_uses_raw_text(lamp: FakeLamp, session) -> None: + lamp.reply("POST", "/api/v1/device/state", status=500, raw="boom") + with pytest.raises(NinlightRequestError) as err: + await client_for(lamp, session).set_state(on=True) + assert err.value.message == "boom" + + +async def test_non_json_success_is_response_error(lamp: FakeLamp, session) -> None: + lamp.reply("GET", "/api/v1/device/state", raw="") + with pytest.raises(NinlightResponseError): + await client_for(lamp, session).get_state() + + +@pytest.mark.parametrize("missing", ["on", "brightness", "mode", "capabilities"]) +async def test_state_without_required_field_is_response_error( + lamp: FakeLamp, session, missing: str +) -> None: + lamp.reply( + "GET", "/api/v1/device/state", payload={k: v for k, v in STATE_RGB.items() if k != missing} + ) + with pytest.raises(NinlightResponseError): + await client_for(lamp, session).get_state() + + +@pytest.mark.parametrize("missing", ["mac", "device_type", "fw_version"]) +async def test_info_without_required_field_is_response_error( + lamp: FakeLamp, session, missing: str +) -> None: + lamp.reply( + "GET", "/api/v1/system/info", payload={k: v for k, v in INFO_RGB.items() if k != missing} + ) + with pytest.raises(NinlightResponseError): + await client_for(lamp, session).get_info() + + +async def test_timeout_is_connection_error(lamp: FakeLamp, session) -> None: + lamp.reply("GET", "/api/v1/device/state", payload=STATE_RGB, delay=0.5) + with pytest.raises(NinlightConnectionError): + await client_for(lamp, session, timeout=0.05).get_state() + + +async def test_refused_connection_is_connection_error(session) -> None: + client = NinlightClient(session, "127.0.0.1", TOKEN, port=1) + with pytest.raises(NinlightConnectionError): + await client.get_state() + + +async def test_host_property_returns_configured_host(session) -> None: + client = NinlightClient(session, "192.168.2.22", TOKEN, port=3333) + assert client.host == "192.168.2.22" + + +async def test_command_with_empty_response_body_succeeds(lamp: FakeLamp, session) -> None: + lamp.reply("PUT", "/api/v1/system/command", status=204, raw="") + await client_for(lamp, session).reboot() + + +async def test_error_body_json_without_error_field_uses_raw_text(lamp: FakeLamp, session) -> None: + lamp.reply("POST", "/api/v1/device/state", status=500, payload={"detail": "bad brightness"}) + with pytest.raises(NinlightRequestError) as err: + await client_for(lamp, session).set_state(on=True) + assert "detail" in err.value.message + + +async def test_success_json_array_is_response_error(lamp: FakeLamp, session) -> None: + lamp.reply("GET", "/api/v1/device/state", raw="[1, 2, 3]") + with pytest.raises(NinlightResponseError): + await client_for(lamp, session).get_state()