@ecopoesis/homebridge-dmx 0.1.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.
- package/CLAUDE.md +176 -0
- package/README.md +146 -0
- package/config.schema.json +143 -0
- package/dist/cli/stick.d.ts +3 -0
- package/dist/cli/stick.d.ts.map +1 -0
- package/dist/cli/stick.js +176 -0
- package/dist/cli/stick.js.map +1 -0
- package/dist/color/cct.d.ts +3 -0
- package/dist/color/cct.d.ts.map +1 -0
- package/dist/color/cct.js +33 -0
- package/dist/color/cct.js.map +1 -0
- package/dist/color/dimmer.d.ts +3 -0
- package/dist/color/dimmer.d.ts.map +1 -0
- package/dist/color/dimmer.js +16 -0
- package/dist/color/dimmer.js.map +1 -0
- package/dist/color/hsvcct.d.ts +3 -0
- package/dist/color/hsvcct.d.ts.map +1 -0
- package/dist/color/hsvcct.js +52 -0
- package/dist/color/hsvcct.js.map +1 -0
- package/dist/color/index.d.ts +7 -0
- package/dist/color/index.d.ts.map +1 -0
- package/dist/color/index.js +28 -0
- package/dist/color/index.js.map +1 -0
- package/dist/color/parsers.d.ts +20 -0
- package/dist/color/parsers.d.ts.map +1 -0
- package/dist/color/parsers.js +232 -0
- package/dist/color/parsers.js.map +1 -0
- package/dist/color/rgb.d.ts +3 -0
- package/dist/color/rgb.d.ts.map +1 -0
- package/dist/color/rgb.js +32 -0
- package/dist/color/rgb.js.map +1 -0
- package/dist/color/rgbaw.d.ts +3 -0
- package/dist/color/rgbaw.d.ts.map +1 -0
- package/dist/color/rgbaw.js +48 -0
- package/dist/color/rgbaw.js.map +1 -0
- package/dist/color/rgbw.d.ts +3 -0
- package/dist/color/rgbw.d.ts.map +1 -0
- package/dist/color/rgbw.js +37 -0
- package/dist/color/rgbw.js.map +1 -0
- package/dist/color/rgbww.d.ts +3 -0
- package/dist/color/rgbww.d.ts.map +1 -0
- package/dist/color/rgbww.js +54 -0
- package/dist/color/rgbww.js.map +1 -0
- package/dist/color/types.d.ts +64 -0
- package/dist/color/types.d.ts.map +1 -0
- package/dist/color/types.js +91 -0
- package/dist/color/types.js.map +1 -0
- package/dist/config.d.ts +60 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +191 -0
- package/dist/config.js.map +1 -0
- package/dist/controller.d.ts +32 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +128 -0
- package/dist/controller.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/platform.d.ts +17 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/platform.js +96 -0
- package/dist/platform.js.map +1 -0
- package/dist/platformAccessory.d.ts +18 -0
- package/dist/platformAccessory.d.ts.map +1 -0
- package/dist/platformAccessory.js +81 -0
- package/dist/platformAccessory.js.map +1 -0
- package/dist/settings.d.ts +14 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +15 -0
- package/dist/settings.js.map +1 -0
- package/dist/stick/frame.d.ts +16 -0
- package/dist/stick/frame.d.ts.map +1 -0
- package/dist/stick/frame.js +51 -0
- package/dist/stick/frame.js.map +1 -0
- package/dist/stick/kdf.d.ts +23 -0
- package/dist/stick/kdf.d.ts.map +1 -0
- package/dist/stick/kdf.js +90 -0
- package/dist/stick/kdf.js.map +1 -0
- package/dist/stick/protocol.d.ts +28 -0
- package/dist/stick/protocol.d.ts.map +1 -0
- package/dist/stick/protocol.js +78 -0
- package/dist/stick/protocol.js.map +1 -0
- package/dist/stick/session.d.ts +50 -0
- package/dist/stick/session.d.ts.map +1 -0
- package/dist/stick/session.js +301 -0
- package/dist/stick/session.js.map +1 -0
- package/examples/dmx.yaml +65 -0
- package/package.json +51 -0
- package/src/cli/stick.ts +198 -0
- package/src/color/cct.ts +40 -0
- package/src/color/dimmer.ts +24 -0
- package/src/color/hsvcct.ts +59 -0
- package/src/color/index.ts +34 -0
- package/src/color/parsers.ts +232 -0
- package/src/color/rgb.ts +33 -0
- package/src/color/rgbaw.ts +46 -0
- package/src/color/rgbw.ts +36 -0
- package/src/color/rgbww.ts +56 -0
- package/src/color/types.ts +150 -0
- package/src/config.ts +248 -0
- package/src/controller.ts +136 -0
- package/src/index.ts +9 -0
- package/src/platform.ts +118 -0
- package/src/platformAccessory.ts +102 -0
- package/src/settings.ts +18 -0
- package/src/stick/frame.ts +70 -0
- package/src/stick/kdf.ts +99 -0
- package/src/stick/protocol.ts +90 -0
- package/src/stick/session.ts +348 -0
- package/stick-de3.json +6 -0
- package/tools/aes-hook.c +1404 -0
- package/tools/analyze-pcap.mjs +143 -0
- package/tools/capture-aes-key.lldb +25 -0
- package/tools/capture-and-verify.sh +46 -0
- package/tools/capture-handshake.sh +59 -0
- package/tools/capture-send.sh +39 -0
- package/tools/crack-kdf.mjs +121 -0
- package/tools/crack-key.mjs +170 -0
- package/tools/decode-frame.mjs +110 -0
- package/tools/decrypt-011c.mjs +123 -0
- package/tools/decrypt-dmx.mjs +130 -0
- package/tools/decrypt-ssl2.ts +154 -0
- package/tools/derive-dmx-key.mjs +189 -0
- package/tools/diff-011c.mjs +127 -0
- package/tools/dump-pcap-summary.mjs +54 -0
- package/tools/dump-tcp-stream.mjs +123 -0
- package/tools/extract-011c.mjs +117 -0
- package/tools/extract-frame.mjs +78 -0
- package/tools/find-011c-key.mjs +140 -0
- package/tools/find-iv.mjs +153 -0
- package/tools/ghidra/README.md +136 -0
- package/tools/ghidra/__pycache__/dump-011c-callers.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-011c-handler.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-aes-candidates.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-anchors.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-auth2.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-authenticate.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-block-cipher.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-cipher.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-ctor-chain.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-curve.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-encompassing.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-handshake.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-kdf-chain.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-kdf-context.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-profile.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-real-encrypt.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-secret.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-setsoftware.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-stick3-vtable.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-swxref.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/dump-vt-key-getter.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-cipher.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-ctor.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-md5.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-secret-users.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-stick3-crypto.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-stick3-device.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-stick3a-ctor.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-stick3anet-ctor.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/find-vtables.cpython-313.pyc +0 -0
- package/tools/ghidra/__pycache__/string-walk.cpython-313.pyc +0 -0
- package/tools/ghidra/dump-011c-callers.py +140 -0
- package/tools/ghidra/dump-011c-handler.py +158 -0
- package/tools/ghidra/dump-aes-candidates.py +99 -0
- package/tools/ghidra/dump-anchors.py +192 -0
- package/tools/ghidra/dump-auth2.py +50 -0
- package/tools/ghidra/dump-authenticate.py +71 -0
- package/tools/ghidra/dump-block-cipher.py +88 -0
- package/tools/ghidra/dump-cipher.py +106 -0
- package/tools/ghidra/dump-ctor-chain.py +107 -0
- package/tools/ghidra/dump-curve.py +112 -0
- package/tools/ghidra/dump-encompassing.py +146 -0
- package/tools/ghidra/dump-handshake.py +76 -0
- package/tools/ghidra/dump-kdf-chain.py +105 -0
- package/tools/ghidra/dump-kdf-context.py +72 -0
- package/tools/ghidra/dump-profile.py +59 -0
- package/tools/ghidra/dump-real-encrypt.py +90 -0
- package/tools/ghidra/dump-secret.py +84 -0
- package/tools/ghidra/dump-setsoftware.py +63 -0
- package/tools/ghidra/dump-stick3-vtable.py +182 -0
- package/tools/ghidra/dump-swxref.py +27 -0
- package/tools/ghidra/dump-vt-key-getter.py +170 -0
- package/tools/ghidra/find-cipher.py +210 -0
- package/tools/ghidra/find-ctor.py +109 -0
- package/tools/ghidra/find-md5.py +97 -0
- package/tools/ghidra/find-secret-users.py +98 -0
- package/tools/ghidra/find-stick3-crypto.py +190 -0
- package/tools/ghidra/find-stick3-device.py +111 -0
- package/tools/ghidra/find-stick3a-ctor.py +87 -0
- package/tools/ghidra/find-stick3anet-ctor.py +76 -0
- package/tools/ghidra/find-vtables.py +165 -0
- package/tools/ghidra/out-011c-callers-run.log +51 -0
- package/tools/ghidra/out-011c-run.log +50 -0
- package/tools/ghidra/out-vt-key-run.log +55 -0
- package/tools/ghidra/string-walk.py +180 -0
- package/tools/hmac-key.sh +51 -0
- package/tools/hwm-entitlements.plist +18 -0
- package/tools/inspect-plaintext.mjs +87 -0
- package/tools/lldb-capture-aes.py +137 -0
- package/tools/lldb_hmac_key.py +93 -0
- package/tools/lldb_pathA_scan.py +341 -0
- package/tools/lldb_pathC_keytrace.py +300 -0
- package/tools/lldb_pathD_kdf_watch.py +281 -0
- package/tools/pathA-launch-hwm.sh +86 -0
- package/tools/pathA-scan.sh +52 -0
- package/tools/pathC-keytrace.sh +49 -0
- package/tools/pathC-run.sh +71 -0
- package/tools/pathD-kdf-watch.sh +51 -0
- package/tools/quick-trigger.mjs +86 -0
- package/tools/run-hooked-hwmanager.sh +51 -0
- package/tools/scan-aes-schedule.mjs +135 -0
- package/tools/send_dmx.mjs +480 -0
- package/tools/stick-status.mjs +57 -0
- package/tools/test-aes-detector.mjs +100 -0
- package/tools/test-tcp-write.ts +274 -0
- package/tools/test-udp-dmx.ts +324 -0
- package/tools/test-x25519.mjs +87 -0
- package/tools/try-decrypt.ts +144 -0
- package/tools/try-hardcoded-key.mjs +86 -0
- package/tools/try-hardcoded-key.ts +103 -0
- package/tools/try-key.mjs +103 -0
- package/tools/verify-011c-key.mjs +121 -0
- package/tools/verify-kdf.mjs +142 -0
- package/tsconfig.json +19 -0
package/src/platform.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// DmxPlatform — Homebridge DynamicPlatformPlugin entry.
|
|
2
|
+
//
|
|
3
|
+
// Loads the config, instantiates one StickController per controller spec,
|
|
4
|
+
// creates one Lightbulb accessory per patch entry, wires each accessory to
|
|
5
|
+
// its controller.
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
API,
|
|
9
|
+
Characteristic,
|
|
10
|
+
DynamicPlatformPlugin,
|
|
11
|
+
Logger,
|
|
12
|
+
PlatformAccessory,
|
|
13
|
+
PlatformConfig,
|
|
14
|
+
Service,
|
|
15
|
+
} from 'homebridge';
|
|
16
|
+
|
|
17
|
+
import { Controller, LoadedConfig, loadConfig, RawConfig } from './config.js';
|
|
18
|
+
import { StickController } from './controller.js';
|
|
19
|
+
import { StickFixture } from './platformAccessory.js';
|
|
20
|
+
import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js';
|
|
21
|
+
|
|
22
|
+
export class DmxPlatform implements DynamicPlatformPlugin {
|
|
23
|
+
public readonly Service: typeof Service;
|
|
24
|
+
public readonly Characteristic: typeof Characteristic;
|
|
25
|
+
public readonly accessories: PlatformAccessory[] = [];
|
|
26
|
+
|
|
27
|
+
private cfg: LoadedConfig | null = null;
|
|
28
|
+
private controllers = new Map<string, StickController>();
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
public readonly log: Logger,
|
|
32
|
+
public readonly config: PlatformConfig,
|
|
33
|
+
public readonly api: API,
|
|
34
|
+
) {
|
|
35
|
+
this.Service = api.hap.Service;
|
|
36
|
+
this.Characteristic = api.hap.Characteristic;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
this.cfg = loadConfig(
|
|
40
|
+
config as unknown as RawConfig,
|
|
41
|
+
api.user?.storagePath?.() ?? process.cwd(),
|
|
42
|
+
);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
this.log.error('config error:', (e as Error).message);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const c of this.cfg.controllers) {
|
|
49
|
+
this.controllers.set(c.id, this.makeController(c));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this.log.info(
|
|
53
|
+
`DMX platform configured: ${this.cfg.controllers.length} controller` +
|
|
54
|
+
`${this.cfg.controllers.length === 1 ? '' : 's'}, ` +
|
|
55
|
+
`${this.cfg.fixtures.length} fixture${this.cfg.fixtures.length === 1 ? '' : 's'}`,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
this.api.on('didFinishLaunching', () => this.discoverDevices());
|
|
59
|
+
this.api.on('shutdown', () => {
|
|
60
|
+
for (const c of this.controllers.values()) c.shutdown();
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private makeController(c: Controller): StickController {
|
|
65
|
+
switch (c.type) {
|
|
66
|
+
case 'StickDE3':
|
|
67
|
+
return new StickController(c.ip, this.log);
|
|
68
|
+
default:
|
|
69
|
+
throw new Error(`unsupported controller type "${(c as Controller).type}"`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Called by Homebridge for each cached accessory at startup. */
|
|
74
|
+
configureAccessory(accessory: PlatformAccessory): void {
|
|
75
|
+
this.log.debug(`restoring cached accessory: ${accessory.displayName}`);
|
|
76
|
+
this.accessories.push(accessory);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private discoverDevices(): void {
|
|
80
|
+
if (!this.cfg) return;
|
|
81
|
+
|
|
82
|
+
const wantUuids = new Set<string>();
|
|
83
|
+
for (const fx of this.cfg.fixtures) {
|
|
84
|
+
const uuid = this.api.hap.uuid.generate(`dmx:${fx.controller.id}:${fx.id}`);
|
|
85
|
+
wantUuids.add(uuid);
|
|
86
|
+
|
|
87
|
+
const controller = this.controllers.get(fx.controller.id);
|
|
88
|
+
if (!controller) {
|
|
89
|
+
this.log.error(`fixture "${fx.id}": no controller "${fx.controller.id}"`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const existing = this.accessories.find((a) => a.UUID === uuid);
|
|
94
|
+
if (existing) {
|
|
95
|
+
this.log.debug(`wiring cached accessory ${fx.id}`);
|
|
96
|
+
existing.context.fixture = { id: fx.id, controllerId: fx.controller.id };
|
|
97
|
+
new StickFixture(this, existing, fx, controller);
|
|
98
|
+
} else {
|
|
99
|
+
this.log.info(`registering new accessory: ${fx.name} (${fx.id})`);
|
|
100
|
+
const accessory = new this.api.platformAccessory(fx.name, uuid);
|
|
101
|
+
accessory.context.fixture = { id: fx.id, controllerId: fx.controller.id };
|
|
102
|
+
new StickFixture(this, accessory, fx, controller);
|
|
103
|
+
this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
|
|
104
|
+
this.accessories.push(accessory);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const stale = this.accessories.filter((a) => !wantUuids.has(a.UUID));
|
|
109
|
+
if (stale.length) {
|
|
110
|
+
this.log.info(`unregistering ${stale.length} stale accessor${stale.length === 1 ? 'y' : 'ies'}`);
|
|
111
|
+
this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, stale);
|
|
112
|
+
for (const a of stale) {
|
|
113
|
+
const i = this.accessories.indexOf(a);
|
|
114
|
+
if (i >= 0) this.accessories.splice(i, 1);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// StickFixture — one HomeKit Lightbulb accessory per patched fixture.
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
CharacteristicValue,
|
|
5
|
+
PlatformAccessory,
|
|
6
|
+
Service,
|
|
7
|
+
} from 'homebridge';
|
|
8
|
+
|
|
9
|
+
import { Fixture } from './config.js';
|
|
10
|
+
import { HomeKitLightState } from './color/types.js';
|
|
11
|
+
import { StickController } from './controller.js';
|
|
12
|
+
import type { DmxPlatform } from './platform.js';
|
|
13
|
+
import { CHARACTERISTIC_UPDATE_DELAY_MS } from './settings.js';
|
|
14
|
+
|
|
15
|
+
export class StickFixture {
|
|
16
|
+
/** Current HomeKit state we'll send to the Stick on the next push. */
|
|
17
|
+
private state: HomeKitLightState = { on: false, brightness: 100 };
|
|
18
|
+
|
|
19
|
+
/** Debounce timer for HomeKit's multi-characteristic dispatch
|
|
20
|
+
* (Hue/Saturation/Brightness arrive as 3 separate set calls). */
|
|
21
|
+
private pending: NodeJS.Timeout | null = null;
|
|
22
|
+
|
|
23
|
+
constructor(
|
|
24
|
+
private readonly platform: DmxPlatform,
|
|
25
|
+
private readonly accessory: PlatformAccessory,
|
|
26
|
+
private readonly fixture: Fixture,
|
|
27
|
+
private readonly controller: StickController,
|
|
28
|
+
) {
|
|
29
|
+
const C = platform.Characteristic;
|
|
30
|
+
const info = accessory.getService(platform.Service.AccessoryInformation);
|
|
31
|
+
info?.setCharacteristic(C.Manufacturer, 'Stick-DE3 DMX')
|
|
32
|
+
?.setCharacteristic(C.Model, fixture.profile.name)
|
|
33
|
+
?.setCharacteristic(C.SerialNumber, fixture.id);
|
|
34
|
+
|
|
35
|
+
const bulb: Service =
|
|
36
|
+
accessory.getService(platform.Service.Lightbulb)
|
|
37
|
+
?? accessory.addService(platform.Service.Lightbulb, fixture.name);
|
|
38
|
+
|
|
39
|
+
bulb.setCharacteristic(C.Name, fixture.name);
|
|
40
|
+
|
|
41
|
+
const chars = fixture.profile.model.characteristics;
|
|
42
|
+
|
|
43
|
+
bulb.getCharacteristic(C.On)
|
|
44
|
+
.onGet(() => this.state.on)
|
|
45
|
+
.onSet((v) => { this.state.on = Boolean(v); this.schedule(); });
|
|
46
|
+
|
|
47
|
+
if (chars.includes('Brightness')) {
|
|
48
|
+
bulb.getCharacteristic(C.Brightness)
|
|
49
|
+
.onGet(() => this.state.brightness)
|
|
50
|
+
.onSet((v: CharacteristicValue) => {
|
|
51
|
+
this.state.brightness = Number(v);
|
|
52
|
+
this.schedule();
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (chars.includes('Hue')) {
|
|
57
|
+
bulb.getCharacteristic(C.Hue)
|
|
58
|
+
.onGet(() => this.state.hue ?? 0)
|
|
59
|
+
.onSet((v) => {
|
|
60
|
+
this.state.hue = Number(v);
|
|
61
|
+
// setting hue implies color mode (sat>0); leave saturation as-is
|
|
62
|
+
this.schedule();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (chars.includes('Saturation')) {
|
|
67
|
+
bulb.getCharacteristic(C.Saturation)
|
|
68
|
+
.onGet(() => this.state.saturation ?? 0)
|
|
69
|
+
.onSet((v) => {
|
|
70
|
+
this.state.saturation = Number(v);
|
|
71
|
+
this.schedule();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (chars.includes('ColorTemperature')) {
|
|
76
|
+
bulb.getCharacteristic(C.ColorTemperature)
|
|
77
|
+
.onGet(() => this.state.colorTemperatureMireds ?? 200)
|
|
78
|
+
.onSet((v) => {
|
|
79
|
+
this.state.colorTemperatureMireds = Number(v);
|
|
80
|
+
// CCT mode: HomeKit toggles between color and white modes; we mirror
|
|
81
|
+
// by zeroing saturation when CCT is changed.
|
|
82
|
+
this.state.saturation = 0;
|
|
83
|
+
this.schedule();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private schedule(): void {
|
|
89
|
+
if (this.pending) clearTimeout(this.pending);
|
|
90
|
+
this.pending = setTimeout(() => {
|
|
91
|
+
this.pending = null;
|
|
92
|
+
const s = this.state;
|
|
93
|
+
this.platform.log.info(
|
|
94
|
+
`[${this.fixture.id}] HK set: on=${s.on} br=${s.brightness}` +
|
|
95
|
+
(s.hue != null ? ` h=${s.hue}` : '') +
|
|
96
|
+
(s.saturation != null ? ` s=${s.saturation}` : '') +
|
|
97
|
+
(s.colorTemperatureMireds != null ? ` ct=${s.colorTemperatureMireds}` : ''),
|
|
98
|
+
);
|
|
99
|
+
this.controller.setFixture(this.fixture, this.state);
|
|
100
|
+
}, CHARACTERISTIC_UPDATE_DELAY_MS);
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Homebridge plugin constants.
|
|
2
|
+
|
|
3
|
+
export const PLATFORM_NAME = 'DMX';
|
|
4
|
+
export const PLUGIN_NAME = '@ecopoesis/homebridge-dmx';
|
|
5
|
+
|
|
6
|
+
/** Coalesce HomeKit's multi-characteristic dispatch (Hue+Sat+Brightness
|
|
7
|
+
* arrive as 3 separate sets when picking a color). Mirrors unifi-ap-rgb. */
|
|
8
|
+
export const CHARACTERISTIC_UPDATE_DELAY_MS = 50;
|
|
9
|
+
|
|
10
|
+
/** "Send until quiet" debounce. After the LAST HomeKit change, we wait
|
|
11
|
+
* this long before spawning the send_dmx subprocess. Coalesces a fast
|
|
12
|
+
* slider drag into one transaction. */
|
|
13
|
+
export const DEBOUNCE_MS = 750;
|
|
14
|
+
|
|
15
|
+
/** Frame interval (40ms = 25Hz, matches HWM exactly). Unused by the
|
|
16
|
+
* subprocess controller — send_dmx.mjs's own FRAME_HZ env knob is what
|
|
17
|
+
* matters now. */
|
|
18
|
+
export const FRAME_INTERVAL_MS = 40;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Encrypted 576-byte DMX frame builder.
|
|
2
|
+
//
|
|
3
|
+
// Layout (verified empirically 2026-05-23):
|
|
4
|
+
//
|
|
5
|
+
// 32-byte clear header
|
|
6
|
+
// +0x00 (8B) magic = "Stick_3A"
|
|
7
|
+
// +0x08 (2B) opcode 0x0019
|
|
8
|
+
// +0x0a (8B) fieldA — the shared session message counter (Token)
|
|
9
|
+
// +0x12 (2B) port — 0 for universe A; B unknown
|
|
10
|
+
// +0x14 (2B) channel count, always 512
|
|
11
|
+
// +0x16 (1B) constant 100
|
|
12
|
+
// +0x17 (1B) frame seq counter (per-frame, modulo 256)
|
|
13
|
+
// +0x18 (8B) random nonce
|
|
14
|
+
//
|
|
15
|
+
// 544-byte AES-256-CBC ciphertext
|
|
16
|
+
// IV = fieldA(8) ‖ nonce(8)
|
|
17
|
+
// plaintext = [P0(16)][zeros(16)][channels(512)] ← off-by-16 fix
|
|
18
|
+
// (was [P0][channels][trailer])
|
|
19
|
+
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
import { Buffer } from 'node:buffer';
|
|
22
|
+
import { MAGIC, P0 } from './protocol.js';
|
|
23
|
+
|
|
24
|
+
export interface FrameOpts {
|
|
25
|
+
/** AES-256 session key (32B) from KDF */
|
|
26
|
+
key: Buffer;
|
|
27
|
+
/** 512 DMX channel values (0-255 each) */
|
|
28
|
+
channels: Uint8Array;
|
|
29
|
+
/** universe port id (0 = A; B not yet known) */
|
|
30
|
+
port: number;
|
|
31
|
+
/** 8-byte fieldA from the session Token counter */
|
|
32
|
+
fieldA: Buffer;
|
|
33
|
+
/** per-frame sequence counter (low byte) */
|
|
34
|
+
seq: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Build one 576-byte encrypted DMX frame. */
|
|
38
|
+
export function buildFrame(opts: FrameOpts): Buffer {
|
|
39
|
+
if (opts.channels.length !== 512) {
|
|
40
|
+
throw new Error(`channels must be 512 bytes, got ${opts.channels.length}`);
|
|
41
|
+
}
|
|
42
|
+
if (opts.key.length !== 32) {
|
|
43
|
+
throw new Error(`key must be 32 bytes (AES-256), got ${opts.key.length}`);
|
|
44
|
+
}
|
|
45
|
+
if (opts.fieldA.length !== 8) {
|
|
46
|
+
throw new Error(`fieldA must be 8 bytes, got ${opts.fieldA.length}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const nonce = crypto.randomBytes(8);
|
|
50
|
+
const iv = Buffer.concat([opts.fieldA, nonce]); // 16-byte CBC IV
|
|
51
|
+
|
|
52
|
+
// [P0(16)][zeros(16)][channels(512)] = 544
|
|
53
|
+
const plain = Buffer.concat([P0, Buffer.alloc(16), Buffer.from(opts.channels)]);
|
|
54
|
+
|
|
55
|
+
const c = crypto.createCipheriv('aes-256-cbc', opts.key, iv);
|
|
56
|
+
c.setAutoPadding(false);
|
|
57
|
+
const body = Buffer.concat([c.update(plain), c.final()]); // 544
|
|
58
|
+
|
|
59
|
+
const hdr = Buffer.alloc(32);
|
|
60
|
+
MAGIC.copy(hdr, 0);
|
|
61
|
+
hdr.writeUInt16LE(0x0019, 8);
|
|
62
|
+
opts.fieldA.copy(hdr, 0x0a);
|
|
63
|
+
hdr.writeUInt16LE(opts.port, 0x12);
|
|
64
|
+
hdr.writeUInt16LE(512, 0x14);
|
|
65
|
+
hdr[0x16] = 100;
|
|
66
|
+
hdr[0x17] = opts.seq & 0xff;
|
|
67
|
+
nonce.copy(hdr, 0x18);
|
|
68
|
+
|
|
69
|
+
return Buffer.concat([hdr, body]); // 576
|
|
70
|
+
}
|
package/src/stick/kdf.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Stick-DE3 DMX cipher — per-session AES-256 key derivation (KDF).
|
|
2
|
+
//
|
|
3
|
+
// TypeScript port of tools/derive-dmx-key.mjs (which remains the canonical
|
|
4
|
+
// reference). The .mjs version is kept untouched so diagnostic tools that
|
|
5
|
+
// import it (verify-kdf.mjs, decrypt-dmx.mjs, etc.) keep working.
|
|
6
|
+
//
|
|
7
|
+
// Math summary (verified end-to-end against a real session):
|
|
8
|
+
//
|
|
9
|
+
// curve = NIST P-256 / prime256v1
|
|
10
|
+
// d = random scalar mod n (ephemeral private, 32B)
|
|
11
|
+
// our = d · G (our public key, sent in 0x0F)
|
|
12
|
+
// Q = stick's response pubkey (received in 0x0F reply)
|
|
13
|
+
// S = decompress(STATIC_POINT_33B) (hardcoded in HM binary)
|
|
14
|
+
//
|
|
15
|
+
// AES-256 key = LE( X(d · S) XOR X(d · Q) )
|
|
16
|
+
//
|
|
17
|
+
// Endianness: every 256-bit coord on the wire is LITTLE-endian limbs;
|
|
18
|
+
// node's `crypto` works in big-endian (SEC1), so we byte-reverse at every
|
|
19
|
+
// boundary. The installed AES key is the LE form of the XOR.
|
|
20
|
+
|
|
21
|
+
import crypto from 'node:crypto';
|
|
22
|
+
import { Buffer } from 'node:buffer';
|
|
23
|
+
|
|
24
|
+
export const P256 = {
|
|
25
|
+
p: 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffffn,
|
|
26
|
+
n: 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n,
|
|
27
|
+
a: 0xffffffff00000001000000000000000000000000fffffffffffffffffffffffcn,
|
|
28
|
+
b: 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn,
|
|
29
|
+
Gx: 0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296n,
|
|
30
|
+
Gy: 0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5n,
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
// 33-byte SEC1-compressed static point. Stored verbatim at HardwareManager
|
|
34
|
+
// file offset 0x7b0490 (vmaddr 0x1007b0490), prefix 0x03 (odd Y).
|
|
35
|
+
export const STATIC_POINT_COMPRESSED = Buffer.from(
|
|
36
|
+
'0328081290396d063e5114526ed978b926558f6ba1c96ad2facf76fffb3ffea0fe',
|
|
37
|
+
'hex',
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const rev = (b: Buffer): Buffer => Buffer.from(b).reverse();
|
|
41
|
+
const pad32 = (b: Buffer): Buffer =>
|
|
42
|
+
b.length >= 32 ? b.subarray(b.length - 32)
|
|
43
|
+
: Buffer.concat([Buffer.alloc(32 - b.length), b]);
|
|
44
|
+
|
|
45
|
+
function modpow(base: bigint, exp: bigint, mod: bigint): bigint {
|
|
46
|
+
let r = 1n;
|
|
47
|
+
base %= mod;
|
|
48
|
+
while (exp > 0n) {
|
|
49
|
+
if (exp & 1n) r = (r * base) % mod;
|
|
50
|
+
base = (base * base) % mod;
|
|
51
|
+
exp >>= 1n;
|
|
52
|
+
}
|
|
53
|
+
return r;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** SEC1-decompress a 33-byte compressed point to 65-byte uncompressed. */
|
|
57
|
+
export function decompressPoint(compressed33: Buffer): Buffer {
|
|
58
|
+
const { p, a, b } = P256;
|
|
59
|
+
const prefix = compressed33[0];
|
|
60
|
+
const x = BigInt('0x' + compressed33.subarray(1, 33).toString('hex'));
|
|
61
|
+
const rhs = ((((x * x) % p) * x) % p + ((a * x) % p) + b) % p;
|
|
62
|
+
let y = modpow(rhs, (p + 1n) / 4n, p); // p ≡ 3 (mod 4)
|
|
63
|
+
if ((y * y) % p !== rhs) throw new Error('static point X is not on P-256');
|
|
64
|
+
if ((y & 1n) !== BigInt(prefix & 1)) y = p - y;
|
|
65
|
+
const hex = (v: bigint) => v.toString(16).padStart(64, '0');
|
|
66
|
+
return Buffer.from('04' + hex(x) + hex(y), 'hex');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Uncompressed SEC1 point (65B, 0x04‖X_be‖Y_be) → 64B little-endian wire. */
|
|
70
|
+
export function pointToWire(uncompressed65: Buffer): Buffer {
|
|
71
|
+
const xbe = uncompressed65.subarray(1, 33);
|
|
72
|
+
const ybe = uncompressed65.subarray(33, 65);
|
|
73
|
+
return Buffer.concat([rev(xbe), rev(ybe)]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 64B little-endian wire point → uncompressed SEC1 (65B) for node crypto. */
|
|
77
|
+
export function wireToPoint(wire64: Buffer): Buffer {
|
|
78
|
+
const xbe = rev(wire64.subarray(0, 32));
|
|
79
|
+
const ybe = rev(wire64.subarray(32, 64));
|
|
80
|
+
return Buffer.concat([Buffer.from([0x04]), xbe, ybe]);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Create the ephemeral P-256 keypair (steps 1-2 of the KDF). */
|
|
84
|
+
export function makeEphemeral(): crypto.ECDH {
|
|
85
|
+
const ecdh = crypto.createECDH('prime256v1');
|
|
86
|
+
ecdh.generateKeys();
|
|
87
|
+
return ecdh;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Derive the per-session AES-256 key.
|
|
91
|
+
* key = LE( X(d·S) XOR X(d·Q) ) */
|
|
92
|
+
export function deriveDmxKey(ecdh: crypto.ECDH, stickPub65: Buffer): Buffer {
|
|
93
|
+
const S = decompressPoint(STATIC_POINT_COMPRESSED);
|
|
94
|
+
const x1 = pad32(ecdh.computeSecret(S));
|
|
95
|
+
const x2 = pad32(ecdh.computeSecret(stickPub65));
|
|
96
|
+
const xorBE = Buffer.alloc(32);
|
|
97
|
+
for (let i = 0; i < 32; i++) xorBE[i] = x1[i] ^ x2[i];
|
|
98
|
+
return rev(xorBE);
|
|
99
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Wire-protocol constants + low-level helpers for the Stick-DE3 control
|
|
2
|
+
// channel (TCP/2431) and the encrypted DMX stream (UDP/2431).
|
|
3
|
+
//
|
|
4
|
+
// Ported verbatim from tools/send_dmx.mjs (which is now frozen as the
|
|
5
|
+
// reference implementation). All offsets / magic / opcodes here have been
|
|
6
|
+
// reverse-engineered from the 2024-03-21 ESA2 HardwareManager binary; see
|
|
7
|
+
// the [[stick-cipher-is-stream-not-aes]] memory note for the full RE trail.
|
|
8
|
+
|
|
9
|
+
import { Buffer } from 'node:buffer';
|
|
10
|
+
|
|
11
|
+
export const TCP_PORT = 2431;
|
|
12
|
+
export const UDP_DST_PORT = 2431;
|
|
13
|
+
export const UDP_SRC_PORT = 2430;
|
|
14
|
+
|
|
15
|
+
export const MAGIC = Buffer.from('Stick_3A');
|
|
16
|
+
export const LSAG = Buffer.from('LSAG_ALL');
|
|
17
|
+
|
|
18
|
+
// 16-byte fixed plaintext header that every DMX frame starts with. Confirmed
|
|
19
|
+
// constant across ≥2 independent sessions. Recovered from decrypted frames.
|
|
20
|
+
export const P0 = Buffer.from('5b4e99da9685ad976c432b0a7ff9ffcc', 'hex');
|
|
21
|
+
|
|
22
|
+
// HMAC-SHA256 key for the 0x48 TCP-auth handshake.
|
|
23
|
+
// = "#h.6xcKsGD{y}-z" (15 bytes), runtime-extracted from HardwareManager
|
|
24
|
+
// and verified against a captured 0x48 message.
|
|
25
|
+
export const AUTH_KEY = Buffer.from('23682e3678634b7347447b797d2d7a', 'hex');
|
|
26
|
+
|
|
27
|
+
// The Stick's static 0x0F pubkey. Constant across every observed session
|
|
28
|
+
// (it's the "Q" the device claims as ephemeral but never actually rotates
|
|
29
|
+
// in practice). Used as a fallback if the live 0x0F reply can't be parsed.
|
|
30
|
+
// 64-byte little-endian wire form.
|
|
31
|
+
export const Q_FALLBACK = Buffer.from(
|
|
32
|
+
'87ef58c2660c272b54a74bbc94cb8518108e370b7eed78456bd8d120c6b9ac0a' +
|
|
33
|
+
'd791e4ce698aea761679f4b92a3ecf2acd12bf9bc308ce0ba8cb96630871105e',
|
|
34
|
+
'hex',
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/** A monotonic 4-byte LE counter shared between TCP messages and UDP frame
|
|
38
|
+
* fieldA. The Stick rejects out-of-order tokens. */
|
|
39
|
+
export class Token {
|
|
40
|
+
private n: number;
|
|
41
|
+
constructor(start = 0x80) { this.n = start; }
|
|
42
|
+
next(): Buffer {
|
|
43
|
+
const b = Buffer.alloc(8);
|
|
44
|
+
b.writeUInt32LE(this.n++, 0);
|
|
45
|
+
return b;
|
|
46
|
+
}
|
|
47
|
+
/** Current value without advancing. */
|
|
48
|
+
peek(): number { return this.n; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Build a TCP/2431 message: magic(8) ‖ opcode(2 LE) ‖ ...body parts. */
|
|
52
|
+
export function msg(magic: Buffer, opcode: number, ...parts: Buffer[]): Buffer {
|
|
53
|
+
const op = Buffer.alloc(2);
|
|
54
|
+
op.writeUInt16LE(opcode, 0);
|
|
55
|
+
return Buffer.concat([magic, op, ...parts]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Find a Stick_3A / LSAG_ALL message of `opcode` and at least `minLen`
|
|
59
|
+
* bytes inside the accumulating receive buffer. Returns the slice or null. */
|
|
60
|
+
export function findMsg(rxBuf: Buffer, opcode: number, minLen: number): Buffer | null {
|
|
61
|
+
for (let o = 0; o + 10 <= rxBuf.length; o++) {
|
|
62
|
+
const head = rxBuf.subarray(o, o + 8);
|
|
63
|
+
if (!head.equals(MAGIC) && !head.equals(LSAG)) continue;
|
|
64
|
+
if (rxBuf.readUInt16LE(o + 8) !== opcode) continue;
|
|
65
|
+
if (rxBuf.length < o + minLen) continue;
|
|
66
|
+
return rxBuf.subarray(o, o + minLen);
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Poll a predicate every 20ms until it returns non-null or `timeoutMs`
|
|
72
|
+
* elapses. Returns the value or null on timeout. */
|
|
73
|
+
export function waitFor<T>(
|
|
74
|
+
predicate: () => T | null,
|
|
75
|
+
timeoutMs: number,
|
|
76
|
+
): Promise<T | null> {
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
const deadline = Date.now() + timeoutMs;
|
|
79
|
+
const tick = () => {
|
|
80
|
+
const r = predicate();
|
|
81
|
+
if (r != null) return resolve(r);
|
|
82
|
+
if (Date.now() > deadline) return resolve(null);
|
|
83
|
+
setTimeout(tick, 20);
|
|
84
|
+
};
|
|
85
|
+
tick();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const sleep = (ms: number): Promise<void> =>
|
|
90
|
+
new Promise((r) => setTimeout(r, ms));
|