From adec66a6a27db59435271a7ee03c737238e54773 Mon Sep 17 00:00:00 2001 From: ninharp Date: Sun, 20 Sep 2026 03:42:42 +0200 Subject: [PATCH] feat: erste Fassung der ninstrip-Integration fuer Home Assistant Auf dem Geruest von ha-ninlight aufgebaut und bewusst parallel gehalten: gleiche Fehlerklassen in api.py, gleicher Coordinator mit Sperre und Fehlertoleranz, gleicher Einrichtungsablauf samt Zeroconf und Reauth. Eigenes Repo statt Erweiterung von ha-ninlight: die Geraete melden sich unter verschiedenen mDNS-Diensten, das Datenmodell unterscheidet sich deutlich (Segmente und Szenen gegen ein einzelnes Licht), und ha-ninlight laeuft produktiv mit vier Lampen - Verzweigungen dort haetten beide Produkte gefaehrdet. Entitaeten: light (Hauptschalter und Helligkeit), select (Szene), button (Neustart), vier Diagnosesensoren. In ha-dev eingebunden und am echten Geraet geprueft: Aus- und Einschalten, Helligkeit 40 Prozent, Szenenwechsel auf Regenbogen - das Geraet meldet jeweils genau das zurueck. --- .gitignore | 4 + README.md | 58 ++++++ custom_components/ninstrip/__init__.py | 44 +++++ custom_components/ninstrip/api.py | 171 +++++++++++++++++ custom_components/ninstrip/button.py | 31 +++ custom_components/ninstrip/config_flow.py | 177 ++++++++++++++++++ custom_components/ninstrip/const.py | 25 +++ custom_components/ninstrip/coordinator.py | 122 ++++++++++++ custom_components/ninstrip/entity.py | 54 ++++++ custom_components/ninstrip/light.py | 68 +++++++ custom_components/ninstrip/manifest.json | 13 ++ custom_components/ninstrip/select.py | 67 +++++++ custom_components/ninstrip/sensor.py | 108 +++++++++++ custom_components/ninstrip/strings.json | 55 ++++++ .../ninstrip/translations/de.json | 55 ++++++ .../ninstrip/translations/en.json | 88 +++++++++ 16 files changed, 1140 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 custom_components/ninstrip/__init__.py create mode 100644 custom_components/ninstrip/api.py create mode 100644 custom_components/ninstrip/button.py create mode 100644 custom_components/ninstrip/config_flow.py create mode 100644 custom_components/ninstrip/const.py create mode 100644 custom_components/ninstrip/coordinator.py create mode 100644 custom_components/ninstrip/entity.py create mode 100644 custom_components/ninstrip/light.py create mode 100644 custom_components/ninstrip/manifest.json create mode 100644 custom_components/ninstrip/select.py create mode 100644 custom_components/ninstrip/sensor.py create mode 100644 custom_components/ninstrip/strings.json create mode 100644 custom_components/ninstrip/translations/de.json create mode 100644 custom_components/ninstrip/translations/en.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c3a077 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +*.pyc +.venv/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ab6453 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# ha-ninstrip + +Home-Assistant-Integration für **ninstrip** — ESP32-C3-Firmware für +IC-getriebene LED-Strips. Schwesterprojekt zu +[ha-ninlight](https://gitea.zukunpht.de/ninharp/ha-ninlight). + +## Warum eine eigene Integration + +ninlight und ninstrip teilen sich das API-Gerüst (Port 3333, `/api/v1`, +`Authorization: `), beschreiben aber verschiedene Geräte: eine Lampe +mit einem Licht gegen einen Strip mit Segmenten und Szenen. Beide Modelle in +eine Integration zu zwingen hieße, in Coordinator und Entitäten dauernd zu +verzweigen — und bei jedem Fehler beide Produkte zu gefährden. Dazu kommt, +dass sich die Geräte unter verschiedenen mDNS-Diensten melden +(`_ninlight._tcp` gegen `_ninstrip._tcp`) und ohnehin getrennt gefunden +werden. + +Das Gerüst ist aber bewusst **eins zu eins von ninlight übernommen**: gleiche +Fehlerklassen, gleicher Coordinator-Aufbau, gleicher Einrichtungsablauf. Wer +dort etwas korrigiert, findet es hier an derselben Stelle. + +## Einrichtung + +Das Gerät meldet sich per mDNS und wird von Home Assistant vorgeschlagen. +Nötig ist nur das **API-Token** — es steht einmalig auf der seriellen Konsole, +wenn das Gerät zum ersten Mal startet. Ein Werksreset erzeugt ein neues; dann +fragt Home Assistant von selbst nach. + +Ohne mDNS geht es auch von Hand über Adresse und Token. + +## Entitäten + +| Entität | | +|---|---| +| `light` | Hauptschalter und Helligkeit des ganzen Strips | +| `select` | aufgeschaltete Szene (acht Plätze in der Firmware) | +| `button` | Neustart | +| `sensor` | WLAN-Signal, Laufzeit, freier Speicher, Leistungsbegrenzung | + +Die Diagnose-Sensoren sind standardmäßig abgeschaltet; die +Leistungsbegrenzung ist an, weil sie verrät, ob die Strombegrenzung gerade +herunterregelt (100 % heißt: tut sie nicht). + +**Eine Lichtentität je Gerät, nicht je Segment.** `device/state` ist der +Hauptschalter und trägt keine Farbe — bei mehreren Segmenten gäbe es keine +eine Farbe, die stimmen würde. Die Segmente stellt man über Szenen. + +## Noch nicht drin + +- Einzelne Segmente als eigene Entitäten +- Firmware-Aktualisierung über eine `update`-Entität (die Firmware kann OTA + mit Rückfallebene, die Integration nutzt es noch nicht) +- sACN/E1.31 — in der Firmware geplant, siehe ninstrip-Repo + +## Entwicklung + +Die Integration wird in der gemeinsamen Dev-Instanz `ha-dev` per Mount +eingebunden, der Quellcode bleibt in diesem Repo. diff --git a/custom_components/ninstrip/__init__.py b/custom_components/ninstrip/__init__.py new file mode 100644 index 0000000..bb30cd3 --- /dev/null +++ b/custom_components/ninstrip/__init__.py @@ -0,0 +1,44 @@ +"""Die ninstrip-Integration.""" + +from __future__ import annotations + +from homeassistant.const import CONF_HOST, CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .api import NinstripClient +from .const import DEFAULT_PORT +from .coordinator import NinstripConfigEntry, NinstripCoordinator + +PLATFORMS: list[Platform] = [ + Platform.LIGHT, + Platform.SELECT, + Platform.SENSOR, + Platform.BUTTON, +] + + +async def async_setup_entry(hass: HomeAssistant, entry: NinstripConfigEntry) -> bool: + """Einen Strip einrichten.""" + client = NinstripClient( + async_get_clientsession(hass), + entry.data[CONF_HOST], + entry.data[CONF_TOKEN], + port=entry.data.get("port", DEFAULT_PORT), + ) + coordinator = NinstripCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_options_updated)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: NinstripConfigEntry) -> bool: + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def _async_options_updated(hass: HomeAssistant, entry: NinstripConfigEntry) -> None: + """Eine geaenderte Abfragehaeufigkeit wirkt ohne Neustart.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/custom_components/ninstrip/api.py b/custom_components/ninstrip/api.py new file mode 100644 index 0000000..7209f4b --- /dev/null +++ b/custom_components/ninstrip/api.py @@ -0,0 +1,171 @@ +"""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 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 diff --git a/custom_components/ninstrip/button.py b/custom_components/ninstrip/button.py new file mode 100644 index 0000000..b3b289c --- /dev/null +++ b/custom_components/ninstrip/button.py @@ -0,0 +1,31 @@ +"""Neustart-Knopf fuer ein ninstrip-Geraet.""" + +from __future__ import annotations + +from homeassistant.components.button import ButtonDeviceClass, ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import NinstripConfigEntry, NinstripCoordinator +from .entity import NinstripEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NinstripConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + async_add_entities([NinstripRestartButton(entry.runtime_data)]) + + +class NinstripRestartButton(NinstripEntity, ButtonEntity): + _attr_device_class = ButtonDeviceClass.RESTART + _attr_entity_category = EntityCategory.CONFIG + _attr_translation_key = "restart" + + def __init__(self, coordinator: NinstripCoordinator) -> None: + super().__init__(coordinator, "restart") + + async def async_press(self) -> None: + await self.coordinator.async_call(lambda client: client.reboot()) diff --git a/custom_components/ninstrip/config_flow.py b/custom_components/ninstrip/config_flow.py new file mode 100644 index 0000000..8a41d2b --- /dev/null +++ b/custom_components/ninstrip/config_flow.py @@ -0,0 +1,177 @@ +"""Einrichtungsablauf fuer ninstrip-Geraete.""" + +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + ConfigFlowResult, + OptionsFlowWithReload, +) +from homeassistant.const import CONF_HOST, CONF_TOKEN +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo +import voluptuous as vol + +from .api import NinstripAuthError, NinstripClient, NinstripError, NinstripResponseError +from .const import ( + CONF_SCAN_INTERVAL, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + MAX_SCAN_INTERVAL, + MIN_SCAN_INTERVAL, +) +from .entity import device_name + +_LOGGER = logging.getLogger(__name__) + + +class NinstripConfigFlow(ConfigFlow, domain=DOMAIN): + """Ein Eintrag je Geraet, erkannt an seiner MAC.""" + + VERSION = 1 + + def __init__(self) -> None: + self._host: str = "" + self._discovered_name: str = "" + + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> NinstripOptionsFlow: + return NinstripOptionsFlow() + + async def _async_probe( + self, host: str, token: str + ) -> tuple[dict[str, Any] | None, dict[str, str]]: + """Erst das Token pruefen - system/settings verlangt es immer - dann die Kennung lesen. + + Wichtig: system/info allein taugt nicht als Pruefung, es antwortet auch + ohne Token. Genau wie bei ninlight. + """ + client = NinstripClient(async_get_clientsession(self.hass), host, token) + try: + await client.get_settings() + info = await client.get_info() + except NinstripAuthError: + return None, {"base": "invalid_auth"} + except NinstripResponseError: + return None, {"base": "not_ninstrip"} + except NinstripError: + return None, {"base": "cannot_connect"} + except Exception: + _LOGGER.exception("Unerwarteter Fehler beim Pruefen von %s", host) + return None, {"base": "unknown"} + return info, {} + + async def _async_finish(self, host: str, token: str, info: dict[str, Any]) -> ConfigFlowResult: + mac = format_mac(info["mac"]) + await self.async_set_unique_id(mac) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + return self.async_create_entry( + title=device_name(info), data={CONF_HOST: host, CONF_TOKEN: token} + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + errors: dict[str, str] = {} + if user_input is not None: + info, errors = await self._async_probe( + user_input[CONF_HOST], user_input[CONF_TOKEN] + ) + if info is not None: + return await self._async_finish( + user_input[CONF_HOST], user_input[CONF_TOKEN], info + ) + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST, default=self._host): str, + vol.Required(CONF_TOKEN): str, + } + ), + errors=errors, + ) + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Gefunden ueber _ninstrip._tcp.""" + self._host = discovery_info.host + props = discovery_info.properties or {} + mac = props.get("mac") + if mac: + await self.async_set_unique_id(format_mac(str(mac))) + self._abort_if_unique_id_configured(updates={CONF_HOST: self._host}) + self._discovered_name = str(props.get("name") or discovery_info.name.split(".")[0]) + self.context["title_placeholders"] = {"name": self._discovered_name} + return await self.async_step_discovery_confirm() + + async def async_step_discovery_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Das Token steht einmalig auf der seriellen Konsole - es muss von Hand herein.""" + errors: dict[str, str] = {} + if user_input is not None: + info, errors = await self._async_probe(self._host, user_input[CONF_TOKEN]) + if info is not None: + return await self._async_finish(self._host, user_input[CONF_TOKEN], info) + return self.async_show_form( + step_id="discovery_confirm", + data_schema=vol.Schema({vol.Required(CONF_TOKEN): str}), + description_placeholders={"name": self._discovered_name, "host": self._host}, + errors=errors, + ) + + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + self._host = entry_data[CONF_HOST] + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Nach einem Werksreset hat das Geraet ein neues Token.""" + errors: dict[str, str] = {} + entry = self._get_reauth_entry() + if user_input is not None: + info, errors = await self._async_probe(self._host, user_input[CONF_TOKEN]) + if info is not None: + return self.async_update_reload_and_abort( + entry, data_updates={CONF_TOKEN: user_input[CONF_TOKEN]} + ) + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Required(CONF_TOKEN): str}), + description_placeholders={"host": self._host}, + errors=errors, + ) + + +class NinstripOptionsFlow(OptionsFlowWithReload): + """Bisher nur die Abfragehaeufigkeit.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + if user_input is not None: + return self.async_create_entry(data=user_input) + current = self.config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required(CONF_SCAN_INTERVAL, default=current): vol.All( + vol.Coerce(int), vol.Range(min=MIN_SCAN_INTERVAL, max=MAX_SCAN_INTERVAL) + ) + } + ), + ) diff --git a/custom_components/ninstrip/const.py b/custom_components/ninstrip/const.py new file mode 100644 index 0000000..bfd6b21 --- /dev/null +++ b/custom_components/ninstrip/const.py @@ -0,0 +1,25 @@ +"""Konstanten der ninstrip-Integration. Bewusst frei von Home-Assistant-Importen.""" + +from __future__ import annotations + +from typing import Final + +DOMAIN: Final = "ninstrip" + +CONF_SCAN_INTERVAL: Final = "scan_interval" + +DEFAULT_PORT: Final = 3333 +DEFAULT_SCAN_INTERVAL: Final = 30 +MIN_SCAN_INTERVAL: Final = 10 +MAX_SCAN_INTERVAL: Final = 300 + +# So viele aufeinanderfolgende Fehlversuche werden geschluckt, bevor das +# Geraet als nicht verfuegbar gilt. Ein kurzer Aussetzer im WLAN soll nicht +# sofort alle Entitaeten ausgrauen. +MAX_POLL_FAILURES: Final = 3 + +# Die Firmware erlaubt hoechstens 16 Segmente (nl_segments.h) und 512 Pixel. +MAX_SEGMENTS: Final = 16 +MAX_PIXELS: Final = 512 +# Acht Szenenplaetze (nl_presets.h). +MAX_PRESETS: Final = 8 diff --git a/custom_components/ninstrip/coordinator.py b/custom_components/ninstrip/coordinator.py new file mode 100644 index 0000000..10a4bbb --- /dev/null +++ b/custom_components/ninstrip/coordinator.py @@ -0,0 +1,122 @@ +"""Datenkoordinator fuer genau ein ninstrip-Geraet.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .api import ( + NinstripAuthError, + NinstripBusyError, + NinstripClient, + NinstripError, + NinstripRequestError, +) +from .const import CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL, DOMAIN, MAX_POLL_FAILURES + +_LOGGER = logging.getLogger(__name__) + +type NinstripConfigEntry = ConfigEntry[NinstripCoordinator] + + +@dataclass(frozen=True, slots=True) +class NinstripData: + """Letzter bekannter Stand eines Geraets.""" + + state: dict[str, Any] + info: dict[str, Any] + presets: dict[str, Any] + + +class NinstripCoordinator(DataUpdateCoordinator[NinstripData]): + """Fragt ein Geraet ab; jede Anfrage dorthin laeuft durch `lock`.""" + + config_entry: NinstripConfigEntry + + def __init__( + self, hass: HomeAssistant, entry: NinstripConfigEntry, client: NinstripClient + ) -> None: + super().__init__( + hass, + _LOGGER, + config_entry=entry, + name=f"{DOMAIN} {client.host}", + update_interval=timedelta( + seconds=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ), + ) + self.client = client + # Der ESP32-C3 hat einen Kern, den sich WLAN-Stack und LED-Ausgabe + # teilen. Mehrere gleichzeitige Anfragen bringen nichts ausser Last. + self.lock = asyncio.Lock() + self.ota_in_progress = False + self._consecutive_poll_failures = 0 + + async def _async_update_data(self) -> NinstripData: + if self.ota_in_progress and self.data is not None: + return self.data + try: + async with self.lock: + state = await self.client.get_state() + info = await self.client.get_info() + presets = await self.client.get_presets() + except NinstripAuthError as err: + raise ConfigEntryAuthFailed(f"{self.client.host}: Token abgelehnt") from err + except NinstripError as err: + self._consecutive_poll_failures += 1 + if self.data is not None and self._consecutive_poll_failures < MAX_POLL_FAILURES: + _LOGGER.debug( + "%s: Abfrage fehlgeschlagen (%d/%d), behalte den letzten Stand: %s", + self.client.host, + self._consecutive_poll_failures, + MAX_POLL_FAILURES, + err, + ) + return self.data + raise UpdateFailed(f"{self.client.host}: {err}") from err + self._consecutive_poll_failures = 0 + return NinstripData(state, info, presets) + + async def async_call[T](self, action: Callable[[NinstripClient], Awaitable[T]]) -> T: + """Fuehrt eine Anfrage unter der Sperre aus und uebersetzt Fehler fuer die Oberflaeche.""" + try: + async with self.lock: + return await action(self.client) + except NinstripAuthError as err: + self.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="auth_failed" + ) from err + except NinstripBusyError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="device_busy" + ) from err + except NinstripRequestError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="request_failed", + translation_placeholders={"message": err.message}, + ) from err + except NinstripError as err: + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="cannot_connect" + ) from err + + async def async_set_state(self, **fields: Any) -> None: + """Schreibt und uebernimmt die vollstaendige Antwort des Geraets direkt.""" + state = await self.async_call(lambda client: client.set_state(**fields)) + self._consecutive_poll_failures = 0 + self.async_set_updated_data(NinstripData(state, self.data.info, self.data.presets)) + + async def async_after_change(self) -> None: + """Nach einem Eingriff, der den ganzen Stand aendern kann (Szene, Segmente).""" + await self.async_request_refresh() diff --git a/custom_components/ninstrip/entity.py b/custom_components/ninstrip/entity.py new file mode 100644 index 0000000..0f06a1f --- /dev/null +++ b/custom_components/ninstrip/entity.py @@ -0,0 +1,54 @@ +"""Basisentitaet und Geraetedaten fuer ninstrip.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo, format_mac +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import NinstripCoordinator + + +def device_name(info: dict[str, Any]) -> str: + """Der eigene Name des Geraets, sonst ' '.""" + name = str(info.get("name") or "").strip() + if name: + return name + mac4 = format_mac(info["mac"]).replace(":", "")[-4:] + return f"{info.get('product') or 'ninstrip'} {mac4}" + + +def strip_model(info: dict[str, Any]) -> str | None: + """Kurzbeschreibung des Strips fuer die Geraeteseite, etwa 'ws2805, 60 Pixel'.""" + strip = info.get("strip") + if not isinstance(strip, dict): + return None + chip = strip.get("chipset") + pixels = strip.get("pixel_count") + if chip and pixels: + return f"{chip}, {pixels} Pixel" + return chip or None + + +class NinstripEntity(CoordinatorEntity[NinstripCoordinator]): + """Gemeinsame Basis: eindeutige Kennung aus der MAC, ein Geraet je Strip.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: NinstripCoordinator, key: str) -> None: + super().__init__(coordinator) + info = coordinator.data.info + mac = format_mac(info["mac"]) + self._attr_unique_id = f"{mac}_{key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, mac)}, + connections={(dr.CONNECTION_NETWORK_MAC, mac)}, + manufacturer="ninstrip", + model=strip_model(info), + model_id=info.get("product"), + name=device_name(info), + sw_version=info["fw_version"], + ) diff --git a/custom_components/ninstrip/light.py b/custom_components/ninstrip/light.py new file mode 100644 index 0000000..6d5aef9 --- /dev/null +++ b/custom_components/ninstrip/light.py @@ -0,0 +1,68 @@ +"""Hauptschalter und Helligkeit eines ninstrip. + +Bewusst eine Entitaet je Geraet, nicht je Segment: device/state ist der +Hauptschalter und traegt keine Farbe - bei mehreren Segmenten gaebe es keine +eine Farbe, die stimmen wuerde. Die Segmente selbst werden ueber Szenen +gestellt; einzelne Segmententitaeten koennen spaeter dazukommen. +""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import NinstripConfigEntry, NinstripCoordinator +from .entity import NinstripEntity + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NinstripConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + async_add_entities([NinstripLight(entry.runtime_data)]) + + +class NinstripLight(NinstripEntity, LightEntity): + """Der Strip als Ganzes.""" + + _attr_name = None + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + + def __init__(self, coordinator: NinstripCoordinator) -> None: + super().__init__(coordinator, "light") + + @property + def is_on(self) -> bool: + return bool(self.coordinator.data.state.get("on")) + + @property + def brightness(self) -> int | None: + """Das Geraet rechnet in Prozent, Home Assistant in 0..255.""" + value = self.coordinator.data.state.get("brightness") + if value is None: + return None + return round(max(0, min(100, int(value))) * 255 / 100) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + state = self.coordinator.data.state + return { + "segment_count": state.get("segment_count"), + "pixel_count": state.get("pixel_count"), + "preset": state.get("preset"), + "preset_modified": state.get("preset_modified"), + } + + async def async_turn_on(self, **kwargs: Any) -> None: + fields: dict[str, Any] = {"on": True} + if ATTR_BRIGHTNESS in kwargs: + fields["brightness"] = round(int(kwargs[ATTR_BRIGHTNESS]) * 100 / 255) + await self.coordinator.async_set_state(**fields) + + async def async_turn_off(self, **kwargs: Any) -> None: + await self.coordinator.async_set_state(on=False) diff --git a/custom_components/ninstrip/manifest.json b/custom_components/ninstrip/manifest.json new file mode 100644 index 0000000..04ad752 --- /dev/null +++ b/custom_components/ninstrip/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "ninstrip", + "name": "ninstrip", + "codeowners": ["@ninharp"], + "config_flow": true, + "documentation": "https://gitea.zukunpht.de/ninharp/ha-ninstrip", + "integration_type": "device", + "iot_class": "local_polling", + "issue_tracker": "https://gitea.zukunpht.de/ninharp/ha-ninstrip/issues", + "requirements": [], + "version": "0.1.0", + "zeroconf": [{ "type": "_ninstrip._tcp.local." }] +} diff --git a/custom_components/ninstrip/select.py b/custom_components/ninstrip/select.py new file mode 100644 index 0000000..fc02571 --- /dev/null +++ b/custom_components/ninstrip/select.py @@ -0,0 +1,67 @@ +"""Szenenauswahl eines ninstrip.""" + +from __future__ import annotations + +from homeassistant.components.select import SelectEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import NinstripConfigEntry, NinstripCoordinator +from .entity import NinstripEntity + +# Wird angezeigt, wenn keine Szene aufgeschaltet ist oder jemand die Segmente +# seit dem Aufschalten veraendert hat. +NONE_OPTION = "—" + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NinstripConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + async_add_entities([NinstripPresetSelect(entry.runtime_data)]) + + +class NinstripPresetSelect(NinstripEntity, SelectEntity): + """Waehlt eine der belegten Szenen aus.""" + + _attr_translation_key = "preset" + _attr_icon = "mdi:playlist-play" + + def __init__(self, coordinator: NinstripCoordinator) -> None: + super().__init__(coordinator, "preset") + + def _used(self) -> list[dict]: + presets = self.coordinator.data.presets.get("presets") or [] + return [p for p in presets if p.get("used")] + + @property + def options(self) -> list[str]: + return [NONE_OPTION] + [str(p.get("name") or f"Szene {p['id'] + 1}") for p in self._used()] + + @property + def current_option(self) -> str: + """Nach einer Aenderung an den Segmenten passt keine Szene mehr - dann '—'.""" + state = self.coordinator.data.state + if state.get("preset_modified"): + return NONE_OPTION + aktiv = state.get("preset") + if aktiv is None: + return NONE_OPTION + for p in self._used(): + if p.get("id") == aktiv: + return str(p.get("name") or f"Szene {aktiv + 1}") + return NONE_OPTION + + async def async_select_option(self, option: str) -> None: + if option == NONE_OPTION: + # Es gibt kein "Szene abwaehlen" - das Gegenstueck ist Ausschalten. + await self.coordinator.async_set_state(on=False) + return + for p in self._used(): + name = str(p.get("name") or f"Szene {p['id'] + 1}") + if name == option: + index = int(p["id"]) + await self.coordinator.async_call(lambda c: c.apply_preset(index)) + await self.coordinator.async_after_change() + return diff --git a/custom_components/ninstrip/sensor.py b/custom_components/ninstrip/sensor.py new file mode 100644 index 0000000..5d8adaa --- /dev/null +++ b/custom_components/ninstrip/sensor.py @@ -0,0 +1,108 @@ +"""Diagnosewerte eines ninstrip.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfInformation, + UnitOfTime, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import NinstripConfigEntry, NinstripCoordinator, NinstripData +from .entity import NinstripEntity + + +@dataclass(frozen=True, kw_only=True) +class NinstripSensorDescription(SensorEntityDescription): + """Beschreibung samt Zugriff auf den Wert.""" + + value: Callable[[NinstripData], Any] + + +def _wifi(data: NinstripData, key: str) -> Any: + wifi = data.info.get("wifi") + return wifi.get(key) if isinstance(wifi, dict) else None + + +def _strip(data: NinstripData, key: str) -> Any: + strip = data.info.get("strip") + return strip.get(key) if isinstance(strip, dict) else None + + +SENSORS: tuple[NinstripSensorDescription, ...] = ( + NinstripSensorDescription( + key="rssi", + translation_key="rssi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value=lambda d: _wifi(d, "rssi"), + ), + NinstripSensorDescription( + key="uptime", + translation_key="uptime", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value=lambda d: d.info.get("uptime"), + ), + NinstripSensorDescription( + key="free_heap", + translation_key="free_heap", + native_unit_of_measurement=UnitOfInformation.BYTES, + device_class=SensorDeviceClass.DATA_SIZE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value=lambda d: d.info.get("free_heap"), + ), + # Sagt, ob die Strombegrenzung gerade herunterregelt. 100 heisst: nein. + NinstripSensorDescription( + key="power_scale", + translation_key="power_scale", + native_unit_of_measurement="%", + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda d: _strip(d, "power_scale"), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NinstripConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + coordinator = entry.runtime_data + async_add_entities(NinstripSensor(coordinator, d) for d in SENSORS) + + +class NinstripSensor(NinstripEntity, SensorEntity): + entity_description: NinstripSensorDescription + + def __init__( + self, coordinator: NinstripCoordinator, description: NinstripSensorDescription + ) -> None: + super().__init__(coordinator, description.key) + self.entity_description = description + + @property + def native_value(self) -> Any: + return self.entity_description.value(self.coordinator.data) diff --git a/custom_components/ninstrip/strings.json b/custom_components/ninstrip/strings.json new file mode 100644 index 0000000..d462575 --- /dev/null +++ b/custom_components/ninstrip/strings.json @@ -0,0 +1,55 @@ +{ + "config": { + "step": { + "user": { + "title": "ninstrip hinzufügen", + "description": "Adresse und API-Token des Geräts. Das Token wird beim ersten Start einmalig auf der seriellen Konsole ausgegeben; ein Werksreset erzeugt ein neues.", + "data": { "host": "Adresse", "token": "API-Token" } + }, + "discovery_confirm": { + "title": "ninstrip gefunden", + "description": "{name} unter {host}. Bitte das API-Token eingeben.", + "data": { "token": "API-Token" } + }, + "reauth_confirm": { + "title": "Token erneuern", + "description": "Das Gerät unter {host} weist das gespeicherte Token ab. Nach einem Werksreset hat es ein neues.", + "data": { "token": "API-Token" } + } + }, + "error": { + "cannot_connect": "Gerät nicht erreichbar", + "invalid_auth": "Token abgelehnt", + "not_ninstrip": "Dort antwortet kein ninstrip", + "unknown": "Unerwarteter Fehler" + }, + "abort": { + "already_configured": "Dieses Gerät ist bereits eingerichtet", + "reauth_successful": "Token erneuert" + } + }, + "options": { + "step": { + "init": { + "title": "Einstellungen", + "data": { "scan_interval": "Abfrageabstand (Sekunden)" } + } + } + }, + "entity": { + "select": { "preset": { "name": "Szene" } }, + "button": { "restart": { "name": "Neu starten" } }, + "sensor": { + "rssi": { "name": "WLAN-Signal" }, + "uptime": { "name": "Laufzeit" }, + "free_heap": { "name": "Freier Speicher" }, + "power_scale": { "name": "Leistungsbegrenzung" } + } + }, + "exceptions": { + "auth_failed": { "message": "Das Gerät hat das Token abgelehnt." }, + "device_busy": { "message": "Das Gerät ist beschäftigt - vermutlich läuft eine Aktualisierung." }, + "request_failed": { "message": "Das Gerät hat die Anfrage abgewiesen: {message}" }, + "cannot_connect": { "message": "Das Gerät ist nicht erreichbar." } + } +} diff --git a/custom_components/ninstrip/translations/de.json b/custom_components/ninstrip/translations/de.json new file mode 100644 index 0000000..d462575 --- /dev/null +++ b/custom_components/ninstrip/translations/de.json @@ -0,0 +1,55 @@ +{ + "config": { + "step": { + "user": { + "title": "ninstrip hinzufügen", + "description": "Adresse und API-Token des Geräts. Das Token wird beim ersten Start einmalig auf der seriellen Konsole ausgegeben; ein Werksreset erzeugt ein neues.", + "data": { "host": "Adresse", "token": "API-Token" } + }, + "discovery_confirm": { + "title": "ninstrip gefunden", + "description": "{name} unter {host}. Bitte das API-Token eingeben.", + "data": { "token": "API-Token" } + }, + "reauth_confirm": { + "title": "Token erneuern", + "description": "Das Gerät unter {host} weist das gespeicherte Token ab. Nach einem Werksreset hat es ein neues.", + "data": { "token": "API-Token" } + } + }, + "error": { + "cannot_connect": "Gerät nicht erreichbar", + "invalid_auth": "Token abgelehnt", + "not_ninstrip": "Dort antwortet kein ninstrip", + "unknown": "Unerwarteter Fehler" + }, + "abort": { + "already_configured": "Dieses Gerät ist bereits eingerichtet", + "reauth_successful": "Token erneuert" + } + }, + "options": { + "step": { + "init": { + "title": "Einstellungen", + "data": { "scan_interval": "Abfrageabstand (Sekunden)" } + } + } + }, + "entity": { + "select": { "preset": { "name": "Szene" } }, + "button": { "restart": { "name": "Neu starten" } }, + "sensor": { + "rssi": { "name": "WLAN-Signal" }, + "uptime": { "name": "Laufzeit" }, + "free_heap": { "name": "Freier Speicher" }, + "power_scale": { "name": "Leistungsbegrenzung" } + } + }, + "exceptions": { + "auth_failed": { "message": "Das Gerät hat das Token abgelehnt." }, + "device_busy": { "message": "Das Gerät ist beschäftigt - vermutlich läuft eine Aktualisierung." }, + "request_failed": { "message": "Das Gerät hat die Anfrage abgewiesen: {message}" }, + "cannot_connect": { "message": "Das Gerät ist nicht erreichbar." } + } +} diff --git a/custom_components/ninstrip/translations/en.json b/custom_components/ninstrip/translations/en.json new file mode 100644 index 0000000..45c80b0 --- /dev/null +++ b/custom_components/ninstrip/translations/en.json @@ -0,0 +1,88 @@ +{ + "config": { + "step": { + "user": { + "title": "Add ninstrip", + "description": "Host and API token of the device. The token is printed once on the serial console at first boot; a factory reset creates a new one.", + "data": { + "host": "Host", + "token": "API token" + } + }, + "discovery_confirm": { + "title": "ninstrip found", + "description": "{name} at {host}. Please enter the API token.", + "data": { + "token": "API token" + } + }, + "reauth_confirm": { + "title": "Renew token", + "description": "The device at {host} rejects the stored token. After a factory reset it has a new one.", + "data": { + "token": "API token" + } + } + }, + "error": { + "cannot_connect": "Device unreachable", + "invalid_auth": "Token rejected", + "not_ninstrip": "No ninstrip answering there", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "This device is already configured", + "reauth_successful": "Token renewed" + } + }, + "options": { + "step": { + "init": { + "title": "Settings", + "data": { + "scan_interval": "Polling interval (seconds)" + } + } + } + }, + "entity": { + "select": { + "preset": { + "name": "Scene" + } + }, + "button": { + "restart": { + "name": "Restart" + } + }, + "sensor": { + "rssi": { + "name": "Wi-Fi signal" + }, + "uptime": { + "name": "Uptime" + }, + "free_heap": { + "name": "Free memory" + }, + "power_scale": { + "name": "Power limiting" + } + } + }, + "exceptions": { + "auth_failed": { + "message": "The device rejected the token." + }, + "device_busy": { + "message": "The device is busy - an update is probably running." + }, + "request_failed": { + "message": "The device rejected the request: {message}" + }, + "cannot_connect": { + "message": "The device is unreachable." + } + } +}