Skip to content

Add USB flash hand-off from ESPHome Device Builder via postMessage#923

Merged
bdraco merged 18 commits into
mainfrom
web-flash-postmessage
Jun 20, 2026
Merged

Add USB flash hand-off from ESPHome Device Builder via postMessage#923
bdraco merged 18 commits into
mainfrom
web-flash-postmessage

Conversation

@bdraco

@bdraco bdraco commented Jun 19, 2026

Copy link
Copy Markdown
Member

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 resetSerialDevice with a reset strategy that boots the app (RTC-watchdog for S2/S3/C2/C3, UsbJtagSerialReset for 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.

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.
@bdraco bdraco marked this pull request as ready for review June 19, 2026 21:27
@bdraco bdraco requested a review from Copilot June 19, 2026 21:27

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-flash Lit component that negotiates with the opener via a nonce, receives firmware parts over postMessage, converts them into install-web’s FileToFlash[] format, and launches the existing install dialog flow.
  • Update the dashboard to switch into this flash hand-off mode when a nonce is 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.

Comment thread web.esphome.io/src/dashboard/ew-dashboard.ts Outdated
Comment thread web.esphome.io/src/dashboard/ew-web-flash.ts
Comment thread web.esphome.io/src/dashboard/ew-web-flash.ts
Comment thread web.esphome.io/src/dashboard/ew-web-flash.ts Outdated
Comment thread web.esphome.io/src/dashboard/ew-web-flash.ts Outdated
Comment thread web.esphome.io/src/dashboard/ew-web-flash.ts Outdated
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

esphbot commented Jun 19, 2026

Copy link
Copy Markdown

Previous review — superseded by a newer review below.

@esphbot esphbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking issues found — see the review comment above.

bdraco added 4 commits June 19, 2026 16:40
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.
bdraco added 2 commits June 19, 2026 17:07
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.
@bdraco

bdraco commented Jun 19, 2026

Copy link
Copy Markdown
Member Author

Thanks Kōan — this pass ran on 10d5a8e (the initial commit); all of these were addressed in 94489d4 and the branch is now at dd866a2:

On PROTOCOL_VERSION: it's advertised but intentionally not enforced while there's a single version; happy to add a check when v2 lands. The string-concat note I left as String.fromCharCode (a later TextDecoder("latin1") attempt corrupted firmware, since that label is windows-1252 per the Encoding spec).

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".

@esphbot

esphbot commented Jun 19, 2026

Copy link
Copy Markdown

@bdraco: Thanks Kōan — this pass ran on 10d5a8e (the initial commit); all of these were addressed in `94489...

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.

@esphbot

esphbot commented Jun 19, 2026

Copy link
Copy Markdown

Previous review — superseded by a newer review below.

@esphbot esphbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@bdraco

bdraco commented Jun 19, 2026

Copy link
Copy Markdown
Member Author

Thanks Kōan — both cosmetic suggestions applied:

  • Flash mode is now decided in connectedCallback (before the first render), so a real hand-off no longer flickers the normal dashboard for a frame; firstUpdated early-returns in flash mode.
  • Updated the _targetOrigin comment to note it's seeded from the origin hash param when present, otherwise '*' (keeping the no-nonce-outbound reasoning).

Also note the _post origin fallback was just hardened (66049d6) to log rather than swallow non-origin errors and to guard the fallback call, matching the same fix on the device-builder flasher (#1609).

@esphbot

esphbot commented Jun 19, 2026

Copy link
Copy Markdown

Previous review — superseded by a newer review below.

@esphbot esphbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking issues found — see the review comment above.

@bdraco

bdraco commented Jun 19, 2026

Copy link
Copy Markdown
Member Author

Thanks Kōan:

  • Terminal state on timeout (suggestion): fixed in 8edc7c1 — when the 10s announce window elapses with no firmware, the card now flips to an error ("Didn't receive firmware. Return to ESPHome Device Builder and try the USB install again.") instead of spinning forever.

  • hardResetChip on the shared install path (important): intentional, not scope creep. The bare-RTS resetSerialDevice left native USB-Serial-JTAG chips (S3/C3) in download mode after writeFlash on the normal Connect & Install too, so scoping the fix to only the hand-off would leave the main web flasher broken for those chips. The new strategy is the same one esphome/device-builder-frontend's web-serial.ts already uses in production (RTC-WDT for S2/S3/C2/C3, UsbJtagSerialReset for native USB, classicHardReset otherwise), and classicHardReset additionally releases GPIO0 (DTR low) which the old bare-RTS reset never did, so it's an improvement for UART-bridge ESP32/ESP8266, not just native USB. Verified on an S3 over USB-Serial-JTAG; classic-bridge (CP2102/CH340) verification on the normal flow is worth a maintainer pass before merge. I'll note this in the PR description so the scope is explicit.

  • PROTOCOL_VERSION: now exchanged both directions (added FirmwareMessage.version), but enforcement is intentionally deferred until a breaking change needs it; the additive-compatibility contract and the version-as-lever rule are documented in Document protocol extensibility and add version/deviceName fields device-builder#1611.

@bdraco

bdraco commented Jun 19, 2026

Copy link
Copy Markdown
Member Author

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.

@esphbot

esphbot commented Jun 19, 2026

Copy link
Copy Markdown

@bdraco: Thanks Kōan:

Scope decision sound. Native USB-Serial-JTAG fix belongs on the shared path. One follow-up: classicHardReset and UsbJtagSerialReset.reset() aren't guarded like watchdogReset. After installed = true, a throwing reset propagates and reports the flash as failed. Wrap the reset in try/catch; flash already committed at that point. Classic CP2102/CH340 pass still the remaining gate.

@bdraco

bdraco commented Jun 19, 2026

Copy link
Copy Markdown
Member Author

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.

bdraco added a commit to esphome/device-builder-frontend that referenced this pull request Jun 19, 2026
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.
@esphbot

esphbot commented Jun 19, 2026

Copy link
Copy Markdown

Previous review — superseded by a newer review below.

@esphbot esphbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@bdraco

bdraco commented Jun 20, 2026

Copy link
Copy Markdown
Member Author

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.
@bdraco

bdraco commented Jun 20, 2026

Copy link
Copy Markdown
Member Author

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.

@esphbot

esphbot commented Jun 20, 2026

Copy link
Copy Markdown

Previous review — superseded by a newer review below.

@esphbot esphbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@bdraco

bdraco commented Jun 20, 2026

Copy link
Copy Markdown
Member Author

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.

@esphbot

esphbot commented Jun 20, 2026

Copy link
Copy Markdown

Previous review — superseded by a newer review below.

@esphbot esphbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

@bdraco

bdraco commented Jun 20, 2026

Copy link
Copy Markdown
Member Author

Done in the latest commit: wrapped the config0 expression in >>> 0 so it reads as an unsigned u32 (0xD0000102) instead of a negative signed int, with a comment naming the fields (enable / stage0=reset-system / clock prescaler / timeout-stage). Same bit pattern esptool-js packs, just no longer a maintenance trap.

@esphbot

esphbot commented Jun 20, 2026

Copy link
Copy Markdown

PR Review — Add USB flash hand-off from ESPHome Device Builder via postMessage

Solid, 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:

  • The postMessage auth model is genuinely well-reasoned for an unknown opener origin: ev.source === window.opener + one-time hash nonce, the nonce never travels outbound, and _targetOrigin only narrows from '*' after a verified inbound frame — so the '*' fallback leaks nothing.
  • isFlashParts validates each part's address (non-negative integer) and data (ArrayBuffer) at the message boundary before any conversion.
  • The toBinaryString comment pinning why TextDecoder('latin1') corrupts firmware (windows-1252 remaps 0x80–0x9F) documents a real footgun in code.
  • The installed guard cleanly separates the intentional post-flash USB re-enumeration disconnect from a genuine mid-flash failure, and hardResetChip is wrapped so a reset throw can't mark a committed write as failed.
  • Scope is honestly disclosed: hardResetChip changes the normal Connect & Install path too, with reported hardware verification on S3 (native USB-JTAG) and a classic chip over a UART bridge.

Three non-blocking suggestions:

  • The disconnect listener in _handleInstall is never removed (harmless given the installed guard, but worth confirming retries don't reuse the same device object).
  • wdtConfig1 = 2000 is the lone unnamed magic number in an otherwise well-annotated register sequence.
  • watchdogReset returns true on 'armed', not 'confirmed booted' — fine today, worth a comment so the fall-through contract stays clear.


Checklist

  • postMessage authentication / origin handling
  • Input validation at cross-origin message boundary
  • No firmware corruption in binary-string conversion
  • Error paths preserve correct success/failure signal
  • Change scope matches PR description
  • No hardcoded secrets
  • Resource cleanup on error paths — suggestion #1

Automated review by Kōan (Claude) HEAD=dc39dff 3 min 20s

@esphbot esphbot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

Comment thread web.esphome.io/src/dashboard/ew-web-flash.ts
private _erase = true;
private _readyTimer?: number;

connectedCallback(): void {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

technically, firstUpdated is the correct LitElement way, but in this case we'll never connect more than once, so it's fine.

Comment thread web.esphome.io/src/dashboard/ew-web-flash.ts
Comment thread web.esphome.io/src/dashboard/ew-web-flash.ts Outdated
_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.
@bdraco

bdraco commented Jun 20, 2026

Copy link
Copy Markdown
Member Author

thanks

@bdraco bdraco merged commit cc73a4f into main Jun 20, 2026
7 checks passed
@bdraco bdraco deleted the web-flash-postmessage branch June 20, 2026 03:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants