agentsclimarketplace

Firmware deploy

Skill Attaky-Module/Attaky-Firmware-Builder/skills/firmware-deploy

This skill takes a built `.bin` set from `firmware-build` and gets it onto an Attaky device. Two local-flash paths are supported today: (1) `esptool.py` over USB; (2) the Attaky web flasher at `flasher.attaky.com` via Web Serial in Chromium browsers (the web flasher is in development; the skill stages the path and falls back to esptool). A third path — uploading to the Attaky firmware store — is reserved for when its upload API is finalized and is marked explicitly TBD. Pre-flight verifies the bin files, chip target, and flash addresses against the build manifest; post-flash the skill auto-runs a 15-30 s serial monitor to confirm boot via the codegen-default logcat banner. Use when the user has built firmware and says "flash it to my Attaky", "install on my device", "put it on my hardware", "deploy the firmware", "upload to fwstore", "publish my firmware", "how do I get this on the device", or hands off from `firmware-build`. Last stage of the chain; success means firmware running on the device.From its SKILL.md

Install
npx -y skills add Attaky-Module/Attaky-Firmware-Builder --skill firmware-deploy

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 8 stars8 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

43.3 KB, as published. Nobody here has run it

Firmware Deploy

This is stage 6 — the last one — of the attaky-firmware-builder chain. The user has built .bin files at <workspace>/build/ (typically produced by firmware-build). You get those bytes onto their Attaky device.

The user is non-technical. Their goal: stop reading docs, plug in USB, see the device run their firmware. Most of this skill's design is about handling the messy real-world bits (which port, which chip, did it actually boot) so they don't have to.

Operating Principles (non-negotiable)

  1. Don't flash the wrong device. A pre-flight check confirms the chip connected over USB matches the catalog's expected Core MCU (ESP32-S3 for Core_ESP32_1.0). If a mismatch is detected (different ESP variant, wrong device entirely), surface it to the user before writing any flash — you can break unrelated hardware if you keep going.

  2. One deploy path per session. Default silently to local flash via esptool.py over USB (§2 below) — the only path that actually flashes anything today (the web flasher falls back to it, and the fwstore upload is TBD; Operating Principle 5). The web flasher and fwstore alternatives surface only if esptool turns out unavailable or the user asks about other ways. Whichever path ends up in use applies for the rest of the run.

  3. Confirm boot, don't just confirm flash. A successful esptool.py write_flash returns OK whether the device actually boots or not. Always run a short serial monitor after flash to read the codegen-default logcat banner (per firmware-codegen SKILL.md Operating Principle 6) and surface to the user whether the app actually came up.

  4. Catalog is authoritative for chip + flash layout. Pre-flight chip target check, flash address sanity, baud rate defaults all come from ../../shared/hardware-catalog.md. Don't infer.

  5. TBD means TBD. The fwstore upload path is reserved for when the upload API is finalized. This skill does not assume any workflow shape, payload schema, or auth model for fwstore today. When the user picks the fwstore path, the skill explicitly tells them it isn't implemented yet and fall-backs to a local path.

Workflow

Collaboration mode

Read the Collaboration mode field from the spec package (any of parts-list.md, module-combination.md, requirements.md, dev-plan.md — they match), else concept.md, else (porting lane) port/plan.md / port/survey.md; default autonomous if absent (build already treats port/plan.md as the port identity source). See ../../shared/collaboration-mode.md.

In step-by-step, before this stage's work, ask this stage's functional questions (none — deploy's stop is the real-hardware confirm gate), show the plan, execute on OK. In autonomous, just do the stage and stop only at the essential gates. Never ask the user a technical question in either mode.

1. Find the build

Locate the build artifacts before doing anything:

  • Look in <workspace>/build/ (the workspace established by firmware-brainstorm, extended by every later skill in the chain; the build/ folder is what firmware-build writes).

  • Required files inside: at minimum firmware.bin, bootloader.bin, partitions.bin, plus build-manifest.md (firmware-build SKILL.md §7 spec). Arduino branch additionally has boot_app0.bin.

  • If build/ is missing or any required file is absent, ask the user one short question:

    "I can't find a complete build/ folder in this workspace (looking for firmware.bin, bootloader.bin, partitions.bin, build-manifest.md). Give me a path to the build, or run firmware-build first to produce one."

    Wait for either an absolute path or "go run build first." Don't proceed without the build.

Read build/build-manifest.md — flash address table is the source of truth for what goes where.

Also scan <workspace>/references/ if it exists — durable findings from earlier stages (a board quirk, a fix-loop lesson) may shortcut a boot-failure diagnosis. Scratch output (raw serial captures, probe dumps) goes to <workspace>/tmp/, safe to delete wholesale.

2. Pick the deploy path

Default silently to (A) local flash via esptool.py over USB. This is a technical decision, not a functional one — deploy never asks the user to choose a technical path (../../shared/collaboration-mode.md), and (A) is the only path that actually flashes anything today: (B) the web flasher is still in development and falls back to (A) anyway (§4.1), and (C) the fwstore upload API isn't finalized, so it can't run at all (Operating Principle 5). Tell the user, as a statement rather than a question, in their language:

"I'll flash it over USB with esptool."

Then go straight to §3.

Surface the alternatives — §4 (Attaky web flasher) and §5 (fwstore upload) — only when one of these two triggers fires, and in each case present them with their current honest status (B: in development, falls back to A; C: TBD, upload API not finalized, cannot run):

  • (A) turns out unavailable. Either esptool is missing and couldn't be installed either (§3.1 exhausts both its install options), or no USB device is found and the user can't get one connected (§3.2's zero-candidates case). Tell the user A didn't work this run, and that B would only fall back to the same A and C can't run yet — so there is no working alternative today unless they resolve what blocked A (install esptool / connect the device). A user who genuinely can't attach a device may still want C purely to publish the build for others, even though it can't run yet.
  • The user asks about other ways to flash (e.g. "what about the web flasher", "can I upload to fwstore instead") at any point in the run. Answer with B / C's honest status above and let them decide from there.

Routing once a path is in use:

  • A (default, or the user's explicit choice after asking) → §3 (esptool path).
  • B (only reached because the user asked for it) → §4 (web flasher path; falls back to A per §4.1 if the tool isn't live).
  • C (only reached because the user asked for it) → §5 (fwstore TBD).

Lock whichever path is entered for the rest of the run.

3. Local flash via esptool.py

3.1 Verify esptool is on PATH

Probe both names — esptool v5+ ships as esptool (no .py); older versions ship as esptool.py. Try esptool --version first; on "command not found", fall back to esptool.py --version. Use whichever name resolves. If both miss, present two paths to the user, in their language:

"esptool.py isn't installed. Two ways to proceed:

(A) Install yourself (recommended) — copy/paste in a terminal:

# macOS
pip install esptool

# Linux
pip3 install esptool

# Windows: see https://docs.espressif.com/projects/esptool/en/latest/

Then re-run firmware-deploy.

(B) Let me try to install it — I'll run pip install esptool via my shell. Caveats: it may fail if you don't have Python / pip set up, may need your user-install path adjusted, and may prompt for credentials I can't see. Manual is usually faster."

Same dual-path pattern as firmware-build. Wait for A or B; on B attempt failure, fall back to A and ask user to install manually.

3.2 Detect the USB port

Run python -m serial.tools.list_ports -v (or the toolchain's equivalent) to enumerate candidate USB serial devices.

Description-string caveat: don't rely on the description string to identify "which one is the Attaky." Linux often shows /dev/ttyUSB0 with no description; macOS shows USB JTAG/serial debug unit for the ESP32-S3 native USB-OTG and USB2.0-Serial (or vendor-specified) for CH340X; Windows varies. When disambiguation is needed, prefer USB vendor-id / product-id (CH340X = 1A86:7523) over substring matching, but accept that the user may need to pick.

  • Exactly one candidate → tentatively pick it. Tell the user briefly: "Detected one USB serial device on <port> — verifying it's an ESP32-S3..." (Don't claim the chip identity yet — §3.3(b) will run chip_id and confirm.)

  • Multiple candidates → ask the user one question:

    "I see multiple USB devices: <port-1> (description), <port-2> (description), .... Which one is your Attaky?"

    Wait for the user's pick.

  • Two identical Attaky boards share the same CH340X descriptor, so the port node (/dev/cu.usbserial-N / ttyUSBn) can reshuffle between plug-ins and across sessions — never assume last session's port is the same board. When it matters which of two boards you flash, pin the identity: read the MAC (esptool --port <port> read_mac) or use the stable by-path device node, and re-scan + re-confirm every session. Flash only the board the user approved.

  • Zero candidates → tell the user, in their language:

    "I don't see any USB serial devices. Plug your Attaky into the computer with the supplied USB-C cable, then re-run firmware-deploy. (If it's plugged in and still not showing, check the cable — some are charge-only and don't carry data. Also confirm the device is powered on: the TFT backlight should be visible or a status LED lit. Some Attaky Complete Builds require a long-press of the POWER button to wake from shipping-mode.)"

    Stop. Do not attempt flash.

3.2.5 Confirm device is powered on

Before pre-flight, surface a short power-on check to the user, in their language. Even with the cable plugged in and the port enumerated, an Attaky device may be in a shipped-from-factory low-power state (Power module disconnected, or battery deeply discharged with no USB load yet supplied). esptool can still reach the chip's ROM bootloader for flashing in this state — but post-flash boot verification (§3.5) will fail because the app never starts.

"Quick check before we flash: does your Attaky device look powered on? Look for the TFT backlight glowing, the RGB status LED lit, or any indicator of life. If it's dark, long-press the POWER button for ~2 seconds to wake it. (Newly-shipped devices and devices that have been off for weeks sometimes need a manual wake even with USB attached.)"

If the user reports the device is dark and a POWER long-press doesn't wake it: the battery may be deeply discharged. Ask them to leave the USB plugged in for 5+ minutes before retrying — the Power module's pass-through charges the cell while attached.

Don't auto-detect "powered on" via probes — esptool's chip_id succeeds whether or not the app is running because it talks to the chip's ROM bootloader directly. The only reliable signal is user-observable.

3.3 Pre-flight check

Before running write_flash, verify three things. Surface any mismatch to the user before writing anything to flash.

(a) Files exist. Every file referenced in build-manifest.md's flash address table is on disk inside build/. Missing → "I see the manifest but can't find <filename> in build/. Re-run firmware-build to regenerate."

(b) Chip target matches. Run esptool.py --port <port> chip_id (or flash_id). Compare the detected chip family to the catalog's Core MCU expectation (Core_ESP32_1.0 → ESP32-S3). Mismatch example:

"Pre-flight: I see an ESP32-S2 on <port> but the build is for ESP32-S3 (Attaky's Core MCU). The device on this port might not be Attaky hardware. Cancel, switch ports, or proceed anyway?"

Control flow on each user reply:

  • "cancel" → stop the deploy run, don't write flash.
  • "switch ports" → loop back to §3.2: re-list ports, re-prompt user for pick, then return to §3.3 with the new port.
  • "proceed anyway" → continue to §3.3(c) and §3.4, but log the override in any deploy log.

Don't proceed unless user explicitly confirms.

(c) Flash addresses look like ESP32-S3 defaults. Compare the manifest's flash address table to the canonical layout (firmware-build SKILL.md §7):

FileExpected default address
bootloader.bin0x0
partitions.bin0x8000
boot_app0.bin (Arduino only)0xe000
firmware.bin0x10000

boot_app0.bin rule: this file is Arduino-on-ESP32 specific (OTA data init blob). A legitimate ESP-IDF build has no boot_app0.bin — that's not a custom layout, that's normal IDF. Only flag "custom!" if the addresses themselves diverge from this table for files that are present.

If the manifest uses non-standard addresses (custom partition table), proceed but note to the user: "The manifest uses custom flash addresses; if the device doesn't boot after flash, this is the first thing to double-check."

3.4 Run the flash

Compose the esptool command from the manifest. Standard form (using whichever esptool name resolved in §3.1; example uses the esptool binary name):

esptool --chip esp32s3 --port <port> --baud 460800 \
  write_flash \
  0x0     bootloader.bin \
  0x8000  partitions.bin \
  0xe000  boot_app0.bin \
  0x10000 firmware.bin

The default Upload baud from the build manifest (composed by firmware-build) is 460800. 921600 is the esptool default but fails reliably on real cables / USB hubs with No serial data received mid-handshake. Don't bump back to 921600 without re-validating on the user's actual setup.

The 0xe000 boot_app0.bin line is Arduino-only — drop it for ESP-IDF builds (which don't produce boot_app0.bin). Don't put a # Arduino only shell comment on the line itself; in the backslash-continued form above, an inline # would consume the rest of the line and break the command.

Stream the command's output to the user as it runs (esptool prints progress percentages — don't suppress those; users like seeing the bytes go up). Surface any error verbatim.

On success → §3.5. On failure → tell the user the error and list common causes in priority order:

  1. USB cable is charge-only and doesn't carry data — by far the most common failure mode for a vibecoder; swap the cable.
  2. Wrong port — the chip on the picked port may not be Attaky; re-check §3.2.
  3. Auto-reset failed (rare on Attaky Core: catalog notes the CH340X's DTR/RTS auto-reset wiring works without manual intervention) — only as last resort, hold BOOT button while re-plugging USB.

Don't auto-retry. Let the user re-invoke.

3.5 Post-flash serial monitor

The flash is written. Now confirm the firmware actually runs by opening a short serial monitor at 115200 baud on the same port for up to 30 seconds.

Monitor tool selection (in priority order):

  1. If the build was PlatformIO (the manifest's Toolchain notes say so) and pio device monitor is on PATH, use: pio device monitor --port <port> --baud 115200
  2. If the build was ESP-IDF and idf.py monitor is on PATH, use: idf.py -p <port> monitor
  3. Fallback for either toolchain: open a Python pyserial reader directly (python -m serial.tools.miniterm <port> 115200, running for 30 s then breaking) — works without a project context and always available alongside esptool.

What to watch for — the codegen-default logcat boot banner per firmware-codegen SKILL.md Operating Principle 6:

[INFO] [APP] <firmware-name> v<version> on <build-SKU>
[INFO] [APP] Build: <build timestamp> / toolchain: <PlatformIO|ESP-IDF>

Boot success criterion (precise so implementers don't diverge):

  • Match the first banner line with the regex ^\[INFO\]\s+\[APP\]\s+(.+?)\s+v(\d+\.\d+\.\d+(?:-A\d+)?)\s+on\s+(.+)$ (the optional -A<NNN> suffix is the ported-firmware version format — firmware-port normalizes the upstream version to its x.y.z core and appends -A<NNN>), appearing in the serial stream within 30 s of monitor open.
  • Match the second banner line with the regex ^\[INFO\]\s+\[APP\]\s+Build:\s+.+?\s+/\s+toolchain:\s+(PlatformIO|ESP-IDF)$ appearing within 5 s after the first.
  • Both lines required for "boot success." If only the first appears, treat as partial — show the user what was seen and let them decide.

The build manifest's ## Boot banner contract section gives the expected resolved values (firmware-name / version / build-SKU substituted) — use those to validate semantic match in addition to the regex shape.

  • If the banner appears → boot success. After the banner, wait a few more seconds for module-init logs ([INFO] [RADIO] init OK, [INFO] [GPS] init OK, etc.). Then close the monitor and summarize to the user, in their language:

    "Flashed and booted. Saw <firmware-name> v<version> and N modules initialize. Last serial line: <line>. Want to keep watching? Run pio device monitor (PlatformIO) or idf.py monitor (ESP-IDF) on this port (<port>)."

  • Milestone-gated runs only — if the build manifest carries a milestone: field (from firmware-build), ALSO require a separate line matching ^\[INFO\]\s+\[APP\]\s+milestone:\s+M(\d+)\b in the serial stream, and confirm the captured M<N> matches the manifest's milestone. This is an additive check on top of the two OP6 banner lines above — their regexes are unchanged. If the marker line is absent or names a different milestone, the codegen/build didn't actually stage this milestone → gate failure: do not advance; hand back to firmware-codegen scoped to this milestone (§3.6 loop). On pass, this gate's row goes into the milestones log (§3.5c + references/milestone-log-template.md).

  • When the manifest carries an expected-modules row — ALSO watch the same stream for the module-detection line and run the full regex + semantic-compare + verdict handling in §3.5a, before closing the monitor and giving the "Flashed and booted" summary above. A verdict=MISMATCH there does not undo "boot success" here — see §3.5a for how to report it.

  • If the banner doesn't appear within 30 s → boot likely failed (panic loop, brownout, partition mismatch, custom partition table not matching what was flashed). Auto-fix loop kicks in — a boot failure is a technical fault, so the loop attempts fixes itself before surfacing anything to the user. Cap: 3 attempts total (matching firmware-build §5).

    See §3.6 Runtime-panic auto-fix loop.

3.5a Modules-line check (when the manifest carries the check)

After the boot-banner check above passes, and only when build-manifest.md carries a ## Module detection contract section with an expected-modules row (firmware-build SKILL.md §7 / references/build-manifest-template.md), also watch the same serial stream (still inside the 30 s window) for the module-detection line defined by ../../shared/module-detection-and-safe-boot.md §3.

Row absent ⇒ skip this check entirely — not just the semantic compare below, the regex match too. Note in any summary that this firmware carries no module-detection check; that is not a failure, it is the contract's own applicability rule (§6): idea-lane firmware always carries the check, port-lane firmware only if firmware-port injected it.

Regex — note the contrast with the two OP6 banner regexes above, which deliberately tolerate \s+ whitespace: this line is FROZEN byte-for-byte per the contract (codegen never varies its spacing), so the check uses LITERAL single spaces and must not be loosened to match the banner's tolerant style:

^\[INFO\] \[HW\] modules: expected=.+ detected=.* expand=(FM-AM|LoRa-GPS|none-or-media|probe-error) verdict=(MATCH|MISMATCH)$
  • expected= — non-empty (.+): a firmware that carries the check never prints an empty expected set.
  • detected= — may be empty (.*): e.g. a main-I²C-bus-init failure prints detected= empty alongside expand=probe-error.
  • expand= — exactly one of the four tokens (FM-AM / LoRa-GPS / none-or-media / probe-error).
  • verdict= — MATCH or MISMATCH.

Semantic compare. Once the regex matches, parse the expected= field out of the matched line (the regex validates shape only — it has no capture groups; split on the literal detected= delimiter):

  • Literal (bringup) — the bring-up-baseline exception (contract §6). Treat as informational only: log the line, run no semantic compare against the manifest, and never treat it as a failure regardless of the line's own verdict=.
  • Otherwise — the comma-joined ID set in expected= must match the manifest's expected-modules row exactly (same ID set; order doesn't matter). A mismatch here is between the LINE and the MANIFEST — not the line's own verdict= field — and means the flashed binary doesn't correspond to this build/ folder (stale manifest, wrong workspace). Surface that as a deploy-side integrity problem before trusting anything else about the boot.

Verdict handling (once the semantic compare above is clean):

  • verdict=MATCH → proceed as ordinary boot success (folds into the §3.5 summary below).

  • verdict=MISMATCH → this is a successful DETECTION, not a flash failure, and NOT a trigger for the §3.6 runtime-panic auto-fix loop. Tell the user, in their language:

    "Flashed and booted fine — but the firmware says the attached modules don't match what this build expects (expected=<...> vs detected=<...>). The device is safely running Core-only mode, not broken. To fix it: attach the module this firmware expects, or flash a build that matches what's actually attached to the device."

    Let the user choose: (a) attach the matching module and re-run firmware-deploy (the same build/ works, no rebuild needed), or (b) go back to firmware-spec / firmware-codegen to build for the hardware actually attached. Don't pick for them.

If the manifest carries the row but the modules line never appears within the same 30 s window, treat it like a missing second banner line (partial boot-verify) — something regressed between codegen's line-emission and this boot; don't silently drop the check.

3.5b Visual verify (optional, when serial-screenshot is supported)

After boot-banner success, if the project ships a serial-screenshot command (a screenshot line-command on the firmware's serial console that dumps the LVGL — or equivalent — framebuffer back to the host as raw bytes), the agent SHOULD additionally capture the rendered screen as a PNG and visually inspect it instead of stopping at "boot banner seen".

This upgrades the post-flash check from "the firmware reached setup() end" to "the firmware reached setup() end AND the rendered UI looks the way the spec says it should." It is the right level of confidence for handoff in a UI-heavy project.

Detection. A project supports this if both:

  1. A host capture script exists somewhere in the project — typically at the project root (e.g. screenshot.sh) or under tools/. Locate by searching the project root + one level of likely subdirs (tools/, scripts/) for a script with screenshot in its name.
  2. The firmware sources contain a serial command handler matching the string "screenshot". Grep the firmware source dir (the one containing setup() / app_main() — see firmware-build §2 for how to locate that) for this string.

If either piece is missing, skip this sub-step — it is not a project requirement. (If the user explicitly asks for it to be added, follow §2 of ../../shared/screenshot-debug.md.)

Invocation. After the boot-banner check completes and the monitor is closed:

<path-to-the-script-located-above> <workspace>/tmp/post-flash.png <port>

(Run from the project root if the script uses relative paths internally, which is the common case. Create <workspace>/tmp/ first if it doesn't exist — it is the workspace scratch folder, safe to delete wholesale.)

Inspect. Read the PNG (any image-capable read tool) and compare to the approved ui/preview.html from firmware-ui if one is available — they should render the same content at the panel's native resolution.

If the rendered screen materially deviates from the spec, surface the discrepancy to the user with both images. Do NOT auto-fix visual issues — they usually indicate a spec/code mismatch that benefits from a human eyeball, not a blind retry.

Caveat — a screenshot can't catch panel colour-order bugs. The capture is the pre-flush framebuffer, so it always shows the LVGL working colours. A panel configured BGR-vs-RGB renders wrong colours on the physical panel only — invisible in the PNG. So if the user reports wrong colours but the screenshot looks correct, it's a panel RGB-order / byte-swap issue (see ../../shared/display-and-lvgl-gotchas.md §2), not a UI-code bug.

Protocol details, gotchas, "how to add this to a project that doesn't have it yet", and reference implementation: ../../shared/screenshot-debug.md.

3.5c Milestone-gated runs — log the gate, then loop (opt-in)

Only when the build manifest carries a milestone: field (the user chose firmware-codegen's milestone-staged generation, or the workspace is a firmware-port port following its port/plan.md milestone ladder). This turns the single end-of-chain boot-verify into a per-milestone gate:

  • Local esptool path only. Milestone-gated runs use §3 (local esptool), NOT the §4 web flasher — the web flasher can't return the boot-banner + milestone-marker evidence or append the log row, so it can't gate. (Revisit if it gains that capability.)
  • Append a row to the milestones log <workspace>/deploy/milestones.md after every gate (see references/milestone-log-template.md): milestone, board id / port, build hash, the banner + marker line seen, the user's physical-EFFECT confirmation, and pass/fail. This is the durable proof the gate actually passed on hardware — "it built" is not a pass.
  • On pass → loop, don't wrap up. Return to firmware-codegen for the next milestone (port workspaces: return to firmware-port, whose port/plan.md milestone ladder names the next milestone); then build → this gate again. The §6 wrap-up + the acceptance walkthrough run only after the last milestone passes.
  • On fail → §3.6, scoped to this milestone (hand back to codegen with the milestone identity; never regenerate a milestone that already passed).

3.6 Runtime-panic auto-fix loop

The post-flash monitor saw no boot banner in 30 s, OR saw the banner but then a panic dump (Guru Meditation Error: ...) within ~10 s. That's a runtime failure, but the user shouldn't have to read panic dumps. Iterate up to 3 attempts total before surfacing.

Root-cause before each reflash — don't blind-retry. Decode the panic backtrace and reason from the serial output rather than guessing and reflashing. Two recurring shapes worth knowing (see ../../shared/esp32-bringup-and-rtos-patterns.md §1–§2): a LoadProhibited / poison-pattern crash in code that is obviously fine is usually a stack overflow from an oversized frame in the caller chain, not a bug at the crash site; and a peripheral that is silently dead (no panic, "nothing happens") is best split with one rate-limited log at the driver boundary before patching wiring or addresses.

Reconcile against a known-working configuration where one exists — a reference firmware / config proven on the same board + display driver is the fastest way to spot a wrong flag, pin, or rotation value. But a reference is only trustworthy if it was actually validated on hardware: a config inherited from a project whose feature was never board-verified carries that latent bug straight into your firmware. And before blaming the hardware, confirm the test METHOD is sound — a two-node radio test that resets both boards at the same instant collides their packets (stagger them: one listens, reset the other); a chip that "reads absent" may simply be unpopulated. Ask the user to check the physical hardware before changing config.

Attempt classification (per attempt, before the fix is applied):

  • Surgical — a single targeted patch can plausibly fix it. Examples: missing build flag (-DTFT_MISO=-1), wrong frequency in radio.cpp init, off-by-one in a single setup function, missing pin pull-up, wrong I²C address constant.
  • Structural — the codegen output is broken in a way a one-line patch won't help. Examples: wrong partition table; SPI peripheral conflict between two modules; missing entire setup() call; feature-implementation gap.

For surgical → apply the patch directly here (back up source/ first; on patch-failed-to-build or still-panics, revert). For structural → hand off to firmware-codegen in fix mode (same call shape as firmware-build §5.3) with the panic dump + last 30 lines of serial + your hypothesis.

Loop body (per attempt n = 1, 2, 3):

  1. Print one short progress line to the user, e.g. "Boot panic. Trying TFT_MISO=-1 build flag (attempt 1 of 3)…". Don't dump the panic.
  2. Apply the fix (surgical patch OR codegen-fix-mode invocation).
  3. Re-run firmware-build (which itself runs its own 3-attempt build-error loop if needed — those don't count against the deploy budget).
  4. On build success: re-run §3.4 flash + §3.5 monitor.
  5. On boot success → record any reusable lesson (end of this section), close the loop, go to §6 wrap-up.
  6. On boot fail → record what was tried + the new panic, increment n.

Common deploy-side runtime-panic causes (try these in this order when the symptom matches):

Symptom in panic dumpHypothesisFix
Saved PC:0x403cd9aa consistently in ROM stage, no app bannerboard_build.flash_mode = dio forced on Core ESP32-S3 rev-v0.2 silicon — its 2nd-stage bootloader boot-loops under dioRemove the flash_mode override (use the platform default qio). Validated 2026-06-28 (Core ESP32-S3 rev v0.2): qio boots, dio loops. NOT a partition problem — the standard OTA-shaped default_16MB.csv boots fine.
Banner + OP7 + early modules log, then silence at display::setup / g_tft.init() (no panic, no further output — tft.init() HANGS)On arduino-esp32 3.x, TFT_eSPI's default / FSPI SPI port collides with the flash/PSRAM SPI controller and the first transaction blocks foreverUse -DUSE_HSPI_PORT=1 (HSPI/SPI2, off that controller) in build_flags — NOT -DUSE_FSPI_PORT=1 (FSPI is exactly what hangs). HW-validated 2026-06-29 against a working reference firmware on the same platform.
Banner OK, then StoreProhibited in display::setup AND the Complete Build has Expand_LoRa-GPS_1.0TFT_eSPI default MISO pin collides with LoRa SX1262 MISO on IO7Add -DTFT_MISO=-1 to build_flags (ST7789 is write-only).
Banner OK, then StoreProhibited in same module each boot, no obvious flag fixModule init touches an uninitialized peripheral / NULL pointer in user codeHand off to firmware-codegen in fix mode with backtrace
Boot loops with rst:0x10 (RTCWDT_RTC_RESET)Brown-out / power dip — USB cable insufficientTell user to swap to a wall adapter or known-good cable. (Environment, not code — burns one attempt)
Persistent ROM-stage boot fault, no bannerEarly 2nd-stage bootloader fault (flash / PSRAM config)Do NOT force board_build.flash_mode = dio — forcing dio is itself what boot-loops rev-v0.2 silicon (see the 0x403cd9aa row). Use the platform default qio; if a specific board genuinely won't boot in qio, treat it as a power / cable / board-rev investigation, not a blanket flash-mode change.
mode:DIO shows but app never runs, no panic dumpWrong partition table size — app slot too smallRe-run firmware-build; check Flash% utilization in build output
Display works but image is upside-down 180°ST7789 panel on Mesh Pocket 1.0 is mounted 180° relative to chip 0°Change firmware to call setRotation(3) for landscape (NOT 1) or setRotation(2) for portrait (NOT 0).
Visible flicker on every redrawdisplay tick uses full clear() + redraw at high cadenceLower the redraw cadence (≥1 s) AND gate redraw on a dirty-bit flipped only by state changes. Avoid full-screen fillScreen on every animation tick.

3-attempt budget exhausted — surface to user (and only then, once the 3-attempt auto-fix budget is spent):

"Flashed but the device won't boot — I tried 3 fixes and it's still panicking. Here's what I tried:

  1. <attempt 1 description> → <outcome>
  2. <attempt 2 description> → <outcome>
  3. <attempt 3 description> → <outcome>

Last serial output:

<last 20 lines, including the panic dump>

The most likely remaining causes are environmental rather than code — power supply (try a known-good wall adapter), USB cable (charge-only cables look identical to data cables), or a hardware fault. Want to try another fix idea, or escalate?"

Don't auto-loop beyond 3 attempts. Don't ask the user "what should I do" before exhausting the budget — fixing technical faults is the skill's job. Three tries, then a complete report, then wait for input.

Either way the loop ends: if it root-caused something reusable (a panic signature → fix mapping not already in the table above, a board-specific quirk), record it as <workspace>/references/YYYY-MM-DD-<topic>.md with a one-line provenance header — the next iteration should not have to rediscover it.

4. Local flash via Attaky web flasher

4.1 Check if the web flasher tool is live

The Attaky web flasher (target: https://flasher.attaky.com or similar) is in development as of this skill version. Before pointing the user there, verify the URL is reachable.

Reachability criterion (precise so implementers don't diverge): do a GET on the URL with a 5-second timeout.

  • Reachable = HTTP 200 OR a 301/302/307/308 redirect chain ending in 200 within 3 hops. Treat as live.
  • Unreachable = anything else: connection error, DNS failure, timeout, 4xx, 5xx, redirect loop, non-2xx terminus. Treat as not-yet-live and fall back.

If reachable → §4.2 (point user there). If unreachable → fall back: tell the user, in their language:

"The Attaky web flasher isn't live yet — I'll fall back to the esptool path. Switching to (A)…"

Then go to §3 (the silent fallback is the implicit re-route per Operating Principle 2 "one deploy path per session" — don't re-ask §2; the user already chose B).

4.2 Walk the user through the web flasher

If the tool is live (in a future version of this skill), the hand-off is short — the tool itself runs the flash:

  1. Tell the user to open <flasher-url> in Chrome / Edge / Brave (Web Serial API requires Chromium-based browser; Safari and Firefox don't support it).
  2. Tell them to drop the entire <workspace>/build/ folder into the page (or upload firmware.bin + bootloader.bin + partitions.bin + build-manifest.md individually). The tool reads the manifest for flash addresses.
  3. Plug in USB. The browser prompts to pick a serial port; user picks the Attaky device.
  4. Click "Flash" in the web tool.
  5. The tool handles flash + boot verification on its end.

Tell the user when it's done:

"When the web flasher reports success, you're done. Come back to me if anything goes wrong (especially if the device doesn't show a boot banner — paste the last lines of the serial monitor and I'll help diagnose)."

This skill ends here on the web-flasher path; serial monitor / post-flash diagnostics are owned by the web tool, not this skill.

5. Fwstore upload path — TBD

The Attaky firmware store (fwstore) upload flow is not implemented in this skill version. The upload API, payload schema, auth model, and user-facing publication flow are all yet to be defined.

When the user picks (C) fwstore in §2, tell them, in their language:

"The fwstore upload path isn't ready yet — the upload API, auth, and publication flow haven't been defined. When the API ships, this skill will be updated to walk you through the process. For now, your options:

  1. Local flash now — flash the build onto your own device via (A) esptool.py or (B) the Attaky web flasher, instead of publishing to fwstore. I can help with that — just say 'flash locally'.
  2. Wait for fwstore — keep the build at <workspace>/build/ and re-run me once fwstore is live (you'll see an update to this plugin announcing it).

What now?"

If the user says "flash locally" → switch to §3 (esptool default, since web flasher is also in dev). Otherwise stop politely.

Do not assume any fwstore workflow shape, payload, or auth model in this version. When the API is finalized, this section gets a real workflow body — until then, the explicit TBD framing above is the contract.

6. Wrap up

firmware-deploy is the last stage of the chain. After a successful deploy (esptool boot-banner confirmed, or web flasher reports success), the user has firmware running on their device.

Acceptance walkthrough (before you call it done). A firmware that boots and inits every module can still ship defects a build-clean binary hides — a wrong colour on a degraded path, an idle-state glitch, a missing confirmation toast, a control that does nothing. Before the closing message, walk the user through a short acceptance checklist built from requirements.md §1 (features) + concept.md §3 (flow) (port workspaces: built from port/survey.md's feature inventory and MVP bar instead): one line per feature — "do X, you should see/hear Y." Have them exercise each on the real device and confirm. Anything that fails is a real bug — capture it (the record-bugs discipline) and route it back through firmware-codegen's fix loop; don't paper over it. This is the customer-unboxing gate the rest of the chain otherwise skips, and it reliably finds what boot-verify alone misses.

Closing message to the user, in their language:

"Done. Your <firmware-name> firmware is on the device. You've just walked the full chain: brainstorm → spec → codegen → build → deploy (or, for a port: survey → plan → implement → build → deploy). The <workspace>/ folder holds the whole project (concept or port/ docs, references, spec if present, source, build, optional deploy log; tmp/ is scratch you can delete anytime).

A few likely next moves:

  • Same build, another device? Unplug, plug in the next Attaky, and re-invoke firmware-deploy — port detection runs again. Build artifacts are reused; no rebuild needed.
  • Iterate the firmware? Come back and describe what to change (new feature, bug fix, behavior tweak); we'll loop through the chain again.
  • Save a deploy log? Optional — say 'save log' and I'll write <workspace>/deploy/<UTC timestamp>-flash.log with the serial monitor capture, flash command, and manifest snapshot for future debugging."

After printing the closing message, ask one short question:

"Save a deploy log? (yes / no)"

  • yes → write <workspace>/deploy/<UTC timestamp>-flash.log containing the serial monitor capture + flash command + manifest snapshot. Brief confirm to user with the path.
  • no → don't create <workspace>/deploy/. Most successful deploys end with just the device running.

This skill ends here either way. The chain is complete.

Hardware catalog handling

The catalog at ../../shared/hardware-catalog.md is the source for:

  • Expected chip target: Core_ESP32_1.0 → ESP32-S3. Used in pre-flight chip-mismatch detection (§3.3 (b)).
  • Flash size and partition layout: 16 MB QuadSPI flash, default Arduino 16 MB partition table. Used in flash address sanity check (§3.3 (c)).
  • USB-UART bridge: CH340X (Core has it built-in). Used to recognize the Attaky device in --list-ports output (look for CH340 in the description string).
  • Auto-reset support: ESP32-S3 + CH340X auto-reset via DTR/RTS is wired on the Attaky Core. esptool's default reset behavior (--before default_reset) works without manual BOOT button hold. No special incantation needed.

If the catalog is a placeholder, this skill's pre-flight chip-target check has nothing to compare against — note the gap to the user ("I can't verify the chip target without the catalog; flashing anyway, but if the device doesn't boot, the wrong-chip case isn't caught"). Proceed cautiously rather than refusing.

Forbidden behaviors

  • Do not flash without the §3.3 pre-flight check on the esptool path. Skipping pre-flight risks bricking unrelated hardware on a wrong-port.
  • Do not assume any fwstore API / payload / auth model. The fwstore path stays TBD until the user finalizes the upload flow.
  • Do not surface runtime panics to the user before the §3.6 3-attempt auto-fix loop is exhausted. Runtime panics are technical faults the loop owns until its budget is spent. Bouncing the user out at the first panic is wrong — match the build-error loop pattern: try a surgical patch / codegen fix-mode pass / one more codegen fix-mode pass, then surface the full diff history if all 3 fail. Do not loop beyond 3 attempts.
  • Do not auto-install esptool or any other tool without the user's explicit "(B) yes try it" answer in §3.1. System-level package management requires user consent.
  • Do not skip the post-flash serial monitor (§3.5). "Flash done" ≠ "device runs your firmware"; the monitor confirms the latter via the codegen-default logcat banner.
  • Recommend only vetted flashers: esptool (the canonical Espressif tool every ESP32 dev uses) or the Attaky web flasher when it's live. Avoid unvetted third-party flash tools (e.g. random esp-web-flasher forks or generic ESPHome flashers).

Tone

The user has been through brainstorm, spec, codegen, build — they just want to see their thing run on the device. Be confident, guide them through the messy USB / port / chip-detect bits without narration overload, and celebrate briefly when boot logs confirm success. Sound like a senior firmware engineer who's done this hundred times — quiet during the easy steps, helpful when something goes wrong.

Reference files

  • references/flash-error-playbook.md — (if present) a guide for diagnosing common flash + boot failures (brownout, panic, partition mismatch, USB cable issues). If absent, the §3.4 / §3.5 inline hints are enough for the typical cases.
  • references/milestone-log-template.md — (if present) the <workspace>/deploy/milestones.md table shape for milestone-gated runs (§3.5c): one row per per-milestone hardware gate.

Keep looking

Skills are one crate of 628,971. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.