From 1185243bc1d413439e767f34fa39f7c89ef45b89 Mon Sep 17 00:00:00 2001 From: Will Lehnertz <6597817+packetThrower@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:29:22 -0700 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20Windows=20test=20findings=20?= =?UTF-8?q?=E2=80=94=20Macronix=20status/OTP,=20partial=20dumps,=20USB=20e?= =?UTF-8?q?rrors,=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found testing v0.8.0-beta.1 on Windows with an MX25L12835F: - Status registers decode Macronix's layout (BP0-3, QE, SRWD; CR for 0x15). SR1 0x40 (QE) no longer warns of block protection, and 0x35 (EQIO, enter-QPI on Macronix) is never sent to a 0xC2 part. - Security-register ops refuse Macronix (different OTP scheme); the pane hides its controls. - A failed or cancelled read deletes the partial dump (closing it first so Windows can delete it). - An enumerated-but-unopenable CH341A reports the driver/udev fix instead of 'not found'; a mid-transfer unplug reports 'disconnected' instead of 'Pipe error'. - View menu zoom items, dialogs open in the read save location, scrollable sidebar, log lines clear the toolbar chips, shorter hex description, BIOS explorer explains non-UEFI images. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 28 ++++ docs/src/content/docs/usage/spi.md | 79 +++++------ src/ch341.rs | 34 ++++- src/error.rs | 5 +- src/gui/app/bios.rs | 6 +- src/gui/app/hex.rs | 2 +- src/gui/app/i2c.rs | 6 +- src/gui/app/otp.rs | 11 +- src/gui/app/spi.rs | 21 ++- src/gui/log.rs | 5 +- src/gui/menus.rs | 8 +- src/gui/panes.rs | 3 + src/gui/panes/bios.rs | 9 ++ src/gui/panes/hex.rs | 11 +- src/gui/panes/status.rs | 112 +++++---------- src/gui/sidebar.rs | 22 ++- src/i2c_ops.rs | 23 +-- src/ops.rs | 120 ++++++++-------- src/prefs.rs | 8 ++ src/spi.rs | 221 ++++++++++++++++------------- 20 files changed, 405 insertions(+), 329 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0854370..bb64247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,34 @@ Changelog](https://keepachangelog.com/en/1.1.0/); versions follow - **Hex viewer zoom is easier to find**: the pane's description now mentions Cmd/Ctrl + / − / 0, and the GUI docs gain a Keyboard shortcuts section covering every shortcut. +- **View menu has Zoom In / Zoom Out / Actual Size** for the hex + viewer. +- **File pickers open in the read save location** until you've + picked a file somewhere else. +- **A short window can scroll the sidebar** to reach every tool; + Settings stays pinned at the bottom. + +### Fixed + +- **Status registers on Macronix chips**: SR1 is decoded with + Macronix's layout (BP0–3, QE, SRWD) and the configuration register + replaces SR3. A set QE bit no longer triggers a false + "block-protect bits set" warning. etch341 also no longer sends + `0x35` to Macronix parts, where it switches the chip into QPI mode. +- **Security registers refuse Macronix chips**: they use a different + OTP scheme, so the pane hides its read/erase/write controls and + the CLI `otp` commands stop with an error. +- **A failed or cancelled read deletes the partial dump** instead of + leaving a truncated file with a normal dump name (SPI and I²C, + GUI and CLI). +- **Clearer USB errors**: a CH341A that's plugged in but has no + driver (Windows without WinUSB) or no udev rule now says so + instead of "device not found", and unplugging mid-operation + reports "CH341A disconnected" instead of "Pipe error". +- **BIOS explorer explains a non-UEFI image** instead of showing an + empty settings table. +- **Activity-log lines wrap before the log's toolbar buttons** + instead of running underneath them. ## [0.8.0-beta.1] — 2026-10-25 diff --git a/docs/src/content/docs/usage/spi.md b/docs/src/content/docs/usage/spi.md index f68abf8..97d42b0 100644 --- a/docs/src/content/docs/usage/spi.md +++ b/docs/src/content/docs/usage/spi.md @@ -141,40 +141,38 @@ the first non-`0xFF` byte and reports the offset. etch341 sr ``` -Reads the three SPI status registers (SR1 / SR2 / SR3) and decodes -the standard bit fields. The two most common reasons you'd reach -for this: +Reads the SPI status registers and decodes the standard bit +fields. The two most common reasons you'd reach for this: - **Writes silently fail** — the chip is happily ACK-ing the write - but the data never lands. Almost always block-protect bits - (`BP[2:0]`, `SEC/BP3`) set in SR1. Clear them via WRSR before - programming. `etch341 sr` will surface the gotcha with a - follow-up note. -- **Quad-mode opcodes NACK** — `QE` (Quad Enable) is clear in SR2. - Mostly only matters if you're trying to use the chip in QSPI - mode; standard `0x03` / `0x02` reads + writes work either way. - -### What's universal vs vendor-specific - -`SR1` (read via opcode `0x05`) is read by **every** SPI NOR chip, -and the decoded bits (`WIP` / `WEL` / `BP[2:0]` / `TB` / `SRP0`) -mean the same thing on Winbond, Macronix, GigaDevice, ISSI, EON, -etc. The one wrinkle is bit 6 — Winbond calls it `SEC` (4 KB vs -64 KB protect granularity), Macronix calls it `BP3` (extending the -block-protect mask by one bit). Same position, different semantic; -`etch341 sr` labels it `SEC/BP3` to hedge. - -`SR2` (opcode `0x35`) is on most chips made since ~2012 — W25Q, -modern MX25L / MX25U, GD25Q. Older or simpler parts (some -SST25VF, EN25, basic AT25) don't implement it; `etch341 sr` shows -`0xFF (chip didn't respond — likely doesn't implement SR2)` -rather than decoding garbage. - -`SR3` (opcode `0x15`) is the most vendor-specific — pure W25Q -convention. On a Macronix or older chip you'll see the same -"didn't respond" fallback. - -The decoded labels for SR2 / SR3 follow the W25Q-family convention. + but the data never lands. Almost always block-protect (`BP`) bits + set in SR1. Clear them via WRSR before programming. `etch341 sr` + will surface the gotcha with a follow-up note. +- **Quad-mode opcodes NACK** — `QE` (Quad Enable) is clear. Mostly + only matters if you're trying to use the chip in QSPI mode; + standard `0x03` / `0x02` reads + writes work either way. + +### Winbond-style vs Macronix layouts + +`SR1` (opcode `0x05`) is read by **every** SPI NOR chip, and +`WIP` / `WEL` sit in bits 0–1 everywhere. Above that the layouts +split by vendor, and `etch341 sr` picks the decode from the JEDEC +manufacturer byte: + +- **Winbond / GigaDevice and most others**: SR1 holds `BP[2:0]`, + `TB`, `SEC` and `SRP0`; `SR2` (opcode `0x35`) holds `SRP1`, `QE`, + `LB`, `CMP`, `SUS`; `SR3` (opcode `0x15`) holds `ADP`, `WPS`, + `DRV` and `HOLD/RST`. Older or simpler parts (some SST25VF, EN25, + basic AT25) don't implement SR2 / SR3; `etch341 sr` shows + `0xFF (chip didn't respond — likely doesn't implement SR2)` rather + than decoding garbage. +- **Macronix** (JEDEC `0xC2…`): SR1 holds `BP[3:0]` (bits 2–5), + `QE` (bit 6) and `SRWD` (bit 7). There's no SR2 — on Macronix + `0x35` is *Enable QPI*, which would switch the chip out of plain + SPI, so etch341 never sends it. Opcode `0x15` reads the + configuration register (`CR`: output drive strength `ODS`, `TB`, + dummy cycles `DC`). + Raw hex + binary are always shown so you can cross-check against the chip's datasheet if the labels don't apply. @@ -186,25 +184,24 @@ JEDEC ID : 0xEF4018 Chip : W25Q128JV SR1 : 0x00 (0b00000000) - WIP=0 WEL=0 BP=0 TB=0 SEC/BP3=0 SRP0=0 + WIP=0 WEL=0 BP=0 TB=0 SEC=0 SRP0=0 SR2 : 0x02 (0b00000010) SRP1=0 QE=1 LB=0 CMP=0 SUS=0 SR3 : 0x60 (0b01100000) ADP=0 WPS=0 DRV=3 HOLD/RST=0 ``` -A chip without SR2 / SR3 (like a Macronix MX25U4033E pulled from a -graphics card): +A Macronix MX25L12835F with Quad Enable set (not block protection): ```text $ etch341 sr -JEDEC ID : 0xC22533 -Chip : MX25U4033E +JEDEC ID : 0xC22018 +Chip : MX25L12835F -SR1 : 0x00 (0b00000000) - WIP=0 WEL=0 BP=0 TB=0 SEC/BP3=0 SRP0=0 -SR2 : 0xFF (chip didn't respond — likely doesn't implement SR2) -SR3 : 0xFF (chip didn't respond — likely doesn't implement SR3) +SR1 : 0x40 (0b01000000) + WIP=0 WEL=0 BP=0 QE=1 SRWD=0 +CR : 0x07 (0b00000111) + ODS=7 TB=0 DC=0 ``` The GUI's **Status regs** pane (under the workflow divider in diff --git a/src/ch341.rs b/src/ch341.rs index a8257c7..f4ee56b 100644 --- a/src/ch341.rs +++ b/src/ch341.rs @@ -81,8 +81,8 @@ pub struct Ch341 { /// Whether a CH341A is plugged in. Enumerates USB devices only — /// never opens or claims one — so it's safe to call while an op holds -/// the device. GUI-only (the header's presence poller). -#[cfg(feature = "gui")] +/// the device. Used by the GUI's presence poller and to tell "no +/// driver" and "unplugged mid-transfer" apart from other USB errors. pub fn is_present() -> bool { let Ok(devices) = rusb::devices() else { return false; @@ -93,6 +93,16 @@ pub fn is_present() -> bool { }) } +/// A pulled cable surfaces as Pipe / NoDevice / Io depending on the +/// OS; report it as a disconnect when the device is gone. +fn transfer_err(e: rusb::Error) -> Error { + if is_present() { + Error::Usb(e) + } else { + Error::Disconnected + } +} + impl Ch341 { pub fn open(verbose: bool) -> Result { let mut ch = Self::open_raw(verbose)?; @@ -111,7 +121,15 @@ impl Ch341 { let ctx = Context::new()?; let handle = ctx .open_device_with_vid_pid(VID, PID) - .ok_or(Error::DeviceNotFound)?; + // Enumerated but not openable = no WinUSB driver (Windows) + // or no udev rule (Linux), not "unplugged". + .ok_or_else(|| { + if is_present() { + Error::PermissionDenied + } else { + Error::DeviceNotFound + } + })?; // On Linux the kernel `ch341` driver auto-binds to the // CH341A on plug-in (presenting it as /dev/ttyUSB*); for @@ -224,7 +242,10 @@ impl Ch341 { if self.verbose { eprintln!(" -> OUT [{:>2}]: {}", buf.len(), hex::encode(buf)); } - let n = self.handle.write_bulk(EP_OUT, buf, USB_TIMEOUT)?; + let n = self + .handle + .write_bulk(EP_OUT, buf, USB_TIMEOUT) + .map_err(transfer_err)?; if n != buf.len() { return Err(Error::Usb(rusb::Error::Io)); } @@ -232,7 +253,10 @@ impl Ch341 { } fn bulk_in(&self, buf: &mut [u8]) -> Result<()> { - let n = self.handle.read_bulk(EP_IN, buf, USB_TIMEOUT)?; + let n = self + .handle + .read_bulk(EP_IN, buf, USB_TIMEOUT) + .map_err(transfer_err)?; if self.verbose { eprintln!(" <- IN [{:>2}]: {}", n, hex::encode(&buf[..n])); } diff --git a/src/error.rs b/src/error.rs index 1422486..b60cd99 100644 --- a/src/error.rs +++ b/src/error.rs @@ -15,7 +15,7 @@ pub enum Error { DeviceNotFound, #[error( - "Permission denied opening CH341A.\n \ + "Found the CH341A (USB 1a86:5512) but couldn't open it: no access or no driver.\n \ Linux: sudo cp platform/udev/99-ch341a.rules /etc/udev/rules.d/ && sudo udevadm control --reload\n \ Windows: run Zadig and bind WinUSB to the CH341A device." )] @@ -36,6 +36,9 @@ pub enum Error { #[error("USB error: {0}")] Usb(#[from] rusb::Error), + #[error("CH341A disconnected during the operation")] + Disconnected, + #[error("I/O error: {0}")] Io(#[from] std::io::Error), diff --git a/src/gui/app/bios.rs b/src/gui/app/bios.rs index 413ea8c..b642a7e 100644 --- a/src/gui/app/bios.rs +++ b/src/gui/app/bios.rs @@ -18,7 +18,7 @@ impl AppView { /// decompresses sections, so it runs in a background task off the /// UI thread; only the finished `Vec` is handed back. pub fn pick_bios_file(&mut self, cx: &mut Context) { - let start_dir = self.prefs.last_hex_dir.clone(); + let start_dir = self.prefs.dialog_dir(); cx.spawn(async move |weak, cx| { let mut dialog = rfd::AsyncFileDialog::new() .add_filter("BIOS images", &["bin", "rom", "fd"]) @@ -135,7 +135,7 @@ impl AppView { .and_then(|p| p.file_name()) .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| "(loaded image)".into()); - let start_dir = self.prefs.last_hex_dir.clone(); + let start_dir = self.prefs.dialog_dir(); cx.spawn(async move |weak, cx| { let mut dialog = rfd::AsyncFileDialog::new() .add_filter("BIOS images", &["bin", "rom", "fd"]) @@ -198,7 +198,7 @@ impl AppView { .and_then(|p| p.file_stem()) .map(|s| format!("{}-settings.json", s.to_string_lossy())) .unwrap_or_else(|| "bios-settings.json".to_string()); - let start_dir = self.prefs.last_hex_dir.clone(); + let start_dir = self.prefs.dialog_dir(); cx.spawn(async move |weak, cx| { let mut dialog = rfd::AsyncFileDialog::new().set_file_name(default_name); if let Some(dir) = start_dir { diff --git a/src/gui/app/hex.rs b/src/gui/app/hex.rs index f7acda1..681bb56 100644 --- a/src/gui/app/hex.rs +++ b/src/gui/app/hex.rs @@ -368,7 +368,7 @@ impl AppView { /// and gpui panics with "RefCell already borrowed". Running on a /// foreground async task lets the click-handler borrow drop first. pub fn pick_hex_file(&mut self, cx: &mut Context) { - let start_dir = self.prefs.last_hex_dir.clone(); + let start_dir = self.prefs.dialog_dir(); cx.spawn(async move |weak, cx| { let mut dialog = rfd::AsyncFileDialog::new() .add_filter("Flash dumps", &["bin", "rom"]) diff --git a/src/gui/app/i2c.rs b/src/gui/app/i2c.rs index 6b2123f..5f07b8c 100644 --- a/src/gui/app/i2c.rs +++ b/src/gui/app/i2c.rs @@ -120,11 +120,13 @@ impl AppView { format!("Read {size} bytes from {name} → {}", path.display()), ) } + // `ops::read` deletes the truncated dump on any failure. Err(err) if crate::gui::is_cancel(&err) => { - // Don't leave a truncated dump that looks real. - let _ = std::fs::remove_file(&path); this.set_op_result(false, "Read cancelled; partial dump removed".into()) } + // Only the "read:" stage creates the file. + Err(err) if err.starts_with("read:") => this + .set_op_result(false, format!("Read failed: {err}; partial dump removed")), Err(err) => this.set_op_result(false, format!("Read failed: {err}")), } cx.notify(); diff --git a/src/gui/app/otp.rs b/src/gui/app/otp.rs index ad168a0..952e3d6 100644 --- a/src/gui/app/otp.rs +++ b/src/gui/app/otp.rs @@ -25,16 +25,7 @@ impl AppView { .spawn(async move { let mut ch = Programmer::open(false).map_err(|e| format!("open: {e}"))?; ch.set_clock(speed).map_err(|e| format!("set clock: {e}"))?; - let detect = ops::run_detect(&mut ch).map_err(|e| format!("detect: {e}"))?; - match detect.diagnosis { - Diagnosis::MisoStuckLow => { - return Err("MISO stuck low (target board contention)".into()); - } - Diagnosis::MisoFloatsHigh => { - return Err("MISO floats high (no chip / HOLD# grounded)".into()); - } - _ => {} - } + ops::ensure_chip_present(&mut ch).map_err(|e| format!("{e}"))?; ops::read_otp_registers(&mut ch).map_err(|e| format!("{e}")) }) .await; diff --git a/src/gui/app/spi.rs b/src/gui/app/spi.rs index 9570738..d1e8549 100644 --- a/src/gui/app/spi.rs +++ b/src/gui/app/spi.rs @@ -466,11 +466,15 @@ impl AppView { true, format!("Read {size} bytes from {name} → {}", path.display()), ), + // `ops::read` deletes the truncated dump on any failure. Err(err) if crate::gui::is_cancel(&err) => { - // Don't leave a truncated dump that looks real. - let _ = std::fs::remove_file(&path); this.set_op_result(false, "Read cancelled; partial dump removed".into()) } + // Only the "read:" stage creates the file. + Err(err) if err.starts_with("read:") => this.set_op_result( + false, + format!("Read failed: {err}; partial dump removed"), + ), Err(err) => this.set_op_result(false, format!("Read failed: {err}")), } cx.notify(); @@ -602,7 +606,8 @@ impl AppView { } _ => {} } - crate::spi::read_all_status(&mut ch).map_err(|e| format!("{e}")) + crate::spi::read_all_status(&mut ch, detect.jedec_id[0] == 0xC2) + .map_err(|e| format!("{e}")) }) .await; @@ -611,10 +616,12 @@ impl AppView { match outcome { Ok(regs) => { this.status_regs = Some(regs); - this.push_log(format!( - "Status OK: SR1=0x{:02X} SR2=0x{:02X} SR3=0x{:02X}", - regs.sr1, regs.sr2, regs.sr3 - )); + let summary: Vec = regs + .views() + .iter() + .map(|v| format!("{}=0x{:02X}", v.name, v.value)) + .collect(); + this.push_log(format!("Status OK: {}", summary.join(" "))); } Err(err) => this.set_op_result(false, format!("Status read failed: {err}")), } diff --git a/src/gui/log.rs b/src/gui/log.rs index d8a8ad5..2026f9c 100644 --- a/src/gui/log.rs +++ b/src/gui/log.rs @@ -206,7 +206,10 @@ fn surface( .flex() .flex_col() .gap_1() - .px_5() + .pl_5() + // Clear the floating chips so long lines wrap + // before running underneath them. + .pr(px(112.0)) .py_3() .font_family(theme::MONO_FONT) .text_size(px(12.0)) diff --git a/src/gui/menus.rs b/src/gui/menus.rs index 1072648..c67e796 100644 --- a/src/gui/menus.rs +++ b/src/gui/menus.rs @@ -14,8 +14,8 @@ //! Open, About) are handled on the root view in `AppView::render`. use super::{ - About, Bus, CopyHexSelection, FindNextAction, FindPrevAction, FocusFind, Identify, Navigate, - OpenBios, Pane, Quit, SetBus, + About, Bus, CopyHexSelection, FindNextAction, FindPrevAction, FocusFind, HexZoomIn, HexZoomOut, + HexZoomReset, Identify, Navigate, OpenBios, Pane, Quit, SetBus, }; use gpui::{App, KeyBinding, Menu, MenuItem}; use gpui_kit::component::GlobalState; @@ -92,6 +92,10 @@ fn build_menus(bus: Bus) -> Vec { MenuItem::separator(), MenuItem::action("Hex Viewer", Navigate(Pane::Hex)), MenuItem::action("BIOS Explorer", Navigate(Pane::Bios)), + MenuItem::separator(), + MenuItem::action("Zoom In", HexZoomIn), + MenuItem::action("Zoom Out", HexZoomOut), + MenuItem::action("Actual Size", HexZoomReset), ], disabled: false, }, diff --git a/src/gui/panes.rs b/src/gui/panes.rs index c3b4c2e..05d7644 100644 --- a/src/gui/panes.rs +++ b/src/gui/panes.rs @@ -160,6 +160,9 @@ pub fn render( inputs.otp_write_path, inputs.otp_erase_armed, inputs.otp_write_armed, + inputs + .detect_result + .is_some_and(|d| d.jedec.starts_with("C2")), inputs.op_result, cx, ) diff --git a/src/gui/panes/bios.rs b/src/gui/panes/bios.rs index 4f9e102..3b7e82b 100644 --- a/src/gui/panes/bios.rs +++ b/src/gui/panes/bios.rs @@ -81,6 +81,15 @@ pub(super) fn bios_pane( // Nothing loaded yet — the file row above is the whole pane. return col; }; + if settings.is_empty() && bios_ifd.is_none() { + // Parsed fine but found no Setup forms: not a UEFI image (a + // router bootloader, a VBIOS, …). Say so rather than show an + // empty table. + return col.child(body( + "No UEFI Setup settings found in this image. It doesn't look \ + like a UEFI BIOS (no firmware volumes with Setup forms).", + )); + } // Search overrides navigation (matches across every form); otherwise // the selected form scopes the list, and with neither we show all. diff --git a/src/gui/panes/hex.rs b/src/gui/panes/hex.rs index 23e9898..a5b701c 100644 --- a/src/gui/panes/hex.rs +++ b/src/gui/panes/hex.rs @@ -61,13 +61,10 @@ pub(super) fn hex_pane( ), ) .child(body( - "Inspect any binary file in hex+ASCII, or extract its \ - printable strings (≥4 chars). The Find bar below works in \ - both modes — typing highlights matching bytes in Hex and \ - filters the Strings list. Press Enter on `0xOFFSET` to jump \ - to that address; on a pattern, Enter jumps to the first \ - match. Chevrons step between matches. Zoom the text with \ - Cmd/Ctrl + and −, reset with Cmd/Ctrl 0.", + // Kept short: every line here comes out of the grid's height. + "Hex+ASCII or printable strings of any file. Find highlights \ + matches; Enter on `0xOFFSET` jumps there. Zoom with Cmd/Ctrl \ + + and −, reset with Cmd/Ctrl 0.", )) // File picker in a titled GroupBox, matching the other panes' // file-selection controls (Verify / Write / I²C). diff --git a/src/gui/panes/status.rs b/src/gui/panes/status.rs index ad63cdf..da5b0c1 100644 --- a/src/gui/panes/status.rs +++ b/src/gui/panes/status.rs @@ -11,7 +11,7 @@ pub(super) fn status_pane( ) -> impl IntoElement { let mut col = op_pane( "Status registers", - "Reads SR1 / SR2 / SR3 and decodes the standard bits. Useful \ + "Reads the status registers and decodes the standard bits. Useful \ for diagnosing writes that silently fail (block-protect set) \ or quad-mode issues (QE clear).", ); @@ -23,7 +23,7 @@ pub(super) fn status_pane( )); col = col.children(result_block(result)); if let Some(r) = regs { - // Group the three register blocks inside a single card so + // Group the register blocks inside a single card so // "decoded result" is visually distinct from the pane's // heading + body + button stack above. Each SR block keeps // its own header (SR1 / SR2 / SR3) inside the card. The @@ -31,53 +31,18 @@ pub(super) fn status_pane( // three registers to the clipboard for paste-into-issue // / share-with-a-coworker workflows. let copy_text = format_status_for_copy(&r); - col = col.child( - card_with_copy(copy_text, "copy-status", cx) - .child(status_register_block( - "SR1", - r.sr1, - true, - &[ - ("WIP", r.wip().to_string()), - ("WEL", r.wel().to_string()), - ("BP", r.bp().to_string()), - ("TB", r.tb().to_string()), - ("SEC/BP3", r.sec_or_bp3().to_string()), - ("SRP0", r.srp0().to_string()), - ], - )) - .child(status_register_block( - "SR2", - r.sr2, - r.sr2_present(), - &[ - ("SRP1", r.srp1().to_string()), - ("QE", r.qe().to_string()), - ("LB", r.lb().to_string()), - ("CMP", r.cmp().to_string()), - ("SUS", r.sus().to_string()), - ], - )) - .child(status_register_block( - "SR3", - r.sr3, - r.sr3_present(), - &[ - ("ADP", r.adp().to_string()), - ("WPS", r.wps().to_string()), - ("DRV", r.drv().to_string()), - ("HOLD/RST", r.hold_rst().to_string()), - ], - )), - ); + let mut card = card_with_copy(copy_text, "copy-status", cx); + for v in r.views() { + card = card.child(status_register_block(v.name, v.value, v.present, &v.bits)); + } + col = col.child(card); // Same gotcha-surfacing note the CLI prints — most common // reason a user is on this pane is "writes silently failing" // due to BP bits, so call it out directly. - if r.bp() != 0 || r.sec_or_bp3() { + if r.bp() != 0 { col = col.child(armed_warning( "SR1 has block-protect bits set: writes and erases to the protected range \ - will silently fail. Clear BP[2:0] (and SEC/BP3 if set) via WRSR before \ - programming.", + will silently fail. Clear the BP bits via WRSR before programming.", )); } } @@ -89,42 +54,18 @@ pub(super) fn status_pane( /// so the GUI Copy button doesn't need to round-trip through /// `ops::print_status` (which writes to stdout). pub(super) fn format_status_for_copy(r: &crate::spi::StatusRegisters) -> String { - let bit = |b: bool| if b { '1' } else { '0' }; let mut s = String::new(); - s.push_str(&format!("SR1 : 0x{:02X} (0b{:08b})\n", r.sr1, r.sr1)); - s.push_str(&format!( - " WIP={} WEL={} BP={} TB={} SEC/BP3={} SRP0={}\n", - bit(r.wip()), - bit(r.wel()), - r.bp(), - bit(r.tb()), - bit(r.sec_or_bp3()), - bit(r.srp0()), - )); - if r.sr2_present() { - s.push_str(&format!("SR2 : 0x{:02X} (0b{:08b})\n", r.sr2, r.sr2)); - s.push_str(&format!( - " SRP1={} QE={} LB={} CMP={} SUS={}\n", - bit(r.srp1()), - bit(r.qe()), - r.lb(), - bit(r.cmp()), - bit(r.sus()), - )); - } else { - s.push_str("SR2 : 0xFF (chip didn't respond)\n"); - } - if r.sr3_present() { - s.push_str(&format!("SR3 : 0x{:02X} (0b{:08b})\n", r.sr3, r.sr3)); - s.push_str(&format!( - " ADP={} WPS={} DRV={} HOLD/RST={}\n", - bit(r.adp()), - bit(r.wps()), - r.drv(), - bit(r.hold_rst()), - )); - } else { - s.push_str("SR3 : 0xFF (chip didn't respond)\n"); + for v in r.views() { + if v.present { + s.push_str(&format!( + "{:<4}: 0x{:02X} (0b{:08b})\n", + v.name, v.value, v.value + )); + let bits: Vec = v.bits.iter().map(|(n, b)| format!("{n}={b}")).collect(); + s.push_str(&format!(" {}\n", bits.join(" "))); + } else { + s.push_str(&format!("{:<4}: 0xFF (chip didn't respond)\n", v.name)); + } } s } @@ -193,12 +134,14 @@ pub(super) fn status_register_block( /// `otp erase` / `otp write`: read dumps the three registers as a /// copyable hexdump card; erase / write target one register behind /// the same two-stage arm/confirm the Erase and Write panes use. +#[allow(clippy::too_many_arguments)] pub(super) fn otp_pane( regs: Option<&[crate::ops::OtpRegister]>, target_register: u8, write_path: Option<&Path>, erase_armed: bool, write_armed: bool, + macronix: bool, result: Option<&(bool, String)>, cx: &mut Context, ) -> impl IntoElement { @@ -211,6 +154,17 @@ pub(super) fn otp_pane( clears bits, so erase first for a clean write. Macronix \ parts use a different opcode and aren't covered.", ); + // The ops refuse Macronix too (`ops::ensure_chip_present`); hiding + // the controls keeps a user from arming an erase that can't apply. + if macronix { + return col.child( + Alert::info( + "otp-macronix", + "The detected chip is Macronix, so these controls are unavailable.", + ) + .max_w(px(680.0)), + ); + } col = col.child(action_button_for( "Read security registers", "read-otp", diff --git a/src/gui/sidebar.rs b/src/gui/sidebar.rs index 0717329..cc03e5d 100644 --- a/src/gui/sidebar.rs +++ b/src/gui/sidebar.rs @@ -1,7 +1,7 @@ use super::{AppView, Bus, Pane, theme}; use gpui::{ - ClickEvent, Context, IntoElement, ParentElement, Styled as _, div, prelude::FluentBuilder as _, - px, + ClickEvent, Context, InteractiveElement as _, IntoElement, ParentElement, + StatefulInteractiveElement as _, Styled as _, div, prelude::FluentBuilder as _, px, }; use gpui_kit::component::badge::Badge; use gpui_kit::component::button::{Button, ButtonGroup, ButtonVariants as _}; @@ -50,11 +50,19 @@ pub fn render(selected: Pane, bus: Bus, cx: &mut Context) -> impl IntoE .items_center() .child(bus_toggle(bus, cx)), ) - .child(match bus { - Bus::Spi => spi_workflow(selected, cx).into_any_element(), - Bus::I2c => i2c_workflow(selected, cx).into_any_element(), - }) - .child(div().flex_1()) + // Scrolls when the window is too short for every step and + // tool; Settings stays pinned below. + .child( + div() + .id("sidebar-workflow") + .flex_1() + .min_h(px(0.0)) + .overflow_y_scroll() + .child(match bus { + Bus::Spi => spi_workflow(selected, cx).into_any_element(), + Bus::I2c => i2c_workflow(selected, cx).into_any_element(), + }), + ) .child(item( Pane::Settings, "⚙ Settings", diff --git a/src/i2c_ops.rs b/src/i2c_ops.rs index 75aa804..e209046 100644 --- a/src/i2c_ops.rs +++ b/src/i2c_ops.rs @@ -53,14 +53,21 @@ pub fn read( progress.start(len as u64); let mut addr = mem_addr; - while addr < end { - let n = std::cmp::min(READ_CHUNK_BYTES, end - addr); - let data = i2c::read(bus, chip, addr, n, pin_straps)?; - out.write_all(&data)?; - hasher.update(&data); - addr += n; - crate::ops::tick(progress, (addr - mem_addr) as u64)?; - } + let streamed = (|| { + while addr < end { + let n = std::cmp::min(READ_CHUNK_BYTES, end - addr); + let data = i2c::read(bus, chip, addr, n, pin_straps)?; + out.write_all(&data)?; + hasher.update(&data); + addr += n; + crate::ops::tick(progress, (addr - mem_addr) as u64)?; + } + Ok(()) + })(); + // Close before removing: Windows can't delete an open file. + drop(out); + crate::ops::remove_if_failed(output, &streamed); + streamed?; progress.finish(); println!("Read OK : {} bytes → {}", len, output.display()); println!("SHA-256 : {}", hex::encode(hasher.finalize())); diff --git a/src/ops.rs b/src/ops.rs index 0cf8d1a..cd5f650 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -210,7 +210,7 @@ pub fn status(verbose: bool) -> Result<()> { Diagnosis::UnknownChip => println!("Chip : (JEDEC ID not in DB; reading SR anyway)"), } println!(); - let regs = spi::read_all_status(&mut ch)?; + let regs = spi::read_all_status(&mut ch, detect.jedec_id[0] == 0xC2)?; print_status(®s); Ok(()) } @@ -220,57 +220,28 @@ pub fn status(verbose: bool) -> Result<()> { /// without going through stdout. (The print itself stays here so /// the CLI dispatcher doesn't need to know about the layout.) fn print_status(regs: &spi::StatusRegisters) { - println!("SR1 : 0x{:02X} (0b{:08b})", regs.sr1, regs.sr1); - println!( - " WIP={} WEL={} BP={} TB={} SEC/BP3={} SRP0={}", - bit(regs.wip()), - bit(regs.wel()), - regs.bp(), - bit(regs.tb()), - bit(regs.sec_or_bp3()), - bit(regs.srp0()), - ); - if regs.sr2_present() { - println!("SR2 : 0x{:02X} (0b{:08b})", regs.sr2, regs.sr2); - println!( - " SRP1={} QE={} LB={} CMP={} SUS={}", - bit(regs.srp1()), - bit(regs.qe()), - regs.lb(), - bit(regs.cmp()), - bit(regs.sus()), - ); - } else { - println!("SR2 : 0xFF (chip didn't respond, likely doesn't implement SR2)"); - } - if regs.sr3_present() { - println!("SR3 : 0x{:02X} (0b{:08b})", regs.sr3, regs.sr3); - println!( - " ADP={} WPS={} DRV={} HOLD/RST={}", - bit(regs.adp()), - bit(regs.wps()), - regs.drv(), - bit(regs.hold_rst()), - ); - } else { - println!("SR3 : 0xFF (chip didn't respond, likely doesn't implement SR3)"); + for r in regs.views() { + if r.present { + println!("{:<4}: 0x{:02X} (0b{:08b})", r.name, r.value, r.value); + let bits: Vec = r.bits.iter().map(|(n, v)| format!("{n}={v}")).collect(); + println!(" {}", bits.join(" ")); + } else { + println!( + "{:<4}: 0xFF (chip didn't respond, likely doesn't implement {})", + r.name, r.name + ); + } } - // Two common gotchas worth surfacing without forcing the user - // to know the bit semantics: a non-zero BP mask silently fails - // writes, and writes to the SR itself need SRP0/SRP1 to permit - // it. Anything else is left to the decoded view above. - if regs.bp() != 0 || regs.sec_or_bp3() { + // The common gotcha worth surfacing without forcing the user to + // know the bit semantics: a non-zero BP mask silently fails writes. + if regs.bp() != 0 { println!(); println!("note : SR1 has block-protect bits set: writes and erases to the protected"); - println!(" range will silently fail. Clear BP[2:0] (and SEC/BP3 if set) via"); - println!(" WRSR before programming."); + println!(" range will silently fail. Clear the BP bits via WRSR before"); + println!(" programming."); } } -fn bit(b: bool) -> char { - if b { '1' } else { '0' } -} - /// Read the chip's SFDP table, parse the JEDEC Basic Flash /// Parameter Table out of it, and print both the raw header walk /// and the decoded BFPT. Same JEDEC-first guard as `status` so @@ -430,12 +401,26 @@ pub fn read_otp_registers(spi: &mut dyn SpiTransport) -> Result Ok(out) } -/// Bail with a clear error if no chip is driving MISO. Run before a +/// Macronix keeps its secured OTP behind ENSO/EXSO (0xB1/0xC1), not +/// the 0x48/0x42/0x44 register opcodes, so the OTP ops refuse it. +pub const MACRONIX_OTP: &str = + "Macronix chips use a different security-OTP scheme (ENSO/EXSO); not supported"; + +pub fn is_macronix(detect: &DetectResult) -> bool { + detect.jedec_id[0] == 0xC2 +} + +/// Bail with a clear error if no chip is driving MISO, or if it's a +/// Macronix part the security-register opcodes don't apply to. Run before a /// destructive OTP op so "no chip / board contention" surfaces as a /// message instead of a confusing verify failure after a write that /// never reached silicon. pub fn ensure_chip_present(spi: &mut dyn SpiTransport) -> Result<()> { - match run_detect(spi)?.diagnosis { + let detect = run_detect(spi)?; + if is_macronix(&detect) { + return Err(Error::Otp(MACRONIX_OTP.into())); + } + match detect.diagnosis { Diagnosis::MisoStuckLow => Err(Error::Otp( "MISO stuck low (board contention); lift pin 8 (VCC) or remove the chip".into(), )), @@ -541,6 +526,9 @@ pub fn otp(verbose: bool) -> Result<()> { println!("Chip : (JEDEC ID not in DB; reading security registers anyway)") } } + if is_macronix(&detect) { + return Err(Error::Otp(MACRONIX_OTP.into())); + } println!(); println!("Security registers (Winbond/GigaDevice 0x48 convention, 3 x 256 B):"); let regs = read_otp_registers(&mut ch)?; @@ -648,6 +636,14 @@ pub fn synthesize_from_sfdp(spi: &mut dyn SpiTransport, jedec: &str) -> Result) { + if result.is_err() { + let _ = std::fs::remove_file(output); + } +} + pub fn read( spi: &mut dyn SpiTransport, chip: &Chip, @@ -682,16 +678,25 @@ pub fn read( let mut addr = start; let end = start + len; - while addr < end { - let n = std::cmp::min(READ_CHUNK, end - addr); - let data = spi::read_data(spi, addressing, addr, n as usize)?; - out.write_all(&data)?; - hasher.update(&data); - addr += n; - tick(progress, (addr - start) as u64)?; + let streamed = (|| { + while addr < end { + let n = std::cmp::min(READ_CHUNK, end - addr); + let data = spi::read_data(spi, addressing, addr, n as usize)?; + out.write_all(&data)?; + hasher.update(&data); + addr += n; + tick(progress, (addr - start) as u64)?; + } + out.flush()?; + Ok(()) + })(); + // Close before removing: Windows can't delete an open file. + drop(out); + if !stdout_mode { + remove_if_failed(output, &streamed); } + streamed?; progress.finish(); - out.flush()?; if !stdout_mode { // To stderr (`eprintln!`) so the dump file can be redirected // (`etch341 read -o foo.bin > log.txt`) without losing the @@ -994,7 +999,8 @@ mod tests { let err = read(&mut mock, &fake_chip(1), 0, 16, &tmp, &mut CancelSink).unwrap_err(); assert!(matches!(err, Error::Cancelled)); mock.assert_drained(); - let _ = std::fs::remove_file(&tmp); + // The truncated dump is deleted, not left looking like a good one. + assert!(!tmp.exists()); } /// Security-register read step: 0x48 + 3-byte addr + dummy, then diff --git a/src/prefs.rs b/src/prefs.rs index 6e21de7..0683355 100644 --- a/src/prefs.rs +++ b/src/prefs.rs @@ -119,6 +119,14 @@ impl Default for Prefs { } impl Prefs { + /// Where file dialogs open: the last picked folder, else the read + /// save location (where dumps land), else the OS default. + pub fn dialog_dir(&self) -> Option { + self.last_hex_dir + .clone() + .or_else(|| self.read_output_dir.clone()) + } + /// Per-OS prefs file location: /// - Linux: `$HOME/.config/etch341/prefs.toml` (XDG-ish) /// - macOS: `$HOME/.config/etch341/prefs.toml` (same; many diff --git a/src/spi.rs b/src/spi.rs index ece5f34..4010a60 100644 --- a/src/spi.rs +++ b/src/spi.rs @@ -9,8 +9,8 @@ use std::time::{Duration, Instant}; pub mod opcode { pub const JEDEC_ID: u8 = 0x9F; pub const READ_STATUS: u8 = 0x05; - /// Read Status Register 2 (W25Q-family + most modern Macronix / - /// GigaDevice parts). Older / simpler chips (24C, SST25VF, some + /// Read Status Register 2 (W25Q-family + GigaDevice; NOT Macronix, + /// where 0x35 is EQIO / enter-QPI). Older / simpler chips (24C, SST25VF, some /// EN25 sizes) don't implement this — they NACK or echo back /// `0xFF`/`0x00`. Caller should treat suspicious-looking values /// as "not present" rather than as legit register state. @@ -117,104 +117,111 @@ pub mod sr1 { pub const WEL: u8 = 0b0000_0010; } -/// Raw SR1/SR2/SR3 register bytes read from the chip, plus the -/// canonical W25Q-family decoded bit names. Other vendors mostly -/// follow this layout for SR1; SR2/SR3 vary more — the decode is -/// best-effort and shouldn't be used to drive writes on chips -/// outside the W25Q / MX25L / GD25Q families without checking the -/// datasheet first. +/// Raw status-register bytes read from the chip. The decode follows +/// the W25Q family (also GigaDevice) unless `macronix` is set: +/// Macronix puts BP3 at SR1 bit 5 and QE at bit 6, has no SR2 (its +/// `0x35` is EQIO, enter-QPI, so we never send it), and `0x15` reads +/// its configuration register rather than an SR3. Best-effort for +/// other vendors; check the datasheet before acting on it. #[derive(Debug, Clone, Copy)] pub struct StatusRegisters { pub sr1: u8, pub sr2: u8, + /// W25Q SR3, or the configuration register on Macronix. pub sr3: u8, + pub macronix: bool, +} + +/// One decoded register for display: name, raw byte, whether the chip +/// answered (`0xFF` = MISO high-z, i.e. not implemented), and the +/// decoded `NAME=value` fields. +pub struct RegisterView { + pub name: &'static str, + pub value: u8, + pub present: bool, + pub bits: Vec<(&'static str, String)>, } impl StatusRegisters { - // ---- SR1 ---- - pub fn wip(self) -> bool { - self.sr1 & 0b0000_0001 != 0 - } - pub fn wel(self) -> bool { - self.sr1 & 0b0000_0010 != 0 - } - /// Block-Protect bits (BP0..BP2). Together with TB and SR1.bit6 - /// (varies BP3/SEC by family) they decode to a protected - /// region. We surface the raw value; printing converts to - /// dot/bar style for readability. + /// Block-protect bits: BP0..BP3 (SR1 bits 2-5) on Macronix, + /// BP0..BP2 (bits 2-4) on W25Q. Non-zero means some range is + /// write-protected and program/erase there silently fails. pub fn bp(self) -> u8 { - (self.sr1 >> 2) & 0b0000_0111 - } - /// Top/Bottom protect. When set, the BP bits protect from the - /// bottom of the chip up; when clear, from the top down. - pub fn tb(self) -> bool { - self.sr1 & 0b0010_0000 != 0 - } - /// Bit 6 of SR1. On W25Q this is SEC (4 KB sector vs 64 KB - /// block protect granularity); on MX25L it's BP3 (extending - /// the block-protect mask one bit). Caller decides which name - /// to print based on chip family. - pub fn sec_or_bp3(self) -> bool { - self.sr1 & 0b0100_0000 != 0 - } - /// Status Register Protect 0 — pairs with SR2.SRP1 to gate - /// whether SR can be written via WRSR. - pub fn srp0(self) -> bool { - self.sr1 & 0b1000_0000 != 0 - } - - // ---- SR2 ---- - pub fn srp1(self) -> bool { - self.sr2 & 0b0000_0001 != 0 - } - /// Quad Enable. Must be set for chips wired in QSPI mode; some - /// devices NACK the read-quad opcodes if it's clear. - pub fn qe(self) -> bool { - self.sr2 & 0b0000_0010 != 0 - } - /// Security-register lock bits LB1..LB3. Setting one bit - /// one-time-programs that security page closed. - pub fn lb(self) -> u8 { - (self.sr2 >> 3) & 0b0000_0111 - } - /// Complement Protect — inverts the BP protect range - /// (protected region becomes unprotected and vice versa). - pub fn cmp(self) -> bool { - self.sr2 & 0b0100_0000 != 0 - } - /// Erase / Program Suspend status. - pub fn sus(self) -> bool { - self.sr2 & 0b1000_0000 != 0 - } - - // ---- SR3 ---- - /// 4-byte-address mode at power-up. - pub fn adp(self) -> bool { - self.sr3 & 0b0000_0010 != 0 - } - /// Write Protect Selection: 0 = CMP/SEC/TB/BP (standard); - /// 1 = individual block / sector lock (WPS-style). - pub fn wps(self) -> bool { - self.sr3 & 0b0000_0100 != 0 - } - /// Output driver strength (DRV1:DRV0). 0=100%, 1=75%, 2=50%, 3=25%. - pub fn drv(self) -> u8 { - (self.sr3 >> 5) & 0b0000_0011 - } - /// Pin-7 mode: 0 = /HOLD enabled; 1 = /RESET enabled. - pub fn hold_rst(self) -> bool { - self.sr3 & 0b1000_0000 != 0 - } - - /// `true` when the byte is `0xFF` — every read returned all-ones - /// which on SPI means MISO was high-z (no device drove it) or - /// the chip NACKed by tri-stating. Useful for hiding garbage - /// decode of SR2/SR3 on chips that don't implement those. - pub fn sr2_present(self) -> bool { - self.sr2 != 0xFF - } - pub fn sr3_present(self) -> bool { - self.sr3 != 0xFF + let mask = if self.macronix { 0x0F } else { 0x07 }; + (self.sr1 >> 2) & mask + } + + /// The registers this chip family has, decoded. Shared by the CLI + /// `sr` printout, the GUI Status pane and its Copy text. + pub fn views(self) -> Vec { + let b = |byte: u8, bit: u8| ((byte >> bit) & 1).to_string(); + let sr1 = self.sr1; + let sr3 = self.sr3; + if self.macronix { + return vec![ + RegisterView { + name: "SR1", + value: sr1, + present: true, + bits: vec![ + ("WIP", b(sr1, 0)), + ("WEL", b(sr1, 1)), + ("BP", self.bp().to_string()), + ("QE", b(sr1, 6)), + ("SRWD", b(sr1, 7)), + ], + }, + RegisterView { + name: "CR", + value: sr3, + present: sr3 != 0xFF, + bits: vec![ + ("ODS", (sr3 & 0x07).to_string()), + ("TB", b(sr3, 3)), + ("DC", (sr3 >> 6).to_string()), + ], + }, + ]; + } + let sr2 = self.sr2; + vec![ + RegisterView { + name: "SR1", + value: sr1, + present: true, + bits: vec![ + ("WIP", b(sr1, 0)), + ("WEL", b(sr1, 1)), + ("BP", self.bp().to_string()), + ("TB", b(sr1, 5)), + ("SEC", b(sr1, 6)), + ("SRP0", b(sr1, 7)), + ], + }, + RegisterView { + name: "SR2", + value: sr2, + present: sr2 != 0xFF, + bits: vec![ + ("SRP1", b(sr2, 0)), + ("QE", b(sr2, 1)), + ("LB", ((sr2 >> 3) & 0x07).to_string()), + ("CMP", b(sr2, 6)), + ("SUS", b(sr2, 7)), + ], + }, + RegisterView { + name: "SR3", + value: sr3, + present: sr3 != 0xFF, + bits: vec![ + ("ADP", b(sr3, 1)), + ("WPS", b(sr3, 2)), + ("DRV", ((sr3 >> 5) & 0x03).to_string()), + ("HOLD/RST", b(sr3, 7)), + ], + }, + ] } } @@ -293,16 +300,17 @@ pub fn read_security_register( Ok(rx) } -/// Read all three status registers in a single op. SR2/SR3 reads -/// against a chip that doesn't implement them return `0xFF` (MISO -/// pulled high while the chip ignores the opcode); `StatusRegisters` -/// surfaces that via `sr2_present`/`sr3_present` so the caller can -/// skip the decode for that register. -pub fn read_all_status(spi: &mut dyn SpiTransport) -> Result { +/// Read the status registers in a single op. Registers a chip doesn't +/// implement read back `0xFF` (MISO pulled high while the chip ignores +/// the opcode); `RegisterView::present` hides their decode. On +/// Macronix (`0xC2`) SR2 is skipped: `0x35` there enters QPI mode, +/// after which the chip stops answering plain SPI until power-cycled. +pub fn read_all_status(spi: &mut dyn SpiTransport, macronix: bool) -> Result { Ok(StatusRegisters { sr1: read_status(spi)?, - sr2: read_status_2(spi)?, + sr2: if macronix { 0xFF } else { read_status_2(spi)? }, sr3: read_status_3(spi)?, + macronix, }) } @@ -508,6 +516,23 @@ mod tests { mock.assert_drained(); } + #[test] + fn macronix_status_skips_0x35_and_decodes_qe() { + // MX25L12835F with QE set: SR1 0x40 is not block protection. + let mut mock = MockSpi::new([ + (vec![0x05, 0x00], vec![0xFF, 0x40]), + (vec![0x15, 0x00], vec![0xFF, 0x07]), + ]); + let regs = read_all_status(&mut mock, true).unwrap(); + mock.assert_drained(); + assert_eq!(regs.bp(), 0); + let views = regs.views(); + assert_eq!(views[0].bits[3], ("QE", "1".to_string())); + assert_eq!(views[1].name, "CR"); + // BP3 is SR1 bit 5 on Macronix. + assert_eq!(StatusRegisters { sr1: 0x20, ..regs }.bp(), 0b1000); + } + #[test] fn read_data_3byte_builds_command_and_strips_header() { let mut mock = MockSpi::new([( From cda72499ddd9f60a70e40de765ca83e077be2ec5 Mon Sep 17 00:00:00 2001 From: Will Lehnertz <6597817+packetThrower@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:32:05 -0700 Subject: [PATCH 2/3] fix(i2c): chip picker updates the header and disarms; size-check on arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows I²C testing: switching the chip in Read carried into Write while the header still showed the previous chip, and an 8 KB file could be armed against a 24C08 (the write itself already refused it). Now a chip change re-labels the header and disarms write/erase, and arming checks the file fits and names file, size and chip. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ src/gui/app/i2c.rs | 24 +++++++++++++++++++++--- src/gui/mod.rs | 21 ++++++++++++++++++++- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb64247..84dd945 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,10 @@ Changelog](https://keepachangelog.com/en/1.1.0/); versions follow empty settings table. - **Activity-log lines wrap before the log's toolbar buttons** instead of running underneath them. +- **I²C chip picker keeps everything in step**: choosing a different + chip updates the header immediately and disarms a pending write or + erase. Arming a write refuses a file larger than the chosen chip, + and the armed log line names the file, its size and the chip. ## [0.8.0-beta.1] — 2026-10-25 diff --git a/src/gui/app/i2c.rs b/src/gui/app/i2c.rs index 5f07b8c..5025831 100644 --- a/src/gui/app/i2c.rs +++ b/src/gui/app/i2c.rs @@ -214,11 +214,29 @@ impl AppView { if self.i2c_write_armed { self.i2c_write_armed = false; self.start_i2c_write(cx); - } else { - self.i2c_write_armed = true; - self.push_log("⚠ i2c write armed: click again to confirm".into()); + return; + } + // Refuse to arm a file that can't fit, and name what the + // confirming click will do. + let (Some(path), Some(name)) = (self.i2c_write_path.clone(), self.i2c_chip_name(cx)) else { + return; + }; + let file_len = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + let chip_size = crate::i2c_ops::resolve_chip(&name).map_or(0, |c| c.size_bytes as u64); + if file_len > chip_size { + self.set_op_result( + false, + format!("File is {file_len} bytes but {name} only holds {chip_size}"), + ); cx.notify(); + return; } + self.i2c_write_armed = true; + self.push_log(format!( + "⚠ i2c write armed: {} ({file_len} bytes) → {name}; click again to confirm", + path.file_name().unwrap_or_default().to_string_lossy() + )); + cx.notify(); } fn start_i2c_write(&mut self, cx: &mut Context) { diff --git a/src/gui/mod.rs b/src/gui/mod.rs index fea6013..17c8e36 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -889,6 +889,25 @@ impl AppView { this.select_bios_form(target, cx); } }); + // Picking a different I²C chip updates the header straight away + // and disarms a pending write/erase, so the header, the pane and + // the armed op never disagree about which chip is targeted. + let i2c_chip_sub = cx.observe(&i2c_chip_select, |this: &mut Self, select, cx| { + let Some(name) = select.read(cx).selected_value().map(|v| v.to_string()) else { + return; + }; + let changed = match &this.connection { + Connection::Ready { chip_name, .. } => *chip_name != name, + Connection::Present => true, + Connection::Disconnected | Connection::NoChip => false, + }; + if changed { + this.note_i2c_chip(&name); + this.i2c_write_armed = false; + this.i2c_erase_armed = false; + cx.notify(); + } + }); Self::spawn_presence_poller(cx); Self { selected: Pane::Detect, @@ -954,7 +973,7 @@ impl AppView { bios_search_term: String::new(), bios_search_state, i2c_chip_select, - _subscriptions: vec![sub, bios_sub, nav_sub], + _subscriptions: vec![sub, bios_sub, nav_sub, i2c_chip_sub], progress: Arc::new(SharedProgress::default()), prefs: Prefs::load(), focus_handle: cx.focus_handle(), From 553cccd518af3cfe0c4a1bb07a4cf2c97ed679a6 Mon Sep 17 00:00:00 2001 From: Will Lehnertz <6597817+packetThrower@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:14:54 -0700 Subject: [PATCH 3/3] fix(gui): log lines wrap; chip change clears the stale result; menu shows Ctrl+= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows retest: long log lines still overflowed — the flex child needs min_w(0) and the column a full width before whitespace_normal can wrap. Also clear the previous chip's op result when the I²C chip changes, and bind '+' before '=' so the zoom menu shows the key users press. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++-- src/gui/log.rs | 9 ++++++++- src/gui/mod.rs | 11 +++++++++-- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84dd945..46ec816 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,8 +41,8 @@ Changelog](https://keepachangelog.com/en/1.1.0/); versions follow - **Activity-log lines wrap before the log's toolbar buttons** instead of running underneath them. - **I²C chip picker keeps everything in step**: choosing a different - chip updates the header immediately and disarms a pending write or - erase. Arming a write refuses a file larger than the chosen chip, + chip updates the header immediately, disarms a pending write or + erase, and clears the previous chip's result message. Arming a write refuses a file larger than the chosen chip, and the armed log line names the file, its size and the chip. ## [0.8.0-beta.1] — 2026-10-25 diff --git a/src/gui/log.rs b/src/gui/log.rs index 2026f9c..752c243 100644 --- a/src/gui/log.rs +++ b/src/gui/log.rs @@ -203,6 +203,7 @@ fn surface( .overflow_y_scroll() .child( div() + .w_full() .flex() .flex_col() .gap_1() @@ -238,7 +239,13 @@ fn log_row(line: &LogLine, local_tz: bool) -> impl IntoElement { .child( // Wrap long log lines (file paths, error messages) instead // of letting them run off the right edge. - div().flex_1().whitespace_normal().child(line.text.clone()), + // `min_w(0)` lets the flex child shrink below its text's + // width so it wraps instead of overflowing. + div() + .flex_1() + .min_w(px(0.0)) + .whitespace_normal() + .child(line.text.clone()), ) } diff --git a/src/gui/mod.rs b/src/gui/mod.rs index 17c8e36..1b594d8 100644 --- a/src/gui/mod.rs +++ b/src/gui/mod.rs @@ -249,8 +249,10 @@ pub fn run() -> Result<(), Box> { KeyBinding::new("cmd-g", FindNextAction, None), KeyBinding::new("cmd-shift-g", FindPrevAction, None), KeyBinding::new("cmd-c", CopyHexSelection, None), - KeyBinding::new("cmd-=", HexZoomIn, None), + // `+` first: the menu shows the last-bound key, and `=` + // is the one users actually press. KeyBinding::new("cmd-+", HexZoomIn, None), + KeyBinding::new("cmd-=", HexZoomIn, None), KeyBinding::new("cmd--", HexZoomOut, None), KeyBinding::new("cmd-0", HexZoomReset, None), ]); @@ -261,8 +263,10 @@ pub fn run() -> Result<(), Box> { KeyBinding::new("ctrl-g", FindNextAction, None), KeyBinding::new("ctrl-shift-g", FindPrevAction, None), KeyBinding::new("ctrl-c", CopyHexSelection, None), - KeyBinding::new("ctrl-=", HexZoomIn, None), + // `+` first: the menu shows the last-bound key, and `=` + // is the one users actually press. KeyBinding::new("ctrl-+", HexZoomIn, None), + KeyBinding::new("ctrl-=", HexZoomIn, None), KeyBinding::new("ctrl--", HexZoomOut, None), KeyBinding::new("ctrl-0", HexZoomReset, None), ]); @@ -905,6 +909,9 @@ impl AppView { this.note_i2c_chip(&name); this.i2c_write_armed = false; this.i2c_erase_armed = false; + // A result (e.g. "file too big for 24C08") was about + // the previous chip. + this.op_result = None; cx.notify(); } });