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.
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user