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:
ninharp
2026-09-20 03:42:42 +02:00
commit adec66a6a2
16 changed files with 1140 additions and 0 deletions
+44
View File
@@ -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)
+171
View File
@@ -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
+31
View File
@@ -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())
+177
View File
@@ -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)
)
}
),
)
+25
View File
@@ -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
+122
View File
@@ -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()
+54
View File
@@ -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 '<Produkt> <letzte vier MAC-Stellen>'."""
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"],
)
+68
View File
@@ -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)
+13
View File
@@ -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." }]
}
+67
View File
@@ -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
+108
View File
@@ -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)
+55
View File
@@ -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." }
}
}
@@ -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." }
}
}
@@ -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."
}
}
}