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,4 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
.DS_Store
|
||||
@@ -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: <token>`), 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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"],
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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." }]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user