homebridge-moonside 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.
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env ts-node
2
+
3
+ /**
4
+ * Minimal CLI helper that talks directly to the Firebase backend so we can
5
+ * debug color and pixel commands without going through Homebridge.
6
+ *
7
+ * Usage examples:
8
+ * MOONSIDE_EMAIL="user@example.com" \
9
+ * MOONSIDE_PASSWORD="hunter2" \
10
+ * MOONSIDE_DEVICE_ID="88:57:21:74:0F:20" \
11
+ * npx ts-node --esm scripts/moonside-control.ts --hex ff8800 --brightness 60
12
+ *
13
+ * MOONSIDE_EMAIL="user@example.com" \
14
+ * MOONSIDE_PASSWORD="hunter2" \
15
+ * MOONSIDE_DEVICE_ID="88:57:21:74:0F:20" \
16
+ * npx ts-node --esm scripts/moonside-control.ts --hex 00ff80 --pixels 0-79
17
+ */
18
+
19
+ import { setTimeout as delay } from 'node:timers/promises';
20
+
21
+ const FIREBASE_API_KEY = 'AIzaSyCC-qQZqcZhxqsbO7GB0nXZShab9gV06Bk';
22
+ const FIREBASE_IDENTITY_URL = 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword';
23
+ const REALTIME_DATABASE_URL = 'https://moonside-501a1.firebaseio.com';
24
+
25
+ interface LoginResponse {
26
+ idToken: string;
27
+ localId: string;
28
+ }
29
+
30
+ interface Options {
31
+ command?: string;
32
+ color?: { r: number; g: number; b: number };
33
+ brightness?: number;
34
+ pixels?: number[];
35
+ delayMs: number;
36
+ }
37
+
38
+ function parseChannel(value: string): number {
39
+ const channel = Number(value);
40
+ if (!Number.isFinite(channel) || channel < 0 || channel > 255) {
41
+ throw new Error(`Invalid RGB channel value: ${value}`);
42
+ }
43
+ return channel;
44
+ }
45
+
46
+ function parsePixelList(input: string): number[] {
47
+ const segments = input.split(',');
48
+ const indices = new Set<number>();
49
+
50
+ for (const segment of segments) {
51
+ if (segment.includes('-')) {
52
+ const [startRaw, endRaw] = segment.split('-', 2);
53
+ const start = Number(startRaw);
54
+ const end = Number(endRaw);
55
+ if (!Number.isInteger(start) || !Number.isInteger(end)) {
56
+ throw new Error(`Invalid pixel range: ${segment}`);
57
+ }
58
+ const [min, max] = start <= end ? [start, end] : [end, start];
59
+ for (let value = min; value <= max; value += 1) {
60
+ indices.add(value);
61
+ }
62
+ } else {
63
+ const value = Number(segment);
64
+ if (!Number.isInteger(value)) {
65
+ throw new Error(`Invalid pixel index: ${segment}`);
66
+ }
67
+ indices.add(value);
68
+ }
69
+ }
70
+
71
+ return Array.from(indices).sort((a, b) => a - b);
72
+ }
73
+
74
+ function hexToColor(input: string): { r: number; g: number; b: number } {
75
+ const match = input.trim().replace(/^#/, '');
76
+ if (!/^[0-9a-fA-F]{6}$/.test(match)) {
77
+ throw new Error(`Invalid hex color: ${input}`);
78
+ }
79
+ return {
80
+ r: parseInt(match.slice(0, 2), 16),
81
+ g: parseInt(match.slice(2, 4), 16),
82
+ b: parseInt(match.slice(4), 16),
83
+ };
84
+ }
85
+
86
+ function clampByte(value: number): number {
87
+ if (!Number.isFinite(value)) {
88
+ return 0;
89
+ }
90
+ return Math.max(0, Math.min(255, Math.round(value)));
91
+ }
92
+
93
+ function formatPixelPayload(color: { r: number; g: number; b: number }): string {
94
+ const pad = (value: number) => clampByte(value).toString().padStart(3, '0');
95
+ return `${pad(color.r)}${pad(color.g)}${pad(color.b)}`;
96
+ }
97
+
98
+ function buildColorCommand(color: { r: number; g: number; b: number }): string {
99
+ return `COLOR${formatPixelPayload(color)}`;
100
+ }
101
+
102
+ function buildDeviceUrl(login: LoginResponse, deviceId: string): string {
103
+ const encodedDeviceId = encodeURIComponent(deviceId);
104
+ return `${REALTIME_DATABASE_URL}/userDevices/${login.localId}/${encodedDeviceId}.json?auth=${login.idToken}`;
105
+ }
106
+
107
+ async function authenticate(email: string, password: string): Promise<LoginResponse> {
108
+ const response = await fetch(`${FIREBASE_IDENTITY_URL}?key=${FIREBASE_API_KEY}`, {
109
+ method: 'POST',
110
+ headers: { 'Content-Type': 'application/json' },
111
+ body: JSON.stringify({
112
+ email,
113
+ password,
114
+ returnSecureToken: true,
115
+ }),
116
+ });
117
+
118
+ if (!response.ok) {
119
+ const details = await response.text();
120
+ throw new Error(`Firebase auth failed: ${response.status} ${response.statusText} - ${details}`);
121
+ }
122
+
123
+ const payload = await response.json() as {
124
+ idToken?: string;
125
+ localId?: string;
126
+ };
127
+
128
+ if (!payload.idToken || !payload.localId) {
129
+ throw new Error('Firebase auth response was missing idToken/localId.');
130
+ }
131
+
132
+ return {
133
+ idToken: payload.idToken,
134
+ localId: payload.localId,
135
+ };
136
+ }
137
+
138
+ async function sendControl(login: LoginResponse, deviceId: string, controlData: string) {
139
+ const url = buildDeviceUrl(login, deviceId);
140
+ const payload = { controlData };
141
+
142
+ const response = await fetch(url, {
143
+ method: 'PATCH',
144
+ headers: { 'Content-Type': 'application/json' },
145
+ body: JSON.stringify(payload),
146
+ });
147
+
148
+ if (!response.ok) {
149
+ const details = await response.text();
150
+ throw new Error(`Failed to send ${controlData}: ${response.status} ${response.statusText} - ${details}`);
151
+ }
152
+
153
+ const body = await response.json();
154
+ console.log('Sent %s -> %s', controlData, JSON.stringify(body));
155
+ }
156
+
157
+ async function setPixels(
158
+ login: LoginResponse,
159
+ deviceId: string,
160
+ color: { r: number; g: number; b: number },
161
+ indices: number[],
162
+ delayMs: number,
163
+ ) {
164
+ const payload = formatPixelPayload(color);
165
+
166
+ for (const index of indices) {
167
+ const command = `PIXEL,${index},${payload}`;
168
+ await sendControl(login, deviceId, command);
169
+ if (delayMs > 0) {
170
+ await delay(delayMs);
171
+ }
172
+ }
173
+ }
174
+
175
+ function parseArgs(argv: string[]): Options {
176
+ const options: Options = {
177
+ delayMs: Number(process.env.MOONSIDE_PIXEL_DELAY ?? 50),
178
+ };
179
+
180
+ for (let i = 0; i < argv.length; i += 1) {
181
+ const arg = argv[i];
182
+ switch (arg) {
183
+ case '--command':
184
+ options.command = argv[++i];
185
+ break;
186
+ case '--hex':
187
+ options.color = hexToColor(argv[++i]);
188
+ break;
189
+ case '--rgb':
190
+ options.color = {
191
+ r: parseChannel(argv[++i]),
192
+ g: parseChannel(argv[++i]),
193
+ b: parseChannel(argv[++i]),
194
+ };
195
+ break;
196
+ case '--brightness':
197
+ options.brightness = Number(argv[++i]);
198
+ break;
199
+ case '--pixels':
200
+ options.pixels = parsePixelList(argv[++i]);
201
+ break;
202
+ case '--pixel-delay':
203
+ options.delayMs = Number(argv[++i]);
204
+ break;
205
+ default:
206
+ throw new Error(`Unknown argument: ${arg}`);
207
+ }
208
+ }
209
+
210
+ return options;
211
+ }
212
+
213
+ async function main() {
214
+ const email = process.env.MOONSIDE_EMAIL;
215
+ const password = process.env.MOONSIDE_PASSWORD;
216
+ const deviceId = process.env.MOONSIDE_DEVICE_ID;
217
+
218
+ if (!email || !password || !deviceId) {
219
+ throw new Error('Set MOONSIDE_EMAIL, MOONSIDE_PASSWORD, and MOONSIDE_DEVICE_ID environment variables before running this script.');
220
+ }
221
+
222
+ const options = parseArgs(process.argv.slice(2));
223
+ if (!options.command && !options.color && options.brightness === undefined) {
224
+ throw new Error('Provide --command, --hex/--rgb, or --brightness so there is something to send.');
225
+ }
226
+
227
+ const login = await authenticate(email, password);
228
+
229
+ if (options.brightness !== undefined) {
230
+ const level = Math.max(1, Math.min(100, Math.round(options.brightness)));
231
+ await sendControl(login, deviceId, `BRIGH${level}`);
232
+ }
233
+
234
+ if (options.command) {
235
+ await sendControl(login, deviceId, options.command);
236
+ }
237
+
238
+ if (options.color) {
239
+ if (options.pixels?.length) {
240
+ await setPixels(login, deviceId, options.color, options.pixels, options.delayMs);
241
+ } else {
242
+ const command = buildColorCommand(options.color);
243
+ await sendControl(login, deviceId, command);
244
+ }
245
+ }
246
+ }
247
+
248
+ void main().catch(error => {
249
+ console.error(error instanceof Error ? error.message : error);
250
+ process.exitCode = 1;
251
+ });