From 7fd279721f09df4d978c79d50afbf3ee8190129e Mon Sep 17 00:00:00 2001 From: Michael Sauer Date: Sat, 26 Sep 2026 18:01:56 +0200 Subject: [PATCH] chore: HA-Kopie und die von der Station abgedeckten Werkzeuge wieder heraus Der Shield-Zweig brachte zwei Verzeichnisse mit, die nichts mit dem Board zu tun haben. Nach Ruecksprache mit Michael bleibt davon nur, was es sonst nirgends gibt. ha-integration/ faellt ganz weg: die Home-Assistant-Integration hat ihr eigenes Repo (ha-ninlight, privat im Gitea) und laeuft dort in Michaels Produktivinstanz. Eine zweite Kopie hier wuerde unweigerlich auseinanderlaufen, und dann waere unklar, welche gilt. Aus qconnex-listen/ fliegen die Teile, die tools/station betriebsfertig kann: hostapd.conf, dnsmasq-qcx.conf, up.sh, down.sh und otaserve.py bauen von Hand den Zugangspunkt "qconnex-firmware" und liefern Abbilder aus - genau der Migrationsmodus der Pi-Station, dort als Dienst, mit Produkt-ID-Tabelle und Signaturpruefung. Ihre eigene hostapd.conf nennt sich im Kopf selbst "temporary experiment, do not enable as a service". Es bleibt, wofuer es bei uns nichts gibt: onboard.py spricht die ALTE Firmware ueber ihren TLS-Socket auf Port 2223 an (unser onboard.sh richtet dagegen eine bereits migrierte ninlight-Lampe ein - anderer Zweck), dazu die Analysewerkzeuge tlssniff.py, raw3333.py und logserver.py sowie bulb-map.txt. Ausserdem in diesem Commit: die erwarteten Zahlen in test_release.sh von 35/32 auf 39/36. Der Merge lief konfliktfrei durch und liess sie trotzdem falsch stehen - der Zweig zweigte vor der Reparatur der Zugangsdatenpruefung ab und konnte ihre neue Meldung nicht kennen, waehrend sie sein neuntes Ziel nicht kennen konnte. Woran die Zahlen haengen, steht jetzt im Kommentar daneben. --- .../custom_components/ninlight/__init__.py | 54 ----- .../custom_components/ninlight/client.py | 214 ------------------ .../custom_components/ninlight/config_flow.py | 166 -------------- .../custom_components/ninlight/const.py | 25 -- .../custom_components/ninlight/coordinator.py | 107 --------- .../custom_components/ninlight/entity.py | 40 ---- .../custom_components/ninlight/light.py | 182 --------------- .../custom_components/ninlight/manifest.json | 12 - .../custom_components/ninlight/strings.json | 35 --- .../ninlight/translations/en.json | 35 --- qconnex-listen/dnsmasq-qcx.conf | 13 -- qconnex-listen/down.sh | 11 - qconnex-listen/hostapd.conf | 15 -- qconnex-listen/otaserve.py | 72 ------ qconnex-listen/up.sh | 22 -- tools/tests/test_release.sh | 11 +- 16 files changed, 8 insertions(+), 1006 deletions(-) delete mode 100644 ha-integration/custom_components/ninlight/__init__.py delete mode 100644 ha-integration/custom_components/ninlight/client.py delete mode 100644 ha-integration/custom_components/ninlight/config_flow.py delete mode 100644 ha-integration/custom_components/ninlight/const.py delete mode 100644 ha-integration/custom_components/ninlight/coordinator.py delete mode 100644 ha-integration/custom_components/ninlight/entity.py delete mode 100644 ha-integration/custom_components/ninlight/light.py delete mode 100644 ha-integration/custom_components/ninlight/manifest.json delete mode 100644 ha-integration/custom_components/ninlight/strings.json delete mode 100644 ha-integration/custom_components/ninlight/translations/en.json delete mode 100644 qconnex-listen/dnsmasq-qcx.conf delete mode 100755 qconnex-listen/down.sh delete mode 100644 qconnex-listen/hostapd.conf delete mode 100644 qconnex-listen/otaserve.py delete mode 100755 qconnex-listen/up.sh diff --git a/ha-integration/custom_components/ninlight/__init__.py b/ha-integration/custom_components/ninlight/__init__.py deleted file mode 100644 index 8d3b252..0000000 --- a/ha-integration/custom_components/ninlight/__init__.py +++ /dev/null @@ -1,54 +0,0 @@ -"""The Ninlight integration.""" - -from __future__ import annotations - -import logging - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant - -from .const import CONF_TOKEN, DEFAULT_PORT, PLATFORMS -from .coordinator import NinlightConfigEntry, NinlightCoordinator - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass: HomeAssistant, entry: NinlightConfigEntry) -> bool: - """Set up a Ninlight bulb from a config entry.""" - coordinator = NinlightCoordinator(hass, entry) - - # Register the teardown before the first refresh: HA runs the on_unload callbacks both - # when setup fails and when the entry unloads, and awaits the coroutine they return. - # That covers every exit path, so no bulb client can be left with an open connection - # slot. The bulb serves one request per connection, so this matters. - entry.async_on_unload(coordinator.async_shutdown) - - try: - await coordinator.async_config_entry_first_refresh() - except Exception: - # ConfigEntryNotReady / ConfigEntryAuthFailed propagate to HA, which retries or - # raises reauth. Close now rather than waiting for the retry. - await coordinator.async_shutdown() - raise - - entry.runtime_data = coordinator - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: NinlightConfigEntry) -> bool: - """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: - """Migrate an entry written by an earlier version of this integration.""" - if config_entry.version > 1: - return False - if "port" not in config_entry.data or CONF_TOKEN not in config_entry.data: - new = dict(config_entry.data) - new.setdefault("port", DEFAULT_PORT) - new.setdefault(CONF_TOKEN, new.pop("api_key", "")) - hass.config_entries.async_update_entry(config_entry, data=new, version=1) - _LOGGER.debug("Migrated ninlight entry %s to v1", config_entry.entry_id) - return True diff --git a/ha-integration/custom_components/ninlight/client.py b/ha-integration/custom_components/ninlight/client.py deleted file mode 100644 index aa9051f..0000000 --- a/ha-integration/custom_components/ninlight/client.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Asynchronous client for the Ninlight REST API. - -The firmware (src/net/nl_api.c) speaks plain HTTP/1.1 on port 3333 with a raw token in -the `Authorization` header - no "Bearer" scheme prefix, no TLS, no sessions. The bulb -serves one request per connection and accepts at most four queued connections, so the -client must not keep a hot connection pool open. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - -import httpx - -from .const import DEFAULT_PORT - - -class NinlightError(Exception): - """Base error, raised on connection problems and non-JSON replies.""" - - -class NinlightAuthError(NinlightError): - """The bulb rejected our token (HTTP 401).""" - - -class NinlightBadRequest(NinlightError): - """The bulb rejected the payload (HTTP 400) with the reason in its body.""" - - def __init__(self, message: str, reason: str | None) -> None: - super().__init__(f"{message}: {reason}" if reason else message) - self.reason = reason - - -@dataclass(slots=True) -class NinlightState: - """A parsed `GET /api/v1/device/state` plus the identity from `system/info`.""" - - on: bool - brightness: int # 1..100, the firmware's native unit - mode: str # "color" | "white" - r: int - g: int - b: int - color_temp: int # kelvin - ct_min: int - ct_max: int - has_rgb: bool - # Settings echoed by the same reply; the coordinator exposes them as entities. - power_on_behaviour: str - fade_in: int - fade_out: int - transition_default: int - wake_on_change: bool - # From system/info, merged in by the coordinator. - device_type: str | None = None - product_name: str | None = None - fw_version: str | None = None - mac: str | None = None - name: str | None = None - rssi: int | None = None - ip: str | None = None - # Segment model only (ninstrip): the raw segment list; the light maps onto - # segment 0 while a scene across the remaining segments stays untouched. - segments: list[dict[str, Any]] = field(default_factory=list) - # True for ninstrip firmware, whose light state lives in segments. Decided by - # presence of `segment_count` in device/state and `product` in system/info - - # both keys are ninstrip-only (nl_api_device.c / nl_api_system.c). Bulb - # device_types are board ids (nl_boards[] in ninlight-w600: "evk", ...), so the - # name is not a signal, but the keys are. - strip: bool = False - - @classmethod - def from_api( - cls, - state: dict[str, Any], - info: dict[str, Any] | None = None, - segments: list[dict[str, Any]] | None = None, - ) -> NinlightState: - color = state.get("color") or {} - caps = state.get("capabilities") or {} - ct = caps.get("color_temp") or [2700, 6500] - wifi = (info or {}).get("wifi") or {} - seg = (segments or [None])[0] or {} - # `segment_count` and `product` exist only on ninstrip firmware; the bulb's - # device_type is a board id and carries no signal either way. - strip = "segment_count" in state or "product" in (info or {}) - # On a strip the light lives in segment 0; mode "off" carries no color at all, - # so mode/color fall back to white there and is_on decides darkness anyway. - seg_mode = seg.get("mode") - return cls( - on=bool(state.get("on", False)), - brightness=int(state.get("brightness", 0)), - mode="color" if seg_mode == "solid" else str(state.get("mode", "white")), - r=int((seg.get("color") or color).get("r", 0)), - g=int((seg.get("color") or color).get("g", 0)), - b=int((seg.get("color") or color).get("b", 0)), - color_temp=int(seg.get("color_temp", state.get("color_temp", 2700))), - ct_min=int(ct[0]), - ct_max=int(ct[1]), - has_rgb=bool(caps.get("color", True)) if strip else bool(caps.get("color", False)), - power_on_behaviour=str(state.get("power_on_behaviour", "last")), - fade_in=int(state.get("fade_in", 0)), - fade_out=int(state.get("fade_out", 0)), - transition_default=int(state.get("transition_default", 0)), - wake_on_change=bool(state.get("wake_on_change", False)), - device_type=(info or {}).get("device_type"), - product_name=(info or {}).get("product_name"), - fw_version=(info or {}).get("fw_version"), - mac=(info or {}).get("mac"), - name=(info or {}).get("name"), - rssi=wifi.get("rssi"), - ip=wifi.get("sta_ip") or wifi.get("ip"), - segments=list(segments or []), - strip=strip, - ) - - -class NinlightClient: - """One client per bulb (per config entry).""" - - def __init__( - self, - host: str, - token: str, - *, - port: int = DEFAULT_PORT, - session: httpx.AsyncClient | None = None, - ) -> None: - self._base = f"http://{host}:{port}/api/v1" - # Raw token, not "Bearer ": nl_http_parse hands the header value straight to - # token_ok(), which compares it against the stored token byte for byte. - self._headers = {"Authorization": token} - self._owns_session = session is None - self._session: httpx.AsyncClient = session or httpx.AsyncClient( - headers=self._headers, - timeout=httpx.Timeout(8.0, connect=4.0), - # keepalive_expiry of ~0 makes httpx close after each response instead of - # holding one of the bulb's four listen-queue slots open between polls. - limits=httpx.Limits(max_connections=1, max_keepalive_connections=0, keepalive_expiry=0.1), - ) - - async def close(self) -> None: - if self._owns_session: - await self._session.aclose() - - async def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]: - try: - resp = await self._session.request(method, f"{self._base}{path}", json=payload) - except httpx.InvalidURL as err: # programming error, not a transient one - raise NinlightError(f"invalid bulb URL: {err}") from err - except httpx.HTTPError as err: - raise NinlightError(f"request to bulb failed: {err}") from err - - if resp.status_code == 401: - raise NinlightAuthError("bulb rejected the API token") - if resp.status_code == 400: - raise NinlightBadRequest("bulb rejected the request", _error_field(resp)) - if resp.status_code != 200: - raise NinlightError(f"bulb answered {resp.status_code}: {_error_field(resp)}") - try: - data = resp.json() - except ValueError as err: - raise NinlightError("bulb sent a non-JSON body") from err - if not isinstance(data, dict): - raise NinlightError("bulb sent an unexpected JSON shape") - return data - - async def get_state(self) -> dict[str, Any]: - return await self._request("GET", "/device/state") - - async def get_segments(self) -> dict[str, Any]: - """Segment model of the ninstrip firmware (bulbs have no such endpoint).""" - return await self._request("GET", "/device/segments") - - async def set_segments(self, segments: list[dict[str, Any]]) -> dict[str, Any]: - """Replace the whole segment list. ninstrip answers with `{"status": "ok"}`.""" - return await self._request("PUT", "/device/segments", {"segments": segments}) - - async def patch_segment(self, index: int, patch: dict[str, Any]) -> dict[str, Any]: - """Update one segment; omitted fields keep their current value.""" - return await self._request("PUT", f"/device/segments/{index}", patch) - - async def get_info(self) -> dict[str, Any]: - return await self._request("GET", "/system/info") - - async def get_settings(self) -> dict[str, Any]: - """`system/settings` is the one endpoint that always requires the token. - - `system/info` and a state GET answer without auth unless `auth_all` is set - (nl_api.c: the state branch tests `s.auth_all && !authed`, the info branch tests - nothing), so only settings proves the token is right. - """ - return await self._request("GET", "/system/settings") - - async def set_state(self, payload: dict[str, Any]) -> dict[str, Any]: - return await self._request("POST", "/device/state", payload) - - async def ping(self) -> dict[str, Any]: - return await self._request("PUT", "/system/command", {"command": {"type": "ping"}}) - - async def reboot(self) -> dict[str, Any]: - return await self._request("PUT", "/system/command", {"command": {"type": "reboot"}}) - - -def _error_field(resp: httpx.Response) -> str | None: - try: - body = resp.json() - except ValueError: - return resp.text[:120] or None - if isinstance(body, dict): - err = body.get("error") - return str(err) if err is not None else None - return None diff --git a/ha-integration/custom_components/ninlight/config_flow.py b/ha-integration/custom_components/ninlight/config_flow.py deleted file mode 100644 index b7b213a..0000000 --- a/ha-integration/custom_components/ninlight/config_flow.py +++ /dev/null @@ -1,166 +0,0 @@ -"""Config flow for Ninlight.""" - -from __future__ import annotations - -import logging -from typing import Any - -import voluptuous as vol - -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult -from homeassistant.data_entry_flow import AbortFlow -from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo - -from .client import NinlightAuthError, NinlightClient, NinlightError -from .const import CONF_TOKEN, DEFAULT_PORT, DEFAULT_TOKEN, DOMAIN - -_LOGGER = logging.getLogger(__name__) - -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required("host"): str, - vol.Optional("port", default=DEFAULT_PORT): vol.All(vol.Coerce(int), vol.Range(1, 65535)), - vol.Optional(CONF_TOKEN, default=DEFAULT_TOKEN): vol.All(str, vol.Length(min=1, max=64)), - } -) - - -def _normalize_mac(mac: str) -> str: - """Keep one canonical form for the unique id (zeroconf and system/info both use `aa:bb:...`).""" - return mac.strip().lower() - - -def _is_full_mac(mac: str) -> bool: - """True for a colon-separated 12-hex-digit MAC, the only TXT form usable as unique id. - - ninstrip devices publish the 4-byte suffix (`eb4b98`, nl_wifi.c's mac4) in their TXT - record; two bulbs sharing a last octet would collide, so those need a probe instead. - """ - parts = mac.strip().lower().split(":") - return len(parts) == 6 and all(len(p) == 2 and all(c in "0123456789abcdef" for c in p) for p in parts) - - -class NinlightConfigFlow(ConfigFlow, domain=DOMAIN): - """Handle a config flow for Ninlight.""" - - VERSION = 1 - - async def _probe_mac(self, host: str, port: int) -> str: - """Return the bulb's MAC. Needs no token: `system/info` is unauthenticated.""" - client = NinlightClient(host, DEFAULT_TOKEN, port=port) - try: - info = await client.get_info() - finally: - await client.close() - mac = info.get("mac") - if not isinstance(mac, str) or not mac: - raise NinlightError("bulb did not report a MAC address") - return _normalize_mac(mac) - - async def _probe_mac_and_token(self, host: str, port: int, token: str) -> str: - """As above, plus verify the token against `system/settings`. - - Only that endpoint authenticates, so a probe that stops at system/info would - happily accept a wrong token (nl_api.c's info branch never checks `authed`). - """ - client = NinlightClient(host, token, port=port) - try: - info = await client.get_info() - await client.get_settings() - finally: - await client.close() - mac = info.get("mac") - if not isinstance(mac, str) or not mac: - raise NinlightError("bulb did not report a MAC address") - return _normalize_mac(mac) - - async def _entry_from_data(self, data: dict[str, Any], title: str) -> ConfigFlowResult: - await self.async_set_unique_id(data["mac"]) - # Addresses come from DHCP, so rediscovering a known MAC at a new IP must move the - # existing entry rather than abort a re-add. - self._abort_if_unique_id_configured(updates=data) - return self.async_create_entry(title=title, data=data) - - async def async_step_user(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult: - errors: dict[str, str] = {} - if user_input is not None: - host = user_input["host"].strip() - port = int(user_input["port"]) - token = user_input[CONF_TOKEN].strip() - try: - mac = await self._probe_mac_and_token(host, port, token) - except NinlightAuthError: - errors[CONF_TOKEN] = "invalid_auth" - except NinlightError: - errors["base"] = "cannot_connect" - else: - return await self._entry_from_data( - {"host": host, "port": port, CONF_TOKEN: token, "mac": mac}, title=host - ) - - return self.async_show_form(step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors) - - async def async_step_zeroconf( - self, discovery_info: ZeroconfServiceInfo - ) -> ConfigFlowResult: - """Handle discovery of a `_ninlight._tcp` service.""" - props = {str(k).lower(): str(v) for k, v in discovery_info.properties.items()} - port = discovery_info.port or DEFAULT_PORT - mac = props.get("mac", "") - # A TXT record may hold no MAC at all (bulb still announcing) or only the short - # 4-byte suffix form ninstrip publishes (`eb4b98`). Unique ids are full MACs, so - # anything that is not one colon-form is replaced by a probe of system/info. - if not _is_full_mac(mac): - try: - mac = await self._probe_mac(discovery_info.host, port) - except NinlightError: - raise AbortFlow("cannot_connect") from None - mac = _normalize_mac(mac) - - await self.async_set_unique_id(mac) - updates = { - "host": discovery_info.host, - "port": port, - "mac": mac, - "device_type": props.get("type", ""), - } - self._abort_if_unique_id_configured(updates=updates) - - # Instance name is `ninlight-` until someone names the bulb; a name set on - # the device is published in the TXT record and wins. - title = props.get("name") or discovery_info.name.split(".", 1)[0] - self.context["title_placeholders"] = {"name": title} - # Create rather than prompt. A confirm step would leave the flow parked in - # `in_progress`, where it blocks a manual add of the same bulb with - # already_in_progress - and a bulb that is reachable but not announcing mDNS - # (two of ours) has to be added manually, so that collision is not theoretical. - return self.async_create_entry(title=title, data={**updates, CONF_TOKEN: DEFAULT_TOKEN}) - - async def async_step_reauth(self, entry_data: dict[str, Any]) -> ConfigFlowResult: - return await self.async_step_reauth_confirm() - - async def async_step_reauth_confirm( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) - assert entry is not None - errors: dict[str, str] = {} - if user_input is not None: - token = user_input[CONF_TOKEN].strip() - try: - await self._probe_mac_and_token(entry.data["host"], entry.data.get("port", DEFAULT_PORT), token) - except NinlightAuthError: - errors[CONF_TOKEN] = "invalid_auth" - except NinlightError: - errors["base"] = "cannot_connect" - else: - self.hass.config_entries.async_update_entry(entry, data={**entry.data, CONF_TOKEN: token}) - await self.hass.config_entries.async_reload(entry.entry_id) - return self.async_abort(reason="reauth_successful") - - return self.async_show_form( - step_id="reauth_confirm", - data_schema=vol.Schema({vol.Required(CONF_TOKEN, default=entry.data.get(CONF_TOKEN, "")): str}), - errors=errors, - description_placeholders={"name": entry.title}, - ) diff --git a/ha-integration/custom_components/ninlight/const.py b/ha-integration/custom_components/ninlight/const.py deleted file mode 100644 index ad76317..0000000 --- a/ha-integration/custom_components/ninlight/const.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Constants for the Ninlight integration.""" - -from __future__ import annotations - -from datetime import timedelta -from typing import Final - -from homeassistant.const import Platform - -DOMAIN = "ninlight" - -PLATFORMS: Final = [Platform.LIGHT] - -CONF_TOKEN = "token" - -DEFAULT_PORT = 3333 -DEFAULT_TOKEN = "aabbccddeeff0011223344" - -# The firmware has no push channel, so state is polled. A bulb answers in well under -# a second on the LAN; 30 s keeps the UI honest without adding noticeable REST traffic -# for a nine bulb setup. -SCAN_INTERVAL = timedelta(seconds=30) - -# Mirror of NL_MAX_TRANSITION_MS in src/light/nl_control.h. -MAX_TRANSITION_MS = 60000 diff --git a/ha-integration/custom_components/ninlight/coordinator.py b/ha-integration/custom_components/ninlight/coordinator.py deleted file mode 100644 index 39bfcbf..0000000 --- a/ha-integration/custom_components/ninlight/coordinator.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Data coordinator for a single Ninlight bulb.""" - -from __future__ import annotations - -import logging -from contextlib import suppress -from typing import Any, TypeAlias - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -from .client import ( - NinlightAuthError, - NinlightBadRequest, - NinlightClient, - NinlightError, - NinlightState, -) -from .const import CONF_TOKEN, DEFAULT_PORT, DOMAIN, SCAN_INTERVAL - -_LOGGER = logging.getLogger(__name__) - - -class NinlightCoordinator(DataUpdateCoordinator[NinlightState]): - """Polls one bulb. One coordinator, and one HTTP client, per config entry. - - `system/info` is fetched on the first successful refresh only. It carries identity - (MAC, product name, firmware) that does not change while the bulb runs, and the bulb - accepts at most four queued TCP connections (listen(,4) in nl_http_server.c), so - skipping it on every poll keeps one of those slots free for the UI. - """ - - config_entry: NinlightConfigEntry - - def __init__(self, hass: HomeAssistant, entry: NinlightConfigEntry) -> None: - super().__init__( - hass, - _LOGGER, - config_entry=entry, - name=f"{DOMAIN} {entry.title}", - update_interval=SCAN_INTERVAL, - ) - self.client = NinlightClient( - entry.data["host"], - entry.data.get(CONF_TOKEN, ""), - port=entry.data.get("port", DEFAULT_PORT), - ) - self.info: dict[str, Any] | None = None - self._identity_ok = False - self._is_strip = False - - async def async_shutdown(self) -> None: - """Close the HTTP client alongside the coordinator's own teardown.""" - await self.client.close() - await super().async_shutdown() - - async def _async_update_data(self) -> NinlightState: - try: - state = await self.client.get_state() - except NinlightAuthError as err: - # Only possible when the bulb has `auth_all` set; a wrong token will not fix - # itself, so surface a re-auth flow instead of retrying every interval. - raise ConfigEntryAuthFailed(str(err)) from err - except NinlightBadRequest as err: - # A poll sends no body, so this can only mean a firmware we do not match. - raise UpdateFailed(str(err)) from err - except NinlightError as err: - raise UpdateFailed(str(err)) from err - - if not self._identity_ok: - # One extra pair of requests, then never again: the bulb accepts at most four - # queued TCP connections (listen(,4) in nl_http_server.c), so keeping the poll - # path to a single GET leaves slots free for the UI. - with suppress(NinlightError): - # system/info carries identity that only changes on a reboot or an OTA. - self.info = await self.client.get_info() - try: - # system/settings always demands the token, so it is the only call that - # proves ours is right. A failure here on a bulb whose state endpoint - # answered happily means the token is wrong while `auth_all` is off - the - # light would work and silently never be able to be renamed or updated. - await self.client.get_settings() - except NinlightAuthError as err: - raise ConfigEntryAuthFailed(str(err)) from err - # `product` is an ninstrip-only key in system/info (bulbs send - # product_name); once known, the heavier segment poll follows every refresh. - self._is_strip = "product" in (self.info or {}) - self._identity_ok = True - - segments: list[dict[str, Any]] | None = None - if self._is_strip: - # The light lives in the segment list; the plain state GET above carries - # only on/brightness for it. A firmware without the endpoint (misdetected - # strip) must not fail the whole poll for that. - try: - segments = (await self.client.get_segments()).get("segments") - except NinlightError as err: - _LOGGER.debug("segment poll failed: %s", err) - - return NinlightState.from_api(state, self.info, segments) - - -# Declared with TypeAlias (not the 3.12 `type` statement) so the same file lints on the -# project's 3.11 tooling and runs on HA's 3.14. -NinlightConfigEntry: TypeAlias = ConfigEntry[NinlightCoordinator] diff --git a/ha-integration/custom_components/ninlight/entity.py b/ha-integration/custom_components/ninlight/entity.py deleted file mode 100644 index 58941f7..0000000 --- a/ha-integration/custom_components/ninlight/entity.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Base entity for Ninlight devices.""" - -from __future__ import annotations - -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DEFAULT_PORT, DOMAIN -from .coordinator import NinlightCoordinator, NinlightConfigEntry - - -class NinlightEntity(CoordinatorEntity[NinlightCoordinator]): - """Common device info for every entity of one bulb.""" - - _attr_has_entity_name = True - - def __init__(self, coordinator: NinlightCoordinator, entry: NinlightConfigEntry) -> None: - super().__init__(coordinator, context=entry.entry_id) - mac = (entry.unique_id or "unknown").lower() - info = coordinator.info or {} - # Deliberately no `connections={("mac", mac)}`: HA treats a MAC in `connections` - # as a network-topology link, so a bulb the FRITZ!Box tracker already knows merged - # into that router-adjacent device and its entity id fell back to the IP. The MAC - # still identifies the device via unique_id; identifiers are the sole identity here. - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, mac)}, - # The bulb itself has no user-visible name field that survives rediscovery - # cleanly, so the entry title (set from mDNS or by the user) names the device. - name=entry.title, - manufacturer="Klyqa", - model=info.get("product_name") or info.get("product"), - model_id=info.get("device_type"), - sw_version=info.get("fw_version"), - configuration_url=f"http://{entry.data['host']}:{entry.data.get('port', DEFAULT_PORT)}/", - ) - - -def _entry_mac(entry: NinlightConfigEntry) -> str: - mac: str = entry.unique_id or "unknown" - return mac.lower() diff --git a/ha-integration/custom_components/ninlight/light.py b/ha-integration/custom_components/ninlight/light.py deleted file mode 100644 index 74fa1f6..0000000 --- a/ha-integration/custom_components/ninlight/light.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Light platform for Ninlight bulbs.""" - -from __future__ import annotations - -import logging -from typing import Any - -from homeassistant.components.light import ( - ATTR_BRIGHTNESS, - ATTR_COLOR_TEMP_KELVIN, - ATTR_RGB_COLOR, - ATTR_TRANSITION, - ColorMode, - LightEntity, - LightEntityFeature, -) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .client import NinlightBadRequest, NinlightError, NinlightState -from .const import MAX_TRANSITION_MS -from .coordinator import NinlightConfigEntry, NinlightCoordinator -from .entity import NinlightEntity - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry( - hass: HomeAssistant, - entry: NinlightConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the light for a Ninlight bulb config entry.""" - async_add_entities([NinlightLight(entry.runtime_data, entry)]) - - -def _to_ha_brightness(pct: int) -> int: - """Bulb brightness is 1..100; HA brightness is 0..255.""" - return max(1, min(255, round(pct * 255 / 100))) - - -def _to_bulb_brightness(level: int) -> int: - """The bulb rejects brightness 0 and anything above 100 (invalid brightness).""" - return max(1, min(100, round(level * 100 / 255))) - - -class NinlightLight(NinlightEntity, LightEntity): - """A Klyqa bulb running the Ninlight firmware.""" - - # No translation_key: with has_entity_name and a single entity per device, HA names the - # light after the device, giving light.schreibtisch_links rather than ..._bulb. - _attr_name = None - - def __init__(self, coordinator: NinlightCoordinator, entry: NinlightConfigEntry) -> None: - super().__init__(coordinator, entry) - state = coordinator.data - self._attr_unique_id = f"{(entry.unique_id or '').lower()}-light" - # Both modes are advertised for RGB bulbs: the firmware keeps the last color and - # the last color temperature and picks between them with `mode`, which is exactly - # how HA models a color_temp + rgb light. - self._attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.RGB} if state.has_rgb else { - ColorMode.COLOR_TEMP - } - self._attr_supported_features = LightEntityFeature.TRANSITION - self._attr_min_color_temp_kelvin = state.ct_min - self._attr_max_color_temp_kelvin = state.ct_max - - @property - def is_on(self) -> bool: - return self.coordinator.data.on - - @property - def brightness(self) -> int | None: - return _to_ha_brightness(self.coordinator.data.brightness) - - @property - def color_mode(self) -> ColorMode: - state = self.coordinator.data - if not state.on: - # Nothing is lit; reporting a mode would contradict is_on=False. - return ColorMode.UNKNOWN - if state.has_rgb and state.mode == "color": - return ColorMode.RGB - return ColorMode.COLOR_TEMP - - @property - def color_temp_kelvin(self) -> int | None: - state = self.coordinator.data - return state.color_temp if self.color_mode == ColorMode.COLOR_TEMP else None - - @property - def rgb_color(self) -> tuple[int, int, int] | None: - state = self.coordinator.data - if self.color_mode == ColorMode.RGB: - return (state.r, state.g, state.b) - return None - - async def async_turn_on(self, **kwargs: Any) -> None: - state = self.coordinator.data - payload: dict[str, Any] = {"on": True} - - if (level := kwargs.get(ATTR_BRIGHTNESS)) is not None: - payload["brightness"] = _to_bulb_brightness(int(level)) - elif not state.on: - # HA expects turn_on without a brightness to restore the previous level. The - # firmware already does that (it keeps `brightness` while off), so sending it - # would only re-assert a value the bulb may have clamped itself - but a bulb - # that has never been on reports brightness 0, which its API rejects. Mirror - # the last known value only when the bulb actually has one. - if state.brightness < 1: - payload["brightness"] = 100 - - # The firmware rejects color and color_temp in one request with - # "color and color_temp are exclusive", so pick one. HA only sends both when a - # caller passes them together; color is the more specific intent and wins. - kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) - rgb = kwargs.get(ATTR_RGB_COLOR) - seg: dict[str, Any] | None = None - if rgb is not None and state.has_rgb: - r, g, b = (int(max(0, min(255, v))) for v in rgb) - if state.strip: - seg = {"mode": "solid", "color": {"r": r, "g": g, "b": b}} - else: - payload["color"] = {"r": r, "g": g, "b": b} - elif kelvin is not None: - ct = max(state.ct_min, min(state.ct_max, int(kelvin))) - if state.strip: - seg = {"mode": "white", "color_temp": ct} - else: - payload["color_temp"] = ct - - if (transition := kwargs.get(ATTR_TRANSITION)) is not None: - payload["transition"] = max(0, min(MAX_TRANSITION_MS, int(float(transition) * 1000))) - - await self._apply(payload, seg) - - async def async_turn_off(self, **kwargs: Any) -> None: - payload: dict[str, Any] = {"on": False} - if (transition := kwargs.get(ATTR_TRANSITION)) is not None: - payload["transition"] = max(0, min(MAX_TRANSITION_MS, int(float(transition) * 1000))) - await self._apply(payload, None) - - async def _apply(self, payload: dict[str, Any], seg_patch: dict[str, Any] | None) -> None: - """Send one command and update immediately. - - POST /device/state answers with the full new state, so the coordinator does not - need a refresh round-trip and the UI settles on the real bulb state at once. - - On the segment-model firmware the color goes into segment 0 as a separate PATCH - (`PUT /device/segments/0`, fields omitted keep their value); the master on and - brightness stay in the state POST, because the firmware's fade rides on them. - Segment brightness stays untouched so master scaling remains the single knob. - """ - try: - data = await self.coordinator.client.set_state(payload) - if seg_patch is not None: - segs = self.coordinator.data.segments - start = segs[0]["start"] if segs else 0 - end = segs[0]["end"] if segs else max(0, int(data.get("pixel_count", 1)) - 1) - await self.coordinator.client.patch_segment( - 0, {**seg_patch, "start": start, "end": end} - ) - except NinlightBadRequest as err: - # A rejected command is a bug or a firmware mismatch, not a network fault: - # surface it to the caller and leave the entity's state alone. - raise HomeAssistantError(str(err)) from err - except NinlightError as err: - self.coordinator.async_set_update_error(err) - raise HomeAssistantError(str(err)) from err - - # Push the device's own reply through the coordinator so every entity of this - # device (light, sensors) settles on the real state at once. - segments = None - if self.coordinator.data.strip: - try: - segments = (await self.coordinator.client.get_segments()).get("segments") - except NinlightError: - pass # the next poll recovers it; the state reply above is still right - self.coordinator.async_set_updated_data( - NinlightState.from_api(data, self.coordinator.info, segments) - ) diff --git a/ha-integration/custom_components/ninlight/manifest.json b/ha-integration/custom_components/ninlight/manifest.json deleted file mode 100644 index fe1a1c4..0000000 --- a/ha-integration/custom_components/ninlight/manifest.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "domain": "ninlight", - "name": "Ninlight", - "codeowners": [], - "config_flow": true, - "documentation": "https://github.com/ninharp/ninlight-w600", - "integration_type": "device", - "iot_class": "local_polling", - "requirements": [], - "version": "0.1.0", - "zeroconf": ["_ninlight._tcp.local.", "_ninstrip._tcp.local."] -} diff --git a/ha-integration/custom_components/ninlight/strings.json b/ha-integration/custom_components/ninlight/strings.json deleted file mode 100644 index b91fb9f..0000000 --- a/ha-integration/custom_components/ninlight/strings.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "config": { - "steps": { - "user": { - "title": "Ninlight bulb", - "description": "Connect to a bulb running the Ninlight firmware. Use the address shown in your router, or `ninlight-.local`.", - "data": { - "host": "Host", - "port": "Port", - "token": "API token" - }, - "data_description": { - "token": "The bulb's api.token setting. Defaults to the firmware's built-in token." - } - }, - "reauth_confirm": { - "title": "Reauthenticate {name}", - "description": "The bulb rejected the stored API token. Enter its current token.", - "data": { - "token": "API token" - } - } - }, - "error": { - "cannot_connect": "Could not reach the bulb on port 3333. Check the address and that the bulb is powered.", - "invalid_auth": "Wrong API token." - }, - "abort": { - "already_configured": "This bulb is already configured.", - "already_in_progress": "A setup flow for this bulb is already running.", - "cannot_connect": "Could not reach the bulb.", - "reauth_successful": "The API token was updated." - } - } -} diff --git a/ha-integration/custom_components/ninlight/translations/en.json b/ha-integration/custom_components/ninlight/translations/en.json deleted file mode 100644 index b91fb9f..0000000 --- a/ha-integration/custom_components/ninlight/translations/en.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "config": { - "steps": { - "user": { - "title": "Ninlight bulb", - "description": "Connect to a bulb running the Ninlight firmware. Use the address shown in your router, or `ninlight-.local`.", - "data": { - "host": "Host", - "port": "Port", - "token": "API token" - }, - "data_description": { - "token": "The bulb's api.token setting. Defaults to the firmware's built-in token." - } - }, - "reauth_confirm": { - "title": "Reauthenticate {name}", - "description": "The bulb rejected the stored API token. Enter its current token.", - "data": { - "token": "API token" - } - } - }, - "error": { - "cannot_connect": "Could not reach the bulb on port 3333. Check the address and that the bulb is powered.", - "invalid_auth": "Wrong API token." - }, - "abort": { - "already_configured": "This bulb is already configured.", - "already_in_progress": "A setup flow for this bulb is already running.", - "cannot_connect": "Could not reach the bulb.", - "reauth_successful": "The API token was updated." - } - } -} diff --git a/qconnex-listen/dnsmasq-qcx.conf b/qconnex-listen/dnsmasq-qcx.conf deleted file mode 100644 index ce6e39c..0000000 --- a/qconnex-listen/dnsmasq-qcx.conf +++ /dev/null @@ -1,13 +0,0 @@ -# dnsmasq for the qconnex-firmware listener (no service integration) -interface=wlan0 -bind-interfaces -listen-address=192.168.8.1 -no-resolv -server=192.168.178.1 -address=/#/192.168.8.1 -dhcp-range=192.168.8.10,192.168.8.200,255.255.255.0,10m -dhcp-option=3,192.168.8.1 -dhcp-option=6,192.168.8.1 -log-dhcp -log-queries -no-ping diff --git a/qconnex-listen/down.sh b/qconnex-listen/down.sh deleted file mode 100755 index 5e6cd5d..0000000 --- a/qconnex-listen/down.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -# Tear down the qconnex-firmware migration listener. -set -u -HERE=$(cd "$(dirname "$0")" && pwd) -SUDO=${SUDO:-sudo -n} -[ -f "$HERE/otaserve.pid" ] && $SUDO pkill -f "python3 $HERE/otaserve.py" 2>/dev/null; rm -f "$HERE/otaserve.pid" -[ -f "$HERE/dnsmasq.pid" ] && $SUDO kill "$(cat "$HERE/dnsmasq.pid")" 2>/dev/null && rm -f "$HERE/dnsmasq.pid" -$SUDO pkill -f "hostapd -B -f $HERE/hostapd.log" 2>/dev/null -$SUDO ip addr flush dev wlan0 2>/dev/null -$SUDO nmcli dev set wlan0 managed yes 2>/dev/null || true -echo "listener down" diff --git a/qconnex-listen/hostapd.conf b/qconnex-listen/hostapd.conf deleted file mode 100644 index 5a44e3d..0000000 --- a/qconnex-listen/hostapd.conf +++ /dev/null @@ -1,15 +0,0 @@ -# qconnex-firmware listener AP — temporary experiment, do not enable as a service -interface=wlan0 -driver=nl80211 -ssid=qconnex-firmware -hw_mode=g -channel=6 -ieee80211n=0 -auth_algs=1 -# WPA2 with the password the firmware's bootup-OTA STA config expects. -# (Open AP worked for the W600 bulbs; ESP32-C3 STA threshold is WPA_PSK.) -wpa=2 -wpa_passphrase=qconnex-firmware -wpa_pairwise=CCMP -rsn_pairwise=CCMP -wmm_enabled=0 diff --git a/qconnex-listen/otaserve.py b/qconnex-listen/otaserve.py deleted file mode 100644 index 6d1627a..0000000 --- a/qconnex-listen/otaserve.py +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env python3 -"""Serve qcx OTA images from ./images with HTTP Range support, plus request logging.""" -import http.server, os, re, sys, datetime - -ROOT = os.path.join(sys.path[0], "images") -LOG = os.path.join(sys.path[0], "qcx-requests.log") - - -class H(http.server.BaseHTTPRequestHandler): - protocol_version = "HTTP/1.1" - - def log_message(self, *a): - pass - - def do_GET(self): - path = self.path.lstrip("/") - with open(LOG, "a") as f: - f.write("=== GET %s (%s) %s\nPATH: %s\n" % (datetime.datetime.now(), self.client_address[0], - dict(self.headers), path)) - fp = os.path.join(ROOT, path) - # any bootup-OTA request for a qcx image that doesn't exist on purpose - # (unknown product/version combo) -> serve the ninlight migrate image - if not os.path.isfile(fp) and path.startswith("qcx_firmware_"): - if "ledshield" in path: - guess = "ninlight_ledshield_rgb_migrate_dev.img" - elif "rgbc3" in path: - guess = "ninlight_ninstrip-rgbc3_migrate_dev.img" - elif ".gu10" in path: - guess = "ninlight_gu10_rgb_migrate_dev.img" - else: - guess = "ninlight_e27_rgb_migrate_dev.img" - fp = os.path.join(ROOT, guess) - if not os.path.isfile(fp): - p = b"not found\n" - self.send_response(404) - self.send_header("Content-Length", str(len(p))) - self.end_headers() - self.wfile.write(p) - return - size = os.path.getsize(fp) - start, rng = 0, self.headers.get("Range") - if rng and (m := re.match(r"bytes=(\d+)-(\d*)$", rng)): - start = int(m.group(1)) - end = int(m.group(2)) if m.group(2) else size - 1 - else: - end = size - 1 - end = min(end, size - 1) - length = max(0, end - start + 1) - self.send_response(206 if rng else 200) - self.send_header("Content-Length", str(length)) - self.send_header("Content-Type", "application/octet-stream") - if rng: - self.send_header("Content-Range", "bytes %d-%d/%d" % (start, end, size)) - self.end_headers() - with open(fp, "rb") as f: - f.seek(start) - left = length - while left > 0: - chunk = f.read(min(8192, left)) - if not chunk: - break - self.wfile.write(chunk) - left -= len(chunk) - print("served %s %d-%d to %s" % (path, start, end, self.client_address[0]), flush=True) - - do_POST = do_HEAD = do_PUT = do_GET - - -if __name__ == "__main__": - os.makedirs(ROOT, exist_ok=True) - print("qcx ota server on 0.0.0.0:80 root=%s" % ROOT, flush=True) - http.server.ThreadingHTTPServer(("0.0.0.0", 80), H).serve_forever() diff --git a/qconnex-listen/up.sh b/qconnex-listen/up.sh deleted file mode 100755 index 6259894..0000000 --- a/qconnex-listen/up.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -# Bring up the qconnex-firmware migration listener (AP + DHCP/DNS + OTA server). -# Takes wlan0 as an open AP "qconnex-firmware" on 192.168.8.1. Machine stays -# online via ethernet. Teardown: ./down.sh -set -euo pipefail -HERE=$(cd "$(dirname "$0")" && pwd) -SUDO=${SUDO:-sudo -n} - -# wlan0 must be free of any NetworkManager claim -$SUDO nmcli dev set wlan0 managed no 2>/dev/null || true -$SUDO ip link set wlan0 up -$SUDO ip addr replace 192.168.8.1/24 dev wlan0 - -$SUDO dnsmasq --conf-file="$HERE/dnsmasq-qcx.conf" --pid-file="$HERE/dnsmasq.pid" -$SUDO hostapd -B -f "$HERE/hostapd.log" "$HERE/hostapd.conf" -sleep 1 - -# serve images on :80 (needs root to bind <1024) -nohup $SUDO python3 "$HERE/otaserve.py" >/dev/null 2>&1 & -echo $! > "$HERE/otaserve.pid" - -echo "AP qconnex-firmware up on 192.168.8.1; serving $HERE/images (log: $HERE/qcx-requests.log)" diff --git a/tools/tests/test_release.sh b/tools/tests/test_release.sh index ed2949d..b7c07ff 100755 --- a/tools/tests/test_release.sh +++ b/tools/tests/test_release.sh @@ -410,9 +410,14 @@ else fi echo "--- Meldung der Zugangsdatenpruefung nennt Dateien und entpackte Abbilder ---" -# 8 ota + 8 flash + 16 migrate + bruno/ninlight.yml + README.md + onboard.sh = 35, -# davon 32 Abbilder. SHA256SUMS entsteht erst nach der Pruefung. -if echo "$OUT" | grep -qF "sauber (35 Dateien geprueft, davon 32 Abbilder entpackt)"; then +# 9 ota + 9 flash + 18 migrate + bruno/ninlight.yml + README.md + onboard.sh = 39, +# davon 36 Abbilder. SHA256SUMS entsteht erst nach der Pruefung. +# +# Die Zahlen haengen an der Laenge von TARGETS: mit ledshield_rgb kamen vier +# Dateien dazu (je ein _ota, ein _gz und zwei signierte Migrationsabzuege), alle +# vier sind Abbilder. Wer ein Ziel ergaenzt, passt sie hier mit an - der Merge +# des Shield-Zweigs lief konfliktfrei durch und liess trotzdem 35/32 stehen. +if echo "$OUT" | grep -qF "sauber (39 Dateien geprueft, davon 36 Abbilder entpackt)"; then pass "Meldung nennt Dateizahl und Zahl der entpackten Abbilder" else fail "Meldung der Zugangsdatenpruefung unerwartet: $(echo "$OUT" | grep -i sauber || echo '(keine sauber-Zeile)')"