-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.html
More file actions
2080 lines (2003 loc) · 98.4 KB
/
Copy pathindex.html
File metadata and controls
2080 lines (2003 loc) · 98.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>projectMM Installer</title>
<link rel="icon" type="image/png" href="./favicon.png">
<!-- ESP Web Tools dropped in Step 3 of the board-injection plan. EWT 10.x
held the SerialPort exclusively and didn't expose its internal
ImprovSerial client, which made post-PROVISIONED board injection
impossible from this page (and silently broke "Your devices"
auto-add since the state-changed event never escaped the dialog's
shadow DOM). The install flow is now driven by
./install-orchestrator.js — esptool-js for flash + improv-wifi-serial-sdk
for WiFi provisioning, both sharing the SerialPort we own end-to-end.
The orchestrator is imported by the inline module script at the
bottom of <body>; no top-level <script> needed. -->
<style>
:root {
--bg: #1a1a2e;
--card: #16213e;
--fg: #e0e0e0;
--muted: #a0a0b0;
--accent: #a78bfa;
--border: #2a3a6a;
--ok: #57c97a; /* green — "active" capability (supported + a module configured in deviceModels.json) */
--sup: #e3c84a; /* yellow — "supported" capability (firmware supports it, not pre-configured) */
--plan: #e8923a; /* orange — "planned" capability (no module yet; greener than red, by design) */
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 15px;
line-height: 1.55;
display: flex;
flex-direction: column;
align-items: center;
padding: 24px 16px 64px;
}
main { width: 100%; max-width: 640px; }
.help-link {
display: inline-block;
margin-left: 8px;
width: 22px; height: 22px; line-height: 22px;
text-align: center;
font-size: 14px; font-weight: 600;
vertical-align: middle;
color: var(--accent);
border: 1px solid var(--border);
border-radius: 50%;
text-decoration: none;
}
.help-link:hover { border-color: var(--accent); }
.version-chip {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
background: var(--card);
color: var(--muted);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 13px;
font-weight: normal;
vertical-align: middle;
}
h1 {
margin: 0 0 8px;
font-size: 28px;
color: var(--accent);
}
p.tag { margin: 0 0 24px; color: var(--muted); }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
margin-bottom: 16px;
}
label { display: block; font-weight: 600; margin-bottom: 6px; }
select {
width: 100%;
padding: 10px 12px;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 6px;
font: inherit;
}
.button-row { margin-top: 16px; }
.note { color: var(--muted); font-size: 13px; margin-top: 10px; }
/* `.windows-only` elements are `hidden` by default in the HTML; the tiny
userAgent check at the top of <body> below removes `hidden` only on
Windows. Inverse to a CSS-only approach because CSS can't detect the
host OS — `[hidden]` already wins specificity-wise. */
.erase-row { margin-top: 12px; font-size: 13px; }
.erase-row label { cursor: pointer; }
.erase-row input { vertical-align: middle; margin-right: 6px; }
.erase-note { display: inline; margin-top: 0; }
a { color: var(--accent); }
code {
background: rgba(255,255,255,0.06);
padding: 1px 6px;
border-radius: 3px;
font-size: 13px;
}
.browser-warning {
background: #3a2a1a;
border: 1px solid #6a4a2a;
color: #e6c890;
display: none;
}
ol { padding-left: 22px; }
ol li { margin-bottom: 6px; }
.credits {
max-width: 720px;
margin: 32px auto 24px;
padding: 0 16px;
text-align: center;
border-top: 1px solid var(--border);
padding-top: 16px;
}
.credits .note { margin-top: 0; }
/* Minimal mirror of the device UI's control-row shape so the shared
install-picker module (src/ui/install-picker.js) renders the same
way on the installer page. The picker emits `.control-row` + child
`<select>` markup; without these rules the rows wouldn't lay out. */
.control-row {
display: flex;
align-items: center;
gap: 12px;
margin: 10px 0;
}
.control-label {
flex: 0 0 80px;
font-weight: 600;
color: var(--muted);
}
.control-row select { flex: 1; }
/* the shared picker still renders its own board <select>
(#rp-board) — we keep it (so its change-listener wires) but hide its row;
the picture grid above drives it. The row is the .control-row that
contains #rp-board. */
.control-row:has(#rp-board) { display: none; }
/* Picture board grid — collapsed by default (a control-row field), expands
on click. The summary button is the row's field, so it flexes like the
selects (flex: 1) to line up with USB Port / Release / Firmware. */
#board-summary {
flex: 1; display: flex; align-items: center; justify-content: space-between;
gap: 12px; padding: 10px 12px; background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: 6px; font: inherit;
cursor: pointer; text-align: left;
}
#board-summary:hover { border-color: var(--accent); }
.board-summary-left { display: flex; align-items: center; gap: 10px; min-width: 0; }
.board-summary-thumb {
width: 36px; height: 24px; border-radius: 3px; flex-shrink: 0;
background: #0e1020 center/contain no-repeat; border: 1px solid var(--border);
}
#board-summary-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.board-summary-caret { color: var(--muted); transition: transform .15s; flex-shrink: 0; }
#board-summary[aria-expanded="true"] .board-summary-caret { transform: rotate(180deg); }
/* The expanded grid breaks out full-width below the row (aligns with the
field column by offsetting the label width + gap). */
#board-expand { margin: 0 0 10px 92px; }
.board-grid-controls { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; }
#board-search {
flex: 1; min-width: 160px; padding: 8px 10px; background: var(--bg);
color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font: inherit;
}
.board-clear {
background: transparent; color: var(--muted); border: 1px solid var(--border);
border-radius: 6px; padding: 8px 12px; font: inherit; font-size: 13px; cursor: pointer;
}
.board-clear:hover { color: var(--fg); border-color: var(--accent); }
.board-filter-notice { color: var(--muted); font-size: 12px; margin-bottom: 10px; }
.board-filter-notice button {
background: none; border: none; color: var(--accent); font: inherit; font-size: 12px;
cursor: pointer; padding: 0; text-decoration: underline;
}
#board-grid { max-height: 420px; overflow-y: auto; } /* expanded grid scrolls, not the page */
#board-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px;
}
.bg-chip-label {
grid-column: 1 / -1; color: var(--muted); font-size: 11px; text-transform: uppercase;
letter-spacing: .06em; margin: 6px 0 0;
}
.bg-card {
background: var(--bg); border: 1px solid var(--border); border-radius: 8px;
overflow: hidden; cursor: pointer; transition: border-color .12s, background .12s;
display: flex; flex-direction: column;
}
.bg-card:hover { border-color: var(--accent); }
.bg-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }
.bg-thumb {
aspect-ratio: 16 / 10; background: #0e1020 center/contain no-repeat;
display: flex; align-items: center; justify-content: center;
color: var(--muted); font-size: 10px; border-bottom: 1px solid var(--border);
}
.bg-thumb.noimg::after { content: "no photo"; }
.bg-body { padding: 8px 9px; display: flex; flex-direction: column; gap: 3px; }
.bg-name { font-weight: 600; font-size: 12px; line-height: 1.2; }
.bg-meta { color: var(--muted); font-size: 11px; }
/* Capability chips: supported (green) vs planned (orange) — distinguished by
colour, not by extra text. Labels are kept short in deviceModels.json so every
chip fits the ~150px card; the full label + state is in the chip's title
tooltip. */
.bg-caps { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 3px; }
.bg-cap {
font-size: 9px; line-height: 1.5; padding: 0 5px; border-radius: 999px;
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.bg-cap.act { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); }
.bg-cap.sup { background: color-mix(in srgb, var(--sup) 20%, transparent); color: var(--sup); }
.bg-cap.plan { background: color-mix(in srgb, var(--plan) 20%, transparent); color: var(--plan); }
.bg-link { color: var(--accent); font-size: 11px; text-decoration: none; }
.bg-link:hover { text-decoration: underline; }
/* Board-details popup — native <dialog> (standard modal pattern: built-in
backdrop, ESC-to-close, focus trap; no bespoke modal JS). Shows the full
deviceModels.json entry as a readable summary plus a collapsible raw-JSON block. */
#board-details::backdrop { background: rgba(0,0,0,0.6); }
#board-details {
background: var(--card); color: var(--fg);
border: 1px solid var(--border); border-radius: 10px;
padding: 0; max-width: 560px; width: calc(100% - 32px);
max-height: 80vh; overflow: auto;
}
.bd-head {
display: flex; align-items: baseline; justify-content: space-between;
gap: 12px; padding: 16px 18px 8px;
}
.bd-title { font-size: 16px; font-weight: 600; }
.bd-close {
background: none; border: none; color: var(--muted);
font-size: 20px; line-height: 1; cursor: pointer; padding: 0 4px;
}
.bd-close:hover { color: var(--fg); }
.bd-body { padding: 0 18px 18px; }
.bd-row { display: flex; gap: 8px; padding: 3px 0; font-size: 13px; }
.bd-key { color: var(--muted); min-width: 92px; }
.bd-val { flex: 1; min-width: 0; word-break: break-word; }
.bd-section { margin-top: 14px; font-weight: 600; font-size: 13px; }
.bd-mod { margin-top: 8px; padding-left: 10px; border-left: 2px solid var(--border); }
.bd-mod-name { font-size: 13px; }
.bd-mod-name .bd-mod-id { color: var(--muted); font-weight: normal; }
.bd-ctrl { font-size: 12px; color: var(--muted); padding-left: 8px; }
.bd-ctrl code { font-size: 11px; }
.bd-raw { margin-top: 16px; }
.bd-raw summary { cursor: pointer; color: var(--accent); font-size: 12px; }
.bd-raw pre {
margin: 8px 0 0; padding: 10px; background: var(--bg);
border: 1px solid var(--border); border-radius: 6px;
font-size: 11px; overflow: auto; white-space: pre;
}
.bg-link.bg-details { cursor: pointer; }
.action-btn {
background: var(--accent);
color: var(--bg);
border: none;
border-radius: 6px;
padding: 10px 20px;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.rp-status { color: var(--muted); font-size: 13px; }
.rp-status-row { min-height: 1.5em; }
/* Inline spinner shown in a field while its data is still being fetched
(install-picker renderSkeleton). A 1em spinning ring, sized to sit next
to the select's "Loading…" placeholder. */
.rp-spinner {
display: inline-block;
width: 1em; height: 1em;
vertical-align: -0.15em;
margin-right: 0.4em;
border: 2px solid var(--muted);
border-top-color: transparent;
border-radius: 50%;
animation: rp-spin 0.7s linear infinite;
}
@keyframes rp-spin { to { transform: rotate(360deg); } }
/* "Your devices" card — one row per provisioned device. The row
is the picker's `.control-row` flex shape with the device info
on the left and action buttons on the right. */
.device-row {
justify-content: space-between;
padding: 8px 0;
border-top: 1px solid rgba(255,255,255,0.06);
}
.device-row:first-child { border-top: 0; }
.device-info { min-width: 0; flex: 1; }
.device-url {
display: block;
font-family: ui-monospace, monospace;
color: var(--muted);
font-size: 12px;
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-url:hover { color: var(--accent); text-decoration: underline; }
.device-seen { color: var(--muted); font-size: 12px; margin-top: 2px; }
.device-actions { display: flex; gap: 6px; flex-shrink: 0; }
.device-btn {
background: transparent;
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 4px;
padding: 4px 10px;
font: inherit;
font-size: 12px;
cursor: pointer;
}
.device-model-name { color: var(--fg); font-size: 12px; margin-top: 2px; }
.device-btn:hover { background: rgba(123, 158, 255, 0.08); }
/* Install modal — backdrop + centered card. Replaces the ESP Web Tools
<esp-web-install-button> shadow-DOM dialog. Sections show one at a
time via .install-section.active. */
.install-backdrop {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.65);
display: none;
align-items: center;
justify-content: center;
z-index: 1000;
}
.install-backdrop.open { display: flex; }
.install-modal {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 24px;
max-width: 480px;
width: calc(100% - 32px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
}
.install-modal h2 {
margin: 0 0 16px;
font-size: 20px;
color: var(--accent);
}
.install-section { display: none; }
.install-section.active { display: block; }
.install-status { margin: 8px 0; color: var(--muted); }
.install-done-note { margin: 4px 0 10px; font-size: 13px; color: var(--muted); }
/* Notice variant — for a flashed-OK-but-action-needed outcome (e.g. eth-only firmware
waiting on a cable). Amber, like the "supported" capability chip (var(--sup)): reads as
"do this next", not a plain note and not a red error. */
.install-done-note.install-done-note--notice {
color: var(--sup);
background: color-mix(in srgb, var(--sup) 12%, transparent);
border-left: 3px solid var(--sup);
padding: 8px 10px; border-radius: 4px;
}
.install-warn { color: #d4a052; font-size: 12px; margin-top: 8px; }
.install-progress {
height: 8px;
background: var(--bg);
border-radius: 4px;
overflow: hidden;
margin: 12px 0;
}
.install-progress-bar {
height: 100%;
background: var(--accent);
width: 0;
transition: width 0.2s;
}
/* Indeterminate state — esptool-js's eraseFlash() doesn't report
progress (12 s of "wait and hope"), so we animate a marquee-style
bar to confirm the page hasn't hung. Toggled by adding the
.indeterminate class to .install-progress-bar; width set to 100%
so the animation has something to clip. */
.install-progress-bar.indeterminate {
width: 100%;
background: linear-gradient(
90deg,
var(--bg) 0%,
var(--accent) 40%,
var(--accent) 60%,
var(--bg) 100%);
background-size: 200% 100%;
animation: install-marquee 1.4s linear infinite;
transition: none;
}
@keyframes install-marquee {
from { background-position: 100% 0; }
to { background-position: -100% 0; }
}
.install-form label {
display: block;
margin: 12px 0 4px;
font-size: 13px;
color: var(--muted);
}
.install-form input[type="text"],
.install-form input[type="password"] {
width: 100%;
padding: 8px 12px;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 6px;
font: inherit;
}
.install-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 16px;
}
.install-actions button {
padding: 8px 16px;
font: inherit;
font-weight: 600;
border: 0;
border-radius: 6px;
cursor: pointer;
}
.install-actions button.primary {
background: var(--accent);
color: #1a1a2e;
}
.install-actions button.secondary {
background: transparent;
color: var(--fg);
border: 1px solid var(--border);
}
.install-error {
color: #f8a5a5;
font-size: 13px;
margin: 12px 0;
white-space: pre-wrap;
word-break: break-word;
}
.install-success-url {
display: block; /* IP and <name>.local each on their own line */
width: fit-content;
margin-top: 8px;
color: var(--accent);
text-decoration: none;
font-family: ui-monospace, monospace;
}
.install-success-url:hover { text-decoration: underline; }
.install-log-wrap {
margin-top: 16px;
border-top: 1px solid var(--border);
padding-top: 12px;
}
.install-log-toggle {
background: transparent;
color: var(--muted);
border: 0;
padding: 0;
cursor: pointer;
font: inherit;
font-size: 12px;
text-decoration: underline;
}
.install-log-toggle:hover { color: var(--fg); }
.install-log {
margin-top: 8px;
max-height: 240px;
overflow: auto;
background: var(--bg);
color: var(--muted);
font-family: ui-monospace, monospace;
font-size: 11px;
padding: 8px;
border: 1px solid var(--border);
border-radius: 4px;
white-space: pre-wrap;
word-break: break-all;
}
</style>
</head>
<body>
<!-- Reveal the Windows-CH340 BOOT-button hints (the `.windows-only`
paragraphs in the install modal) only on Windows hosts. Defers to
DOMContentLoaded so the elements (declared further down) exist by
the time the toggle runs. navigator.userAgent's "Windows" substring
is the same signal MDN documents for OS detection on the open web —
no third-party UA parser pulled in for one boolean. The install
modal is hidden by default, so the toggle happens off-screen and
there's no flash-of-visible-content concern. -->
<script>
document.addEventListener('DOMContentLoaded', () => {
if (/Windows/i.test(navigator.userAgent)) {
document.querySelectorAll('.windows-only').forEach(el => el.hidden = false);
}
});
</script>
<main>
<h1>projectMM Installer <span class="version-chip" id="version-chip" hidden></span>
<a class="help-link" href="https://github.com/MoonModules/projectMM/blob/main/docs/install/README.md"
target="_blank" rel="noopener" title="Installer help (opens the docs in a new tab)">?</a></h1>
<p class="tag">Plug in your ESP32. Pick a release + device. Flash. Get the device on your network. Open it in a browser.</p>
<div class="card browser-warning" id="browser-warning">
<strong>This browser can't flash directly.</strong>
Web Serial is required — open this page in
<strong>Chrome</strong> or <strong>Edge</strong> on a desktop. Firefox
and Safari don't support Web Serial. The release page on GitHub has the
same binaries for manual flashing.
</div>
<div class="card">
<strong>Step 1 — Pick a release + device and flash.</strong>
<p class="note" style="margin-top: 4px">
The picker shows stable releases, release candidates (<code>v…-rc…</code>),
and a <code>latest</code> build published on every merge to main — all flagged
<em>(beta)</em> except stables. Newest stable is the default; pick
<code>latest</code> to try the newest unreleased changes.
</p>
<!-- Port picker — optional pre-pick, matching the install-picker
dropdown shape. Populated from navigator.serial.getPorts() (ports
the user has previously granted; empty list on first visit). The
final "Pick another port…" option triggers the browser's native
picker. Web Serial doesn't expose an enumeration API outside the
user-gesture picker, so this is as close to a "live dropdown of
plugged-in devices" as the platform allows. If the user clicks
Install without picking, the orchestrator falls back to its own
requestPort() prompt — current behaviour preserved. -->
<!-- Picking a port auto-detects the connected chip (ESP Web Tools /
ESPHome model: connect and detect are one action) and narrows the
board dropdown below to that chip family — no separate Detect button.
#detect-status shows the result ("Detected ESP32-S3 — selected …")
right under the port row; re-detect = "Pick another port…". This is
web-installer only; the shared picker module (embedded in firmware,
no local serial) carries the narrowing logic but never the port UI. -->
<div class="control-row" id="port-row">
<span class="control-label">USB Port</span>
<select id="port-select" class="rp-select"></select>
</div>
<div class="control-row" id="detect-status-row">
<span class="control-label"></span>
<span id="detect-status" class="rp-status"></span>
</div>
<!-- Erase opt-in. Lives here in the source DOM (outside the picker
mount) so the picker module stays installer-agnostic — its
render() does NOT know about erase semantics. The picker hoists
this element into its tree just above the Install button row via
the `installRowExtras` option below; the picker re-attaches the
same node on every re-render (release-list reload, board pick),
so the listener wired on the checkbox in index.html keeps firing.
Default off because a normal re-flash overwrites
bootloader+partition-table+app in place (the regions writeFlash
touches) and preserves user state (WiFi credentials, board name)
on LittleFS, which is what users usually want. Tick the box when:
- switching firmware variants whose partition tables differ
(esp32 1.3MB app vs esp32s3-n16r8 16MB flash)
- wiping persisted user state (a chip that already has saved
WiFi boots into PROVISIONED and the installer can't speak
Improv to it; erase first avoids that)
- clean-slate testing
Costs ~12 s of chip-erase time before the flash starts. -->
<div class="erase-row" id="erase-row">
<label>
<input type="checkbox" id="erase-before-flash">
Erase chip first
<span class="note erase-note">— wipes saved WiFi + board, adds ~12 s. Tick when switching firmware variant or starting clean.</span>
</label>
<label>
<input type="checkbox" id="apply-device-defaults">
Apply device defaults
<span class="note erase-note">— sets this device model's recommended modules/settings. Auto-ticks with erase; untick to keep your current config when re-flashing a configured device.</span>
</label>
</div>
<!-- picture board picker. Renders a visual card grid from
deviceModels.json (image + product link), and on select drives the shared
picker's own (hidden) board <select> so the existing flash flow is
reused unchanged. The shared install-picker.js + firmware UI are NOT
modified (board images are a Pages-only asset, never flashed). -->
<!-- Board picker: a control-row consistent with USB Port / Release /
Firmware (left "Device" label + a field). The field is a collapsed
summary button; clicking expands the search + card grid full-width
below the row. Picking a board collapses it back to the summary.
The shared install-picker.js + firmware UI are NOT modified
(board images are a Pages-only asset, never flashed). -->
<div id="board-grid-card">
<div class="control-row">
<span class="control-label">Device</span>
<button type="button" id="board-summary" aria-expanded="false">
<span class="board-summary-left">
<span id="board-summary-thumb" class="board-summary-thumb" hidden></span>
<span id="board-summary-label">Pick a device</span>
</span>
<span class="board-summary-caret">▾</span>
</button>
</div>
<div id="board-expand" hidden>
<div class="board-grid-controls">
<input id="board-search" type="search" placeholder="Search devices…" autocomplete="off" aria-label="Search devices">
<button type="button" id="board-clear" class="board-clear">Generic / no device</button>
</div>
<!-- Shown only when a Detect has narrowed the grid to one chip family;
the toggle is the escape hatch if detection was wrong or your board
isn't in the detected family. -->
<div id="board-filter-notice" class="board-filter-notice" hidden></div>
<div id="board-grid"></div>
</div>
</div>
<!-- Board-details popup, filled by showBoardDetails() from the card's
"details" link. Native <dialog>: backdrop, ESC, focus trap for free. -->
<dialog id="board-details">
<div class="bd-head">
<span class="bd-title" id="bd-title"></span>
<button type="button" class="bd-close" id="bd-close" aria-label="Close">✕</button>
</div>
<div class="bd-body" id="bd-body"></div>
</dialog>
<!-- Picker mounts here. Release (newest-first, RCs flagged) → Board (the
board <select> is CSS-hidden; the grid above drives it).
Same module the on-device OTA UI uses. The picker slots #erase-row into
its tree at render time via the installRowExtras option below. -->
<div id="picker-mount"></div>
<!-- Monitor button: pre/post install serial viewer. Renders as a
separate row below the picker so the picker stays installer-
agnostic (on-device OTA UI doesn't have Web Serial, so no
monitor there). Disabled until a port is picked — same gate
the Install button uses (orchestrator falls back to
requestPort() if missing, but the monitor needs an existing
handle since it doesn't drive a user-gesture-triggered open).
Clicking opens #monitor-backdrop. -->
<div class="control-row">
<span class="control-label"></span>
<button id="monitor-btn" class="action-btn" type="button">Monitor serial port</button>
</div>
<!-- Install modal mounts at end of body so it overlays everything.
Driven by install-orchestrator.js, opened from the picker's
onInstall callback. -->
<p class="note">
Ethernet works out of the box for the device you pick — its PHY type and
pins come from the catalog, no rebuild. Boards without Ethernet just use
WiFi.
</p>
</div>
<div class="card">
<strong>Step 2 — Get the device on your network.</strong>
<ul>
<li><strong>Already configured</strong> (Ethernet cable, or WiFi set on a previous flash) — nothing to do. The device joins on boot and appears under "Your devices" below automatically.</li>
<li><strong>New, over WiFi</strong> — the installer's <em>Configure Wi-Fi</em> step opens right after flashing: enter SSID + password, click <strong>Connect</strong>.</li>
<li><strong>Skipped that step?</strong> The device boots a SoftAP <code>MM-XXXX</code> (4 hex digits from the chip MAC). Join it, open <code>http://4.3.2.1</code>, enter your WiFi credentials.</li>
</ul>
</div>
<div class="card">
<strong>Your devices</strong>
<p class="note" style="margin-top: 4px">
Devices you've provisioned via this page are remembered in this
browser. <em>Clear browser data</em> wipes the list.
</p>
<div id="devices-mount"></div>
</div>
<div class="card">
<strong>Step 3 — Open the device's UI in a browser.</strong>
<ul>
<li><code>http://<devicename>.local</code> — the mDNS hostname tracks the device's name. Out of the box that's <code>MM-XXXX</code> (same 4 hex digits as the SoftAP / the name Improv reports), so <code>http://MM-XXXX.local</code>. Rename the device in its UI and the hostname updates to match. Works on most home networks.</li>
<li>or its DHCP-assigned IP, visible in your router's client list.</li>
</ul>
<p class="note">
Source code and releases:
<a href="https://github.com/MoonModules/projectMM" target="_blank" rel="noopener">github.com/MoonModules/projectMM</a>.
</p>
</div>
</main>
<!-- Credits / acknowledgements. Loaded as CDN ES modules straight from
unpkg — pinned versions in install-orchestrator.js. -->
<footer class="credits">
<p class="note">
Powered by
<a href="https://github.com/espressif/esptool-js" target="_blank" rel="noopener">esptool-js</a>
(Web Serial flasher),
<a href="https://github.com/improv-wifi/sdk-js" target="_blank" rel="noopener">improv-wifi-serial-sdk</a>
(WiFi provisioning), and the
<a href="https://www.improv-wifi.com/serial/" target="_blank" rel="noopener">Improv-Serial protocol</a>
for provisioning and pushing device defaults over USB.
</p>
</footer>
<!-- Install modal — driven entirely by JS below. Sections show one at a
time via .install-section.active toggling. Empty until the user
clicks Install in the picker. -->
<div class="install-backdrop" id="install-backdrop">
<div class="install-modal">
<h2 id="install-title">Installing</h2>
<div class="install-section" id="section-connecting">
<div class="install-status">Connecting to device over Web Serial…</div>
<div class="install-status" id="connecting-detail"></div>
<!-- Documented Windows DTR/RTS timing issue (espressif/esptool#136,
#790): on Windows the auto-reset-into-bootloader sequence
dispatches setDTR and setRTS as separate IOCTLs, so the
ESP32's EN+IO0 toggle doesn't always land. Chrome's Web Serial
API can't tightly batch the two writes the way pyserial /
native esptool can. Worse on Windows 11 than Windows 10. The
official workaround is the manual BOOT/RST sequence the
Espressif docs recommend for any board where auto-reset
doesn't fire. Hidden on macOS/Linux. -->
<p class="note windows-only" hidden>
<strong>Windows users:</strong> if you see "Failed to connect with
the device," it's a known Windows timing issue with browser-based
flashing. Hold the <code>BOOT</code> (or <code>BUT1</code>) button
on the board, tap <code>RST</code> (reset) once, then release
<code>BOOT</code> — then click Install again.
</p>
</div>
<!-- Shown when the user's picked port doesn't open (probe failed):
wrong device (e.g. they picked Bluetooth-Incoming-Port from the
OS list) OR a stale handle (device unplugged + replugged since
the grant). The OS port picker is modal and covers the install
modal, so any guidance written into #connecting-detail and
followed immediately by requestPort() is invisible — the user
never sees it. This section gates the re-prompt behind a Try
again button so the message lands before the OS picker covers
the page. -->
<div class="install-section" id="section-wrong-port">
<div class="install-status">That port didn't respond.</div>
<div class="install-status">
Pick the USB-Serial entry your ESP32 is on (look for
<code>usbmodem…</code> on macOS, <code>COMxx</code> on Windows,
<code>ttyUSB…</code> on Linux). Bluetooth ports and debug
consoles aren't it.
</div>
<div class="install-actions">
<button type="button" class="primary" id="wrong-port-retry">Try again</button>
</div>
</div>
<div class="install-section" id="section-flashing">
<div class="install-status" id="flashing-status">Preparing to flash…</div>
<div class="install-progress"><div class="install-progress-bar" id="flash-bar"></div></div>
<p class="note install-warn">Don't close this window or unplug the device until flash completes — interrupting mid-write leaves the chip in a half-flashed state that needs a full re-flash to recover.</p>
</div>
<!-- WiFi-creds form: rendered LAZILY by buildWifiForm() the first time
showSection("wifiForm") runs, NOT in the static HTML.
Why: a `<input type="password">` present in the DOM at page load
triggers macOS iCloud Passwords / 1Password / LastPass autofill
prompts every refresh — even when the install modal isn't open —
because those scanners walk the DOM, not the visible region. The
`autocomplete="off"` + `data-*-ignore` attributes we used before
handle the extension popups but not the OS-level keychain prompt.
Deferring the form means the password field doesn't exist until
the user is actually mid-install, at which point the prompt is
contextually appropriate (it's a password they just typed). -->
<div class="install-section" id="section-wifi-form"></div>
<div class="install-section" id="section-provisioning">
<div class="install-status" id="provisioning-status">Connecting to your WiFi…</div>
</div>
<!-- Needs-IP form: shown when the device didn't speak Improv back
(alreadyOnline branch, or an Improv-less firmware like esp32-eth).
User types the IP/hostname so we can still add the device to
"Your devices". (No serial config push happens on this path — the
device-model defaults are applied later via MoonDeck on the LAN.)
Retry button: cheap second chance at Improv before falling back
to manual IP entry. Some boards (LOLIN S3 mini, slow-booting
variants) lose the race against the post-flash 2 s reopen window;
a click two seconds later usually catches the now-ready UART
task. Unlimited clicks — user-driven, no policy. -->
<div class="install-section" id="section-needs-ip">
<div class="install-status" id="needs-ip-status">
Flash done. The device didn't respond over USB — that's normal for
Ethernet-only firmware (e.g. <code>esp32-eth</code>) or when the
device booted with previously-saved WiFi credentials.
</div>
<div class="install-status" id="needs-ip-retry-status" hidden>
Trying Improv again…
</div>
<form id="needs-ip-form" class="install-form" onsubmit="return false">
<label for="needs-ip-input">Device IP or hostname</label>
<input type="text" id="needs-ip-input" autocomplete="off"
placeholder="192.168.1.42 or MM-XXXX.local" required>
<p class="note">
Find it on your router's admin page or the device's USB serial log
(right after boot). Leave blank and Skip to finish without adding
the device to <em>Your devices</em>.
</p>
<div class="install-actions">
<button type="button" class="secondary" id="needs-ip-retry">Try Improv again</button>
<button type="button" class="secondary" id="needs-ip-skip">Skip</button>
<button type="submit" class="primary" id="needs-ip-add">Add device</button>
</div>
</form>
</div>
<div class="install-section" id="section-done">
<div class="install-status" id="done-status">Device is online!</div>
<div class="install-done-note" id="done-defaults" hidden></div>
<a class="install-success-url" id="done-url" target="_blank" rel="noopener"></a>
<a class="install-success-url" id="done-url-mdns" target="_blank" rel="noopener" hidden></a>
<div class="install-actions">
<button type="button" class="primary" id="done-close">Close</button>
</div>
</div>
<div class="install-section" id="section-error">
<div class="install-status">Something went wrong.</div>
<div class="install-error" id="error-message"></div>
<!-- Same Windows DTR/RTS-timing hint as in section-connecting,
repeated here because the connecting phase is often too quick
to read before the error lands. See the comment in
section-connecting for the upstream-issue references. -->
<p class="note windows-only" hidden>
<strong>Windows users:</strong> if the stage was
<code>connect-flash</code>, it's a known Windows timing issue
with browser-based flashing. Hold the <code>BOOT</code> (or
<code>BUT1</code>) button on the board, tap <code>RST</code>
once, release <code>BOOT</code>, then click Install again.
</p>
<div class="install-actions">
<button type="button" class="secondary" id="error-close">Close</button>
</div>
</div>
<!-- Log panel — collapsible, shows esptool-js + ImprovSerial chatter.
Mostly for diagnosing flash failures; hidden by default to keep
the modal compact. -->
<div class="install-log-wrap">
<button type="button" class="install-log-toggle" id="log-toggle">Show log</button>
<pre class="install-log" id="install-log" hidden></pre>
</div>
</div>
</div>
<!-- Monitor modal — live serial-port viewer. Reuses .install-backdrop /
.install-modal CSS so the look matches the install dialog. The
monitor opens its own Web Serial read loop (the install flow opens
its own); only one consumer at a time can hold a port, so the
monitor closes automatically if the user clicks Install while it's
running, and vice versa. The Reset button pulses RTS to trigger a
device reset so boot logs land in the visible buffer (Web Serial
can only see bytes sent AFTER it opens the port — flashing first
then opening the monitor would miss the boot log without a reset). -->
<div class="install-backdrop" id="monitor-backdrop">
<div class="install-modal" style="max-width: 720px;">
<h2>Serial monitor</h2>
<div class="install-status" id="monitor-status">Opening port…</div>
<pre class="install-log" id="monitor-output" style="max-height: 420px; display: block;"></pre>
<div class="install-actions" style="margin-top: 12px;">
<button type="button" class="secondary" id="monitor-reset">Reset device</button>
<button type="button" class="secondary" id="monitor-clear">Clear</button>
<button type="button" class="primary" id="monitor-close">Close</button>
</div>
</div>
</div>
<script type="module">
// Shared install-picker (release → board → firmware). Same file as the
// on-device OTA UI uses; only the onInstall callback differs:
// - Device UI: POST the chosen .bin URL to /api/firmware/url; device
// fetches the binary directly via esp_https_ota.
// - Web installer (here): hand the manifest URL to the orchestrator,
// which flashes via esptool-js then provisions WiFi via Improv,
// all over the same SerialPort.
//
// Manifests + binaries must be same-origin with this page (Web Serial
// would happily flash from any URL, but the manifest fetch + part
// downloads via fetch() are subject to CORS). The release workflow
// self-hosts the last N releases into pages/install/releases/<tag>/.
// toLocalUrl rewrites the picker's absolute GitHub URLs to the local
// copies before handing them to the orchestrator.
import { installPicker } from "./install-picker.js";
import { myDevices } from "./devices.js";
import { installer } from "./install-orchestrator.js";
// Board catalog + chip detection — web-installer only, kept out of the
// firmware-embedded install-picker.js and injected here via boardSupport.
import * as boardSupport from "./install-picker-boards.js";
// Show the project version next to the heading. library.json ships
// alongside index.html (preview_installer.py + release.yml both copy
// it). Fetch silently — if it's missing for any reason, leave the
// chip hidden rather than rendering "?" noise.
(async () => {
try {
const res = await fetch("./library.json");
if (!res.ok) return;
const lib = await res.json();
if (!lib || !lib.version) return;
const chip = document.getElementById("version-chip");
chip.textContent = `v${lib.version}`;
chip.hidden = false;
} catch (_) { /* silent: cosmetic-only */ }
})();
// Map a GitHub release-asset URL to its Pages-hosted mirror.
// https://github.com/MoonModules/projectMM/releases/download/<TAG>/<file>
// → ./releases/<TAG>/<file>
function toLocalUrl(githubUrl) {
const m = /\/releases\/download\/([^/]+)\/([^/]+)$/.exec(githubUrl);
if (!m) return githubUrl; // unrecognised shape: pass through unchanged
const [, tag, name] = m;
// https://github.com/.../releases/download/<TAG>/<file>
// → ./releases/<TAG>/<file> (same-origin, served from this dir)
return `./releases/${tag}/${name}`;
}
// --- Install modal section toggling ---------------------------------
const backdrop = document.getElementById("install-backdrop");
const title = document.getElementById("install-title");
const sections = {
connecting: document.getElementById("section-connecting"),
wrongPort: document.getElementById("section-wrong-port"),
flashing: document.getElementById("section-flashing"),
wifiForm: document.getElementById("section-wifi-form"),
provisioning: document.getElementById("section-provisioning"),
needsIp: document.getElementById("section-needs-ip"),
done: document.getElementById("section-done"),
error: document.getElementById("section-error"),
};
function showSection(name) {
for (const [k, el] of Object.entries(sections)) {
el.classList.toggle("active", k === name);
}
}
function openModal(titleText) {
title.textContent = titleText;
backdrop.classList.add("open");
// Reset the log per install session so users see only the current run.
document.getElementById("install-log").textContent = "";
// Reset toggle to collapsed state.
const log = document.getElementById("install-log");
const toggle = document.getElementById("log-toggle");
log.hidden = true;
toggle.textContent = "Show log";
// Reset the needs-ip dialog to its idle state — covers the case where
// a prior install ended in retry-success (which leaves the disabled +
// spinner state intact, since the WiFi-creds form took over the
// visible card) and the next install hits the needs-ip path again.
showNeedsIpRetrying(false);
// Lock out the monitor button while an install runs. Web Serial
// grants exclusive port access; a Monitor click mid-flash would
// race the install for the SerialPort and either steal it
// (corrupting the flash) or get a misleading "already open" error.
// closeModal re-enables.
monitorBtn.disabled = true;
// Guard against accidental tab-close mid-flash. Browser shows a
// generic "leave site?" prompt (Chrome ignores the custom text since
// 2017 — security hardening). disarmUnloadGuard() runs on done /
// error / cancel so the user can close the page when it's safe.
armUnloadGuard();
}
function closeModal() {
backdrop.classList.remove("open");
disarmUnloadGuard();
monitorBtn.disabled = false;
// Wipe the password field on every modal close — success, cancel,
// or error. The form is built lazily and re-used across installs in
// the same page session; without this the typed password lingers
// in the live `.value`. (We don't clear `defaultValue` — it stayed
// as the form's initial empty string from buildWifiForm's innerHTML
// assignment; setting `.value` clears the rendered value, which is
// the only thing visible to a script reading the DOM after close.)
// Same wipe runs at the start of each `wifi-creds-form` show, but
// having both belts ensures the wipe runs even when the user
// bypasses the form via Skip or the install fails before reaching
// the creds step.
const passEl = document.getElementById("wifi-password");
if (passEl) passEl.value = "";
}
let unloadGuard = null;
function armUnloadGuard() {
if (unloadGuard) return;
unloadGuard = (e) => {
e.preventDefault();
e.returnValue = ""; // legacy contract — Chrome ignores the string
};
window.addEventListener("beforeunload", unloadGuard);
}
function disarmUnloadGuard() {