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:
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user