Merge remote-tracking branch 'origin/montana-black-shield' into merge-probe

* origin/montana-black-shield:
  docs(bulb-map): Montana-Black-Shield (f960) ergaenzt
  chore(tools): qconnex-Migrationswerkzeug und HA-Integration ins Repo
  feat(station): Montana-Black-Produkt-ID -> Ziel ledshield_rgb
  feat(board): ledshield_rgb — Montana Black RGB-LED-Shield (analoge RGB-Streifen)
  build: tools/podman.sh — docker.sh-Pendant für podman (gleiches Image, gleicher Mount)
  Überträgt lokale Änderungen (nl_crc8 unsigned-shift, docker.sh cd -P) in neuen Top-Level-Tree und entfernt altes ninlight-w600/
  Initial commit
This commit is contained in:
Michael Sauer
2026-09-26 17:18:27 +02:00
38 changed files with 1260 additions and 26 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ if(NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release) set(CMAKE_BUILD_TYPE Release)
endif() 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") set(NINLIGHT_DEVICE "" CACHE STRING "Target device")
if(NOT NINLIGHT_DEVICE IN_LIST NINLIGHT_DEVICES) if(NOT NINLIGHT_DEVICE IN_LIST NINLIGHT_DEVICES)
message(FATAL_ERROR "Set -DNINLIGHT_DEVICE=<one of: ${NINLIGHT_DEVICES}>") message(FATAL_ERROR "Set -DNINLIGHT_DEVICE=<one of: ${NINLIGHT_DEVICES}>")
+1
View File
@@ -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
+1 -1
View File
@@ -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 -S . -B build/evk -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake -DNINLIGHT_DEVICE=evk
cmake --build build/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`. **Lokale Einstellungen** (Token, später WLAN): `config.local.cmake.example` → `config.local.cmake`.
@@ -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
@@ -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 <token>": 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
@@ -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-<mac4>` 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},
)
@@ -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
@@ -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]
@@ -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()
@@ -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)
)
@@ -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."]
}
@@ -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-<mac>.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."
}
}
}
@@ -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-<mac>.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."
}
}
}
+5
View File
@@ -0,0 +1,5 @@
# Laufzeitarteifakte
qcx-requests.log
hostapd.log
*.pid
images/
+11
View File
@@ -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)
+13
View File
@@ -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
+11
View File
@@ -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"
+15
View File
@@ -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
+47
View File
@@ -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()
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Onboard a qconnex/KLYQA bulb via its softAP TLS onboarding socket (port 2223).
Usage: onboard.py <bulb-ip> <ssid> <wifi-password> [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":"<hex>","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()
+72
View File
@@ -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()
+24
View File
@@ -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()
+34
View File
@@ -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()
+22
View File
@@ -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)"
+8
View File
@@ -31,6 +31,14 @@ const nl_board_t nl_boards[] = {
* PB16=WW, PB15=CW - verify on a series bulb. */ * PB16=WW, PB15=CW - verify on a series bulb. */
{"g95_cw", "Klyqa G95 Vintage CW/WW", NL_DRV_PWM, false, 2000, 5000, 600, {"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}, {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]); const size_t nl_boards_count = sizeof(nl_boards) / sizeof(nl_boards[0]);
+5
View File
@@ -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 */ /* keep the lowest brightness step visible */
if (!out[NL_CH_R] && !out[NL_CH_G] && !out[NL_CH_B] && lin[maxi] > 0) if (!out[NL_CH_R] && !out[NL_CH_G] && !out[NL_CH_B] && lin[maxi] > 0)
out[NL_CH_R + maxi] = 1; 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 { } else {
uint32_t cw = level * nl_ct_cw_fraction(v->ct, ct_min, ct_max) / 65535u; uint32_t cw = level * nl_ct_cw_fraction(v->ct, ct_min, ct_max) / 65535u;
uint32_t ww = level - cw; uint32_t ww = level - cw;
+1 -1
View File
@@ -19,7 +19,7 @@ uint8_t nl_crc8(const void *data, size_t len)
while (len--) { while (len--) {
crc ^= *p++; crc ^= *p++;
for (int k = 0; k < 8; k++) 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; return crc;
} }
+17 -7
View File
@@ -5,12 +5,12 @@
void setUp(void) {} void setUp(void) {}
void tearDown(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) static void test_all_devices_present(void)
{ {
TEST_ASSERT_EQUAL_UINT(8, nl_boards_count); TEST_ASSERT_EQUAL_UINT(9, nl_boards_count);
for (size_t i = 0; i < 8; i++) { for (size_t i = 0; i < 9; i++) {
const nl_board_t *b = nl_board_find(IDS[i]); const nl_board_t *b = nl_board_find(IDS[i]);
TEST_ASSERT_NOT_NULL_MESSAGE(b, IDS[i]); TEST_ASSERT_NOT_NULL_MESSAGE(b, IDS[i]);
TEST_ASSERT_EQUAL_STRING(IDS[i], b->id); 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 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. */ property of that one product, not of a class of them. */
bool g95 = strcmp(b->id, "g95_cw") == 0; 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_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(shield ? 4000 : (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 ? 5000 : 6500), b->ct_max, b->id);
} }
} }
@@ -47,8 +50,15 @@ static void test_pwm_boards_consistent(void)
if (b->driver != NL_DRV_PWM) if (b->driver != NL_DRV_PWM)
continue; continue;
TEST_ASSERT_TRUE(b->pwm_freq_hz > 0); TEST_ASSERT_TRUE(b->pwm_freq_hz > 0);
TEST_ASSERT_TRUE(b->pwm[NL_CH_CW].pwm_ch >= 0); if (b->ct_min == b->ct_max) {
TEST_ASSERT_TRUE(b->pwm[NL_CH_WW].pwm_ch >= 0); /* 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++) 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); TEST_ASSERT_EQUAL_MESSAGE(b->has_rgb, b->pwm[c].pwm_ch >= 0, b->id);
for (int c = 0; c < NL_CH_COUNT; c++) { for (int c = 0; c < NL_CH_COUNT; c++) {
+17
View File
@@ -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]); 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) static void test_zero_brightness_is_dark(void)
{ {
uint8_t ch[NL_CH_COUNT]; uint8_t ch[NL_CH_COUNT];
@@ -107,6 +123,7 @@ int main(void)
RUN_TEST(test_white_mix_keeps_total); RUN_TEST(test_white_mix_keeps_total);
RUN_TEST(test_color_mode); RUN_TEST(test_color_mode);
RUN_TEST(test_one_percent_is_visible); RUN_TEST(test_one_percent_is_visible);
RUN_TEST(test_rgb_only_white_mode);
RUN_TEST(test_zero_brightness_is_dark); RUN_TEST(test_zero_brightness_is_dark);
return UNITY_END(); return UNITY_END();
} }
+1 -1
View File
@@ -3,7 +3,7 @@
set -euo pipefail set -euo pipefail
ROOT=$(cd "$(dirname "$0")/.." && pwd) ROOT=$(cd "$(dirname "$0")/.." && pwd)
OUT=${BUILD_ROOT:-$ROOT/build} 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 for d in "${DEVICES[@]}"; do
echo "== $d" echo "== $d"
cmake -S "$ROOT" -B "$OUT/$d" -G Ninja \ cmake -S "$ROOT" -B "$OUT/$d" -G Ninja \
+2 -2
View File
@@ -2,7 +2,7 @@
# Baut Firmwareabzuege OHNE einkompilierte WLAN-Zugangsdaten: die Lampen starten damit # Baut Firmwareabzuege OHNE einkompilierte WLAN-Zugangsdaten: die Lampen starten damit
# im Onboarding-AP (ninlight-<mac4>), statt sich in ein fremdes WLAN einzuwaehlen. # im Onboarding-AP (ninlight-<mac4>), 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 # Ergebnis: je Ziel ein Build-Verzeichnis unter $NINLIGHT_NOCREDS_BUILD_REL (Vorgabe
# "build-nocreds", relativ zum Repo-Wurzelverzeichnis) mit den ueblichen Artefakten # "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} DOCKER=${NINLIGHT_DOCKER:-$ROOT/tools/docker.sh}
BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds} 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 if [ $# -gt 0 ]; then
TARGETS=("$@") TARGETS=("$@")
else else
+1 -1
View File
@@ -2,6 +2,6 @@
# Runs a command inside the ninlight build container (x86_64, gcc-arm-none-eabi 4.9-2014q4). # 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 # Usage: tools/docker.sh cmake --build build/evk
set -euo pipefail 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" 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 "$@" exec docker run --rm --platform linux/amd64 -v "$ROOT":/work -w /work ninlight-build "$@"
+6
View File
@@ -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 "$@"
+3 -3
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/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 # (die Lampen starten also im Onboarding-AP), in allen drei Bildformen - OTA, Migration
# (dev und prod signiert) und serielle Erstbespielung. # (dev und prod signiert) und serielle Erstbespielung.
# #
@@ -14,7 +14,7 @@ set -euo pipefail
VERSION=${1:?Aufruf: tools/release.sh <version>} VERSION=${1:?Aufruf: tools/release.sh <version>}
ROOT=$(cd "$(dirname "$0")/.." && pwd) 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_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds}
BUILD_DIR="$ROOT/$BUILD_REL" BUILD_DIR="$ROOT/$BUILD_REL"
NAME="ninlight-$VERSION" NAME="ninlight-$VERSION"
@@ -196,7 +196,7 @@ diesem Archiv sind **ohne einkompilierte WLAN-Zugangsdaten** gebaut: eine damit
Lampe startet im Onboarding-Zugangspunkt \`ninlight-<mac4>\` (Passwort \`ninlight\`) und Lampe startet im Onboarding-Zugangspunkt \`ninlight-<mac4>\` (Passwort \`ninlight\`) und
wird mit \`onboard.sh\` ins WLAN gebracht. 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 ## Was liegt wo
+1
View File
@@ -40,6 +40,7 @@ _BASE = [
("klyqa.lighting.cw-ww.e14", "e14_cw", "Klyqa E14 CW/WW"), ("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.gu10", "gu10_cw", "Klyqa GU10 CW/WW"),
("klyqa.lighting.cw-ww.g95", "g95_cw", "Klyqa G95 Vintage 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+\-_.]") _UNSAFE = re.compile(r"[^0-9A-Za-z+\-_.]")
+2 -1
View File
@@ -33,7 +33,8 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
import products 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$") PATH_RE = re.compile(r"^/ninlight_(" + "|".join(TARGETS) + r")_ota\.img$")
TRAILER_SIZE = 512 TRAILER_SIZE = 512
TRAILER_MAGIC = b"NINL" TRAILER_MAGIC = b"NINL"
+3 -3
View File
@@ -7,12 +7,12 @@ import products # noqa: E402
def test_table_is_complete(): 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.""" Produktions-ID hat."""
assert len(products.PRODUCTS) == 15 assert len(products.PRODUCTS) == 17
targets = {p.target for p in products.PRODUCTS.values()} targets = {p.target for p in products.PRODUCTS.values()}
assert targets == {"evk", "e27_rgb", "e14_rgb", "gu10_rgb", 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(): def test_table_carries_no_variant():
+5 -5
View File
@@ -48,7 +48,7 @@ FAILED=0
pass() { echo "ok - $1"; } pass() { echo "ok - $1"; }
fail() { echo "FEHLER - $1" >&2; FAILED=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. # Wegwerf-Zugangsdaten - niemals die echten aus dem Repo.
SECRET_SSID="TestWLAN-4b7f-geheim" 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_OTA=$(find "$A/ota" -type f | wc -l | tr -d ' ')
N_FLASH=$(find "$A/flash" -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_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_OTA" = "${#TARGETS[@]}" ] && pass "ota/ enthaelt ${#TARGETS[@]} Dateien" || fail "ota/ enthaelt $N_OTA statt ${#TARGETS[@]}"
[ "$N_FLASH" = "8" ] && pass "flash/ enthaelt 8 Dateien" || fail "flash/ enthaelt $N_FLASH statt 8" [ "$N_FLASH" = "${#TARGETS[@]}" ] && pass "flash/ enthaelt ${#TARGETS[@]} Dateien" || fail "flash/ enthaelt $N_FLASH statt ${#TARGETS[@]}"
[ "$N_MIG" = "16" ] && pass "migrate/ enthaelt 16 Dateien" || fail "migrate/ enthaelt $N_MIG statt 16" [ "$N_MIG" = "$(( 2 * ${#TARGETS[@]} ))" ] && pass "migrate/ enthaelt $(( 2 * ${#TARGETS[@]} )) Dateien" || fail "migrate/ enthaelt $N_MIG statt $(( 2 * ${#TARGETS[@]} ))"
MISS="" MISS=""
for t in "${TARGETS[@]}"; do for t in "${TARGETS[@]}"; do
[ -f "$A/ota/ninlight_${t}_ota.img" ] || MISS="$MISS ota/$t" [ -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" tar xzf "$SANDBOX/dist/ninlight-0.9.9.tar.gz" -C "$EXTRACT"
A4="$EXTRACT/ninlight-0.9.9" 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/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" pass "ota/ trotzdem vollstaendig"
else else
fail "ota/ unvollstaendig" fail "ota/ unvollstaendig"