@sensebox/flash-tool 0.1.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -190,12 +190,17 @@ var DEFAULT_FLASH_OPTIONS = {
190
190
  eraseAll: false,
191
191
  usingUsbOtg: true
192
192
  };
193
+ var KNOWN_USB_DEVICES = {
194
+ "303a:81b8": "senseBox MCU-S2 (ESP32-S2)",
195
+ "303a:2": "senseBox MCU-S2 (ESP32-S2 bootloader)"
196
+ };
193
197
  function labelForPort(port) {
194
198
  var _a;
195
199
  const info = _nullishCoalesce(((_a = port.getInfo) == null ? void 0 : _a.call(port)), () => ( {}));
196
200
  const vendor = info.usbVendorId ? info.usbVendorId.toString(16).padStart(4, "0") : "????";
197
201
  const product = info.usbProductId ? info.usbProductId.toString(16).padStart(4, "0") : "????";
198
- return `USB device (VID 0x${vendor} / PID 0x${product})`;
202
+ const known = info.usbVendorId !== void 0 && info.usbProductId !== void 0 ? KNOWN_USB_DEVICES[`${info.usbVendorId.toString(16)}:${info.usbProductId.toString(16)}`] : void 0;
203
+ return known || `USB device (VID 0x${vendor} / PID 0x${product})`;
199
204
  }
200
205
  function useFlashDevice({
201
206
  usbVendorId = DEFAULT_USB_VENDOR_ID,
@@ -222,10 +227,17 @@ function useFlashDevice({
222
227
  filters: [{ usbVendorId }]
223
228
  });
224
229
  setPort(selectedPort);
225
- setBootloaderPort(null);
226
230
  setNeedsBootloaderPermission(false);
227
231
  const label = labelForPort(selectedPort);
228
232
  setDeviceLabel(label);
233
+ if (isRomBootloaderPort(selectedPort, usbVendorId)) {
234
+ setBootloaderPort(selectedPort);
235
+ appendLog(
236
+ "Board is already in bootloader mode - skipping reset.\n"
237
+ );
238
+ } else {
239
+ setBootloaderPort(null);
240
+ }
229
241
  return selectedPort;
230
242
  } catch (err) {
231
243
  if ((err == null ? void 0 : err.name) !== "NotFoundError") {
@@ -233,7 +245,7 @@ function useFlashDevice({
233
245
  }
234
246
  return null;
235
247
  }
236
- }, [usbVendorId]);
248
+ }, [usbVendorId, appendLog]);
237
249
  const disconnect = _react.useCallback.call(void 0, () => {
238
250
  setPort(null);
239
251
  setBootloaderPort(null);
@@ -391,7 +403,7 @@ function useFlashDevice({
391
403
  var LOADERS = {
392
404
  circuitpy: () => Promise.resolve().then(() => _interopRequireWildcard(require("./circuitpy-YPOEPYDE.cjs"))),
393
405
  ota: () => Promise.resolve().then(() => _interopRequireWildcard(require("./ota-FYGBPZA7.cjs"))),
394
- basic: () => Promise.resolve().then(() => _interopRequireWildcard(require("./basic-O4U74F7T.cjs"))),
406
+ basic: () => Promise.resolve().then(() => _interopRequireWildcard(require("./basic-PDQS3NQS.cjs"))),
395
407
  standard: () => Promise.resolve().then(() => _interopRequireWildcard(require("./standard-N65QFGWL.cjs")))
396
408
  };
397
409
  var BUILTIN_FIRMWARE_TYPES = Object.keys(LOADERS);
@@ -410,15 +422,263 @@ async function loadBuiltinFirmware(type) {
410
422
  return base64ToBytes(module.default);
411
423
  }
412
424
 
425
+ // src/react/Dropdown.jsx
426
+
427
+
428
+ function Dropdown({
429
+ options,
430
+ value,
431
+ onChange,
432
+ disabled = false,
433
+ placeholder,
434
+ className,
435
+ "aria-label": ariaLabel
436
+ }) {
437
+ const [open, setOpen] = _react.useState.call(void 0, false);
438
+ const [activeIndex, setActiveIndex] = _react.useState.call(void 0, -1);
439
+ const rootRef = _react.useRef.call(void 0, null);
440
+ const triggerRef = _react.useRef.call(void 0, null);
441
+ const optionRefs = _react.useRef.call(void 0, []);
442
+ const selectedIndex = options.findIndex((option) => option.value === value);
443
+ const selected = selectedIndex >= 0 ? options[selectedIndex] : null;
444
+ _react.useEffect.call(void 0, () => {
445
+ if (disabled && open) setOpen(false);
446
+ }, [disabled, open]);
447
+ _react.useEffect.call(void 0, () => {
448
+ if (!open) return;
449
+ const onDocPointerDown = (event) => {
450
+ if (rootRef.current && !rootRef.current.contains(event.target)) {
451
+ setOpen(false);
452
+ }
453
+ };
454
+ document.addEventListener("pointerdown", onDocPointerDown);
455
+ return () => document.removeEventListener("pointerdown", onDocPointerDown);
456
+ }, [open]);
457
+ _react.useEffect.call(void 0, () => {
458
+ if (!open) return;
459
+ setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0);
460
+ }, [open, selectedIndex]);
461
+ _react.useEffect.call(void 0, () => {
462
+ var _a;
463
+ if (!open || activeIndex < 0) return;
464
+ (_a = optionRefs.current[activeIndex]) == null ? void 0 : _a.focus();
465
+ }, [open, activeIndex]);
466
+ const commit = (index) => {
467
+ var _a;
468
+ const option = options[index];
469
+ if (!option || option.disabled) return;
470
+ onChange == null ? void 0 : onChange(option.value);
471
+ setOpen(false);
472
+ (_a = triggerRef.current) == null ? void 0 : _a.focus();
473
+ };
474
+ const handleTriggerKeyDown = (event) => {
475
+ if (disabled) return;
476
+ if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown" || event.key === "ArrowUp") {
477
+ event.preventDefault();
478
+ setOpen(true);
479
+ }
480
+ };
481
+ const moveActive = (delta) => {
482
+ setActiveIndex((current) => {
483
+ const count = options.length;
484
+ let next = current;
485
+ for (let i = 0; i < count; i++) {
486
+ next = (next + delta + count) % count;
487
+ if (!options[next].disabled) return next;
488
+ }
489
+ return current;
490
+ });
491
+ };
492
+ const handleListKeyDown = (event) => {
493
+ var _a;
494
+ switch (event.key) {
495
+ case "ArrowDown":
496
+ event.preventDefault();
497
+ moveActive(1);
498
+ break;
499
+ case "ArrowUp":
500
+ event.preventDefault();
501
+ moveActive(-1);
502
+ break;
503
+ case "Home":
504
+ event.preventDefault();
505
+ setActiveIndex(0);
506
+ break;
507
+ case "End":
508
+ event.preventDefault();
509
+ setActiveIndex(options.length - 1);
510
+ break;
511
+ case "Enter":
512
+ case " ":
513
+ event.preventDefault();
514
+ commit(activeIndex);
515
+ break;
516
+ case "Escape":
517
+ event.preventDefault();
518
+ setOpen(false);
519
+ (_a = triggerRef.current) == null ? void 0 : _a.focus();
520
+ break;
521
+ case "Tab":
522
+ setOpen(false);
523
+ break;
524
+ default:
525
+ break;
526
+ }
527
+ };
528
+ return /* @__PURE__ */ _react2.default.createElement(
529
+ "div",
530
+ {
531
+ ref: rootRef,
532
+ className: `sbdropdown${open ? " sbdropdown--open" : ""}${disabled ? " sbdropdown--disabled" : ""}${className ? ` ${className}` : ""}`
533
+ },
534
+ /* @__PURE__ */ _react2.default.createElement(
535
+ "button",
536
+ {
537
+ ref: triggerRef,
538
+ type: "button",
539
+ className: "sbdropdown__trigger",
540
+ "aria-haspopup": "listbox",
541
+ "aria-expanded": open,
542
+ "aria-label": ariaLabel,
543
+ disabled,
544
+ onClick: () => setOpen((v) => !v),
545
+ onKeyDown: handleTriggerKeyDown
546
+ },
547
+ /* @__PURE__ */ _react2.default.createElement("span", { className: "sbdropdown__icon-slot" }, selected == null ? void 0 : selected.icon),
548
+ /* @__PURE__ */ _react2.default.createElement("span", { className: "sbdropdown__trigger-label" }, (selected == null ? void 0 : selected.label) || placeholder),
549
+ /* @__PURE__ */ _react2.default.createElement(IconChevron, { className: "sbdropdown__chevron" })
550
+ ),
551
+ /* @__PURE__ */ _react2.default.createElement(
552
+ "ul",
553
+ {
554
+ className: "sbdropdown__menu",
555
+ role: "listbox",
556
+ "aria-label": ariaLabel,
557
+ onKeyDown: handleListKeyDown
558
+ },
559
+ options.map((option, index) => /* @__PURE__ */ _react2.default.createElement("li", { key: option.value, role: "presentation" }, /* @__PURE__ */ _react2.default.createElement(
560
+ "button",
561
+ {
562
+ ref: (el) => {
563
+ optionRefs.current[index] = el;
564
+ },
565
+ type: "button",
566
+ role: "option",
567
+ "aria-selected": option.value === value,
568
+ tabIndex: -1,
569
+ className: `sbdropdown__option${option.value === value ? " sbdropdown__option--selected" : ""}${index === activeIndex ? " sbdropdown__option--active" : ""}`,
570
+ disabled: option.disabled,
571
+ onClick: () => commit(index),
572
+ onMouseEnter: () => setActiveIndex(index)
573
+ },
574
+ /* @__PURE__ */ _react2.default.createElement("span", { className: "sbdropdown__icon-slot" }, option.icon),
575
+ /* @__PURE__ */ _react2.default.createElement("span", { className: "sbdropdown__option-label" }, option.label),
576
+ option.value === value && /* @__PURE__ */ _react2.default.createElement(IconCheck, { className: "sbdropdown__option-check" })
577
+ )))
578
+ )
579
+ );
580
+ }
581
+ function IconChevron({ className }) {
582
+ return /* @__PURE__ */ _react2.default.createElement(
583
+ "svg",
584
+ {
585
+ className,
586
+ viewBox: "0 0 10 6",
587
+ width: "10",
588
+ height: "6",
589
+ fill: "none",
590
+ stroke: "currentColor",
591
+ strokeWidth: "1.5",
592
+ strokeLinecap: "round",
593
+ strokeLinejoin: "round",
594
+ "aria-hidden": "true"
595
+ },
596
+ /* @__PURE__ */ _react2.default.createElement("path", { d: "M1 1l4 4 4-4" })
597
+ );
598
+ }
599
+ function IconCheck({ className }) {
600
+ return /* @__PURE__ */ _react2.default.createElement(
601
+ "svg",
602
+ {
603
+ className,
604
+ viewBox: "0 0 16 16",
605
+ width: "12",
606
+ height: "12",
607
+ fill: "none",
608
+ stroke: "currentColor",
609
+ strokeWidth: "2",
610
+ strokeLinecap: "round",
611
+ strokeLinejoin: "round",
612
+ "aria-hidden": "true"
613
+ },
614
+ /* @__PURE__ */ _react2.default.createElement("path", { d: "M3 8.5 6.5 12 13 4.5" })
615
+ );
616
+ }
617
+ Dropdown.propTypes = {
618
+ options: _proptypes2.default.arrayOf(
619
+ _proptypes2.default.shape({
620
+ value: _proptypes2.default.string.isRequired,
621
+ label: _proptypes2.default.node.isRequired,
622
+ icon: _proptypes2.default.node,
623
+ disabled: _proptypes2.default.bool
624
+ })
625
+ ).isRequired,
626
+ value: _proptypes2.default.string,
627
+ onChange: _proptypes2.default.func,
628
+ disabled: _proptypes2.default.bool,
629
+ placeholder: _proptypes2.default.string,
630
+ className: _proptypes2.default.string,
631
+ "aria-label": _proptypes2.default.string
632
+ };
633
+
413
634
  // src/react/FlashTool.jsx
414
635
  var builtinFirmwareCache = /* @__PURE__ */ new Map();
415
636
  var FIRMWARE_TYPES = ["circuitpy", "ota", "basic", "standard"];
637
+ var FIRMWARE_ALIASES = {
638
+ circuitpython: "circuitpy"
639
+ };
640
+ function resolveFirmwareType(value) {
641
+ if (!value) return void 0;
642
+ return FIRMWARE_ALIASES[value] || value;
643
+ }
416
644
  var BUILTIN_FIRMWARE_FLASH_OVERRIDES = {
417
645
  circuitpy: { address: 0, eraseAll: true },
418
646
  ota: { address: 0, eraseAll: true },
419
647
  basic: { address: 0, eraseAll: true },
420
648
  standard: { address: 0, eraseAll: true }
421
649
  };
650
+ var DEFAULT_FIRMWARE_DESCRIPTIONS = {
651
+ circuitpy: {
652
+ text: "Install CircuitPython on your senseBox now! For a more detailed tutorial, check out our",
653
+ linkText: "docs",
654
+ linkUrl: "https://docs.sensebox.de/docs/editors/circuitpython/circuitpython_esp32/?board=edus2"
655
+ },
656
+ ota: {
657
+ text: "Flashes the senseBox OTA firmware, which lets you update your senseBox wirelessly afterwards."
658
+ },
659
+ basic: {
660
+ text: "A minimal starter sketch - a good starting point for your own senseBox projects."
661
+ },
662
+ standard: {
663
+ text: "The TinyUF2 bootloader, ready to read out and upload data from your connected sensors."
664
+ }
665
+ };
666
+ var DEFAULT_FIRMWARE_DESCRIPTIONS_DE = {
667
+ circuitpy: {
668
+ text: "Installiere jetzt CircuitPython auf deiner senseBox! Ein ausf\xFChrliches Tutorial findest du in unserer",
669
+ linkText: "Dokumentation",
670
+ linkUrl: "https://docs.sensebox.de/docs/editors/circuitpython/circuitpython_esp32/?board=edus2"
671
+ },
672
+ ota: {
673
+ text: "Spielt die senseBox-OTA-Firmware auf, mit der du deine senseBox anschlie\xDFend drahtlos aktualisieren kannst."
674
+ },
675
+ basic: {
676
+ text: "Ein minimales Starter-Sketch - ein guter Ausgangspunkt f\xFCr eigene senseBox-Projekte."
677
+ },
678
+ standard: {
679
+ text: "Das Standard-senseBox-Sketch, bereit zum Auslesen und Hochladen von Daten deiner angeschlossenen Sensoren."
680
+ }
681
+ };
422
682
  var DEFAULT_STRINGS = {
423
683
  unsupported: "Web Serial is not supported in this browser.",
424
684
  connect: "Connect board",
@@ -432,16 +692,26 @@ var DEFAULT_STRINGS = {
432
692
  flash: "Upload",
433
693
  flashing: "Uploading...",
434
694
  done: "Upload complete",
695
+ newUpload: "Start new upload",
435
696
  error: "Upload failed",
436
697
  retry: "Retry",
437
698
  disconnect: "Disconnect",
438
699
  showLog: "Show log",
439
700
  hideLog: "Hide log",
701
+ logPlaceholder: 'Click "Connect board" to see the log here.',
440
702
  sketchLabel: "Sketch",
703
+ step1Title: "Connect your board",
704
+ step1Hint: "Plug in your senseBox, then pick it from the browser's device list.",
705
+ step2Title: "Enter bootloader mode",
706
+ step2Hint: "Put the board into upload mode.",
707
+ step2HintPermission: "Select the bootloader port the browser is asking for.",
708
+ step2Done: "Bootloader ready",
709
+ step3Title: "Upload firmware",
710
+ step3Hint: "Waiting for the previous steps to finish.",
441
711
  firmwareCircuitpy: "CircuitPython",
442
712
  firmwareOta: "senseBox OTA",
443
- firmwareBasic: "Basic",
444
- firmwareStandard: "Standard senseBox Sketch",
713
+ firmwareBasic: "senseBox:basic",
714
+ firmwareStandard: "TinyUF2",
445
715
  help: "Help",
446
716
  helpTitle: "How it works",
447
717
  helpStep1: "Connect your senseBox",
@@ -450,15 +720,60 @@ var DEFAULT_STRINGS = {
450
720
  helpStep4: "Start the upload",
451
721
  helpClose: "Close"
452
722
  };
723
+ var DEFAULT_STRINGS_DE = {
724
+ unsupported: "Web Serial wird von diesem Browser nicht unterst\xFCtzt.",
725
+ connect: "Board verbinden",
726
+ connecting: "Verbinde...",
727
+ connected: "senseBox verbunden",
728
+ prepare: "Upload vorbereiten",
729
+ preparing: "Bereite vor...",
730
+ grantPermission: "Bootloader-Port ausw\xE4hlen",
731
+ waitingForBinary: "Warte auf Firmware...",
732
+ loadingFirmware: "Firmware wird geladen...",
733
+ flash: "Hochladen",
734
+ flashing: "Wird hochgeladen...",
735
+ done: "Upload abgeschlossen",
736
+ newUpload: "Neuen Upload starten",
737
+ error: "Upload fehlgeschlagen",
738
+ retry: "Erneut versuchen",
739
+ disconnect: "Trennen",
740
+ showLog: "Protokoll anzeigen",
741
+ hideLog: "Protokoll ausblenden",
742
+ logPlaceholder: 'Klicke auf "Board verbinden", um das Protokoll hier zu sehen.',
743
+ sketchLabel: "Sketch",
744
+ step1Title: "Board verbinden",
745
+ step1Hint: "Schlie\xDFe deine senseBox an und w\xE4hle sie aus der Ger\xE4teliste des Browsers aus.",
746
+ step2Title: "Bootloader-Modus aktivieren",
747
+ step2Hint: "Versetze das Board in den Upload-Modus.",
748
+ step2HintPermission: "W\xE4hle den Bootloader-Port aus, nach dem der Browser fragt.",
749
+ step2Done: "Bootloader bereit",
750
+ step3Title: "Firmware hochladen",
751
+ step3Hint: "Warte auf die vorherigen Schritte.",
752
+ firmwareCircuitpy: "CircuitPython",
753
+ firmwareOta: "senseBox OTA",
754
+ firmwareBasic: "senseBox:basic",
755
+ firmwareStandard: "TinyUF2",
756
+ help: "Hilfe",
757
+ helpTitle: "So funktioniert's",
758
+ helpStep1: "Verbinde deine senseBox",
759
+ helpStep2: "W\xE4hle einen Sketch aus",
760
+ helpStep3: "Erteile deinem Browser die Berechtigung",
761
+ helpStep4: "Starte den Upload",
762
+ helpClose: "Schlie\xDFen"
763
+ };
453
764
  function FlashTool({
454
765
  binary,
455
766
  firmwares,
767
+ firmware,
456
768
  firmwareType,
457
769
  defaultFirmwareType,
458
770
  firmwareFlashOptions,
771
+ firmwareDescriptions,
459
772
  usbVendorId,
460
773
  flashOptions,
461
774
  autoFlash = true,
775
+ expand = false,
776
+ language = "en",
462
777
  strings,
463
778
  className,
464
779
  onConnected,
@@ -467,18 +782,25 @@ function FlashTool({
467
782
  onError,
468
783
  onFirmwareTypeChange
469
784
  }) {
470
- const merged = { ...DEFAULT_STRINGS, ...strings };
471
- const [logOpen, setLogOpen] = _react2.default.useState(false);
785
+ const merged = {
786
+ ...language === "de" ? DEFAULT_STRINGS_DE : DEFAULT_STRINGS,
787
+ ...strings
788
+ };
789
+ const [logOpen, setLogOpen] = _react2.default.useState(expand);
472
790
  const [helpOpen, setHelpOpen] = _react2.default.useState(false);
473
791
  const [busy, setBusy] = _react2.default.useState(null);
792
+ const lockedFirmwareType = resolveFirmwareType(firmwareType || firmware);
474
793
  const [selectedType, setSelectedType] = _react2.default.useState(
475
- firmwareType || defaultFirmwareType || FIRMWARE_TYPES[0]
794
+ lockedFirmwareType || defaultFirmwareType || FIRMWARE_TYPES[0]
476
795
  );
477
- const firmwareLocked = Boolean(firmwareType);
796
+ const firmwareLocked = Boolean(lockedFirmwareType);
478
797
  const showFirmwarePicker = Boolean(firmwares) || !binary || firmwareLocked;
479
798
  _react.useEffect.call(void 0, () => {
480
- if (firmwareType) setSelectedType(firmwareType);
481
- }, [firmwareType]);
799
+ if (lockedFirmwareType) setSelectedType(lockedFirmwareType);
800
+ }, [lockedFirmwareType]);
801
+ _react.useEffect.call(void 0, () => {
802
+ if (expand) setLogOpen(true);
803
+ }, [expand]);
482
804
  _react.useEffect.call(void 0, () => {
483
805
  if (!helpOpen) return;
484
806
  const onKeyDown = (event) => {
@@ -552,16 +874,30 @@ function FlashTool({
552
874
  const latestLogLine = _react2.default.useMemo(() => {
553
875
  if (!log) return "";
554
876
  const lines = log.split("\n").filter((line) => line.trim().length > 0);
555
- return lines[lines.length - 1] || "";
877
+ const lastLine = lines[lines.length - 1] || "";
878
+ return lastLine.replace(/\s*\(\d+ ?%\)\s*$/, "");
556
879
  }, [log]);
557
- const handleFirmwareChange = (event) => {
558
- const value = event.target.value;
880
+ const activeFirmwareDescription = showFirmwarePicker ? {
881
+ ...language === "de" ? DEFAULT_FIRMWARE_DESCRIPTIONS_DE[selectedType] : DEFAULT_FIRMWARE_DESCRIPTIONS[selectedType],
882
+ ...firmwareDescriptions == null ? void 0 : firmwareDescriptions[selectedType]
883
+ } : null;
884
+ const handleFirmwareChange = (value) => {
559
885
  setSelectedType(value);
560
886
  onFirmwareTypeChange == null ? void 0 : onFirmwareTypeChange(value);
561
887
  };
888
+ const firmwareOptions = FIRMWARE_TYPES.map((type) => ({
889
+ value: type,
890
+ label: merged[`firmware${type.charAt(0).toUpperCase()}${type.slice(1)}`],
891
+ icon: FIRMWARE_ICONS[type]
892
+ }));
562
893
  const handleRetryFirmwareLoad = () => {
563
894
  setBuiltinRetryToken((token) => token + 1);
564
895
  };
896
+ const logRef = _react.useRef.call(void 0, null);
897
+ _react.useEffect.call(void 0, () => {
898
+ if (!logOpen || !logRef.current) return;
899
+ logRef.current.scrollTop = logRef.current.scrollHeight;
900
+ }, [log, logOpen]);
565
901
  const prevConnected = _react.useRef.call(void 0, connected);
566
902
  _react.useEffect.call(void 0, () => {
567
903
  if (connected && !prevConnected.current) onConnected == null ? void 0 : onConnected(deviceLabel);
@@ -621,24 +957,20 @@ function FlashTool({
621
957
  resetFlashState();
622
958
  if (resolvedBinary) flash(resolvedBinary, flashOverrides);
623
959
  };
624
- return /* @__PURE__ */ _react2.default.createElement("div", { className: `sbflash ${className || ""}` }, /* @__PURE__ */ _react2.default.createElement("div", { className: "sbflash__row" }, /* @__PURE__ */ _react2.default.createElement(
625
- "span",
626
- {
627
- className: `sbflash__dot sbflash__dot--${status === "error" ? "error" : status === "flashing" ? "busy" : connected ? "ok" : "off"}`
628
- }
629
- ), /* @__PURE__ */ _react2.default.createElement(
630
- "span",
631
- {
632
- className: "sbflash__label",
633
- title: connected ? deviceLabel : void 0
634
- },
635
- connected ? merged.connected : merged.connect
636
- )), /* @__PURE__ */ _react2.default.createElement("div", { className: "sbflash__field" }, /* @__PURE__ */ _react2.default.createElement("div", { className: "sbflash__field-head" }, /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__field-label" }, merged.sketchLabel), /* @__PURE__ */ _react2.default.createElement("div", { className: "sbflash__icon-row" }, log && /* @__PURE__ */ _react2.default.createElement(
960
+ const flashComplete = status === "done";
961
+ const step1Status = flashComplete ? "done" : connected ? "done" : "active";
962
+ const step2Status = flashComplete ? "done" : !connected ? "pending" : bootloaderReady ? "done" : "active";
963
+ const step3Status = flashComplete ? "done" : !bootloaderReady ? "pending" : status === "error" ? "error" : "active";
964
+ const handleStartNew = () => {
965
+ resetFlashState();
966
+ };
967
+ return /* @__PURE__ */ _react2.default.createElement("div", { className: `sbflash ${className || ""}` }, /* @__PURE__ */ _react2.default.createElement("div", { className: "sbflash__field" }, /* @__PURE__ */ _react2.default.createElement("div", { className: "sbflash__field-head" }, /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__field-label" }, merged.sketchLabel), /* @__PURE__ */ _react2.default.createElement("div", { className: "sbflash__icon-row" }, /* @__PURE__ */ _react2.default.createElement(
637
968
  "button",
638
969
  {
639
970
  type: "button",
640
971
  className: `sbflash__icon-button${logOpen ? " sbflash__icon-button--active" : ""}`,
641
- onClick: () => setLogOpen((v) => !v),
972
+ onClick: () => !expand && setLogOpen((v) => !v),
973
+ disabled: expand,
642
974
  "aria-pressed": logOpen,
643
975
  "aria-label": logOpen ? merged.hideLog : merged.showLog,
644
976
  title: logOpen ? merged.hideLog : merged.showLog
@@ -664,16 +996,16 @@ function FlashTool({
664
996
  title: merged.help
665
997
  },
666
998
  /* @__PURE__ */ _react2.default.createElement(IconHelp, null)
667
- ))), showFirmwarePicker && /* @__PURE__ */ _react2.default.createElement("div", { className: "sbflash__select-wrap" }, /* @__PURE__ */ _react2.default.createElement(
668
- "select",
999
+ ))), showFirmwarePicker && /* @__PURE__ */ _react2.default.createElement(
1000
+ Dropdown,
669
1001
  {
670
- className: "sbflash__select",
1002
+ options: firmwareOptions,
671
1003
  value: selectedType,
672
1004
  onChange: handleFirmwareChange,
673
- disabled: firmwareLocked || status === "flashing"
674
- },
675
- FIRMWARE_TYPES.map((type) => /* @__PURE__ */ _react2.default.createElement("option", { key: type, value: type }, merged[`firmware${type.charAt(0).toUpperCase()}${type.slice(1)}`]))
676
- )), builtinError && /* @__PURE__ */ _react2.default.createElement("div", { className: "sbflash__field-error-row" }, /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__field-error" }, builtinError), /* @__PURE__ */ _react2.default.createElement(
1005
+ disabled: firmwareLocked || status === "flashing",
1006
+ "aria-label": merged.sketchLabel
1007
+ }
1008
+ ), builtinError && /* @__PURE__ */ _react2.default.createElement("div", { className: "sbflash__field-error-row" }, /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__field-error" }, builtinError), /* @__PURE__ */ _react2.default.createElement(
677
1009
  "button",
678
1010
  {
679
1011
  type: "button",
@@ -681,7 +1013,15 @@ function FlashTool({
681
1013
  onClick: handleRetryFirmwareLoad
682
1014
  },
683
1015
  merged.retry
684
- ))), helpOpen && _reactdom.createPortal.call(void 0,
1016
+ )), (activeFirmwareDescription == null ? void 0 : activeFirmwareDescription.text) && /* @__PURE__ */ _react2.default.createElement("p", { className: "sbflash__field-description" }, activeFirmwareDescription.text, activeFirmwareDescription.linkUrl && /* @__PURE__ */ _react2.default.createElement(_react2.default.Fragment, null, " ", /* @__PURE__ */ _react2.default.createElement(
1017
+ "a",
1018
+ {
1019
+ href: activeFirmwareDescription.linkUrl,
1020
+ target: "_blank",
1021
+ rel: "noopener noreferrer"
1022
+ },
1023
+ activeFirmwareDescription.linkText || activeFirmwareDescription.linkUrl
1024
+ )))), helpOpen && _reactdom.createPortal.call(void 0,
685
1025
  /* @__PURE__ */ _react2.default.createElement(
686
1026
  "div",
687
1027
  {
@@ -716,7 +1056,12 @@ function FlashTool({
716
1056
  )
717
1057
  ),
718
1058
  document.body
719
- ), !connected && /* @__PURE__ */ _react2.default.createElement(
1059
+ ), /* @__PURE__ */ _react2.default.createElement("ol", { className: "sbflash__stepper" }, /* @__PURE__ */ _react2.default.createElement("li", { className: `sbflash__step sbflash__step--${step1Status}` }, /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__step-marker" }, /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__step-index" }, step1Status === "done" ? /* @__PURE__ */ _react2.default.createElement(IconCheck2, null) : 1), /* @__PURE__ */ _react2.default.createElement(
1060
+ "span",
1061
+ {
1062
+ className: `sbflash__step-line${step1Status === "done" ? " sbflash__step-line--done" : ""}`
1063
+ }
1064
+ )), /* @__PURE__ */ _react2.default.createElement("div", { className: "sbflash__step-body" }, /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__step-title" }, merged.step1Title), !connected && !flashComplete ? /* @__PURE__ */ _react2.default.createElement(_react2.default.Fragment, null, /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__step-hint" }, merged.step1Hint), /* @__PURE__ */ _react2.default.createElement(
720
1065
  "button",
721
1066
  {
722
1067
  type: "button",
@@ -725,7 +1070,12 @@ function FlashTool({
725
1070
  disabled: busy === "connect"
726
1071
  },
727
1072
  busy === "connect" ? merged.connecting : merged.connect
728
- ), connected && needsBootloaderPermission && /* @__PURE__ */ _react2.default.createElement(
1073
+ )) : /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__step-detail", title: deviceLabel }, deviceLabel || merged.connected))), /* @__PURE__ */ _react2.default.createElement("li", { className: `sbflash__step sbflash__step--${step2Status}` }, /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__step-marker" }, /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__step-index" }, step2Status === "done" ? /* @__PURE__ */ _react2.default.createElement(IconCheck2, null) : 2), /* @__PURE__ */ _react2.default.createElement(
1074
+ "span",
1075
+ {
1076
+ className: `sbflash__step-line${step2Status === "done" ? " sbflash__step-line--done" : ""}`
1077
+ }
1078
+ )), /* @__PURE__ */ _react2.default.createElement("div", { className: "sbflash__step-body" }, /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__step-title" }, merged.step2Title), step2Status === "active" && needsBootloaderPermission && /* @__PURE__ */ _react2.default.createElement(_react2.default.Fragment, null, /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__step-hint" }, merged.step2HintPermission), /* @__PURE__ */ _react2.default.createElement(
729
1079
  "button",
730
1080
  {
731
1081
  type: "button",
@@ -734,16 +1084,7 @@ function FlashTool({
734
1084
  disabled: busy === "grant"
735
1085
  },
736
1086
  merged.grantPermission
737
- ), connected && !needsBootloaderPermission && !bootloaderReady && /* @__PURE__ */ _react2.default.createElement(
738
- "button",
739
- {
740
- type: "button",
741
- className: "sbflash__button sbflash__button--secondary",
742
- onClick: handlePrepare,
743
- disabled: busy === "prepare" || status === "flashing"
744
- },
745
- busy === "prepare" ? merged.preparing : merged.prepare
746
- ), connected && bootloaderReady && status !== "flashing" && status !== "error" && /* @__PURE__ */ _react2.default.createElement(
1087
+ )), step2Status === "active" && !needsBootloaderPermission && /* @__PURE__ */ _react2.default.createElement(_react2.default.Fragment, null, /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__step-hint" }, merged.step2Hint)), step2Status === "done" && /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__step-detail" }, merged.step2Done), step2Status === "pending" && /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__step-hint" }, merged.step2Hint))), /* @__PURE__ */ _react2.default.createElement("li", { className: `sbflash__step sbflash__step--${step3Status}` }, /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__step-marker" }, /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__step-index" }, step3Status === "done" ? /* @__PURE__ */ _react2.default.createElement(IconCheck2, null) : 3)), /* @__PURE__ */ _react2.default.createElement("div", { className: "sbflash__step-body" }, /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__step-title" }, merged.step3Title), step3Status === "pending" && /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__step-hint" }, merged.step3Hint), step3Status === "active" && status !== "flashing" && /* @__PURE__ */ _react2.default.createElement(
747
1088
  "button",
748
1089
  {
749
1090
  type: "button",
@@ -758,7 +1099,15 @@ function FlashTool({
758
1099
  className: "sbflash__progress-fill",
759
1100
  style: { width: `${Math.min(100, progress)}%` }
760
1101
  }
761
- )), /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__progress-value" }, progress, "%")), latestLogLine && /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__progress-detail" }, latestLogLine)) : /* @__PURE__ */ _react2.default.createElement("div", { className: "sbflash__task" }, /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__task-spinner" }), /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__task-label" }, latestLogLine || merged.flashing))), status === "done" && /* @__PURE__ */ _react2.default.createElement("div", { className: "sbflash__status sbflash__status--ok" }, merged.done), status === "error" && /* @__PURE__ */ _react2.default.createElement("div", { className: "sbflash__status sbflash__status--error" }, merged.error, /* @__PURE__ */ _react2.default.createElement(
1102
+ )), /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__progress-value" }, progress, "%")), latestLogLine && /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__progress-detail" }, latestLogLine)) : /* @__PURE__ */ _react2.default.createElement("div", { className: "sbflash__task" }, /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__task-spinner" }), /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__task-label" }, latestLogLine || merged.flashing))), status === "done" && /* @__PURE__ */ _react2.default.createElement("div", { className: "sbflash__done-banner", role: "status" }, /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__done-icon" }, /* @__PURE__ */ _react2.default.createElement(IconCheck2, null)), /* @__PURE__ */ _react2.default.createElement("span", { className: "sbflash__done-text" }, merged.done), /* @__PURE__ */ _react2.default.createElement(
1103
+ "button",
1104
+ {
1105
+ type: "button",
1106
+ className: "sbflash__button sbflash__button--secondary",
1107
+ onClick: handleStartNew
1108
+ },
1109
+ merged.newUpload
1110
+ )), status === "error" && /* @__PURE__ */ _react2.default.createElement("div", { className: "sbflash__status sbflash__status--error" }, merged.error, /* @__PURE__ */ _react2.default.createElement(
762
1111
  "button",
763
1112
  {
764
1113
  type: "button",
@@ -766,7 +1115,7 @@ function FlashTool({
766
1115
  onClick: handleRetry
767
1116
  },
768
1117
  merged.retry
769
- )), log && logOpen && /* @__PURE__ */ _react2.default.createElement("pre", { className: "sbflash__log" }, log));
1118
+ ))))), logOpen && /* @__PURE__ */ _react2.default.createElement("pre", { className: "sbflash__log", ref: logRef }, log || merged.logPlaceholder));
770
1119
  }
771
1120
  function IconTerminal() {
772
1121
  return /* @__PURE__ */ _react2.default.createElement(
@@ -787,6 +1136,23 @@ function IconTerminal() {
787
1136
  /* @__PURE__ */ _react2.default.createElement("path", { d: "M8.5 11h3" })
788
1137
  );
789
1138
  }
1139
+ function IconCheck2() {
1140
+ return /* @__PURE__ */ _react2.default.createElement(
1141
+ "svg",
1142
+ {
1143
+ viewBox: "0 0 16 16",
1144
+ width: "11",
1145
+ height: "11",
1146
+ fill: "none",
1147
+ stroke: "currentColor",
1148
+ strokeWidth: "2",
1149
+ strokeLinecap: "round",
1150
+ strokeLinejoin: "round",
1151
+ "aria-hidden": "true"
1152
+ },
1153
+ /* @__PURE__ */ _react2.default.createElement("path", { d: "M3 8.5 6.5 12 13 4.5" })
1154
+ );
1155
+ }
790
1156
  function IconDisconnect() {
791
1157
  return /* @__PURE__ */ _react2.default.createElement(
792
1158
  "svg",
@@ -824,6 +1190,85 @@ function IconHelp() {
824
1190
  /* @__PURE__ */ _react2.default.createElement("circle", { cx: "8", cy: "11.5", r: "0.9", fill: "currentColor", stroke: "none" })
825
1191
  );
826
1192
  }
1193
+ function IconChip() {
1194
+ return /* @__PURE__ */ _react2.default.createElement(
1195
+ "svg",
1196
+ {
1197
+ viewBox: "0 0 16 16",
1198
+ width: "14",
1199
+ height: "14",
1200
+ fill: "none",
1201
+ stroke: "currentColor",
1202
+ strokeWidth: "1.4",
1203
+ strokeLinecap: "round",
1204
+ strokeLinejoin: "round",
1205
+ "aria-hidden": "true"
1206
+ },
1207
+ /* @__PURE__ */ _react2.default.createElement("rect", { x: "4", y: "4", width: "8", height: "8", rx: "1.2" }),
1208
+ /* @__PURE__ */ _react2.default.createElement("path", { d: "M6 1.5v2M10 1.5v2M6 12.5v2M10 12.5v2M1.5 6v-.01M1.5 10v-.01M14.5 6v-.01M14.5 10v-.01M1.5 6h2M1.5 10h2M12.5 6h2M12.5 10h2" })
1209
+ );
1210
+ }
1211
+ function IconCloud() {
1212
+ return /* @__PURE__ */ _react2.default.createElement(
1213
+ "svg",
1214
+ {
1215
+ viewBox: "0 0 16 16",
1216
+ width: "14",
1217
+ height: "14",
1218
+ fill: "none",
1219
+ stroke: "currentColor",
1220
+ strokeWidth: "1.4",
1221
+ strokeLinecap: "round",
1222
+ strokeLinejoin: "round",
1223
+ "aria-hidden": "true"
1224
+ },
1225
+ /* @__PURE__ */ _react2.default.createElement("path", { d: "M4.5 12.5A3 3 0 0 1 4 6.53 3.5 3.5 0 0 1 11 5.5a2.75 2.75 0 0 1 .5 5.46" }),
1226
+ /* @__PURE__ */ _react2.default.createElement("path", { d: "M6.5 12.5h5" }),
1227
+ /* @__PURE__ */ _react2.default.createElement("path", { d: "M8 9.5v5M6.25 12.25 8 14.5l1.75-2.25" })
1228
+ );
1229
+ }
1230
+ function IconBolt() {
1231
+ return /* @__PURE__ */ _react2.default.createElement(
1232
+ "svg",
1233
+ {
1234
+ viewBox: "0 0 16 16",
1235
+ width: "14",
1236
+ height: "14",
1237
+ fill: "none",
1238
+ stroke: "currentColor",
1239
+ strokeWidth: "1.4",
1240
+ strokeLinecap: "round",
1241
+ strokeLinejoin: "round",
1242
+ "aria-hidden": "true"
1243
+ },
1244
+ /* @__PURE__ */ _react2.default.createElement("path", { d: "M8.5 1.5 3 9h4l-1 5.5L13 7H9l-0.5-5.5Z" })
1245
+ );
1246
+ }
1247
+ function IconLayers() {
1248
+ return /* @__PURE__ */ _react2.default.createElement(
1249
+ "svg",
1250
+ {
1251
+ viewBox: "0 0 16 16",
1252
+ width: "14",
1253
+ height: "14",
1254
+ fill: "none",
1255
+ stroke: "currentColor",
1256
+ strokeWidth: "1.4",
1257
+ strokeLinecap: "round",
1258
+ strokeLinejoin: "round",
1259
+ "aria-hidden": "true"
1260
+ },
1261
+ /* @__PURE__ */ _react2.default.createElement("path", { d: "M8 1.75 14 5 8 8.25 2 5Z" }),
1262
+ /* @__PURE__ */ _react2.default.createElement("path", { d: "M2 8 8 11.25 14 8" }),
1263
+ /* @__PURE__ */ _react2.default.createElement("path", { d: "M2 11 8 14.25 14 11" })
1264
+ );
1265
+ }
1266
+ var FIRMWARE_ICONS = {
1267
+ circuitpy: /* @__PURE__ */ _react2.default.createElement(IconChip, null),
1268
+ ota: /* @__PURE__ */ _react2.default.createElement(IconCloud, null),
1269
+ basic: /* @__PURE__ */ _react2.default.createElement(IconBolt, null),
1270
+ standard: /* @__PURE__ */ _react2.default.createElement(IconLayers, null)
1271
+ };
827
1272
  FlashTool.propTypes = {
828
1273
  /** Already-compiled firmware image to flash. Ignored if `firmwares` is set. */
829
1274
  binary: _proptypes2.default.instanceOf(Uint8Array),
@@ -834,19 +1279,36 @@ FlashTool.propTypes = {
834
1279
  basic: _proptypes2.default.instanceOf(Uint8Array),
835
1280
  standard: _proptypes2.default.instanceOf(Uint8Array)
836
1281
  }),
1282
+ /** Same as `firmwareType`, but also accepts friendlier aliases (e.g. "circuitpython" for "circuitpy"). */
1283
+ firmware: _proptypes2.default.oneOf([
1284
+ ...FIRMWARE_TYPES,
1285
+ ...Object.keys(FIRMWARE_ALIASES)
1286
+ ]),
837
1287
  /** Locks the sketch dropdown to this value and disables it. One of "circuitpy" | "ota" | "basic" | "standard". */
838
1288
  firmwareType: _proptypes2.default.oneOf(FIRMWARE_TYPES),
839
1289
  /** Initial dropdown selection when `firmwareType` isn't set (dropdown stays interactive). */
840
1290
  defaultFirmwareType: _proptypes2.default.oneOf(FIRMWARE_TYPES),
841
1291
  /** Per-sketch overrides for the esptool flash step, keyed by firmware type. Merges over (and can override) the built-in CircuitPython defaults. */
842
1292
  firmwareFlashOptions: _proptypes2.default.objectOf(_proptypes2.default.object),
1293
+ /** Per-sketch description shown under the sketch picker, keyed by firmware type: { text, linkText, linkUrl }. Merges over (and can override) the built-in defaults. */
1294
+ firmwareDescriptions: _proptypes2.default.objectOf(
1295
+ _proptypes2.default.shape({
1296
+ text: _proptypes2.default.string,
1297
+ linkText: _proptypes2.default.string,
1298
+ linkUrl: _proptypes2.default.string
1299
+ })
1300
+ ),
843
1301
  /** USB vendor id used to filter the device picker. Defaults to senseBox (0x303a). */
844
1302
  usbVendorId: _proptypes2.default.number,
845
1303
  /** Overrides for the esptool flash step (address, baudrate, flashMode, flashFreq, flashSize, eraseAll, usingUsbOtg). */
846
1304
  flashOptions: _proptypes2.default.object,
847
1305
  /** Start flashing automatically once the selected firmware and a connected device are both ready. */
848
1306
  autoFlash: _proptypes2.default.bool,
849
- /** Overrides for the built-in (English) UI strings. */
1307
+ /** Keep the log/terminal view expanded by default and disable the button to collapse it. */
1308
+ expand: _proptypes2.default.bool,
1309
+ /** UI language for the built-in strings. "de" uses the built-in German translation. */
1310
+ language: _proptypes2.default.oneOf(["en", "de"]),
1311
+ /** Overrides for the built-in UI strings (applied on top of `language`). */
850
1312
  strings: _proptypes2.default.object,
851
1313
  className: _proptypes2.default.string,
852
1314
  onConnected: _proptypes2.default.func,