homebridge-lanternic 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/LICENSE +21 -0
- package/README.md +260 -0
- package/config.schema.json +412 -0
- package/dist/ble/magicLanternBleManager.d.ts +78 -0
- package/dist/ble/magicLanternBleManager.js +325 -0
- package/dist/ble/magicLanternBleManager.js.map +1 -0
- package/dist/ble/magicLanternCommands.d.ts +16 -0
- package/dist/ble/magicLanternCommands.js +49 -0
- package/dist/ble/magicLanternCommands.js.map +1 -0
- package/dist/ble/nobleTypes.d.ts +33 -0
- package/dist/ble/nobleTypes.js +2 -0
- package/dist/ble/nobleTypes.js.map +1 -0
- package/dist/color.d.ts +10 -0
- package/dist/color.js +65 -0
- package/dist/color.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/platform.d.ts +23 -0
- package/dist/platform.js +150 -0
- package/dist/platform.js.map +1 -0
- package/dist/platformAccessory.d.ts +26 -0
- package/dist/platformAccessory.js +139 -0
- package/dist/platformAccessory.js.map +1 -0
- package/dist/settings.d.ts +2 -0
- package/dist/settings.js +3 -0
- package/dist/settings.js.map +1 -0
- package/dist/types.d.ts +62 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/util/async.d.ts +2 -0
- package/dist/util/async.js +24 -0
- package/dist/util/async.js.map +1 -0
- package/dist/util/bluetooth.d.ts +3 -0
- package/dist/util/bluetooth.js +15 -0
- package/dist/util/bluetooth.js.map +1 -0
- package/package.json +81 -0
- package/tools/calibrate.mjs +314 -0
- package/tools/calibrator/app.js +399 -0
- package/tools/calibrator/index.html +91 -0
- package/tools/calibrator/styles.css +302 -0
- package/tools/explore.mjs +73 -0
- package/tools/scan.mjs +88 -0
- package/tools/send-sequence.mjs +76 -0
- package/tools/send.mjs +106 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createReadStream, existsSync } from 'node:fs';
|
|
3
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { createServer } from 'node:http';
|
|
5
|
+
import { extname, join, normalize } from 'node:path';
|
|
6
|
+
import { fileURLToPath, URL } from 'node:url';
|
|
7
|
+
|
|
8
|
+
import { withBindings } from '@stoprocent/noble';
|
|
9
|
+
|
|
10
|
+
const root = fileURLToPath(new URL('.', import.meta.url));
|
|
11
|
+
const staticRoot = join(root, 'calibrator');
|
|
12
|
+
const profilePath = join(process.cwd(), '.lanternic-calibration.json');
|
|
13
|
+
|
|
14
|
+
const binding = process.env.LANTERNIC_BINDING ?? 'default';
|
|
15
|
+
const port = Number(process.env.LANTERNIC_CALIBRATE_PORT ?? '4287');
|
|
16
|
+
const serviceUuid = cleanHex(process.env.LANTERNIC_SERVICE_UUID ?? 'fff0');
|
|
17
|
+
const characteristicUuid = cleanHex(process.env.LANTERNIC_CHARACTERISTIC_UUID ?? 'fff3');
|
|
18
|
+
const defaultAddress = process.env.LANTERNIC_ADDRESS ?? process.argv[2] ?? '3ce161969c280342f1cbc8dac2b53dc5';
|
|
19
|
+
|
|
20
|
+
const noble = withBindings(binding);
|
|
21
|
+
|
|
22
|
+
let targetAddress = defaultAddress;
|
|
23
|
+
let queue = Promise.resolve();
|
|
24
|
+
let peripheral;
|
|
25
|
+
let characteristic;
|
|
26
|
+
let idleTimer;
|
|
27
|
+
|
|
28
|
+
function cleanHex(input) {
|
|
29
|
+
return String(input ?? '').replace(/[^0-9a-f]/gi, '').toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function peripheralId(candidate) {
|
|
33
|
+
return candidate.address || candidate.uuid || candidate.id;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clampByte(value) {
|
|
37
|
+
const numeric = Number(value);
|
|
38
|
+
if (!Number.isFinite(numeric)) {
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
return Math.max(0, Math.min(255, Math.round(numeric)));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function colorFrame({ red, green, blue }) {
|
|
45
|
+
return Buffer.from([
|
|
46
|
+
0x7e,
|
|
47
|
+
0x07,
|
|
48
|
+
0x05,
|
|
49
|
+
0x03,
|
|
50
|
+
clampByte(red),
|
|
51
|
+
clampByte(green),
|
|
52
|
+
clampByte(blue),
|
|
53
|
+
0x10,
|
|
54
|
+
0xef,
|
|
55
|
+
]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function powerFrame(on) {
|
|
59
|
+
return on
|
|
60
|
+
? Buffer.from('7e0404f00001ff00ef', 'hex')
|
|
61
|
+
: Buffer.from('7e0404000000ff00ef', 'hex');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function delay(milliseconds) {
|
|
65
|
+
return new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function json(response, status, body) {
|
|
69
|
+
const data = JSON.stringify(body);
|
|
70
|
+
response.writeHead(status, {
|
|
71
|
+
'content-type': 'application/json; charset=utf-8',
|
|
72
|
+
'content-length': Buffer.byteLength(data),
|
|
73
|
+
});
|
|
74
|
+
response.end(data);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function requestJson(request) {
|
|
78
|
+
const chunks = [];
|
|
79
|
+
for await (const chunk of request) {
|
|
80
|
+
chunks.push(chunk);
|
|
81
|
+
}
|
|
82
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
83
|
+
return body ? JSON.parse(body) : {};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function runExclusive(operation) {
|
|
87
|
+
const run = queue.then(operation, operation);
|
|
88
|
+
queue = run.then(
|
|
89
|
+
() => undefined,
|
|
90
|
+
() => undefined,
|
|
91
|
+
);
|
|
92
|
+
return run;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function ensureConnected() {
|
|
96
|
+
if (peripheral?.state === 'connected' && characteristic) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await noble.waitForPoweredOnAsync(15_000);
|
|
101
|
+
await noble.startScanningAsync([], true);
|
|
102
|
+
|
|
103
|
+
const targetId = cleanHex(targetAddress);
|
|
104
|
+
const found = await new Promise((resolve, reject) => {
|
|
105
|
+
const timeout = setTimeout(() => {
|
|
106
|
+
noble.removeListener('discover', onDiscover);
|
|
107
|
+
reject(new Error(`Timed out scanning for ${targetAddress}`));
|
|
108
|
+
}, 20_000);
|
|
109
|
+
|
|
110
|
+
const onDiscover = candidate => {
|
|
111
|
+
const ids = [candidate.id, candidate.uuid, candidate.address, peripheralId(candidate)].map(cleanHex);
|
|
112
|
+
if (!ids.includes(targetId)) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
clearTimeout(timeout);
|
|
117
|
+
noble.removeListener('discover', onDiscover);
|
|
118
|
+
resolve(candidate);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
noble.on('discover', onDiscover);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await noble.stopScanningAsync();
|
|
125
|
+
await found.connectAsync();
|
|
126
|
+
|
|
127
|
+
const result = await found.discoverSomeServicesAndCharacteristicsAsync(
|
|
128
|
+
[serviceUuid],
|
|
129
|
+
[characteristicUuid],
|
|
130
|
+
);
|
|
131
|
+
const foundCharacteristic = result.characteristics[0];
|
|
132
|
+
|
|
133
|
+
if (!foundCharacteristic) {
|
|
134
|
+
await found.disconnectAsync();
|
|
135
|
+
throw new Error(`Missing characteristic ${serviceUuid}/${characteristicUuid}`);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
found.on('disconnect', () => {
|
|
139
|
+
peripheral = undefined;
|
|
140
|
+
characteristic = undefined;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
peripheral = found;
|
|
144
|
+
characteristic = foundCharacteristic;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function disconnectSoon() {
|
|
148
|
+
if (idleTimer) {
|
|
149
|
+
clearTimeout(idleTimer);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
idleTimer = setTimeout(() => {
|
|
153
|
+
void runExclusive(async () => {
|
|
154
|
+
if (peripheral?.state === 'connected') {
|
|
155
|
+
await peripheral.disconnectAsync();
|
|
156
|
+
}
|
|
157
|
+
peripheral = undefined;
|
|
158
|
+
characteristic = undefined;
|
|
159
|
+
});
|
|
160
|
+
}, 15_000);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function writeFrames(frames) {
|
|
164
|
+
return runExclusive(async () => {
|
|
165
|
+
await ensureConnected();
|
|
166
|
+
const withoutResponse = !characteristic.properties.includes('write')
|
|
167
|
+
&& characteristic.properties.includes('writeWithoutResponse');
|
|
168
|
+
|
|
169
|
+
for (const frame of frames) {
|
|
170
|
+
await characteristic.writeAsync(frame, withoutResponse);
|
|
171
|
+
await delay(80);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await disconnectSoon();
|
|
175
|
+
return frames.map(frame => frame.toString('hex'));
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function serveStatic(request, response) {
|
|
180
|
+
const url = new URL(request.url, 'http://localhost');
|
|
181
|
+
const relative = url.pathname === '/' ? 'index.html' : decodeURIComponent(url.pathname.slice(1));
|
|
182
|
+
const filePath = normalize(join(staticRoot, relative));
|
|
183
|
+
|
|
184
|
+
if (!filePath.startsWith(staticRoot) || !existsSync(filePath)) {
|
|
185
|
+
response.writeHead(404);
|
|
186
|
+
response.end('Not found');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const types = {
|
|
191
|
+
'.css': 'text/css; charset=utf-8',
|
|
192
|
+
'.html': 'text/html; charset=utf-8',
|
|
193
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
response.writeHead(200, {
|
|
197
|
+
'content-type': types[extname(filePath)] ?? 'application/octet-stream',
|
|
198
|
+
});
|
|
199
|
+
createReadStream(filePath).pipe(response);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function handleApi(request, response) {
|
|
203
|
+
try {
|
|
204
|
+
if (request.method === 'GET' && request.url === '/api/status') {
|
|
205
|
+
let savedProfile = null;
|
|
206
|
+
if (existsSync(profilePath)) {
|
|
207
|
+
savedProfile = JSON.parse(await readFile(profilePath, 'utf8'));
|
|
208
|
+
}
|
|
209
|
+
json(response, 200, {
|
|
210
|
+
binding,
|
|
211
|
+
targetAddress,
|
|
212
|
+
serviceUuid,
|
|
213
|
+
characteristicUuid,
|
|
214
|
+
savedProfile,
|
|
215
|
+
});
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (request.method === 'POST' && request.url === '/api/target') {
|
|
220
|
+
const body = await requestJson(request);
|
|
221
|
+
targetAddress = String(body.address ?? '').trim();
|
|
222
|
+
peripheral = undefined;
|
|
223
|
+
characteristic = undefined;
|
|
224
|
+
json(response, 200, { targetAddress });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (request.method === 'POST' && request.url === '/api/color') {
|
|
229
|
+
const body = await requestJson(request);
|
|
230
|
+
const frames = await writeFrames([colorFrame(body)]);
|
|
231
|
+
json(response, 200, { ok: true, frames });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (request.method === 'POST' && request.url === '/api/power') {
|
|
236
|
+
const body = await requestJson(request);
|
|
237
|
+
const frames = await writeFrames([powerFrame(Boolean(body.on))]);
|
|
238
|
+
json(response, 200, { ok: true, frames });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (request.method === 'POST' && request.url === '/api/sequence') {
|
|
243
|
+
const body = await requestJson(request);
|
|
244
|
+
const delayBetween = Math.max(100, Math.min(5000, Number(body.delayMs ?? 900)));
|
|
245
|
+
const colors = Array.isArray(body.colors) ? body.colors : [];
|
|
246
|
+
const frames = [];
|
|
247
|
+
|
|
248
|
+
for (const color of colors) {
|
|
249
|
+
frames.push(colorFrame(color));
|
|
250
|
+
if (delayBetween > 80) {
|
|
251
|
+
frames.push(Buffer.from([]));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const written = await runExclusive(async () => {
|
|
256
|
+
await ensureConnected();
|
|
257
|
+
const withoutResponse = !characteristic.properties.includes('write')
|
|
258
|
+
&& characteristic.properties.includes('writeWithoutResponse');
|
|
259
|
+
const hexFrames = [];
|
|
260
|
+
|
|
261
|
+
for (const color of colors) {
|
|
262
|
+
const frame = colorFrame(color);
|
|
263
|
+
await characteristic.writeAsync(frame, withoutResponse);
|
|
264
|
+
hexFrames.push(frame.toString('hex'));
|
|
265
|
+
await delay(delayBetween);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
await disconnectSoon();
|
|
269
|
+
return hexFrames;
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
json(response, 200, { ok: true, frames: written });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (request.method === 'POST' && request.url === '/api/profile') {
|
|
277
|
+
const body = await requestJson(request);
|
|
278
|
+
await writeFile(profilePath, `${JSON.stringify(body, null, 2)}\n`);
|
|
279
|
+
json(response, 200, { ok: true, path: profilePath });
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
json(response, 404, { error: 'Not found' });
|
|
284
|
+
} catch (error) {
|
|
285
|
+
json(response, 500, {
|
|
286
|
+
error: error instanceof Error ? error.message : String(error),
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const server = createServer((request, response) => {
|
|
292
|
+
if (request.url?.startsWith('/api/')) {
|
|
293
|
+
void handleApi(request, response);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
void serveStatic(request, response);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
process.on('SIGINT', async () => {
|
|
301
|
+
try {
|
|
302
|
+
if (peripheral?.state === 'connected') {
|
|
303
|
+
await peripheral.disconnectAsync();
|
|
304
|
+
}
|
|
305
|
+
noble.stop();
|
|
306
|
+
} finally {
|
|
307
|
+
process.exit(0);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
server.listen(port, '127.0.0.1', () => {
|
|
312
|
+
console.log(`LanternIC calibration app: http://127.0.0.1:${port}`);
|
|
313
|
+
console.log(`Target: ${targetAddress}`);
|
|
314
|
+
});
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
const state = {
|
|
2
|
+
targetAddress: '',
|
|
3
|
+
requested: { red: 255, green: 0, blue: 0 },
|
|
4
|
+
profile: {
|
|
5
|
+
brightness: 1,
|
|
6
|
+
saturation: 1,
|
|
7
|
+
gain: { red: 1, green: 1, blue: 1 },
|
|
8
|
+
gamma: { red: 1, green: 1, blue: 1 },
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const swatches = [
|
|
13
|
+
['Red', '#ff0000'],
|
|
14
|
+
['Orange', '#ff8000'],
|
|
15
|
+
['Yellow', '#ffff00'],
|
|
16
|
+
['Green', '#00ff00'],
|
|
17
|
+
['Blue', '#0000ff'],
|
|
18
|
+
['Purple', '#8000ff'],
|
|
19
|
+
['Cyan', '#00ffff'],
|
|
20
|
+
['Magenta', '#ff00ff'],
|
|
21
|
+
['White', '#ffffff'],
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const sequence = [
|
|
25
|
+
['Red', '#ff0000'],
|
|
26
|
+
['Orange', '#ff8000'],
|
|
27
|
+
['Yellow', '#ffff00'],
|
|
28
|
+
['Green', '#00ff00'],
|
|
29
|
+
['Blue', '#0000ff'],
|
|
30
|
+
['Purple', '#8000ff'],
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const $ = selector => document.querySelector(selector);
|
|
34
|
+
|
|
35
|
+
const elements = {
|
|
36
|
+
colorPicker: $('#colorPicker'),
|
|
37
|
+
correctedHex: $('#correctedHex'),
|
|
38
|
+
correctedSwatch: $('#correctedSwatch'),
|
|
39
|
+
downloadProfileButton: $('#downloadProfileButton'),
|
|
40
|
+
gainControls: $('#gainControls'),
|
|
41
|
+
globalControls: $('#globalControls'),
|
|
42
|
+
powerOffButton: $('#powerOffButton'),
|
|
43
|
+
powerOnButton: $('#powerOnButton'),
|
|
44
|
+
profileOutput: $('#profileOutput'),
|
|
45
|
+
requestedHex: $('#requestedHex'),
|
|
46
|
+
requestedSwatch: $('#requestedSwatch'),
|
|
47
|
+
resetButton: $('#resetButton'),
|
|
48
|
+
rgbControls: $('#rgbControls'),
|
|
49
|
+
saveProfileButton: $('#saveProfileButton'),
|
|
50
|
+
saveTargetButton: $('#saveTargetButton'),
|
|
51
|
+
sendButton: $('#sendButton'),
|
|
52
|
+
sequenceButton: $('#sequenceButton'),
|
|
53
|
+
sequenceList: $('#sequenceList'),
|
|
54
|
+
statusText: $('#statusText'),
|
|
55
|
+
swatchGrid: $('#swatchGrid'),
|
|
56
|
+
targetAddress: $('#targetAddress'),
|
|
57
|
+
gammaControls: $('#gammaControls'),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function clamp(value, min, max) {
|
|
61
|
+
return Math.max(min, Math.min(max, value));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function clampByte(value) {
|
|
65
|
+
return Math.round(clamp(value, 0, 255));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hexToRgb(hex) {
|
|
69
|
+
const clean = hex.replace('#', '');
|
|
70
|
+
return {
|
|
71
|
+
red: parseInt(clean.slice(0, 2), 16),
|
|
72
|
+
green: parseInt(clean.slice(2, 4), 16),
|
|
73
|
+
blue: parseInt(clean.slice(4, 6), 16),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function rgbToHex({ red, green, blue }) {
|
|
78
|
+
return `#${[red, green, blue].map(value => clampByte(value).toString(16).padStart(2, '0')).join('')}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function correctedColor(input) {
|
|
82
|
+
const profile = state.profile;
|
|
83
|
+
const luma = (input.red * 0.2126) + (input.green * 0.7152) + (input.blue * 0.0722);
|
|
84
|
+
const saturated = {
|
|
85
|
+
red: luma + ((input.red - luma) * profile.saturation),
|
|
86
|
+
green: luma + ((input.green - luma) * profile.saturation),
|
|
87
|
+
blue: luma + ((input.blue - luma) * profile.saturation),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
red: channelCorrection(saturated.red, profile.gain.red, profile.gamma.red, profile.brightness),
|
|
92
|
+
green: channelCorrection(saturated.green, profile.gain.green, profile.gamma.green, profile.brightness),
|
|
93
|
+
blue: channelCorrection(saturated.blue, profile.gain.blue, profile.gamma.blue, profile.brightness),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function channelCorrection(value, gain, gamma, brightness) {
|
|
98
|
+
const normalized = clamp(value / 255, 0, 1);
|
|
99
|
+
const gammaAdjusted = normalized ** gamma;
|
|
100
|
+
return clampByte(gammaAdjusted * gain * brightness * 255);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function setStatus(text) {
|
|
104
|
+
elements.statusText.textContent = text;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function setRequested(color, send = false) {
|
|
108
|
+
state.requested = {
|
|
109
|
+
red: clampByte(color.red),
|
|
110
|
+
green: clampByte(color.green),
|
|
111
|
+
blue: clampByte(color.blue),
|
|
112
|
+
};
|
|
113
|
+
render();
|
|
114
|
+
|
|
115
|
+
if (send) {
|
|
116
|
+
void sendCurrentColor();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function makeSlider(container, options) {
|
|
121
|
+
const row = document.createElement('div');
|
|
122
|
+
row.className = 'control';
|
|
123
|
+
|
|
124
|
+
const label = document.createElement('label');
|
|
125
|
+
label.textContent = options.label;
|
|
126
|
+
|
|
127
|
+
const range = document.createElement('input');
|
|
128
|
+
range.type = 'range';
|
|
129
|
+
range.min = String(options.min);
|
|
130
|
+
range.max = String(options.max);
|
|
131
|
+
range.step = String(options.step);
|
|
132
|
+
range.value = String(options.get());
|
|
133
|
+
|
|
134
|
+
const number = document.createElement('input');
|
|
135
|
+
number.type = 'number';
|
|
136
|
+
number.min = String(options.min);
|
|
137
|
+
number.max = String(options.max);
|
|
138
|
+
number.step = String(options.step);
|
|
139
|
+
number.value = String(options.get());
|
|
140
|
+
|
|
141
|
+
const update = value => {
|
|
142
|
+
const numeric = clamp(Number(value), options.min, options.max);
|
|
143
|
+
options.set(numeric);
|
|
144
|
+
range.value = String(numeric);
|
|
145
|
+
number.value = String(numeric);
|
|
146
|
+
render();
|
|
147
|
+
if (options.live) {
|
|
148
|
+
scheduleSend();
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
range.addEventListener('input', () => update(range.value));
|
|
153
|
+
number.addEventListener('change', () => update(number.value));
|
|
154
|
+
|
|
155
|
+
row.append(label, range, number);
|
|
156
|
+
container.append(row);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let sendTimer;
|
|
160
|
+
|
|
161
|
+
function scheduleSend() {
|
|
162
|
+
if (sendTimer) {
|
|
163
|
+
clearTimeout(sendTimer);
|
|
164
|
+
}
|
|
165
|
+
sendTimer = setTimeout(() => {
|
|
166
|
+
void sendCurrentColor();
|
|
167
|
+
}, 140);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function api(path, body) {
|
|
171
|
+
const response = await fetch(path, {
|
|
172
|
+
method: 'POST',
|
|
173
|
+
headers: { 'content-type': 'application/json' },
|
|
174
|
+
body: JSON.stringify(body),
|
|
175
|
+
});
|
|
176
|
+
const data = await response.json();
|
|
177
|
+
|
|
178
|
+
if (!response.ok) {
|
|
179
|
+
throw new Error(data.error ?? response.statusText);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return data;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function sendCurrentColor() {
|
|
186
|
+
const corrected = correctedColor(state.requested);
|
|
187
|
+
try {
|
|
188
|
+
const result = await api('/api/color', corrected);
|
|
189
|
+
setStatus(`Sent ${rgbToHex(corrected)} · ${result.frames?.[0] ?? ''}`);
|
|
190
|
+
} catch (error) {
|
|
191
|
+
setStatus(error instanceof Error ? error.message : String(error));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function setPower(on) {
|
|
196
|
+
try {
|
|
197
|
+
await api('/api/power', { on });
|
|
198
|
+
setStatus(on ? 'Power on sent' : 'Power off sent');
|
|
199
|
+
} catch (error) {
|
|
200
|
+
setStatus(error instanceof Error ? error.message : String(error));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function runSequence() {
|
|
205
|
+
const colors = sequence.map(([, hex]) => correctedColor(hexToRgb(hex)));
|
|
206
|
+
try {
|
|
207
|
+
await api('/api/sequence', { colors, delayMs: 900 });
|
|
208
|
+
setStatus('Sequence complete');
|
|
209
|
+
} catch (error) {
|
|
210
|
+
setStatus(error instanceof Error ? error.message : String(error));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function profileJson() {
|
|
215
|
+
return {
|
|
216
|
+
targetAddress: state.targetAddress,
|
|
217
|
+
protocol: {
|
|
218
|
+
serviceUuid: 'fff0',
|
|
219
|
+
characteristicUuid: 'fff3',
|
|
220
|
+
colorFrame: '7e070503RRGGBB10ef',
|
|
221
|
+
},
|
|
222
|
+
colorCalibration: state.profile,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function saveLocalProfile() {
|
|
227
|
+
localStorage.setItem('lanternic-calibration-profile', JSON.stringify(state.profile));
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function loadLocalProfile() {
|
|
231
|
+
const saved = localStorage.getItem('lanternic-calibration-profile');
|
|
232
|
+
if (!saved) {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
state.profile = { ...state.profile, ...JSON.parse(saved) };
|
|
238
|
+
} catch {
|
|
239
|
+
localStorage.removeItem('lanternic-calibration-profile');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function render() {
|
|
244
|
+
const corrected = correctedColor(state.requested);
|
|
245
|
+
const requestedHex = rgbToHex(state.requested);
|
|
246
|
+
const correctedHex = rgbToHex(corrected);
|
|
247
|
+
|
|
248
|
+
elements.colorPicker.value = requestedHex;
|
|
249
|
+
elements.requestedHex.textContent = requestedHex;
|
|
250
|
+
elements.correctedHex.textContent = `sending ${correctedHex}`;
|
|
251
|
+
elements.requestedSwatch.style.background = requestedHex;
|
|
252
|
+
elements.correctedSwatch.style.background = correctedHex;
|
|
253
|
+
elements.profileOutput.value = JSON.stringify(profileJson(), null, 2);
|
|
254
|
+
saveLocalProfile();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function buildControls() {
|
|
258
|
+
for (const channel of ['red', 'green', 'blue']) {
|
|
259
|
+
makeSlider(elements.rgbControls, {
|
|
260
|
+
label: channel,
|
|
261
|
+
min: 0,
|
|
262
|
+
max: 255,
|
|
263
|
+
step: 1,
|
|
264
|
+
live: true,
|
|
265
|
+
get: () => state.requested[channel],
|
|
266
|
+
set: value => {
|
|
267
|
+
state.requested[channel] = value;
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
makeSlider(elements.globalControls, {
|
|
273
|
+
label: 'brightness',
|
|
274
|
+
min: 0.1,
|
|
275
|
+
max: 1.5,
|
|
276
|
+
step: 0.01,
|
|
277
|
+
live: true,
|
|
278
|
+
get: () => state.profile.brightness,
|
|
279
|
+
set: value => {
|
|
280
|
+
state.profile.brightness = value;
|
|
281
|
+
},
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
makeSlider(elements.globalControls, {
|
|
285
|
+
label: 'saturation',
|
|
286
|
+
min: 0,
|
|
287
|
+
max: 1.6,
|
|
288
|
+
step: 0.01,
|
|
289
|
+
live: true,
|
|
290
|
+
get: () => state.profile.saturation,
|
|
291
|
+
set: value => {
|
|
292
|
+
state.profile.saturation = value;
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
for (const channel of ['red', 'green', 'blue']) {
|
|
297
|
+
makeSlider(elements.gainControls, {
|
|
298
|
+
label: channel,
|
|
299
|
+
min: 0.2,
|
|
300
|
+
max: 2,
|
|
301
|
+
step: 0.01,
|
|
302
|
+
live: true,
|
|
303
|
+
get: () => state.profile.gain[channel],
|
|
304
|
+
set: value => {
|
|
305
|
+
state.profile.gain[channel] = value;
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
for (const channel of ['red', 'green', 'blue']) {
|
|
311
|
+
makeSlider(elements.gammaControls, {
|
|
312
|
+
label: channel,
|
|
313
|
+
min: 0.4,
|
|
314
|
+
max: 3,
|
|
315
|
+
step: 0.01,
|
|
316
|
+
live: true,
|
|
317
|
+
get: () => state.profile.gamma[channel],
|
|
318
|
+
set: value => {
|
|
319
|
+
state.profile.gamma[channel] = value;
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function buildSwatches() {
|
|
326
|
+
for (const [name, hex] of swatches) {
|
|
327
|
+
const button = document.createElement('button');
|
|
328
|
+
button.className = 'swatch-button';
|
|
329
|
+
button.type = 'button';
|
|
330
|
+
button.innerHTML = `<span class="mini-swatch" style="background:${hex}"></span><span>${name}</span>`;
|
|
331
|
+
button.addEventListener('click', () => setRequested(hexToRgb(hex), true));
|
|
332
|
+
elements.swatchGrid.append(button);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
for (const [name, hex] of sequence) {
|
|
336
|
+
const item = document.createElement('div');
|
|
337
|
+
item.className = 'swatch-button';
|
|
338
|
+
item.innerHTML = `<span class="mini-swatch" style="background:${hex}"></span><span>${name}</span>`;
|
|
339
|
+
elements.sequenceList.append(item);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function resetProfile() {
|
|
344
|
+
state.profile = {
|
|
345
|
+
brightness: 1,
|
|
346
|
+
saturation: 1,
|
|
347
|
+
gain: { red: 1, green: 1, blue: 1 },
|
|
348
|
+
gamma: { red: 1, green: 1, blue: 1 },
|
|
349
|
+
};
|
|
350
|
+
localStorage.removeItem('lanternic-calibration-profile');
|
|
351
|
+
window.location.reload();
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function init() {
|
|
355
|
+
loadLocalProfile();
|
|
356
|
+
|
|
357
|
+
const status = await fetch('/api/status').then(response => response.json());
|
|
358
|
+
state.targetAddress = status.targetAddress;
|
|
359
|
+
elements.targetAddress.value = status.targetAddress;
|
|
360
|
+
if (status.savedProfile?.colorCalibration) {
|
|
361
|
+
state.profile = status.savedProfile.colorCalibration;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
buildControls();
|
|
365
|
+
buildSwatches();
|
|
366
|
+
|
|
367
|
+
elements.colorPicker.addEventListener('input', () => setRequested(hexToRgb(elements.colorPicker.value), true));
|
|
368
|
+
elements.sendButton.addEventListener('click', () => void sendCurrentColor());
|
|
369
|
+
elements.powerOnButton.addEventListener('click', () => void setPower(true));
|
|
370
|
+
elements.powerOffButton.addEventListener('click', () => void setPower(false));
|
|
371
|
+
elements.sequenceButton.addEventListener('click', () => void runSequence());
|
|
372
|
+
elements.resetButton.addEventListener('click', resetProfile);
|
|
373
|
+
elements.saveTargetButton.addEventListener('click', async () => {
|
|
374
|
+
const result = await api('/api/target', { address: elements.targetAddress.value });
|
|
375
|
+
state.targetAddress = result.targetAddress;
|
|
376
|
+
setStatus(`Target set to ${state.targetAddress}`);
|
|
377
|
+
render();
|
|
378
|
+
});
|
|
379
|
+
elements.saveProfileButton.addEventListener('click', async () => {
|
|
380
|
+
const result = await api('/api/profile', profileJson());
|
|
381
|
+
setStatus(`Saved ${result.path}`);
|
|
382
|
+
});
|
|
383
|
+
elements.downloadProfileButton.addEventListener('click', () => {
|
|
384
|
+
const blob = new Blob([`${JSON.stringify(profileJson(), null, 2)}\n`], { type: 'application/json' });
|
|
385
|
+
const url = URL.createObjectURL(blob);
|
|
386
|
+
const link = document.createElement('a');
|
|
387
|
+
link.href = url;
|
|
388
|
+
link.download = 'lanternic-calibration.json';
|
|
389
|
+
link.click();
|
|
390
|
+
URL.revokeObjectURL(url);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
render();
|
|
394
|
+
setStatus(`Ready · ${status.binding} · ${status.serviceUuid}/${status.characteristicUuid}`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
init().catch(error => {
|
|
398
|
+
setStatus(error instanceof Error ? error.message : String(error));
|
|
399
|
+
});
|