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,91 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>LanternIC Calibrator</title>
|
|
7
|
+
<link rel="stylesheet" href="/styles.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<main class="app-shell">
|
|
11
|
+
<header class="topbar">
|
|
12
|
+
<div>
|
|
13
|
+
<h1>LanternIC Calibrator</h1>
|
|
14
|
+
<p id="statusText">Starting…</p>
|
|
15
|
+
</div>
|
|
16
|
+
<div class="target-row">
|
|
17
|
+
<label for="targetAddress">Target</label>
|
|
18
|
+
<input id="targetAddress" spellcheck="false">
|
|
19
|
+
<button id="saveTargetButton" type="button">Use Target</button>
|
|
20
|
+
</div>
|
|
21
|
+
</header>
|
|
22
|
+
|
|
23
|
+
<section class="workspace">
|
|
24
|
+
<div class="panel color-panel">
|
|
25
|
+
<div class="panel-heading">
|
|
26
|
+
<h2>Live Color</h2>
|
|
27
|
+
<div class="swatch-pair">
|
|
28
|
+
<span id="requestedSwatch" class="swatch"></span>
|
|
29
|
+
<span id="correctedSwatch" class="swatch"></span>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div class="picker-row">
|
|
34
|
+
<input id="colorPicker" type="color" value="#ff0000" aria-label="Color picker">
|
|
35
|
+
<div>
|
|
36
|
+
<strong id="requestedHex">#ff0000</strong>
|
|
37
|
+
<span id="correctedHex">sending #ff0000</span>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div class="slider-stack" id="rgbControls"></div>
|
|
42
|
+
|
|
43
|
+
<div class="button-row">
|
|
44
|
+
<button id="sendButton" type="button">Send Color</button>
|
|
45
|
+
<button id="powerOnButton" type="button">On</button>
|
|
46
|
+
<button id="powerOffButton" type="button">Off</button>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<div class="swatch-grid" id="swatchGrid"></div>
|
|
50
|
+
</div>
|
|
51
|
+
|
|
52
|
+
<div class="panel">
|
|
53
|
+
<div class="panel-heading">
|
|
54
|
+
<h2>Correction</h2>
|
|
55
|
+
<button id="resetButton" type="button">Reset</button>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div class="slider-stack" id="globalControls"></div>
|
|
59
|
+
<div class="control-columns">
|
|
60
|
+
<div>
|
|
61
|
+
<h3>Gain</h3>
|
|
62
|
+
<div class="slider-stack" id="gainControls"></div>
|
|
63
|
+
</div>
|
|
64
|
+
<div>
|
|
65
|
+
<h3>Gamma</h3>
|
|
66
|
+
<div class="slider-stack" id="gammaControls"></div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<div class="panel">
|
|
72
|
+
<div class="panel-heading">
|
|
73
|
+
<h2>Test</h2>
|
|
74
|
+
<button id="sequenceButton" type="button">Run Sequence</button>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div class="sequence-list" id="sequenceList"></div>
|
|
78
|
+
|
|
79
|
+
<h2>Profile</h2>
|
|
80
|
+
<div class="button-row">
|
|
81
|
+
<button id="saveProfileButton" type="button">Save</button>
|
|
82
|
+
<button id="downloadProfileButton" type="button">Download</button>
|
|
83
|
+
</div>
|
|
84
|
+
<textarea id="profileOutput" readonly spellcheck="false"></textarea>
|
|
85
|
+
</div>
|
|
86
|
+
</section>
|
|
87
|
+
</main>
|
|
88
|
+
|
|
89
|
+
<script src="/app.js" type="module"></script>
|
|
90
|
+
</body>
|
|
91
|
+
</html>
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light;
|
|
3
|
+
--bg: #f5f7f8;
|
|
4
|
+
--panel: #ffffff;
|
|
5
|
+
--text: #182026;
|
|
6
|
+
--muted: #66727c;
|
|
7
|
+
--line: #d9e0e5;
|
|
8
|
+
--accent: #226c5f;
|
|
9
|
+
--accent-strong: #174f46;
|
|
10
|
+
--danger: #a93f3f;
|
|
11
|
+
--shadow: 0 10px 30px rgba(24, 32, 38, 0.08);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
* {
|
|
15
|
+
box-sizing: border-box;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
body {
|
|
19
|
+
margin: 0;
|
|
20
|
+
min-width: 320px;
|
|
21
|
+
background: var(--bg);
|
|
22
|
+
color: var(--text);
|
|
23
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
button,
|
|
27
|
+
input,
|
|
28
|
+
textarea {
|
|
29
|
+
font: inherit;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
button {
|
|
33
|
+
min-height: 38px;
|
|
34
|
+
border: 1px solid var(--line);
|
|
35
|
+
border-radius: 6px;
|
|
36
|
+
background: #ffffff;
|
|
37
|
+
color: var(--text);
|
|
38
|
+
padding: 8px 12px;
|
|
39
|
+
cursor: pointer;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
button:hover {
|
|
43
|
+
border-color: var(--accent);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
button:active {
|
|
47
|
+
transform: translateY(1px);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.app-shell {
|
|
51
|
+
width: min(1500px, 100%);
|
|
52
|
+
margin: 0 auto;
|
|
53
|
+
padding: 20px;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.topbar {
|
|
57
|
+
display: grid;
|
|
58
|
+
grid-template-columns: minmax(240px, 1fr) minmax(320px, 620px);
|
|
59
|
+
gap: 20px;
|
|
60
|
+
align-items: end;
|
|
61
|
+
padding: 6px 0 18px;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
h1,
|
|
65
|
+
h2,
|
|
66
|
+
h3,
|
|
67
|
+
p {
|
|
68
|
+
margin: 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
h1 {
|
|
72
|
+
font-size: 28px;
|
|
73
|
+
line-height: 1.15;
|
|
74
|
+
font-weight: 760;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
h2 {
|
|
78
|
+
font-size: 18px;
|
|
79
|
+
line-height: 1.2;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
h3 {
|
|
83
|
+
margin-bottom: 10px;
|
|
84
|
+
font-size: 13px;
|
|
85
|
+
color: var(--muted);
|
|
86
|
+
text-transform: uppercase;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#statusText {
|
|
90
|
+
margin-top: 6px;
|
|
91
|
+
color: var(--muted);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.target-row {
|
|
95
|
+
display: grid;
|
|
96
|
+
grid-template-columns: auto minmax(0, 1fr) auto;
|
|
97
|
+
gap: 10px;
|
|
98
|
+
align-items: center;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.target-row label {
|
|
102
|
+
color: var(--muted);
|
|
103
|
+
font-size: 13px;
|
|
104
|
+
font-weight: 700;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.target-row input {
|
|
108
|
+
width: 100%;
|
|
109
|
+
min-height: 38px;
|
|
110
|
+
border: 1px solid var(--line);
|
|
111
|
+
border-radius: 6px;
|
|
112
|
+
padding: 8px 10px;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.workspace {
|
|
116
|
+
display: grid;
|
|
117
|
+
grid-template-columns: 1.1fr 1fr 0.9fr;
|
|
118
|
+
gap: 16px;
|
|
119
|
+
align-items: start;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.panel {
|
|
123
|
+
min-width: 0;
|
|
124
|
+
border: 1px solid var(--line);
|
|
125
|
+
border-radius: 8px;
|
|
126
|
+
background: var(--panel);
|
|
127
|
+
box-shadow: var(--shadow);
|
|
128
|
+
padding: 16px;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
.panel-heading {
|
|
132
|
+
display: flex;
|
|
133
|
+
justify-content: space-between;
|
|
134
|
+
gap: 12px;
|
|
135
|
+
align-items: center;
|
|
136
|
+
margin-bottom: 16px;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.swatch-pair {
|
|
140
|
+
display: flex;
|
|
141
|
+
gap: 8px;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
.swatch {
|
|
145
|
+
display: inline-block;
|
|
146
|
+
width: 34px;
|
|
147
|
+
height: 34px;
|
|
148
|
+
border: 1px solid var(--line);
|
|
149
|
+
border-radius: 6px;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.picker-row {
|
|
153
|
+
display: grid;
|
|
154
|
+
grid-template-columns: 76px minmax(0, 1fr);
|
|
155
|
+
gap: 14px;
|
|
156
|
+
align-items: center;
|
|
157
|
+
margin-bottom: 18px;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
#colorPicker {
|
|
161
|
+
width: 76px;
|
|
162
|
+
height: 58px;
|
|
163
|
+
border: 0;
|
|
164
|
+
padding: 0;
|
|
165
|
+
background: transparent;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#requestedHex,
|
|
169
|
+
#correctedHex {
|
|
170
|
+
display: block;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#correctedHex {
|
|
174
|
+
margin-top: 4px;
|
|
175
|
+
color: var(--muted);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
.slider-stack {
|
|
179
|
+
display: grid;
|
|
180
|
+
gap: 10px;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.control {
|
|
184
|
+
display: grid;
|
|
185
|
+
grid-template-columns: 76px minmax(120px, 1fr) 64px;
|
|
186
|
+
gap: 10px;
|
|
187
|
+
align-items: center;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
.control label {
|
|
191
|
+
color: var(--muted);
|
|
192
|
+
font-size: 13px;
|
|
193
|
+
font-weight: 700;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.control input[type="range"] {
|
|
197
|
+
width: 100%;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.control input[type="number"] {
|
|
201
|
+
width: 64px;
|
|
202
|
+
min-height: 32px;
|
|
203
|
+
border: 1px solid var(--line);
|
|
204
|
+
border-radius: 6px;
|
|
205
|
+
padding: 5px 7px;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.button-row {
|
|
209
|
+
display: flex;
|
|
210
|
+
flex-wrap: wrap;
|
|
211
|
+
gap: 8px;
|
|
212
|
+
margin-top: 16px;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#sendButton,
|
|
216
|
+
#sequenceButton,
|
|
217
|
+
#saveTargetButton {
|
|
218
|
+
border-color: var(--accent);
|
|
219
|
+
background: var(--accent);
|
|
220
|
+
color: #ffffff;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
#sendButton:hover,
|
|
224
|
+
#sequenceButton:hover,
|
|
225
|
+
#saveTargetButton:hover {
|
|
226
|
+
background: var(--accent-strong);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
#powerOffButton {
|
|
230
|
+
color: var(--danger);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.swatch-grid,
|
|
234
|
+
.sequence-list {
|
|
235
|
+
display: grid;
|
|
236
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
237
|
+
gap: 8px;
|
|
238
|
+
margin-top: 16px;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.swatch-button {
|
|
242
|
+
display: grid;
|
|
243
|
+
grid-template-columns: 24px minmax(0, 1fr);
|
|
244
|
+
gap: 8px;
|
|
245
|
+
align-items: center;
|
|
246
|
+
min-width: 0;
|
|
247
|
+
text-align: left;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.mini-swatch {
|
|
251
|
+
width: 24px;
|
|
252
|
+
height: 24px;
|
|
253
|
+
border: 1px solid var(--line);
|
|
254
|
+
border-radius: 5px;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
.control-columns {
|
|
258
|
+
display: grid;
|
|
259
|
+
grid-template-columns: 1fr 1fr;
|
|
260
|
+
gap: 16px;
|
|
261
|
+
margin-top: 18px;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
textarea {
|
|
265
|
+
width: 100%;
|
|
266
|
+
min-height: 170px;
|
|
267
|
+
margin-top: 12px;
|
|
268
|
+
border: 1px solid var(--line);
|
|
269
|
+
border-radius: 6px;
|
|
270
|
+
padding: 10px;
|
|
271
|
+
resize: vertical;
|
|
272
|
+
color: var(--text);
|
|
273
|
+
background: #fbfcfc;
|
|
274
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
275
|
+
font-size: 12px;
|
|
276
|
+
line-height: 1.45;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
@media (max-width: 1100px) {
|
|
280
|
+
.workspace,
|
|
281
|
+
.topbar {
|
|
282
|
+
grid-template-columns: 1fr;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
@media (max-width: 680px) {
|
|
287
|
+
.app-shell {
|
|
288
|
+
padding: 14px;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.target-row,
|
|
292
|
+
.picker-row,
|
|
293
|
+
.control,
|
|
294
|
+
.control-columns {
|
|
295
|
+
grid-template-columns: 1fr;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.swatch-grid,
|
|
299
|
+
.sequence-list {
|
|
300
|
+
grid-template-columns: 1fr 1fr;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { withBindings } from '@stoprocent/noble';
|
|
3
|
+
|
|
4
|
+
const binding = process.env.LANTERNIC_BINDING ?? 'default';
|
|
5
|
+
const address = process.argv[2];
|
|
6
|
+
|
|
7
|
+
if (!address) {
|
|
8
|
+
console.error('Usage: lanternic-explore <address>');
|
|
9
|
+
console.error('Repo development: npm run explore -- <address>');
|
|
10
|
+
process.exit(2);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const cleanId = input => String(input ?? '').replace(/[^0-9a-f]/gi, '').toLowerCase();
|
|
14
|
+
const peripheralId = peripheral => peripheral.address || peripheral.uuid || peripheral.id;
|
|
15
|
+
const targetId = cleanId(address);
|
|
16
|
+
const noble = withBindings(binding);
|
|
17
|
+
|
|
18
|
+
console.log(`Exploring ${address} with binding=${binding}`);
|
|
19
|
+
|
|
20
|
+
await noble.waitForPoweredOnAsync(15_000);
|
|
21
|
+
await noble.startScanningAsync([], true);
|
|
22
|
+
|
|
23
|
+
const peripheral = await new Promise((resolve, reject) => {
|
|
24
|
+
const timeout = setTimeout(() => {
|
|
25
|
+
noble.removeListener('discover', onDiscover);
|
|
26
|
+
reject(new Error(`Timed out scanning for ${address}`));
|
|
27
|
+
}, 20_000);
|
|
28
|
+
|
|
29
|
+
const onDiscover = candidate => {
|
|
30
|
+
const ids = [candidate.id, candidate.uuid, candidate.address, peripheralId(candidate)].map(cleanId);
|
|
31
|
+
if (!ids.includes(targetId)) {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
clearTimeout(timeout);
|
|
36
|
+
noble.removeListener('discover', onDiscover);
|
|
37
|
+
resolve(candidate);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
noble.on('discover', onDiscover);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
await noble.stopScanningAsync();
|
|
44
|
+
|
|
45
|
+
const name = peripheral.advertisement?.localName ?? '(unnamed)';
|
|
46
|
+
console.log(`Found ${name} id=${peripheral.id} uuid=${peripheral.uuid ?? 'n/a'} address=${peripheral.address ?? 'n/a'} rssi=${peripheral.rssi ?? 'n/a'}`);
|
|
47
|
+
|
|
48
|
+
await peripheral.connectAsync();
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const { services } = await peripheral.discoverAllServicesAndCharacteristicsAsync();
|
|
52
|
+
|
|
53
|
+
for (const service of services) {
|
|
54
|
+
console.log(`service ${service.uuid}`);
|
|
55
|
+
|
|
56
|
+
for (const characteristic of service.characteristics ?? []) {
|
|
57
|
+
const properties = characteristic.properties?.join(',') ?? '';
|
|
58
|
+
console.log(` char ${characteristic.uuid} props=${properties}`);
|
|
59
|
+
|
|
60
|
+
if (characteristic.properties?.includes('read')) {
|
|
61
|
+
try {
|
|
62
|
+
const value = await characteristic.readAsync();
|
|
63
|
+
console.log(` read ${value.toString('hex') || '(empty)'}`);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.log(` read failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
await peripheral.disconnectAsync();
|
|
72
|
+
noble.stop();
|
|
73
|
+
}
|
package/tools/scan.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { withBindings } from '@stoprocent/noble';
|
|
3
|
+
|
|
4
|
+
const cleanId = value => String(value ?? '').replace(/[^0-9a-f]/gi, '').toLowerCase();
|
|
5
|
+
const binding = process.env.LANTERNIC_BINDING ?? 'default';
|
|
6
|
+
const seconds = Number(process.env.LANTERNIC_SCAN_SECONDS ?? '20');
|
|
7
|
+
const showAll = process.env.LANTERNIC_SCAN_ALL === '1';
|
|
8
|
+
const minRssi = process.env.LANTERNIC_MIN_RSSI === undefined
|
|
9
|
+
? undefined
|
|
10
|
+
: Number(process.env.LANTERNIC_MIN_RSSI);
|
|
11
|
+
const prefixes = (process.env.LANTERNIC_PREFIXES ?? 'Triones,MELK,ELK-BLEDOM,LED,OA')
|
|
12
|
+
.split(',')
|
|
13
|
+
.map(prefix => prefix.trim().toLowerCase())
|
|
14
|
+
.filter(Boolean);
|
|
15
|
+
const serviceUuids = (process.env.LANTERNIC_SERVICE_UUIDS ?? 'fff0')
|
|
16
|
+
.split(',')
|
|
17
|
+
.map(prefix => cleanId(prefix.trim()))
|
|
18
|
+
.filter(Boolean);
|
|
19
|
+
|
|
20
|
+
const noble = withBindings(binding);
|
|
21
|
+
const seen = new Map();
|
|
22
|
+
|
|
23
|
+
const peripheralId = peripheral => peripheral.address || peripheral.uuid || peripheral.id;
|
|
24
|
+
const formatAddress = value => {
|
|
25
|
+
const normalized = cleanId(value);
|
|
26
|
+
if (normalized.length !== 12) {
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
return normalized.match(/.{1,2}/g).join(':');
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const matches = peripheral => {
|
|
33
|
+
if (showAll) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const name = peripheral.advertisement?.localName?.toLowerCase() ?? '';
|
|
38
|
+
const advertisedServiceUuids = peripheral.advertisement?.serviceUuids?.map(cleanId) ?? [];
|
|
39
|
+
const matchesPrefix = prefixes.some(prefix => name.startsWith(prefix));
|
|
40
|
+
const matchesService = serviceUuids.some(serviceUuid => advertisedServiceUuids.includes(serviceUuid));
|
|
41
|
+
const matchesRssi = typeof minRssi !== 'number'
|
|
42
|
+
|| !Number.isFinite(minRssi)
|
|
43
|
+
|| typeof peripheral.rssi !== 'number'
|
|
44
|
+
|| peripheral.rssi >= minRssi;
|
|
45
|
+
return (matchesPrefix || matchesService) && matchesRssi;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
console.log(`Scanning ${seconds}s with binding=${binding} showAll=${showAll}`);
|
|
49
|
+
console.log('If macOS asks for Bluetooth access, approve it for the app running this command.');
|
|
50
|
+
|
|
51
|
+
await noble.waitForPoweredOnAsync(15_000);
|
|
52
|
+
|
|
53
|
+
noble.on('discover', peripheral => {
|
|
54
|
+
if (!matches(peripheral)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const id = peripheralId(peripheral);
|
|
59
|
+
const key = cleanId(id);
|
|
60
|
+
|
|
61
|
+
if (seen.has(key)) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const name = peripheral.advertisement?.localName ?? '(unnamed)';
|
|
66
|
+
const address = formatAddress(peripheralId(peripheral));
|
|
67
|
+
const rssi = typeof peripheral.rssi === 'number' ? peripheral.rssi : 'n/a';
|
|
68
|
+
const advertisedServiceUuids = peripheral.advertisement?.serviceUuids ?? [];
|
|
69
|
+
const manufacturerData = peripheral.advertisement?.manufacturerData?.toString('hex');
|
|
70
|
+
const deviceConfig = {
|
|
71
|
+
name: name === '(unnamed)' ? `LanternIC ${String(id).slice(-6)}` : name,
|
|
72
|
+
address: id,
|
|
73
|
+
manufacturer: 'Magic Lantern',
|
|
74
|
+
model: 'Magic Lantern RGBIC',
|
|
75
|
+
colorOrder: 'rgb',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
seen.set(key, { name, address, id: peripheral.id, uuid: peripheral.uuid, rssi });
|
|
79
|
+
console.log(`${name} address=${address} id=${peripheral.id} uuid=${peripheral.uuid ?? 'n/a'} rssi=${rssi} services=${advertisedServiceUuids.join(',') || 'n/a'} manufacturerData=${manufacturerData ?? 'n/a'}`);
|
|
80
|
+
console.log(` Homebridge device JSON: ${JSON.stringify(deviceConfig)}`);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await noble.startScanningAsync([], true);
|
|
84
|
+
await new Promise(resolve => setTimeout(resolve, seconds * 1000));
|
|
85
|
+
await noble.stopScanningAsync();
|
|
86
|
+
noble.stop();
|
|
87
|
+
|
|
88
|
+
console.log(`Found ${seen.size} matching device(s).`);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { withBindings } from '@stoprocent/noble';
|
|
3
|
+
|
|
4
|
+
const binding = process.env.LANTERNIC_BINDING ?? 'default';
|
|
5
|
+
const serviceUuid = (process.env.LANTERNIC_SERVICE_UUID ?? 'fff0').replace(/[^0-9a-f]/gi, '').toLowerCase();
|
|
6
|
+
const characteristicUuid = (process.env.LANTERNIC_CHARACTERISTIC_UUID ?? 'fff3').replace(/[^0-9a-f]/gi, '').toLowerCase();
|
|
7
|
+
const delayMs = Number(process.env.LANTERNIC_SEQUENCE_DELAY_MS ?? '150');
|
|
8
|
+
const address = process.argv[2];
|
|
9
|
+
const frames = process.argv.slice(3);
|
|
10
|
+
|
|
11
|
+
if (!address || frames.length === 0) {
|
|
12
|
+
console.error('Usage: lanternic-send-sequence <address> <hex-frame> [hex-frame...]');
|
|
13
|
+
console.error('Repo development: npm run send-sequence -- <address> <hex-frame> [hex-frame...]');
|
|
14
|
+
process.exit(2);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const cleanId = input => String(input ?? '').replace(/[^0-9a-f]/gi, '').toLowerCase();
|
|
18
|
+
const peripheralId = peripheral => peripheral.address || peripheral.uuid || peripheral.id;
|
|
19
|
+
const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
|
|
20
|
+
const targetId = cleanId(address);
|
|
21
|
+
const noble = withBindings(binding);
|
|
22
|
+
const payloads = frames.map(frame => Buffer.from(cleanId(frame), 'hex'));
|
|
23
|
+
|
|
24
|
+
console.log(`Sending ${payloads.length} frame(s) to ${address} with binding=${binding}`);
|
|
25
|
+
|
|
26
|
+
await noble.waitForPoweredOnAsync(15_000);
|
|
27
|
+
await noble.startScanningAsync([], true);
|
|
28
|
+
|
|
29
|
+
const peripheral = await new Promise((resolve, reject) => {
|
|
30
|
+
const timeout = setTimeout(() => {
|
|
31
|
+
noble.removeListener('discover', onDiscover);
|
|
32
|
+
reject(new Error(`Timed out scanning for ${address}`));
|
|
33
|
+
}, 20_000);
|
|
34
|
+
|
|
35
|
+
const onDiscover = candidate => {
|
|
36
|
+
const ids = [candidate.id, candidate.uuid, candidate.address, peripheralId(candidate)].map(cleanId);
|
|
37
|
+
if (!ids.includes(targetId)) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
clearTimeout(timeout);
|
|
42
|
+
noble.removeListener('discover', onDiscover);
|
|
43
|
+
resolve(candidate);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
noble.on('discover', onDiscover);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await noble.stopScanningAsync();
|
|
50
|
+
await peripheral.connectAsync();
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
const { characteristics } = await peripheral.discoverSomeServicesAndCharacteristicsAsync(
|
|
54
|
+
[serviceUuid],
|
|
55
|
+
[characteristicUuid],
|
|
56
|
+
);
|
|
57
|
+
const characteristic = characteristics[0];
|
|
58
|
+
|
|
59
|
+
if (!characteristic) {
|
|
60
|
+
throw new Error(`Missing characteristic ${serviceUuid}/${characteristicUuid}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const withoutResponse = !characteristic.properties.includes('write')
|
|
64
|
+
&& characteristic.properties.includes('writeWithoutResponse');
|
|
65
|
+
|
|
66
|
+
for (const payload of payloads) {
|
|
67
|
+
console.log(`WRITE ${payload.toString('hex')} withoutResponse=${withoutResponse}`);
|
|
68
|
+
await characteristic.writeAsync(payload, withoutResponse);
|
|
69
|
+
await wait(delayMs);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log('Sequence complete');
|
|
73
|
+
} finally {
|
|
74
|
+
await peripheral.disconnectAsync();
|
|
75
|
+
noble.stop();
|
|
76
|
+
}
|