Skip to content

Commit 5c90af0

Browse files
authored
esp32: default to native espflash with esptool fallback (#197)
* esp32: default to native espflash with esptool fallback * esp32: address CodeRabbit review feedback
1 parent 37b327c commit 5c90af0

5 files changed

Lines changed: 151 additions & 71 deletions

File tree

crates/fbuild-daemon/Cargo.toml

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,10 @@ name = "fbuild_daemon"
1515
path = "src/lib.rs"
1616

1717
[features]
18-
# Issue #66 spike. Forwards to `fbuild-deploy/espflash-native` so
19-
# `FBUILD_USE_ESPFLASH_VERIFY=1` / `FBUILD_USE_ESPFLASH_WRITE=1` can
20-
# actually route through espflash at runtime. Default-off: the daemon
21-
# binary we ship keeps the conservative esptool-subprocess path unless
22-
# the CI/release pipeline flips this on explicitly.
23-
default = []
18+
# Compile native espflash-backed ESP32 verify/write support into the daemon
19+
# by default. Runtime fallback still routes through esptool automatically if
20+
# the native path fails on a board/host combination.
21+
default = ["espflash-native"]
2422
espflash-native = ["fbuild-deploy/espflash-native"]
2523

2624
[dependencies]
@@ -58,7 +56,7 @@ tempfile = { workspace = true }
5856
socket2 = "0.6"
5957

6058
# libc::kill(pid, 0) is used as a probe to detect dead daemon PIDs at
61-
# startup (stale-PID cleanup). Unix-only Windows uses a manual
59+
# startup (stale-PID cleanup). Unix-only; Windows uses a manual
6260
# OpenProcess FFI in main.rs.
6361
[target.'cfg(unix)'.dependencies]
6462
libc = "0.2"

crates/fbuild-daemon/src/handlers/operations.rs

Lines changed: 64 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,13 @@ use std::sync::Arc;
1818
/// pre-checks through the native [`espflash`] crate (issue #66) instead
1919
/// of the Python `esptool` subprocess.
2020
///
21-
/// Controlled by the `FBUILD_USE_ESPFLASH_VERIFY` environment variable
22-
/// (set to `1`, `true`, `yes`, or `on` to enable — case-insensitive).
23-
/// Any other value — including unset — keeps the default esptool path,
24-
/// so users on unusual setups retain the existing escape hatch until
25-
/// the native path has bench time on every ESP32 family member.
21+
/// Controlled by the `FBUILD_USE_ESPFLASH_VERIFY` environment variable.
22+
/// Native verify is enabled by default when compiled in.
23+
/// Set this variable to `0`, `false`, `no`, or `off` (case-insensitive)
24+
/// to force esptool.
2625
#[cfg(feature = "espflash-native")]
2726
pub(crate) fn native_verify_enabled() -> bool {
28-
match std::env::var("FBUILD_USE_ESPFLASH_VERIFY") {
29-
Ok(v) => matches!(
30-
v.trim().to_ascii_lowercase().as_str(),
31-
"1" | "true" | "yes" | "on"
32-
),
33-
Err(_) => false,
34-
}
27+
env_default_enabled("FBUILD_USE_ESPFLASH_VERIFY")
3528
}
3629

3730
/// Returns `true` when the daemon should trust an in-memory firmware
@@ -137,20 +130,23 @@ pub(crate) fn compute_esp32_image_hash(
137130
/// through the native [`espflash`] crate (issue #66) instead of the
138131
/// Python `esptool` subprocess.
139132
///
140-
/// Controlled by the `FBUILD_USE_ESPFLASH_WRITE` environment variable
141-
/// (set to `1`, `true`, `yes`, or `on` to enable — case-insensitive).
142-
/// Independent of `FBUILD_USE_ESPFLASH_VERIFY` — either toggle can be
143-
/// flipped without the other. Default off so esptool remains the safe
144-
/// fallback while the native write path accumulates bench time across
145-
/// every ESP32 family member.
133+
/// Controlled by the `FBUILD_USE_ESPFLASH_WRITE` environment variable.
134+
/// Native write is enabled by default when compiled in. Independent of
135+
/// `FBUILD_USE_ESPFLASH_VERIFY`. Set it to `0`, `false`, `no`, or `off`
136+
/// (case-insensitive) to force esptool.
146137
#[cfg(feature = "espflash-native")]
147138
pub(crate) fn native_write_enabled() -> bool {
148-
match std::env::var("FBUILD_USE_ESPFLASH_WRITE") {
149-
Ok(v) => matches!(
139+
env_default_enabled("FBUILD_USE_ESPFLASH_WRITE")
140+
}
141+
142+
#[cfg(feature = "espflash-native")]
143+
fn env_default_enabled(name: &str) -> bool {
144+
match std::env::var(name) {
145+
Ok(v) => !matches!(
150146
v.trim().to_ascii_lowercase().as_str(),
151-
"1" | "true" | "yes" | "on"
147+
"0" | "false" | "no" | "off"
152148
),
153-
Err(_) => false,
149+
Err(_) => true,
154150
}
155151
}
156152

@@ -1260,14 +1256,11 @@ pub async fn deploy(
12601256
} else {
12611257
deployer
12621258
};
1263-
// Issue #66: opt-in native `verify-flash` + `write-flash`
1264-
// via the `espflash` crate. Only compiled in when the
1265-
// daemon is built with `--features espflash-native`;
1266-
// default builds keep the esptool-subprocess path and
1267-
// skip pulling espflash + its ~30 transitive deps. At
1268-
// runtime, further gated by the `FBUILD_USE_ESPFLASH_*`
1269-
// env vars so operators can still fall back to esptool
1270-
// on an espflash-native build.
1259+
// Issue #66: native `verify-flash` + `write-flash` via
1260+
// the `espflash` crate. This is compiled in by default;
1261+
// `FBUILD_USE_ESPFLASH_*` are opt-out switches, and the
1262+
// deployer falls back to esptool automatically when a
1263+
// native operation fails.
12711264
#[cfg(feature = "espflash-native")]
12721265
let deployer = deployer
12731266
.with_native_verify(native_verify_enabled())
@@ -2243,6 +2236,47 @@ mod deploy_message_tests {
22432236
}
22442237
}
22452238

2239+
#[cfg(all(test, feature = "espflash-native"))]
2240+
mod espflash_env_tests {
2241+
use super::{native_verify_enabled, native_write_enabled};
2242+
2243+
// This lock only serializes these unit tests while they mutate the
2244+
// process environment. Production callers and any other tests reading
2245+
// the same env vars remain unsynchronized.
2246+
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2247+
2248+
#[test]
2249+
fn native_verify_defaults_on_and_allows_opt_out() {
2250+
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2251+
std::env::remove_var("FBUILD_USE_ESPFLASH_VERIFY");
2252+
assert!(native_verify_enabled());
2253+
2254+
std::env::set_var("FBUILD_USE_ESPFLASH_VERIFY", "0");
2255+
assert!(!native_verify_enabled());
2256+
2257+
std::env::set_var("FBUILD_USE_ESPFLASH_VERIFY", "false");
2258+
assert!(!native_verify_enabled());
2259+
2260+
std::env::set_var("FBUILD_USE_ESPFLASH_VERIFY", "1");
2261+
assert!(native_verify_enabled());
2262+
std::env::remove_var("FBUILD_USE_ESPFLASH_VERIFY");
2263+
}
2264+
2265+
#[test]
2266+
fn native_write_defaults_on_and_allows_opt_out() {
2267+
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
2268+
std::env::remove_var("FBUILD_USE_ESPFLASH_WRITE");
2269+
assert!(native_write_enabled());
2270+
2271+
std::env::set_var("FBUILD_USE_ESPFLASH_WRITE", "off");
2272+
assert!(!native_write_enabled());
2273+
2274+
std::env::set_var("FBUILD_USE_ESPFLASH_WRITE", "yes");
2275+
assert!(native_write_enabled());
2276+
std::env::remove_var("FBUILD_USE_ESPFLASH_WRITE");
2277+
}
2278+
}
2279+
22462280
#[cfg(test)]
22472281
mod image_hash_memo_tests {
22482282
//! Memo-cache correctness for [`compute_esp32_image_hash`]: the

crates/fbuild-deploy/Cargo.toml

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,10 @@ rust-version.workspace = true
77
license.workspace = true
88

99
[features]
10-
# Default is the conservative esptool-subprocess path. The native
11-
# espflash-backed verify/write paths compile in only when the caller
12-
# explicitly opts in via this feature — keeps the default dep graph slim
13-
# (no espflash/strum/deku/miette/... pulled in for consumers that don't
14-
# need it) and gives issue #66's spike a clean kill-switch while it
15-
# accumulates bench time on every ESP32 family member.
16-
default = []
10+
# Compile the native espflash-backed verify/write paths by default. The
11+
# ESP32 deployer keeps the esptool subprocess path as an automatic runtime
12+
# fallback when native connect/verify/write fails.
13+
default = ["espflash-native"]
1714
espflash-native = ["dep:espflash", "dep:md-5"]
1815

1916
[dependencies]
@@ -31,15 +28,12 @@ tracing = { workspace = true }
3128
async-trait = { workspace = true }
3229
object = { workspace = true }
3330
# Native ESP32 flasher protocol (issue #66). The `serialport` feature
34-
# gates the `Flasher` type (the real transport entry point) so we enable
35-
# it; default features stay off to keep the CLI/TUI surface out of our
36-
# dependency graph. Optional so default builds don't pull espflash +
37-
# its ~30 transitive deps (strum, deku, miette, ...); enabled via the
38-
# `espflash-native` feature.
31+
# gates the `Flasher` type (the real transport entry point), so it is
32+
# enabled while espflash's CLI/TUI defaults stay off.
3933
espflash = { version = "4", default-features = false, features = ["serialport"], optional = true }
4034
# Local MD5 of firmware regions for comparison against espflash's on-chip
4135
# `FLASH_MD5SUM` command. Optional and gated behind `espflash-native`
42-
# since it's only used by the native verify path.
36+
# since it is only used by the native verify path.
4337
md-5 = { version = "0.10", optional = true }
4438

4539
[dev-dependencies]

crates/fbuild-deploy/README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,29 @@
11
# fbuild-deploy
22

3-
Firmware deployment to embedded devices via platform-specific upload tools (esptool, avrdude, teensy_loader_cli), and device reset sequences.
3+
Firmware deployment to embedded devices via platform-specific upload tools (espflash/esptool, avrdude, teensy_loader_cli), and device reset sequences.
44

55
## Key Types
66

77
- `Deployer` -- trait for platform-specific firmware upload (`deploy` method)
88
- `DeploymentResult` -- success/failure with message and optional port
9-
- `Esp32Deployer` -- esptool-based deployer with chip-specific flash offsets and modes
9+
- `Esp32Deployer` -- ESP32 deployer with native espflash fast path and esptool fallback
1010
- `AvrDeployer` -- avrdude-based deployer for Arduino boards
1111
- `TeensyDeployer` -- teensy_loader_cli-based deployer via USB HID
1212

1313
## Modules
1414

1515
- **esp32** -- `Esp32Deployer`, `EsptoolParams`; handles bootloader/partitions/firmware offsets per MCU
16-
- **esp32_native** -- native espflash-backed `verify-flash` and `write-flash` (issue #66); opt-in via `FBUILD_USE_ESPFLASH_VERIFY=1` / `FBUILD_USE_ESPFLASH_WRITE=1` (independent toggles), falls back to esptool subprocess by default
16+
- **esp32_native** -- native espflash-backed `verify-flash` and `write-flash` (issue #66); enabled by default when compiled in, with automatic esptool subprocess fallback
1717
- **avr** -- `AvrDeployer`, `AvrdudeParams`; flashes firmware.hex via serial
1818
- **teensy** -- `TeensyDeployer`, `TeensyLoaderParams`; flashes firmware.hex via USB
1919
- **reset** -- `reset_device`, `detect_platform_for_reset`; DTR/RTS toggle sequences per platform
2020

2121
Skip-redeploy is handled authoritatively by the daemon's device-side `verify-flash` pre-check (see `handlers/operations.rs`), which asks the ESP32 stub flasher for a per-region MD5 via `FLASH_MD5SUM` before writing. The previous client-side `FirmwareLedger` was removed (issue #18) because it could not detect flashes performed outside fbuild.
2222

23-
### Native verify-flash and write-flash (issue #66, opt-in)
23+
### Native verify-flash and write-flash (issue #66)
2424

25-
Setting `FBUILD_USE_ESPFLASH_VERIFY=1` routes the ESP32 verify pre-check through the native [`espflash`](https://crates.io/crates/espflash) crate instead of the Python `esptool` subprocess, avoiding ~1 s of interpreter startup and ~0.5 s of subprocess spawn per invocation. Setting `FBUILD_USE_ESPFLASH_WRITE=1` routes `write-flash` through the same in-process path both the full deploy and the selective post-verify-mismatch rewrite. The two toggles are independent, so you can opt into native verify without native write (or vice versa). The daemon pre-empts any active serial monitor via `SharedSerialManager::preempt_for_deploy` before opening the port, so the native path never contends with the existing lease.
25+
The ESP32 verify pre-check uses the native [`espflash`](https://crates.io/crates/espflash) crate by default instead of the Python `esptool` subprocess, avoiding ~1 s of interpreter startup and ~0.5 s of subprocess spawn per invocation. `write-flash` uses the same in-process path for both full deploys and selective post-verify-mismatch rewrites. If native verify/write fails, the deployer logs a warning and retries the same operation through esptool. Set `FBUILD_USE_ESPFLASH_VERIFY=0` and/or `FBUILD_USE_ESPFLASH_WRITE=0` to force esptool for either phase.
2626

27-
Progress from espflash's `ProgressCallbacks` is bridged into `tracing` and throttled to roughly one log line per 10% of each region, which the daemon's existing log broadcaster surfaces. Structured WebSocket progress frames on the deploy channel are a follow-up.
27+
The daemon pre-empts any active serial monitor via `SharedSerialManager::preempt_for_deploy` before opening the port, so neither path contends with the existing lease. Progress from espflash's `ProgressCallbacks` is bridged into `tracing` and throttled to roughly one log line per 10% of each region, which the daemon's existing log broadcaster surfaces. Structured WebSocket progress frames on the deploy channel are a follow-up.
2828

2929
See `docs/architecture/deploy-preemption.md` for architecture details.

crates/fbuild-deploy/src/esp32.rs

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -50,28 +50,53 @@ pub struct Esp32Deployer {
5050
/// instead of the Python `esptool` subprocess.
5151
///
5252
/// The daemon sets this from the `FBUILD_USE_ESPFLASH_VERIFY` env
53-
/// var. Default `false` keeps esptool as the fallback so users on
54-
/// unusual setups aren't forced onto the native path until it has
55-
/// bench time on every ESP32 family member.
53+
/// var. When enabled, the deployer still falls back to esptool if
54+
/// the native path fails on a given board or host.
5655
///
5756
/// Only compiled in when the `espflash-native` cargo feature is
58-
/// enabled (issue #66 spike). Without the feature the struct layout
59-
/// doesn't even carry the flag, so default builds pay zero cost for
60-
/// the native path.
57+
/// enabled.
6158
#[cfg(feature = "espflash-native")]
6259
use_native_verify: bool,
6360
/// Route `write-flash` through the native [`espflash`] crate
6461
/// instead of the Python `esptool` subprocess (issue #66).
6562
///
6663
/// The daemon sets this from the `FBUILD_USE_ESPFLASH_WRITE` env
67-
/// var, independently of `use_native_verify`. Default `false` keeps
68-
/// esptool as the safe fallback.
64+
/// var, independently of `use_native_verify`. When enabled, the
65+
/// deployer still falls back to esptool if the native path fails.
6966
///
7067
/// Feature-gated — see `use_native_verify`.
7168
#[cfg(feature = "espflash-native")]
7269
use_native_write: bool,
7370
}
7471

72+
#[cfg(feature = "espflash-native")]
73+
fn native_write_or_fallback<F>(port: &str, label: &str, native: F) -> Option<DeploymentResult>
74+
where
75+
F: FnOnce() -> Result<DeploymentResult>,
76+
{
77+
match native() {
78+
Ok(result) if result.success => Some(result),
79+
Ok(result) => {
80+
tracing::warn!(
81+
port,
82+
"native {} failed ({}); falling back to esptool",
83+
label,
84+
result.message
85+
);
86+
None
87+
}
88+
Err(e) => {
89+
tracing::warn!(
90+
port,
91+
"native {} failed ({}); falling back to esptool",
92+
label,
93+
e
94+
);
95+
None
96+
}
97+
}
98+
}
99+
75100
impl Esp32Deployer {
76101
#[allow(clippy::too_many_arguments)]
77102
pub fn new(
@@ -114,10 +139,11 @@ impl Esp32Deployer {
114139
}
115140

116141
/// Opt this deployer into the native espflash-based write-flash
117-
/// path (issue #66). Independent of `with_native_verify`. On opt-in
118-
/// both [`Deployer::deploy`] and [`Esp32Deployer::deploy_regions`]
119-
/// route through the in-process espflash `Flasher`, skipping the
120-
/// ~1.5 s Python/esptool startup per flash.
142+
/// path (issue #66). Independent of `with_native_verify`. When
143+
/// enabled, both [`Deployer::deploy`] and
144+
/// [`Esp32Deployer::deploy_regions`] route through the in-process
145+
/// espflash `Flasher`, skipping the ~1.5 s Python/esptool startup
146+
/// per flash unless they need to fall back.
121147
///
122148
/// Only present when the `espflash-native` cargo feature is enabled.
123149
#[cfg(feature = "espflash-native")]
@@ -235,8 +261,26 @@ impl Esp32Deployer {
235261
pub fn try_verify_deployment(&self, firmware_path: &Path, port: &str) -> Result<VerifyOutcome> {
236262
#[cfg(feature = "espflash-native")]
237263
if self.use_native_verify {
238-
return self.try_verify_deployment_native(firmware_path, port);
264+
match self.try_verify_deployment_native(firmware_path, port) {
265+
Ok(outcome) => return Ok(outcome),
266+
Err(e) => {
267+
tracing::warn!(
268+
port,
269+
"native verify-flash failed ({}); falling back to esptool",
270+
e
271+
);
272+
}
273+
}
239274
}
275+
276+
self.try_verify_deployment_esptool(firmware_path, port)
277+
}
278+
279+
fn try_verify_deployment_esptool(
280+
&self,
281+
firmware_path: &Path,
282+
port: &str,
283+
) -> Result<VerifyOutcome> {
240284
let args = self.build_verify_flash_args(firmware_path, port);
241285
let args_ref: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
242286

@@ -1126,7 +1170,11 @@ impl Esp32Deployer {
11261170

11271171
#[cfg(feature = "espflash-native")]
11281172
if self.use_native_write {
1129-
return self.try_deploy_regions_native(firmware_path, port, regions);
1173+
if let Some(result) = native_write_or_fallback(port, "selective write-flash", || {
1174+
self.try_deploy_regions_native(firmware_path, port, regions)
1175+
}) {
1176+
return Ok(result);
1177+
}
11301178
}
11311179

11321180
let args = self.build_write_flash_args(firmware_path, port, Some(regions));
@@ -1197,7 +1245,13 @@ impl Deployer for Esp32Deployer {
11971245

11981246
#[cfg(feature = "espflash-native")]
11991247
if self.use_native_write {
1200-
return self.try_deploy_native(firmware_path, port);
1248+
if let Some(result) =
1249+
native_write_or_fallback(port, "write-flash", || {
1250+
self.try_deploy_native(firmware_path, port)
1251+
})
1252+
{
1253+
return Ok(result);
1254+
}
12011255
}
12021256

12031257
let args = self.build_write_flash_args(firmware_path, port, None);

0 commit comments

Comments
 (0)