@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/cli/stick.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// stick — set DMX channels on a Stick-DE3, transactionally.
|
|
3
|
+
//
|
|
4
|
+
// stick [--config <yaml>] <ip> <assignment> [<assignment>...]
|
|
5
|
+
//
|
|
6
|
+
// Assignment forms:
|
|
7
|
+
// <fixture-id>=<value> named fixture from config
|
|
8
|
+
// value: "#FFFFFF", "hsl(120,50%,50%)",
|
|
9
|
+
// "hwb(180 10% 20%)", "rgb(255,128,0)",
|
|
10
|
+
// "kelvin(2700)", "50%", "128", "on", "off"
|
|
11
|
+
// u<universe>,<channel>=<byte> raw single-channel write (e.g. "uA,6=255")
|
|
12
|
+
//
|
|
13
|
+
// Env knobs (same as the StickSession opts):
|
|
14
|
+
// STREAM_MS, CHATTER_MS, SETTLE_2E_MS, FRAME_HZ, SECTORS, RUN_PROBE,
|
|
15
|
+
// PROBE_GAP_MS, KEY_DUMP
|
|
16
|
+
//
|
|
17
|
+
// Config search order if --config not given:
|
|
18
|
+
// $STICK_CONFIG, ./stick-de3.yaml, ~/.config/stick-de3.yaml.
|
|
19
|
+
// A config is OPTIONAL when ONLY raw u<universe>,<channel>=<byte> assignments
|
|
20
|
+
// are used (and the ip is on the command line).
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { Buffer } from 'node:buffer';
|
|
26
|
+
import yaml from 'js-yaml';
|
|
27
|
+
|
|
28
|
+
import { LoadedConfig, loadConfig, RawConfig } from '../config.js';
|
|
29
|
+
import { parseValue } from '../color/parsers.js';
|
|
30
|
+
import { StickSession } from '../stick/session.js';
|
|
31
|
+
|
|
32
|
+
interface CliAssignment {
|
|
33
|
+
/** universe 0/1 */
|
|
34
|
+
universe: number;
|
|
35
|
+
/** 1-based channel */
|
|
36
|
+
channel: number;
|
|
37
|
+
/** byte value */
|
|
38
|
+
value: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findDefaultConfig(): string | null {
|
|
42
|
+
const candidates = [
|
|
43
|
+
process.env.STICK_CONFIG,
|
|
44
|
+
path.resolve(process.cwd(), 'stick-de3.yaml'),
|
|
45
|
+
path.resolve(os.homedir(), '.config/stick-de3.yaml'),
|
|
46
|
+
].filter(Boolean) as string[];
|
|
47
|
+
for (const c of candidates) {
|
|
48
|
+
try { fs.accessSync(c, fs.constants.R_OK); return c; } catch { /* miss */ }
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function usage(): never {
|
|
54
|
+
console.error(
|
|
55
|
+
'usage: stick [--config <yaml>] <ip> <assignment> [<assignment>...]\n' +
|
|
56
|
+
' assignments:\n' +
|
|
57
|
+
' <fixture-id>=<value> named fixture (config required)\n' +
|
|
58
|
+
' u<universe>,<channel>=<n> raw, e.g. uA,6=255',
|
|
59
|
+
);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseArgs(argv: string[]): { configPath: string | null; ip: string; assigns: string[] } {
|
|
64
|
+
let configPath: string | null = null;
|
|
65
|
+
const rest: string[] = [];
|
|
66
|
+
for (let i = 0; i < argv.length; i++) {
|
|
67
|
+
if (argv[i] === '--config' || argv[i] === '-c') {
|
|
68
|
+
configPath = argv[++i];
|
|
69
|
+
} else if (argv[i] === '-h' || argv[i] === '--help') {
|
|
70
|
+
usage();
|
|
71
|
+
} else {
|
|
72
|
+
rest.push(argv[i]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (rest.length < 2) usage();
|
|
76
|
+
return { configPath, ip: rest[0], assigns: rest.slice(1) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function loadOptionalConfig(configPath: string | null, ip: string): LoadedConfig | null {
|
|
80
|
+
const p = configPath ?? findDefaultConfig();
|
|
81
|
+
if (!p) return null;
|
|
82
|
+
const cwd = path.dirname(p);
|
|
83
|
+
const text = fs.readFileSync(p, 'utf8');
|
|
84
|
+
const raw: RawConfig & { ip?: string } =
|
|
85
|
+
p.endsWith('.yaml') || p.endsWith('.yml') ? (yaml.load(text) as RawConfig) : JSON.parse(text);
|
|
86
|
+
// CLI convenience: if the config doesn't define controllers, synthesize
|
|
87
|
+
// one from the cmdline ip (or a legacy top-level `ip:` field).
|
|
88
|
+
if (!raw.controllers || raw.controllers.length === 0) {
|
|
89
|
+
raw.controllers = [{ id: 'cli', type: 'StickDE3', ip: raw.ip ?? ip }];
|
|
90
|
+
}
|
|
91
|
+
return loadConfig(raw, cwd);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Build a Map<universe, Uint8Array(512)> of channel targets from CLI args.
|
|
95
|
+
function resolveAssignments(
|
|
96
|
+
assigns: string[],
|
|
97
|
+
cfg: LoadedConfig | null,
|
|
98
|
+
): Map<number, Uint8Array> {
|
|
99
|
+
const universes = new Map<number, Uint8Array>();
|
|
100
|
+
const slot = (u: number) => {
|
|
101
|
+
let arr = universes.get(u);
|
|
102
|
+
if (!arr) { arr = new Uint8Array(512); universes.set(u, arr); }
|
|
103
|
+
return arr;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
for (const a of assigns) {
|
|
107
|
+
const eq = a.indexOf('=');
|
|
108
|
+
if (eq < 0) throw new Error(`bad assignment "${a}": expected key=value`);
|
|
109
|
+
const lhs = a.slice(0, eq).trim();
|
|
110
|
+
const rhs = a.slice(eq + 1).trim();
|
|
111
|
+
|
|
112
|
+
// Raw: u<universe>,<channel>=<byte>
|
|
113
|
+
const raw = /^u([ab]),(\d+)$/i.exec(lhs);
|
|
114
|
+
if (raw) {
|
|
115
|
+
const u = raw[1].toUpperCase() === 'A' ? 0 : 1;
|
|
116
|
+
const ch = Number(raw[2]);
|
|
117
|
+
const byte = Number(rhs);
|
|
118
|
+
if (!Number.isInteger(ch) || ch < 1 || ch > 512) {
|
|
119
|
+
throw new Error(`raw "${a}": channel out of 1..512`);
|
|
120
|
+
}
|
|
121
|
+
if (!Number.isInteger(byte) || byte < 0 || byte > 255) {
|
|
122
|
+
throw new Error(`raw "${a}": value out of 0..255`);
|
|
123
|
+
}
|
|
124
|
+
slot(u)[ch - 1] = byte;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Named fixture
|
|
129
|
+
if (!cfg) {
|
|
130
|
+
throw new Error(`assignment "${a}" needs a config; use --config or set STICK_CONFIG`);
|
|
131
|
+
}
|
|
132
|
+
const fx = cfg.fixtures.find((f) => f.id === lhs);
|
|
133
|
+
if (!fx) {
|
|
134
|
+
throw new Error(`unknown fixture id "${lhs}". Known: ${cfg.fixtures.map((f) => f.id).join(', ')}`);
|
|
135
|
+
}
|
|
136
|
+
const state = parseValue(rhs);
|
|
137
|
+
const bytes = fx.profile.model.render(state, fx.profile.channels);
|
|
138
|
+
const arr = slot(fx.universe);
|
|
139
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
140
|
+
arr[fx.startCh - 1 + i] = bytes[i];
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return universes;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function main(): Promise<void> {
|
|
148
|
+
const { configPath, ip, assigns } = parseArgs(process.argv.slice(2));
|
|
149
|
+
const cfg = loadOptionalConfig(configPath, ip);
|
|
150
|
+
// CLI ip arg overrides config ip if both present
|
|
151
|
+
const target = ip;
|
|
152
|
+
|
|
153
|
+
const universes = resolveAssignments(assigns, cfg);
|
|
154
|
+
if (universes.size === 0) {
|
|
155
|
+
console.error('no channels to set'); process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Summarise what we're sending
|
|
159
|
+
for (const [u, arr] of universes) {
|
|
160
|
+
const lit = [...arr.entries()].filter(([, v]) => v).map(([i, v]) => `ch${i + 1}=${v}`);
|
|
161
|
+
console.log(` universe ${u ? 'B' : 'A'}: ${lit.length} channel${lit.length === 1 ? '' : 's'}: ${lit.join(' ')}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const session = await StickSession.connect(target, {
|
|
165
|
+
log: (...a) => console.log(' ', ...a),
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Optional key dump (for offline pcap decryption)
|
|
169
|
+
try {
|
|
170
|
+
const keyPath = process.env.KEY_DUMP || `${os.homedir()}/.send_dmx-key.txt`;
|
|
171
|
+
// Access the key indirectly: it's a private field of StickSession, so we
|
|
172
|
+
// dump the most recent value we know about. Since we don't expose it,
|
|
173
|
+
// skip the dump here — the diagnostic tools in tools/ already do this.
|
|
174
|
+
void keyPath;
|
|
175
|
+
} catch { /* silent */ }
|
|
176
|
+
|
|
177
|
+
const STREAM_MS = Number(process.env.STREAM_MS || 750);
|
|
178
|
+
const FRAME_HZ = Number(process.env.FRAME_HZ || 25);
|
|
179
|
+
const frameMs = Math.max(1, Math.round(1000 / FRAME_HZ));
|
|
180
|
+
|
|
181
|
+
console.log(` streaming at ${FRAME_HZ} Hz (${frameMs}ms) for ${STREAM_MS}ms`);
|
|
182
|
+
let nFrames = 0;
|
|
183
|
+
const timer = setInterval(() => {
|
|
184
|
+
for (const [u, arr] of universes) {
|
|
185
|
+
session.send(arr, u);
|
|
186
|
+
nFrames++;
|
|
187
|
+
}
|
|
188
|
+
}, frameMs);
|
|
189
|
+
|
|
190
|
+
await new Promise((res) => setTimeout(res, STREAM_MS));
|
|
191
|
+
clearInterval(timer);
|
|
192
|
+
console.log(` streamed ${nFrames} frames`);
|
|
193
|
+
|
|
194
|
+
session.destroy();
|
|
195
|
+
console.log(' disconnected (RST) — Stick should hold the last value');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
main().catch((e) => { console.error('failed:', e); process.exit(1); });
|
package/src/color/cct.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// cct — tunable-white (2 channel): intensity + color temp. No color.
|
|
2
|
+
// channel_order: intensity (+optional fine) and a cct channel with K range.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ChannelDef,
|
|
6
|
+
ColorModel,
|
|
7
|
+
HomeKitLightState,
|
|
8
|
+
brightness16,
|
|
9
|
+
ctByte,
|
|
10
|
+
requireRoles,
|
|
11
|
+
writeMaybe16,
|
|
12
|
+
} from './types.js';
|
|
13
|
+
|
|
14
|
+
export const cct: ColorModel = {
|
|
15
|
+
id: 'cct',
|
|
16
|
+
characteristics: ['On', 'Brightness', 'ColorTemperature'],
|
|
17
|
+
validate(channels) {
|
|
18
|
+
requireRoles(channels, ['intensity', 'cct']);
|
|
19
|
+
const c = channels.find((ch) => ch.role === 'cct');
|
|
20
|
+
if (!c || c.kMin == null || c.kMax == null) {
|
|
21
|
+
throw new Error("cct model needs a Kelvin range, e.g. 'ColorTemp 2700-6500'");
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
render(state: HomeKitLightState, channels: ChannelDef[]): Uint8Array {
|
|
25
|
+
const out = new Uint8Array(channels.length);
|
|
26
|
+
let i = 0;
|
|
27
|
+
while (i < channels.length) {
|
|
28
|
+
const ch = channels[i];
|
|
29
|
+
if (ch.role === 'intensity' && !ch.fine) {
|
|
30
|
+
i = writeMaybe16(out, channels, i, brightness16(state));
|
|
31
|
+
} else if (ch.role === 'cct' && !ch.fine) {
|
|
32
|
+
out[i] = ctByte(state.colorTemperatureMireds, ch.kMin!, ch.kMax!);
|
|
33
|
+
i++;
|
|
34
|
+
} else {
|
|
35
|
+
i++;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// dimmer — single-channel intensity-only fixture.
|
|
2
|
+
// channel_order: 1 entry, role 'intensity' (optionally followed by a 'fine').
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ChannelDef,
|
|
6
|
+
ColorModel,
|
|
7
|
+
HomeKitLightState,
|
|
8
|
+
brightness16,
|
|
9
|
+
requireRoles,
|
|
10
|
+
writeMaybe16,
|
|
11
|
+
} from './types.js';
|
|
12
|
+
|
|
13
|
+
export const dimmer: ColorModel = {
|
|
14
|
+
id: 'dimmer',
|
|
15
|
+
characteristics: ['On', 'Brightness'],
|
|
16
|
+
validate(channels) {
|
|
17
|
+
requireRoles(channels, ['intensity']);
|
|
18
|
+
},
|
|
19
|
+
render(state: HomeKitLightState, channels: ChannelDef[]): Uint8Array {
|
|
20
|
+
const out = new Uint8Array(channels.length);
|
|
21
|
+
writeMaybe16(out, channels, 0, brightness16(state));
|
|
22
|
+
return out;
|
|
23
|
+
},
|
|
24
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// hsvcct — the WAC DC-WD05 native model.
|
|
2
|
+
//
|
|
3
|
+
// 5 channels: intensity, intensity (fine), cct, saturation, hue. Saturation
|
|
4
|
+
// = 0 → fixture is in CCT-only white mode (uses the cct channel).
|
|
5
|
+
// Saturation > 0 → fixture is in color mode (uses hue + saturation).
|
|
6
|
+
//
|
|
7
|
+
// Mapping per CLAUDE.md (verified on hardware 2026-05-23):
|
|
8
|
+
// On → ch0 high byte / ch1 low byte = intensity
|
|
9
|
+
// Brightness → ch0/ch1 16-bit
|
|
10
|
+
// Hue → ch4 = round(hue * 255 / 360)
|
|
11
|
+
// Saturation → ch3 = round(sat * 2.55)
|
|
12
|
+
// CCT → ch2 = scale-to-Krange (K from 1000000/mireds)
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
ChannelDef,
|
|
16
|
+
ColorModel,
|
|
17
|
+
HomeKitLightState,
|
|
18
|
+
brightness16,
|
|
19
|
+
clamp,
|
|
20
|
+
ctByte,
|
|
21
|
+
requireRoles,
|
|
22
|
+
writeMaybe16,
|
|
23
|
+
} from './types.js';
|
|
24
|
+
|
|
25
|
+
export const hsvcct: ColorModel = {
|
|
26
|
+
id: 'hsvcct',
|
|
27
|
+
characteristics: ['On', 'Brightness', 'Hue', 'Saturation', 'ColorTemperature'],
|
|
28
|
+
validate(channels) {
|
|
29
|
+
requireRoles(channels, ['intensity', 'cct', 'saturation', 'hue']);
|
|
30
|
+
const c = channels.find((ch) => ch.role === 'cct');
|
|
31
|
+
if (!c || c.kMin == null || c.kMax == null) {
|
|
32
|
+
throw new Error("hsvcct model needs a Kelvin range, e.g. 'ColorTemp 1650-8000'");
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
render(state: HomeKitLightState, channels: ChannelDef[]): Uint8Array {
|
|
36
|
+
const out = new Uint8Array(channels.length);
|
|
37
|
+
let i = 0;
|
|
38
|
+
while (i < channels.length) {
|
|
39
|
+
const ch = channels[i];
|
|
40
|
+
if (ch.fine) { i++; continue; }
|
|
41
|
+
switch (ch.role) {
|
|
42
|
+
case 'intensity':
|
|
43
|
+
i = writeMaybe16(out, channels, i, brightness16(state));
|
|
44
|
+
continue;
|
|
45
|
+
case 'cct':
|
|
46
|
+
out[i] = ctByte(state.colorTemperatureMireds, ch.kMin!, ch.kMax!);
|
|
47
|
+
break;
|
|
48
|
+
case 'saturation':
|
|
49
|
+
out[i] = Math.round(clamp(state.saturation ?? 0, 0, 100) * 2.55);
|
|
50
|
+
break;
|
|
51
|
+
case 'hue':
|
|
52
|
+
out[i] = Math.round((((state.hue ?? 0) % 360) / 360) * 255);
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
i++;
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Color-model registry. Look up a model by its lowercase id.
|
|
2
|
+
|
|
3
|
+
import { ColorModel } from './types.js';
|
|
4
|
+
import { dimmer } from './dimmer.js';
|
|
5
|
+
import { cct } from './cct.js';
|
|
6
|
+
import { rgb } from './rgb.js';
|
|
7
|
+
import { rgbw } from './rgbw.js';
|
|
8
|
+
import { rgbww } from './rgbww.js';
|
|
9
|
+
import { rgbaw } from './rgbaw.js';
|
|
10
|
+
import { hsvcct } from './hsvcct.js';
|
|
11
|
+
|
|
12
|
+
export const COLOR_MODELS: Record<string, ColorModel> = {
|
|
13
|
+
dimmer: dimmer,
|
|
14
|
+
cct: cct,
|
|
15
|
+
rgb: rgb,
|
|
16
|
+
rgbw: rgbw,
|
|
17
|
+
rgbww: rgbww,
|
|
18
|
+
rgbaw: rgbaw,
|
|
19
|
+
hsvcct: hsvcct,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Resolve a model by id (case-insensitive). Throws if unknown. */
|
|
23
|
+
export function getColorModel(id: string): ColorModel {
|
|
24
|
+
const m = COLOR_MODELS[id.toLowerCase()];
|
|
25
|
+
if (!m) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`unknown colormodel "${id}". Supported: ${Object.keys(COLOR_MODELS).join(', ')}`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
return m;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export * from './types.js';
|
|
34
|
+
export * from './parsers.js';
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Parsers — channel-name parser (used during config load) and CLI value
|
|
2
|
+
// parser (`#hex`, `hsl(...)`, `hwb(...)`, `0..255` for dimmer).
|
|
3
|
+
|
|
4
|
+
import { ChannelDef, ChannelRole, HomeKitLightState, clamp } from './types.js';
|
|
5
|
+
|
|
6
|
+
// ── channel-name parser ────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
const NAME_TO_ROLE: Record<string, ChannelRole> = {
|
|
9
|
+
intensity: 'intensity',
|
|
10
|
+
brightness: 'intensity',
|
|
11
|
+
dimmer: 'intensity',
|
|
12
|
+
master: 'intensity',
|
|
13
|
+
cct: 'cct',
|
|
14
|
+
colortemp: 'cct',
|
|
15
|
+
colortemperature: 'cct',
|
|
16
|
+
temp: 'cct',
|
|
17
|
+
tunable: 'cct',
|
|
18
|
+
saturation: 'saturation',
|
|
19
|
+
sat: 'saturation',
|
|
20
|
+
hue: 'hue',
|
|
21
|
+
red: 'red',
|
|
22
|
+
r: 'red',
|
|
23
|
+
green: 'green',
|
|
24
|
+
g: 'green',
|
|
25
|
+
blue: 'blue',
|
|
26
|
+
b: 'blue',
|
|
27
|
+
white: 'white',
|
|
28
|
+
w: 'white',
|
|
29
|
+
warmwhite: 'warm-white',
|
|
30
|
+
ww: 'warm-white',
|
|
31
|
+
coolwhite: 'cool-white',
|
|
32
|
+
cw: 'cool-white',
|
|
33
|
+
amber: 'amber',
|
|
34
|
+
a: 'amber',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Parse a channel_order entry, e.g.:
|
|
38
|
+
* "Intensity" → { role: 'intensity' }
|
|
39
|
+
* "Intensity (Fine)" → { role: 'intensity', fine: true }
|
|
40
|
+
* "ColorTemp 1650-8000" → { role: 'cct', kMin: 1650, kMax: 8000 }
|
|
41
|
+
* "Warm White" → { role: 'warm-white' }
|
|
42
|
+
*/
|
|
43
|
+
export function parseChannelName(raw: string): ChannelDef {
|
|
44
|
+
const s = raw.trim();
|
|
45
|
+
// Strip a trailing (Fine) marker
|
|
46
|
+
const fineMatch = /\((fine|lo|low)\)\s*$/i.exec(s);
|
|
47
|
+
const beforeFine = fineMatch ? s.slice(0, fineMatch.index).trim() : s;
|
|
48
|
+
// K range "Name <kMin>-<kMax>"
|
|
49
|
+
const kMatch = /\s+(\d+)\s*[-–]\s*(\d+)\s*K?\s*$/.exec(beforeFine);
|
|
50
|
+
const beforeK = kMatch ? beforeFine.slice(0, kMatch.index).trim() : beforeFine;
|
|
51
|
+
// Normalize: strip whitespace, lowercase
|
|
52
|
+
const key = beforeK.replace(/\s+/g, '').toLowerCase();
|
|
53
|
+
const role = NAME_TO_ROLE[key];
|
|
54
|
+
if (!role) throw new Error(`unknown channel name: "${raw}"`);
|
|
55
|
+
|
|
56
|
+
const def: ChannelDef = { role };
|
|
57
|
+
if (fineMatch) def.fine = true;
|
|
58
|
+
if (kMatch) {
|
|
59
|
+
const kMin = Number(kMatch[1]);
|
|
60
|
+
const kMax = Number(kMatch[2]);
|
|
61
|
+
if (!(kMin > 0 && kMax > kMin)) {
|
|
62
|
+
throw new Error(`bad K range in "${raw}": expected positive ascending`);
|
|
63
|
+
}
|
|
64
|
+
def.kMin = kMin;
|
|
65
|
+
def.kMax = kMax;
|
|
66
|
+
}
|
|
67
|
+
return def;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── CLI value parser ───────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/** Parse a CLI fixture value into a canonical HomeKit state.
|
|
73
|
+
* Supports:
|
|
74
|
+
* "#rrggbb" / "#rgb" — hex color
|
|
75
|
+
* "rgb(r,g,b)" — 0..255 each
|
|
76
|
+
* "hsl(h, s%, l%)" — CSS HSL
|
|
77
|
+
* "hwb(h, w%, b%)" — CSS HWB
|
|
78
|
+
* "kelvin(K)" / "K=K" — CCT (sat=0, brightness=100)
|
|
79
|
+
* "0".."255" — single byte (dimmer profile)
|
|
80
|
+
* "off" / "0%" / "on" / "100%" — convenience
|
|
81
|
+
*/
|
|
82
|
+
export function parseValue(raw: string): HomeKitLightState {
|
|
83
|
+
const s = raw.trim();
|
|
84
|
+
|
|
85
|
+
// off / on
|
|
86
|
+
if (/^off$/i.test(s)) return { on: false, brightness: 0 };
|
|
87
|
+
if (/^on$/i.test(s)) return { on: true, brightness: 100 };
|
|
88
|
+
|
|
89
|
+
// bare number / percent — checked before hex because a 3-digit decimal
|
|
90
|
+
// like "128" is ALSO a valid 3-char hex string.
|
|
91
|
+
if (/^\d+%?$/.test(s)) {
|
|
92
|
+
const isPct = s.endsWith('%');
|
|
93
|
+
const v = Number(s.replace('%', ''));
|
|
94
|
+
if (isPct) {
|
|
95
|
+
return { on: v > 0, brightness: clamp(v, 0, 100) };
|
|
96
|
+
}
|
|
97
|
+
return { on: v > 0, brightness: Math.round((clamp(v, 0, 255) / 255) * 100) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// hex (requires # for clarity unless it contains a non-decimal hex char)
|
|
101
|
+
const hex = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(s);
|
|
102
|
+
if (hex) {
|
|
103
|
+
const h = hex[1];
|
|
104
|
+
const expand = h.length === 3 ? [...h].map((c) => c + c).join('') : h;
|
|
105
|
+
const r = parseInt(expand.slice(0, 2), 16);
|
|
106
|
+
const g = parseInt(expand.slice(2, 4), 16);
|
|
107
|
+
const b = parseInt(expand.slice(4, 6), 16);
|
|
108
|
+
return rgbToState(r, g, b);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// rgb(r,g,b)
|
|
112
|
+
const rgb = /^rgb\s*\(\s*([^)]+)\)$/i.exec(s);
|
|
113
|
+
if (rgb) {
|
|
114
|
+
const parts = splitArgs(rgb[1]);
|
|
115
|
+
if (parts.length !== 3) throw new Error(`bad rgb(): need 3 args in "${raw}"`);
|
|
116
|
+
const [r, g, b] = parts.map((p) => numberOrPercent(p, 255));
|
|
117
|
+
return rgbToState(r, g, b);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// hsl(h, s%, l%)
|
|
121
|
+
const hsl = /^hsl\s*\(\s*([^)]+)\)$/i.exec(s);
|
|
122
|
+
if (hsl) {
|
|
123
|
+
const parts = splitArgs(hsl[1]);
|
|
124
|
+
if (parts.length !== 3) throw new Error(`bad hsl(): need 3 args in "${raw}"`);
|
|
125
|
+
const h = angle(parts[0]);
|
|
126
|
+
const sPct = percent(parts[1]);
|
|
127
|
+
const lPct = percent(parts[2]);
|
|
128
|
+
return hslToState(h, sPct, lPct);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// hwb(h, w%, b%)
|
|
132
|
+
const hwb = /^hwb\s*\(\s*([^)]+)\)$/i.exec(s);
|
|
133
|
+
if (hwb) {
|
|
134
|
+
const parts = splitArgs(hwb[1]);
|
|
135
|
+
if (parts.length !== 3) throw new Error(`bad hwb(): need 3 args in "${raw}"`);
|
|
136
|
+
const h = angle(parts[0]);
|
|
137
|
+
const wPct = percent(parts[1]);
|
|
138
|
+
const bPct = percent(parts[2]);
|
|
139
|
+
return hwbToState(h, wPct, bPct);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// kelvin
|
|
143
|
+
const k = /^(?:k|kelvin)\s*[=:(]?\s*(\d+)\s*K?\)?$/i.exec(s);
|
|
144
|
+
if (k) {
|
|
145
|
+
const K = Number(k[1]);
|
|
146
|
+
if (!(K > 0)) throw new Error(`bad kelvin in "${raw}"`);
|
|
147
|
+
return { on: true, brightness: 100, saturation: 0, colorTemperatureMireds: Math.round(1_000_000 / K) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
throw new Error(`can't parse value: "${raw}"`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function splitArgs(s: string): string[] {
|
|
154
|
+
// Support both comma and space separators (CSS Level 4 syntax).
|
|
155
|
+
return s.replace(/,/g, ' ').split(/\s+/).filter((p) => p.length > 0);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function percent(s: string): number {
|
|
159
|
+
const m = /^(-?\d*\.?\d+)\s*%?$/.exec(s);
|
|
160
|
+
if (!m) throw new Error(`bad percent: "${s}"`);
|
|
161
|
+
return Number(m[1]);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function numberOrPercent(s: string, base: number): number {
|
|
165
|
+
const m = /^(-?\d*\.?\d+)\s*(%?)$/.exec(s);
|
|
166
|
+
if (!m) throw new Error(`bad number: "${s}"`);
|
|
167
|
+
const v = Number(m[1]);
|
|
168
|
+
return m[2] === '%' ? Math.round((v / 100) * base) : v;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function angle(s: string): number {
|
|
172
|
+
const m = /^(-?\d*\.?\d+)\s*(deg|rad|turn)?$/i.exec(s);
|
|
173
|
+
if (!m) throw new Error(`bad angle: "${s}"`);
|
|
174
|
+
let v = Number(m[1]);
|
|
175
|
+
if (/^rad$/i.test(m[2] ?? '')) v = (v * 180) / Math.PI;
|
|
176
|
+
else if (/^turn$/i.test(m[2] ?? '')) v = v * 360;
|
|
177
|
+
return ((v % 360) + 360) % 360;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Convert RGB (0..255) into HomeKit HSV+brightness state. */
|
|
181
|
+
function rgbToState(r: number, g: number, b: number): HomeKitLightState {
|
|
182
|
+
const rN = clamp(r, 0, 255) / 255;
|
|
183
|
+
const gN = clamp(g, 0, 255) / 255;
|
|
184
|
+
const bN = clamp(b, 0, 255) / 255;
|
|
185
|
+
const max = Math.max(rN, gN, bN);
|
|
186
|
+
const min = Math.min(rN, gN, bN);
|
|
187
|
+
const v = max;
|
|
188
|
+
const d = max - min;
|
|
189
|
+
let h = 0;
|
|
190
|
+
if (d !== 0) {
|
|
191
|
+
if (max === rN) h = 60 * (((gN - bN) / d) % 6);
|
|
192
|
+
else if (max === gN) h = 60 * (((bN - rN) / d) + 2);
|
|
193
|
+
else h = 60 * (((rN - gN) / d) + 4);
|
|
194
|
+
}
|
|
195
|
+
if (h < 0) h += 360;
|
|
196
|
+
const s = max === 0 ? 0 : (d / max) * 100;
|
|
197
|
+
return {
|
|
198
|
+
on: v > 0,
|
|
199
|
+
brightness: Math.round(v * 100),
|
|
200
|
+
hue: Math.round(h),
|
|
201
|
+
saturation: Math.round(s),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function hslToState(h: number, sPct: number, lPct: number): HomeKitLightState {
|
|
206
|
+
// CSS HSL → HSV
|
|
207
|
+
const s = clamp(sPct, 0, 100) / 100;
|
|
208
|
+
const l = clamp(lPct, 0, 100) / 100;
|
|
209
|
+
const v = l + s * Math.min(l, 1 - l);
|
|
210
|
+
const sv = v === 0 ? 0 : 2 * (1 - l / v);
|
|
211
|
+
return {
|
|
212
|
+
on: v > 0,
|
|
213
|
+
brightness: Math.round(v * 100),
|
|
214
|
+
hue: ((h % 360) + 360) % 360,
|
|
215
|
+
saturation: Math.round(sv * 100),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function hwbToState(h: number, wPct: number, bPct: number): HomeKitLightState {
|
|
220
|
+
// CSS HWB → HSV
|
|
221
|
+
let w = clamp(wPct, 0, 100) / 100;
|
|
222
|
+
let bk = clamp(bPct, 0, 100) / 100;
|
|
223
|
+
if (w + bk > 1) { const t = w + bk; w /= t; bk /= t; }
|
|
224
|
+
const v = 1 - bk;
|
|
225
|
+
const s = v === 0 ? 0 : 1 - w / v;
|
|
226
|
+
return {
|
|
227
|
+
on: v > 0,
|
|
228
|
+
brightness: Math.round(v * 100),
|
|
229
|
+
hue: ((h % 360) + 360) % 360,
|
|
230
|
+
saturation: Math.round(s * 100),
|
|
231
|
+
};
|
|
232
|
+
}
|
package/src/color/rgb.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// rgb — 3-channel additive color.
|
|
2
|
+
// channel_order: red, green, blue (any order).
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ChannelDef,
|
|
6
|
+
ColorModel,
|
|
7
|
+
HomeKitLightState,
|
|
8
|
+
hsvToRgb01,
|
|
9
|
+
rgbBytes,
|
|
10
|
+
requireRoles,
|
|
11
|
+
} from './types.js';
|
|
12
|
+
|
|
13
|
+
export const rgb: ColorModel = {
|
|
14
|
+
id: 'rgb',
|
|
15
|
+
characteristics: ['On', 'Brightness', 'Hue', 'Saturation'],
|
|
16
|
+
validate(channels) {
|
|
17
|
+
requireRoles(channels, ['red', 'green', 'blue']);
|
|
18
|
+
},
|
|
19
|
+
render(state: HomeKitLightState, channels: ChannelDef[]): Uint8Array {
|
|
20
|
+
const rgb01 = hsvToRgb01(state.hue ?? 0, state.saturation ?? 0);
|
|
21
|
+
const px = rgbBytes(rgb01, state);
|
|
22
|
+
const out = new Uint8Array(channels.length);
|
|
23
|
+
for (let i = 0; i < channels.length; i++) {
|
|
24
|
+
if (channels[i].fine) continue; // RGB fine channels not common; leave zero
|
|
25
|
+
switch (channels[i].role) {
|
|
26
|
+
case 'red': out[i] = px.r; break;
|
|
27
|
+
case 'green': out[i] = px.g; break;
|
|
28
|
+
case 'blue': out[i] = px.b; break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// rgbaw — RGB + Amber + White. Amber gets pulled out of the
|
|
2
|
+
// yellow/orange region of the RGB, white from the remaining
|
|
3
|
+
// achromatic component. Stage-lighting common.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ChannelDef,
|
|
7
|
+
ColorModel,
|
|
8
|
+
HomeKitLightState,
|
|
9
|
+
hsvToRgb01,
|
|
10
|
+
rgbBytes,
|
|
11
|
+
requireRoles,
|
|
12
|
+
} from './types.js';
|
|
13
|
+
|
|
14
|
+
export const rgbaw: ColorModel = {
|
|
15
|
+
id: 'rgbaw',
|
|
16
|
+
characteristics: ['On', 'Brightness', 'Hue', 'Saturation'],
|
|
17
|
+
validate(channels) {
|
|
18
|
+
requireRoles(channels, ['red', 'green', 'blue', 'amber', 'white']);
|
|
19
|
+
},
|
|
20
|
+
render(state: HomeKitLightState, channels: ChannelDef[]): Uint8Array {
|
|
21
|
+
const rgb01 = hsvToRgb01(state.hue ?? 0, state.saturation ?? 0);
|
|
22
|
+
const px = rgbBytes(rgb01, state);
|
|
23
|
+
|
|
24
|
+
// Amber ≈ min(R, ~0.75·G) where G is dominated by R, ie. yellow-orange region.
|
|
25
|
+
// Pull amber out of the green to avoid red dimming.
|
|
26
|
+
const amber = Math.min(px.r, Math.round(px.g * 0.75));
|
|
27
|
+
let r = px.r, g = px.g - amber, b = px.b;
|
|
28
|
+
|
|
29
|
+
// White from the remaining achromatic component.
|
|
30
|
+
const w = Math.min(r, g, b);
|
|
31
|
+
r -= w; g -= w; b -= w;
|
|
32
|
+
|
|
33
|
+
const out = new Uint8Array(channels.length);
|
|
34
|
+
for (let i = 0; i < channels.length; i++) {
|
|
35
|
+
if (channels[i].fine) continue;
|
|
36
|
+
switch (channels[i].role) {
|
|
37
|
+
case 'red': out[i] = r; break;
|
|
38
|
+
case 'green': out[i] = g; break;
|
|
39
|
+
case 'blue': out[i] = b; break;
|
|
40
|
+
case 'amber': out[i] = amber; break;
|
|
41
|
+
case 'white': out[i] = w; break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
},
|
|
46
|
+
};
|