Compare commits
2 Commits
a1f58b7953
...
c12539d933
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c12539d933 | ||
|
|
f8a4d53851 |
17
firmware/.env.example
Normal file
17
firmware/.env.example
Normal 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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
202
firmware/src/bin/wifi_tls.rs
Normal file
202
firmware/src/bin/wifi_tls.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user