Die Firmware kann sACN/E1.31 - jetzt auch in Home Assistant, aufgebaut wie in
ha-ninlight: Schalter fuer den Empfang, Zahlen fuer Universe und Startslot,
Auswahl fuer das Verhalten nach Stromende, zwei Diagnose-Binaersensoren.
Die Einstellungen liegen hinter system/settings, der Koordinator liest sie
deshalb mit - eine vierte Anfrage je Zyklus. Nach dem Schreiben wird
zurueckgelesen statt angenommen, wie in ha-ninlight.
Zwei Unterschiede zu ninlight:
- Kein Layout-Feld: ninstrip nutzt fest drei Kanaele je Pixel.
- Der Startslot gilt in JEDEM belegten Universe. Die Obergrenze ist deshalb
513 minus Kanaele je Pixel, also 510 - ein festes 512 boete Werte an, bei
denen kein einziges Pixel mehr ins Universe passt.
Aeltere Firmware ohne sacn-Block: die Entitaeten entstehen trotzdem und
melden sich nicht verfuegbar, bis ein Update kommt.
In ha-dev an der Stehlampe geprueft: Universe, Startslot, on_timeout und der
Schalter kommen jeweils auf dem Geraet an; der Aktiv-Sensor steht waehrend
eines Stroms auf an, danach auf aus. 511 als Startslot weist Home Assistant
selbst ab ("outside valid range 1 - 510"). Einstellungen der Lampe danach
zurueckgestellt.
176 lines
6.2 KiB
Python
176 lines
6.2 KiB
Python
"""HTTP-Client fuer die REST-API von ninstrip. Frei von Home-Assistant-Importen.
|
|
|
|
Bewusst nah an ha-ninlight gehalten: gleiche Fehlerklassen, gleicher Aufbau.
|
|
Wer dort etwas korrigiert, findet es hier an derselben Stelle wieder.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
import aiohttp
|
|
|
|
DEFAULT_PORT = 3333
|
|
DEFAULT_TIMEOUT = 10.0
|
|
|
|
_STATE_REQUIRED = ("on", "brightness", "segment_count", "pixel_count")
|
|
_INFO_REQUIRED = ("mac", "product", "fw_version")
|
|
|
|
|
|
class NinstripError(Exception):
|
|
"""Oberklasse aller Geraetefehler."""
|
|
|
|
|
|
class NinstripConnectionError(NinstripError):
|
|
"""Das Geraet war nicht erreichbar (Zeitueberschreitung, abgewiesen, zurueckgesetzt)."""
|
|
|
|
|
|
class NinstripAuthError(NinstripError):
|
|
"""Das Geraet hat das Token abgelehnt (HTTP 401)."""
|
|
|
|
|
|
class NinstripResponseError(NinstripError):
|
|
"""Es hat geantwortet, aber nicht wie ein ninstrip (kein JSON, Pflichtfeld fehlt)."""
|
|
|
|
|
|
class NinstripRequestError(NinstripError):
|
|
"""Die Anfrage wurde abgewiesen (HTTP >= 400 ausser 401/409)."""
|
|
|
|
def __init__(self, status: int, message: str) -> None:
|
|
super().__init__(f"HTTP {status}: {message}")
|
|
self.status = status
|
|
self.message = message
|
|
|
|
|
|
class NinstripBusyError(NinstripRequestError):
|
|
"""HTTP 409 - es laeuft schon etwas, etwa eine Aktualisierung."""
|
|
|
|
|
|
class NinstripClient:
|
|
"""Spricht mit genau einem Geraet. Zustandslos bis auf Adresse und 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
|
|
|
|
# --- Lesen ---------------------------------------------------------
|
|
|
|
async def get_state(self) -> dict[str, Any]:
|
|
return _require(await self._request("GET", "device/state"), _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_segments(self) -> dict[str, Any]:
|
|
return _require(await self._request("GET", "device/segments"), (), "segments")
|
|
|
|
async def get_presets(self) -> dict[str, Any]:
|
|
return _require(await self._request("GET", "device/presets"), ("presets",), "presets")
|
|
|
|
async def get_effects(self) -> dict[str, Any]:
|
|
return _require(await self._request("GET", "device/effects"), (), "effects")
|
|
|
|
async def get_settings(self) -> dict[str, Any]:
|
|
"""Der eine Lesezugriff, der immer ein gueltiges Token verlangt."""
|
|
return _require(await self._request("GET", "system/settings"), (), "settings")
|
|
|
|
# --- Schreiben -----------------------------------------------------
|
|
|
|
async def set_state(self, **fields: Any) -> dict[str, Any]:
|
|
"""POST der Felder; das Geraet antwortet mit seinem vollstaendigen Zustand."""
|
|
return _require(
|
|
await self._request("POST", "device/state", fields), _STATE_REQUIRED, "state"
|
|
)
|
|
|
|
async def put_segments(self, segments: list[dict[str, Any]]) -> Any:
|
|
return await self._request("PUT", "device/segments", {"segments": segments})
|
|
|
|
async def patch_segment(self, index: int, **fields: Any) -> Any:
|
|
return await self._request("PUT", f"device/segments/{index}", fields)
|
|
|
|
async def save_preset(self, index: int, name: str | None = None) -> Any:
|
|
"""Sichert die laufende Ansicht auf einem Szenenplatz."""
|
|
return await self._request(
|
|
"PUT", f"device/presets/{index}", {"name": name} if name else {}
|
|
)
|
|
|
|
async def apply_preset(self, index: int) -> Any:
|
|
return await self._request("PUT", f"device/presets/{index}/apply")
|
|
|
|
async def set_sacn(self, **fields: Any) -> None:
|
|
"""sACN-Einstellungen aendern. Weggelassene Felder behalten ihren Wert."""
|
|
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}}
|
|
)
|
|
|
|
# --- Unterbau ------------------------------------------------------
|
|
|
|
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 NinstripConnectionError(f"{method} {path}: {err!r}") from err
|
|
|
|
if status == 401:
|
|
raise NinstripAuthError(f"{method} {path}: Token abgelehnt")
|
|
if status == 409:
|
|
raise NinstripBusyError(status, _error_text(text))
|
|
if status >= 400:
|
|
raise NinstripRequestError(status, _error_text(text))
|
|
if not text:
|
|
return None
|
|
try:
|
|
return json.loads(text)
|
|
except ValueError as err:
|
|
raise NinstripResponseError(f"{method} {path}: kein 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 NinstripResponseError(f"{what}: JSON-Objekt erwartet, {type(data).__name__} bekommen")
|
|
missing = [name for name in fields if name not in data]
|
|
if missing:
|
|
raise NinstripResponseError(f"{what}: es fehlt {', '.join(missing)}")
|
|
return data
|