Skip to content

Commit 3d5880f

Browse files
zackeesclaude
andcommitted
feat(teensy): comprehensive deployer state machine (closes #433, #432)
Replaces the bare `teensy_loader_cli` wrapper with a state machine that addresses the wedged-Windows-host failure modes catalogued in #433: * `soft_reboot.rs` — baud-134 trigger so a running user firmware can be flashed unattended (no program-button press); also fixes #432 (`Soft reboot is not implemented for Win32`). * `halfkay_probe.rs` — confirms the CDC port left the bus after the baud-134 trigger. * `flash.rs` — bounded retry+backoff around `teensy_loader_cli` for the WinUSB "error writing to Teensy" quirk that fires 6/10 cycles. * `port_discovery.rs` — pre-flash port snapshot + post-flash new-CDC-ACM detection; surfaces the freshly enumerated port through `DeploymentResult.port`. * `first_byte_probe.rs` — advisory probe that warns when a successful flash produces zero serial bytes (typical `setup()`-hang signature). * `usb_type.rs` — best-effort read of the build's `usb_type` and advisory when no Serial endpoint exists (`USB_RAWHID`, …). * Two-tier timeouts (`flash_timeout_secs` vs `wait_for_halfkay_timeout_secs`) so first-flash button-press scenarios don't kill the loader at 60s. * Env escape hatches: `FBUILD_TEENSY_FLASH_RETRIES`, `FBUILD_TEENSY_FIRST_BYTE_TIMEOUT_SECS`, `FBUILD_TEENSY_DISABLE_BAUD_134_TRIGGER`. Daemon's `/api/deploy` now forwards `DeploymentResult.port` to the post-deploy monitor (failure mode #7) so re-enumeration to a new COM name doesn't leave the monitor on the dead pre-flash port. Public API of `TeensyDeployer::new` / `from_board_config` is unchanged; `TeensyLoaderParams` gains opt-in fields with sensible defaults so the daemon dispatch site needs no code change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 619d6ca commit 3d5880f

13 files changed

Lines changed: 1398 additions & 220 deletions

File tree

crates/fbuild-build/src/symbol_analyzer.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,7 @@ pub fn format_markdown_report(map: &FineGrainedSymbolMap, top_n: usize) -> Strin
408408
/// 2. `<dir>/.fbuild/build/**/firmware.elf` (fbuild native output).
409409
/// 3. `<dir>/.pio/build/**/firmware.elf` (PlatformIO output).
410410
/// 4. Any `*.elf` directly inside `<dir>`.
411+
///
411412
/// Returns the most recently-modified candidate when multiple match.
412413
pub fn discover_elf_in_project(project_dir: &Path) -> Option<PathBuf> {
413414
// 1. build_info.json

crates/fbuild-core/src/symbol_analysis/mod.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -690,6 +690,5 @@ fn sym_type_for_synth(output_section: &str) -> char {
690690
}
691691
}
692692

693-
694693
#[cfg(test)]
695694
mod tests;

crates/fbuild-core/src/symbol_analysis/tests.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,8 @@ fn parse_nm_line_skips_unsized() {
1919

2020
#[test]
2121
fn extract_archive_from_pio_path() {
22-
let (arc, obj) = extract_archive_and_object(
23-
".pio/build/esp32s3/lib0d9/libFastLED.a(fl.channels+.cpp.o)",
24-
);
22+
let (arc, obj) =
23+
extract_archive_and_object(".pio/build/esp32s3/lib0d9/libFastLED.a(fl.channels+.cpp.o)");
2524
assert_eq!(arc.as_deref(), Some("libFastLED.a"));
2625
assert_eq!(obj, "fl.channels+.cpp.o");
2726
}

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

Lines changed: 62 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -684,13 +684,10 @@ pub async fn deploy(
684684
Box::new(deployer)
685685
}
686686
fbuild_core::Platform::Teensy => {
687-
// The TeensyDeployer in fbuild-deploy/src/teensy.rs has been
688-
// fully implemented (incl. unit tests for teensy40 / teensy41
689-
// board configs) but wasn't dispatched here — issue #430.
690-
// Without this arm `bash autoresearch teensyXX --simd` (and
691-
// every other Teensy autoresearch flag) failed at the deploy
692-
// step with "deployer for Teensy not yet implemented",
693-
// blocking FastLED/FastLED#2628 hardware bring-up.
687+
// TeensyDeployer state machine (#433) is dispatched here.
688+
// Initial wire-up was #430/#431; the deployer itself now owns
689+
// baud-134 soft reboot, bounded retry, post-flash port
690+
// discovery, and the optional first-byte advisory probe.
694691
let board_config =
695692
fbuild_config::BoardConfig::from_board_id(&board_id, &deploy_board_overrides)
696693
.unwrap_or_else(|_| {
@@ -788,43 +785,44 @@ pub async fn deploy(
788785
}
789786
}
790787

791-
let (deploy_success, deploy_stdout, deploy_stderr, deploy_outcome) = match deploy_result {
792-
Ok(Ok(r)) if r.success => (true, Some(r.stdout), Some(r.stderr), r.outcome),
793-
Ok(Ok(r)) => {
794-
return (
795-
StatusCode::INTERNAL_SERVER_ERROR,
796-
Json(OperationResponse {
797-
success: false,
798-
request_id,
799-
message: r.message,
800-
exit_code: 1,
801-
output_file: Some(reported_output_file.clone()),
802-
output_dir: reported_output_dir.clone(),
803-
launch_url: None,
804-
stdout: Some(r.stdout),
805-
stderr: Some(r.stderr),
806-
}),
807-
);
808-
}
809-
Ok(Err(e)) => {
810-
return (
811-
StatusCode::INTERNAL_SERVER_ERROR,
812-
Json(OperationResponse::fail(
813-
request_id,
814-
format!("deploy error: {}", e),
815-
)),
816-
);
817-
}
818-
Err(e) => {
819-
return (
820-
StatusCode::INTERNAL_SERVER_ERROR,
821-
Json(OperationResponse::fail(
822-
request_id,
823-
format!("deploy task panicked: {}", e),
824-
)),
825-
);
826-
}
827-
};
788+
let (deploy_success, deploy_stdout, deploy_stderr, deploy_outcome, deploy_post_port) =
789+
match deploy_result {
790+
Ok(Ok(r)) if r.success => (true, Some(r.stdout), Some(r.stderr), r.outcome, r.port),
791+
Ok(Ok(r)) => {
792+
return (
793+
StatusCode::INTERNAL_SERVER_ERROR,
794+
Json(OperationResponse {
795+
success: false,
796+
request_id,
797+
message: r.message,
798+
exit_code: 1,
799+
output_file: Some(reported_output_file.clone()),
800+
output_dir: reported_output_dir.clone(),
801+
launch_url: None,
802+
stdout: Some(r.stdout),
803+
stderr: Some(r.stderr),
804+
}),
805+
);
806+
}
807+
Ok(Err(e)) => {
808+
return (
809+
StatusCode::INTERNAL_SERVER_ERROR,
810+
Json(OperationResponse::fail(
811+
request_id,
812+
format!("deploy error: {}", e),
813+
)),
814+
);
815+
}
816+
Err(e) => {
817+
return (
818+
StatusCode::INTERNAL_SERVER_ERROR,
819+
Json(OperationResponse::fail(
820+
request_id,
821+
format!("deploy task panicked: {}", e),
822+
)),
823+
);
824+
}
825+
};
828826
// Build the "deploy succeeded (...)" prefix used by every
829827
// monitor-attached and non-monitor-attached response below. Stable
830828
// wording — see GitHub issue #76 and the DeployOutcome::describe
@@ -834,7 +832,26 @@ pub async fn deploy(
834832
// Post-deploy monitoring: if monitor_after is set, open the serial port
835833
// and stream lines checking halt conditions (matching Python behavior).
836834
if deploy_success && req.monitor_after {
837-
let monitor_port = deploy_port_str.unwrap_or_else(|| "/dev/ttyUSB0".to_string());
835+
// Prefer the post-flash port name surfaced by the deployer (e.g. the
836+
// Teensy state machine in #433 returns the freshly re-enumerated CDC
837+
// ACM port, which can differ from the pre-flash `--port`). Fall back
838+
// to the caller-supplied port, then to a platform default.
839+
let monitor_port = deploy_post_port
840+
.clone()
841+
.or(deploy_port_str.clone())
842+
.unwrap_or_else(|| "/dev/ttyUSB0".to_string());
843+
if deploy_post_port
844+
.as_deref()
845+
.zip(deploy_port_str.as_deref())
846+
.is_some_and(|(post, pre)| post != pre)
847+
{
848+
tracing::info!(
849+
"device re-enumerated as {} after flash (was {}); monitor attaching to {}",
850+
deploy_post_port.as_deref().unwrap_or(""),
851+
deploy_port_str.as_deref().unwrap_or(""),
852+
monitor_port,
853+
);
854+
}
838855
let baud_rate = 115200u32;
839856

840857
// Open the port for monitoring

crates/fbuild-deploy/src/teensy.rs

Lines changed: 0 additions & 171 deletions
This file was deleted.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Teensy deployer
2+
3+
State machine for `fbuild deploy -e teensyXX` that takes a wedged Windows host
4+
back to unattended flashing without manual button presses or USB reseats.
5+
6+
Implements the design from [issue
7+
#433](https://github.com/FastLED/fbuild/issues/433) (which supersedes #432). The
8+
old single-file `teensy.rs` shipped only the bare `teensy_loader_cli` invocation
9+
and inherited every failure mode the issue catalogues.
10+
11+
## Module layout
12+
13+
- **`mod.rs`**`TeensyDeployer` + `TeensyLoaderParams`; orchestrates the state
14+
machine below.
15+
- **`soft_reboot.rs`** — opens the device's CDC ACM port at **baud 134**, the
16+
Teensyduino USB stack's magic-baud signal to drop into HalfKay. Replaces the
17+
Windows-only `teensy_loader_cli` reboot which prints
18+
`Soft reboot is not implemented for Win32`.
19+
- **`halfkay_probe.rs`** — confirms the CDC port vanished from
20+
`serialport::available_ports()` (HalfKay proxy: the device left CDC class for
21+
HID), or waits up to `wait_for_halfkay_timeout_secs` for the user to press the
22+
program button.
23+
- **`flash.rs`** — bounded retry loop around `teensy_loader_cli`. Each attempt
24+
is a fresh subprocess; stops on first success; surfaces per-attempt diagnostic
25+
on the way to exhaustion.
26+
- **`port_discovery.rs`** — pre-flash port snapshot + post-flash detection of
27+
the newly enumerated CDC ACM port. Filled into `DeploymentResult.port` so the
28+
post-deploy monitor can attach to the right device.
29+
- **`first_byte_probe.rs`** — advisory probe that opens the post-flash port and
30+
reports whether any byte arrived inside `first_byte_timeout_secs`. Silent
31+
firmware is surfaced as a structured diagnostic, not a deploy failure.
32+
- **`usb_type.rs`** — best-effort read of `usb_type` from the build artifact
33+
directory; advises the monitor when the device was built without a Serial
34+
endpoint (`USB_MIDI_SERIAL`, `USB_RAWHID`).
35+
36+
## State machine
37+
38+
```
39+
pre-snapshot → (CDC at port? → baud-134 trigger) → wait-for-HalfKay
40+
→ flash with retry → wait-for-new-CDC → first-byte probe
41+
→ DeploymentResult { port: Some(new_port), … }
42+
```
43+
44+
Failure at any stage returns a `DeploymentResult { success: false, message:
45+
<stage-specific>, stderr: <last loader output> }` — same envelope the daemon
46+
already propagates to the CLI verbatim.
47+
48+
## Env escape hatches
49+
50+
- `FBUILD_TEENSY_FLASH_RETRIES` — override `flash_retries` (default 5).
51+
- `FBUILD_TEENSY_FIRST_BYTE_TIMEOUT_SECS` — override
52+
`first_byte_timeout_secs` (default 10, `0` disables).
53+
- `FBUILD_TEENSY_DISABLE_BAUD_134_TRIGGER` — opt out of the baud-134 trigger
54+
(debug aid for the few hosts where `SerialPortBuilder::baud_rate(134)` is not
55+
honored).

0 commit comments

Comments
 (0)