diff --git a/CMakeLists.txt b/CMakeLists.txt index 47c59aa..38a08bd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -10,7 +10,7 @@ if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Release) endif() -set(NINLIGHT_DEVICES evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw) +set(NINLIGHT_DEVICES evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb) set(NINLIGHT_DEVICE "" CACHE STRING "Target device") if(NOT NINLIGHT_DEVICE IN_LIST NINLIGHT_DEVICES) message(FATAL_ERROR "Set -DNINLIGHT_DEVICE=") diff --git a/IDEA.md b/IDEA.md new file mode 100644 index 0000000..6e136fe --- /dev/null +++ b/IDEA.md @@ -0,0 +1 @@ +An open source replacement firmware for the no longer supported Klyqa smart wifi light bulbs based on the Winner Micro W600 chip diff --git a/README.md b/README.md index 8cc739a..34e2f07 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Native Builds (mit lokal installierter ARM-Toolchain): cmake -S . -B build/evk -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake -DNINLIGHT_DEVICE=evk cmake --build build/evk -**Geräte:** evk, e27_rgb, e14_rgb, gu10_rgb, e27_cw, e14_cw, gu10_cw, g95_cw. +**Geräte:** evk, e27_rgb, e14_rgb, gu10_rgb, e27_cw, e14_cw, gu10_cw, g95_cw, ledshield_rgb (Montana Black LED Shield, nur RGB). **Lokale Einstellungen** (Token, später WLAN): `config.local.cmake.example` → `config.local.cmake`. diff --git a/ha-integration/custom_components/ninlight/__init__.py b/ha-integration/custom_components/ninlight/__init__.py new file mode 100644 index 0000000..8d3b252 --- /dev/null +++ b/ha-integration/custom_components/ninlight/__init__.py @@ -0,0 +1,54 @@ +"""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 new file mode 100644 index 0000000..aa9051f --- /dev/null +++ b/ha-integration/custom_components/ninlight/client.py @@ -0,0 +1,214 @@ +"""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 new file mode 100644 index 0000000..b7b213a --- /dev/null +++ b/ha-integration/custom_components/ninlight/config_flow.py @@ -0,0 +1,166 @@ +"""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 new file mode 100644 index 0000000..ad76317 --- /dev/null +++ b/ha-integration/custom_components/ninlight/const.py @@ -0,0 +1,25 @@ +"""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 new file mode 100644 index 0000000..39bfcbf --- /dev/null +++ b/ha-integration/custom_components/ninlight/coordinator.py @@ -0,0 +1,107 @@ +"""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 new file mode 100644 index 0000000..58941f7 --- /dev/null +++ b/ha-integration/custom_components/ninlight/entity.py @@ -0,0 +1,40 @@ +"""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 new file mode 100644 index 0000000..74fa1f6 --- /dev/null +++ b/ha-integration/custom_components/ninlight/light.py @@ -0,0 +1,182 @@ +"""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 new file mode 100644 index 0000000..fe1a1c4 --- /dev/null +++ b/ha-integration/custom_components/ninlight/manifest.json @@ -0,0 +1,12 @@ +{ + "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 new file mode 100644 index 0000000..b91fb9f --- /dev/null +++ b/ha-integration/custom_components/ninlight/strings.json @@ -0,0 +1,35 @@ +{ + "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 new file mode 100644 index 0000000..b91fb9f --- /dev/null +++ b/ha-integration/custom_components/ninlight/translations/en.json @@ -0,0 +1,35 @@ +{ + "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/.gitignore b/qconnex-listen/.gitignore new file mode 100644 index 0000000..dfaf0cc --- /dev/null +++ b/qconnex-listen/.gitignore @@ -0,0 +1,5 @@ +# Laufzeitarteifakte +qcx-requests.log +hostapd.log +*.pid +images/ diff --git a/qconnex-listen/bulb-map.txt b/qconnex-listen/bulb-map.txt new file mode 100644 index 0000000..bb54cbe --- /dev/null +++ b/qconnex-listen/bulb-map.txt @@ -0,0 +1,11 @@ +# Ninlight Bulbs — MAC → Raum (identifiziert 2026-09-21) +f7ce Küche (grün) +fd1e Küche (rot) +fadb Küche (magenta; erster Converter vom 20.9.) +f860 Schlafzimmer +faed Schlafzimmer +fb38 Schlafzimmer (blau) +63e9 Sofa (hellblau) +10a7 unbekannt (on:false, IP .29) +1fe9 unbekannt (on:false, IP .31) +f960 Montana Black LED-Shield (28:6d:ce:3c:f9:60, ledshield_rgb, 25.9. migriert) diff --git a/qconnex-listen/dnsmasq-qcx.conf b/qconnex-listen/dnsmasq-qcx.conf new file mode 100644 index 0000000..ce6e39c --- /dev/null +++ b/qconnex-listen/dnsmasq-qcx.conf @@ -0,0 +1,13 @@ +# 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 new file mode 100755 index 0000000..5e6cd5d --- /dev/null +++ b/qconnex-listen/down.sh @@ -0,0 +1,11 @@ +#!/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 new file mode 100644 index 0000000..5a44e3d --- /dev/null +++ b/qconnex-listen/hostapd.conf @@ -0,0 +1,15 @@ +# 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/logserver.py b/qconnex-listen/logserver.py new file mode 100644 index 0000000..241e5b4 --- /dev/null +++ b/qconnex-listen/logserver.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Logging HTTP server for the qconnex-firmware listener experiment. + +Logs every request (method, path, headers, body snippet) to qcx-requests.log +and answers 404 for everything. No images served yet — this run only watches +what the original Klyqa firmware asks for after joining the AP. +""" +import http.server +import sys + +LOG = sys.path[0] + "/qcx-requests.log" + + +class Handler(http.server.BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def log_message(self, fmt, *args): # keep our own log format only + pass + + def handle_one_request_log(self): + body = b"" + n = int(self.headers.get("Content-Length") or 0) + if n: + body = self.rfile.read(min(n, 2048)) + with open(LOG, "a") as f: + f.write("=== %s\n" % self.command) + f.write("url: %s\n" % self.path) + f.write("client: %s:%s\n" % self.client_address) + for k, v in self.headers.items(): + f.write("%s: %s\n" % (k, v)) + if body: + f.write("body: %r\n" % body) + f.flush() + print("%s %s %s" % (self.command, self.client_address[0], self.path), flush=True) + payload = b"not found\n" + self.send_response(404) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + do_GET = do_POST = do_HEAD = do_PUT = do_DELETE = do_OPTIONS = handle_one_request_log + + +if __name__ == "__main__": + srv = http.server.ThreadingHTTPServer(("0.0.0.0", 80), Handler) + print("qcx log server on 0.0.0.0:80", flush=True) + srv.serve_forever() diff --git a/qconnex-listen/onboard.py b/qconnex-listen/onboard.py new file mode 100644 index 0000000..328198d --- /dev/null +++ b/qconnex-listen/onboard.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Onboard a qconnex/KLYQA bulb via its softAP TLS onboarding socket (port 2223). + +Usage: onboard.py [access-token] + +Wire framing: every message is a 4-byte header (u16 BE payload length + 2 +reserved bytes) followed by the JSON payload. Sends +{"type":"configure","ssid":"","password":"***","access_token":...} +and prints replies. Device IP is 10.42.42.10 while its KLYQA-xxxxxx softAP is up. +""" +import socket, ssl, sys, json, struct + +def frame(obj): + b = json.dumps(obj).encode() + return struct.pack(">HH", len(b), 0) + b + +def main(): + ip, ssid, pswd = sys.argv[1], sys.argv[2], sys.argv[3] + token = sys.argv[4] if len(sys.argv) > 4 else "aabbccddeeff0011223344" + + ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + ctx.options |= 0x4 # OP_LEGACY_SERVER_CONNECT (mbedTLS server) + + s = ctx.wrap_socket(socket.create_connection((ip, 2223), timeout=10)) + print(f"connected {ip}:2223 {s.cipher()[0]}", file=sys.stderr) + + buf = b"" + def drain(sec, label): + nonlocal buf + s.settimeout(sec) + n = 0 + try: + while True: + chunk = s.recv(4096) + if not chunk: break + buf += chunk + while len(buf) >= 4: + ln = struct.unpack(">H", buf[:2])[0] + if len(buf) < 4 + ln: break + msg, buf = buf[4+ln:], buf[4+ln:] + n += 1 + print(f"{label}{msg.decode(errors='replace')[:400]}") + except socket.timeout: + pass + return n + + drain(2, "recv: ") + cfg = {"type": "configure", + "ssid": ssid.encode().hex(), + "password": pswd.encode().hex(), + "access_token": token} + s.sendall(frame(cfg)) + print("sent configure", file=sys.stderr) + if not drain(10, "recv: "): + print("no reply (device may just proceed to join the WLAN)", file=sys.stderr) + s.close() + +if __name__ == "__main__": + main() diff --git a/qconnex-listen/otaserve.py b/qconnex-listen/otaserve.py new file mode 100644 index 0000000..6d1627a --- /dev/null +++ b/qconnex-listen/otaserve.py @@ -0,0 +1,72 @@ +#!/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/raw3333.py b/qconnex-listen/raw3333.py new file mode 100644 index 0000000..5797656 --- /dev/null +++ b/qconnex-listen/raw3333.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Dump raw bytes clients send to 192.168.8.1:3333 (what would ninlight poll?).""" +import socket, sys, datetime, binascii +s = socket.socket() +s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +s.bind(("192.168.8.1", 3333)) +s.listen(8) +print("raw 3333 listener", flush=True) +while True: + c, a = s.accept() + try: + c.settimeout(5) + data = b"" + try: + while len(data) < 65536: + ch = c.recv(2048) + if not ch: + break + data += ch + except socket.timeout: + pass + print("=== %s %s %d bytes\n%s" % (datetime.datetime.now(), a[0], len(data), repr(data)), flush=True) + finally: + c.close() diff --git a/qconnex-listen/tlssniff.py b/qconnex-listen/tlssniff.py new file mode 100644 index 0000000..0317752 --- /dev/null +++ b/qconnex-listen/tlssniff.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""TCP 443 sniffer: accept, read first bytes, log hex+printable, close. + +For an HTTPS client this captures the TLS ClientHello in plaintext — the SNI +extension names the host the bulb actually wants to talk to. +""" +import socket, sys, datetime, binascii + +LOG = sys.path[0] + "/qcx-tls.log" +s = socket.socket() +s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) +s.bind(("0.0.0.0", 443)) +s.listen(8) +print("qcx tls sniffer on 0.0.0.0:443", flush=True) +while True: + c, addr = s.accept() + try: + c.settimeout(3) + data = b"" + try: + while len(data) < 4096: + chunk = c.recv(2048) + if not chunk: + break + data += chunk + except socket.timeout: + pass + with open(LOG, "a") as f: + f.write("=== %s %s:%s %d bytes\n" % (datetime.datetime.now(), addr[0], addr[1], len(data))) + f.write(binascii.hexlify(data).decode() + "\n") + f.write(repr(data) + "\n") + print("tls from %s: %d bytes" % (addr[0], len(data)), flush=True) + finally: + c.close() diff --git a/qconnex-listen/up.sh b/qconnex-listen/up.sh new file mode 100755 index 0000000..6259894 --- /dev/null +++ b/qconnex-listen/up.sh @@ -0,0 +1,22 @@ +#!/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/src/board/nl_board.c b/src/board/nl_board.c index 0a49838..e24d4a6 100644 --- a/src/board/nl_board.c +++ b/src/board/nl_board.c @@ -31,6 +31,14 @@ const nl_board_t nl_boards[] = { * PB16=WW, PB15=CW - verify on a series bulb. */ {"g95_cw", "Klyqa G95 Vintage CW/WW", NL_DRV_PWM, false, 2000, 5000, 600, {NONE, NONE, NONE, CH(1, NL_PB(13)), CH(3, NL_PB(15))}, NO_SM}, + /* Montana Black LED Shield (Klyqa LED-Strippen-Steuergeraet, + product_id @qcx.montana.black.ledshield.rgb): analog RGB-Streifen an + lowseitigen MOSFETs, kein CW/WW-Zweig. PIN-Kanaele und 800 Hz aus der + Original-Firmware (fw-w600 qcl_build_config_lamp.h, HARDWARE_MOBL_LED_SHIELD); + dort invertiert init mit invertiertem Duty, effektive Wirkung wie hier + active-high. ct_min == ct_max markiert "weiss = RGB-Kombination". */ + {"ledshield_rgb", "Montana Black RGB LED Shield", NL_DRV_PWM, true, 4000, 4000, 800, + {CH(1, NL_PB(13)), CH(2, NL_PB(16)), CH(3, NL_PB(15)), NONE, NONE}, NO_SM}, }; const size_t nl_boards_count = sizeof(nl_boards) / sizeof(nl_boards[0]); diff --git a/src/light/nl_color.c b/src/light/nl_color.c index e559a3a..28b524e 100644 --- a/src/light/nl_color.c +++ b/src/light/nl_color.c @@ -62,6 +62,11 @@ void nl_visual_to_channels(const nl_visual_t *v, uint16_t ct_min, uint16_t ct_ma /* keep the lowest brightness step visible */ if (!out[NL_CH_R] && !out[NL_CH_G] && !out[NL_CH_B] && lin[maxi] > 0) out[NL_CH_R + maxi] = 1; + } else if (ct_min == ct_max) { + /* RGB-only board (ledshield_rgb): no white channels wired, ct range + * collapsed to a point. White shows as an equal RGB mix. */ + uint8_t w = to8(level); + out[NL_CH_R] = out[NL_CH_G] = out[NL_CH_B] = w ? w : 1; } else { uint32_t cw = level * nl_ct_cw_fraction(v->ct, ct_min, ct_max) / 65535u; uint32_t ww = level - cw; diff --git a/src/util/nl_crc.c b/src/util/nl_crc.c index 5f9e7b9..2b2d942 100644 --- a/src/util/nl_crc.c +++ b/src/util/nl_crc.c @@ -19,7 +19,7 @@ uint8_t nl_crc8(const void *data, size_t len) while (len--) { crc ^= *p++; for (int k = 0; k < 8; k++) - crc = (uint8_t)((crc & 0x80u) ? (crc << 1) ^ 0x07u : (crc << 1)); + crc = (uint8_t)((crc & 0x80u) ? (int)(((unsigned)crc << 1) ^ 0x07u) : (int)((unsigned)crc << 1)); } return crc; } diff --git a/test/test_board.c b/test/test_board.c index bb3655d..845912a 100644 --- a/test/test_board.c +++ b/test/test_board.c @@ -5,12 +5,12 @@ void setUp(void) {} void tearDown(void) {} -static const char *IDS[] = {"evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw"}; +static const char *IDS[] = {"evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw", "ledshield_rgb"}; static void test_all_devices_present(void) { - TEST_ASSERT_EQUAL_UINT(8, nl_boards_count); - for (size_t i = 0; i < 8; i++) { + TEST_ASSERT_EQUAL_UINT(9, nl_boards_count); + for (size_t i = 0; i < 9; i++) { const nl_board_t *b = nl_board_find(IDS[i]); TEST_ASSERT_NOT_NULL_MESSAGE(b, IDS[i]); TEST_ASSERT_EQUAL_STRING(IDS[i], b->id); @@ -34,9 +34,12 @@ static void test_rgb_flags_and_ct_ranges(void) 5000 K - hard-coded per board rather than derived, because "warm variant" is a property of that one product, not of a class of them. */ bool g95 = strcmp(b->id, "g95_cw") == 0; + /* ledshield_rgb has no white LEDs at all: ct_min == ct_max is the + sentinel "white mode renders as an RGB mix" (see nl_color.c). */ + bool shield = strcmp(b->id, "ledshield_rgb") == 0; TEST_ASSERT_EQUAL_MESSAGE(rgb, b->has_rgb, b->id); - TEST_ASSERT_EQUAL_UINT16_MESSAGE(g95 ? 2000 : 2700, b->ct_min, b->id); - TEST_ASSERT_EQUAL_UINT16_MESSAGE(g95 ? 5000 : 6500, b->ct_max, b->id); + TEST_ASSERT_EQUAL_UINT16_MESSAGE(shield ? 4000 : (g95 ? 2000 : 2700), b->ct_min, b->id); + TEST_ASSERT_EQUAL_UINT16_MESSAGE(shield ? 4000 : (g95 ? 5000 : 6500), b->ct_max, b->id); } } @@ -47,8 +50,15 @@ static void test_pwm_boards_consistent(void) if (b->driver != NL_DRV_PWM) continue; TEST_ASSERT_TRUE(b->pwm_freq_hz > 0); - TEST_ASSERT_TRUE(b->pwm[NL_CH_CW].pwm_ch >= 0); - TEST_ASSERT_TRUE(b->pwm[NL_CH_WW].pwm_ch >= 0); + if (b->ct_min == b->ct_max) { + /* RGB-only board: white is an RGB mix, no white channels wired. */ + TEST_ASSERT_TRUE(b->has_rgb); + TEST_ASSERT_EQUAL(-1, b->pwm[NL_CH_CW].pwm_ch); + TEST_ASSERT_EQUAL(-1, b->pwm[NL_CH_WW].pwm_ch); + } else { + TEST_ASSERT_TRUE(b->pwm[NL_CH_CW].pwm_ch >= 0); + TEST_ASSERT_TRUE(b->pwm[NL_CH_WW].pwm_ch >= 0); + } for (int c = NL_CH_R; c <= NL_CH_B; c++) TEST_ASSERT_EQUAL_MESSAGE(b->has_rgb, b->pwm[c].pwm_ch >= 0, b->id); for (int c = 0; c < NL_CH_COUNT; c++) { diff --git a/test/test_color.c b/test/test_color.c index 6074f3a..cf2cca2 100644 --- a/test/test_color.c +++ b/test/test_color.c @@ -88,6 +88,22 @@ static void test_one_percent_is_visible(void) TEST_ASSERT_EQUAL_INT(1, ch[NL_CH_CW] + ch[NL_CH_WW]); } +static void test_rgb_only_white_mode(void) +{ + uint8_t ch[NL_CH_COUNT]; + /* ct_min == ct_max: RGB-only board, white = equal RGB mix, no division. */ + nl_visual_t v = vis(10000, NL_MODE_WHITE, 0, 0, 0, 4000); + nl_visual_to_channels(&v, 4000, 4000, ch); + TEST_ASSERT_EQUAL_UINT8(255, ch[NL_CH_R]); + TEST_ASSERT_EQUAL_UINT8(255, ch[NL_CH_G]); + TEST_ASSERT_EQUAL_UINT8(255, ch[NL_CH_B]); + TEST_ASSERT_EQUAL_UINT8(0, ch[NL_CH_CW]); + TEST_ASSERT_EQUAL_UINT8(0, ch[NL_CH_WW]); + v.bri = 100; /* 1 % stays visible */ + nl_visual_to_channels(&v, 4000, 4000, ch); + TEST_ASSERT_EQUAL_UINT8(1, ch[NL_CH_R]); +} + static void test_zero_brightness_is_dark(void) { uint8_t ch[NL_CH_COUNT]; @@ -107,6 +123,7 @@ int main(void) RUN_TEST(test_white_mix_keeps_total); RUN_TEST(test_color_mode); RUN_TEST(test_one_percent_is_visible); + RUN_TEST(test_rgb_only_white_mode); RUN_TEST(test_zero_brightness_is_dark); return UNITY_END(); } diff --git a/tools/build_all.sh b/tools/build_all.sh index a74cf56..8148243 100755 --- a/tools/build_all.sh +++ b/tools/build_all.sh @@ -3,7 +3,7 @@ set -euo pipefail ROOT=$(cd "$(dirname "$0")/.." && pwd) OUT=${BUILD_ROOT:-$ROOT/build} -DEVICES=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw) +DEVICES=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb) for d in "${DEVICES[@]}"; do echo "== $d" cmake -S "$ROOT" -B "$OUT/$d" -G Ninja \ diff --git a/tools/build_nocreds.sh b/tools/build_nocreds.sh index 7eb6434..22ad3cd 100755 --- a/tools/build_nocreds.sh +++ b/tools/build_nocreds.sh @@ -2,7 +2,7 @@ # Baut Firmwareabzuege OHNE einkompilierte WLAN-Zugangsdaten: die Lampen starten damit # im Onboarding-AP (ninlight-), statt sich in ein fremdes WLAN einzuwaehlen. # -# Aufruf: tools/build_nocreds.sh [ziel ...] (ohne Argumente: alle acht Ziele) +# Aufruf: tools/build_nocreds.sh [ziel ...] (ohne Argumente: alle Ziele) # # Ergebnis: je Ziel ein Build-Verzeichnis unter $NINLIGHT_NOCREDS_BUILD_REL (Vorgabe # "build-nocreds", relativ zum Repo-Wurzelverzeichnis) mit den ueblichen Artefakten @@ -22,7 +22,7 @@ ROOT=$(cd "$(dirname "$0")/.." && pwd) DOCKER=${NINLIGHT_DOCKER:-$ROOT/tools/docker.sh} BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds} -DEVICES=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw) +DEVICES=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb) if [ $# -gt 0 ]; then TARGETS=("$@") else diff --git a/tools/docker.sh b/tools/docker.sh index b12afbd..43f6fc1 100755 --- a/tools/docker.sh +++ b/tools/docker.sh @@ -2,6 +2,6 @@ # Runs a command inside the ninlight build container (x86_64, gcc-arm-none-eabi 4.9-2014q4). # Usage: tools/docker.sh cmake --build build/evk set -euo pipefail -ROOT=$(cd "$(dirname "$0")/.." && pwd) +ROOT=$(cd -P "$(dirname "$0")/.." >/dev/null && pwd -P) docker image inspect ninlight-build >/dev/null 2>&1 || docker build --platform linux/amd64 -t ninlight-build "$ROOT/docker" exec docker run --rm --platform linux/amd64 -v "$ROOT":/work -w /work ninlight-build "$@" diff --git a/tools/podman.sh b/tools/podman.sh new file mode 100755 index 0000000..06dfda2 --- /dev/null +++ b/tools/podman.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# docker.sh equivalent using podman (same image, same mount, no emulation flag needed if native) +set -euo pipefail +ROOT=$(cd -P "$(dirname "$0")/.." >/dev/null && pwd -P) +podman image exists ninlight-build || podman build --platform linux/amd64 -t ninlight-build "$ROOT/docker" +exec podman run --rm --platform linux/amd64 -v "$ROOT":/work -w /work ninlight-build "$@" diff --git a/tools/release.sh b/tools/release.sh index 4c1c2f0..dcb353a 100755 --- a/tools/release.sh +++ b/tools/release.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Baut ein Auslieferungsarchiv: alle acht Ziele, OHNE einkompilierte WLAN-Zugangsdaten +# Baut ein Auslieferungsarchiv: alle Ziele, OHNE einkompilierte WLAN-Zugangsdaten # (die Lampen starten also im Onboarding-AP), in allen drei Bildformen - OTA, Migration # (dev und prod signiert) und serielle Erstbespielung. # @@ -14,7 +14,7 @@ set -euo pipefail VERSION=${1:?Aufruf: tools/release.sh } ROOT=$(cd "$(dirname "$0")/.." && pwd) -TARGETS=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw) +TARGETS=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb) BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds} BUILD_DIR="$ROOT/$BUILD_REL" NAME="ninlight-$VERSION" @@ -196,7 +196,7 @@ diesem Archiv sind **ohne einkompilierte WLAN-Zugangsdaten** gebaut: eine damit Lampe startet im Onboarding-Zugangspunkt \`ninlight-\` (Passwort \`ninlight\`) und wird mit \`onboard.sh\` ins WLAN gebracht. -Ziele: evk, e27_rgb, e14_rgb, gu10_rgb, e27_cw, e14_cw, gu10_cw, g95_cw. +Ziele: evk, e27_rgb, e14_rgb, gu10_rgb, e27_cw, e14_cw, gu10_cw, g95_cw, ledshield_rgb. ## Was liegt wo diff --git a/tools/station/products.py b/tools/station/products.py index 110d516..fa5c7ff 100644 --- a/tools/station/products.py +++ b/tools/station/products.py @@ -40,6 +40,7 @@ _BASE = [ ("klyqa.lighting.cw-ww.e14", "e14_cw", "Klyqa E14 CW/WW"), ("klyqa.lighting.cw-ww.gu10", "gu10_cw", "Klyqa GU10 CW/WW"), ("klyqa.lighting.cw-ww.g95", "g95_cw", "Klyqa G95 Vintage CW/WW"), + ("qcx.montana.black.ledshield.rgb", "ledshield_rgb", "Montana Black RGB LED Shield"), ] _UNSAFE = re.compile(r"[^0-9A-Za-z+\-_.]") diff --git a/tools/station/server.py b/tools/station/server.py index 4ee9bfb..27e9c8d 100755 --- a/tools/station/server.py +++ b/tools/station/server.py @@ -33,7 +33,8 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import products -TARGETS = ("evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw") +TARGETS = ("evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw", + "ledshield_rgb") PATH_RE = re.compile(r"^/ninlight_(" + "|".join(TARGETS) + r")_ota\.img$") TRAILER_SIZE = 512 TRAILER_MAGIC = b"NINL" diff --git a/tools/station/tests/test_products.py b/tools/station/tests/test_products.py index c6c7a28..dde5e30 100644 --- a/tools/station/tests/test_products.py +++ b/tools/station/tests/test_products.py @@ -7,12 +7,12 @@ import products # noqa: E402 def test_table_is_complete(): - """Sieben Geraete in beiden Schreibweisen der Produkt-ID, plus das EVK, das nur eine + """Acht Geraete in beiden Schreibweisen der Produkt-ID, plus das EVK, das nur eine Produktions-ID hat.""" - assert len(products.PRODUCTS) == 15 + assert len(products.PRODUCTS) == 17 targets = {p.target for p in products.PRODUCTS.values()} assert targets == {"evk", "e27_rgb", "e14_rgb", "gu10_rgb", - "e27_cw", "e14_cw", "gu10_cw", "g95_cw"} + "e27_cw", "e14_cw", "gu10_cw", "g95_cw", "ledshield_rgb"} def test_table_carries_no_variant(): diff --git a/tools/tests/test_release.sh b/tools/tests/test_release.sh index ed9dfcc..ed2949d 100755 --- a/tools/tests/test_release.sh +++ b/tools/tests/test_release.sh @@ -48,7 +48,7 @@ FAILED=0 pass() { echo "ok - $1"; } fail() { echo "FEHLER - $1" >&2; FAILED=1; } -TARGETS=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw) +TARGETS=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb) # Wegwerf-Zugangsdaten - niemals die echten aus dem Repo. SECRET_SSID="TestWLAN-4b7f-geheim" @@ -298,9 +298,9 @@ if [ -x "$A/onboard.sh" ]; then pass "onboard.sh ist ausfuehrbar"; else fail "on N_OTA=$(find "$A/ota" -type f | wc -l | tr -d ' ') N_FLASH=$(find "$A/flash" -type f | wc -l | tr -d ' ') N_MIG=$(find "$A/migrate" -type f | wc -l | tr -d ' ') -[ "$N_OTA" = "8" ] && pass "ota/ enthaelt 8 Dateien" || fail "ota/ enthaelt $N_OTA statt 8" -[ "$N_FLASH" = "8" ] && pass "flash/ enthaelt 8 Dateien" || fail "flash/ enthaelt $N_FLASH statt 8" -[ "$N_MIG" = "16" ] && pass "migrate/ enthaelt 16 Dateien" || fail "migrate/ enthaelt $N_MIG statt 16" +[ "$N_OTA" = "${#TARGETS[@]}" ] && pass "ota/ enthaelt ${#TARGETS[@]} Dateien" || fail "ota/ enthaelt $N_OTA statt ${#TARGETS[@]}" +[ "$N_FLASH" = "${#TARGETS[@]}" ] && pass "flash/ enthaelt ${#TARGETS[@]} Dateien" || fail "flash/ enthaelt $N_FLASH statt ${#TARGETS[@]}" +[ "$N_MIG" = "$(( 2 * ${#TARGETS[@]} ))" ] && pass "migrate/ enthaelt $(( 2 * ${#TARGETS[@]} )) Dateien" || fail "migrate/ enthaelt $N_MIG statt $(( 2 * ${#TARGETS[@]} ))" MISS="" for t in "${TARGETS[@]}"; do [ -f "$A/ota/ninlight_${t}_ota.img" ] || MISS="$MISS ota/$t" @@ -478,7 +478,7 @@ EXTRACT="$TMPROOT/x4"; mkdir -p "$EXTRACT" tar xzf "$SANDBOX/dist/ninlight-0.9.9.tar.gz" -C "$EXTRACT" A4="$EXTRACT/ninlight-0.9.9" if [ ! -d "$A4/migrate" ]; then pass "migrate/ liegt nicht vor"; else fail "migrate/ trotzdem vorhanden"; fi -if [ -d "$A4/ota" ] && [ "$(find "$A4/ota" -type f | wc -l | tr -d ' ')" = "8" ]; then +if [ -d "$A4/ota" ] && [ "$(find "$A4/ota" -type f | wc -l | tr -d ' ')" = "${#TARGETS[@]}" ]; then pass "ota/ trotzdem vollstaendig" else fail "ota/ unvollstaendig"