From f2cae9b7ce2bd28d1dc6822ec22b50624c09f0b7 Mon Sep 17 00:00:00 2001 From: Michael Sauer Date: Sun, 20 Sep 2026 00:57:16 +0200 Subject: [PATCH] docs(sacn): Sender fuer die Abnahme, README und Bruno-Anfrage --- README.md | 29 +++++++++++++++- bruno/ninlight.yml | 24 ++++++++++++- tools/sacn_send.py | 84 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100755 tools/sacn_send.py diff --git a/README.md b/README.md index a89d0e7..9f8a014 100644 --- a/README.md +++ b/README.md @@ -65,12 +65,39 @@ Verfügbare Befehle in der Konsole: `help`. gespeicherten Zugangsdaten). Im Fallback-AP (Router war > 5 min weg, Lampe hat aber bereits Zugangsdaten) ist der Token-Header wie bei jedem anderen Endpunkt erforderlich. Nach Erfolg schließt der AP nach 60 s. Ohne Verbindung für 5 min öffnet die Lampe den AP zusätzlich (Fallback). -- REST auf Port 3333, Präfix `/api/v1`: `GET|POST device/state`, `GET system/info`, `GET|PUT system/settings` und `PUT system/command` (beide mit Header `Authorization: `, Default-Token `aabbccddeeff0011223344`). +- REST auf Port 3333, Präfix `/api/v1`: `GET|POST device/state`, `GET system/info`, `GET|PUT system/settings` (kennt u.a. den Typ `sacn`, siehe unten) und `PUT system/command` (beide mit Header `Authorization: `, Default-Token `aabbccddeeff0011223344`). - Beispiel: `curl -X POST http://ninlight-681f.local:3333/api/v1/device/state -d '{"on":true,"brightness":40,"color_temp":2700}'` - Suchen: `dns-sd -B _ninlight._tcp` (macOS), Details `dns-sd -L ninlight-681f _ninlight._tcp`. - Bruno: `bruno/ninlight.yml` importieren, Environment „EVK (mDNS)" bzw. „Onboarding-AP" wählen. - Konsole: `net` zeigt Modus, SSID, IP, RSSI und den letzten Onboarding-Fehler. +### sACN / E1.31 + +Die Lampe empfängt sACN (E1.31) auf UDP-Port 5568 — per Multicast auf +`239.255..` und per Unicast direkt an ihre IP. Nach einem +Update ist der Empfang aktiv: Universe 1, Startslot 1. + +Slots ab `start_slot`: **Dimmer, R, G, B, CW, WW** (Boards ohne RGB: **Dimmer, CW, WW**). +Wieviele es sind, steht als `slots` in `GET system/settings`. + +Während Pakete fließen, hat sACN Vorrang; die Werte gehen ohne Fade direkt an die +Kanäle, und der per API gesetzte Zustand bleibt unangetastet. 2,5 s nach dem letzten +Paket endet die Übernahme — je nach `on_timeout` fällt die Lampe auf den per API +gesetzten Zustand zurück (`release`, Vorgabe) oder behält die letzten Werte (`hold`). +Ein Befehl an `device/state` holt die Kontrolle in beiden Fällen sofort zurück. + +Umstellen: + + curl -X PUT http://:3333/api/v1/system/settings \ + -H 'Authorization: ' \ + -d '{"type":"sacn","universe":12,"start_slot":7,"on_timeout":"hold"}' + +Der Port ist offen und nicht authentifiziert — so sieht das Protokoll es vor. Wer im +WLAN sitzt, kann die Lampe damit leuchten lassen, aber nichts einstellen und nichts +speichern. Zum Abschalten: `{"type":"sacn","enabled":false}`. + +Zum Ausprobieren liegt ein Sender bei: `tools/sacn_send.py --values 255,255,0,0,0,0 --seconds 5`. + ## Firmware-Update Der Build erzeugt pro Gerät zwei Images: diff --git a/bruno/ninlight.yml b/bruno/ninlight.yml index 16e7e79..2e68d9a 100644 --- a/bruno/ninlight.yml +++ b/bruno/ninlight.yml @@ -328,10 +328,32 @@ items: timeout: 0 followRedirects: true maxRedirects: 5 + - info: + name: Set sACN + type: http + seq: 7 + http: + method: PUT + url: '{{device_address}}:{{device_port}}/api/v1/system/settings' + body: + type: json + data: |- + { + "type": "sacn", + "enabled": true, + "universe": 1, + "start_slot": 1, + "on_timeout": "release" + } + settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 - info: name: Commands type: folder - seq: 7 + seq: 8 items: - info: name: Ping diff --git a/tools/sacn_send.py b/tools/sacn_send.py new file mode 100755 index 0000000..93c7acb --- /dev/null +++ b/tools/sacn_send.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Schickt sACN/E1.31-Pakete an eine ninlight-Lampe - fuer die Abnahme von Hand. + +Aufruf: + tools/sacn_send.py --universe 1 --start-slot 1 --values 255,255,0,0,0,0 --seconds 5 + tools/sacn_send.py --host 192.168.2.122 --values 255,0,255,0,0,0 --seconds 5 + tools/sacn_send.py --values 255,255,255,255,255,255 --seconds 3 --terminate + +Ohne --host geht es per Multicast an 239.255.., mit --host per Unicast direkt an +die Lampe. Die Werte sind der Slot-Satz ab --start-slot: Dimmer, R, G, B, CW, WW (Boards +ohne RGB: Dimmer, CW, WW). +""" +import argparse +import socket +import struct +import time + +E131_PORT = 5568 +ACN_PID = b"ASC-E1.17\0\0\0" + + +def packet(universe, sequence, values, start_slot, priority, terminated): + slots = bytearray(512) + for i, v in enumerate(values): + slots[start_slot - 1 + i] = v + count = start_slot - 1 + len(values) + data = bytes(slots[:count]) + total = 126 + count + + p = bytearray(total) + struct.pack_into(">HH", p, 0, 0x0010, 0x0000) + p[4:16] = ACN_PID + struct.pack_into(">H", p, 16, 0x7000 | (total - 16)) + struct.pack_into(">I", p, 18, 0x00000004) + p[22:38] = b"ninlight-abnahme" # CID, 16 Byte + struct.pack_into(">H", p, 38, 0x7000 | (total - 38)) + struct.pack_into(">I", p, 40, 0x00000002) + p[44:44 + 13] = b"ninlight test" + p[108] = priority + p[111] = sequence & 0xFF + p[112] = 0x40 if terminated else 0x00 + struct.pack_into(">H", p, 113, universe) + struct.pack_into(">H", p, 115, 0x7000 | (total - 115)) + p[117] = 0x02 + p[118] = 0xA1 + struct.pack_into(">HH", p, 119, 0x0000, 0x0001) + struct.pack_into(">H", p, 123, count + 1) + p[125] = 0x00 + p[126:] = data + return bytes(p) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--universe", type=int, default=1) + ap.add_argument("--start-slot", type=int, default=1) + ap.add_argument("--values", default="255,255,255,0,0,0") + ap.add_argument("--priority", type=int, default=100) + ap.add_argument("--seconds", type=float, default=5.0) + ap.add_argument("--fps", type=float, default=30.0) + ap.add_argument("--host", help="Unicast statt Multicast") + ap.add_argument("--terminate", action="store_true", + help="zum Schluss ein Paket mit gesetztem stream_terminated-Bit") + a = ap.parse_args() + + values = [int(v) for v in a.values.split(",")] + dest = a.host or "239.255.%d.%d" % (a.universe >> 8, a.universe & 0xFF) + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 8) + + seq, deadline, step = 0, time.time() + a.seconds, 1.0 / a.fps + while time.time() < deadline: + s.sendto(packet(a.universe, seq, values, a.start_slot, a.priority, False), + (dest, E131_PORT)) + seq += 1 + time.sleep(step) + if a.terminate: + s.sendto(packet(a.universe, seq, values, a.start_slot, a.priority, True), + (dest, E131_PORT)) + print("%d Pakete an %s:%d, Universe %d" % (seq, dest, E131_PORT, a.universe)) + + +if __name__ == "__main__": + main()