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:
@@ -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:
|
||||
|
||||
@@ -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 ------------------------------------------------------
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user