From 4002a886a5ddcb16123c1ae4d82d356b76b46c4e Mon Sep 17 00:00:00 2001 From: Empire Date: Thu, 13 Aug 2026 22:23:27 +0200 Subject: [PATCH] add hardware reference notes and INA219 bit-banged I2C implementation --- firmware/HARDWARE.md | 151 ++++++++++++++++++++++++++ firmware/src/selfcheck.rs | 219 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 firmware/HARDWARE.md create mode 100644 firmware/src/selfcheck.rs diff --git a/firmware/HARDWARE.md b/firmware/HARDWARE.md new file mode 100644 index 0000000..2b72b0d --- /dev/null +++ b/firmware/HARDWARE.md @@ -0,0 +1,151 @@ +# Hardware implementor's notes + +Reference for anyone touching `firmware/src/*.rs`: what's wired to what, which +peripherals/pins are used and why, and the non-obvious dependency-version +constraints this firmware relies on. Written against the real, currently +maintained schematic at `/home/empire/workspace/PlantCtrl/Hardware/open-bms/bms/bms.kicad_sch`. + +**The KiCad project checked into *this* repo (`board/bms/`) is a stale, older +board revision.** Its component references and pin assignments do not match +the hardware this firmware actually targets — do not use it as a source of +truth. Re-derive facts from the real schematic (e.g. via +`kicad-cli sch export netlist`) if anything below is in doubt. + +Target chip: **CH32V203C8T6** (RISC-V, `riscv32imc-unknown-none-elf`), no +external crystal populated — clocked from HSI only (see RCC note below). + +## Pin map + +| Pin | Function | Notes | +|---|---|---| +| PA8 | D7 LED | Active-high, 2.2kΩ (R13) to GND on the cathode side | +| PB12 | D3 LED | Active-high, 2.2kΩ (R14) to GND | +| PB13 | D4 LED | Active-high, 2.2kΩ (R15) to GND | +| PB14 | D5 LED | Active-high, 2.2kΩ (R16) to GND | +| PB15 | D6 LED | Active-high, 2.2kΩ (R17) to GND | +| PA11 | USB D− | Fixed-function USB pin on this chip, not remappable | +| PA12 | USB D+ | Fixed-function USB pin on this chip, not remappable | +| PA4 | SPI1 NSS (flash CS) | Driven manually as a GPIO `Output` — `ch32-hal`'s `Spi` has no CS management. Idle-high, matches the board's 10kΩ pull-up (R7) | +| PA5 | SPI1 SCK | Default/non-remapped SPI1 pin | +| PA6 | SPI1 MISO | Default/non-remapped SPI1 pin | +| PA7 | SPI1 MOSI | Default/non-remapped SPI1 pin | +| PB9 | INA219 SDA | **Bit-banged**, not hardware I2C — see gotcha below | +| PB10 | INA219 SCL | **Bit-banged**, not hardware I2C — see gotcha below | +| PB2 | Spare GPIO input | Pulled low via 10kΩ (R8), nothing else attached. Only real "input" available on this board | +| PB6 | I2C1 SDA (`S_SDA`) | Battery-pack-facing bus (XT30 connector pins 3/4, BAV99 protection diodes). Currently unused — see I2C1 note below | +| PB7 | I2C1 SCL (`S_SCL`) | Battery-pack-facing bus. Currently unused — see I2C1 note below | + +Not wired to anything: PA0-3, PA9-10, PA15, PB0-1, PB3-5, PB8, PB11, PC13-15. +`Boot1`/`Reset1` are the BOOT0 strap and NRST reset buttons respectively — +not runtime-readable GPIOs, don't try to poll them as inputs. + +## Peripherals in use + +- **USBD** — USB CDC-ACM virtual serial console. `hal::usbd::Driver::new(p.USBD, Irqs, p.PA12 /*dp*/, p.PA11 /*dm*/)`. Interrupt vector is shared with CAN1: `bind_interrupts!` needs `USB_LP_CAN1_RX0 => hal::usbd::InterruptHandler`. Max packet size is 64 bytes — `write_packet()` on a single call **fails** (`EndpointError::BufferOverflow`, does not panic) for anything longer; chunk before sending (see `usb_writer` in `main.rs`). +- **SPI1** — blocking, talks to the W25Q128JVE NOR flash (U5). `hal::spi::Spi::new_blocking::<0>(p.SPI1, sck, mosi, miso, config)` — note the argument order is `(sck, mosi, miso)`, not alphabetical. No hardware CS support in this driver; drive it yourself. +- **I2C1** — hardware peripheral, PB6/PB7 (remap 0). Intended for the battery-pack-facing slave bus (device acts as an I2C *slave*, address `0x55` in the existing but currently-commented-out code). **Not currently enabled** — `I2c::listen_blocking()` is a genuine busy-spin with no `.await`, which starves every other embassy task (USB included) on this single-threaded executor the instant it's called. Don't re-enable it without either moving it off the main task's critical path or replacing it with a non-blocking variant. +- **I2C2** — hardware peripheral, fixed to PB10 (SCL) / PB11 (SDA), no remap. **Do not use for the INA219.** PB11 is completely unconnected on this board. See the bit-bang gotcha below. +- **TIM2** — claimed by `ch32-hal`'s `time-driver-tim2` feature as the embassy time driver's tick source. Don't reuse TIM2 directly for anything else. + +## Hardware gotcha: the INA219 bus needs software (bit-banged) I2C + +The schematic labels a bus "SCL"/"SDA" going to the INA219 (U3), wired to +PB10 and PB9. This looks like it should be a normal hardware I2C bus, but +**it isn't reachable by any single hardware I2C peripheral on this chip**: + +- I2C2 is fixed to PB10=SCL, **PB11**=SDA (no remap available). PB11 is + unconnected on this board. +- I2C1 remap 1 is **PB8**=SCL, PB9=SDA. PB8 is unconnected on this board. + +PB10 only pairs (in hardware) with PB11; PB9 only pairs with PB8. Neither +partner pin is wired to the sensor. This is a board wiring quirk, not +something fixable via `ch32-hal` configuration. `firmware/src/selfcheck.rs` +therefore implements a minimal bit-banged I2C driver (`BitbangI2c`) over +plain open-drain GPIOs (`hal::gpio::Flex`) on PB9/PB10, implementing +`embedded_hal::i2c::I2c` via its single required `transaction()` method (the +`read`/`write`/`write_read` provided methods come for free from that). It +does **not** support clock stretching — fine for the INA219, which doesn't +stretch, but keep that in mind if another device ever goes on this bus. + +INA219 address: **0x48** — `A0`→GND, `A1`→SDA on this board, i.e. +`ina219::address::Address::from_pins(Pin::Gnd, Pin::Sda)`. Not the more +common default addresses seen in INA219 examples/datasheet defaults. + +Current-sense shunt: R4 ‖ R5, 100mΩ each → **50mΩ** combined. Current is +derived in software from `shunt_voltage_uv() / 50`, not from the INA219's +own calibration registers (`IntCalibration`) — this board's real max current +draw isn't documented anywhere, so guessing a `current_lsb` for hardware +calibration seemed worse than a simple, honest Ohm's-law calculation using +the known shunt value. + +## RCC / clock configuration + +`hal::init` is called with `rcc: hal::rcc::Config::SYSCLK_FREQ_144MHZ_HSI`, +**not** the HAL's default config. This is required for USB to get a valid +48MHz derived clock, and it's HSI-based because there's no external crystal +on this board (`OSC_IN`/`OSC_OUT` are unconnected). If you ever add code +that assumes a different clock tree, check this first. + +## Dependency versions that matter (not arbitrary pins in `Cargo.toml`) + +These aren't just "whatever version worked" — each one fixes a real, +previously-hit build or runtime failure. Don't casually bump/change them +without re-verifying: + +- **`embassy-executor` features = `["arch-spin", "executor-thread"]`** — NOT + `arch-riscv32`. `arch-riscv32`'s WFI-based sleep races with the USB + interrupt's wake signal: if the interrupt fires right as the core is about + to sleep, the wake can be missed and anything `.await`ing on it (e.g. + `wait_connection()`/`write_packet()`) hangs forever, even though the + device still enumerates fine. `arch-spin` busy-polls instead of sleeping, + sidestepping the race. (Symptom if this regresses: USB enumerates, but no + console output ever appears.) Confirmed the same fix is needed in the + sibling `PlantCtrl/Software/CAN_Sensor` project on the same HAL fork. +- **`embassy-time` features = `["generic-queue-8"]`, version `"0.5.0"`** — + needs to match the `embassy-time` version `ch32-hal` itself pulls in + transitively (currently 0.5.x); a direct-dependency version mismatch + causes a Cargo `links` conflict (`embassy-time-queue-utils` can't resolve + two different versions simultaneously). The `generic-queue-8` feature + makes `embassy-time` use its own self-contained timer queue instead of + requiring `embassy-executor` to implement an "integrated timer queue" + symbol that our pinned `embassy-executor 0.7.0` doesn't provide (that + support landed in later `embassy-executor` versions). +- **`embassy-usb = "0.5.1"`** — NOT 0.3.0/0.4.0. Those depend on + `embassy-usb-driver ^0.1.0`; `ch32-hal`'s `hal::usbd::Driver` implements + the `embassy-usb-driver 0.2.0` traits. `embassy-usb` versions ≥0.5.0 are + the first to depend on `embassy-usb-driver ^0.2.0`. Using an older + `embassy-usb` here is a hard type-check failure, not a subtle bug. +- **`heapless` feature = `"portable-atomic-critical-section"`** — needed for + `static_cell` (used for the `'static` USB buffers/class/device via the + `mk_static!` macro in `main.rs`) to build at all. `riscv32imc` has no + native atomic compare-and-swap instruction; without this feature, + `static_cell`'s internal `AtomicBool::compare_exchange` fails to compile + for this target. This feature makes `portable-atomic` (a shared transitive + dependency) emulate CAS via critical sections instead. +- **`ch32-hal`** — git dependency on `ju6ge/ch32-hal`, branch + `feature/i2c-slave-api` (upstream `ch32-rs/ch32-hal` does **not** have the + I2C slave-mode API this firmware's (currently disabled) I2C1 code depends + on — checked directly, no `SlaveConfig`/`listen_blocking` there). This + branch has been force-pushed/rebased before (the previously-locked commit + disappeared from GitHub entirely) — if `cargo build` ever fails to fetch + the pinned commit in `Cargo.lock`, that's almost certainly why; re-run + `cargo update -p ch32-hal` to pick up the branch's current tip. +- Entry point is `qingke_rt::entry` (via + `#[embassy_executor::main(entry = "qingke_rt::entry")]`), matching + `ch32-hal`'s own examples for this chip family. + +## Logging + +`println!` in `main.rs` is a **local macro**, not `ch32_hal::println!` — the +latter writes over WCH's SDI single-wire debug protocol (needs a WCH-Link +probe attached, see `hal`'s `debug.rs`). The local one formats into a +`heapless::String<128>` and pushes it onto a channel (`LOG_CH`) that a +spawned `usb_writer` task drains and writes out over the USB CDC-ACM +console. It's `#[macro_export]`ed with `$crate::LOG_CH` internally so other +modules (e.g. `selfcheck.rs`) can call `crate::println!(...)` and have it +land in the same place. Sends are non-blocking (`try_send`) and silently +drop the line if the channel (capacity 8) is full — there's no backpressure, +so a burst of many `println!` calls in a tight loop with no `.await` in +between can lose messages if nothing's draining the channel yet (e.g. no +terminal connected). USB CDC devices also re-enumerate on every reflash; +most terminal programs need to be manually reconnected afterward. diff --git a/firmware/src/selfcheck.rs b/firmware/src/selfcheck.rs new file mode 100644 index 0000000..48ff75a --- /dev/null +++ b/firmware/src/selfcheck.rs @@ -0,0 +1,219 @@ +//! Power-on self-check: exercises every external component on the board and +//! reports pass/fail plus readings over the USB console. + +use core::fmt::Write as _; +use embassy_time::Timer; +use embedded_hal::i2c::{ErrorKind, ErrorType, I2c, NoAcknowledgeSource, Operation}; +use hal::gpio::{Flex, Input, Output, Speed}; +use hal::mode::Blocking; +use hal::spi::Spi; +use ina219::address::{Address, Pin as Ina219Pin}; +use ina219::SyncIna219; +use {ch32_hal as hal, ch32_hal::peripherals::SPI1}; + +/// Bit-banged I2C on two plain GPIOs. +/// +/// The INA219's I2C bus is wired to PB9 (SDA) and PB10 (SCL) on this board, +/// but those two pins belong to *different* hardware I2C peripherals (PB10 is +/// I2C2's SCL, whose SDA partner PB11 is unconnected here; PB9 is only valid +/// as I2C1's SDA under pin-remap, whose SCL partner PB8 is also unconnected). +/// No single hardware I2C instance can reach both pins as wired, so this bus +/// has to be driven as plain open-drain GPIOs instead. No clock stretching +/// support, same as most minimal bit-bang I2C implementations. +pub struct BitbangI2c<'a, 'd> { + scl: &'a mut Flex<'d>, + sda: &'a mut Flex<'d>, +} + +#[derive(Debug)] +pub struct BitbangI2cError; + +impl embedded_hal::i2c::Error for BitbangI2cError { + fn kind(&self) -> ErrorKind { + ErrorKind::NoAcknowledge(NoAcknowledgeSource::Unknown) + } +} + +impl<'a, 'd> BitbangI2c<'a, 'd> { + pub fn new(scl: &'a mut Flex<'d>, sda: &'a mut Flex<'d>) -> Self { + scl.set_as_output_open_drain(Speed::Low); + sda.set_as_output_open_drain(Speed::Low); + scl.set_high(); + sda.set_high(); + let this = Self { scl, sda }; + Self::half_delay(); + this + } + + fn half_delay() { + // ~10us at 144MHz -> ~50kHz bit-bang clock, well within I2C standard mode. + qingke::riscv::asm::delay(1500); + } + + fn start(&mut self) { + self.sda.set_high(); + self.scl.set_high(); + Self::half_delay(); + self.sda.set_low(); + Self::half_delay(); + self.scl.set_low(); + Self::half_delay(); + } + + fn stop(&mut self) { + self.sda.set_low(); + Self::half_delay(); + self.scl.set_high(); + Self::half_delay(); + self.sda.set_high(); + Self::half_delay(); + } + + fn write_bit(&mut self, bit: bool) { + if bit { + self.sda.set_high(); + } else { + self.sda.set_low(); + } + Self::half_delay(); + self.scl.set_high(); + Self::half_delay(); + self.scl.set_low(); + Self::half_delay(); + } + + fn read_bit(&mut self) -> bool { + self.sda.set_high(); // release so the slave can drive it + Self::half_delay(); + self.scl.set_high(); + Self::half_delay(); + let bit = self.sda.is_high(); + self.scl.set_low(); + Self::half_delay(); + bit + } + + fn write_byte(&mut self, byte: u8) -> Result<(), BitbangI2cError> { + for i in (0..8).rev() { + self.write_bit((byte >> i) & 1 != 0); + } + if self.read_bit() { + // NACK + Err(BitbangI2cError) + } else { + Ok(()) + } + } + + fn read_byte(&mut self, ack: bool) -> u8 { + let mut byte = 0u8; + for _ in 0..8 { + byte = (byte << 1) | u8::from(self.read_bit()); + } + self.write_bit(!ack); + byte + } +} + +impl<'a, 'd> ErrorType for BitbangI2c<'a, 'd> { + type Error = BitbangI2cError; +} + +impl<'a, 'd> I2c for BitbangI2c<'a, 'd> { + fn transaction(&mut self, address: u8, operations: &mut [Operation<'_>]) -> Result<(), Self::Error> { + self.start(); + let mut last_read: Option = None; + for op in operations.iter_mut() { + let is_read = matches!(op, Operation::Read(_)); + if last_read != Some(is_read) { + if last_read.is_some() { + self.start(); // repeated start + } + self.write_byte((address << 1) | u8::from(is_read))?; + } + match op { + Operation::Write(data) => { + for &b in data.iter() { + self.write_byte(b)?; + } + } + Operation::Read(buf) => { + let len = buf.len(); + for (i, b) in buf.iter_mut().enumerate() { + *b = self.read_byte(i + 1 != len); + } + } + } + last_read = Some(is_read); + } + self.stop(); + Ok(()) + } +} + +/// Current-sense shunt on this board: R4 (100mOhm) parallel with R5 (100mOhm). +const SHUNT_MILLIOHM: i32 = 50; + +#[allow(clippy::too_many_arguments)] +pub async fn run<'d>( + d7: &mut Output<'d>, + d3: &mut Output<'d>, + d4: &mut Output<'d>, + d5: &mut Output<'d>, + d6: &mut Output<'d>, + input_pb2: &Input<'d>, + spi: &mut Spi<'d, SPI1, Blocking>, + flash_cs: &mut Output<'d>, + i2c_scl: &mut Flex<'d>, + i2c_sda: &mut Flex<'d>, +) { + crate::println!("=== self-check start ==="); + + crate::println!("-- LED outputs --"); + let leds: [(&str, &mut Output<'d>); 5] = [("D3", d3), ("D4", d4), ("D5", d5), ("D6", d6), ("D7", d7)]; + for (name, led) in leds { + crate::println!(" {name}: on"); + led.set_high(); + Timer::after_millis(150).await; + led.set_low(); + } + + crate::println!("-- input --"); + crate::println!(" PB2: {}", if input_pb2.is_high() { "high" } else { "low" }); + + crate::println!("-- current sensor (INA219 via bit-banged I2C on PB9/PB10) --"); + let i2c = BitbangI2c::new(i2c_scl, i2c_sda); + match SyncIna219::new(i2c, Address::from_pins(Ina219Pin::Gnd, Ina219Pin::Sda)) { + Ok(mut dev) => match (dev.bus_voltage(), dev.shunt_voltage()) { + (Ok(bus), Ok(shunt)) => { + let bus_mv = bus.voltage_mv(); + let shunt_uv = shunt.shunt_voltage_uv(); + let current_ma = shunt_uv / SHUNT_MILLIOHM; + crate::println!( + " PASS: bus={bus_mv}mV shunt={shunt_uv}uV current={current_ma}mA (shunt={SHUNT_MILLIOHM}mOhm)" + ); + } + _ => crate::println!(" FAIL: INA219 responded but a measurement read failed"), + }, + Err(_) => crate::println!(" FAIL: INA219 not responding (addr 0x48)"), + } + + crate::println!("-- SPI flash (W25Q128, JEDEC ID) --"); + flash_cs.set_low(); + let mut buf: [u8; 4] = [0x9F, 0, 0, 0]; + let result = spi.blocking_transfer_in_place(&mut buf); + flash_cs.set_high(); + match result { + Ok(()) => { + let [_, mfg, mem_type, capacity] = buf; + if mfg == 0xEF { + crate::println!(" PASS: JEDEC ID {mfg:02x} {mem_type:02x} {capacity:02x}"); + } else { + crate::println!(" FAIL: unexpected JEDEC ID {mfg:02x} {mem_type:02x} {capacity:02x}"); + } + } + Err(_) => crate::println!(" FAIL: SPI transfer error"), + } + + crate::println!("=== self-check done ==="); +}