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:
+1
-1
@@ -10,7 +10,7 @@ if(NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE Release)
|
||||
endif()
|
||||
|
||||
set(NINLIGHT_DEVICES evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw)
|
||||
set(NINLIGHT_DEVICES evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb)
|
||||
set(NINLIGHT_DEVICE "" CACHE STRING "Target device")
|
||||
if(NOT NINLIGHT_DEVICE IN_LIST NINLIGHT_DEVICES)
|
||||
message(FATAL_ERROR "Set -DNINLIGHT_DEVICE=<one of: ${NINLIGHT_DEVICES}>")
|
||||
|
||||
@@ -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
|
||||
@@ -24,7 +24,7 @@ Native Builds (mit lokal installierter ARM-Toolchain):
|
||||
cmake -S . -B build/evk -G Ninja -DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake -DNINLIGHT_DEVICE=evk
|
||||
cmake --build build/evk
|
||||
|
||||
**Geräte:** evk, e27_rgb, e14_rgb, gu10_rgb, e27_cw, e14_cw, gu10_cw, g95_cw.
|
||||
**Geräte:** evk, e27_rgb, e14_rgb, gu10_rgb, e27_cw, e14_cw, gu10_cw, g95_cw, ledshield_rgb (Montana Black LED Shield, nur RGB).
|
||||
|
||||
**Lokale Einstellungen** (Token, später WLAN): `config.local.cmake.example` → `config.local.cmake`.
|
||||
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# Laufzeitarteifakte
|
||||
qcx-requests.log
|
||||
hostapd.log
|
||||
*.pid
|
||||
images/
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Executable
+11
@@ -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"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Executable
+22
@@ -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)"
|
||||
@@ -31,6 +31,14 @@ const nl_board_t nl_boards[] = {
|
||||
* PB16=WW, PB15=CW - verify on a series bulb. */
|
||||
{"g95_cw", "Klyqa G95 Vintage CW/WW", NL_DRV_PWM, false, 2000, 5000, 600,
|
||||
{NONE, NONE, NONE, CH(1, NL_PB(13)), CH(3, NL_PB(15))}, NO_SM},
|
||||
/* Montana Black LED Shield (Klyqa LED-Strippen-Steuergeraet,
|
||||
product_id @qcx.montana.black.ledshield.rgb): analog RGB-Streifen an
|
||||
lowseitigen MOSFETs, kein CW/WW-Zweig. PIN-Kanaele und 800 Hz aus der
|
||||
Original-Firmware (fw-w600 qcl_build_config_lamp.h, HARDWARE_MOBL_LED_SHIELD);
|
||||
dort invertiert init mit invertiertem Duty, effektive Wirkung wie hier
|
||||
active-high. ct_min == ct_max markiert "weiss = RGB-Kombination". */
|
||||
{"ledshield_rgb", "Montana Black RGB LED Shield", NL_DRV_PWM, true, 4000, 4000, 800,
|
||||
{CH(1, NL_PB(13)), CH(2, NL_PB(16)), CH(3, NL_PB(15)), NONE, NONE}, NO_SM},
|
||||
};
|
||||
|
||||
const size_t nl_boards_count = sizeof(nl_boards) / sizeof(nl_boards[0]);
|
||||
|
||||
@@ -62,6 +62,11 @@ void nl_visual_to_channels(const nl_visual_t *v, uint16_t ct_min, uint16_t ct_ma
|
||||
/* keep the lowest brightness step visible */
|
||||
if (!out[NL_CH_R] && !out[NL_CH_G] && !out[NL_CH_B] && lin[maxi] > 0)
|
||||
out[NL_CH_R + maxi] = 1;
|
||||
} else if (ct_min == ct_max) {
|
||||
/* RGB-only board (ledshield_rgb): no white channels wired, ct range
|
||||
* collapsed to a point. White shows as an equal RGB mix. */
|
||||
uint8_t w = to8(level);
|
||||
out[NL_CH_R] = out[NL_CH_G] = out[NL_CH_B] = w ? w : 1;
|
||||
} else {
|
||||
uint32_t cw = level * nl_ct_cw_fraction(v->ct, ct_min, ct_max) / 65535u;
|
||||
uint32_t ww = level - cw;
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ uint8_t nl_crc8(const void *data, size_t len)
|
||||
while (len--) {
|
||||
crc ^= *p++;
|
||||
for (int k = 0; k < 8; k++)
|
||||
crc = (uint8_t)((crc & 0x80u) ? (crc << 1) ^ 0x07u : (crc << 1));
|
||||
crc = (uint8_t)((crc & 0x80u) ? (int)(((unsigned)crc << 1) ^ 0x07u) : (int)((unsigned)crc << 1));
|
||||
}
|
||||
return crc;
|
||||
}
|
||||
|
||||
+17
-7
@@ -5,12 +5,12 @@
|
||||
void setUp(void) {}
|
||||
void tearDown(void) {}
|
||||
|
||||
static const char *IDS[] = {"evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw"};
|
||||
static const char *IDS[] = {"evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw", "ledshield_rgb"};
|
||||
|
||||
static void test_all_devices_present(void)
|
||||
{
|
||||
TEST_ASSERT_EQUAL_UINT(8, nl_boards_count);
|
||||
for (size_t i = 0; i < 8; i++) {
|
||||
TEST_ASSERT_EQUAL_UINT(9, nl_boards_count);
|
||||
for (size_t i = 0; i < 9; i++) {
|
||||
const nl_board_t *b = nl_board_find(IDS[i]);
|
||||
TEST_ASSERT_NOT_NULL_MESSAGE(b, IDS[i]);
|
||||
TEST_ASSERT_EQUAL_STRING(IDS[i], b->id);
|
||||
@@ -34,9 +34,12 @@ static void test_rgb_flags_and_ct_ranges(void)
|
||||
5000 K - hard-coded per board rather than derived, because "warm variant" is a
|
||||
property of that one product, not of a class of them. */
|
||||
bool g95 = strcmp(b->id, "g95_cw") == 0;
|
||||
/* ledshield_rgb has no white LEDs at all: ct_min == ct_max is the
|
||||
sentinel "white mode renders as an RGB mix" (see nl_color.c). */
|
||||
bool shield = strcmp(b->id, "ledshield_rgb") == 0;
|
||||
TEST_ASSERT_EQUAL_MESSAGE(rgb, b->has_rgb, b->id);
|
||||
TEST_ASSERT_EQUAL_UINT16_MESSAGE(g95 ? 2000 : 2700, b->ct_min, b->id);
|
||||
TEST_ASSERT_EQUAL_UINT16_MESSAGE(g95 ? 5000 : 6500, b->ct_max, b->id);
|
||||
TEST_ASSERT_EQUAL_UINT16_MESSAGE(shield ? 4000 : (g95 ? 2000 : 2700), b->ct_min, b->id);
|
||||
TEST_ASSERT_EQUAL_UINT16_MESSAGE(shield ? 4000 : (g95 ? 5000 : 6500), b->ct_max, b->id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +50,15 @@ static void test_pwm_boards_consistent(void)
|
||||
if (b->driver != NL_DRV_PWM)
|
||||
continue;
|
||||
TEST_ASSERT_TRUE(b->pwm_freq_hz > 0);
|
||||
TEST_ASSERT_TRUE(b->pwm[NL_CH_CW].pwm_ch >= 0);
|
||||
TEST_ASSERT_TRUE(b->pwm[NL_CH_WW].pwm_ch >= 0);
|
||||
if (b->ct_min == b->ct_max) {
|
||||
/* RGB-only board: white is an RGB mix, no white channels wired. */
|
||||
TEST_ASSERT_TRUE(b->has_rgb);
|
||||
TEST_ASSERT_EQUAL(-1, b->pwm[NL_CH_CW].pwm_ch);
|
||||
TEST_ASSERT_EQUAL(-1, b->pwm[NL_CH_WW].pwm_ch);
|
||||
} else {
|
||||
TEST_ASSERT_TRUE(b->pwm[NL_CH_CW].pwm_ch >= 0);
|
||||
TEST_ASSERT_TRUE(b->pwm[NL_CH_WW].pwm_ch >= 0);
|
||||
}
|
||||
for (int c = NL_CH_R; c <= NL_CH_B; c++)
|
||||
TEST_ASSERT_EQUAL_MESSAGE(b->has_rgb, b->pwm[c].pwm_ch >= 0, b->id);
|
||||
for (int c = 0; c < NL_CH_COUNT; c++) {
|
||||
|
||||
@@ -88,6 +88,22 @@ static void test_one_percent_is_visible(void)
|
||||
TEST_ASSERT_EQUAL_INT(1, ch[NL_CH_CW] + ch[NL_CH_WW]);
|
||||
}
|
||||
|
||||
static void test_rgb_only_white_mode(void)
|
||||
{
|
||||
uint8_t ch[NL_CH_COUNT];
|
||||
/* ct_min == ct_max: RGB-only board, white = equal RGB mix, no division. */
|
||||
nl_visual_t v = vis(10000, NL_MODE_WHITE, 0, 0, 0, 4000);
|
||||
nl_visual_to_channels(&v, 4000, 4000, ch);
|
||||
TEST_ASSERT_EQUAL_UINT8(255, ch[NL_CH_R]);
|
||||
TEST_ASSERT_EQUAL_UINT8(255, ch[NL_CH_G]);
|
||||
TEST_ASSERT_EQUAL_UINT8(255, ch[NL_CH_B]);
|
||||
TEST_ASSERT_EQUAL_UINT8(0, ch[NL_CH_CW]);
|
||||
TEST_ASSERT_EQUAL_UINT8(0, ch[NL_CH_WW]);
|
||||
v.bri = 100; /* 1 % stays visible */
|
||||
nl_visual_to_channels(&v, 4000, 4000, ch);
|
||||
TEST_ASSERT_EQUAL_UINT8(1, ch[NL_CH_R]);
|
||||
}
|
||||
|
||||
static void test_zero_brightness_is_dark(void)
|
||||
{
|
||||
uint8_t ch[NL_CH_COUNT];
|
||||
@@ -107,6 +123,7 @@ int main(void)
|
||||
RUN_TEST(test_white_mix_keeps_total);
|
||||
RUN_TEST(test_color_mode);
|
||||
RUN_TEST(test_one_percent_is_visible);
|
||||
RUN_TEST(test_rgb_only_white_mode);
|
||||
RUN_TEST(test_zero_brightness_is_dark);
|
||||
return UNITY_END();
|
||||
}
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
set -euo pipefail
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
OUT=${BUILD_ROOT:-$ROOT/build}
|
||||
DEVICES=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw)
|
||||
DEVICES=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb)
|
||||
for d in "${DEVICES[@]}"; do
|
||||
echo "== $d"
|
||||
cmake -S "$ROOT" -B "$OUT/$d" -G Ninja \
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Baut Firmwareabzuege OHNE einkompilierte WLAN-Zugangsdaten: die Lampen starten damit
|
||||
# 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
|
||||
# "build-nocreds", relativ zum Repo-Wurzelverzeichnis) mit den ueblichen Artefakten
|
||||
@@ -22,7 +22,7 @@ ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
DOCKER=${NINLIGHT_DOCKER:-$ROOT/tools/docker.sh}
|
||||
BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds}
|
||||
|
||||
DEVICES=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw)
|
||||
DEVICES=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb)
|
||||
if [ $# -gt 0 ]; then
|
||||
TARGETS=("$@")
|
||||
else
|
||||
|
||||
+1
-1
@@ -2,6 +2,6 @@
|
||||
# Runs a command inside the ninlight build container (x86_64, gcc-arm-none-eabi 4.9-2014q4).
|
||||
# Usage: tools/docker.sh cmake --build build/evk
|
||||
set -euo pipefail
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
ROOT=$(cd -P "$(dirname "$0")/.." >/dev/null && pwd -P)
|
||||
docker image inspect ninlight-build >/dev/null 2>&1 || docker build --platform linux/amd64 -t ninlight-build "$ROOT/docker"
|
||||
exec docker run --rm --platform linux/amd64 -v "$ROOT":/work -w /work ninlight-build "$@"
|
||||
|
||||
Executable
+6
@@ -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
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Baut ein Auslieferungsarchiv: alle acht Ziele, OHNE einkompilierte WLAN-Zugangsdaten
|
||||
# Baut ein Auslieferungsarchiv: alle Ziele, OHNE einkompilierte WLAN-Zugangsdaten
|
||||
# (die Lampen starten also im Onboarding-AP), in allen drei Bildformen - OTA, Migration
|
||||
# (dev und prod signiert) und serielle Erstbespielung.
|
||||
#
|
||||
@@ -14,7 +14,7 @@ set -euo pipefail
|
||||
|
||||
VERSION=${1:?Aufruf: tools/release.sh <version>}
|
||||
ROOT=$(cd "$(dirname "$0")/.." && pwd)
|
||||
TARGETS=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw)
|
||||
TARGETS=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb)
|
||||
BUILD_REL=${NINLIGHT_NOCREDS_BUILD_REL:-build-nocreds}
|
||||
BUILD_DIR="$ROOT/$BUILD_REL"
|
||||
NAME="ninlight-$VERSION"
|
||||
@@ -196,7 +196,7 @@ diesem Archiv sind **ohne einkompilierte WLAN-Zugangsdaten** gebaut: eine damit
|
||||
Lampe startet im Onboarding-Zugangspunkt \`ninlight-<mac4>\` (Passwort \`ninlight\`) und
|
||||
wird mit \`onboard.sh\` ins WLAN gebracht.
|
||||
|
||||
Ziele: evk, e27_rgb, e14_rgb, gu10_rgb, e27_cw, e14_cw, gu10_cw, g95_cw.
|
||||
Ziele: evk, e27_rgb, e14_rgb, gu10_rgb, e27_cw, e14_cw, gu10_cw, g95_cw, ledshield_rgb.
|
||||
|
||||
## Was liegt wo
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ _BASE = [
|
||||
("klyqa.lighting.cw-ww.e14", "e14_cw", "Klyqa E14 CW/WW"),
|
||||
("klyqa.lighting.cw-ww.gu10", "gu10_cw", "Klyqa GU10 CW/WW"),
|
||||
("klyqa.lighting.cw-ww.g95", "g95_cw", "Klyqa G95 Vintage CW/WW"),
|
||||
("qcx.montana.black.ledshield.rgb", "ledshield_rgb", "Montana Black RGB LED Shield"),
|
||||
]
|
||||
|
||||
_UNSAFE = re.compile(r"[^0-9A-Za-z+\-_.]")
|
||||
|
||||
@@ -33,7 +33,8 @@ from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import products
|
||||
|
||||
TARGETS = ("evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw")
|
||||
TARGETS = ("evk", "e27_rgb", "e14_rgb", "gu10_rgb", "e27_cw", "e14_cw", "gu10_cw", "g95_cw",
|
||||
"ledshield_rgb")
|
||||
PATH_RE = re.compile(r"^/ninlight_(" + "|".join(TARGETS) + r")_ota\.img$")
|
||||
TRAILER_SIZE = 512
|
||||
TRAILER_MAGIC = b"NINL"
|
||||
|
||||
@@ -7,12 +7,12 @@ import products # noqa: E402
|
||||
|
||||
|
||||
def test_table_is_complete():
|
||||
"""Sieben Geraete in beiden Schreibweisen der Produkt-ID, plus das EVK, das nur eine
|
||||
"""Acht Geraete in beiden Schreibweisen der Produkt-ID, plus das EVK, das nur eine
|
||||
Produktions-ID hat."""
|
||||
assert len(products.PRODUCTS) == 15
|
||||
assert len(products.PRODUCTS) == 17
|
||||
targets = {p.target for p in products.PRODUCTS.values()}
|
||||
assert targets == {"evk", "e27_rgb", "e14_rgb", "gu10_rgb",
|
||||
"e27_cw", "e14_cw", "gu10_cw", "g95_cw"}
|
||||
"e27_cw", "e14_cw", "gu10_cw", "g95_cw", "ledshield_rgb"}
|
||||
|
||||
|
||||
def test_table_carries_no_variant():
|
||||
|
||||
@@ -48,7 +48,7 @@ FAILED=0
|
||||
pass() { echo "ok - $1"; }
|
||||
fail() { echo "FEHLER - $1" >&2; FAILED=1; }
|
||||
|
||||
TARGETS=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw)
|
||||
TARGETS=(evk e27_rgb e14_rgb gu10_rgb e27_cw e14_cw gu10_cw g95_cw ledshield_rgb)
|
||||
|
||||
# Wegwerf-Zugangsdaten - niemals die echten aus dem Repo.
|
||||
SECRET_SSID="TestWLAN-4b7f-geheim"
|
||||
@@ -298,9 +298,9 @@ if [ -x "$A/onboard.sh" ]; then pass "onboard.sh ist ausfuehrbar"; else fail "on
|
||||
N_OTA=$(find "$A/ota" -type f | wc -l | tr -d ' ')
|
||||
N_FLASH=$(find "$A/flash" -type f | wc -l | tr -d ' ')
|
||||
N_MIG=$(find "$A/migrate" -type f | wc -l | tr -d ' ')
|
||||
[ "$N_OTA" = "8" ] && pass "ota/ enthaelt 8 Dateien" || fail "ota/ enthaelt $N_OTA statt 8"
|
||||
[ "$N_FLASH" = "8" ] && pass "flash/ enthaelt 8 Dateien" || fail "flash/ enthaelt $N_FLASH statt 8"
|
||||
[ "$N_MIG" = "16" ] && pass "migrate/ enthaelt 16 Dateien" || fail "migrate/ enthaelt $N_MIG statt 16"
|
||||
[ "$N_OTA" = "${#TARGETS[@]}" ] && pass "ota/ enthaelt ${#TARGETS[@]} Dateien" || fail "ota/ enthaelt $N_OTA statt ${#TARGETS[@]}"
|
||||
[ "$N_FLASH" = "${#TARGETS[@]}" ] && pass "flash/ enthaelt ${#TARGETS[@]} Dateien" || fail "flash/ enthaelt $N_FLASH statt ${#TARGETS[@]}"
|
||||
[ "$N_MIG" = "$(( 2 * ${#TARGETS[@]} ))" ] && pass "migrate/ enthaelt $(( 2 * ${#TARGETS[@]} )) Dateien" || fail "migrate/ enthaelt $N_MIG statt $(( 2 * ${#TARGETS[@]} ))"
|
||||
MISS=""
|
||||
for t in "${TARGETS[@]}"; do
|
||||
[ -f "$A/ota/ninlight_${t}_ota.img" ] || MISS="$MISS ota/$t"
|
||||
@@ -478,7 +478,7 @@ EXTRACT="$TMPROOT/x4"; mkdir -p "$EXTRACT"
|
||||
tar xzf "$SANDBOX/dist/ninlight-0.9.9.tar.gz" -C "$EXTRACT"
|
||||
A4="$EXTRACT/ninlight-0.9.9"
|
||||
if [ ! -d "$A4/migrate" ]; then pass "migrate/ liegt nicht vor"; else fail "migrate/ trotzdem vorhanden"; fi
|
||||
if [ -d "$A4/ota" ] && [ "$(find "$A4/ota" -type f | wc -l | tr -d ' ')" = "8" ]; then
|
||||
if [ -d "$A4/ota" ] && [ "$(find "$A4/ota" -type f | wc -l | tr -d ' ')" = "${#TARGETS[@]}" ]; then
|
||||
pass "ota/ trotzdem vollstaendig"
|
||||
else
|
||||
fail "ota/ unvollstaendig"
|
||||
|
||||
Reference in New Issue
Block a user