feat(api): HTTP-Client fuer die ninlight-Lampen
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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="<html>")
|
||||
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()
|
||||
Reference in New Issue
Block a user