1
0
Fork 0
mirror of https://github.com/imjasonh/esp32 synced 2026-07-06 23:52:24 +00:00
Commit graph

47 commits

Author SHA1 Message Date
ed1d18d8df ci: restore caches before cargo install espflash
`cargo install espflash --locked` builds from source. Moving the
.embuild + Swatinem cargo-cache restores ahead of it lets the install
use the cached registry index + downloaded crates instead of fetching
them fresh each run — shaves ~30 s off the install on warm caches.

Pure step-reordering; no behavioural change. Both caches still depend
on inputs that exist at checkout, so an earlier restore is safe.

Bigger speedup is available by replacing the cargo install with a
prebuilt-binary install (e.g. taiki-e/install-action drops espflash
from ~3-5 min to ~2 s). Deferred to its own PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:17:25 -04:00
dbe679d6ea
Merge pull request #12 from imjasonh/ota-config-fw-version
Provision OTA repo/tag/poll_secs; tag logs+metrics with fw_version
2026-05-03 00:12:18 -04:00
01c8c2da75 provision OTA repo/tag/poll_secs; tag logs+metrics with fw_version
Two related additions for fleet observability + tunability:

1. `[ota]` block in provisioning.toml. Optional, individually-optional
   fields: `repo`, `tag`, `poll_secs`. Writes to the existing `ota` NVS
   namespace (key names match src/ota.rs's NVS_REPO / NVS_TAG /
   NVS_POLL_SECS). Missing keys leave the firmware on its compile-time
   defaults (ghcr.io/imjasonh/esp32:latest, 60 s poll).

2. `fw_version` (the GIT_SHA baked in at build time) on every log
   entry and every metric series, so behaviour can be correlated with
   a release across the fleet.
   - cloud_log: jsonPayload.fw_version on every entry.
   - metrics: metric.labels.fw_version on every TimeSeries (resource
     labels can't carry it — `generic_node` has a fixed schema; metric
     labels let queries split heap, rssi, etc. by fw).

Both threaded from main.rs's existing FW_VERSION constant; no new
config plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:56:26 -04:00
e525bb2bbf
Merge pull request #11 from imjasonh/serialize-https
Serialize cloud HTTPS, pause senders during OTA download
2026-05-02 21:00:48 -04:00
219e35903f Pause cloud_log + metrics during OTA blob download
Even with serialization + dynamic mbedtls buffers + KEEP_PEER_CERT=n,
two concurrent TLS sessions don't fit on this chip's heap. The OTA
download holds its session open for tens of seconds while
cloud_log + metrics POSTs need ~25-30 KB of contiguous heap on top
for their handshake — and the second session reliably triggers
MBEDTLS_ERR_SSL_ALLOC_FAILED, killing the OTA download mid-stream.

Add an `OTA_DOWNLOAD_IN_PROGRESS` AtomicBool in `gcp_auth`. OTA's
`download_and_apply` flips it true via an `OtaDownloadGuard` (RAII so
the flag clears even on `?` returns). cloud_log + metrics check the
flag at the top of each cycle and skip — but don't drain — when set.

Effects:
- cloud_log entries accumulate in the bounded queue and flush in a
  single batched POST once the download completes (or fails).
- metrics misses at most one snapshot per download.
- OTA download succeeds reliably — verified end-to-end (~19s clean,
  no TLS errors, "download complete, SHA verified" reached).

Downloads only happen when there's a genuinely new image, so this
pause is rare in steady state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:56:09 -04:00
f0c23bbe1b Serialize short HTTPS calls; tighten mbedtls per-session heap
The metrics work in PR #10 added a third concurrent TLS client
(metrics → monitoring.googleapis.com) on top of cloud_log → logging
and OTA → ghcr. Three concurrent handshakes pinned ~90 KB of mbedtls
context and pushed `min_free_heap` into the single-digit-KB range,
producing intermittent ESP_ERR_HTTP_CONNECT failures (mbedtls
ALLOC_FAILED at -0x7F00).

Serialize via a shared `Arc<Mutex<()>>` constructed in main and held
**at the call sites** in each sender — std `Mutex` isn't reentrant and
`auth.get_or_refresh()` mints a token (an HTTPS POST to oauth2) that
must run inside the same critical section. Lock granularity:

- cloud_log: token-refresh + entries:write under one lock window.
- metrics: token-refresh + timeSeries.create under one lock window.
- ota::poll_once: token + manifest under lock; release for the
  signature bundle fetch's lock; release; verify (CPU, no TLS);
  download_and_apply UNLOCKED. Releasing between phases avoids
  blocking cloud_log/metrics behind ~2s of pure-Rust X.509/ECDSA work.
- ota::download_and_apply: deliberately not in the lock — multi-second
  blob downloads must not block per-5-s log flushes or per-30-s
  metrics POSTs.

Plus mbedtls heap knobs that compose with the dynamic-buffer config
already in sdkconfig:

- CONFIG_MBEDTLS_SSL_KEEP_PEER_CERTIFICATE=n: drop the parsed peer
  cert chain after handshake. We don't reuse sessions or do mutual
  TLS, so we never need it post-handshake. Saves ~3-5 KB per active
  session.

Known limitation, deferred to a follow-up: cloud_log/metrics handshakes
can still collide with the held-open OTA download session and OOM the
download mid-stream. The plan there is an `OTA_DOWNLOAD_IN_PROGRESS`
atomic that cloud_log + metrics check before locking — they skip the
POST and let the queue accumulate until the download finishes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:48:53 -04:00
e03f6e92e7
Merge pull request #10 from imjasonh/metrics
metrics: ship chip-health stats to Cloud Monitoring
2026-05-02 19:43:28 -04:00
99da9c6b87 metrics: ship Cloud Monitoring time series; refactor gcp_auth
Implements monitoring-plan.md.

- src/gcp_auth.rs: shared TokenProvider. Mints a multi-scope
  (logging.write + monitoring.write) JWT once and caches the bearer;
  cloud_log + metrics threads share it. Also moves http_post,
  parse_signing_key, b64url, now_unix_secs, unix_to_rfc3339,
  device_mac out of cloud_log.rs.
- src/cloud_log.rs: cloud_log::run takes Arc<TokenProvider> instead
  of owning the JWT/token state. LogQueue::stats() exposes depth +
  lifetime drop count for the metrics path.
- src/metrics.rs: periodic snapshot → POST /v3/projects/{id}/timeSeries.
  GAUGE INT64 metrics under custom.googleapis.com/esp32/<name>:
  free_heap, free_heap_internal, min_free_heap, largest_free_block,
  stack_hwm (label task=main|ota|cloud_log|metrics), wifi_rssi,
  wifi_channel, cpu_freq_mhz, uptime_secs, nvs_used/free_entries,
  cloud_log_queue_depth, cloud_log_dropped_total. Auto-creates
  MetricDescriptors on first POST. Cadence via NVS metric_intvl
  (gcp namespace), default 300s, 0 disables.
- Each spawned thread publishes its TaskHandle_t into a module-level
  AtomicUsize so metrics can read uxTaskGetStackHighWaterMark per
  task without holding handles for them. (Xtensa StackType_t is
  uint8_t, so the API returns bytes — no word-size multiplication.)

Plus:
- sdkconfig: enable CONFIG_MBEDTLS_DYNAMIC_BUFFER (+ free_config_data,
  free_ca_cert) so three concurrent TLS clients (cloud_log + metrics
  + OTA) don't blow our heap budget. Recovers ~15-25 KB per session.
- Makefile: `make flash` and `make run` now pass --partition-table.
  Without it, espflash 4.x writes a *default* (factory-only) partition
  table over our OTA layout, silently bricking OTA on the device.
- README + provisioning.toml.example: gcloud command for
  roles/monitoring.metricWriter; document metrics_interval_secs.
- tools/provision: write metric_intvl NVS u32 (default 300).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:38:04 -04:00
4a6d8d77c2 monitoring-plan.md: ship metrics to Cloud Monitoring
Plan for src/metrics.rs — periodic snapshot of heap, stack, wifi, cpu,
uptime, NVS, cloud_log queue stats; POSTed as time series to
monitoring.googleapis.com/v3/timeSeries. Shares the cloud_log SA + key
via a multi-scope JWT and a refactored src/gcp_auth.rs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:01:00 -04:00
bf8d552ab9
Merge pull request #9 from imjasonh/cloud-logging
Cloud Logging: ship tracing events to GCP via service-account auth
2026-05-02 18:06:30 -04:00
62b50fde76 Merge main into cloud-logging
main bumped sha2 0.10 -> 0.11 (PR #8) and switched format!("{:x}", ...)
to hex::encode in src/ota.rs. The auto-merge handled both, plus
Cargo.toml union of dep lists. Cargo.lock had one residual marker around
the firmware deps block (kept main's qualified "sha2 0.11.0" + retained
cloud-logging's rsa/time/tracing-subscriber).

rsa = 0.9 still depends on sha2 = 0.10 internally, so SigningKey<Sha256>
must be parameterised against rsa's re-export (rsa::sha2::Sha256), not
our top-level sha2 0.11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 18:01:58 -04:00
03b4b223a8 cloud_log: tolerate missing NVS, trim PEM, demote tick logs
- ESP_ERR_NVS_NOT_FOUND from EspNvs::new(gcp,...) is normal for devices
  without a [gcp] block; return Ok(None) rather than crashing.
- parse_signing_key trims trailing whitespace; jq -r appends a newline
  on top of the PEM's own trailing newline and pem-rfc7468 then errors
  trying to parse a second (empty) block.
- Demote per-poll OTA lines (sleeping / no change / manifest) and
  per-flush cloud_log lines (posted batch / minted token) from info to
  debug. They fire continuously without actionable info, and at INFO
  they flood Cloud Logging. INFO stays for OTA update + verify events.

Plus README GCP setup steps and .gitignore for SA key files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:56:32 -04:00
1264ca7da1 cloud_log: real sender — JWT, RSA-SHA256, OAuth2, POST to logging.googleapis.com
Replaces the stub eprintln sender with the real implementation:

- mint_access_token: builds + signs a service-account JWT (RS256 over
  base64url(header).base64url(claims)), POSTs to oauth2.googleapis.com
  with the standard grant_type=...jwt-bearer form body, parses the
  access_token + expires_in.
- CachedToken: held in the sender thread's local state. Refreshes
  300 seconds before expiry (Google issues 1h tokens; we re-mint at
  T+55min).
- post_batch: serializes the WriteEntriesRequest with logName,
  resource (generic_node + project + MAC node_id), and entries
  (severity + jsonPayload + optional timestamp), POSTs to
  logging.googleapis.com/v2/entries:write with the bearer.
- Backoff on failure: sleep doubles on each consecutive failure
  (capped at 5 min). Batches dropped on POST failure rather than
  re-enqueued; loss is surfaced via dropped_before on the next entry.

Crates added: rsa 0.9 (with sha2 feature for AssociatedOid; PKCS#1
v1.5 needs the DigestInfo prefix), time 0.3 (RFC3339 formatting).

main.rs: SNTP startup is now gated on gcp.is_some(). The JWT auth
needs a real wall-clock for / (Google rejects ~5min skew),
so devices with cloud logging pay a one-time ~few-second sync at
boot. Devices without [gcp] skip it. Log entry timestamps themselves
are still optional — when omitted, GCP server-side assigns them.

Cloud Logging severity mapping: tracing TRACE/DEBUG -> DEBUG,
INFO -> INFO, WARN -> WARNING, ERROR -> ERROR (matches GCP's
LogSeverity enum).

device_mac() reads via esp_efuse_mac_get_default and uses the lower
6 bytes as the node_id resource label, so multi-device logs can be
filtered by MAC.

Firmware size: 1.60 MB -> 1.71 MB (+rsa, +time, +sender code).
Still under the 1.94 MB slot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:13:03 -04:00
7cb4e8c75c cloud_log: NVS-loaded config, tracing layer, queue, stub sender
First commit of cloud-logging work (logs-plan.md). Foundation only:
the actual NTP/JWT/RSA/HTTPS-POST sender is the next commit; this
sender is a stub that writes 'would POST' to serial.

src/cloud_log.rs:
- GcpConfig::load reads the optional 'gcp' NVS namespace. If any
  required key (project_id / sa_email / sa_key_id / sa_key_pem) is
  missing, returns Ok(None) — cloud logging is opt-in per device.
- LogQueue: Mutex<VecDeque>-backed bounded ring buffer (256 entries),
  drops oldest when full and surfaces the drop count on the next push.
- CloudLogLayer: tracing_subscriber Layer that captures events,
  extracts structured fields via field::Visit, applies the configured
  min_severity filter, and pushes onto the queue. Uses wall-clock
  time when SystemTime::now() is past 2020 (NTP synced); else None
  so Cloud Logging assigns server-side timestamps.

main.rs:
- Take NVS first, before any tracing events fire.
- If the gcp NVS namespace is populated, install CloudLogLayer as
  the global tracing subscriber and spawn the sender thread (32 KB
  stack).
- tracing now uses the 'log-always' feature so events still emit
  log records even with a subscriber installed — keeps EspLogger
  writing to serial regardless.

tools/provision/:
- Optional [gcp] section in provisioning.toml. Tool emits the gcp
  namespace into the NVS CSV when present, validates min_severity
  spelling early.

ota.md: gcp namespace added to NVS schema docs.
provisioning.toml.example: commented [gcp] block.

Firmware size: 1.52 MB -> 1.60 MB (tracing-subscriber + cloud_log code).
Plenty of slot headroom remaining (1.94 MB).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 17:06:00 -04:00
02a9af0653
Merge pull request #8 from imjasonh/dependabot/cargo/firmware-7fd8197374
Bump the firmware group with 3 updates
2026-05-02 16:55:56 -04:00
8aa29329ff firmware: use hex::encode for SHA digests (sha2 0.11 dropped LowerHex)
Same fix as the publisher in PR #5: sha2 0.11's digest output type
(`Array<u8, _>`) doesn't impl `LowerHex`, so `format!("{:x}",
Sha256::digest(...))` no longer compiles. Switch to `hex::encode`
and add `hex = "0.4"` as a direct dep.

Two call sites in src/ota.rs (manifest digest hash + downloaded layer
hash). esp-idf-svc 0.52 + embedded-svc 0.29 had no API changes that
affected our code paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:51:14 -04:00
892716bbeb
Merge pull request #7 from imjasonh/dependabot/cargo/tools/provision/host-tools-4fd9825042
Bump toml from 0.8.23 to 1.1.2+spec-1.1.0 in /tools/provision in the host-tools group across 1 directory
2026-05-02 16:47:15 -04:00
dependabot[bot]
c3628fe03c
Bump the firmware group with 3 updates
Bumps the firmware group with 3 updates: [esp-idf-svc](https://github.com/esp-rs/esp-idf-svc), [embedded-svc](https://github.com/esp-rs/embedded-svc) and [sha2](https://github.com/RustCrypto/hashes).


Updates `esp-idf-svc` from 0.51.0 to 0.52.1
- [Changelog](https://github.com/esp-rs/esp-idf-svc/blob/master/CHANGELOG.md)
- [Commits](https://github.com/esp-rs/esp-idf-svc/commits)

Updates `embedded-svc` from 0.28.1 to 0.29.0
- [Changelog](https://github.com/esp-rs/embedded-svc/blob/master/CHANGELOG.md)
- [Commits](https://github.com/esp-rs/embedded-svc/compare/v0.28.1...v0.29.0)

Updates `sha2` from 0.10.9 to 0.11.0
- [Commits](https://github.com/RustCrypto/hashes/compare/sha2-v0.10.9...sha2-v0.11.0)

---
updated-dependencies:
- dependency-name: esp-idf-svc
  dependency-version: 0.52.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: firmware
- dependency-name: embedded-svc
  dependency-version: 0.29.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: firmware
- dependency-name: sha2
  dependency-version: 0.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: firmware
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 20:35:56 +00:00
dependabot[bot]
fa044f4be9
Bump toml in /tools/provision in the host-tools group across 1 directory
Bumps the host-tools group with 1 update in the /tools/provision directory: [toml](https://github.com/toml-rs/toml).


Updates `toml` from 0.8.23 to 1.1.2+spec-1.1.0
- [Commits](https://github.com/toml-rs/toml/compare/toml-v0.8.23...toml-v1.1.2)

---
updated-dependencies:
- dependency-name: toml
  dependency-version: 1.1.2+spec-1.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: host-tools
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 20:35:47 +00:00
fa32f0b774 Fix Dependabot on firmware crate; fold provisioning-plan.md into ota.md
Dependabot fix: drop the root rust-toolchain.toml so Dependabot's
runner doesn't try to use the (uninstalled) `esp` toolchain when
running `cargo update`. The firmware Make recipe now invokes
`cargo +esp build` explicitly so local + CI builds still pin the
right toolchain. Host tools (publisher, provision) keep their own
rust-toolchain.toml = stable.

Dependabot config: batch tools/publisher + tools/provision into one
weekly PR using the `directories:` (plural) field. Firmware Cargo
deps and GHA action versions stay in their own PRs.

ota.md: fold in the provisioning-plan.md content (now that
provisioning is shipped). Updates partition layout numbers, NVS
schema, bootstrap workflow, and trust-config description (no longer
compile-time consts). Adds a 'Soft vs hard trust' section folded
from the plan. Future Work merged.

provisioning-plan.md deleted; README and CLAUDE.md updated to point
at ota.md and drop the dropped file from the project layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:34:47 -04:00
042f26d376
Merge pull request #6 from imjasonh/provisioning
Provision Wi-Fi creds + trust roots via NVS (no secrets in OTA image)
2026-05-02 16:26:45 -04:00
1928dcfd47 Provision Wi-Fi creds + trust roots via NVS instead of compile-time embed
Implements provisioning-plan.md. The OTA-distributed firmware no longer
contains any secrets or per-device config — those live in NVS and are
written via USB by `make provision` from a (gitignored)
`provisioning.toml`. Same firmware bytes can run on any device.

Firmware changes:
- src/main.rs reads wifi/ssid + wifi/pass from NVS namespace `wifi`.
  Strict-inert (sit-and-log) when missing; no compile-time fallback.
  WIFI_SSID/WIFI_PASS env! macros gone.
- src/trust.rs becomes a `TrustConfig::load(nvs)` loader. Identities
  come from NVS namespace `trust`, key `identities` (JSON-encoded
  array). Sigstore root + intermediate PEMs come from `trust/fulcio_root`
  and `trust/fulcio_inter` blobs. include_str! gone.
- src/sig.rs and src/ota.rs thread &TrustConfig through verify_bundle
  and the OTA loop instead of reading global consts.
- build.rs no longer tracks WIFI_* env changes.

New tool:
- tools/provision/ host-side cargo crate. Reads provisioning.toml,
  emits an NVS CSV, shells out to ESP-IDF's nvs_partition_gen.py to
  produce a binary NVS image, optionally `espflash write-bin`'s it.

Make targets:
- `make provision` — build NVS image + flash it.
- `make bootstrap` — flash-all + provision (new device setup).
- `make build` no longer requires wifi.env.
- wifi.env removed from prereqs / WIFI_ENV variable removed.

Workflows:
- ci.yml: drops the placeholder wifi.env step, adds a `build provision`
  job alongside the firmware + publisher jobs.
- publish.yml: drops WIFI_SSID/WIFI_PASS secret reads. Only secret
  needed is the auto-injected GITHUB_TOKEN.

Docs:
- README updated to use `make bootstrap` + `make provision` flow.
  Project layout moved to a new repo-local CLAUDE.md.
- provisioning.toml.example committed as the template the user copies.
- wifi.env.example removed (no longer used).

Migration: existing devices need `make bootstrap` over USB. The new
firmware has no embedded creds; OTAing to it without provisioning
would just sit in strict-inert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 16:14:26 -04:00
89a80d7ba3 Plan: secrets + trust config provisioned via NVS, OTA image device-agnostic
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:58:47 -04:00
979c862941 Aggressive release-profile flags + grow OTA slots to 1.94 MB
`[profile.release]` now uses lto, codegen-units=1, strip, and
panic=abort (last one matches the build-std=panic_abort already in
.cargo/config.toml). Combined: firmware shrinks from 1.71 MB to
1.52 MB (~9% smaller).

partitions.csv: grow each OTA slot from 1.75 MB to 1.9375 MB by
absorbing the previously-unused 384 KB at the end of flash. Now no
unused space at the end. Repartitioning is a USB-only migration
(`make flash-all`); device migrated locally before pushing.

Net headroom per slot: 126 KB -> 463 KB (3.7x). Plenty for the
upcoming e-ink driver + embedded-graphics stack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:39:33 -04:00
d6d1a7ba1a Rename ota-plan.md -> ota.md; rewrite as descriptive system doc
Was structured as a multi-phase plan with each phase marked DONE; now
that the system is operational, restructure as documentation describing
what's built (architecture, components, partition layout, update flow,
anti-bricking + USB recovery, CI signing, on-device verification).

Remaining ideas moved to a 'Future work' section at the end. Dropped
explicit support for legacy cosign .sig formats from the doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 15:17:49 -04:00
0ebc860e6f README: refresh to reflect operational OTA, add CI badges
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:53:44 -04:00
15a19eb46c
Merge pull request #5 from imjasonh/dependabot/cargo/tools/publisher/publisher-f34648a8fe
Bump sha2 from 0.10.9 to 0.11.0 in /tools/publisher in the publisher group
2026-05-02 14:51:37 -04:00
24dc6b5fe1 Makefile: nuke stale python-shim symlinks before recreating
CI failure: `embuild ... Could not install esp-idf ... Failed to locate
python ... Too many levels of symbolic links`. Root cause: when
.embuild is restored from a previous workflow's cache, the stale
python-shim/python3 symlink can collide with what `uv python find`
returns -- producing a self-reference that the kernel surfaces as
ELOOP on the next access.

Fix: `rm -f` the shim symlinks before resolving uv's 3.12 path, so
each run starts from a known-empty state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:50:41 -04:00
43ca7f98ba publisher: use hex::encode for SHA digests (sha2 0.11 dropped LowerHex)
sha2 0.11 changed the digest return type from `GenericArray<u8, _>`
to `Array<u8, _>`, which doesn't implement `LowerHex` -- so the
existing `format!("{:x}", Sha256::digest(...))` calls no longer
compile. Switch to `hex::encode` (added as a dep), which works on
any `AsRef<[u8]>`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:47:32 -04:00
dependabot[bot]
bd8406e8fa
Bump sha2 in /tools/publisher in the publisher group
Bumps the publisher group in /tools/publisher with 1 update: [sha2](https://github.com/RustCrypto/hashes).


Updates `sha2` from 0.10.9 to 0.11.0
- [Commits](https://github.com/RustCrypto/hashes/compare/sha2-v0.10.9...sha2-v0.11.0)

---
updated-dependencies:
- dependency-name: sha2
  dependency-version: 0.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: publisher
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-02 18:42:22 +00:00
f94f0f9f8a Add Dependabot config: weekly grouped updates for GHA + Cargo deps
Three ecosystems: GitHub Actions (workflow files), firmware Cargo
deps (root), publisher Cargo deps (tools/publisher). Each ecosystem
groups all updates into a single weekly PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:41:15 -04:00
6596cfe0e0 publish.yml: pin cosign-installer to v4.1.1 (no v4 moving tag)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:38:22 -04:00
689270f4c9
Merge pull request #4 from imjasonh/bump-deps-and-ci
Add CI workflow, bump GHA actions, commit Cargo.lock
2026-05-02 14:33:25 -04:00
0e9559f375 CI: drop .embuild cache (corruption across runners), pass --target on publisher
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:27:42 -04:00
9ef15ba16d CI: pin setup-uv to v7 (no v8 moving tag), add publisher rust-toolchain
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:22:34 -04:00
71fa55f189 Add CI workflow, bump GHA actions, refresh + commit Cargo.lock
CI workflow (.github/workflows/ci.yml):
- Triggers on PRs (and main pushes for redundant fast feedback).
- Two parallel jobs: build the firmware (with placeholder wifi.env),
  build the host-side publisher tool. No publishing or signing -- that
  remains publish.yml's job.
- Per-PR concurrency: a new push to a branch cancels in-flight CI for
  the previous commit on that branch.

GHA action bumps (publish.yml + ci.yml both use the new versions):
- actions/checkout    v4 -> v6
- astral-sh/setup-uv  v3 -> v8
- esp-rs/xtensa-toolchain  v1.5 -> v1.7
- sigstore/cosign-installer  v3 -> v4 (defaults to cosign 3, which
  still produces the Sigstore Bundle v0.3 format we verify on-device)
- actions/cache       v4 -> v5
- Swatinem/rust-cache stays v2 (already current)

Gets us off the deprecated Node.js 20 actions runtime.

Cargo.lock: now tracked. Both crates (firmware and publisher) are
binaries, and binary Cargo.lock should be committed for reproducible
builds. `cargo update` was a no-op except for a couple transitive
patch bumps in the firmware (generic-array 0.14.7 -> 0.14.9).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:16:55 -04:00
58343daf1c GHA: drop invalid @3 spec on cargo install espflash
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:02:13 -04:00
6cc6f38ce0 Auto-publish from GHA on push to main; sign by immutable digest
GitHub Actions workflow at .github/workflows/publish.yml builds the
firmware on every push to main, pushes it to ghcr.io/imjasonh/esp32,
and cosign-signs it keylessly using the workflow's ambient OIDC token.
Required repo secrets: WIFI_SSID, WIFI_PASS (the GITHUB_TOKEN is
injected automatically).

Trust changes:
- src/trust.rs adds the GHA workflow identity to TRUSTED_IDENTITIES,
  alongside the existing email entry. The SAN URI pins the exact
  workflow file at refs/heads/main; a malicious commit that adds a
  different workflow won't match.
- src/sig.rs's SAN extractor now also handles GeneralName::Uniform-
  ResourceIdentifier (workflow URIs), not just Rfc822Name (emails).

Sign-by-digest:
- tools/publisher prints `digest: sha256:...` on stdout (tracing logs
  redirected to stderr) so downstream tooling can target the immutable
  manifest digest.
- Makefile captures that digest and runs `cosign sign ... $REPO@DIGEST`
  instead of `:latest`, eliminating the tag-race window between push
  and sign.

Bootstrap: the device's currently-running firmware doesn't know the
GHA identity yet, so the first GHA-signed publish will be rejected.
A manual `make publish` after this lands rolls out the new
TRUSTED_IDENTITIES, after which GHA-signed updates verify cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:58:44 -04:00
25ac144222 Mark phase 4a done in ota-plan.md
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:50:49 -04:00
5be27a560c Add cosign signature verification (OTA phase 4a)
Each OTA fetch is now gated on a Sigstore Bundle signature whose Fulcio
cert identifies a member of an allowlist hardcoded in src/trust.rs
(currently imjasonh@gmail.com / accounts.google.com). The allowlist
cannot be changed via OTA -- only by editing source and reflashing
over USB.

Publisher (Makefile): make publish now runs cosign sign --yes after
the publisher push. Cosign keyless OIDC pops a browser the first time;
subsequent signs reuse the cached token within ~10min.

Firmware:
- src/trust.rs: TRUSTED_IDENTITIES list + bundled Sigstore root and
  intermediate CA PEMs (trust/fulcio_root.pem, fulcio_intermediate.pem).
- src/sig.rs: parse Sigstore Bundle v0.3 (DSSE envelope), verify (a)
  Fulcio cert SAN email + OID 1.3.6.1.4.1.57264.1.1 issuer match the
  allowlist, (b) leaf cert chains to bundled Sigstore root via
  P-384 ECDSA-SHA384, (c) DSSE signature verifies via P-256 ECDSA-SHA256
  over the PAE, (d) in-toto Statement subject digest binds to our
  manifest digest.
- src/ota.rs: fetch_manifest now returns the manifest's own SHA256 (the
  digest cosign signed), not just the parsed body. New
  fetch_signature_bundle walks the OCI 1.1 referrers layout cosign uses
  (image index -> inner manifest -> bundle blob). Sig is fetched and
  verified before the firmware download starts.

Crates: p256, p384, x509-cert (with pem feature), base64. Adds
~350 KB to the firmware -- repartitioned ota slots from 1.5MB to
1.75MB to fit (USB-only migration). Bumped OTA thread stack to
48KB for cert-parsing headroom.

Verified end-to-end: Jason signed :latest with cosign keyless,
device polled, all four verification steps passed (identity, chain,
DSSE sig, in-toto binding), then downloaded and applied as before.

Phase 4b (Rekor SET / transparency log inclusion proof) and 4c
(operational hardening) remain.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:50:03 -04:00
1ab146d685 Migrate firmware logging from log to tracing
Swap `log = "0.4"` for `tracing = { version = "0.1", features = ["log"] }`.
The `log` feature makes tracing events emit log records when no subscriber
is set, so EspLogger (a log::Logger) keeps writing to ESP-IDF's log system.
No subscriber needed on the device.

Convert call sites in main.rs and src/ota.rs to tracing macros and
restructure the high-signal lines to use structured fields:

  ota: boot summary fw="b517946" repo=ghcr.io/imjasonh/esp32 tag=latest
                    poll_secs=60 last_digest=sha256:c7e09ea...
  ota: sleeping sleep_secs=63 failures=0
  ota: download progress written=262144 total=1272624

The bridged log records render fields as `key=value` (and `key="value"`
for Debug-formatted ones) appended to the message text -- greppable,
and ready for a real tracing subscriber if we want one later.

Verified end-to-end on the device: phase 3 image OTA'd to the tracing
build, boot summary logged with new field syntax.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:10:32 -04:00
b517946cce Add OTA operational glue (OTA phase 3, partial)
src/ota.rs:
- OtaConfig::load_from_nvs reads `repo` (str), `tag` (str), and
  `poll_secs` (u32) from the `ota` namespace, falling back to the
  compile-time defaults when keys are absent. Provisioning the values
  from outside the firmware is deferred (see ota-plan.md).
- Exponential backoff with +/- 10% jitter on registry errors:
  consecutive_failures grows on each failure, sleep is
  poll_interval * 2^failures capped at 1h. Jitter is also applied on
  the success path so a fleet doesn't poll in lockstep. Randomness
  via esp_random().
- Boot summary log: one line on startup with fw version, repo, tag,
  poll interval, and last applied digest. Easier to diagnose remote
  device state from a single grep.

main.rs passes FW_VERSION (the git SHA from env!) to ota::run, which
includes it in the boot summary. The OtaConfig parameter is gone --
ota::run loads from NVS itself.

ota-plan.md marks phase 3 done (partial); If-None-Match, force-update
GPIO, and an NVS provisioning mechanism explicitly deferred.

Verified end-to-end: published phase 3 image, watched the device pick
it up, boot into ota_1, mark valid, then log the boot summary. Two
consecutive sleeps fired at 64s and 66s for the same 60s base, proving
jitter is firing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:04:17 -04:00
a91cf62da6 Add device-side OTA loop (OTA phase 2)
src/ota.rs polls ghcr.io every 60s on a dedicated 32 KB pthread,
fetches the manifest with an anonymous Bearer token, and on a digest
mismatch streams the layer into the inactive OTA partition while
verifying SHA256 against the descriptor as it goes. Aborts and
bails on size or SHA mismatch; otherwise sets the boot partition,
persists pending_digest to NVS, and reboots.

main.rs detects PENDING_VERIFY on boot and only calls
esp_ota_mark_app_valid_cancel_rollback() after Wi-Fi + the existing
HTTPS bringup checks pass -- so an OTA that breaks networking
auto-reverts on the bootloader's next boot. After mark-valid,
pending_digest is promoted to last_digest in NVS.

GIT_SHA is baked into the firmware via env!() at compile time
(set by the Makefile from `git rev-parse --short HEAD`) and logged
on boot, so we can see which build is running.

Bumped CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT to 16 KB; 8 KB
stack-overflowed during HTTPS + JSON + SHA work.

`make monitor` now auto-passes --non-interactive when stdout is
not a TTY, so it works in scripts and background tasks.

Verified end-to-end against ghcr.io/imjasonh/esp32 -- published v1,
flashed via USB, bumped a visible version string, published v2,
and the device polled, downloaded, SHA-verified, rebooted, and
marked the new image valid after bringup. Zero USB intervention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:53:19 -04:00
2c3ea9380a Add OCI artifact publisher (OTA phase 1)
tools/publisher/ wraps a firmware .bin as an OCI artifact and pushes
to GHCR using oci-distribution. Mediatypes vnd.esp32.firmware.{v1+json,
bin}; manifest carries org.opencontainers.image.source so GHCR auto-
links the package on first push. Pushes both :latest and :sha-<short>.

A pull-verify subcommand fetches the manifest and layer back and checks
the SHA against a local file -- end-to-end verified pushes and pulls
match bit-for-bit. Same oci-distribution API will be reused for the
device-side fetch in phase 2.

Wired into Make as save-image / publish / pull-verify. GH_TOKEN sourced
from a gitignored gh.env (classic PAT with write:packages -- fine-
grained PATs don't expose a Packages permission for user-owned packages).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:29:51 -04:00
0a5d0530d1 Switch to OTA partition layout and document Wi-Fi + HTTPS demo
2-slot OTA partition table (ota_0/ota_1, 1.5MB each) on 4MB flash.
sdkconfig.defaults is now generated by the Makefile from a template
so we can substitute the absolute path to partitions.csv -- IDF's
CMake resolves CONFIG_PARTITION_TABLE_CUSTOM_FILENAME relative to
embuild's synthetic project under target/, not our repo root.

Adds make flash-all for full erase + bootloader + partition table +
app reflash; doubles as USB recovery if both OTA slots end up bad.

Includes plans for e-ink display work (eink-plan.md) and OCI-based
OTA updates via GHCR (ota-plan.md). Phase 0 of OTA shipped: device
boots into ota_0, verified via esp_ota_get_running_partition() log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 12:14:21 -04:00
9d28a3b437 initial commit
Signed-off-by: Jason Hall <imjasonh@gmail.com>
2026-05-02 11:43:41 -04:00
6b132b9937
Initial commit 2026-05-02 11:43:06 -04:00