-
Notifications
You must be signed in to change notification settings - Fork 45
Expand file tree
/
Copy pathapp.js
More file actions
1381 lines (1241 loc) · 58.5 KB
/
Copy pathapp.js
File metadata and controls
1381 lines (1241 loc) · 58.5 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
// app.js - WLED Web Installer
//
// Fetches available WLED releases from the GitHub Releases API, builds
// esp-web-tools manifests on the fly (as blob URLs) for every combination of
// version x variant x flash size, and wires up the page's controls.
//
// No build step, no server component - everything below runs directly in
// the browser so the page can be hosted as-is on GitHub Pages.
(function () {
'use strict';
// ---------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------
const GITHUB_RELEASES_URL = 'https://api.github.com/repos/wled/WLED/releases';
const DOWNLOAD_MIRROR = 'https://download.wled.me';
const CACHE_KEY = 'wled_webinstaller_releases_cache';
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const MAX_STABLE_RELEASES = 8;
const MAX_BETA_RELEASES = 2;
// "Basic" hides every secondary row (flash size, memory type, flash
// mode) and forces their defaults, leaving only the release/variant
// selection visible - see selectedFlashSize/selectedMemoryType/
// selectedFlashMode and setManifest's forceBasicMode step below. The
// HUB75 layout row is deliberately NOT included in that list: it stays
// visible whenever the HUB75 variant is picked, in both modes, since
// there's no single "default" HUB75 board to fall back to - the layout
// choice is as essential as the version dropdown itself, not an
// advanced/optional refinement.
const DEFAULT_UI_MODE = 'basic';
// Base URLs for locally-hosted bootloader / partition-table files. These
// are chip-specific (and, for the ESP32-S3, flash-size specific) and are
// shared across all WLED versions. bin/boot/ groups them into
// bootloaders/<chip>/ and partitions/ subfolders - see bin/boot/README.md.
const bootBase = new URL('bin/boot/', window.location.href).href;
const bootloaderBase = bootBase + 'bootloaders/';
const partitionBase = bootBase + 'partitions/';
// ---------------------------------------------------------------------
// Client-side bootloader flash-size patching
// ---------------------------------------------------------------------
// An ESP32-family bootloader image has the flash size baked into one
// nibble of its 8-byte header, plus (on every chip used here) a SHA-256
// digest appended over the whole image - `esptool.py write_flash` patches
// both to match whatever `--flash_size` you pass it, but ESP Web Tools
// (which flashes manifest parts as-is over Web Serial) does not. Rather
// than shipping one pre-patched-and-re-signed .bin per chip x flash size
// (see bin/boot/README.md / tools/gen_boot_images.py, which still do this
// ahead of time via esptool for the one canonical file per chip that IS
// committed), this re-flags a chip's single canonical reference
// bootloader for the other declared sizes right here in the browser,
// using Web Crypto for the SHA-256 recompute. Verified to reproduce every
// previously pre-generated bin/boot/*.bin byte-for-byte from its chip's
// canonical file.
const FLASH_SIZE_NIBBLE = { '4MB': 2, '8MB': 3, '16MB': 4, '32MB': 5 };
/**
* Re-flag an ESP32-family bootloader image for a different flash size:
* patch the flash-size nibble in the header, then - if the image has a
* SHA-256 digest appended (the extended header's append-digest flag) -
* recompute that digest over the new header. The image's checksum byte
* (which covers only segment data, never the header) doesn't need
* recomputing. Layout reference: 8-byte main header + 16-byte extended
* header, then `numSegments` x (8-byte segment header + data), then
* zero-padding to a 16-byte boundary, then the checksum byte, then
* (optionally) the 32-byte digest.
*/
async function patchBootloaderFlashSize(buffer, flashSizeId) {
const nibble = FLASH_SIZE_NIBBLE[flashSizeId];
if (nibble === undefined) throw new Error('unknown flash size: ' + flashSizeId);
const data = new Uint8Array(buffer.slice(0));
const view = new DataView(data.buffer);
if (data[0] !== 0xe9) throw new Error('not an ESP image (bad magic byte)');
const numSegments = data[1];
const appendDigest = data[23]; // last byte of the 16-byte extended header
let pos = 24;
for (let i = 0; i < numSegments; i++) {
const segLen = view.getUint32(pos + 4, true);
pos += 8 + segLen;
}
const pad = (16 - ((pos + 1) % 16)) % 16;
const hashStart = pos + pad + 1; // +1 for the checksum byte
data[3] = (data[3] & 0x0f) | (nibble << 4);
if (appendDigest) {
const digest = await crypto.subtle.digest('SHA-256', data.slice(0, hashStart));
data.set(new Uint8Array(digest), hashStart);
}
return data.buffer;
}
const bootloaderPatchCache = new Map();
/**
* Fetch a chip's canonical reference bootloader once (path relative to
* bootloaderBase, e.g. "esp32-c3/bootloader_c3_8m.bin") and re-flag it for
* `flashSizeId`, returning a blob: URL for the patched bytes (consumable
* by ESP Web Tools exactly like a static file path). Cached per
* (file, size) - the same pair is requested by every firmware variant of
* every release in the dropdown, so without this every one of them would
* re-fetch and re-hash the same bytes.
*/
function getPatchedBootloaderUrl(fileName, flashSizeId) {
const key = fileName + '|' + flashSizeId;
if (!bootloaderPatchCache.has(key)) {
bootloaderPatchCache.set(key, fetch(bootloaderBase + fileName)
.then(function (res) { return res.arrayBuffer(); })
.then(function (buf) { return patchBootloaderFlashSize(buf, flashSizeId); })
.then(function (patched) {
return URL.createObjectURL(new Blob([patched], { type: 'application/octet-stream' }));
}));
}
return bootloaderPatchCache.get(key);
}
// ---------------------------------------------------------------------
// Chip boot configuration
// ---------------------------------------------------------------------
// Describes the boot-stage parts (bootloader / partition table) that must
// be flashed before the WLED firmware itself. The firmware part is always
// appended last, at firmwareOffset.
//
// `flashSizes` is only present for chips whose bootParts can vary by flash
// size - it drives the flash-size selector in the UI even though WLED
// itself ships a single firmware asset for these chips (see resolveSuffix
// / getFlashSizeAvailability, which fall back to a chip's declared
// `flashSizes` when its VARIANTS entry is a plain string rather than a
// per-size suffix map).
const CHIP_CONFIG = {
'ESP32': {
chipFamily: 'ESP32',
defaultFlashSize: '4MB',
flashSizes: ['4MB', '8MB', '16MB'],
// 8MB (bootloader_esp32_8m.bin) is the canonical reference bootloader,
// used as-is; 4MB/16MB are that same file re-flagged client-side (see
// getPatchedBootloaderUrl above). 4MB used to ship as a separate
// pre-merged bootloader+partitions+otadata image instead - removed in
// favor of re-flagging, same as every other size, now that flash-size
// patching is known-good (see bin/boot/README.md). flashModeId picks
// between DIO (default) and QIO bootloader files - see
// resolveFlashMode / FLASH_MODE_BOOTLOADERS above.
bootParts: (flashSizeId, flashModeId) => {
const src = FLASH_MODE_BOOTLOADERS['ESP32'][resolveFlashMode('ESP32', flashModeId)];
if (flashSizeId === '8MB') {
return Promise.resolve([
{ path: bootloaderBase + src, offset: 4096 },
{ path: partitionBase + 'partitions_esp32_8m.bin', offset: 32768 }
]);
}
const size = flashSizeId === '16MB' ? '16MB' : '4MB';
const partitions = size === '16MB' ? 'partitions_esp32_16m.bin' : 'partitions_c3_4m.bin';
return getPatchedBootloaderUrl(src, size).then((url) => [
{ path: url, offset: 4096 },
{ path: partitionBase + partitions, offset: 32768 }
]);
},
firmwareOffset: 65536
},
'ESP32-C3': {
chipFamily: 'ESP32-C3',
defaultFlashSize: '4MB',
flashSizes: ['4MB', '8MB', '16MB'],
// Same split as ESP32 above: 8MB is the canonical reference
// bootloader, 4MB/16MB are it re-flagged client-side. ESP32-C3 is
// also the one chip with a real flash-mode override (see
// FLASH_MODE_ASSETS above) - flashModeId picks between the DIO
// (default) and QIO bootloader builds, both real PlatformIO builds
// rather than anything patched, since flash mode is compiled-in
// bootloader logic, not a header field (see bin/boot/README.md).
bootParts: (flashSizeId, flashModeId) => {
const src = FLASH_MODE_BOOTLOADERS['ESP32-C3'][resolveFlashMode('ESP32-C3', flashModeId)];
if (flashSizeId === '8MB') {
return Promise.resolve([
{ path: bootloaderBase + src, offset: 0 },
{ path: partitionBase + 'partitions_c3_8m.bin', offset: 32768 }
]);
}
const size = flashSizeId === '16MB' ? '16MB' : '4MB';
const partitions = size === '16MB' ? 'partitions_c3_16m.bin' : 'partitions_c3_4m.bin';
return getPatchedBootloaderUrl(src, size).then((url) => [
{ path: url, offset: 0 },
{ path: partitionBase + partitions, offset: 32768 }
]);
},
firmwareOffset: 65536
},
'ESP32-S2': {
chipFamily: 'ESP32-S2',
defaultFlashSize: '4MB',
flashSizes: ['4MB', '8MB', '16MB'],
// QIO (bootloader_s2.bin) is the canonical reference bootloader, used
// as-is; DIO (bootloader_s2_dio.bin) is a real alternate PlatformIO
// build - both are already flagged for 4MB natively (no re-flagging
// needed at that size regardless of mode), 8MB/16MB re-flag whichever
// mode was resolved. See resolveFlashMode / FLASH_MODE_BOOTLOADERS
// above.
bootParts: (flashSizeId, flashModeId) => {
const src = FLASH_MODE_BOOTLOADERS['ESP32-S2'][resolveFlashMode('ESP32-S2', flashModeId)];
if (flashSizeId === '8MB' || flashSizeId === '16MB') {
const partitions = flashSizeId === '16MB' ? 'partitions_s2_16m.bin' : 'partitions_s2_8m.bin';
return getPatchedBootloaderUrl(src, flashSizeId).then((url) => [
{ path: url, offset: 4096 },
{ path: partitionBase + partitions, offset: 32768 }
]);
}
return Promise.resolve([
{ path: bootloaderBase + src, offset: 4096 },
{ path: partitionBase + 'partitions_s2_4m.bin', offset: 32768 }
]);
},
firmwareOffset: 65536
},
'ESP32-S3': {
chipFamily: 'ESP32-S3',
defaultFlashSize: '8MB',
// 8MB (bootloader_s3.bin, QIO) is the canonical reference bootloader,
// used as-is; 4MB/16MB are that same file re-flagged client-side (see
// getPatchedBootloaderUrl above). No normal-variant release asset
// needs OPI mode, so resolveFlashMode never actually picks anything
// but QIO here - the OPI bootloader (bootloader_s3_opi.bin) is real
// and committed, but only wired into the Waveshare HUB75 layout
// below, the one place a matching firmware asset exists for it (see
// bin/boot/README.md's "Flash mode" section).
bootParts: (flashSizeId, flashModeId) => {
const src = FLASH_MODE_BOOTLOADERS['ESP32-S3'][resolveFlashMode('ESP32-S3', flashModeId)];
if (flashSizeId === '4MB' || flashSizeId === '16MB') {
const partitions = flashSizeId === '16MB' ? 'partitions_s3_16m.bin' : 'partitions_s3_4m.bin';
return getPatchedBootloaderUrl(src, flashSizeId).then((url) => [
{ path: url, offset: 0 },
{ path: partitionBase + partitions, offset: 32768 }
]);
}
return Promise.resolve([
{ path: bootloaderBase + src, offset: 0 },
{ path: partitionBase + 'partitions_s3_8m.bin', offset: 32768 }
]);
},
firmwareOffset: 65536
},
'ESP8266': {
chipFamily: 'ESP8266',
defaultFlashSize: '4MB',
bootParts: () => [],
firmwareOffset: 0
}
};
// Flash sizes shown in the UI, in display order. Not every chip supports
// every size - see VARIANTS below.
const FLASH_SIZES = [
{ id: '1MB', label: '1MB' },
{ id: '2MB', label: '2MB' },
{ id: '4MB', label: '4MB' },
{ id: '8MB', label: '8MB' },
{ id: '16MB', label: '16MB' }
];
const DEFAULT_FLASH_SIZE = '4MB';
// ---------------------------------------------------------------------
// HUB75 layouts
// ---------------------------------------------------------------------
// Each HUB75 build targets one specific board/pinout combination rather
// than a chip family with a flash-size choice, so it doesn't fit the
// VARIANTS model below - a layout fully determines chip, flash size and
// boot parts all at once. flashSizeLabel is display-only.
const HUB75_LAYOUTS = [
{
id: 'esp32_default',
label: 'ESP32 (default pinout)',
chip: 'ESP32',
suffix: '_ESP32_HUB75.bin',
flashSizeLabel: '4MB',
bootParts: () => CHIP_CONFIG['ESP32'].bootParts('4MB'),
firmwareOffset: CHIP_CONFIG['ESP32'].firmwareOffset
},
{
id: 'esp32_forum',
label: 'ESP32 (forum pinout)',
chip: 'ESP32',
suffix: '_ESP32_HUB75_forum_pinout.bin',
flashSizeLabel: '4MB',
bootParts: () => CHIP_CONFIG['ESP32'].bootParts('4MB'),
firmwareOffset: CHIP_CONFIG['ESP32'].firmwareOffset
},
{
id: 'hdwf2',
label: 'Huidu HD-WF2',
chip: 'ESP32-S3',
suffix: '_ESP32-S3_HD-WF2.bin',
// Physically a 4MB board; WLED's own build config for it (uncommonly)
// inherits an 8MB-declared bootloader paired with a scaled-down 4MB
// partition table, so we match that exact pairing here rather than
// "correct" it - it's what upstream actually ships and tests.
flashSizeLabel: '4MB',
bootParts: () => [
{ path: bootloaderBase + 'esp32-s3/bootloader_s3.bin', offset: 0 },
{ path: partitionBase + 'partitions_s3_hdwf2.bin', offset: 32768 }
],
firmwareOffset: 65536
},
{
id: 'matrixportal',
label: 'Adafruit MatrixPortal S3',
chip: 'ESP32-S3',
suffix: '_ESP32-S3_Adafruit_Matrixportal.bin',
flashSizeLabel: '8MB',
bootParts: () => CHIP_CONFIG['ESP32-S3'].bootParts('8MB'),
firmwareOffset: 65536
},
{
id: 'moonhub',
label: 'MOONHUB / LilyGO T7-S3',
chip: 'ESP32-S3',
suffix: '_ESP32-S3_16MB_opi_HUB75.bin',
flashSizeLabel: '16MB',
bootParts: () => CHIP_CONFIG['ESP32-S3'].bootParts('16MB'),
firmwareOffset: 65536
},
{
id: 'waveshare',
label: 'Waveshare ESP32-S3-RGB-Matrix',
chip: 'ESP32-S3',
suffix: '_ESP32-S3_Waveshare_HUB75.bin',
// This board uses opi_opi memory (octal flash + octal PSRAM sharing
// the same MSPI controller), which - unlike every other S3 board here -
// genuinely changes the bootloader's compiled boot logic (real octal
// "OPI" boot, not just a different PSRAM bus width the bootloader
// doesn't care about). bootloader_s3_opi.bin is a real PlatformIO
// build targeting that exact config; re-flagging it to 32MB
// client-side works the same way as every other chip/size combo (see
// bin/boot/README.md's "Flash mode" section) - no separate 32MB-only
// file needed anymore.
flashSizeLabel: '32MB',
bootParts: () => getPatchedBootloaderUrl('esp32-s3/bootloader_s3_opi.bin', '32MB').then((url) => [
{ path: url, offset: 0 },
{ path: partitionBase + 'partitions_s3_32m.bin', offset: 32768 }
]),
firmwareOffset: 65536
}
];
const DEFAULT_HUB75_LAYOUT = HUB75_LAYOUTS[0].id;
// ---------------------------------------------------------------------
// Variant definitions
// ---------------------------------------------------------------------
// Each variant maps chip families to the asset-name suffix used in GitHub
// release assets. A chip entry is either:
// - a plain string: one asset, flash size is irrelevant
// - a { flashSizeId: suffix } map: the chip ships multiple binaries
// for different flash sizes
//
// Chips that have no entry for a given variant are simply left out of the
// generated manifest for that variant (e.g. "audio" is ESP32-only).
const VARIANTS = {
normal: {
'ESP32': '_ESP32.bin',
'ESP32-C3': '_ESP32-C3.bin',
'ESP32-S2': '_ESP32-S2.bin',
// 8MB defaults to the no-PSRAM build, same "assume nothing extra
// unless told otherwise" default as ESP32/Wrover below - OPI PSRAM is
// an opt-in via the memory-type row, not the default, even though
// most 8MB S3 dev boards do have it.
'ESP32-S3': { '4MB': '_ESP32-S3_4M_qspi.bin', '8MB': '_ESP32-S3_8MB_none.bin', '16MB': '_ESP32-S3_16MB_opi.bin' },
'ESP8266': { '1MB': '_ESP01.bin', '2MB': '_ESP02.bin', '4MB': '_ESP8266.bin' }
},
ethernet: {
'ESP32': '_ESP32_Ethernet.bin'
},
audio: {
'ESP32': '_ESP32_audioreactive.bin'
},
test: {
'ESP8266': { '1MB': '_ESP01_160.bin', '2MB': '_ESP02_160.bin', '4MB': '_ESP8266_160.bin' }
},
v4: {
'ESP32': '_ESP32_V4.bin'
},
debug: {
'ESP32': '_ESP32_DEBUG.bin'
}
};
// Maps variant names to the DOM ids used for their radio inputs. "hub75"
// is handled outside the VARIANTS table (see HUB75_LAYOUTS above) but its
// radio button follows the same enable/disable convention as the rest.
const VARIANT_IDS = ['normal', 'ethernet', 'audio', 'test', 'v4', 'debug', 'hub75'];
// ---------------------------------------------------------------------
// Memory (PSRAM) variants
// ---------------------------------------------------------------------
// A handful of chips ship an alternate firmware build for a different
// PSRAM configuration - WLED only publishes these for the "normal"
// variant. This is a separate axis from flash size and deliberately
// doesn't reuse the VARIANTS table: the bootloader/partition table don't
// care about PSRAM at all (PSRAM init happens app-side, not in the 2nd
// stage bootloader - see bin/boot/README.md), so a memory variant is
// purely a different firmware asset paired with the SAME boot files the
// base chip already uses at that flash size. Overriding just the suffix
// here (rather than adding these as extra VARIANTS/CHIP_CONFIG chips)
// also keeps them out of the flash-size chart, where they'd otherwise
// show up as confusing near-duplicates of ESP32/ESP32-S3.
//
// `suffixes` is either one suffix (applies at every flash size a chip
// actually declares - see CHIP_CONFIG[chip].flashSizes) or a
// { flashSizeId: suffix } map for sizes where the alternate build only
// exists at one specific size (the PSRAM bus mode split for ESP32-S3 is
// only published at 8MB - 4MB is already QSPI-only and 16MB is already
// OPI-only regardless of this selection). "standard" itself means no
// PSRAM for every chip, including ESP32-S3 at 8MB - OPI is an opt-in
// override here just like Wrover/QSPI, even though it's common on 8MB
// dev boards. Wrover uses the map form even though WLED only ever
// publishes one `_ESP32_WROVER.bin` asset (repeated across its three
// real sizes) specifically so it's absent - and the option hidden - at
// 1MB/2MB, sizes ESP32 (Wrover or otherwise) never actually comes in;
// those only exist for ESP8266 in the flash-size row (see FLASH_SIZES).
const MEMORY_TYPE_IDS = ['standard', 'wrover', 'qspi', 'opi'];
const DEFAULT_MEMORY_TYPE = 'standard';
const MEMORY_TYPE_ASSETS = {
wrover: { chip: 'ESP32', suffixes: { '4MB': '_ESP32_WROVER.bin', '8MB': '_ESP32_WROVER.bin', '16MB': '_ESP32_WROVER.bin' } },
qspi: { chip: 'ESP32-S3', suffixes: { '8MB': '_ESP32-S3_8MB_qspi.bin' } },
opi: { chip: 'ESP32-S3', suffixes: { '8MB': '_ESP32-S3_8MB_opi.bin' } }
};
/** Resolve a memory-variant override suffix for this chip/size, or null if none applies. */
function resolveMemoryOverrideSuffix(memTypeId, chip, flashSizeId) {
const entry = MEMORY_TYPE_ASSETS[memTypeId];
if (!entry || entry.chip !== chip) return null;
if (typeof entry.suffixes === 'string') return entry.suffixes;
return entry.suffixes[flashSizeId] || null;
}
// ---------------------------------------------------------------------
// Flash-mode overrides
// ---------------------------------------------------------------------
// Unlike flash size (a header nibble this repo can patch - see
// getPatchedBootloaderUrl above), flash mode is baked into the
// bootloader's *compiled* SPI-flash-init logic, so every mode needs its
// own real PlatformIO build - there's nothing to compute client-side
// here, just a different committed bootloader file to pick. Every chip
// has its own native default mode (whatever its normal WLED release
// asset is actually built with); ESP32-C3 additionally has a real
// *override* - a genuinely separate release asset WLED publishes for a
// different mode. See bin/boot/README.md's "Flash mode" section for the
// full picture, including modes that exist as committed bootloader
// files but have no matching WLED firmware (ESP32-S3's OPI file is one -
// it's wired directly into the Waveshare HUB75 layout instead of this
// general axis, since that's the only place a matching firmware asset
// exists for it).
const FLASH_MODES = [
{ id: 'dio', label: 'DIO' },
{ id: 'qio', label: 'QIO' }
];
const FLASH_MODE_IDS = ['default', 'dio', 'qio'];
const DEFAULT_FLASH_MODE = 'default';
// Each chip's own native mode - what "Default" resolves to, and what a
// chip falls back to when the globally selected mode isn't one WLED
// actually publishes a matching firmware asset for (see resolveFlashMode
// below). ESP8266 has no entry - it has no flash-mode axis at all.
const CHIP_DEFAULT_FLASH_MODE = {
'ESP32': 'dio',
'ESP32-C3': 'dio',
'ESP32-S2': 'qio',
'ESP32-S3': 'qio'
};
// Bootloader files per chip x mode (see bin/boot/README.md's "Flash
// mode" section for how each was built and verified). Includes modes
// with no matching WLED firmware asset, committed for completeness -
// resolveFlashMode() below is what actually gates which ones this app
// will ever request, so an unpaired file here is inert, not a risk.
const FLASH_MODE_BOOTLOADERS = {
'ESP32': { dio: 'esp32/bootloader_esp32_8m.bin', qio: 'esp32/bootloader_esp32_8m_qio.bin' },
'ESP32-C3': { dio: 'esp32-c3/bootloader_c3_8m.bin', qio: 'esp32-c3/bootloader_c3_8m_qio.bin' },
'ESP32-S2': { qio: 'esp32-s2/bootloader_s2.bin', dio: 'esp32-s2/bootloader_s2_dio.bin' },
'ESP32-S3': { qio: 'esp32-s3/bootloader_s3.bin', opi: 'esp32-s3/bootloader_s3_opi.bin' }
};
// Real firmware-asset overrides - ONLY (chip, mode) pairs WLED actually
// publishes as a separate release binary for. Everything else falls
// back to the chip's own default mode, for both the firmware suffix and
// the bootloader file (resolveFlashMode is the single source of truth
// for both, so the two can never end up mismatched).
const FLASH_MODE_ASSETS = {
qio: { chip: 'ESP32-C3', suffix: '_ESP32-C3-QIO.bin' }
};
/**
* Resolve the effective flash mode for a chip given the globally
* selected mode: the selection itself if it's this chip's own native
* mode, or a real WLED-published override for it - otherwise this
* chip's own default. Returns null for chips with no flash-mode axis
* (ESP8266). Used for BOTH the firmware suffix and the bootloader file
* (see generateManifest / CHIP_CONFIG[*].bootParts), so a mismatch
* between the two - the exact bug this axis exists to avoid - is
* structurally impossible.
*/
function resolveFlashMode(chip, flashModeId) {
const defaultMode = CHIP_DEFAULT_FLASH_MODE[chip];
if (!defaultMode) return null;
if (flashModeId === defaultMode) return defaultMode;
const entry = FLASH_MODE_ASSETS[flashModeId];
if (entry && entry.chip === chip) return flashModeId;
return defaultMode;
}
// ---------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------
/** Return true when a chip entry has a flash-size selection axis. */
function hasFlashAxis(entry) {
return entry !== null && typeof entry === 'object';
}
/** Resolve the asset suffix for a chip, given the globally selected flash size. */
function resolveSuffix(entry, chip, flashSizeId) {
if (!hasFlashAxis(entry)) return entry;
return entry[flashSizeId] || entry[CHIP_CONFIG[chip].defaultFlashSize];
}
/** Find a release asset whose name ends with `suffix` (ignore .gz files). */
function findAsset(assets, suffix) {
if (!suffix) return null;
return assets.find(function (a) {
return a.name.endsWith(suffix) && !a.name.endsWith('.gz');
}) || null;
}
/** Rewrite a GitHub release asset URL to WLED's CORS-enabled download mirror. */
function mirrorUrl(githubUrl) {
return DOWNLOAD_MIRROR + new URL(githubUrl).pathname;
}
/** Extract the WLED version string from asset filenames (for nightly). */
function extractVersionFromAssets(assets) {
for (let i = 0; i < assets.length; i++) {
const m = assets[i].name.match(/^WLED_(.+?)_(ESP\d|ESP8)/);
if (m) return m[1];
}
return 'unknown';
}
/** Build the human-friendly release label shown in the version dropdown. */
function getDisplayVersion(release) {
if (release.tag_name === 'nightly') {
return extractVersionFromAssets(release.assets) + ' Nightly';
}
return release.tag_name.replace(/^v/, '');
}
/** Build the version string stored in generated manifests. */
function getManifestVersion(release, variantName) {
let ver = release.tag_name === 'nightly'
? extractVersionFromAssets(release.assets)
: release.tag_name.replace(/^v/, '');
if (variantName !== 'normal') ver += ' ' + variantName;
return ver;
}
/** Classify a release into release/beta/nightly buckets. */
function categorize(release) {
if (release.tag_name === 'nightly') return 'nightly';
if (release.prerelease) return 'beta';
return 'release';
}
// ---------------------------------------------------------------------
// Manifest generation
// ---------------------------------------------------------------------
/**
* Build an esp-web-tools manifest object for the given release + variant +
* flash size + memory (PSRAM) type + flash mode. Returns null if no
* matching assets are found at all. `memTypeId` only ever applies to the
* "normal" variant - other variants don't publish memory-variant builds,
* so it's simply ignored for them (resolveMemoryOverrideSuffix would
* never match anyway, but the check avoids relying on that implicitly).
* `flashModeId` is resolved per-chip via resolveFlashMode() regardless of
* variant - outside "normal" it's forced to each chip's own default mode,
* since no other variant publishes a flash-mode override.
*/
async function generateManifest(release, variantName, flashSizeId, memTypeId, flashModeId) {
const chipEntries = VARIANTS[variantName];
const version = getManifestVersion(release, variantName);
const builds = [];
for (const chip in chipEntries) {
const overrideSuffix = variantName === 'normal' && memTypeId
? resolveMemoryOverrideSuffix(memTypeId, chip, flashSizeId)
: null;
const flashMode = variantName === 'normal' ? resolveFlashMode(chip, flashModeId) : CHIP_DEFAULT_FLASH_MODE[chip];
const flashModeSuffix = flashMode && flashMode !== CHIP_DEFAULT_FLASH_MODE[chip]
? FLASH_MODE_ASSETS[flashMode].suffix
: null;
const suffix = overrideSuffix || flashModeSuffix || resolveSuffix(chipEntries[chip], chip, flashSizeId);
const asset = findAsset(release.assets, suffix);
if (!asset) continue;
const config = CHIP_CONFIG[chip];
const parts = await config.bootParts(flashSizeId, flashMode);
parts.push({
path: mirrorUrl(asset.browser_download_url),
offset: config.firmwareOffset
});
builds.push({ chipFamily: config.chipFamily, parts: parts });
}
if (builds.length === 0) return null;
return {
name: 'WLED',
version: version,
home_assistant_domain: 'wled',
new_install_prompt_erase: true,
builds: builds
};
}
/**
* Build a manifest for a single HUB75 layout (one specific board). Unlike
* generateManifest(), this always produces at most one build entry, since
* a layout already fully determines the chip.
*/
async function generateHub75Manifest(release, layoutId) {
const layout = HUB75_LAYOUTS.find(function (l) { return l.id === layoutId; });
if (!layout) return null;
const asset = findAsset(release.assets, layout.suffix);
if (!asset) return null;
const parts = await layout.bootParts();
parts.push({
path: mirrorUrl(asset.browser_download_url),
offset: layout.firmwareOffset
});
return {
name: 'WLED',
version: getManifestVersion(release, 'hub75'),
home_assistant_domain: 'wled',
new_install_prompt_erase: true,
builds: [{ chipFamily: layout.chip, parts: parts }]
};
}
/** Convert a manifest object into a blob URL consumable by esp-web-tools. */
function createManifestUrl(manifest) {
const blob = new Blob([JSON.stringify(manifest)], { type: 'application/json' });
return URL.createObjectURL(blob);
}
/**
* For a given release + variant, figure out which chips are actually
* available at each flash size (i.e. a matching release asset exists).
* A chip's VARIANTS entry is either a per-size suffix map (the chip ships
* multiple firmware binaries, e.g. ESP32-S3) or a single fixed suffix (one
* binary covers every flash size, e.g. ESP32) - in the latter case the
* available sizes come from CHIP_CONFIG[chip].flashSizes instead, since
* the same firmware asset is paired with different boot files depending
* on the chosen size. The row itself is only shown when at least one chip
* in this variant has a real choice to make.
*/
function getFlashSizeAvailability(release, variantName) {
const chipEntries = VARIANTS[variantName];
function fixedSizes(chip) {
return (CHIP_CONFIG[chip] && CHIP_CONFIG[chip].flashSizes) || [];
}
const axisChips = Object.keys(chipEntries).filter(function (chip) {
return hasFlashAxis(chipEntries[chip]) || fixedSizes(chip).length > 1;
});
const chipsBySize = {};
FLASH_SIZES.forEach(function (fs) { chipsBySize[fs.id] = []; });
Object.keys(chipEntries).forEach(function (chip) {
const entry = chipEntries[chip];
if (hasFlashAxis(entry)) {
Object.keys(entry).forEach(function (sizeId) {
if (findAsset(release.assets, entry[sizeId])) chipsBySize[sizeId].push(chip);
});
} else if (findAsset(release.assets, entry)) {
fixedSizes(chip).forEach(function (sizeId) { chipsBySize[sizeId].push(chip); });
}
});
return { hasAxis: axisChips.length > 0, chipsBySize: chipsBySize };
}
/**
* Given a release + the currently selected flash size, figure out which
* memory (PSRAM) types have a real asset to offer. Only the "normal"
* variant ever has these, so callers should only invoke this there.
* "standard" (no override - today's default behavior for every chip) is
* always considered available; the row itself is only shown when at
* least one non-standard option also resolves to a real asset.
*/
function getMemoryTypeAvailability(release, flashSizeId) {
const availability = { standard: true };
Object.keys(MEMORY_TYPE_ASSETS).forEach(function (memId) {
const entry = MEMORY_TYPE_ASSETS[memId];
const suffix = resolveMemoryOverrideSuffix(memId, entry.chip, flashSizeId);
availability[memId] = !!findAsset(release.assets, suffix);
});
const hasAxis = Object.keys(MEMORY_TYPE_ASSETS).some(function (memId) { return availability[memId]; });
return { hasAxis: hasAxis, availability: availability };
}
/**
* For a given release + variant + the currently selected flash size,
* figure out which chips are actually available at each flash mode -
* mirrors getFlashSizeAvailability's shape/pattern exactly (same idea:
* a mode "applies to" a chip either because it's that chip's own native
* mode with a real matching asset, or because WLED publishes a genuine
* override for it - see FLASH_MODE_ASSETS). Depends on flashSizeId
* because a chip's own suffix can vary by size (e.g. ESP32-S3's
* per-size map), same reason getMemoryTypeAvailability takes it. The row
* itself is only shown when at least one chip has a real override to
* offer - a chip with only its own single native mode isn't "an axis".
*/
function getFlashModeAvailability(release, variantName, flashSizeId) {
const chipEntries = VARIANTS[variantName];
const chipsByMode = {};
FLASH_MODES.forEach(function (m) { chipsByMode[m.id] = []; });
Object.keys(chipEntries).forEach(function (chip) {
const defaultMode = CHIP_DEFAULT_FLASH_MODE[chip];
if (!defaultMode || !chipsByMode[defaultMode]) return;
const baseSuffix = resolveSuffix(chipEntries[chip], chip, flashSizeId);
if (findAsset(release.assets, baseSuffix)) chipsByMode[defaultMode].push(chip);
});
Object.keys(FLASH_MODE_ASSETS).forEach(function (modeId) {
if (!chipsByMode[modeId]) return;
const entry = FLASH_MODE_ASSETS[modeId];
if (!chipEntries[entry.chip]) return;
if (findAsset(release.assets, entry.suffix) && chipsByMode[modeId].indexOf(entry.chip) === -1) {
chipsByMode[modeId].push(entry.chip);
}
});
const hasAxis = Object.keys(FLASH_MODE_ASSETS).some(function (modeId) {
return !!chipEntries[FLASH_MODE_ASSETS[modeId].chip];
});
return { hasAxis: hasAxis, chipsByMode: chipsByMode };
}
// ---------------------------------------------------------------------
// Dropdown population
// ---------------------------------------------------------------------
/**
* Create a single <option> element for a release. All variant x flash-size
* x memory-type x flash-mode manifests are pre-generated as blob URLs and
* stashed on the option so the UI code can look them up synchronously.
* The memory-type/flash-mode fan-out only happens for "normal" (the only
* variant with these alternate builds) - every other variant just
* generates one manifest per flash size, stored under the
* "standard|default" key, same as before these axes existed. Memory type
* and flash mode are independent per-chip overrides within the same
* manifest (see generateManifest), so every combination of the two needs
* its own manifest - stored under a composite "memTypeId|flashModeId" key
* rather than a third nesting level.
*/
async function createOption(release) {
const opt = document.createElement('option');
opt.textContent = getDisplayVersion(release);
const manifests = {}; // variant -> flashSizeId -> "memTypeId|flashModeId" -> manifest URL
const availability = {}; // variant -> { hasAxis, availability }
let hasPlain = false;
for (const variantName in VARIANTS) {
const bySize = {};
for (const fs of FLASH_SIZES) {
const byKey = {};
const memTypeIds = variantName === 'normal' ? MEMORY_TYPE_IDS : [DEFAULT_MEMORY_TYPE];
const flashModeIds = variantName === 'normal' ? FLASH_MODE_IDS : [DEFAULT_FLASH_MODE];
for (const memId of memTypeIds) {
for (const modeId of flashModeIds) {
const manifest = await generateManifest(release, variantName, fs.id, memId, modeId);
if (manifest) byKey[memId + '|' + modeId] = createManifestUrl(manifest);
}
}
if (Object.keys(byKey).length > 0) bySize[fs.id] = byKey;
}
availability[variantName] = getFlashSizeAvailability(release, variantName);
if (Object.keys(bySize).length > 0) {
manifests[variantName] = bySize;
if (variantName === 'normal') hasPlain = true;
}
}
manifests.hub75 = {};
const hub75Availability = {};
for (const layout of HUB75_LAYOUTS) {
const manifest = await generateHub75Manifest(release, layout.id);
hub75Availability[layout.id] = !!manifest;
if (manifest) manifests.hub75[layout.id] = createManifestUrl(manifest);
}
if (Object.keys(manifests.hub75).length === 0) delete manifests.hub75;
if (!hasPlain) return null;
opt._manifests = manifests;
opt._flashAvailability = availability;
opt._hub75Availability = hub75Availability;
opt._release = release;
return opt;
}
/**
* Populate the release dropdown with grouped options and generated
* manifests. Async because building each option's manifests now involves
* client-side bootloader patching (fetch + SHA-256 recompute) for some
* chip/flash-size combinations - see getPatchedBootloaderUrl above.
*/
async function populateDropdown(releases) {
const sel = document.getElementById('ver');
const groups = { release: [], beta: [], nightly: [] };
releases.forEach(function (r) {
if (r.draft || !r.assets || r.assets.length === 0) return;
groups[categorize(r)].push(r);
});
if (groups.release.length > MAX_STABLE_RELEASES) {
groups.release = groups.release.slice(0, MAX_STABLE_RELEASES);
}
if (groups.beta.length > MAX_BETA_RELEASES) {
groups.beta = groups.beta.slice(0, MAX_BETA_RELEASES);
}
const fragment = document.createDocumentFragment();
const labels = { release: 'Release', beta: 'Beta', nightly: 'Nightly' };
for (const key of ['release', 'beta', 'nightly']) {
if (groups[key].length === 0) continue;
const opts = await Promise.all(groups[key].map(createOption));
const grp = document.createElement('optgroup');
grp.label = labels[key];
opts.forEach(function (opt) {
if (opt) grp.appendChild(opt);
});
if (grp.children.length > 0) fragment.appendChild(grp);
}
if (fragment.children.length === 0) {
showLoadError();
return;
}
sel.innerHTML = '';
sel.appendChild(fragment);
}
// ---------------------------------------------------------------------
// Caching (sessionStorage, 5-minute TTL)
// ---------------------------------------------------------------------
/** Read cached GitHub release metadata if it is still within TTL. */
function getCachedReleases() {
try {
const raw = sessionStorage.getItem(CACHE_KEY);
if (!raw) return null;
const data = JSON.parse(raw);
if (Date.now() - data.timestamp < CACHE_TTL) return data.releases;
} catch (e) { /* ignore */ }
return null;
}
/** Persist GitHub release metadata in sessionStorage with a timestamp. */
function cacheReleases(releases) {
try {
sessionStorage.setItem(CACHE_KEY, JSON.stringify({ timestamp: Date.now(), releases: releases }));
} catch (e) { /* ignore */ }
}
// ---------------------------------------------------------------------
// UI wiring
// ---------------------------------------------------------------------
/** Get the currently selected release <option> element. */
function currentOption() {
const sel = document.getElementById('ver');
return sel.options[sel.selectedIndex];
}
/** Get the selected UI mode ("basic" or "advanced") from its radio group. */
function selectedUiMode() {
const checked = document.querySelector('input[name="uimode"]:checked');
return checked ? checked.value : DEFAULT_UI_MODE;
}
/** Get the selected firmware variant id from the variant radio group. */
function selectedVariant() {
const checked = document.querySelector('input[name="version"]:checked');
return checked ? checked.value : 'normal';
}
/**
* Get the selected flash-size id from the flash-size radio group. In
* Basic mode the row is hidden and this always returns the default
* (4MB) regardless of the hidden radio's actual state - every declared
* flash size is reachable via client-side re-flagging (see
* getPatchedBootloaderUrl), so 4MB is always a valid choice for every
* chip/variant.
*/
function selectedFlashSize() {
if (selectedUiMode() === 'basic') return DEFAULT_FLASH_SIZE;
const checked = document.querySelector('input[name="flashsize"]:checked');
return checked ? checked.value : DEFAULT_FLASH_SIZE;
}
/** Get the selected memory (PSRAM) type id from its radio group. Forced to "standard" in Basic mode - see selectedFlashSize. */
function selectedMemoryType() {
if (selectedUiMode() === 'basic') return DEFAULT_MEMORY_TYPE;
const checked = document.querySelector('input[name="memorytype"]:checked');
return checked ? checked.value : DEFAULT_MEMORY_TYPE;
}
/** Get the selected flash-mode id from its radio group. Forced to "default" in Basic mode - see selectedFlashSize. */
function selectedFlashMode() {
if (selectedUiMode() === 'basic') return DEFAULT_FLASH_MODE;
const checked = document.querySelector('input[name="flashmode"]:checked');
return checked ? checked.value : DEFAULT_FLASH_MODE;
}
/** Get the selected HUB75 layout id from the layout radio group. */
function selectedLayout() {
const checked = document.querySelector('input[name="layout"]:checked');
return checked ? checked.value : DEFAULT_HUB75_LAYOUT;
}
/** Enable/disable + show/hide the variant radio buttons for the current release. */
function updateVariantAvailability(opt) {
VARIANT_IDS.forEach(function (id) {
const input = document.getElementById(id);
const label = document.getElementById(id + '_label');
const available = !!(opt._manifests && opt._manifests[id]);
input.disabled = !available;
label.classList.toggle('disabled__label', !available);
label.classList.toggle('radio__label', available);
});
}
/**
* Enable/disable + show/hide the flash-size radio buttons, and hide the
* whole row if irrelevant. The buttons themselves only ever show the
* size (e.g. "8MB") - which chips that size applies to is rendered in
* the small chart below instead, so the buttons stay a fixed, compact
* width regardless of how many chips share a size.
*/
function updateFlashSizeAvailability(opt, variantName) {
const row = document.getElementById('flashSizeRow');
const info = opt._flashAvailability ? opt._flashAvailability[variantName] : null;
if (!info || !info.hasAxis) {
row.hidden = true;
return;
}
row.hidden = false;
FLASH_SIZES.forEach(function (fs) {
const input = document.getElementById('fs_' + fs.id);
const label = document.getElementById('fs_' + fs.id + '_label');
const chartRow = document.getElementById('fsChart_' + fs.id);
const chartChips = document.getElementById('fsChart_' + fs.id + '_chips');
const chips = info.chipsBySize[fs.id];
const available = chips.length > 0;
input.disabled = !available;
label.classList.toggle('disabled__label', !available);
label.classList.toggle('radio__label', available);
label.textContent = fs.label;
chartRow.hidden = !available;
chartChips.textContent = chips.join(', ');
});
// If the currently checked size is no longer available, fall back to the
// default, or to whichever size is available if even that is missing.
const checkedInput = document.getElementById('fs_' + selectedFlashSize());
if (checkedInput.disabled) {