Add USB flash hand-off from ESPHome Device Builder via postMessage#923
Conversation
When opened with a nonce in the URL hash, web.esphome.io receives a compiled firmware image from the opener over postMessage and flashes it through the existing connect and install flow. This lets the Device Builder dashboard, which runs over plain http in the Home Assistant add-on and so cannot use Web Serial itself, flash a USB device by handing the firmware to this secure context tab to tab. The frame is validated by window source and a one time nonce; the firmware never touches a server.
There was a problem hiding this comment.
Pull request overview
Adds a new “flash hand-off” flow to ESPHome Web so ESPHome Device Builder (running on insecure HTTP, e.g. HA add-on) can compile firmware and then open web.esphome.io to perform USB flashing in a secure context via postMessage.
Changes:
- Introduce a new
ew-web-flashLit component that negotiates with the opener via a nonce, receives firmware parts overpostMessage, converts them intoinstall-web’sFileToFlash[]format, and launches the existing install dialog flow. - Update the dashboard to switch into this flash hand-off mode when a
nonceis present in the URL hash.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| web.esphome.io/src/dashboard/ew-web-flash.ts | New flash hand-off UI and postMessage-based firmware receive/install flow. |
| web.esphome.io/src/dashboard/ew-dashboard.ts | Enables a dedicated “flash mode” path that renders ew-web-flash when launched with a nonce. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Mirror install progress and state back to the opener (new optional onProgress and onStateChange callbacks on install-web-dialog) so the Device Builder dashboard tracks the flash, and pass the device name so the flasher window and tab title identify which device it is for. After a successful install keep the port open and show the standard device card. Hardening from review: gate flash mode on window.opener so a stale #nonce link still shows the dashboard; validate each firmware part; coerce erase to a boolean; stop the ready announcements on failure; guard postMessage against a malformed origin; convert the image with TextDecoder; and keep the mirrored error signal when it is a template.
|
|
esphbot
left a comment
There was a problem hiding this comment.
Blocking issues found — see the review comment above.
ew-esp-device-card gained an optional name, so after a Device Builder hand-off the post-flash card shows the device name instead of the generic "ESP Device"; the standard connect flow keeps the default.
Stop pre-opening the serial port; just request it and pass it in. install-web opens it through the ESPLoader, which surfaces the "hold BOOT and retry" guidance on a chip-init failure and offers its own retry, matching the device-builder flasher. A pre-opened port also broke retries because install-web reopens the port in its finally, so the next open() hit an already-open port. openInstallWebDialog now tolerates a port that isn't open yet. A dismissed picker leaves the ready card up so the user can try again. Also mirror the granular phase to the dashboard: report "Erasing" while the install state has no progress yet, then "Installing" on the first write, so the two tabs no longer disagree.
install-web reset the chip with a bare RTS toggle, which left native USB-Serial-JTAG chips (S3/C3) in download mode after writeFlash, so the firmware never booted (rst ... boot DOWNLOAD, waiting for download). Replace it with a reset strategy that boots the app: an RTC-watchdog reset for S2/S3/C2/C3, the USB-JTAG reset for other native-USB chips, and a classic EN pulse with GPIO0 released for UART bridges. Mirrors the device-builder flasher and the device-builder-frontend web-serial reset. The web-flash hand-off no longer reuses the post-flash port for a device card; that handle is unreliable after a native-USB re-enumeration (logs threw on a null readable). It releases the port so the device boots and shows a done state; logs are via a fresh connect.
Two regressions from the new reset path on native USB-Serial-JTAG: The hand-off passed the port to install-web, whose finally reopened it after the reset; reopening re-reset the just-booted chip into a loop. Pass no port so install-web owns request/open and never reopens; the chip boots once and stays up. The hand-off no longer needs the port back (logs are a separate fresh connect). install-web also reported the reset's expected re-enumeration as "Device disconnected", overwriting success. Ignore the disconnect once the flash has completed.
TextDecoder("latin1") is windows-1252 per the Encoding spec, which
remaps bytes 0x80-0x9F, so the converted firmware string no longer round
-trips to the original bytes and the device fails its boot checksum.
Convert byte-for-byte with String.fromCharCode instead, matching
FileReader.readAsBinaryString.
|
Thanks Kōan — this pass ran on
On Later commits also fixed the real on-hardware issues found in testing: the post-flash reset (native USB-Serial-JTAG was landing in download mode), the install-web port reopen that re-reset the chip into a boot loop, and treating the reset's re-enumeration as "Device disconnected". |
Verified against branch HEAD dd866a2. e9149c5 reverted to chunked String.fromCharCode — correct call. TextDecoder('latin1') aliases windows-1252 per the Encoding spec, remaps 0x80–0x9F, corrupts firmware. Register addresses in hard-reset.ts checked against esptool: S2/S3/C2/C3 match. PROTOCOL_VERSION deferral reasonable for v1. All six prior items confirmed fixed. No blocking issues remain. |
|
|
|
Thanks Kōan — both cosmetic suggestions applied:
Also note the |
|
|
esphbot
left a comment
There was a problem hiding this comment.
Blocking issues found — see the review comment above.
|
Thanks Kōan:
|
|
Confirmed: the shared hardResetChip reset is verified on both an ESP32-S3 (native USB-Serial-JTAG) and a classic chip over a UART bridge, so the normal Connect & Install path is covered, not just the hand-off. |
Scope decision sound. Native USB-Serial-JTAG fix belongs on the shared path. One follow-up: |
|
Thanks Kōan — applied in the latest commit: prepare_installation now mirrors as "Preparing…" instead of "Connecting to device…". Checklist's all green; remaining gate is the companion #904 + the production FLASHER_URL flip, not code. |
Flip FLASHER_URL to the production web.esphome.io flasher and update the release note: its postMessage receiver is esphome/dashboard#923, which must be merged and deployed before a backend release bundles this.
|
|
|
Acknowledged — both are low-severity and non-blocking; leaving as-is for now (the watchdogReset early-write guard is a cheap follow-up if we ever see it in practice). |
- watchdogReset only tolerates the final re-lock write racing the reset; a genuine transport error before the WDT is armed now returns false so hardResetChip falls through to UsbJtagSerialReset instead of leaving a native-USB chip stuck in download mode. - ew-web-flash._install wraps openInstallWebDialog so a throw before onClose re-enables the button instead of leaving it disabled.
|
Update: went ahead and fixed both in 3c146da. (1) watchdogReset only tolerates the final re-lock write racing the reset; a transport error before the WDT is armed now returns false so hardResetChip falls through to UsbJtagSerialReset (no silent download-mode). (2) ew-web-flash._install wraps openInstallWebDialog so a throw before onClose re-enables the button instead of leaving it disabled. |
|
|
|
Fixed in 8cc5e27: _fail() now posts { type: 'esphome-web-flash:state', state: 'error', detail } to the opener, so a post-handoff receiver failure (malformed payload / conversion throw) surfaces on the dashboard instead of hanging its install view. It's a no-op when there's no opener (the open-directly error case). The watchdogReset fall-through you noted was already in 3c146da. |
|
|
|
Done in the latest commit: wrapped the config0 expression in |
PR Review — Add USB flash hand-off from ESPHome Device Builder via postMessageSolid, security-conscious hand-off implementation. No blocking issues — remaining items are nits, and the named code-level gates are the companion PR #904 and the FLASHER_URL flip, not this diff. Specific strengths:
Three non-blocking suggestions:
Checklist
Automated review by Kōan (Claude) |
| private _erase = true; | ||
| private _readyTimer?: number; | ||
|
|
||
| connectedCallback(): void { |
There was a problem hiding this comment.
technically, firstUpdated is the correct LitElement way, but in this case we'll never connect more than once, so it's fine.
_onMessage calls _stopReady() before assigning _files, so the interval was always cleared before _files could become truthy; the guard inside the interval was dead code. The interval now just times out or re-announces.
|
thanks |
What
ESPHome Device Builder can build firmware but cannot flash over USB when it runs over plain http (the Home Assistant add-on), because Web Serial needs a secure context. This adds a mode where Device Builder opens web.esphome.io in a new tab and hands it the compiled firmware over postMessage, so the flashing runs here in a secure context.
When the page is opened with a nonce in the URL hash, it announces itself to the opener, accepts the firmware frame only from its opener window and only when the one time nonce matches (the opener origin is arbitrary, so there is no origin allowlist), converts the transferred image to the form install-web expects, and runs the existing connect and install flow. The firmware never touches a server.
Post-flash reset (affects all installs)
This also replaces install-web's bare-RTS
resetSerialDevicewith a reset strategy that boots the app (RTC-watchdog for S2/S3/C2/C3,UsbJtagSerialResetfor native-USB chips, classic EN-pulse with GPIO0 released for UART bridges), mirroring the device-builder-frontend web-serial reset. Native USB-Serial-JTAG chips previously stayed in download mode after writeFlash; this fixes the normal Connect & Install flow too, not just the hand-off. Verified on an ESP32-S3 over USB-Serial-JTAG and on a classic chip over a UART bridge.Notes
Companion change is in esphome/device-builder-frontend#904; tracked in esphome/backlog#151. Opening as a draft while the Device Builder side is finalized.