feat(update): Firmware-Update aus dem Ordner mit Einmal-URLs und WLAN-Sicherung

This commit is contained in:
Michael Sauer
2026-09-16 16:45:24 +02:00
parent cc318c9d25
commit ccc31d3286
9 changed files with 878 additions and 10 deletions
+29 -3
View File
@@ -4,14 +4,25 @@ from __future__ import annotations
from homeassistant.const import CONF_HOST, CONF_TOKEN, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers import (
config_validation as cv,
)
from homeassistant.helpers import (
device_registry as dr,
)
from homeassistant.helpers import (
issue_registry as ir,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.typing import ConfigType
from .api import NinlightClient
from .const import DEFAULT_TOKEN, DOMAIN
from .coordinator import NinlightConfigEntry, NinlightCoordinator
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.BUTTON,
@@ -20,9 +31,15 @@ PLATFORMS: list[Platform] = [
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,
]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async_get_shared(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: NinlightConfigEntry) -> bool:
client = NinlightClient(
async_get_clientsession(hass), entry.data[CONF_HOST], entry.data[CONF_TOKEN]
@@ -43,16 +60,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: NinlightConfigEntry) ->
registry.async_update_device(device.id, sw_version=info["fw_version"])
entry.async_on_unload(coordinator.async_add_listener(_sync_firmware_version))
await async_start_catalog(hass, async_get_shared(hass))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: NinlightConfigEntry) -> bool:
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unloaded and not [
other
for other in hass.config_entries.async_loaded_entries(DOMAIN)
if other.entry_id != entry.entry_id
]:
async_stop_catalog(async_get_shared(hass))
return unloaded
async def async_remove_entry(hass: HomeAssistant, entry: NinlightConfigEntry) -> None:
ir.async_delete_issue(hass, DOMAIN, f"default_token_{entry.entry_id}")
ir.async_delete_issue(hass, DOMAIN, f"wifi_guard_{entry.entry_id}")
@callback
+22 -7
View File
@@ -9,6 +9,7 @@ from homeassistant.const import CONF_HOST, CONF_TOKEN
from homeassistant.core import HomeAssistant
from .coordinator import NinlightConfigEntry
from .shared import DATA_SHARED
TO_REDACT = {CONF_TOKEN, CONF_HOST, "ssid", "mac", "ip", "sta_ip"}
@@ -17,11 +18,25 @@ async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: NinlightConfigEntry
) -> dict[str, Any]:
coordinator = entry.runtime_data
return async_redact_data(
{
"entry": {"data": dict(entry.data), "options": dict(entry.options)},
"state": coordinator.data.state,
"info": coordinator.data.info,
},
TO_REDACT,
device_type = coordinator.data.info.get("device_type")
shared = hass.data.get(DATA_SHARED)
images = (
[]
if shared is None
else [
{"file": image.path.name, "device_type": image.device_type, "version": image.version}
for image in shared.catalog.images
if image.device_type == device_type
]
)
return {
**async_redact_data(
{
"entry": {"data": dict(entry.data), "options": dict(entry.options)},
"state": coordinator.data.state,
"info": coordinator.data.info,
},
TO_REDACT,
),
"firmware_images": images,
}
+74
View File
@@ -0,0 +1,74 @@
"""Serve firmware images to lamps under one-time URLs."""
from __future__ import annotations
from collections.abc import Mapping
from pathlib import Path
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_LAMP_BASE_URL, DOMAIN, FIRMWARE_URL_PREFIX
class OtaRegistry:
"""Random keys, each bound to exactly one file, valid only while its update runs."""
def __init__(self) -> None:
self._paths: dict[str, Path] = {}
def create(self, path: Path) -> str:
key = secrets.token_urlsafe(32)
self._paths[key] = path
return key
def revoke(self, key: str) -> None:
self._paths.pop(key, None)
def get(self, key: str) -> Path | None:
return self._paths.get(key)
def __len__(self) -> int:
return len(self._paths)
class NinlightFirmwareView(HomeAssistantView):
"""No HA login: the lamp cannot authenticate. The unguessable key is the protection."""
url = FIRMWARE_URL_PREFIX + "{key}"
name = "api:ninlight: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:
path = self._registry.get(key)
if path is None:
return web.Response(status=404)
try:
body = await self._hass.async_add_executor_job(path.read_bytes)
except OSError:
return web.Response(status=404)
return web.Response(body=body, content_type="application/octet-stream")
def lamp_base_url(hass: HomeAssistant, options: Mapping[str, Any]) -> str:
"""The http:// address lamps use to reach Home Assistant."""
base = options.get(CONF_LAMP_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)
+75
View File
@@ -0,0 +1,75 @@
"""State shared by all ninlight entries: the firmware catalog and one-time download keys."""
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 NinlightFirmwareView, OtaRegistry
def firmware_directory(hass: HomeAssistant) -> Path:
return Path(hass.config.path(FIRMWARE_DIR_NAME))
@dataclass
class NinlightShared:
"""One instance per Home Assistant."""
catalog: FirmwareCatalog
registry: OtaRegistry
scan_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
unsub_scan: CALLBACK_TYPE | None = None
DATA_SHARED: HassKey[NinlightShared] = HassKey(DOMAIN)
@callback
def async_get_shared(hass: HomeAssistant) -> NinlightShared:
"""Create on first use and register the download view exactly once."""
shared = hass.data.get(DATA_SHARED)
if shared is None:
shared = NinlightShared(FirmwareCatalog(firmware_directory(hass)), OtaRegistry())
hass.data[DATA_SHARED] = shared
hass.http.register_view(NinlightFirmwareView(hass, shared.registry))
return shared
async def async_scan_catalog(hass: HomeAssistant, shared: NinlightShared) -> 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: NinlightShared) -> None:
if shared.unsub_scan is not None:
return
async def _scheduled_scan(_now: datetime) -> None:
await async_scan_catalog(hass, shared)
shared.unsub_scan = async_track_time_interval(
hass,
_scheduled_scan,
CATALOG_SCAN_INTERVAL,
name="ninlight firmware catalog",
cancel_on_shutdown=True,
)
await async_scan_catalog(hass, shared)
@callback
def async_stop_catalog(shared: NinlightShared) -> None:
if shared.unsub_scan is not None:
shared.unsub_scan()
shared.unsub_scan = None
+232
View File
@@ -0,0 +1,232 @@
"""Firmware update entity for ninlight lamps, including the Wi-Fi guard (spec §7)."""
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 NinlightError
from .const import (
DOMAIN,
MAX_LAMP_URL_LENGTH,
OTA_POLL_INTERVAL_S,
OTA_TIMEOUT_S,
SIGNAL_CATALOG_UPDATED,
)
from .coordinator import NinlightConfigEntry, NinlightCoordinator
from .entity import NinlightEntity
from .firmware import FirmwareImage
from .firmware import is_newer as _firmware_is_newer
from .http import lamp_base_url
from .shared import DATA_SHARED
from .urls import firmware_url
PARALLEL_UPDATES = 0 # several lamps may update at once; each lamp serializes via its lock
def depends_on_compiled_wifi(settings: dict[str, Any]) -> bool:
"""Only an explicit 'stored' makes a lamp independent of compiled-in credentials."""
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 _error(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: NinlightConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
async_add_entities([NinlightUpdateEntity(entry.runtime_data)])
class NinlightUpdateEntity(NinlightEntity, UpdateEntity):
"""Offers the newest suitable image from the firmware folder."""
_attr_device_class = UpdateDeviceClass.FIRMWARE
_attr_supported_features = UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
def __init__(self, coordinator: NinlightCoordinator) -> 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._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:
"""Use our own `git describe` comparison (spec §2): HA's AwesomeVersion misparses these.
HA 2026.9's default comparator treats "0.3.8-47-g71a57" and "0.3.8-36-gca9bb" as equal
version cores and falls back to a string compare, which returns False here even though
commit 47 is newer than commit 36 - the STATE_ON promise this entity makes would silently
break. `firmware.is_newer` already implements the correct `git describe` ordering used to
build `self._offer` in the first place, so state and offer never disagree.
"""
return _firmware_is_newer(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._schedule_evaluation)
)
self._schedule_evaluation()
@callback
def _schedule_evaluation(self) -> None:
self.coordinator.config_entry.async_create_task(
self.hass, self._async_evaluate(), name="ninlight firmware evaluation"
)
@callback
def _handle_coordinator_update(self) -> None:
if not self._attr_in_progress and self._evaluation_key() != self._evaluated_for:
self._schedule_evaluation()
super()._handle_coordinator_update()
def _evaluation_key(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_evaluate(self) -> None:
async with self._eval_lock:
catalog = self.hass.data[DATA_SHARED].catalog
key = self._evaluation_key()
device_type, installed, ssid = key
offer: FirmwareImage | None = None
blocked: FirmwareImage | None = None
newer = catalog.newer_images(device_type, installed)
if newer:
try:
settings = await self.coordinator.async_call(lambda c: c.get_settings())
except HomeAssistantError:
return # keep the previous offer; the next catalog scan retries
dependent = depends_on_compiled_wifi(settings)
for image in newer: # newest first
if not dependent 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._update_wifi_issue(blocked, ssid)
self.async_write_ha_state()
@callback
def _update_wifi_issue(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:
shared = self.hass.data[DATA_SHARED]
image = self._offer
if image is None:
raise _error("no_update")
base = lamp_base_url(self.hass, self.coordinator.config_entry.options)
if not await self.hass.async_add_executor_job(shared.catalog.verify, image):
raise _error("image_invalid")
# Re-check right before sending: settings may have changed since the offer.
info = await self.coordinator.async_call(lambda c: c.get_info())
settings = await self.coordinator.async_call(lambda c: c.get_settings())
if info.get("device_type") != image.device_type:
raise _error("image_invalid")
ssid = _ssid(info)
if depends_on_compiled_wifi(settings) and not await self.hass.async_add_executor_job(
shared.catalog.contains_ssid, image, ssid
):
raise _error("wifi_guard", ssid=ssid or "?")
key = shared.registry.create(image.path)
url = firmware_url(base, key)
if len(url) > MAX_LAMP_URL_LENGTH:
shared.registry.revoke(key)
raise _error("lamp_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_wait_for(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()
async def _async_wait_for(self, target: str, start_uptime: Any) -> None:
"""Wait for the lamp to report the target version, an error, or an old-version reboot.
The firmware clears last_ota_error when an update starts (nl_ota.c), so any value seen
here belongs to this attempt.
"""
deadline = time.monotonic() + OTA_TIMEOUT_S
while True:
await asyncio.sleep(OTA_POLL_INTERVAL_S)
if time.monotonic() >= deadline:
raise _error("update_timeout")
try:
async with self.coordinator.lock:
info = await self.coordinator.client.get_info()
except NinlightError:
continue # flashing or rebooting
version = str(info.get("fw_version"))
if version == target:
return
if error := info.get("last_ota_error"):
raise _error("update_failed", reason=str(error))
uptime = info.get("uptime")
if isinstance(uptime, int) and isinstance(start_uptime, int) and uptime < start_uptime:
raise _error("update_failed", reason=f"rebooted with {version}")