@ecopoesis/homebridge-dmx 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CLAUDE.md +1 -0
  2. package/ENCRYPTION.md +207 -0
  3. package/PROTOCOL.md +277 -0
  4. package/README.md +25 -2
  5. package/config.schema.json +53 -5
  6. package/dist/config.d.ts +5 -1
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +13 -2
  9. package/dist/config.js.map +1 -1
  10. package/dist/controller.d.ts +16 -4
  11. package/dist/controller.d.ts.map +1 -1
  12. package/dist/controller.js +41 -5
  13. package/dist/controller.js.map +1 -1
  14. package/dist/dmxController.d.ts +11 -0
  15. package/dist/dmxController.d.ts.map +1 -0
  16. package/dist/dmxController.js +4 -0
  17. package/dist/dmxController.js.map +1 -0
  18. package/dist/platform.d.ts.map +1 -1
  19. package/dist/platform.js +5 -2
  20. package/dist/platform.js.map +1 -1
  21. package/dist/platformAccessory.d.ts +2 -2
  22. package/dist/platformAccessory.d.ts.map +1 -1
  23. package/dist/platformAccessory.js.map +1 -1
  24. package/dist/sacn.d.ts +29 -0
  25. package/dist/sacn.d.ts.map +1 -0
  26. package/dist/sacn.js +142 -0
  27. package/dist/sacn.js.map +1 -0
  28. package/dist/settings.d.ts +23 -0
  29. package/dist/settings.d.ts.map +1 -1
  30. package/dist/settings.js +24 -0
  31. package/dist/settings.js.map +1 -1
  32. package/dist/zoneAccessory.d.ts +2 -2
  33. package/dist/zoneAccessory.d.ts.map +1 -1
  34. package/dist/zoneAccessory.js.map +1 -1
  35. package/examples/dmx.yaml +13 -4
  36. package/package.json +1 -1
  37. package/src/config.ts +18 -3
  38. package/src/controller.ts +41 -6
  39. package/src/dmxController.ts +14 -0
  40. package/src/platform.ts +8 -4
  41. package/src/platformAccessory.ts +2 -2
  42. package/src/sacn.ts +149 -0
  43. package/src/settings.ts +32 -0
  44. package/src/zoneAccessory.ts +2 -2
  45. package/tools/stick-power-cycle.py +121 -0
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env python3
2
+ # stick-power-cycle — hard power-cycle the Nicolaudie Stick-DE3 by turning
3
+ # PoE off/on on its switch port (sw01 port 15, via PoE splitter).
4
+ #
5
+ # Why: the Stick can wedge into a state where TCP sessions and the crypto
6
+ # handshake succeed but live DMX output is blackout (seen 2026-08-22..24);
7
+ # only a real power removal recovers it. The UniFi UI "Power Cycle" bounce
8
+ # is too short — the splitter rides through it. This holds power off for
9
+ # OFF_SECONDS, then restores, then waits for the Stick to answer ping.
10
+ #
11
+ # Credentials: read at runtime from the homebridge config.json (same local
12
+ # UniFi user the AP-RGB plugin uses). Nothing secret is stored here.
13
+ #
14
+ # Scheduled via systemd user timer (host has no cron):
15
+ # ~/.config/systemd/user/stick-power-cycle.{service,timer} — daily 08:30 UTC
16
+ # (= 4:30am EDT; lights are always off then). Lingering enabled for miker so
17
+ # the timer fires without a login session. Logs: ~/stick-power-cycle.log
18
+ # Manual run: systemctl --user start stick-power-cycle.service
19
+
20
+ import json, socket, ssl, sys, time, urllib.request, http.cookiejar
21
+
22
+ CONTROLLER = 'https://192.168.1.1'
23
+ CONFIG_JSON = '/opt/containers/homebridge/config.json'
24
+ SWITCH_MAC = '94:2a:6f:94:f1:da' # sw01
25
+ PORT_IDX = 15 # Stick-DE3 PoE splitter, "dmx" VLAN 96
26
+ STICK_IP = '192.168.96.2'
27
+ OFF_SECONDS = 30
28
+ BOOT_WAIT_S = 180
29
+
30
+ def log(*a):
31
+ print(time.strftime('%Y-%m-%d %H:%M:%S%z'), *a, flush=True)
32
+
33
+ def load_creds():
34
+ cfg = json.load(open(CONFIG_JSON))
35
+ p = next(x for x in cfg['platforms'] if x.get('platform') == 'UnifiAPLight')
36
+ return p['username'], p['password']
37
+
38
+ class Unifi:
39
+ def __init__(self):
40
+ ctx = ssl.create_default_context()
41
+ ctx.check_hostname = False
42
+ ctx.verify_mode = ssl.CERT_NONE
43
+ self.op = urllib.request.build_opener(
44
+ urllib.request.HTTPSHandler(context=ctx),
45
+ urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
46
+ self.csrf = ''
47
+
48
+ def req(self, method, path, body=None):
49
+ rq = urllib.request.Request(CONTROLLER + path, method=method,
50
+ data=json.dumps(body).encode() if body is not None else None,
51
+ headers={'Content-Type': 'application/json'})
52
+ if self.csrf:
53
+ rq.add_header('x-csrf-token', self.csrf)
54
+ r = self.op.open(rq, timeout=15)
55
+ self.csrf = r.headers.get('x-csrf-token', self.csrf)
56
+ return json.load(r)
57
+
58
+ def login(self, user, pw):
59
+ self.req('POST', '/api/auth/login', {'username': user, 'password': pw})
60
+
61
+ def get_switch(self):
62
+ data = self.req('GET', '/proxy/network/api/s/default/stat/device')['data']
63
+ return next(d for d in data if d.get('mac') == SWITCH_MAC)
64
+
65
+ def set_poe(self, poe_mode):
66
+ # port_overrides is replaced wholesale on PUT — merge, never clobber.
67
+ sw = self.get_switch()
68
+ overrides = sw.get('port_overrides', [])
69
+ entry = next((o for o in overrides if o.get('port_idx') == PORT_IDX), None)
70
+ if entry is None:
71
+ entry = {'port_idx': PORT_IDX}
72
+ overrides.append(entry)
73
+ entry['poe_mode'] = poe_mode
74
+ self.req('PUT', f"/proxy/network/api/s/default/rest/device/{sw['_id']}",
75
+ {'port_overrides': overrides})
76
+ log(f'port {PORT_IDX} poe_mode -> {poe_mode}')
77
+
78
+ def stick_up():
79
+ # TCP probe of the Stick's own control port — proves the device booted,
80
+ # not just that the link is up. (The host has no ping binary anyway.)
81
+ try:
82
+ socket.create_connection((STICK_IP, 2431), timeout=2).close()
83
+ return True
84
+ except OSError:
85
+ return False
86
+
87
+ def main():
88
+ log(f'power-cycling Stick-DE3 (sw {SWITCH_MAC} port {PORT_IDX}, {OFF_SECONDS}s off)')
89
+ api = Unifi()
90
+ api.login(*load_creds())
91
+ api.set_poe('off')
92
+ try:
93
+ time.sleep(OFF_SECONDS)
94
+ finally:
95
+ # restore is sacred: retry hard so the port can never stay dark
96
+ for attempt in range(5):
97
+ try:
98
+ api.set_poe('auto')
99
+ break
100
+ except Exception as e:
101
+ log(f'restore attempt {attempt + 1} failed: {e}')
102
+ time.sleep(5)
103
+ try:
104
+ api.login(*load_creds())
105
+ except Exception:
106
+ pass
107
+ else:
108
+ log('FATAL: could not restore PoE — port may be off!')
109
+ sys.exit(2)
110
+
111
+ deadline = time.time() + BOOT_WAIT_S
112
+ while time.time() < deadline:
113
+ if stick_up():
114
+ log(f'Stick back up ({STICK_IP}:2431 accepting connections)')
115
+ return
116
+ time.sleep(5)
117
+ log(f'WARNING: Stick not reachable within {BOOT_WAIT_S}s of power restore')
118
+ sys.exit(1)
119
+
120
+ if __name__ == '__main__':
121
+ main()