feat: update-Entitaet fuer die Firmware und das Testgeruest

Aufgebaut wie ha-ninlight: die Abbilder liegen unter ninstrip_firmware im
Konfigurationsverzeichnis, Home Assistant liefert sie selbst unter einer
einmaligen Adresse aus, und zwar genau die Bytes, die vorher geprueft
wurden. Dazu firmware.py (Anhaenger, Versionsreihenfolge, WLAN-Pruefung,
Katalog), http.py, urls.py und shared.py.

Drei Unterschiede zu ninlight, alle aus der Firmware heraus:

* Verglichen wird device_type - die Platine -, nicht product. Letzteres ist
  bei allen Platinen 'ninstrip'; die Abbilder unterscheiden sich aber hart,
  die K1Y invertiert das Datensignal und die KL-RGBC3 nicht. Firmware ohne
  dieses Feld bekommt gar kein Angebot.
* Die WLAN-Pruefung sucht die SSID unverpackt: ein ESP32-Abbild ist nicht
  gzip-verpackt wie das des W600. An einem mit sdkconfig.local gebauten
  Abbild gegengeprueft - die SSID steht genau einmal NUL-gerahmt drin.
* Der Fehlerschluessel heisst device_url_too_long statt lamp_url_too_long.

Dazu das Testgeruest, das es hier bisher gar nicht gab: 204 Tests in
tests_unit/ (ohne HA-Instanz) und tests/ (mit), Abdeckung 97 %, dazu
scripts/check.sh als Ersatz fuer die fehlende CI. Die Nutzdaten in
tests_unit/samples.py sind an der Stehlampe abgefragt.

Beim Gegenpruefen per Mutation fiel auf, dass die Platinenpruefung
unmittelbar vor dem Senden ungeprueft war - der Test dazu ist jetzt da und
faellt, wenn man die Pruefung entfernt.
This commit is contained in:
Michael Sauer
2026-09-22 23:10:07 +02:00
parent d0a9487805
commit 2c77b2d325
44 changed files with 3600 additions and 43 deletions
+29 -2
View File
@@ -4,11 +4,17 @@ from __future__ import annotations
from homeassistant.const import CONF_HOST, CONF_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .api import NinstripClient
from .const import DEFAULT_PORT
from .const import DEFAULT_PORT, DOMAIN
from .coordinator import NinstripConfigEntry, NinstripCoordinator
from .shared import async_get_shared, async_start_catalog, async_stop_catalog
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS: list[Platform] = [
Platform.LIGHT,
@@ -18,9 +24,16 @@ PLATFORMS: list[Platform] = [
Platform.SWITCH,
Platform.NUMBER,
Platform.BINARY_SENSOR,
Platform.UPDATE,
]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Die Ausgabe fuer die Abbilder haengt an http und wird einmal angemeldet."""
async_get_shared(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: NinstripConfigEntry) -> bool:
"""Einen Strip einrichten."""
client = NinstripClient(
@@ -33,13 +46,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: NinstripConfigEntry) ->
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await async_start_catalog(hass, async_get_shared(hass))
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)
entladen = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
# Der Katalog gehoert allen Eintraegen gemeinsam; er laeuft weiter,
# solange noch einer da ist.
if entladen and not [
anderer
for anderer in hass.config_entries.async_loaded_entries(DOMAIN)
if anderer.entry_id != entry.entry_id
]:
async_stop_catalog(async_get_shared(hass))
return entladen
async def async_remove_entry(hass: HomeAssistant, entry: NinstripConfigEntry) -> None:
ir.async_delete_issue(hass, DOMAIN, f"wifi_guard_{entry.entry_id}")
async def _async_options_updated(hass: HomeAssistant, entry: NinstripConfigEntry) -> None:
+2 -6
View File
@@ -106,9 +106,7 @@ class NinstripClient:
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 {}
)
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")
@@ -121,9 +119,7 @@ class NinstripClient:
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}}
)
await self._request("PUT", "system/command", {"command": {"type": "fw_update", "url": url}})
# --- Unterbau ------------------------------------------------------
+1 -3
View File
@@ -19,9 +19,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
coordinator = entry.runtime_data
async_add_entities(
[NinstripSacnActive(coordinator), NinstripSacnMulticast(coordinator)]
)
async_add_entities([NinstripSacnActive(coordinator), NinstripSacnMulticast(coordinator)])
class _NinstripSacnInfo(NinstripEntity, BinarySensorEntity):
+30 -23
View File
@@ -21,6 +21,7 @@ import voluptuous as vol
from .api import NinstripAuthError, NinstripClient, NinstripError, NinstripResponseError
from .const import (
CONF_DEVICE_BASE_URL,
CONF_SCAN_INTERVAL,
DEFAULT_SCAN_INTERVAL,
DEFAULT_TOKEN,
@@ -29,6 +30,7 @@ from .const import (
MIN_SCAN_INTERVAL,
)
from .entity import device_name
from .urls import validate_device_base_url
_LOGGER = logging.getLogger(__name__)
@@ -78,18 +80,12 @@ class NinstripConfigFlow(ConfigFlow, domain=DOMAIN):
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:
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]
)
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 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(
@@ -101,9 +97,7 @@ class NinstripConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
async def async_step_zeroconf(self, discovery_info: ZeroconfServiceInfo) -> ConfigFlowResult:
"""Gefunden ueber _ninstrip._tcp."""
self._host = discovery_info.host
props = discovery_info.properties or {}
@@ -131,9 +125,7 @@ class NinstripConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
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()
@@ -158,21 +150,36 @@ class NinstripConfigFlow(ConfigFlow, domain=DOMAIN):
class NinstripOptionsFlow(OptionsFlowWithReload):
"""Bisher nur die Abfragehaeufigkeit."""
"""Abfragehaeufigkeit und die Adresse, unter der Geraete Home Assistant erreichen."""
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
errors: dict[str, str] = {}
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)
base = str(user_input.get(CONF_DEVICE_BASE_URL) or "").strip()
fehler = validate_device_base_url(base) if base else None
if fehler:
errors[CONF_DEVICE_BASE_URL] = fehler
else:
daten: dict[str, Any] = {CONF_SCAN_INTERVAL: user_input[CONF_SCAN_INTERVAL]}
if base:
daten[CONF_DEVICE_BASE_URL] = base.rstrip("/")
return self.async_create_entry(data=daten)
current = self.config_entry.options
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(CONF_SCAN_INTERVAL, default=current): vol.All(
vol.Required(
CONF_SCAN_INTERVAL,
default=current.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL),
): vol.All(
vol.Coerce(int), vol.Range(min=MIN_SCAN_INTERVAL, max=MAX_SCAN_INTERVAL)
)
),
vol.Optional(
CONF_DEVICE_BASE_URL,
description={"suggested_value": current.get(CONF_DEVICE_BASE_URL, "")},
): str,
}
),
errors=errors,
)
+23
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
from datetime import timedelta
from typing import Final
DOMAIN: Final = "ninstrip"
@@ -12,6 +13,9 @@ DOMAIN: Final = "ninstrip"
DEFAULT_TOKEN: Final = "aabbccddeeff0011223344"
CONF_SCAN_INTERVAL: Final = "scan_interval"
# Nur noetig, wenn die Geraete die interne Adresse von Home Assistant nicht
# ueber schlichtes http erreichen.
CONF_DEVICE_BASE_URL: Final = "device_base_url"
DEFAULT_PORT: Final = 3333
DEFAULT_SCAN_INTERVAL: Final = 30
@@ -28,3 +32,22 @@ MAX_SEGMENTS: Final = 16
MAX_PIXELS: Final = 512
# Acht Szenenplaetze (nl_presets.h).
MAX_PRESETS: Final = 8
# --- Firmware-Aktualisierung -----------------------------------------
# Ordner unterhalb des HA-Konfigurationsverzeichnisses, in dem die Abbilder
# liegen (tools/release_images.sh der Firmware legt sie dort ab).
FIRMWARE_DIR_NAME: Final = "ninstrip_firmware"
CATALOG_SCAN_INTERVAL: Final = timedelta(minutes=5)
SIGNAL_CATALOG_UPDATED: Final = f"{DOMAIN}_catalog_updated"
FIRMWARE_URL_PREFIX: Final = "/api/ninstrip/firmware/"
# len(secrets.token_urlsafe(32))
FIRMWARE_KEY_LENGTH: Final = 43
# Laengere fw_update-Adressen weist das Geraet ab (nl_api_system.c: url[256]).
MAX_DEVICE_URL_LENGTH: Final = 255
OTA_POLL_INTERVAL_S: Final = 5.0
# Der ESP32 laedt rund 1 MB und startet danach neu; ein totes Gegenueber kann
# aber minutenlang im TCP haengen. Grosszuegig wie bei ninlight.
OTA_TIMEOUT_S: Final = 12 * 60.0
+222
View File
@@ -0,0 +1,222 @@
"""OTA-Abbilder fuer ninstrip: Anhaenger, Versionsreihenfolge, WLAN-Pruefung, Katalog.
Bewusst frei von Home-Assistant-Importen. Blockierende Funktionen sind
benannt; die Aufrufer schicken sie in einen Executor.
Der Anhaenger ist derselbe wie bei ninlight (512 Byte, Magie "NINL") - genau
deshalb kann die Migrationsstation von dort auch ninstrip-Abbilder ausliefern.
Ein Unterschied bleibt: ninlight laeuft auf dem W600 und traegt seine
Nutzdaten gzip-verpackt, der ESP32 nicht. Die WLAN-Pruefung sucht hier also
im Klartext (siehe `contains_ssid`).
"""
from __future__ import annotations
from dataclasses import dataclass
import hashlib
import logging
from pathlib import Path
import re
import threading
_LOGGER = logging.getLogger(__name__)
TRAILER_SIZE = 512
TRAILER_MAGIC = b"NINL"
TRAILER_FORMAT = 1
# tools/mkimage.py: device[16] und fw_version[16], jeweils NUL-abgeschlossen.
VERSION_FIELD_MAX = 15
_VERSION_RE = re.compile(
r"^(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"
r"(?:-rc(?P<rc>\d+))?"
r"(?:-(?P<commits>\d+)(?P<hash>-g[0-9a-f]*)?)?"
r"(?:-d(?:i(?:r(?:t(?:y)?)?)?)?)?$"
)
class InvalidImageError(ValueError):
"""Die Datei ist kein gueltiges ninstrip-OTA-Abbild."""
@dataclass(frozen=True, slots=True)
class FirmwareVersion:
"""Eine gelesene `git describe`-Version. Der Hash zaehlt bewusst nicht mit."""
base: tuple[int, int, int]
rc: int | None
commits: int
@property
def sort_key(self) -> tuple[int, int, int, int, int, int]:
# Ein Release Candidate kommt vor der Freigabe derselben Grundversion.
rc_rank, rc_number = (0, self.rc) if self.rc is not None else (1, 0)
return (*self.base, rc_rank, rc_number, self.commits)
def parse_version(text: str) -> FirmwareVersion | None:
match = _VERSION_RE.match(text)
if match is None:
return None
if len(text) == VERSION_FIELD_MAX and text[-1].isdigit() and match["hash"] is None:
# Die Kuerzung kann mitten in der Commit- oder rc-Zahl gelandet sein:
# dann ist die Version nicht vergleichbar.
return None
return FirmwareVersion(
(int(match["major"]), int(match["minor"]), int(match["patch"])),
int(match["rc"]) if match["rc"] is not None else None,
int(match["commits"]) if match["commits"] is not None else 0,
)
def is_newer(candidate: str, installed: str) -> bool:
new, old = parse_version(candidate), parse_version(installed)
if new is None or old is None:
return False
return new.sort_key > old.sort_key
def parse_trailer(data: bytes) -> tuple[str, str]:
"""(device_type, version) eines gueltigen Abbildes, sonst InvalidImageError."""
if len(data) <= TRAILER_SIZE:
raise InvalidImageError("zu kurz fuer einen Anhaenger")
payload, trailer = data[:-TRAILER_SIZE], data[-TRAILER_SIZE:]
if trailer[0:4] != TRAILER_MAGIC:
raise InvalidImageError("kein NINL-Anhaenger")
if trailer[4] != TRAILER_FORMAT:
raise InvalidImageError(f"unbekanntes Anhaengerformat {trailer[4]}")
if trailer[37:69] != hashlib.sha256(payload).digest():
raise InvalidImageError("sha256 passt nicht")
device_type = _text_field(trailer[5:21])
version = _text_field(trailer[21:37])
if not device_type or not version:
raise InvalidImageError("Geraetetyp oder Version leer")
return device_type, version
def _text_field(raw: bytes) -> str:
return raw.split(b"\x00", 1)[0].decode("ascii", errors="replace")
def contains_ssid(data: bytes, ssid: str) -> bool:
"""Wahr, wenn das Abbild `ssid` als NUL-gerahmte C-Zeichenkette traegt.
Anders als bei ninlight wird nichts ausgepackt: ein ESP32-Abbild liegt
unverpackt vor, die einkompilierte SSID steht als Zeichenkette drin. Am
Geraet gegengeprueft - in einem mit sdkconfig.local gebauten Abbild kommt
sie genau einmal NUL-gerahmt vor.
"""
if not ssid or len(data) <= TRAILER_SIZE:
return False
return b"\x00" + ssid.encode() + b"\x00" in data[:-TRAILER_SIZE]
@dataclass(frozen=True, slots=True)
class FirmwareImage:
"""Ein gueltiges Abbild im Firmware-Ordner."""
path: Path
device_type: str
version: str
size: int
mtime_ns: int
def _sort_key(image: FirmwareImage) -> tuple[int, ...]:
version = parse_version(image.version)
return version.sort_key if version is not None else ()
class FirmwareCatalog:
"""Alle gueltigen Abbilder eines Ordners. Nicht threadsicher: Aufrufer ordnen die Laeufe."""
def __init__(self, directory: Path) -> None:
self.directory = directory
self.images: tuple[FirmwareImage, ...] = ()
self._parsed: dict[tuple[str, int, int], FirmwareImage | None] = {}
self._ssid_hits: dict[tuple[str, int, int, str], bool] = {}
# scan() laeuft in einem Executor-Thread, waehrend contains_ssid() in
# einem anderen laufen kann (etwa die WLAN-Gegenprobe einer laufenden
# Aktualisierung gegen einen turnusmaessigen Durchlauf): beide lesen
# und aendern _ssid_hits.
self._ssid_hits_lock = threading.Lock()
def scan(self) -> tuple[FirmwareImage, ...]:
"""Blockierend. Ungueltige Dateien werden je (Pfad, mtime, Groesse) einmal gemeldet."""
self.directory.mkdir(parents=True, exist_ok=True)
found: list[FirmwareImage] = []
seen: set[tuple[str, int, int]] = set()
for path in sorted(self.directory.glob("*.img")):
try:
if not path.is_file():
continue
stat = path.stat()
except OSError:
continue
key = (str(path), stat.st_mtime_ns, stat.st_size)
seen.add(key)
if key not in self._parsed:
try:
device_type, version = parse_trailer(path.read_bytes())
except (OSError, InvalidImageError) as err:
_LOGGER.warning("Uebergehe Firmware-Datei %s: %s", path.name, err)
self._parsed[key] = None
else:
self._parsed[key] = FirmwareImage(
path, device_type, version, stat.st_size, stat.st_mtime_ns
)
image = self._parsed[key]
if image is not None:
found.append(image)
self._parsed = {k: v for k, v in self._parsed.items() if k in seen}
with self._ssid_hits_lock:
self._ssid_hits = {k: v for k, v in self._ssid_hits.items() if k[:3] in seen}
self.images = tuple(found)
return self.images
def newer_images(self, device_type: str, installed: str) -> list[FirmwareImage]:
"""Abbilder genau dieser Platine, die neuer sind als `installed` - neuestes zuerst."""
candidates = [
image
for image in self.images
if image.device_type == device_type and is_newer(image.version, installed)
]
return sorted(candidates, key=_sort_key, reverse=True)
def verify_and_read(self, image: FirmwareImage) -> bytes | None:
"""Blockierend. Liefert genau die geprueften Bytes, sonst None.
Wer mit dem Inhalt weiterarbeitet (ausliefern, nach der SSID
durchsuchen), muss diese Bytes nehmen und die Datei nicht erneut
lesen - dazwischen kann sie sich aendern.
"""
try:
stat = image.path.stat()
if (stat.st_mtime_ns, stat.st_size) != (image.mtime_ns, image.size):
return None
data = image.path.read_bytes()
except OSError:
return None
try:
if parse_trailer(data) != (image.device_type, image.version):
return None
except InvalidImageError:
return None
return data
def contains_ssid(self, image: FirmwareImage, ssid: str) -> bool:
"""Blockierend, gemerkt je (Datei, SSID)."""
key = (str(image.path), image.mtime_ns, image.size, ssid)
with self._ssid_hits_lock:
cached = key in self._ssid_hits
hit = self._ssid_hits.get(key)
if cached:
return bool(hit)
try:
data = image.path.read_bytes()
except OSError:
return False
hit = contains_ssid(data, ssid)
with self._ssid_hits_lock:
self._ssid_hits[key] = hit
return hit
+76
View File
@@ -0,0 +1,76 @@
"""Liefert OTA-Abbilder an die Geraete aus, unter einmaligen Adressen."""
from __future__ import annotations
from collections.abc import Mapping
import secrets
from typing import Any
from aiohttp import web
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.network import NoURLAvailableError, get_url
from .const import CONF_DEVICE_BASE_URL, DOMAIN, FIRMWARE_URL_PREFIX
class OtaRegistry:
"""Zufaellige Schluessel, jeder an genau die geprueften Bytes eines Abbildes
gebunden und nur gueltig, solange dessen Aktualisierung laeuft.
Bytes statt eines Pfades: die Datei koennte zwischen Pruefung und Abruf
ersetzt werden - die WLAN-Sicherung wuerde dann fuer eine Datei gelten,
die das Geraet nie bekommt.
"""
def __init__(self) -> None:
self._images: dict[str, bytes] = {}
def create(self, data: bytes) -> str:
key = secrets.token_urlsafe(32)
self._images[key] = data
return key
def revoke(self, key: str) -> None:
self._images.pop(key, None)
def get(self, key: str) -> bytes | None:
return self._images.get(key)
def __len__(self) -> int:
return len(self._images)
class NinstripFirmwareView(HomeAssistantView):
"""Ohne HA-Anmeldung: das Geraet kann sich nicht anmelden. Der unratbare
Schluessel ist der Schutz."""
url = FIRMWARE_URL_PREFIX + "{key}"
name = "api:ninstrip:firmware"
requires_auth = False
def __init__(self, hass: HomeAssistant, registry: OtaRegistry) -> None:
self._hass = hass
self._registry = registry
async def get(self, request: web.Request, key: str) -> web.Response:
body = self._registry.get(key)
if body is None:
return web.Response(status=404)
return web.Response(body=body, content_type="application/octet-stream")
def device_base_url(hass: HomeAssistant, options: Mapping[str, Any]) -> str:
"""Die http://-Adresse, unter der Geraete Home Assistant erreichen."""
base = options.get(CONF_DEVICE_BASE_URL)
if not base:
try:
base = get_url(hass, allow_external=False, allow_cloud=False, prefer_external=False)
except NoURLAvailableError as err:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="no_http_url"
) from err
if not str(base).startswith("http://"):
raise HomeAssistantError(translation_domain=DOMAIN, translation_key="no_http_url")
return str(base)
+1
View File
@@ -3,6 +3,7 @@
"name": "ninstrip",
"codeowners": ["@ninharp"],
"config_flow": true,
"dependencies": ["http"],
"documentation": "https://gitea.zukunpht.de/ninharp/ha-ninstrip",
"integration_type": "device",
"iot_class": "local_polling",
+1 -3
View File
@@ -23,9 +23,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
coordinator = entry.runtime_data
async_add_entities(
[NinstripSacnUniverse(coordinator), NinstripSacnStartSlot(coordinator)]
)
async_add_entities([NinstripSacnUniverse(coordinator), NinstripSacnStartSlot(coordinator)])
class NinstripSacnUniverse(NinstripSacnEntity, NumberEntity):
+3 -1
View File
@@ -69,7 +69,9 @@ class NinstripPresetSelect(NinstripEntity, SelectEntity):
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))
# index als Vorgabewert binden: sonst zeigt das Lambda auf die
# Schleifenvariable statt auf den Wert von jetzt.
await self.coordinator.async_call(lambda c, index=index: c.apply_preset(index))
await self.coordinator.async_after_change()
return
+75
View File
@@ -0,0 +1,75 @@
"""Was sich alle ninstrip-Eintraege teilen: der Abbild-Katalog und die einmaligen Schluessel."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.util.hass_dict import HassKey
from .const import CATALOG_SCAN_INTERVAL, DOMAIN, FIRMWARE_DIR_NAME, SIGNAL_CATALOG_UPDATED
from .firmware import FirmwareCatalog
from .http import NinstripFirmwareView, OtaRegistry
def firmware_directory(hass: HomeAssistant) -> Path:
return Path(hass.config.path(FIRMWARE_DIR_NAME))
@dataclass
class NinstripShared:
"""Einmal je Home Assistant."""
catalog: FirmwareCatalog
registry: OtaRegistry
scan_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
unsub_scan: CALLBACK_TYPE | None = None
DATA_SHARED: HassKey[NinstripShared] = HassKey(DOMAIN)
@callback
def async_get_shared(hass: HomeAssistant) -> NinstripShared:
"""Beim ersten Zugriff anlegen und die Ausgabe genau einmal anmelden."""
shared = hass.data.get(DATA_SHARED)
if shared is None:
shared = NinstripShared(FirmwareCatalog(firmware_directory(hass)), OtaRegistry())
hass.data[DATA_SHARED] = shared
hass.http.register_view(NinstripFirmwareView(hass, shared.registry))
return shared
async def async_scan_catalog(hass: HomeAssistant, shared: NinstripShared) -> None:
async with shared.scan_lock:
await hass.async_add_executor_job(shared.catalog.scan)
async_dispatcher_send(hass, SIGNAL_CATALOG_UPDATED)
async def async_start_catalog(hass: HomeAssistant, shared: NinstripShared) -> None:
if shared.unsub_scan is not None:
return
async def _turnus(_now: datetime) -> None:
await async_scan_catalog(hass, shared)
shared.unsub_scan = async_track_time_interval(
hass,
_turnus,
CATALOG_SCAN_INTERVAL,
name="ninstrip firmware catalog",
cancel_on_shutdown=True,
)
await async_scan_catalog(hass, shared)
@callback
def async_stop_catalog(shared: NinstripShared) -> None:
if shared.unsub_scan is not None:
shared.unsub_scan()
shared.unsub_scan = None
+44 -1
View File
@@ -40,9 +40,17 @@
"init": {
"title": "Einstellungen",
"data": {
"scan_interval": "Abfrageabstand (Sekunden)"
"scan_interval": "Abfrageabstand (Sekunden)",
"device_base_url": "Adresse, unter der die Geräte Home Assistant erreichen"
},
"data_description": {
"device_base_url": "Nur für Firmware-Updates nötig, wenn die Geräte die interne Adresse von Home Assistant nicht über schlichtes http erreichen, z. B. http://192.168.1.50:8123"
}
}
},
"error": {
"invalid_base_url": "Es muss eine schlichte http://-Adresse mit Host und optionalem Port sein, ohne Pfad.",
"base_url_too_long": "Adresse zu lang – das Gerät nimmt für die ganze Firmware-Adresse höchstens 255 Zeichen."
}
},
"entity": {
@@ -97,6 +105,11 @@
"sacn_multicast": {
"name": "sACN-Multicast beigetreten"
}
},
"update": {
"firmware": {
"name": "Firmware"
}
}
},
"exceptions": {
@@ -111,6 +124,36 @@
},
"cannot_connect": {
"message": "Das Gerät ist nicht erreichbar."
},
"update_busy": {
"message": "Auf dem Gerät läuft bereits eine Aktualisierung."
},
"no_update": {
"message": "Es steht keine neuere Firmware bereit."
},
"no_http_url": {
"message": "Die Geräte erreichen Home Assistant nur über schlichtes http. Die Adresse steht in den Einstellungen der Integration."
},
"device_url_too_long": {
"message": "Die Firmware-Adresse ist länger als die 255 Zeichen, die das Gerät annimmt."
},
"image_invalid": {
"message": "Die Firmware-Datei hat sich geändert oder ist nicht mehr gültig."
},
"wifi_guard": {
"message": "Diese Firmware enthält die WLAN-Zugangsdaten für {ssid} nicht; das Gerät verlöre seine Netzverbindung."
},
"update_failed": {
"message": "Die Aktualisierung ist fehlgeschlagen: {reason}"
},
"update_timeout": {
"message": "Das Gerät hat sich innerhalb von 12 Minuten nicht mit der neuen Firmware gemeldet."
}
},
"issues": {
"wifi_guard": {
"title": "Firmware für {name} wird nicht angeboten",
"description": "Im Firmware-Ordner liegt {version} für {device_type}, aber ohne WLAN-Zugangsdaten für {ssid}. Das Gerät kennt sein WLAN nur aus der Firmware und verlöre nach dieser Aktualisierung seine Netzverbindung. Deshalb wird sie nicht angeboten. Wer das Gerät stattdessen über das Onboarding ins WLAN gebracht hat, bekommt das Update ohne diesen Vorbehalt."
}
}
}
@@ -40,9 +40,17 @@
"init": {
"title": "Einstellungen",
"data": {
"scan_interval": "Abfrageabstand (Sekunden)"
"scan_interval": "Abfrageabstand (Sekunden)",
"device_base_url": "Adresse, unter der die Geräte Home Assistant erreichen"
},
"data_description": {
"device_base_url": "Nur für Firmware-Updates nötig, wenn die Geräte die interne Adresse von Home Assistant nicht über schlichtes http erreichen, z. B. http://192.168.1.50:8123"
}
}
},
"error": {
"invalid_base_url": "Es muss eine schlichte http://-Adresse mit Host und optionalem Port sein, ohne Pfad.",
"base_url_too_long": "Adresse zu lang – das Gerät nimmt für die ganze Firmware-Adresse höchstens 255 Zeichen."
}
},
"entity": {
@@ -97,6 +105,11 @@
"sacn_multicast": {
"name": "sACN-Multicast beigetreten"
}
},
"update": {
"firmware": {
"name": "Firmware"
}
}
},
"exceptions": {
@@ -111,6 +124,36 @@
},
"cannot_connect": {
"message": "Das Gerät ist nicht erreichbar."
},
"update_busy": {
"message": "Auf dem Gerät läuft bereits eine Aktualisierung."
},
"no_update": {
"message": "Es steht keine neuere Firmware bereit."
},
"no_http_url": {
"message": "Die Geräte erreichen Home Assistant nur über schlichtes http. Die Adresse steht in den Einstellungen der Integration."
},
"device_url_too_long": {
"message": "Die Firmware-Adresse ist länger als die 255 Zeichen, die das Gerät annimmt."
},
"image_invalid": {
"message": "Die Firmware-Datei hat sich geändert oder ist nicht mehr gültig."
},
"wifi_guard": {
"message": "Diese Firmware enthält die WLAN-Zugangsdaten für {ssid} nicht; das Gerät verlöre seine Netzverbindung."
},
"update_failed": {
"message": "Die Aktualisierung ist fehlgeschlagen: {reason}"
},
"update_timeout": {
"message": "Das Gerät hat sich innerhalb von 12 Minuten nicht mit der neuen Firmware gemeldet."
}
},
"issues": {
"wifi_guard": {
"title": "Firmware für {name} wird nicht angeboten",
"description": "Im Firmware-Ordner liegt {version} für {device_type}, aber ohne WLAN-Zugangsdaten für {ssid}. Das Gerät kennt sein WLAN nur aus der Firmware und verlöre nach dieser Aktualisierung seine Netzverbindung. Deshalb wird sie nicht angeboten. Wer das Gerät stattdessen über das Onboarding ins WLAN gebracht hat, bekommt das Update ohne diesen Vorbehalt."
}
}
}
@@ -40,9 +40,17 @@
"init": {
"title": "Settings",
"data": {
"scan_interval": "Polling interval (seconds)"
"scan_interval": "Polling interval (seconds)",
"device_base_url": "Address devices use to reach Home Assistant"
},
"data_description": {
"device_base_url": "Only needed for firmware updates if devices cannot reach Home Assistant's internal URL over plain http, e.g. http://192.168.1.50:8123"
}
}
},
"error": {
"invalid_base_url": "Must be a plain http:// address with host and optional port, without path.",
"base_url_too_long": "Address too long – the device accepts at most 255 characters for the whole firmware URL."
}
},
"entity": {
@@ -97,6 +105,11 @@
"sacn_multicast": {
"name": "sACN multicast joined"
}
},
"update": {
"firmware": {
"name": "Firmware"
}
}
},
"exceptions": {
@@ -111,6 +124,36 @@
},
"cannot_connect": {
"message": "The device is unreachable."
},
"update_busy": {
"message": "An update is already running on the device."
},
"no_update": {
"message": "No firmware update is available."
},
"no_http_url": {
"message": "Devices can only reach Home Assistant over plain http. Set the address in the integration options."
},
"device_url_too_long": {
"message": "The firmware URL is longer than the 255 characters the device accepts."
},
"image_invalid": {
"message": "The firmware file changed or is no longer valid."
},
"wifi_guard": {
"message": "This firmware does not contain the Wi-Fi credentials for {ssid}; the device would lose its network connection."
},
"update_failed": {
"message": "The update failed: {reason}"
},
"update_timeout": {
"message": "The device did not report the new firmware within 12 minutes."
}
},
"issues": {
"wifi_guard": {
"title": "Firmware for {name} not offered",
"description": "The firmware folder contains {version} for {device_type}, but without Wi-Fi credentials for {ssid}. The device only knows its Wi-Fi from its firmware and would lose its network connection after this update, so the update is not offered. A device that was put on the network through onboarding instead gets the update without this reservation."
}
}
}
+278
View File
@@ -0,0 +1,278 @@
"""Firmware-Aktualisierung fuer ninstrip, samt WLAN-Sicherung.
Aufgebaut wie ha-ninlight. Zwei Unterschiede, beide aus der Firmware heraus:
* Die Platine steht in `device_type` (system/info), nicht in `product` - das
ist bei allen Platinen gleich. Firmware ohne dieses Feld bekommt gar kein
Angebot: ohne die Platine zu kennen, koennte ein K1Y-Abbild auf einer
KL-RGBC3 landen, und die invertiert das Datensignal nicht.
* Die WLAN-Pruefung sucht die SSID unverpackt im Abbild (siehe `firmware`).
"""
from __future__ import annotations
import asyncio
import time
from typing import Any
from homeassistant.components.update import (
UpdateDeviceClass,
UpdateEntity,
UpdateEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .api import NinstripError
from .const import (
DOMAIN,
MAX_DEVICE_URL_LENGTH,
OTA_POLL_INTERVAL_S,
OTA_TIMEOUT_S,
SIGNAL_CATALOG_UPDATED,
)
from .coordinator import NinstripConfigEntry, NinstripCoordinator
from .entity import NinstripEntity
from .firmware import FirmwareImage
from .firmware import contains_ssid as _enthaelt_ssid
from .firmware import is_newer as _ist_neuer
from .http import device_base_url
from .shared import DATA_SHARED
from .urls import firmware_url
PARALLEL_UPDATES = 0 # mehrere Geraete duerfen gleichzeitig; jedes fuer sich ist gesperrt
def haengt_an_kompiliertem_wlan(settings: dict[str, Any]) -> bool:
"""Nur ein ausdrueckliches 'stored' macht ein Geraet vom Abbild unabhaengig."""
wifi = settings.get("wifi")
return not (isinstance(wifi, dict) and wifi.get("source") == "stored")
def _ssid(info: dict[str, Any]) -> str:
wifi = info.get("wifi")
return str(wifi.get("ssid") or "") if isinstance(wifi, dict) else ""
def _fehler(key: str, **placeholders: str) -> HomeAssistantError:
return HomeAssistantError(
translation_domain=DOMAIN,
translation_key=key,
translation_placeholders=placeholders or None,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: NinstripConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
async_add_entities([NinstripUpdateEntity(entry.runtime_data)])
class NinstripUpdateEntity(NinstripEntity, UpdateEntity):
"""Bietet das neueste passende Abbild aus dem Firmware-Ordner an."""
_attr_device_class = UpdateDeviceClass.FIRMWARE
_attr_supported_features = UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
def __init__(self, coordinator: NinstripCoordinator) -> None:
super().__init__(coordinator, "firmware")
self._offer: FirmwareImage | None = None
self._evaluated_for: tuple[str, str, str] | None = None
self._eval_lock = asyncio.Lock()
self._install_lock = asyncio.Lock()
self._attr_in_progress = False
@property
def installed_version(self) -> str | None:
return self.coordinator.data.info.get("fw_version")
@property
def latest_version(self) -> str | None:
return self._offer.version if self._offer is not None else self.installed_version
def version_is_newer(self, latest_version: str, installed_version: str) -> bool:
"""Der eigene `git describe`-Vergleich statt HAs AwesomeVersion.
HA haelt "0.1.0-47-g71a57" und "0.1.0-36-gca9bb" fuer denselben
Versionskern und vergleicht dann die Zeichenketten - das faellt genau
dort falsch aus, wo diese Firmware lebt. `firmware.is_newer` hat die
Reihenfolge, nach der `self._offer` ueberhaupt erst entstanden ist;
so widersprechen sich Zustand und Angebot nie.
"""
return _ist_neuer(latest_version, installed_version)
async def async_added_to_hass(self) -> None:
await super().async_added_to_hass()
self.async_on_remove(
async_dispatcher_connect(self.hass, SIGNAL_CATALOG_UPDATED, self._pruefung_anstossen)
)
self._pruefung_anstossen()
@callback
def _pruefung_anstossen(self) -> None:
# Nie pruefen, waehrend eine Aktualisierung laeuft: das fragte ein
# gerade flashendes Geraet nach seinen Einstellungen und koennte
# Angebot und Hinweis unter der laufenden Aktualisierung wegziehen.
# `_async_install_locked` stoesst selbst genau eine Pruefung an, sobald
# die Merker wieder zurueckgesetzt sind.
if self._attr_in_progress:
return
self.coordinator.config_entry.async_create_task(
self.hass, self._async_pruefen(), name="ninstrip firmware evaluation"
)
@callback
def _handle_coordinator_update(self) -> None:
if not self._attr_in_progress and self._pruefschluessel() != self._evaluated_for:
self._pruefung_anstossen()
super()._handle_coordinator_update()
def _pruefschluessel(self) -> tuple[str, str, str]:
info = self.coordinator.data.info
return (str(info.get("device_type")), str(info.get("fw_version")), _ssid(info))
async def _async_pruefen(self) -> None:
async with self._eval_lock:
catalog = self.hass.data[DATA_SHARED].catalog
key = self._pruefschluessel()
device_type, installed, ssid = key
offer: FirmwareImage | None = None
blocked: FirmwareImage | None = None
# Firmware vor v0.1.0 meldet keinen device_type. Dann bleibt offen,
# fuer welche Platine sie gebaut wurde - also kein Angebot.
newer = (
catalog.newer_images(device_type, installed)
if self.coordinator.data.info.get("device_type")
else []
)
if newer:
try:
settings = await self.coordinator.async_call(lambda c: c.get_settings())
except HomeAssistantError:
return # das bisherige Angebot bleibt; der naechste Durchlauf versucht es
abhaengig = haengt_an_kompiliertem_wlan(settings)
for image in newer: # neuestes zuerst
if not abhaengig or await self.hass.async_add_executor_job(
catalog.contains_ssid, image, ssid
):
offer = image
break
if blocked is None:
blocked = image
self._offer = offer
self._evaluated_for = key
self._wlan_hinweis(blocked, ssid)
self.async_write_ha_state()
@callback
def _wlan_hinweis(self, blocked: FirmwareImage | None, ssid: str) -> None:
entry = self.coordinator.config_entry
issue_id = f"wifi_guard_{entry.entry_id}"
if blocked is None:
ir.async_delete_issue(self.hass, DOMAIN, issue_id)
return
ir.async_create_issue(
self.hass,
DOMAIN,
issue_id,
is_fixable=False,
severity=ir.IssueSeverity.WARNING,
translation_key="wifi_guard",
translation_placeholders={
"name": entry.title,
"version": blocked.version,
"device_type": blocked.device_type,
"ssid": ssid or "?",
},
)
async def async_install(self, version: str | None, backup: bool, **kwargs: Any) -> None:
# Nur der Aufruf, der diese Sperre haelt, darf unten in_progress und
# ota_in_progress anfassen: ohne diese Pruefung koennte ein zweiter
# Aufruf bis zu den Gegenproben durchlaufen - HAs eigene Sperre greift
# erst, wenn _attr_in_progress schon gesetzt ist - und sein `finally`
# wuerde die Merker zuruecksetzen, waehrend der erste noch flasht.
if self._install_lock.locked():
raise _fehler("update_busy")
async with self._install_lock:
await self._async_install_locked()
async def _async_install_locked(self) -> None:
shared = self.hass.data[DATA_SHARED]
image = self._offer
if image is None:
raise _fehler("no_update")
base = device_base_url(self.hass, self.coordinator.config_entry.options)
# Die Bytes hier EINMAL lesen und genau diese pruefen: die Ausgabe
# liefert dieselbe Kopie aus dem Speicher aus. Was danach mit der Datei
# passiert - etwa ein Bau, der sie mittendrin ueberschreibt - aendert
# nichts mehr an dem, was beim Geraet ankommt.
data = await self.hass.async_add_executor_job(shared.catalog.verify_and_read, image)
if data is None:
raise _fehler("image_invalid")
# Unmittelbar vor dem Senden noch einmal nachsehen: die Firmware des
# Geraets kann sich seit der Pruefung geaendert haben (etwa ein altes
# Angebot, das eine fehlgeschlagene Pruefung ueberlebt hat) und darf
# dann nicht mehr eingespielt werden.
info = await self.coordinator.async_call(lambda c: c.get_info())
if not _ist_neuer(image.version, str(info.get("fw_version"))):
raise _fehler("no_update")
settings = await self.coordinator.async_call(lambda c: c.get_settings())
if info.get("device_type") != image.device_type:
raise _fehler("image_invalid")
ssid = _ssid(info)
if haengt_an_kompiliertem_wlan(settings) and not await self.hass.async_add_executor_job(
_enthaelt_ssid, data, ssid
):
raise _fehler("wifi_guard", ssid=ssid or "?")
key = shared.registry.create(data)
url = firmware_url(base, key)
if len(url) > MAX_DEVICE_URL_LENGTH:
shared.registry.revoke(key)
raise _fehler("device_url_too_long")
self._attr_in_progress = True
self.coordinator.ota_in_progress = True
self.async_write_ha_state()
try:
await self.coordinator.async_call(lambda c: c.fw_update(url))
await self._async_warten_auf(image.version, info.get("uptime"))
finally:
shared.registry.revoke(key)
self._attr_in_progress = False
self.coordinator.ota_in_progress = False
await self.coordinator.async_refresh()
self.async_write_ha_state()
self._pruefung_anstossen()
async def _async_warten_auf(self, target: str, start_uptime: Any) -> None:
"""Wartet auf die Zielversion, einen Fehler oder einen Neustart mit der alten.
Die Firmware loescht last_ota_error, wenn eine Aktualisierung beginnt
(nl_ota.c) - was hier auftaucht, gehoert also zu diesem Versuch.
"""
deadline = time.monotonic() + OTA_TIMEOUT_S
while True:
await asyncio.sleep(OTA_POLL_INTERVAL_S)
if time.monotonic() >= deadline:
raise _fehler("update_timeout")
try:
async with self.coordinator.lock:
info = await self.coordinator.client.get_info()
except NinstripError:
continue # flasht oder startet gerade neu
version = str(info.get("fw_version"))
if version == target:
return
if error := info.get("last_ota_error"):
raise _fehler("update_failed", reason=str(error))
uptime = info.get("uptime")
if isinstance(uptime, int) and isinstance(start_uptime, int) and uptime < start_uptime:
raise _fehler("update_failed", reason=f"neu gestartet mit {version}")
+34
View File
@@ -0,0 +1,34 @@
"""Die Adresse, unter der Geraete Home Assistant erreichen. Frei von HA-Importen."""
from __future__ import annotations
from urllib.parse import urlsplit
from .const import FIRMWARE_KEY_LENGTH, FIRMWARE_URL_PREFIX, MAX_DEVICE_URL_LENGTH
def firmware_url(base: str, key: str) -> str:
return f"{base.rstrip('/')}{FIRMWARE_URL_PREFIX}{key}"
def validate_device_base_url(url: str) -> str | None:
"""None, wenn brauchbar, sonst der Fehlerschluessel fuer das Formular."""
try:
parts = urlsplit(url)
port_ok = parts.port is None or parts.port > 0
except ValueError:
return "invalid_base_url"
if (
parts.scheme != "http"
or not parts.hostname
or not port_ok
or parts.username
or parts.password
or parts.query
or parts.fragment
or parts.path not in ("", "/")
):
return "invalid_base_url"
if len(firmware_url(url, "k" * FIRMWARE_KEY_LENGTH)) > MAX_DEVICE_URL_LENGTH:
return "base_url_too_long"
return None