152 lines
10 KiB
Markdown
152 lines
10 KiB
Markdown
# 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<hal::peripherals::USBD>`. 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.
|