Die Firmware kann seit dieser Woche sACN/E1.31. Einstellen liess sich das nur per REST - jetzt steht es in Home Assistant: Schalter fuer den Empfang, Zahlen fuer Universe und Startslot, Auswahlen fuer das Verhalten nach dem Timeout und die Slotbelegung, dazu zwei Diagnose-Binaersensoren. Die sACN-Einstellungen liegen hinter system/settings, nicht in device/state - der Koordinator liest sie deshalb jetzt mit, eine dritte Anfrage je Zyklus. Der Schreibweg liest die Einstellungen danach frisch zurueck statt den gesetzten Wert anzunehmen: die Lampe validiert selbst (die Startslot-Grenze haengt an der Slotbelegung), und eine Entitaet, die das Gewuenschte statt des Gespeicherten zeigt, luegt in genau dem Moment, in dem es darauf ankommt. Aeltere Firmware antwortet ohne den sacn-Block. Die Entitaeten entstehen trotzdem und melden sich als nicht verfuegbar - so werden sie nach einem Update der Lampe beim naechsten Poll von selbst lebendig, ohne Neuladen. Die Obergrenze des Startslots kommt aus dem "slots"-Feld der Lampe, nicht aus einer festen 512: sie haengt an Board UND Slotbelegung, und eine feste Grenze wuerde Werte anbieten, die die Lampe dann ablehnt. Der Multicast-Sensor ist ab Werk deaktiviert wie die uebrigen Diagnosewerte. Er meldet im Normalbetrieb schlicht "beigetreten" - aber eine falsche Gruppenadresse hat zwei Abende gekostet, und genau dort waere sie aufgefallen. test_update.py misst seine Zusage jetzt um den Rescan herum statt nach dem Install: dort pollt der Koordinator ohnehin wieder und liest seitdem auch die Einstellungen mit.
140 lines
4.6 KiB
Python
140 lines
4.6 KiB
Python
"""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 set_sacn(self, **fields: Any) -> None:
|
|
"""Change sACN settings. Fields left out keep their value on the lamp."""
|
|
await self._request("PUT", "system/settings", {"type": "sacn", **fields})
|
|
|
|
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
|