Compare commits

..

2 Commits

Author SHA1 Message Date
Julien Calixte
c12539d933 docs(firmware): record Spike 6 Wi-Fi + TLS verification 2026-07-05 09:22:36 +02:00
Julien Calixte
f8a4d53851 feat(firmware): add Wi-Fi + TLS spike (Spike 6)
Standalone `wifi_tls` binary: station assoc, SNTP, then a validated
HTTPS GET to api.github.com against the esp-idf cert bundle. Gates
Spike 7 (gitoxide push). Creds come from firmware/.env via build.rs.
2026-07-05 09:22:31 +02:00
7 changed files with 304 additions and 7 deletions

17
firmware/.env.example Normal file
View File

@@ -0,0 +1,17 @@
# Build-time config for the network spikes (6/7). Copy to `.env` and fill in.
# `.env` is gitignored; `just` loads it automatically (dotenv-load) so the
# values reach build.rs, which emits them as compile-time env for the binary.
#
# cp .env.example .env # then edit
# just flash-wifi # Spike 6 — Wi-Fi + TLS
#
# Only the editor build (`just flash`) needs none of these.
# Home Wi-Fi (2.4 GHz — the ESP32-S3 has no 5 GHz radio).
TW_WIFI_SSID=your-ssid
TW_WIFI_PASS=your-password
# Note: the spike assumes WPA2-Personal. Open network → leave TW_WIFI_PASS empty.
# A WPA3-only AP needs AuthMethod::WPA3Personal in src/bin/wifi_tls.rs.
# Spike 7 (gitoxide push) will add: TW_REMOTE_URL, TW_GH_USER, TW_PAT,
# TW_AUTHOR_NAME, TW_AUTHOR_EMAIL.

View File

@@ -5,11 +5,23 @@ authors = ["Julien Calixte <juliencalixte@gmail.com>"]
edition = "2024"
resolver = "2"
rust-version = "1.85"
# Declare every binary explicitly so a spike program under src/bin/ doesn't get
# an auto-discovered target with the default test harness (which trips up
# rust-analyzer — see README "harness = false" note).
autobins = false
[[bin]]
name = "firmware"
path = "src/main.rs"
harness = false # do not use the built-in cargo test harness -> resolve rust-analyzer errors
# Spike 6 — Wi-Fi + TLS. Standalone bench program, kept separate from the
# editor firmware. Flash with `just flash-wifi`.
[[bin]]
name = "wifi_tls"
path = "src/bin/wifi_tls.rs"
harness = false
[profile.release]
opt-level = "s"
@@ -28,6 +40,9 @@ esp-idf-svc = { version = "0.52.1", features = ["critical-section", "embassy-tim
# Remove `generic-queue-8` if you plan to use `embassy-time` WITH `embassy-executor`
embassy-time = { version = "0.5", features = ["generic-queue-8"] }
embedded-graphics = "0.8"
# Traits shared with esp-idf-svc (Spike 6 uses `embedded_svc::http::Method`).
# Already in the tree via esp-idf-svc; pinned here so the spike names it directly.
embedded-svc = "0.29"
[build-dependencies]
embuild = "0.33"

View File

@@ -42,6 +42,30 @@ the `$` / end-of-line block caret sits one cell past the last char, `iw` / `aw`
are whitespace-delimited (like vim's `iW` / `aW`), and `cw` isn't special-cased
to `ce`.
**Spike 6 — Wi-Fi + TLS: verified 2026-07-05.** A separate binary —
[`src/bin/wifi_tls.rs`](src/bin/wifi_tls.rs), flashed with `just flash-wifi`
kept apart from the editor firmware. It brings up the station, syncs the clock
over SNTP (mbedtls validates the server cert against wall time, so the 1970 RTC
has to be corrected first), then does an HTTPS GET to `https://api.github.com/`
with cert-chain validation against the esp-idf certificate bundle
(`esp_crt_bundle_attach`), and logs status, a body preview, and free heap around
the handshake (TLS heap pressure is a watched risk). A validated GET is the gate
for Spike 7 (gitoxide push over HTTPS + PAT).
Bench result (WPA2-PSK AP, 2.4 GHz): associate ~3 s → DHCP → SNTP first sync →
`esp-x509-crt-bundle: Certificate validated``HTTPS GET … → 200`, reading real
GitHub JSON. **TLS handshake cost ≈ 35 KB heap** (265 → 229 KB, recovered
after), clean and repeatable across reboots. Note: PSRAM is **not** enabled yet
(only ~339 KB internal heap) — TLS fits, but Spike 7's gitoxide working set will
need `CONFIG_SPIRAM` turned on first.
Credentials are build-time: copy [`.env.example`](.env.example) to `.env`, set
`TW_WIFI_SSID` / `TW_WIFI_PASS`, and `just` loads them (dotenv) so `build.rs`
bakes them in. `.env` is gitignored; the editor build (`just flash`) needs none
of it. `sdkconfig.defaults` gains the full certificate bundle and a bigger main
task stack for the mbedtls handshake — a one-time esp-idf reconfigure on the
next build.
**Spike 5 — partial refresh + typing: verified 2026-07-04.** `main.rs` wires
the keyboard to the panel: [`src/usb_kbd.rs`](src/usb_kbd.rs) feeds decoded
key-downs (US layout, edge-detected) into a queue, and the main loop keeps a
@@ -94,7 +118,8 @@ reseat the jumpers (CS first) before debugging code.
Next up per
[`docs/v0.1-mvp-technical.md`](../docs/v0.1-mvp-technical.md#hardware-bring-up-order):
Wi-Fi/TLS, gitoxide push; SD is deferred.
Wi-Fi/TLS (Spike 6, implemented above), then gitoxide push (Spike 7); SD is
deferred.
**Spike 1 — Blink: verified 2026-07-04.** GPIO 2 + on-board WS2812 toggled
at 1 Hz with `blink N` on USB-serial, proving toolchain, esp-idf link, and

View File

@@ -21,6 +21,18 @@ fn main() {
.unwrap_or_else(|| "unknown".into());
println!("cargo:rustc-env=BUILD_GIT={git}");
println!("cargo:rustc-env=BUILD_TIME={time}Z");
// Wi-Fi credentials for the network spikes (6/7) and, later, the runtime.
// Read at build time and emitted as compile-time env so a binary can pull
// them in with env!(). Empty when unset: the network spike checks at
// runtime and prints a clear message, so the *editor* build never has to
// carry Wi-Fi creds. Source them from firmware/.env (loaded by `just`).
for var in ["TW_WIFI_SSID", "TW_WIFI_PASS"] {
let val = std::env::var(var).unwrap_or_default();
println!("cargo:rustc-env={var}={val}");
println!("cargo:rerun-if-env-changed={var}");
}
// Pointing rerun-if-changed at a file that never exists forces this
// script to rerun on every build, keeping BUILD_TIME fresh.
println!("cargo:rerun-if-changed=.force-build-stamp");

View File

@@ -2,8 +2,13 @@
# Recipes source ~/export-esp.sh themselves (LIBCLANG_PATH + Xtensa GCC),
# so no per-shell setup is needed before calling just.
# Load firmware/.env (Wi-Fi creds for the network spikes) into recipe env.
# Absent .env is fine — the editor build ignores TW_* entirely.
set dotenv-load := true
esp_env := ". ~/export-esp.sh &&"
elf := "target/xtensa-esp32s3-espidf/release/firmware"
elf_wifi := "target/xtensa-esp32s3-espidf/release/wifi_tls"
# list recipes
default:
@@ -11,16 +16,28 @@ default:
# compile (release)
build:
{{esp_env}} cargo build --release
{{esp_env}} cargo build --release --bin firmware
# build + flash + open serial monitor
flash:
{{esp_env}} cargo run --release
{{esp_env}} cargo run --release --bin firmware
# serial monitor only, with decoded backtraces
monitor:
espflash monitor --elf {{elf}}
# Spike 6 — build the Wi-Fi + TLS spike (needs TW_WIFI_SSID / TW_WIFI_PASS in .env)
build-wifi:
{{esp_env}} cargo build --release --bin wifi_tls
# Spike 6 — flash + monitor the Wi-Fi + TLS spike
flash-wifi:
{{esp_env}} cargo run --release --bin wifi_tls
# serial monitor for the Wi-Fi spike, with decoded backtraces
monitor-wifi:
espflash monitor --elf {{elf_wifi}}
# detect board, print chip/MAC/flash size
info:
espflash board-info

View File

@@ -1,12 +1,21 @@
# Rust often needs a bit of an extra main task stack size compared to C (the default is 3K)
# You might have to increase this further if you allocate large stack variables in the main task
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
# You might have to increase this further if you allocate large stack variables in the main task.
# Bumped for the TLS handshake (Spike 6): mbedtls runs on the calling task and
# wants several KB of stack on top of the app's own usage.
CONFIG_ESP_MAIN_TASK_STACK_SIZE=12288
# Increase a bit these stack sizes as they are also a bit too small by default
CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096
CONFIG_FREERTOS_IDLE_TASK_STACKSIZE=4096
# You might have to increase this further if you spawn your own Rust threads
# that allocate large stack variables; or better yet - use
# You might have to increase this further if you spawn your own Rust threads
# that allocate large stack variables; or better yet - use
# `std::thread::Builder::new().stack_size(XXX)` for spawning
CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT=4096
# TLS trust store (Spike 6 — Wi-Fi + TLS, the gate for Spike 7 gitoxide push).
# The certificate bundle backs esp_crt_bundle_attach so an HTTPS GET to
# api.github.com validates against real roots. FULL rather than the common
# subset so a less common CA in the chain can't surprise us on the bench.
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE=y
CONFIG_MBEDTLS_CERTIFICATE_BUNDLE_DEFAULT_FULL=y

View File

@@ -0,0 +1,202 @@
//! Spike 6 — Wi-Fi + TLS.
//!
//! A small standalone bench program (separate binary from the editor firmware)
//! that proves the networking + TLS stack end to end:
//!
//! 1. Bring up the station and associate with the home AP.
//! 2. Sync the clock over SNTP — mbedtls checks the server cert's
//! not-before/not-after against wall time, so without this the 1970 RTC
//! makes every handshake fail with "certificate is not valid yet".
//! 3. HTTPS GET https://api.github.com/ with cert-chain validation against
//! the esp-idf certificate bundle (esp_crt_bundle_attach), and read the
//! response body.
//!
//! A validated GET is the whole point: it's the gate for Spike 7 (gitoxide
//! push over HTTPS + PAT). Free heap is logged around the handshake because
//! TLS heap pressure on this chip is a top-3 watched risk (see qfd.md §6).
//!
//! Credentials come from build-time env (build.rs → env!): set TW_WIFI_SSID /
//! TW_WIFI_PASS in firmware/.env and run `just flash-wifi`.
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use embedded_svc::http::Method;
use esp_idf_svc::eventloop::EspSystemEventLoop;
use esp_idf_svc::hal::delay::FreeRtos;
use esp_idf_svc::hal::peripherals::Peripherals;
use esp_idf_svc::http::client::{Configuration as HttpConfig, EspHttpConnection};
use esp_idf_svc::nvs::EspDefaultNvsPartition;
use esp_idf_svc::sntp::{EspSntp, SyncStatus};
use esp_idf_svc::wifi::{AuthMethod, BlockingWifi, ClientConfiguration, Configuration, EspWifi};
/// Injected by build.rs so serial output identifies the exact build.
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
/// Wi-Fi credentials, baked in at build time from firmware/.env. Empty when
/// unset — checked at runtime so the editor build never depends on them.
const WIFI_SSID: &str = env!("TW_WIFI_SSID");
const WIFI_PASS: &str = env!("TW_WIFI_PASS");
/// The validated endpoint. Root of the GitHub REST API: returns JSON, needs a
/// User-Agent, and is served over a normal public CA chain — a faithful
/// stand-in for the api.github.com host Spike 7 will push through.
const TEST_URL: &str = "https://api.github.com/";
/// SNTP first-sync budget. Home networks resolve pool.ntp.org and answer well
/// within this; failing past it is a real problem worth surfacing.
const SNTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
fn main() -> Result<()> {
// Required once before any esp-idf-svc call; some runtime patches only link
// if this symbol is referenced. See esp-idf-template#71.
esp_idf_svc::sys::link_patches();
esp_idf_svc::log::EspLogger::initialize_default();
log::info!("Typoena — Spike 6 (Wi-Fi + TLS), {BUILD_TAG}");
match run() {
Ok(()) => log::info!("✅ Spike 6 complete — Wi-Fi assoc + SNTP + validated HTTPS GET"),
Err(e) => log::error!("❌ Spike 6 failed: {e:?}"),
}
// Idle instead of returning, so the result stays on screen and Wi-Fi/other
// tasks keep running for inspection rather than the app winding down.
loop {
FreeRtos::delay_ms(1000);
}
}
fn run() -> Result<()> {
if WIFI_SSID.is_empty() {
bail!("TW_WIFI_SSID is empty — set it in firmware/.env (see .env.example) and rebuild");
}
let peripherals = Peripherals::take()?;
let sys_loop = EspSystemEventLoop::take()?;
let nvs = EspDefaultNvsPartition::take()?;
let mut wifi = BlockingWifi::wrap(
EspWifi::new(peripherals.modem, sys_loop.clone(), Some(nvs))?,
sys_loop,
)?;
connect_wifi(&mut wifi)?;
let ip = wifi.wifi().sta_netif().get_ip_info()?;
log::info!("Wi-Fi up — IP {}, GW {}", ip.ip, ip.subnet.gateway);
sync_clock()?;
https_get(TEST_URL)?;
Ok(())
}
/// Associate with the configured AP and wait for the netif (DHCP) to come up.
fn connect_wifi(wifi: &mut BlockingWifi<EspWifi<'static>>) -> Result<()> {
// Open network → no auth; otherwise WPA2-Personal (see .env.example for WPA3).
let auth_method = if WIFI_PASS.is_empty() {
AuthMethod::None
} else {
AuthMethod::WPA2Personal
};
wifi.set_configuration(&Configuration::Client(ClientConfiguration {
ssid: WIFI_SSID
.try_into()
.ok()
.context("SSID longer than 32 bytes")?,
password: WIFI_PASS
.try_into()
.ok()
.context("password longer than 64 bytes")?,
auth_method,
..Default::default()
}))?;
wifi.start()?;
log::info!("associating with \"{WIFI_SSID}\"");
wifi.connect().context("Wi-Fi association failed")?;
wifi.wait_netif_up().context("DHCP / netif never came up")?;
Ok(())
}
/// Kick off SNTP and block until the first sync (or time out). Required before
/// TLS: cert validity is checked against wall time.
fn sync_clock() -> Result<()> {
let sntp = EspSntp::new_default()?;
log::info!("SNTP started, waiting for first sync…");
let start = Instant::now();
while sntp.get_sync_status() != SyncStatus::Completed {
if start.elapsed() >= SNTP_TIMEOUT {
bail!("SNTP did not sync within {SNTP_TIMEOUT:?} — TLS cert validity would fail");
}
FreeRtos::delay_ms(100);
}
let unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// A synced clock lands well past this (2023-11-14); anything below means the
// RTC never actually advanced and TLS would reject on validity.
if unix < 1_700_000_000 {
bail!("clock still at {unix} after SNTP sync — refusing TLS with a bad wall clock");
}
log::info!("clock synced — unix {unix}");
Ok(())
}
/// HTTPS GET with cert-chain validation against the esp-idf certificate bundle.
/// Logs status, the first chunk of the body, and free heap around the request.
fn https_get(url: &str) -> Result<()> {
let heap_before = unsafe { esp_idf_svc::sys::esp_get_free_heap_size() };
let mut conn = EspHttpConnection::new(&HttpConfig {
// Validate the server chain against the bundled roots. If this is None,
// the handshake skips verification — which would defeat the spike.
crt_bundle_attach: Some(esp_idf_svc::sys::esp_crt_bundle_attach),
..Default::default()
})
.context("creating the HTTPS connection (TLS init)")?;
// GitHub rejects requests without a User-Agent.
let headers = [
("User-Agent", "typoena-spike6"),
("Accept", "application/vnd.github+json"),
];
conn.initiate_request(Method::Get, url, &headers)
.context("TLS handshake / request send failed")?;
conn.initiate_response()?;
let status = conn.status();
log::info!("HTTPS GET {url} → {status}");
// Preview the first chunk, then drain the rest so we log the real byte count
// (proves the encrypted stream reads back cleanly, not just the handshake).
let mut buf = [0u8; 512];
let first = conn.read(&mut buf)?;
log::info!(
"body[..{first}]: {}",
String::from_utf8_lossy(&buf[..first]).replace('\n', " ")
);
let mut total = first;
loop {
let n = conn.read(&mut buf)?;
if n == 0 {
break;
}
total += n;
}
let heap_after = unsafe { esp_idf_svc::sys::esp_get_free_heap_size() };
log::info!(
"read {total} bytes; free heap {heap_before} → {heap_after} (Δ {} B during TLS)",
heap_before as i64 - heap_after as i64
);
if !(200..300).contains(&status) {
bail!("unexpected HTTP status {status} (TLS validated, but the request was not OK)");
}
Ok(())
}