feat: wireless stack flashing for STM32WB5xxG#5
Conversation
The WS file need to be flash at a specific address (the FUS also)
Remove duplicate code, create more simple function, less steps, read error fields from operator
Copy the function run_command and add a sender as perameter to post message for the log widget.
Template the Message type, and optionnalize sender
|
Revue exhaustive de la branche. Le design global est cohérent (state-machine 🔴 Bloquants1. La release ne bundle plus
|
There was a problem hiding this comment.
Pull request overview
This PR adds a new “Wireless Stack” flashing workflow for STM32WB5xxG devices and refactors the GUI into a tabbed MainWindow that hosts both the existing DapLink flow and the new wireless-stack flow.
Changes:
- Introduce a new Wireless Stack tab that flashes an
operatorfirmware over SWD (OpenOCD), then drives the FUS over serial to install FUS + a selected stack image. - Refactor UI architecture into
src/ui/*with tab-specific message enums and settings persisted underMainWindow. - Rework OpenOCD config/script handling to use
configs/resolved relative to the executable directory and add live OpenOCD log streaming support.
Reviewed changes
Copilot reviewed 20 out of 46 changed files in this pull request and generated 22 comments.
Show a summary per file
| File | Description |
|---|---|
| wireless_stack/stm32wb5xxG_FUS_fw_for_fus_0_5_3.hex | Adds a bundled precompiled FUS image (for older FUS versions). |
| src/ui/tab_wireless_stack.rs | Implements the Wireless Stack tab UI and flashing/serial/FUS state machine. |
| src/ui/tab_daplink.rs | Refactors DapLink flow to use tab-specific messages under the new MainWindow. |
| src/ui/mod.rs | Exposes new UI modules. |
| src/ui/messages.rs | Adds top-level Message and per-tab message enums + WithLogMessage trait. |
| src/ui/main_window.rs | New tabbed main window, settings load/save, and app event subscription handling. |
| src/ui/log_widget.rs | Adjusts imports for new UI module layout. |
| src/stackfile_config.rs | Defines wireless stack/FUS file enums and filename mapping helpers. |
| src/operator_tool.rs | Adds deserialization structs and helper string mapping for operator/FUS statuses. |
| src/open_ocd_task.rs | Updates OpenOCD script resolution, adds WB55 flashing, and streams logs via optional sender. |
| src/messages.rs | Removes legacy single-tab message enum (replaced by src/ui/messages.rs). |
| src/main.rs | Switches app entrypoint to MainWindow and sets exe_dir at startup. |
| src/dirs.rs | Adds exe_dir concept and resolves configs/ and wireless_stack/ relative to it. |
| configs/wb5x.cfg | Adds OpenOCD config for STM32WB55 over CMSIS-DAP. |
| configs/f1x-unlock.cfg | Adds/renames OpenOCD unlock config under configs/. |
| configs/f1x-mass-erase.cfg | Adds OpenOCD mass erase config under configs/. |
| configs/f1x-flash.cfg | Adds/renames OpenOCD flash config under configs/. |
| README_fr.md | Documents Wireless Stack flow and adds stack metadata table (FR). |
| README.md | Documents Wireless Stack flow and adds stack metadata table (EN). |
| Cargo.toml | Bumps version to 0.2.0; adds serialport; enables iced_aw tab features. |
| Cargo.lock | Updates lockfile for new deps/version bump. |
Comments suppressed due to low confidence (1)
src/open_ocd_task.rs:150
- The stdio variable/thread naming is confusing here:
thread_stdoutis reading fromstderr. Consider renaming the thread/vars to match the actual stream being read to reduce debugging friction.
let thread_message = mutex_messages.clone();
let thread_stdout = thread::spawn(move || {
let lines = BufReader::new(stderr).lines();
for line in lines {
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| Ok(result) => { | ||
| if result.code.is_some() && result.code.unwrap() != 0 { | ||
| Self::send_log(&mut o, LogType::Error("Flash failed".into())).await; | ||
| Self::send_step(&mut o, FwStep::Ready).await; | ||
| } else { | ||
| Self::send_logs(&mut o, result.log).await; | ||
| Self::send_step(&mut o, FwStep::StepUpgradeFUS).await; | ||
| } | ||
| } |
There was a problem hiding this comment.
If OpenOCD exits without a code (result.code == None, e.g. terminated by signal), this condition treats it as success and proceeds to the next step. Treat None as a failure (log it and return to Ready) instead of continuing.
| Ok(result) => { | |
| if result.code.is_some() && result.code.unwrap() != 0 { | |
| Self::send_log(&mut o, LogType::Error("Flash failed".into())).await; | |
| Self::send_step(&mut o, FwStep::Ready).await; | |
| } else { | |
| Self::send_logs(&mut o, result.log).await; | |
| Self::send_step(&mut o, FwStep::StepUpgradeFUS).await; | |
| } | |
| } | |
| Ok(result) => match result.code { | |
| Some(0) => { | |
| Self::send_logs(&mut o, result.log).await; | |
| Self::send_step(&mut o, FwStep::StepUpgradeFUS).await; | |
| } | |
| Some(_) => { | |
| Self::send_log(&mut o, LogType::Error("Flash failed".into())).await; | |
| Self::send_step(&mut o, FwStep::Ready).await; | |
| } | |
| None => { | |
| Self::send_log( | |
| &mut o, | |
| LogType::Error("Flash failed: OpenOCD exited without a status code".into()), | |
| ) | |
| .await; | |
| Self::send_step(&mut o, FwStep::Ready).await; | |
| } | |
| }, |
There was a problem hiding this comment.
Fixed in 437eb38 — exhaustive match on result.code so Some(0) / Some(other) / None are each handled explicitly. Signal-killed OpenOCD is now treated as a flash failure.
| if result.code.is_some() && result.code.unwrap() != 0 { | ||
| Self::send_log(&mut o, LogType::Error("Flash failed".into())).await; | ||
| Self::send_step(&mut o, FwStep::Ready).await; | ||
| return; | ||
| } else { | ||
| Self::send_logs(&mut o, result.log).await; |
There was a problem hiding this comment.
If OpenOCD exits without a code (result.code == None, e.g. terminated by signal), this check treats it as success. Consider handling None as an error and aborting rather than continuing the FUS upgrade flow.
| if result.code.is_some() && result.code.unwrap() != 0 { | |
| Self::send_log(&mut o, LogType::Error("Flash failed".into())).await; | |
| Self::send_step(&mut o, FwStep::Ready).await; | |
| return; | |
| } else { | |
| Self::send_logs(&mut o, result.log).await; | |
| match result.code { | |
| Some(0) => { | |
| Self::send_logs(&mut o, result.log).await; | |
| } | |
| Some(_) => { | |
| Self::send_log(&mut o, LogType::Error("Flash failed".into())).await; | |
| Self::send_step(&mut o, FwStep::Ready).await; | |
| return; | |
| } | |
| None => { | |
| Self::send_log( | |
| &mut o, | |
| LogType::Error("Flash failed: OpenOCD did not exit normally".into()), | |
| ) | |
| .await; | |
| Self::send_step(&mut o, FwStep::Ready).await; | |
| return; | |
| } |
There was a problem hiding this comment.
Fixed in 437eb38 (same exhaustive-match rewrite for the FUS flash step).
| const ERASE_SCRIPT: &str = include_str!("../configs/openocd-mass-erase.cfg"); | ||
| const FLASH_SCRIPT: &str = include_str!("../configs/openocd-flash.cfg"); | ||
| pub const UNLOCK_SCRIPT_FILENAME: &str = "f1x-unlock.cfg"; | ||
| pub const ERASE_SCRIPT_FILENAME: &str = "f1x-erase.cfg"; |
There was a problem hiding this comment.
ERASE_SCRIPT_FILENAME is set to f1x-erase.cfg, but the PR adds configs/f1x-mass-erase.cfg (no f1x-erase.cfg). As a result, erase_target() will look for a non-existent config and fail at runtime. Rename the file or update the constant (and any references) so the filename matches what’s shipped in configs/.
| pub const ERASE_SCRIPT_FILENAME: &str = "f1x-erase.cfg"; | |
| pub const ERASE_SCRIPT_FILENAME: &str = "f1x-mass-erase.cfg"; |
There was a problem hiding this comment.
Fixed in c492e57 — ERASE_SCRIPT_FILENAME now matches the shipped f1x-mass-erase.cfg. Good catch, this one would have failed at runtime.
| } | ||
| }); | ||
|
|
||
| self.serial_selected = Some(self.serial_available_port[0].clone()) |
There was a problem hiding this comment.
refresh_serial_ports() unconditionally sets serial_selected to self.serial_available_port[0]. If available_ports() returns an empty list, this will panic and crash the app. Guard on is_empty() before indexing and leave serial_selected = None when no ports are present.
| self.serial_selected = Some(self.serial_available_port[0].clone()) | |
| self.serial_selected = self.serial_available_port.first().cloned(); |
There was a problem hiding this comment.
Fixed in 1dcd05d — self.serial_available_port.first().cloned(). Default::default() on TabWirelessStack no longer calls refresh_serial_ports either.
| 5. _(optionnal)_ Send test program | ||
|
|
||
| ### Stack Wireless | ||
| The stack is flashed to the co-processor using the [FUS](https://wiki.st.com/stm32mcu/wiki/Connectivity:STM32WB_FUS) and a program called `operator`, which acts as a relay between high-level comands and the FUS. The `operator` is precompiled, but **is not** the official ST Microelectronics version, the sources are available [here](https://github.com/steamicc/codal-steami-samples/tree/main/samples/Peripherals/FUS_WS_Operator) ([https://github.com/steamicc/codal-steami-samples/tree/main/samples/Peripherals/FUS_WS_Operator](https://github.com/steamicc/codal-steami-samples/tree/main/samples/Peripherals/FUS_WS_Operator)). |
There was a problem hiding this comment.
Spelling: comands should be commands.
| The stack is flashed to the co-processor using the [FUS](https://wiki.st.com/stm32mcu/wiki/Connectivity:STM32WB_FUS) and a program called `operator`, which acts as a relay between high-level comands and the FUS. The `operator` is precompiled, but **is not** the official ST Microelectronics version, the sources are available [here](https://github.com/steamicc/codal-steami-samples/tree/main/samples/Peripherals/FUS_WS_Operator) ([https://github.com/steamicc/codal-steami-samples/tree/main/samples/Peripherals/FUS_WS_Operator](https://github.com/steamicc/codal-steami-samples/tree/main/samples/Peripherals/FUS_WS_Operator)). | |
| The stack is flashed to the co-processor using the [FUS](https://wiki.st.com/stm32mcu/wiki/Connectivity:STM32WB_FUS) and a program called `operator`, which acts as a relay between high-level commands and the FUS. The `operator` is precompiled, but **is not** the official ST Microelectronics version, the sources are available [here](https://github.com/steamicc/codal-steami-samples/tree/main/samples/Peripherals/FUS_WS_Operator) ([https://github.com/steamicc/codal-steami-samples/tree/main/samples/Peripherals/FUS_WS_Operator](https://github.com/steamicc/codal-steami-samples/tree/main/samples/Peripherals/FUS_WS_Operator)). |
There was a problem hiding this comment.
Fixed in 6c12321 — "comands" → "commands".
| if let None = fus {} | ||
|
|
There was a problem hiding this comment.
There’s an empty if let None = fus {} statement that has no effect. It reads like leftover debug/placeholder code and makes the control flow harder to follow; consider removing it.
| if let None = fus {} |
There was a problem hiding this comment.
Fixed in 3c6de75 — empty if let None = fus {} removed.
| fields_file, | ||
| serde_json::to_string_pretty(&self).unwrap_or("{}".into()), | ||
| ) { | ||
| Ok(_) => println!("Settings succesfully saved"), |
There was a problem hiding this comment.
Typo in log message: Settings succesfully saved should be Settings successfully saved.
| Ok(_) => println!("Settings succesfully saved"), | |
| Ok(_) => println!("Settings successfully saved"), |
There was a problem hiding this comment.
Fixed in 6c12321 — "succesfully" → "successfully".
| DAPLINK_TAB => col.push(self.tab_daplink.view()), | ||
| WIRELESS_STACK_TAB => col.push(self.tab_ws.view()), | ||
| _ => { | ||
| eprintln!("Invalid selectd tab ({})", self.active_tab); |
There was a problem hiding this comment.
Typo in error message: Invalid selectd tab should be Invalid selected tab.
| eprintln!("Invalid selectd tab ({})", self.active_tab); | |
| eprintln!("Invalid selected tab ({})", self.active_tab); |
There was a problem hiding this comment.
Fixed in 6c12321 — "selectd" → "selected".
| Self::send_log( | ||
| o, | ||
| LogType::Warning(format!( | ||
| "STATUS #{}, attempt #{} failed (Error: {e}.", |
There was a problem hiding this comment.
The warning message format string looks unbalanced: it opens (... but doesn’t close the ) (currently ends with Error: {e}.). Consider fixing the punctuation so logs are easier to read.
| "STATUS #{}, attempt #{} failed (Error: {e}.", | |
| "STATUS #{}, attempt #{} failed (Error: {e}).", |
There was a problem hiding this comment.
Fixed in 7806b2c (the send_double_status rewrite also closed the parenthesis).
| let thread_message = mutex_messages.clone(); | ||
| let thread_stderr = thread::spawn(move || { | ||
| let lines = BufReader::new(stdout).lines(); |
There was a problem hiding this comment.
Related to the above: this thread reads stdout but is named thread_stderr, which makes it easy to misinterpret where messages originate. Renaming the thread/vars (or swapping to match) would improve maintainability.
There was a problem hiding this comment.
Fixed in 3c6de75 — thread_stdout/thread_stderr now actually read from the matching stream, and stdout output is tagged LogType::Info instead of Error (it was painting OpenOCD's normal "Programming Started"/"Verified OK" lines red).
Without configs/ next to the executable, dirs::get_configs_dir() errors out on startup since the runtime no longer materializes OpenOCD scripts from include_str!. Same applies to wireless_stack/ for the WB55 flow. Both folders are now copied into the xpack release archive (linux + windows). libudev-dev is required by serialport on Linux when the libudev backend is enabled (default).
refresh_serial_ports() unconditionally indexed [0] on the freshly populated Vec, panicking when serialport returns Ok(vec![]) — which happens on any machine without a connected device. Use first().cloned() to leave serial_selected as None instead. Also drop the refresh from TabWirelessStack::default(): it ran during MainWindow construction (before the iced runtime had a window) and was already covered by the Window::Opened handler in main_window.rs, which now refreshes ports unconditionally instead of only when settings load.
static mut + unsafe access is a hard error in Rust 2024 edition and emits a lint warning under #[allow(static_mut_refs)] in 2021. Since the value is written once at startup and read concurrently from iced tasks, OnceLock<PathBuf> is the right primitive. Public API unchanged.
Bundles five related fixes:
1. busy-loop: run_command_sender polled child.try_wait() with no
yield, pinning a CPU during the 15-60s OpenOCD runs. Add a 50ms
Timer::after between polls.
2. blocking sleeps in async: thread::sleep was called from inside
async closures driving the wireless stack flow (1s, 1s, 5s) and
from send_and_read_serial, freezing the iced executor for ~10s
per FUS+WS cycle. Switch to async_io::Timer::after;
send_and_read_serial becomes async.
3. log severity: the two pipe-reader threads had their variable names
swapped (thread_stdout was reading stderr) and stdout output was
tagged LogType::Error, painting OpenOCD's normal "Programming
Started"/"Verified OK" lines red. Rename and tag both as Info.
4. BleLld missing from picker: the WirelessStackFile::BleLld variant
exists, the hex is shipped under wireless_stack/, and
wireless_stack_config maps it — but it wasn't in ALL_STACK so the
user couldn't pick it. Added.
5. dead block: drop the leftover `if let None = fus {}` left over
from a refactor.
Adds async-io as a direct dep (already transitively present).
- Extract prepare_merged_hex helper, dedup'd from step_flash_fus and step_flash_fw (path resolution + merge_ws_hex call were copy-pasted). - Remove the unused OperatorStatusResult struct. - Drop the dead `-s scripts` arg in run_command_sender (leftover from the old script-dir layout). - Bump message_runner channel capacity from 1 to 16 so OpenOCD log bursts don't backpressure-stall the worker. - Drop unused iced_aw "tabs" feature; we only use TabBar. - Remove the dead `// #[derive(Default, Debug)]` comment in MainWindow.
The runtime now resolves these folders relative to the executable (target/debug/easy_flash_daplink), so a fresh checkout fails on "The configs folder does not exist" until the user wires them up.
STM32WB's SHCI_C2_FUS_GetState swaps CPU2 from the wireless stack to the FUS on the first call and only returns the real state on the second — confirmed against the operator firmware sources. So sending STATUS twice is intentional, both round-trips need to succeed. The previous flag was hoisted across both passes: if the first pass succeeded on attempt #1 but the second pass exhausted all 3 retries, the function still returned Ok and the flow proceeded with the FUS half-locked. Reset success per pass and bail out as soon as one pass exhausts its retries. Also add a doc comment so the "double" name isn't a mystery for the next reader.
step_flash_fus transitions back to StepUpgradeFUS to verify the version has actually bumped, which is necessary because some chips need an intermediate FUS (0.5.3 -> 1.x -> 1.2). But if the reported version doesn't move forward, the flow re-enters StepFlashFUS forever. Track attempts on TabWirelessStack and bail out after 3 with a clear error. Counter is reset on each Start. The webusb-wireless-stack reference tool doesn't auto-loop here at all — the user clicks UPGRADE manually, observes, decides. Our automation chain needs the bound; this matches the spirit (one deliberate sequence) without breaking the multi-step FUS path.
The third check in validate_fields was a copy-paste of the first: it tested !self.bootloader_path.exists() (already short-circuited above) instead of !self.user_file_path.exists(), so the warning about a missing user file never fired. Also drop the to_str().unwrap() — non-UTF-8 paths panicked the app. Use as_os_str().len() to detect "user left the field empty" without allocating or going through UTF-8. Found by an external review on the main branch.
ERASE_SCRIPT_FILENAME pointed to "f1x-erase.cfg" but the file shipped under configs/ is named "f1x-mass-erase.cfg" (matching the openocd-mass-erase.cfg origin). erase_target() therefore failed at runtime with "no such file or directory" before reaching the chip. Caught by Copilot's review on PR #5.
Two issues in the WB55 flow flagged by Copilot on PR #5: - The three flash steps (operator, FUS, WS) treated result.code == None as success via `code.is_some() && code.unwrap() != 0`. When OpenOCD is killed by signal, code() returns None — currently the flow proceeded to the next step on top of a possibly aborted flash. Match exhaustively on Some(0) / Some(other) / None and abort on the latter two. - fus_upgrade_cmd polled the operator's progress JSON over serial and printed it via println!, which never reached the in-app log widget. Pass the existing TabWsMessage sender through and emit each line as LogType::Info so the user sees the FUS state transitions in real time.
dirs::set_exe_dir was hard-wired to current_exe().parent(), which under cargo run lives in target/debug — so configs/ and wireless_stack/ had to be symlinked there or the app errored out at startup. The previous docs change asked users to set those symlinks manually; this restores the original "just `cargo run`" experience. Cargo populates CARGO_MANIFEST_DIR at runtime when invoking the binary via `cargo run`. We prefer it when set, else fall back to the exe's parent dir for shipped builds where the env var is absent. README symlink instructions reverted. Also fix the misleading "Failed to create/update scripts" error message on current_exe() failure.
- FUS_SATE_* → FUS_STATE_* (3 occurrences) - "Unknow code" → "Unknown code" - "Settings succesfully saved" → "Settings successfully saved" - "Invalid selectd tab" → "Invalid selected tab" - README EN: "high-level comands" → "commands" - README FR: "à cette [addresse]" → "[adresse]"
ERASE_SCRIPT_FILENAME pointed to "f1x-erase.cfg" but the file shipped under configs/ is named "f1x-mass-erase.cfg" (matching the openocd-mass-erase.cfg origin). erase_target() therefore failed at runtime with "no such file or directory" before reaching the chip. Caught by Copilot's review on PR #5.
Two issues in the WB55 flow flagged by Copilot on PR #5: - The three flash steps (operator, FUS, WS) treated result.code == None as success via `code.is_some() && code.unwrap() != 0`. When OpenOCD is killed by signal, code() returns None — currently the flow proceeded to the next step on top of a possibly aborted flash. Match exhaustively on Some(0) / Some(other) / None and abort on the latter two. - fus_upgrade_cmd polled the operator's progress JSON over serial and printed it via println!, which never reached the in-app log widget. Pass the existing TabWsMessage sender through and emit each line as LogType::Info so the user sees the FUS state transitions in real time.
Three issues with the previous CI: - Triggered only on push-to-main and workflow_dispatch, so PRs were never validated before merge — code only got verified after it had already landed. PR #5 was effectively merged unchecked. - Built on ubuntu-latest (currently ubuntu-24.04) while releases target both ubuntu-22.04 and ubuntu-24.04. A version-specific glibc/libudev issue could break the release without warning. - No clippy or rustfmt at all, so style and obvious bugs went uncaught. Add `pull_request` trigger, build matrix on the same OSes the release matrix uses, and a separate lint job running clippy + fmt. The lint job is `continue-on-error: true` for now because the codebase has 41 clippy warnings and isn't currently fmt-clean — making them blocking would force a large cleanup PR. They'll show up in the checks tab so maintainers see the trend, and a follow-up PR can flip them to blocking once the warnings are addressed.
Summary
iced_awtab bar (refactor of the GUI intosrc/ui/{main_window,tab_daplink,tab_wireless_stack}).operatorrelay firmware over SWD with OpenOCD, then drive the FUS through it on a serial port (STATUS/VERSION/DELETE/UPGRADEcommands) to install FUS + the user-selected wireless stack.operatorhex underwireless_stack/. Stack metadata table added to both READMEs.f1x-*.cfg, newconfigs/wb5x.cfgfor the WB55 over CMSIS-DAP, scripts now resolved relative to the executable directory (newdirs::set_exe_dir+get_configs_dir/get_wireless_stack_dir) instead of being materialized into the platform data dir.open_ocd_tasknow streams stdout/stderr lines live to the UI through an mpscSender<Message>(generic over the tab's message type viaWithLogMessage) instead of buffering until the process exits.Window::Openedevent so serial ports can be refreshed after deserialization; per-tab structs serialized underMainWindow.0.2.0; new deps:serialport 4.7.1,iced_awfeaturestabs+tab_bar.Test plan
cargo build --release --lockedon Linux (CI-equivalent).main.BleStackFullExt), confirm the operator is flashed, FUS upgrade reachesFUS_STATE_IDLE, then the chosen WS image installs successfully.Refreshrepopulates the serial port list after plugging/unplugging the board.fields.jsonand that serial ports are re-enumerated.cross(release workflow).