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.
This commit is contained in:
Julien Calixte
2026-07-05 09:22:31 +02:00
parent a1f58b7953
commit f8a4d53851
6 changed files with 278 additions and 6 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

@@ -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(())
}