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.
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
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">&times;</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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "esp32tool",
3
- "version": "1.3.8",
3
+ "version": "1.4.0",
4
4
  "type": "module",
5
5
  "description": "Flash & Read ESP devices using WebSerial, Electron, and also Android mobile via WebUSB",
6
6
  "main": "electron/main.cjs",
Binary file
Binary file