esp32tool 1.3.8 → 1.4.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/apple-touch-icon.png +0 -0
- package/icons/icon-128.png +0 -0
- package/icons/icon-144.png +0 -0
- package/icons/icon-152.png +0 -0
- package/icons/icon-192.png +0 -0
- package/icons/icon-384.png +0 -0
- package/icons/icon-512.png +0 -0
- package/icons/icon-72.png +0 -0
- package/icons/icon-96.png +0 -0
- package/js/console.js +31 -0
- package/js/improv.js +1163 -0
- package/package.json +1 -1
- package/screenshots/desktop.png +0 -0
- package/screenshots/mobile.png +0 -0
package/apple-touch-icon.png
CHANGED
|
Binary file
|
package/icons/icon-128.png
CHANGED
|
Binary file
|
package/icons/icon-144.png
CHANGED
|
Binary file
|
package/icons/icon-152.png
CHANGED
|
Binary file
|
package/icons/icon-192.png
CHANGED
|
Binary file
|
package/icons/icon-384.png
CHANGED
|
Binary file
|
package/icons/icon-512.png
CHANGED
|
Binary file
|
package/icons/icon-72.png
CHANGED
|
Binary file
|
package/icons/icon-96.png
CHANGED
|
Binary file
|
package/js/console.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { ColoredConsole, coloredConsoleStyles } from "./util/console-color.js";
|
|
2
2
|
import { LineBreakTransformer } from "./util/line-break-transformer.js";
|
|
3
|
+
import { ImprovDialog } from "./improv.js";
|
|
3
4
|
|
|
4
5
|
export class ESP32ToolConsole {
|
|
5
6
|
// Bootloader detection patterns
|
|
@@ -107,6 +108,7 @@ export class ESP32ToolConsole {
|
|
|
107
108
|
<div class="esp32tool-console-wrapper">
|
|
108
109
|
<div class="esp32tool-console-header">
|
|
109
110
|
<div class="esp32tool-console-controls">
|
|
111
|
+
<button id="console-improv-btn" title="Improv Wi-Fi">Improv</button>
|
|
110
112
|
<button id="console-clear-btn">Clear</button>
|
|
111
113
|
<button id="console-reset-btn">Reset Device</button>
|
|
112
114
|
<button id="console-close-btn">Close Console</button>
|
|
@@ -149,6 +151,11 @@ export class ESP32ToolConsole {
|
|
|
149
151
|
});
|
|
150
152
|
}
|
|
151
153
|
|
|
154
|
+
const improvBtn = this.containerElement.querySelector("#console-improv-btn");
|
|
155
|
+
if (improvBtn) {
|
|
156
|
+
improvBtn.addEventListener("click", () => this._openImprov());
|
|
157
|
+
}
|
|
158
|
+
|
|
152
159
|
if (this.allowInput) {
|
|
153
160
|
const input = this.containerElement.querySelector(".esp32tool-console-input");
|
|
154
161
|
|
|
@@ -325,6 +332,30 @@ export class ESP32ToolConsole {
|
|
|
325
332
|
}
|
|
326
333
|
}
|
|
327
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Open Improv Wi-Fi dialog.
|
|
337
|
+
* Temporarily disconnects the console reader so Improv can use the serial port,
|
|
338
|
+
* then reconnects when the dialog is closed.
|
|
339
|
+
*/
|
|
340
|
+
async _openImprov() {
|
|
341
|
+
const dialog = new ImprovDialog(this.port);
|
|
342
|
+
await dialog.open(
|
|
343
|
+
// disconnectConsole
|
|
344
|
+
async () => {
|
|
345
|
+
await this.disconnect();
|
|
346
|
+
},
|
|
347
|
+
// reconnectConsole
|
|
348
|
+
async () => {
|
|
349
|
+
const abortController = new AbortController();
|
|
350
|
+
const connection = this._connect(abortController.signal);
|
|
351
|
+
this.cancelConnection = () => {
|
|
352
|
+
abortController.abort();
|
|
353
|
+
return connection;
|
|
354
|
+
};
|
|
355
|
+
},
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
328
359
|
async reconnect(newPort) {
|
|
329
360
|
await this.disconnect();
|
|
330
361
|
this.port = newPort;
|
package/js/improv.js
ADDED
|
@@ -0,0 +1,1163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Improv Wi-Fi Serial Protocol implementation for esp32tool
|
|
3
|
+
* Based on the Improv Wi-Fi Serial SDK (https://github.com/improv-wifi/sdk-serial-js)
|
|
4
|
+
* Protocol spec: https://www.improv-wifi.com/serial/
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Protocol constants
|
|
8
|
+
const SERIAL_PACKET_HEADER = [
|
|
9
|
+
0x49, 0x4d, 0x50, 0x52, 0x4f, 0x56, // "IMPROV"
|
|
10
|
+
1, // protocol version
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const ImprovSerialMessageType = {
|
|
14
|
+
CURRENT_STATE: 0x01,
|
|
15
|
+
ERROR_STATE: 0x02,
|
|
16
|
+
RPC: 0x03,
|
|
17
|
+
RPC_RESULT: 0x04,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const ImprovSerialCurrentState = {
|
|
21
|
+
READY: 0x02,
|
|
22
|
+
PROVISIONING: 0x03,
|
|
23
|
+
PROVISIONED: 0x04,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const ImprovSerialErrorState = {
|
|
27
|
+
NO_ERROR: 0x00,
|
|
28
|
+
INVALID_RPC_PACKET: 0x01,
|
|
29
|
+
UNKNOWN_RPC_COMMAND: 0x02,
|
|
30
|
+
UNABLE_TO_CONNECT: 0x03,
|
|
31
|
+
TIMEOUT: 0xfe,
|
|
32
|
+
UNKNOWN_ERROR: 0xff,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const ImprovSerialRPCCommand = {
|
|
36
|
+
SEND_WIFI_SETTINGS: 0x01,
|
|
37
|
+
REQUEST_CURRENT_STATE: 0x02,
|
|
38
|
+
REQUEST_INFO: 0x03,
|
|
39
|
+
REQUEST_WIFI_NETWORKS: 0x04,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const ERROR_MSGS = {
|
|
43
|
+
0x00: "No error",
|
|
44
|
+
0x01: "Invalid RPC packet",
|
|
45
|
+
0x02: "Unknown RPC command",
|
|
46
|
+
0x03: "Unable to connect",
|
|
47
|
+
0xfe: "Timeout",
|
|
48
|
+
0xff: "Unknown error",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function sleep(ms) {
|
|
52
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* ImprovSerial – communicates with an ESP device using the Improv protocol
|
|
57
|
+
* over an already-opened Web Serial port.
|
|
58
|
+
*/
|
|
59
|
+
class ImprovSerial extends EventTarget {
|
|
60
|
+
constructor(port, logger) {
|
|
61
|
+
super();
|
|
62
|
+
this.port = port;
|
|
63
|
+
this.logger = logger || { log() {}, error() {}, debug() {} };
|
|
64
|
+
this.info = null;
|
|
65
|
+
this.nextUrl = undefined;
|
|
66
|
+
this.state = undefined;
|
|
67
|
+
this.error = ImprovSerialErrorState.NO_ERROR;
|
|
68
|
+
this._reader = null;
|
|
69
|
+
this._rpcFeedback = null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Detect Improv Serial, fetch state and device info.
|
|
74
|
+
* @param {number} timeout – ms to wait for the device to respond (default 1000)
|
|
75
|
+
* @returns {Promise<object>} device info
|
|
76
|
+
*/
|
|
77
|
+
async initialize(timeout = 1000) {
|
|
78
|
+
this.logger.log("Initializing Improv Serial");
|
|
79
|
+
this._processInput().catch((err) => {
|
|
80
|
+
this.logger.error("Improv read loop failed to start", err);
|
|
81
|
+
});
|
|
82
|
+
// Give the input processing time to start
|
|
83
|
+
await sleep(1000);
|
|
84
|
+
if (!this._reader) {
|
|
85
|
+
throw new Error("Port is not ready");
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
let timer;
|
|
89
|
+
const statePromise = this.requestCurrentState();
|
|
90
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
91
|
+
timer = setTimeout(() => reject(new Error("Improv Wi-Fi Serial not detected")), timeout);
|
|
92
|
+
});
|
|
93
|
+
try {
|
|
94
|
+
await Promise.race([statePromise, timeoutPromise]);
|
|
95
|
+
} finally {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
}
|
|
98
|
+
await this.requestInfo();
|
|
99
|
+
} catch (err) {
|
|
100
|
+
await this.close();
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
return this.info;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async close() {
|
|
107
|
+
if (!this._reader) return;
|
|
108
|
+
await new Promise((resolve) => {
|
|
109
|
+
this.addEventListener("disconnect", resolve, { once: true });
|
|
110
|
+
this._reader.cancel();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Request current state. If already provisioned, also retrieves the URL.
|
|
116
|
+
*/
|
|
117
|
+
async requestCurrentState() {
|
|
118
|
+
let rpcResult;
|
|
119
|
+
try {
|
|
120
|
+
const stateChanged = new Promise((resolve, reject) => {
|
|
121
|
+
this.addEventListener("state-changed", resolve, { once: true });
|
|
122
|
+
// Store reject for cleanup below
|
|
123
|
+
this._stateChangedReject = () => {
|
|
124
|
+
this.removeEventListener("state-changed", resolve);
|
|
125
|
+
reject();
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
rpcResult = this._sendRPCWithResponse(
|
|
129
|
+
ImprovSerialRPCCommand.REQUEST_CURRENT_STATE,
|
|
130
|
+
[],
|
|
131
|
+
);
|
|
132
|
+
try {
|
|
133
|
+
await Promise.race([stateChanged, rpcResult.then(() => {})]);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
// rpcResult rejection is the meaningful error
|
|
136
|
+
throw typeof err === "string" ? new Error(err) : err;
|
|
137
|
+
}
|
|
138
|
+
} catch (err) {
|
|
139
|
+
this._rpcFeedback = null;
|
|
140
|
+
throw new Error(`Error fetching current state: ${err}`);
|
|
141
|
+
} finally {
|
|
142
|
+
// Always cleanup the state-changed listener
|
|
143
|
+
if (this._stateChangedReject) {
|
|
144
|
+
this._stateChangedReject();
|
|
145
|
+
this._stateChangedReject = null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (this.state !== ImprovSerialCurrentState.PROVISIONED) {
|
|
150
|
+
this._rpcFeedback = null;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const data = await rpcResult;
|
|
155
|
+
this.nextUrl = data[0];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Request device info (firmware, version, chipFamily, name)
|
|
160
|
+
*/
|
|
161
|
+
async requestInfo(timeout) {
|
|
162
|
+
const response = await this._sendRPCWithResponse(
|
|
163
|
+
ImprovSerialRPCCommand.REQUEST_INFO,
|
|
164
|
+
[],
|
|
165
|
+
timeout,
|
|
166
|
+
);
|
|
167
|
+
this.info = {
|
|
168
|
+
firmware: response[0],
|
|
169
|
+
version: response[1],
|
|
170
|
+
chipFamily: response[2],
|
|
171
|
+
name: response[3],
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Provision WiFi with SSID and password
|
|
177
|
+
*/
|
|
178
|
+
async provision(ssid, password, timeout) {
|
|
179
|
+
const encoder = new TextEncoder();
|
|
180
|
+
const ssidEncoded = encoder.encode(ssid);
|
|
181
|
+
const pwEncoded = encoder.encode(password);
|
|
182
|
+
const data = [
|
|
183
|
+
ssidEncoded.length,
|
|
184
|
+
...ssidEncoded,
|
|
185
|
+
pwEncoded.length,
|
|
186
|
+
...pwEncoded,
|
|
187
|
+
];
|
|
188
|
+
const response = await this._sendRPCWithResponse(
|
|
189
|
+
ImprovSerialRPCCommand.SEND_WIFI_SETTINGS,
|
|
190
|
+
data,
|
|
191
|
+
timeout,
|
|
192
|
+
);
|
|
193
|
+
this.nextUrl = response[0];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Scan for available WiFi networks
|
|
198
|
+
* @returns {Promise<Array<{name: string, rssi: number, secured: boolean}>>}
|
|
199
|
+
*/
|
|
200
|
+
async scan() {
|
|
201
|
+
const results = await this._sendRPCWithMultipleResponses(
|
|
202
|
+
ImprovSerialRPCCommand.REQUEST_WIFI_NETWORKS,
|
|
203
|
+
[],
|
|
204
|
+
);
|
|
205
|
+
const ssids = results.map(([name, rssi, secured]) => ({
|
|
206
|
+
name,
|
|
207
|
+
rssi: parseInt(rssi),
|
|
208
|
+
secured: secured === "YES",
|
|
209
|
+
}));
|
|
210
|
+
ssids.sort((a, b) =>
|
|
211
|
+
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
|
|
212
|
+
);
|
|
213
|
+
return ssids;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── Private methods ────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
_sendRPC(command, data) {
|
|
219
|
+
return this.writePacketToStream(ImprovSerialMessageType.RPC, [
|
|
220
|
+
command,
|
|
221
|
+
data.length,
|
|
222
|
+
...data,
|
|
223
|
+
]);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async _sendRPCWithResponse(command, data, timeout) {
|
|
227
|
+
if (this._rpcFeedback) {
|
|
228
|
+
throw new Error("Only 1 RPC command that requires feedback can be active");
|
|
229
|
+
}
|
|
230
|
+
return await this._awaitRPCResultWithTimeout(
|
|
231
|
+
new Promise((resolve, reject) => {
|
|
232
|
+
this._rpcFeedback = { command, resolve, reject };
|
|
233
|
+
this._sendRPC(command, data);
|
|
234
|
+
}),
|
|
235
|
+
timeout,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async _sendRPCWithMultipleResponses(command, data, timeout) {
|
|
240
|
+
if (this._rpcFeedback) {
|
|
241
|
+
throw new Error("Only 1 RPC command that requires feedback can be active");
|
|
242
|
+
}
|
|
243
|
+
return await this._awaitRPCResultWithTimeout(
|
|
244
|
+
new Promise((resolve, reject) => {
|
|
245
|
+
this._rpcFeedback = { command, resolve, reject, receivedData: [] };
|
|
246
|
+
this._sendRPC(command, data);
|
|
247
|
+
}),
|
|
248
|
+
timeout,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async _awaitRPCResultWithTimeout(sendRPCPromise, timeout) {
|
|
253
|
+
if (!timeout) return await sendRPCPromise;
|
|
254
|
+
return await new Promise((resolve, reject) => {
|
|
255
|
+
const timer = setTimeout(
|
|
256
|
+
() => this._setError(ImprovSerialErrorState.TIMEOUT),
|
|
257
|
+
timeout,
|
|
258
|
+
);
|
|
259
|
+
sendRPCPromise.finally(() => clearTimeout(timer));
|
|
260
|
+
sendRPCPromise.then(resolve, reject);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async _processInput() {
|
|
265
|
+
this.logger.debug("Starting Improv read loop");
|
|
266
|
+
this._reader = this.port.readable.getReader();
|
|
267
|
+
|
|
268
|
+
try {
|
|
269
|
+
let line = [];
|
|
270
|
+
let isImprov; // undefined = not sure
|
|
271
|
+
let improvLength = 0;
|
|
272
|
+
|
|
273
|
+
while (true) {
|
|
274
|
+
const { value, done } = await this._reader.read();
|
|
275
|
+
if (done) break;
|
|
276
|
+
if (!value || value.length === 0) continue;
|
|
277
|
+
|
|
278
|
+
for (const byte of value) {
|
|
279
|
+
if (isImprov === false) {
|
|
280
|
+
if (byte === 10) isImprov = undefined;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (isImprov === true) {
|
|
285
|
+
line.push(byte);
|
|
286
|
+
if (line.length === improvLength) {
|
|
287
|
+
this._handleIncomingPacket(line);
|
|
288
|
+
isImprov = undefined;
|
|
289
|
+
line = [];
|
|
290
|
+
}
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (byte === 10) {
|
|
295
|
+
line = [];
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
line.push(byte);
|
|
300
|
+
|
|
301
|
+
if (line.length !== 9) continue;
|
|
302
|
+
|
|
303
|
+
// Check if it's improv header
|
|
304
|
+
isImprov = String.fromCharCode(...line.slice(0, 6)) === "IMPROV";
|
|
305
|
+
if (!isImprov) {
|
|
306
|
+
line = [];
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
// Format: IMPROV <VERSION> <TYPE> <LENGTH> <DATA> <CHECKSUM>
|
|
310
|
+
const packetLength = line[8];
|
|
311
|
+
improvLength = 9 + packetLength + 1; // header + data + checksum
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
} catch (err) {
|
|
315
|
+
this.logger.error("Error while reading serial port", err);
|
|
316
|
+
} finally {
|
|
317
|
+
this._reader.releaseLock();
|
|
318
|
+
this._reader = null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
this.logger.debug("Finished Improv read loop");
|
|
322
|
+
this.dispatchEvent(new Event("disconnect"));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
_handleIncomingPacket(line) {
|
|
326
|
+
const payload = line.slice(6);
|
|
327
|
+
const version = payload[0];
|
|
328
|
+
const packetType = payload[1];
|
|
329
|
+
const packetLength = payload[2];
|
|
330
|
+
const data = payload.slice(3, 3 + packetLength);
|
|
331
|
+
|
|
332
|
+
this.logger.debug("IMPROV PACKET", { version, packetType, packetLength, data });
|
|
333
|
+
|
|
334
|
+
if (version !== 1) {
|
|
335
|
+
this.logger.error("Received unsupported Improv version", version);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Verify checksum
|
|
340
|
+
const packetChecksum = payload[3 + packetLength];
|
|
341
|
+
let calculatedChecksum = 0;
|
|
342
|
+
for (let i = 0; i < line.length - 1; i++) {
|
|
343
|
+
calculatedChecksum += line[i];
|
|
344
|
+
}
|
|
345
|
+
calculatedChecksum = calculatedChecksum & 0xff;
|
|
346
|
+
if (calculatedChecksum !== packetChecksum) {
|
|
347
|
+
this.logger.error(
|
|
348
|
+
`Invalid checksum ${packetChecksum}, expected ${calculatedChecksum}`,
|
|
349
|
+
);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (packetType === ImprovSerialMessageType.CURRENT_STATE) {
|
|
354
|
+
this.state = data[0];
|
|
355
|
+
this.dispatchEvent(
|
|
356
|
+
new CustomEvent("state-changed", { detail: this.state }),
|
|
357
|
+
);
|
|
358
|
+
} else if (packetType === ImprovSerialMessageType.ERROR_STATE) {
|
|
359
|
+
this._setError(data[0]);
|
|
360
|
+
} else if (packetType === ImprovSerialMessageType.RPC_RESULT) {
|
|
361
|
+
if (!this._rpcFeedback) {
|
|
362
|
+
this.logger.error("Received RPC result while not waiting for one");
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const rpcCommand = data[0];
|
|
366
|
+
if (rpcCommand !== this._rpcFeedback.command) {
|
|
367
|
+
this.logger.error(
|
|
368
|
+
`Received result for command ${rpcCommand} but expected ${this._rpcFeedback.command}`,
|
|
369
|
+
);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Parse TLV-encoded strings
|
|
374
|
+
const result = [];
|
|
375
|
+
const totalLength = data[1];
|
|
376
|
+
let idx = 2;
|
|
377
|
+
while (idx < 2 + totalLength) {
|
|
378
|
+
const strLen = data[idx];
|
|
379
|
+
if (idx + 1 + strLen > 2 + totalLength) {
|
|
380
|
+
this.logger.error("Malformed TLV: string length exceeds packet data");
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
result.push(
|
|
384
|
+
String.fromCodePoint(...data.slice(idx + 1, idx + strLen + 1)),
|
|
385
|
+
);
|
|
386
|
+
idx += strLen + 1;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if ("receivedData" in this._rpcFeedback) {
|
|
390
|
+
if (result.length > 0) {
|
|
391
|
+
this._rpcFeedback.receivedData.push(result);
|
|
392
|
+
} else {
|
|
393
|
+
// Empty result = done
|
|
394
|
+
this._rpcFeedback.resolve(this._rpcFeedback.receivedData);
|
|
395
|
+
this._rpcFeedback = null;
|
|
396
|
+
}
|
|
397
|
+
} else {
|
|
398
|
+
this._rpcFeedback.resolve(result);
|
|
399
|
+
this._rpcFeedback = null;
|
|
400
|
+
}
|
|
401
|
+
} else {
|
|
402
|
+
this.logger.error("Unable to handle Improv packet", payload);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Write a packet to the serial stream with header and checksum
|
|
408
|
+
*/
|
|
409
|
+
async writePacketToStream(type, data) {
|
|
410
|
+
const payload = new Uint8Array([
|
|
411
|
+
...SERIAL_PACKET_HEADER,
|
|
412
|
+
type,
|
|
413
|
+
data.length,
|
|
414
|
+
...data,
|
|
415
|
+
0, // checksum placeholder
|
|
416
|
+
0, // newline placeholder
|
|
417
|
+
]);
|
|
418
|
+
// Calculate checksum (sum of all bytes except last two, & 0xFF)
|
|
419
|
+
payload[payload.length - 2] =
|
|
420
|
+
payload.reduce((sum, cur) => sum + cur, 0) & 0xff;
|
|
421
|
+
payload[payload.length - 1] = 10; // Newline
|
|
422
|
+
|
|
423
|
+
this.logger.debug("Writing Improv packet:", payload);
|
|
424
|
+
const writer = this.port.writable.getWriter();
|
|
425
|
+
try {
|
|
426
|
+
await writer.write(payload);
|
|
427
|
+
} finally {
|
|
428
|
+
try {
|
|
429
|
+
writer.releaseLock();
|
|
430
|
+
} catch (err) {
|
|
431
|
+
console.error("Ignoring release lock error", err);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
_setError(error) {
|
|
437
|
+
this.error = error;
|
|
438
|
+
if (error > 0 && this._rpcFeedback) {
|
|
439
|
+
this._rpcFeedback.reject(new Error(ERROR_MSGS[error] || `UNKNOWN_ERROR (${error})`));
|
|
440
|
+
this._rpcFeedback = null;
|
|
441
|
+
}
|
|
442
|
+
this.dispatchEvent(
|
|
443
|
+
new CustomEvent("error-changed", { detail: this.error }),
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ─── Improv Dialog UI ──────────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
const improvDialogStyles = `
|
|
451
|
+
.improv-overlay {
|
|
452
|
+
position: fixed;
|
|
453
|
+
top: 0; left: 0; right: 0; bottom: 0;
|
|
454
|
+
background: rgba(0,0,0,0.6);
|
|
455
|
+
z-index: 10000;
|
|
456
|
+
display: flex;
|
|
457
|
+
align-items: center;
|
|
458
|
+
justify-content: center;
|
|
459
|
+
animation: improv-fadein 0.2s ease;
|
|
460
|
+
}
|
|
461
|
+
@keyframes improv-fadein {
|
|
462
|
+
from { opacity: 0; }
|
|
463
|
+
to { opacity: 1; }
|
|
464
|
+
}
|
|
465
|
+
.improv-dialog {
|
|
466
|
+
background: #2a2a2a;
|
|
467
|
+
color: #ddd;
|
|
468
|
+
border-radius: 8px;
|
|
469
|
+
padding: 0;
|
|
470
|
+
min-width: 340px;
|
|
471
|
+
max-width: 440px;
|
|
472
|
+
width: 90vw;
|
|
473
|
+
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
|
474
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
|
475
|
+
font-size: 14px;
|
|
476
|
+
overflow: hidden;
|
|
477
|
+
}
|
|
478
|
+
.improv-dialog-header {
|
|
479
|
+
display: flex;
|
|
480
|
+
justify-content: space-between;
|
|
481
|
+
align-items: center;
|
|
482
|
+
padding: 16px 20px;
|
|
483
|
+
background: #333;
|
|
484
|
+
border-bottom: 1px solid #444;
|
|
485
|
+
}
|
|
486
|
+
.improv-dialog-header h3 {
|
|
487
|
+
margin: 0;
|
|
488
|
+
font-size: 16px;
|
|
489
|
+
font-weight: 600;
|
|
490
|
+
}
|
|
491
|
+
.improv-dialog-close {
|
|
492
|
+
background: none;
|
|
493
|
+
border: none;
|
|
494
|
+
color: #999;
|
|
495
|
+
font-size: 20px;
|
|
496
|
+
cursor: pointer;
|
|
497
|
+
padding: 0 4px;
|
|
498
|
+
line-height: 1;
|
|
499
|
+
}
|
|
500
|
+
.improv-dialog-close:hover {
|
|
501
|
+
color: #fff;
|
|
502
|
+
}
|
|
503
|
+
.improv-dialog-body {
|
|
504
|
+
padding: 20px;
|
|
505
|
+
}
|
|
506
|
+
.improv-status {
|
|
507
|
+
text-align: center;
|
|
508
|
+
padding: 20px 0;
|
|
509
|
+
color: #aaa;
|
|
510
|
+
}
|
|
511
|
+
.improv-spinner {
|
|
512
|
+
display: inline-block;
|
|
513
|
+
width: 24px;
|
|
514
|
+
height: 24px;
|
|
515
|
+
border: 3px solid #555;
|
|
516
|
+
border-top-color: #4fc3f7;
|
|
517
|
+
border-radius: 50%;
|
|
518
|
+
animation: improv-spin 0.8s linear infinite;
|
|
519
|
+
margin-bottom: 12px;
|
|
520
|
+
}
|
|
521
|
+
@keyframes improv-spin {
|
|
522
|
+
to { transform: rotate(360deg); }
|
|
523
|
+
}
|
|
524
|
+
.improv-info-grid {
|
|
525
|
+
display: grid;
|
|
526
|
+
grid-template-columns: auto 1fr;
|
|
527
|
+
gap: 8px 16px;
|
|
528
|
+
margin-bottom: 16px;
|
|
529
|
+
}
|
|
530
|
+
.improv-info-label {
|
|
531
|
+
color: #999;
|
|
532
|
+
font-size: 13px;
|
|
533
|
+
}
|
|
534
|
+
.improv-info-value {
|
|
535
|
+
color: #eee;
|
|
536
|
+
font-size: 13px;
|
|
537
|
+
word-break: break-all;
|
|
538
|
+
}
|
|
539
|
+
.improv-state-badge {
|
|
540
|
+
display: inline-block;
|
|
541
|
+
padding: 2px 8px;
|
|
542
|
+
border-radius: 10px;
|
|
543
|
+
font-size: 12px;
|
|
544
|
+
font-weight: 500;
|
|
545
|
+
}
|
|
546
|
+
.improv-state-ready {
|
|
547
|
+
background: #2e7d32;
|
|
548
|
+
color: #c8e6c9;
|
|
549
|
+
}
|
|
550
|
+
.improv-state-provisioning {
|
|
551
|
+
background: #f57f17;
|
|
552
|
+
color: #fff9c4;
|
|
553
|
+
}
|
|
554
|
+
.improv-state-provisioned {
|
|
555
|
+
background: #1565c0;
|
|
556
|
+
color: #bbdefb;
|
|
557
|
+
}
|
|
558
|
+
.improv-actions {
|
|
559
|
+
display: flex;
|
|
560
|
+
flex-direction: column;
|
|
561
|
+
gap: 12px;
|
|
562
|
+
margin-top: 16px;
|
|
563
|
+
}
|
|
564
|
+
.improv-btn {
|
|
565
|
+
padding: 18px 24px;
|
|
566
|
+
border: 1px solid #555;
|
|
567
|
+
border-radius: 6px;
|
|
568
|
+
background: #444;
|
|
569
|
+
color: #ddd;
|
|
570
|
+
cursor: pointer;
|
|
571
|
+
font-size: 14px;
|
|
572
|
+
font-family: inherit;
|
|
573
|
+
text-align: left;
|
|
574
|
+
display: flex;
|
|
575
|
+
align-items: center;
|
|
576
|
+
gap: 12px;
|
|
577
|
+
transition: background 0.15s;
|
|
578
|
+
}
|
|
579
|
+
.improv-btn:hover:not(:disabled) {
|
|
580
|
+
background: #555;
|
|
581
|
+
}
|
|
582
|
+
.improv-btn:disabled {
|
|
583
|
+
opacity: 0.5;
|
|
584
|
+
cursor: not-allowed;
|
|
585
|
+
}
|
|
586
|
+
.improv-btn-primary {
|
|
587
|
+
background: #1976d2;
|
|
588
|
+
border-color: #1565c0;
|
|
589
|
+
color: #fff;
|
|
590
|
+
}
|
|
591
|
+
.improv-btn-primary:hover:not(:disabled) {
|
|
592
|
+
background: #1e88e5;
|
|
593
|
+
}
|
|
594
|
+
.improv-btn-icon {
|
|
595
|
+
font-size: 22px;
|
|
596
|
+
width: 28px;
|
|
597
|
+
text-align: center;
|
|
598
|
+
flex-shrink: 0;
|
|
599
|
+
}
|
|
600
|
+
.improv-btn-text {
|
|
601
|
+
flex: 1;
|
|
602
|
+
}
|
|
603
|
+
.improv-btn-text small {
|
|
604
|
+
display: block;
|
|
605
|
+
color: #aaa;
|
|
606
|
+
font-size: 12px;
|
|
607
|
+
margin-top: 3px;
|
|
608
|
+
}
|
|
609
|
+
.improv-wifi-form {
|
|
610
|
+
display: flex;
|
|
611
|
+
flex-direction: column;
|
|
612
|
+
gap: 12px;
|
|
613
|
+
}
|
|
614
|
+
.improv-wifi-form label {
|
|
615
|
+
display: flex;
|
|
616
|
+
flex-direction: column;
|
|
617
|
+
gap: 4px;
|
|
618
|
+
font-size: 13px;
|
|
619
|
+
color: #aaa;
|
|
620
|
+
}
|
|
621
|
+
.improv-wifi-form input,
|
|
622
|
+
.improv-wifi-form select {
|
|
623
|
+
padding: 8px 12px;
|
|
624
|
+
border: 1px solid #555;
|
|
625
|
+
border-radius: 4px;
|
|
626
|
+
background: #1c1c1c;
|
|
627
|
+
color: #ddd;
|
|
628
|
+
font-size: 14px;
|
|
629
|
+
font-family: inherit;
|
|
630
|
+
outline: none;
|
|
631
|
+
}
|
|
632
|
+
.improv-wifi-form input:focus,
|
|
633
|
+
.improv-wifi-form select:focus {
|
|
634
|
+
border-color: #4fc3f7;
|
|
635
|
+
}
|
|
636
|
+
.improv-wifi-form select {
|
|
637
|
+
appearance: auto;
|
|
638
|
+
}
|
|
639
|
+
.improv-wifi-buttons {
|
|
640
|
+
display: flex;
|
|
641
|
+
gap: 8px;
|
|
642
|
+
margin-top: 4px;
|
|
643
|
+
}
|
|
644
|
+
.improv-wifi-buttons .improv-btn {
|
|
645
|
+
flex: 1;
|
|
646
|
+
justify-content: center;
|
|
647
|
+
}
|
|
648
|
+
.improv-error {
|
|
649
|
+
background: #d32f2f;
|
|
650
|
+
color: #fff;
|
|
651
|
+
padding: 10px 14px;
|
|
652
|
+
border-radius: 4px;
|
|
653
|
+
margin-top: 12px;
|
|
654
|
+
font-size: 13px;
|
|
655
|
+
}
|
|
656
|
+
.improv-success {
|
|
657
|
+
background: #2e7d32;
|
|
658
|
+
color: #c8e6c9;
|
|
659
|
+
padding: 10px 14px;
|
|
660
|
+
border-radius: 4px;
|
|
661
|
+
margin-top: 12px;
|
|
662
|
+
font-size: 13px;
|
|
663
|
+
}
|
|
664
|
+
.improv-section-title {
|
|
665
|
+
font-size: 13px;
|
|
666
|
+
color: #999;
|
|
667
|
+
margin: 16px 0 8px;
|
|
668
|
+
text-transform: uppercase;
|
|
669
|
+
letter-spacing: 0.5px;
|
|
670
|
+
}
|
|
671
|
+
`;
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* ImprovDialog – manages the modal dialog UI for Improv interactions.
|
|
675
|
+
* Constructed with a serial port reference.
|
|
676
|
+
*/
|
|
677
|
+
export class ImprovDialog {
|
|
678
|
+
constructor(port) {
|
|
679
|
+
this.port = port;
|
|
680
|
+
this.client = null;
|
|
681
|
+
this.overlay = null;
|
|
682
|
+
this._view = "loading"; // loading | dashboard | wifi | error
|
|
683
|
+
this._ssids = null;
|
|
684
|
+
this._selectedSsid = null;
|
|
685
|
+
this._errorMsg = null;
|
|
686
|
+
this._successMsg = null;
|
|
687
|
+
this._busy = false;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* Open the Improv dialog, try to connect to the device.
|
|
692
|
+
* Must first disconnect the console reader so Improv can read the port.
|
|
693
|
+
* Returns a promise that resolves when the dialog is closed.
|
|
694
|
+
* @param {Function} disconnectConsole – async fn to disconnect the console reader
|
|
695
|
+
* @param {Function} reconnectConsole – async fn to reconnect the console reader
|
|
696
|
+
*/
|
|
697
|
+
async open(disconnectConsole, reconnectConsole) {
|
|
698
|
+
// Guard against double-open: resolve any existing promise first
|
|
699
|
+
if (this._closeResolve) {
|
|
700
|
+
const existingResolve = this._closeResolve;
|
|
701
|
+
this._closeResolve = null;
|
|
702
|
+
existingResolve();
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
this._disconnectConsole = disconnectConsole;
|
|
706
|
+
this._reconnectConsole = reconnectConsole;
|
|
707
|
+
|
|
708
|
+
// Disconnect console reader so we can use the port
|
|
709
|
+
if (disconnectConsole) {
|
|
710
|
+
await disconnectConsole();
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// Create overlay
|
|
714
|
+
this._injectStyles();
|
|
715
|
+
this.overlay = document.createElement("div");
|
|
716
|
+
this.overlay.className = "improv-overlay";
|
|
717
|
+
this.overlay.addEventListener("click", (e) => {
|
|
718
|
+
if (e.target === this.overlay) this.close();
|
|
719
|
+
});
|
|
720
|
+
this._escHandler = (e) => {
|
|
721
|
+
if (e.key === "Escape") this.close();
|
|
722
|
+
};
|
|
723
|
+
document.addEventListener("keydown", this._escHandler);
|
|
724
|
+
document.body.appendChild(this.overlay);
|
|
725
|
+
|
|
726
|
+
// Show loading view
|
|
727
|
+
this._view = "loading";
|
|
728
|
+
this._render();
|
|
729
|
+
|
|
730
|
+
// Attempt to initialize Improv
|
|
731
|
+
try {
|
|
732
|
+
const logger = {
|
|
733
|
+
log: (...args) => console.log("[Improv]", ...args),
|
|
734
|
+
error: (...args) => console.error("[Improv]", ...args),
|
|
735
|
+
debug: (...args) => console.debug("[Improv]", ...args),
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
this.client = new ImprovSerial(this.port, logger);
|
|
739
|
+
const info = await this.client.initialize(3000);
|
|
740
|
+
|
|
741
|
+
// If provisioned, poll for valid URL (not 0.0.0.0)
|
|
742
|
+
if (this.client.state === ImprovSerialCurrentState.PROVISIONED) {
|
|
743
|
+
const startTime = Date.now();
|
|
744
|
+
while (Date.now() - startTime < 10000) {
|
|
745
|
+
try {
|
|
746
|
+
await this.client.requestCurrentState();
|
|
747
|
+
} catch (e) {
|
|
748
|
+
// Ignore transient polling errors
|
|
749
|
+
}
|
|
750
|
+
if (this.client.nextUrl && !this.client.nextUrl.includes("0.0.0.0")) {
|
|
751
|
+
break;
|
|
752
|
+
}
|
|
753
|
+
await sleep(500);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
this._view = "dashboard";
|
|
758
|
+
this._render();
|
|
759
|
+
} catch (err) {
|
|
760
|
+
console.error("[Improv] Init failed:", err);
|
|
761
|
+
this._view = "error";
|
|
762
|
+
this._errorMsg = "Improv not detected. Make sure the device firmware supports Improv Wi-Fi.";
|
|
763
|
+
this._render();
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Return a promise that resolves when dialog closes
|
|
767
|
+
return new Promise((resolve) => {
|
|
768
|
+
this._closeResolve = resolve;
|
|
769
|
+
});
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
async close() {
|
|
773
|
+
// Close Improv client
|
|
774
|
+
if (this.client) {
|
|
775
|
+
try {
|
|
776
|
+
await this.client.close();
|
|
777
|
+
} catch (e) {
|
|
778
|
+
console.error("[Improv] Close error:", e);
|
|
779
|
+
}
|
|
780
|
+
this.client = null;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (this._escHandler) {
|
|
784
|
+
document.removeEventListener("keydown", this._escHandler);
|
|
785
|
+
this._escHandler = null;
|
|
786
|
+
}
|
|
787
|
+
// Remove overlay
|
|
788
|
+
if (this.overlay) {
|
|
789
|
+
this.overlay.remove();
|
|
790
|
+
this.overlay = null;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Reconnect console reader (guard against concurrent calls)
|
|
794
|
+
const reconnect = this._reconnectConsole;
|
|
795
|
+
this._reconnectConsole = null;
|
|
796
|
+
if (reconnect) {
|
|
797
|
+
await sleep(200);
|
|
798
|
+
await reconnect();
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (this._closeResolve) {
|
|
802
|
+
this._closeResolve();
|
|
803
|
+
this._closeResolve = null;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
_injectStyles() {
|
|
808
|
+
if (document.getElementById("improv-dialog-styles")) return;
|
|
809
|
+
const style = document.createElement("style");
|
|
810
|
+
style.id = "improv-dialog-styles";
|
|
811
|
+
style.textContent = improvDialogStyles;
|
|
812
|
+
document.head.appendChild(style);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
_render() {
|
|
816
|
+
if (!this.overlay) return;
|
|
817
|
+
let body = "";
|
|
818
|
+
|
|
819
|
+
switch (this._view) {
|
|
820
|
+
case "loading":
|
|
821
|
+
body = this._renderLoading();
|
|
822
|
+
break;
|
|
823
|
+
case "dashboard":
|
|
824
|
+
body = this._renderDashboard();
|
|
825
|
+
break;
|
|
826
|
+
case "wifi":
|
|
827
|
+
body = this._renderWifi();
|
|
828
|
+
break;
|
|
829
|
+
case "error":
|
|
830
|
+
body = this._renderError();
|
|
831
|
+
break;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
this.overlay.innerHTML = `
|
|
835
|
+
<div class="improv-dialog">
|
|
836
|
+
<div class="improv-dialog-header">
|
|
837
|
+
<h3>${this._getTitle()}</h3>
|
|
838
|
+
<button class="improv-dialog-close" title="Close">×</button>
|
|
839
|
+
</div>
|
|
840
|
+
<div class="improv-dialog-body">
|
|
841
|
+
${body}
|
|
842
|
+
</div>
|
|
843
|
+
</div>
|
|
844
|
+
`;
|
|
845
|
+
|
|
846
|
+
// Bind close button
|
|
847
|
+
this.overlay.querySelector(".improv-dialog-close")
|
|
848
|
+
.addEventListener("click", () => this.close());
|
|
849
|
+
|
|
850
|
+
// Bind view-specific events
|
|
851
|
+
this._bindEvents();
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
_getTitle() {
|
|
855
|
+
switch (this._view) {
|
|
856
|
+
case "loading": return "Improv Wi-Fi";
|
|
857
|
+
case "dashboard": return this._esc(this.client?.info?.name || "Device");
|
|
858
|
+
case "wifi": return "Wi-Fi Configuration";
|
|
859
|
+
case "error": return "Improv Wi-Fi";
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
_renderLoading() {
|
|
864
|
+
return `
|
|
865
|
+
<div class="improv-status">
|
|
866
|
+
<div class="improv-spinner"></div>
|
|
867
|
+
<div>Connecting to device...</div>
|
|
868
|
+
</div>
|
|
869
|
+
`;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
_renderDashboard() {
|
|
873
|
+
const info = this.client?.info;
|
|
874
|
+
const state = this.client?.state;
|
|
875
|
+
const nextUrl = this.client?.nextUrl;
|
|
876
|
+
|
|
877
|
+
let stateLabel = "Unknown";
|
|
878
|
+
let stateClass = "";
|
|
879
|
+
if (state === ImprovSerialCurrentState.READY) {
|
|
880
|
+
stateLabel = "Ready";
|
|
881
|
+
stateClass = "improv-state-ready";
|
|
882
|
+
} else if (state === ImprovSerialCurrentState.PROVISIONING) {
|
|
883
|
+
stateLabel = "Provisioning...";
|
|
884
|
+
stateClass = "improv-state-provisioning";
|
|
885
|
+
} else if (state === ImprovSerialCurrentState.PROVISIONED) {
|
|
886
|
+
stateLabel = "Connected";
|
|
887
|
+
stateClass = "improv-state-provisioned";
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const wifiLabel = state === ImprovSerialCurrentState.PROVISIONED
|
|
891
|
+
? "Change Wi-Fi" : "Connect to Wi-Fi";
|
|
892
|
+
const wifiDesc = state === ImprovSerialCurrentState.PROVISIONED
|
|
893
|
+
? "Change the Wi-Fi network" : "Configure Wi-Fi credentials";
|
|
894
|
+
|
|
895
|
+
let html = `
|
|
896
|
+
<div class="improv-section-title">Device Info</div>
|
|
897
|
+
<div class="improv-info-grid">
|
|
898
|
+
<span class="improv-info-label">Name</span>
|
|
899
|
+
<span class="improv-info-value">${this._esc(info?.name || "–")}</span>
|
|
900
|
+
<span class="improv-info-label">Firmware</span>
|
|
901
|
+
<span class="improv-info-value">${this._esc(info?.firmware || "–")}</span>
|
|
902
|
+
<span class="improv-info-label">Version</span>
|
|
903
|
+
<span class="improv-info-value">${this._esc(info?.version || "–")}</span>
|
|
904
|
+
<span class="improv-info-label">Chip</span>
|
|
905
|
+
<span class="improv-info-value">${this._esc(info?.chipFamily || "–")}</span>
|
|
906
|
+
<span class="improv-info-label">Status</span>
|
|
907
|
+
<span class="improv-info-value"><span class="improv-state-badge ${stateClass}">${stateLabel}</span></span>
|
|
908
|
+
</div>
|
|
909
|
+
|
|
910
|
+
<div class="improv-section-title">Actions</div>
|
|
911
|
+
<div class="improv-actions">
|
|
912
|
+
<button class="improv-btn improv-btn-primary" id="improv-wifi-btn">
|
|
913
|
+
<span class="improv-btn-icon">📶</span>
|
|
914
|
+
<span class="improv-btn-text">
|
|
915
|
+
${wifiLabel}
|
|
916
|
+
<small>${wifiDesc}</small>
|
|
917
|
+
</span>
|
|
918
|
+
</button>
|
|
919
|
+
`;
|
|
920
|
+
|
|
921
|
+
if (nextUrl) {
|
|
922
|
+
html += `
|
|
923
|
+
<button class="improv-btn" id="improv-visit-btn">
|
|
924
|
+
<span class="improv-btn-icon">🌐</span>
|
|
925
|
+
<span class="improv-btn-text">
|
|
926
|
+
Visit Device
|
|
927
|
+
<small>${this._esc(nextUrl)}</small>
|
|
928
|
+
</span>
|
|
929
|
+
</button>
|
|
930
|
+
`;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
html += `</div>`;
|
|
934
|
+
|
|
935
|
+
if (this._successMsg) {
|
|
936
|
+
html += `<div class="improv-success">${this._esc(this._successMsg)}</div>`;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
return html;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
_renderWifi() {
|
|
943
|
+
if (this._busy) {
|
|
944
|
+
const busyMsg = this._ssids === undefined
|
|
945
|
+
? "Scanning for networks..."
|
|
946
|
+
: "Connecting...";
|
|
947
|
+
return `
|
|
948
|
+
<div class="improv-status">
|
|
949
|
+
<div class="improv-spinner"></div>
|
|
950
|
+
<div>${busyMsg}</div>
|
|
951
|
+
</div>
|
|
952
|
+
`;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
let ssidInput = "";
|
|
956
|
+
if (this._ssids && this._ssids.length > 0) {
|
|
957
|
+
const options = this._ssids.map((s) => {
|
|
958
|
+
const signal = s.rssi > -50 ? "▂▄▆█" : s.rssi > -70 ? "▂▄▆" : s.rssi > -80 ? "▂▄" : "▂";
|
|
959
|
+
const lock = s.secured ? "🔒" : "";
|
|
960
|
+
const sel = s.name === this._selectedSsid ? " selected" : "";
|
|
961
|
+
return `<option value="${this._esc(s.name)}"${sel}>${this._esc(s.name)} ${signal} ${lock}</option>`;
|
|
962
|
+
});
|
|
963
|
+
options.push(`<option value="">Join other network...</option>`);
|
|
964
|
+
ssidInput = `
|
|
965
|
+
<label>
|
|
966
|
+
Network
|
|
967
|
+
<select id="improv-ssid-select">${options.join("")}</select>
|
|
968
|
+
</label>
|
|
969
|
+
<div id="improv-custom-ssid" style="display:none">
|
|
970
|
+
<label>
|
|
971
|
+
SSID
|
|
972
|
+
<input type="text" id="improv-ssid-input" placeholder="Network name">
|
|
973
|
+
</label>
|
|
974
|
+
</div>
|
|
975
|
+
`;
|
|
976
|
+
} else {
|
|
977
|
+
// No scan results or scan not supported
|
|
978
|
+
ssidInput = `
|
|
979
|
+
<label>
|
|
980
|
+
SSID
|
|
981
|
+
<input type="text" id="improv-ssid-input" value="${this._esc(this._selectedSsid || "")}" placeholder="Network name">
|
|
982
|
+
</label>
|
|
983
|
+
`;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
let html = `
|
|
987
|
+
<div class="improv-wifi-form">
|
|
988
|
+
${ssidInput}
|
|
989
|
+
<label>
|
|
990
|
+
Password
|
|
991
|
+
<input type="password" id="improv-password-input" placeholder="Wi-Fi password">
|
|
992
|
+
</label>
|
|
993
|
+
<div class="improv-wifi-buttons">
|
|
994
|
+
<button class="improv-btn" id="improv-wifi-back">Back</button>
|
|
995
|
+
<button class="improv-btn improv-btn-primary" id="improv-wifi-connect">Connect</button>
|
|
996
|
+
</div>
|
|
997
|
+
</div>
|
|
998
|
+
`;
|
|
999
|
+
|
|
1000
|
+
if (this._errorMsg) {
|
|
1001
|
+
html += `<div class="improv-error">${this._esc(this._errorMsg)}</div>`;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
return html;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
_renderError() {
|
|
1008
|
+
return `
|
|
1009
|
+
<div class="improv-status">
|
|
1010
|
+
<div style="font-size: 32px; margin-bottom: 12px;">⚠️</div>
|
|
1011
|
+
<div>${this._esc(this._errorMsg || "An error occurred")}</div>
|
|
1012
|
+
</div>
|
|
1013
|
+
<div class="improv-actions">
|
|
1014
|
+
<button class="improv-btn" id="improv-error-close">Close</button>
|
|
1015
|
+
</div>
|
|
1016
|
+
`;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
_bindEvents() {
|
|
1020
|
+
const bind = (id, event, handler) => {
|
|
1021
|
+
const el = this.overlay?.querySelector(`#${id}`);
|
|
1022
|
+
if (el) el.addEventListener(event, handler);
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
bind("improv-wifi-btn", "click", () => this._showWifi());
|
|
1026
|
+
bind("improv-visit-btn", "click", () => this._visitDevice());
|
|
1027
|
+
bind("improv-wifi-back", "click", () => this._backToDashboard());
|
|
1028
|
+
bind("improv-wifi-connect", "click", () => this._doProvision());
|
|
1029
|
+
bind("improv-error-close", "click", () => this.close());
|
|
1030
|
+
|
|
1031
|
+
// SSID select change handler
|
|
1032
|
+
const ssidSelect = this.overlay?.querySelector("#improv-ssid-select");
|
|
1033
|
+
if (ssidSelect) {
|
|
1034
|
+
ssidSelect.addEventListener("change", () => {
|
|
1035
|
+
const customDiv = this.overlay?.querySelector("#improv-custom-ssid");
|
|
1036
|
+
if (ssidSelect.value === "") {
|
|
1037
|
+
if (customDiv) customDiv.style.display = "block";
|
|
1038
|
+
this._selectedSsid = null;
|
|
1039
|
+
} else {
|
|
1040
|
+
if (customDiv) customDiv.style.display = "none";
|
|
1041
|
+
this._selectedSsid = ssidSelect.value;
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Enter key in password field
|
|
1047
|
+
const pwInput = this.overlay?.querySelector("#improv-password-input");
|
|
1048
|
+
if (pwInput) {
|
|
1049
|
+
pwInput.addEventListener("keydown", (e) => {
|
|
1050
|
+
if (e.key === "Enter") {
|
|
1051
|
+
e.preventDefault();
|
|
1052
|
+
this._doProvision();
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
async _showWifi() {
|
|
1059
|
+
this._view = "wifi";
|
|
1060
|
+
this._errorMsg = null;
|
|
1061
|
+
this._successMsg = null;
|
|
1062
|
+
this._ssids = undefined; // undefined = not loaded
|
|
1063
|
+
this._selectedSsid = null;
|
|
1064
|
+
this._busy = true;
|
|
1065
|
+
this._render();
|
|
1066
|
+
|
|
1067
|
+
// Scan for networks
|
|
1068
|
+
try {
|
|
1069
|
+
const ssids = await this.client.scan();
|
|
1070
|
+
this._ssids = ssids;
|
|
1071
|
+
this._selectedSsid = ssids.length > 0 ? ssids[0].name : null;
|
|
1072
|
+
} catch (err) {
|
|
1073
|
+
console.warn("[Improv] WiFi scan failed:", err);
|
|
1074
|
+
this._ssids = null;
|
|
1075
|
+
this._selectedSsid = null;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
this._busy = false;
|
|
1079
|
+
this._render();
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
_visitDevice() {
|
|
1083
|
+
const url = this.client?.nextUrl;
|
|
1084
|
+
if (url) {
|
|
1085
|
+
try {
|
|
1086
|
+
const parsed = new URL(url);
|
|
1087
|
+
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
|
1088
|
+
window.open(url, "_blank", "noopener");
|
|
1089
|
+
} else {
|
|
1090
|
+
console.warn("[Improv] Blocked non-HTTP URL:", url);
|
|
1091
|
+
}
|
|
1092
|
+
} catch {
|
|
1093
|
+
console.warn("[Improv] Invalid URL:", url);
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
_backToDashboard() {
|
|
1099
|
+
this._view = "dashboard";
|
|
1100
|
+
this._errorMsg = null;
|
|
1101
|
+
this._render();
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
async _doProvision() {
|
|
1105
|
+
const ssidSelect = this.overlay?.querySelector("#improv-ssid-select");
|
|
1106
|
+
const ssidInput = this.overlay?.querySelector("#improv-ssid-input");
|
|
1107
|
+
|
|
1108
|
+
let ssid;
|
|
1109
|
+
if (ssidSelect && ssidSelect.value !== "") {
|
|
1110
|
+
ssid = ssidSelect.value;
|
|
1111
|
+
} else if (ssidInput) {
|
|
1112
|
+
ssid = ssidInput.value.trim();
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const password = this.overlay?.querySelector("#improv-password-input")?.value || "";
|
|
1116
|
+
|
|
1117
|
+
if (!ssid) {
|
|
1118
|
+
this._errorMsg = "Please enter or select a network name.";
|
|
1119
|
+
this._render();
|
|
1120
|
+
return;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
this._errorMsg = null;
|
|
1124
|
+
this._busy = true;
|
|
1125
|
+
this._render();
|
|
1126
|
+
|
|
1127
|
+
try {
|
|
1128
|
+
await this.client.provision(ssid, password, 30000);
|
|
1129
|
+
|
|
1130
|
+
// Poll for valid URL after provisioning
|
|
1131
|
+
const startTime = Date.now();
|
|
1132
|
+
while (Date.now() - startTime < 10000) {
|
|
1133
|
+
try {
|
|
1134
|
+
await this.client.requestCurrentState();
|
|
1135
|
+
} catch (e) {
|
|
1136
|
+
// Ignore polling errors
|
|
1137
|
+
}
|
|
1138
|
+
if (this.client.nextUrl && !this.client.nextUrl.includes("0.0.0.0")) {
|
|
1139
|
+
break;
|
|
1140
|
+
}
|
|
1141
|
+
await sleep(500);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
this._busy = false;
|
|
1145
|
+
this._successMsg = `Successfully connected to "${ssid}"!`;
|
|
1146
|
+
this._view = "dashboard";
|
|
1147
|
+
this._render();
|
|
1148
|
+
} catch (err) {
|
|
1149
|
+
this._busy = false;
|
|
1150
|
+
this._errorMsg = `Failed to connect: ${err}`;
|
|
1151
|
+
this._render();
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
_esc(str) {
|
|
1156
|
+
if (!str) return "";
|
|
1157
|
+
const div = document.createElement("div");
|
|
1158
|
+
div.textContent = str;
|
|
1159
|
+
return div.innerHTML;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
export { ImprovSerial, ImprovSerialCurrentState, ImprovSerialErrorState };
|
package/package.json
CHANGED
package/screenshots/desktop.png
CHANGED
|
Binary file
|
package/screenshots/mobile.png
CHANGED
|
Binary file
|