Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,38 @@ 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.
- **I²C chip picker keeps everything in step**: choosing a different
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

Expand Down
79 changes: 38 additions & 41 deletions docs/src/content/docs/usage/spi.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
34 changes: 29 additions & 5 deletions src/ch341.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Self> {
let mut ch = Self::open_raw(verbose)?;
Expand All @@ -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
Expand Down Expand Up @@ -224,15 +242,21 @@ 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));
}
Ok(())
}

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]));
}
Expand Down
5 changes: 4 additions & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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."
)]
Expand All @@ -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),

Expand Down
6 changes: 3 additions & 3 deletions src/gui/app/bios.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ impl AppView {
/// decompresses sections, so it runs in a background task off the
/// UI thread; only the finished `Vec<Setting>` is handed back.
pub fn pick_bios_file(&mut self, cx: &mut Context<Self>) {
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"])
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/gui/app/hex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self>) {
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"])
Expand Down
30 changes: 25 additions & 5 deletions src/gui/app/i2c.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -212,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<Self>) {
Expand Down
11 changes: 1 addition & 10 deletions src/gui/app/otp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
21 changes: 14 additions & 7 deletions src/gui/app/spi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;

Expand All @@ -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<String> = 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}")),
}
Expand Down
Loading
Loading