chore(tools): qconnex-Migrationswerkzeug und HA-Integration ins Repo

qconnex-listen/ (OTA-Falle: AP + DHCP + Range-Server, Onboarding-Client) und
ha-integration/ (Home-Assistant-custom_component ninlight) waren seit der
Erfindung nur auf diesem Rechner. Bilder, Request-Log, hostapd-Log und PIDs
ueber .gitignore draussen - die Images sind generierbar, sobald die
Signierschluessel erreichbar sind. otaserve.py bildet jetzt auch die
Montana-Black-Produkt-ID auf ledshield_rgb ab.
This commit is contained in:
Robin von Bülow
2026-09-25 17:41:47 +02:00
parent 09ee206906
commit 41088b13ae
21 changed files with 1184 additions and 0 deletions
@@ -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."
}
}
}