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
+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)