docs: README, Abnahmeprotokoll, Brand-Bilder; Gitea statt GitHub/HACS
This commit is contained in:
@@ -1,29 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
pull_request:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
checks:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
- uses: astral-sh/setup-uv@v5
|
|
||||||
with:
|
|
||||||
cache-dependency-glob: "requirements_dev.txt"
|
|
||||||
- run: uv venv --python 3.14 .venv && uv pip install --python .venv/bin/python -r requirements_dev.txt
|
|
||||||
- run: .venv/bin/ruff check . && .venv/bin/ruff format --check .
|
|
||||||
- run: .venv/bin/pytest -q --cov --cov-report=term-missing
|
|
||||||
hassfest:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
- uses: home-assistant/actions/hassfest@master
|
|
||||||
hacs:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v5
|
|
||||||
- uses: hacs/action@main
|
|
||||||
with:
|
|
||||||
category: integration
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# ninlight für Home Assistant
|
||||||
|
|
||||||
|
Lokale Home-Assistant-Integration für Lampen mit der [ninlight-Firmware](https://gitea.zukunpht.de/ninharp/ninlight-w600)
|
||||||
|
(migrierte Klyqa-Lampen auf WinnerMicro W600: GU10, E27, E14, G95). Keine Cloud, kein Konto –
|
||||||
|
HA spricht direkt mit der REST-API der Lampe auf Port 3333.
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Home Assistant 2026.9 oder neuer
|
||||||
|
- Lampen mit ninlight-Firmware im selben Netz
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Das Repo ist privat und nicht über HACS verfügbar. Den Ordner `custom_components/ninlight` nach
|
||||||
|
`<config>/custom_components/ninlight` der Home-Assistant-Instanz kopieren (z. B. per
|
||||||
|
`/usr/bin/scp -r` oder über das Samba-/SSH-Add-on) und Home Assistant neu starten. Für ein Update
|
||||||
|
denselben Ordner ersetzen und erneut neu starten.
|
||||||
|
|
||||||
|
## Einrichtung
|
||||||
|
|
||||||
|
Lampen werden per mDNS (`_ninlight._tcp`) gefunden und unter *Einstellungen → Geräte & Dienste*
|
||||||
|
angeboten. Alternativ „Integration hinzufügen → ninlight" und IP-Adresse oder
|
||||||
|
`ninlight-xxxx.local` eingeben.
|
||||||
|
|
||||||
|
Das Token-Feld ist mit dem **öffentlichen Vorgabe-Token** `aabbccddeeff0011223344` vorbelegt.
|
||||||
|
Solange eine Lampe dieses Token nutzt, kann jeder in deinem Netz sie steuern und neu flashen;
|
||||||
|
HA zeigt dafür einen Reparaturhinweis. Eigenes Token auf der Lampe setzen
|
||||||
|
(`PUT /api/v1/system/settings` mit `{"type":"token","token":"…"}`) und in HA über
|
||||||
|
*Neu konfigurieren* eintragen.
|
||||||
|
|
||||||
|
Wechselt eine Lampe ihre IP, aktualisiert mDNS den Eintrag selbst (Identität ist die MAC).
|
||||||
|
|
||||||
|
## Entitäten
|
||||||
|
|
||||||
|
| Entität | Art | Hinweis |
|
||||||
|
|---|---|---|
|
||||||
|
| Lampe | Licht | An/Aus, Helligkeit, Farbe (RGB-Lampen), Farbtemperatur, Übergang |
|
||||||
|
| Einblenden, Ausblenden, Standardübergang | Zahl (Konfiguration) | Millisekunden |
|
||||||
|
| Einschaltverhalten | Auswahl (Konfiguration) | letzter Zustand / ein / aus |
|
||||||
|
| Bei Änderung einschalten | Schalter (Konfiguration) | |
|
||||||
|
| Neustart | Taste (Konfiguration) | Werksreset wird bewusst nicht angeboten |
|
||||||
|
| Signalstärke | Sensor (Diagnose) | |
|
||||||
|
| IP-Adresse, freier Speicher, letzter Update-Fehler, letzter Start | Sensor (Diagnose) | standardmäßig deaktiviert |
|
||||||
|
| Firmware | Update | siehe unten |
|
||||||
|
|
||||||
|
## Firmware-Updates
|
||||||
|
|
||||||
|
Home Assistant bietet Updates aus dem Ordner **`<config>/ninlight_firmware/`** an (wird beim
|
||||||
|
ersten Start angelegt). Dort `*_ota.img`-Dateien ablegen; Dateinamen sind egal. HA liest
|
||||||
|
Gerätetyp und Version aus dem Trailer des Images, prüft die Prüfsumme und bietet jeder Lampe nur
|
||||||
|
Images **ihres** Gerätetyps an, die **neuer** sind als ihre Firmware.
|
||||||
|
|
||||||
|
Die Lampe lädt das Image über reines `http://` direkt von Home Assistant, unter einer zufälligen
|
||||||
|
Adresse, die nur während dieses einen Updates gilt. Erreichen die Lampen die interne URL von HA
|
||||||
|
nicht über `http://` (z. B. HA hinter HTTPS oder in Docker auf macOS), in den Optionen der
|
||||||
|
Integration **„Adresse, unter der Lampen Home Assistant erreichen"** setzen, etwa
|
||||||
|
`http://192.168.1.50:8123`.
|
||||||
|
|
||||||
|
### WLAN-Sicherung – wichtig
|
||||||
|
|
||||||
|
Eine Lampe kennt ihr WLAN entweder aus **gespeicherten Einstellungen** oder nur aus der
|
||||||
|
**einkompilierten Firmware** (`GET /api/v1/system/settings` → `wifi.source`). Im zweiten Fall
|
||||||
|
würde ein Image **ohne** WLAN-Zugangsdaten die Lampe nach dem Update aus dem Netz werfen – sie
|
||||||
|
startet dann im Onboarding-Zugangspunkt.
|
||||||
|
|
||||||
|
Deshalb bietet HA einer solchen Lampe nur Images an, die die SSID ihres aktuellen WLANs enthalten.
|
||||||
|
Liegt ein neueres Image ohne diese Zugangsdaten im Ordner, erscheint stattdessen ein
|
||||||
|
Reparaturhinweis.
|
||||||
|
|
||||||
|
**Das Release-Archiv der Firmware enthält Images ohne Zugangsdaten.** Für Lampen, die ihr WLAN
|
||||||
|
aus der Firmware beziehen, eigene Images **mit** Zugangsdaten bauen (`config.local.cmake` im
|
||||||
|
Firmware-Repo).
|
||||||
|
|
||||||
|
Grenzen der Prüfung: geprüft wird die SSID, nicht das Passwort; und eine Heim-SSID, die zufällig
|
||||||
|
einem Text der Firmware gleicht (z. B. `ninlight-recovery`), würde fälschlich als vorhanden
|
||||||
|
erkannt.
|
||||||
|
|
||||||
|
## Diagnose
|
||||||
|
|
||||||
|
*Gerät → ⋮ → Diagnose herunterladen* liefert Zustand, Systeminfo und passende Firmware-Dateien.
|
||||||
|
Token, WLAN-Name, MAC und IP sind geschwärzt.
|
||||||
|
|
||||||
|
## Entwicklung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv venv --python /opt/homebrew/bin/python3.14 .venv && uv pip install --python .venv/bin/python -r requirements_dev.txt
|
||||||
|
scripts/check.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Home Assistant 2026.9 braucht Python ≥ 3.14.2. Vor jedem Push `scripts/check.sh` laufen lassen –
|
||||||
|
das Repo liegt im Gitea ohne CI.
|
||||||
|
|
||||||
|
`tests_unit/` läuft ohne Home-Assistant-Instanz (HTTP-Client gegen einen lokalen Server,
|
||||||
|
Firmware-Logik), `tests/` gegen eine Test-Instanz.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -4,10 +4,10 @@
|
|||||||
"codeowners": ["@ninharp"],
|
"codeowners": ["@ninharp"],
|
||||||
"config_flow": true,
|
"config_flow": true,
|
||||||
"dependencies": ["http"],
|
"dependencies": ["http"],
|
||||||
"documentation": "https://github.com/ninharp/ha-ninlight",
|
"documentation": "https://gitea.zukunpht.de/ninharp/ha-ninlight",
|
||||||
"integration_type": "device",
|
"integration_type": "device",
|
||||||
"iot_class": "local_polling",
|
"iot_class": "local_polling",
|
||||||
"issue_tracker": "https://github.com/ninharp/ha-ninlight/issues",
|
"issue_tracker": "https://gitea.zukunpht.de/ninharp/ha-ninlight/issues",
|
||||||
"requirements": [],
|
"requirements": [],
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"zeroconf": [{ "type": "_ninlight._tcp.local." }]
|
"zeroconf": [{ "type": "_ninlight._tcp.local." }]
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Abnahme an echten Lampen
|
||||||
|
|
||||||
|
Dev-HA `ha-klyqa-pet-dev` (HA 2026.9.0), Integration aus `~/workspace/ha-ninlight` gemountet.
|
||||||
|
Lampen: GU10 RGB 192.168.2.22 / .154 / .38, G95 CW 192.168.2.122 (IPs zum Testzeitpunkt).
|
||||||
|
|
||||||
|
| # | Schritt | Erwartung | Ergebnis | Datum |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | Alle vier Lampen per IP einrichten | Gerätename, Modell, Firmware korrekt | | |
|
||||||
|
| 2 | GU10 RGB: an/aus, Helligkeit inkl. kleinster Wert, Farbe, Farbtemperatur, Übergang | Lampe folgt sichtbar, Zustand in HA stimmt | | |
|
||||||
|
| 3 | G95 CW: an/aus, Helligkeit, 2000 K und 5000 K | kein Farbmodus angeboten, Grenzen stimmen | | |
|
||||||
|
| 4 | Jede Konfigurations-Entität schreiben | `GET device/state` zeigt den neuen Wert | | |
|
||||||
|
| 5 | Neustart-Taste | Entitäten kurz nicht verfügbar, dann zurück | | |
|
||||||
|
| 6 | Lampe stromlos, dann wieder an | nicht verfügbar, einmal geloggt; kommt zurück | | |
|
||||||
|
| 7 | Token an der Lampe ändern, dann aus HA schalten | Neuanmeldung erscheint; danach Token zurücksetzen | | |
|
||||||
|
| 8 | Nur Image **ohne** Zugangsdaten passenden Typs in den Ordner | **kein** Update angeboten, Reparaturhinweis | | |
|
||||||
|
| 9 | Image **mit** Zugangsdaten und höherer Version | Update läuft, neue Version in HA, Lampe weiter unter ihrer IP | | |
|
||||||
|
| 10 | Image eines anderen Gerätetyps in den Ordner | wird nicht angeboten | | |
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "ninlight",
|
|
||||||
"render_readme": true,
|
|
||||||
"homeassistant": "2026.9.0"
|
|
||||||
}
|
|
||||||
Executable
+9
@@ -0,0 +1,9 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Local replacement for CI: the repo lives in a private Gitea without Actions.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
.venv/bin/ruff check .
|
||||||
|
.venv/bin/ruff format --check .
|
||||||
|
.venv/bin/pytest -q --cov --cov-report=term-missing
|
||||||
|
.venv/bin/python scripts/check_translations.py
|
||||||
|
echo "check.sh: alles gruen"
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""strings.json and translations/en.json must be identical; de.json must have the same keys."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
BASE = Path(__file__).parents[1] / "custom_components" / "ninlight"
|
||||||
|
|
||||||
|
|
||||||
|
def keys(data: dict, prefix: str = "") -> set[str]:
|
||||||
|
out: set[str] = set()
|
||||||
|
for key, value in data.items():
|
||||||
|
out |= keys(value, f"{prefix}{key}.") if isinstance(value, dict) else {f"{prefix}{key}"}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
strings = json.loads((BASE / "strings.json").read_text(encoding="utf-8"))
|
||||||
|
en = json.loads((BASE / "translations" / "en.json").read_text(encoding="utf-8"))
|
||||||
|
de = json.loads((BASE / "translations" / "de.json").read_text(encoding="utf-8"))
|
||||||
|
if strings != en:
|
||||||
|
print("translations/en.json weicht von strings.json ab")
|
||||||
|
return 1
|
||||||
|
diff = sorted(keys(strings) ^ keys(de))
|
||||||
|
if diff:
|
||||||
|
print("de.json und strings.json haben unterschiedliche Schluessel:", diff)
|
||||||
|
return 1
|
||||||
|
print("Uebersetzungen vollstaendig")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"""Generate the brand images HA serves from custom_components/ninlight/brand/ (HA >= 2026.3)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
OUT = Path(__file__).parents[1] / "custom_components" / "ninlight" / "brand"
|
||||||
|
AMBER = (255, 176, 46, 255)
|
||||||
|
DARK = (38, 38, 46, 255)
|
||||||
|
|
||||||
|
|
||||||
|
def bulb(size: int) -> Image.Image:
|
||||||
|
image = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(image)
|
||||||
|
s = size / 256
|
||||||
|
draw.ellipse((56 * s, 24 * s, 200 * s, 168 * s), fill=AMBER)
|
||||||
|
draw.rounded_rectangle((92 * s, 150 * s, 164 * s, 204 * s), radius=10 * s, fill=AMBER)
|
||||||
|
for y in (206, 222):
|
||||||
|
draw.rounded_rectangle((96 * s, y * s, 160 * s, (y + 10) * s), radius=5 * s, fill=DARK)
|
||||||
|
draw.rounded_rectangle((112 * s, 236 * s, 144 * s, 246 * s), radius=5 * s, fill=DARK)
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
for name, size in (
|
||||||
|
("icon.png", 256),
|
||||||
|
("icon@2x.png", 512),
|
||||||
|
("logo.png", 256),
|
||||||
|
("logo@2x.png", 512),
|
||||||
|
):
|
||||||
|
bulb(size).save(OUT / name)
|
||||||
|
print(OUT / name)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -18,3 +18,9 @@ def test_manifest_matches_constants() -> None:
|
|||||||
assert manifest["config_flow"] is True
|
assert manifest["config_flow"] is True
|
||||||
assert manifest["requirements"] == []
|
assert manifest["requirements"] == []
|
||||||
assert manifest["dependencies"] == ["http"]
|
assert manifest["dependencies"] == ["http"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_points_to_gitea() -> None:
|
||||||
|
manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
|
||||||
|
assert manifest["documentation"] == "https://gitea.zukunpht.de/ninharp/ha-ninlight"
|
||||||
|
assert manifest["issue_tracker"] == "https://gitea.zukunpht.de/ninharp/ha-ninlight/issues"
|
||||||
|
|||||||
Reference in New Issue
Block a user