merge: sACN/E1.31-Fernsteuerung

Die Lampe laesst sich per E1.31 ueber UDP fernsteuern, ohne dass der von Home
Assistant gesetzte Zustand angetastet wird. Abgenommen an einer E27 RGB, siehe
docs/acceptance-sacn.md - dort steht auch, dass Multicast derzeit am WLAN
haengenbleibt und nicht an der Firmware.

* feat/sacn:
  docs(sacn): Multicast-Ursache geklaert - die Lampe tritt bei, das WLAN filtert
  feat(sacn): Ergebnis des Multicast-Beitritts sichtbar machen
  docs(sacn): Abnahmeprotokoll der Hardwarepruefung
  docs(light,e131): zwei Stellen benennen, die sonst wie Versehen aussehen
  fix(sacn): ein nicht gestarteter Empfangs-Task darf die Lampe nicht unbrauchbar machen
  fix(console): Licht-Befehl gibt den DMX-Override frei - die Konsole ist der Rettungsweg
  fix(settings): neue Felder erst ab dem alten sizeof - sonst war sACN nach dem Update aus
  docs(sacn): Sender fuer die Abnahme, README und Bruno-Anfrage
  feat(sacn): UDP-Empfangs-Task und Verdrahtung in App und API
  fix(sacn): validate universe/start_slot via get_int(), reject fractional input
  feat(sacn): Endpunkt zum Einstellen und Statusfeld in system/info
  fix(sacn): sequence handling on priority change and start_slot clamp
  feat(sacn): Uebernahme-Schicht mit Timeout, Prioritaet und Sequenz
  feat(sacn): Override-Zweig in der Licht-Engine
  feat(sacn): Slotzahl je Board und vier Settings-Felder
  fix(sacn): validate universe range (1..63999), add boundary tests
  feat(sacn): E1.31-Pakete pruefen und zerlegen
This commit is contained in:
Michael Sauer
2026-09-20 02:33:49 +02:00
29 changed files with 1733 additions and 13 deletions
+84
View File
@@ -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.<hi>.<lo>, 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()