From e1f6699e45c26d7fa64eb8ef1f7efde420d11168 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 20:28:26 +0000 Subject: [PATCH 01/57] build(deps): bump EMIL to 0059646 for the services/hil terminal The hardware-in-the-loop validation app builds on EMIL's services.hil and services.hil.commands, added after the previously pinned commit. The range only adds those libraries, Terminal command hooks, DutyCycle::FromRatio and an ISB after disabling an NVIC interrupt; no interface hal-st implements changes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6e6638fb..ded68e2c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -26,7 +26,7 @@ if (HALST_STANDALONE) FetchContent_Declare( emil GIT_REPOSITORY https://github.com/embedded-pro/embedded-infra-lib.git - GIT_TAG 501b20e42efc14d6338239ddbf48d6a3ced0d34e # Unreleased + GIT_TAG 00596464de0aa4d4ceb0726f2e117e5a85dabf7f # Unreleased ) add_definitions(-DEMIL_ENABLE_TRACING=1) From dccd1be1e17d35158f2aaa91692e4b39ae26d9f0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 20:28:26 +0000 Subject: [PATCH 02/57] fix(uart): disable the LPUART clock when an LPUART UartStm is destroyed The destructor always called DisableClockUart(uartIndex), so destroying LPUART1 switched off USART1's clock instead (and left LPUART1 clocked). On the Nucleo-WB55/WBA55 USART1 is the ST-LINK virtual COM port. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/UartStm.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/hal_st/stm32fxxx/UartStm.cpp b/hal_st/stm32fxxx/UartStm.cpp index 57055447..44ba9aef 100644 --- a/hal_st/stm32fxxx/UartStm.cpp +++ b/hal_st/stm32fxxx/UartStm.cpp @@ -102,6 +102,15 @@ namespace hal UartStm::~UartStm() { uartArray[uartIndex]->CR1 &= ~(USART_CR1_TE | USART_CR1_RE); + +#if defined(HAS_PERIPHERAL_LPUART) + if (uartArray.begin() == peripheralLpuart.begin()) + { + DisableClockLpuart(uartIndex); + return; + } +#endif + DisableClockUart(uartIndex); } From ebde276a70faa19ab1520e3624457911937d2217 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 03:52:48 +0000 Subject: [PATCH 03/57] feat(validation): add HIL validation firmware for NUCLEO-WB55RG and NUCLEO-WBA55CG Mirror hal-ti's validation app: a firmware that exposes the hal-st drivers through EMIL's services/hil terminal so a host suite and an Analog Discovery 3 can exercise them on real hardware. The command set is in validation/PROTOCOL.md. - target hal_st.validation_firmware, built for stm32wb55 and stm32wba55 with HALST_BUILD_EXAMPLES (on in every preset) - terminal on USART1 (ST-LINK VCP) with UartStmDuplexDma at 921600 8N1 - board profiles with alias tables, reserved pins, DMA request lines and clocks; STM32 pin factory over the generated pinout tables, guarding unbonded pins and shared EXTI lines - factories for GPIO, UART (UartStm, UartStmDma, UartStmDuplexDma, SynchronousUartStm, LPUART), SPI (SpiMasterStm, SpiMasterStmDma, SynchronousSpiMasterStm, GPIO chip select), ADC (one-shot and timer-triggered AdcDmaMultiChannelStm), PWM (PwmStm, SynchronousPwmStm), encoder (timer and LPTIM, plus qei.index) and the WWDG - comparator, CAN, EEPROM and Ethernet commands answer ERR unsupported - devcontainer maps host.docker.internal for the bridge setup, and a stm32wba55cg debug configuration is added Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- .devcontainer/devcontainer.json | 3 +- .vscode/launch.json | 18 + CMakeLists.txt | 3 + validation/CMakeLists.txt | 1 + validation/PROTOCOL.md | 161 ++++++ validation/firmware/AdcFactory.cpp | 341 +++++++++++++ validation/firmware/AdcFactory.hpp | 119 +++++ validation/firmware/BoardInfoStm.cpp | 63 +++ validation/firmware/BoardInfoStm.hpp | 24 + validation/firmware/BoardTypes.hpp | 43 ++ validation/firmware/CMakeLists.txt | 57 +++ validation/firmware/Console.cpp | 18 + validation/firmware/Console.hpp | 37 ++ validation/firmware/Main.cpp | 75 +++ validation/firmware/PeripheralClocks.cpp | 42 ++ validation/firmware/PeripheralClocks.hpp | 9 + validation/firmware/PinFactoryStm.cpp | 172 +++++++ validation/firmware/PinFactoryStm.hpp | 66 +++ validation/firmware/PwmFactory.cpp | 474 ++++++++++++++++++ validation/firmware/PwmFactory.hpp | 105 ++++ validation/firmware/QeiFactory.cpp | 337 +++++++++++++ validation/firmware/QeiFactory.hpp | 91 ++++ validation/firmware/SpiFactory.cpp | 203 ++++++++ validation/firmware/SpiFactory.hpp | 66 +++ validation/firmware/TimerAllocation.cpp | 24 + validation/firmware/TimerAllocation.hpp | 21 + validation/firmware/UartFactory.cpp | 290 +++++++++++ validation/firmware/UartFactory.hpp | 71 +++ validation/firmware/UnsupportedGroups.cpp | 31 ++ validation/firmware/UnsupportedGroups.hpp | 8 + validation/firmware/WatchDogFactory.cpp | 149 ++++++ validation/firmware/WatchDogFactory.hpp | 55 ++ .../boards/stm32wb55/BoardProfile.hpp | 151 ++++++ .../boards/stm32wba55/BoardProfile.hpp | 164 ++++++ 34 files changed, 3491 insertions(+), 1 deletion(-) create mode 100644 validation/CMakeLists.txt create mode 100644 validation/PROTOCOL.md create mode 100644 validation/firmware/AdcFactory.cpp create mode 100644 validation/firmware/AdcFactory.hpp create mode 100644 validation/firmware/BoardInfoStm.cpp create mode 100644 validation/firmware/BoardInfoStm.hpp create mode 100644 validation/firmware/BoardTypes.hpp create mode 100644 validation/firmware/CMakeLists.txt create mode 100644 validation/firmware/Console.cpp create mode 100644 validation/firmware/Console.hpp create mode 100644 validation/firmware/Main.cpp create mode 100644 validation/firmware/PeripheralClocks.cpp create mode 100644 validation/firmware/PeripheralClocks.hpp create mode 100644 validation/firmware/PinFactoryStm.cpp create mode 100644 validation/firmware/PinFactoryStm.hpp create mode 100644 validation/firmware/PwmFactory.cpp create mode 100644 validation/firmware/PwmFactory.hpp create mode 100644 validation/firmware/QeiFactory.cpp create mode 100644 validation/firmware/QeiFactory.hpp create mode 100644 validation/firmware/SpiFactory.cpp create mode 100644 validation/firmware/SpiFactory.hpp create mode 100644 validation/firmware/TimerAllocation.cpp create mode 100644 validation/firmware/TimerAllocation.hpp create mode 100644 validation/firmware/UartFactory.cpp create mode 100644 validation/firmware/UartFactory.hpp create mode 100644 validation/firmware/UnsupportedGroups.cpp create mode 100644 validation/firmware/UnsupportedGroups.hpp create mode 100644 validation/firmware/WatchDogFactory.cpp create mode 100644 validation/firmware/WatchDogFactory.hpp create mode 100644 validation/firmware/boards/stm32wb55/BoardProfile.hpp create mode 100644 validation/firmware/boards/stm32wba55/BoardProfile.hpp diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f5db2f46..9ff68693 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,5 +2,6 @@ "build": { "context": "..", "dockerfile": "Dockerfile" - } + }, + "runArgs": ["--add-host=host.docker.internal:host-gateway"] } diff --git a/.vscode/launch.json b/.vscode/launch.json index 2317c454..6a9657ac 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -84,6 +84,24 @@ "monitor reset" ], "overrideRestartCommands": ["monitor reset"] + }, + { + "name": "stm32wba55cg", + "cwd": "${workspaceFolder}", + "executable": "${command:cmake.launchTargetPath}", + "request": "launch", + "type": "cortex-debug", + "device": "STM32WBA55CG", + "servertype": "external", + "runToEntryPoint": "main", + "showDevDebugOutput": "both", + "gdbTarget": "host.docker.internal:61234", + "overrideLaunchCommands": [ + "monitor reset", + "-target-download", + "monitor reset" + ], + "overrideRestartCommands": ["monitor reset"] } ] } diff --git a/CMakeLists.txt b/CMakeLists.txt index ded68e2c..c5880356 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -84,6 +84,9 @@ add_subdirectory(services) if (HALST_BUILD_EXAMPLES OR HALST_BUILD_EXAMPLES_FREERTOS) add_subdirectory(examples) endif() +if (HALST_BUILD_EXAMPLES) + add_subdirectory(validation) +endif() emil_clangformat_directories(hal_st DIRECTORIES .) diff --git a/validation/CMakeLists.txt b/validation/CMakeLists.txt new file mode 100644 index 00000000..16f1b80c --- /dev/null +++ b/validation/CMakeLists.txt @@ -0,0 +1 @@ +add_subdirectory(firmware) diff --git a/validation/PROTOCOL.md b/validation/PROTOCOL.md new file mode 100644 index 00000000..4abc33c2 --- /dev/null +++ b/validation/PROTOCOL.md @@ -0,0 +1,161 @@ +# Validation terminal protocol + +The validation firmware (`validation/firmware`) exposes the hal-st peripherals of a Nucleo board through EMIL's hardware-in-the-loop terminal (`services::HilTerminal` and the command groups of `services.hil.commands`); hal-st supplies the board profiles, the STM32 pin factory and one factory per peripheral. +The host package (`validation/host`) drives this terminal and a Digilent Analog Discovery 3 to validate the peripherals. + +## Board profiles + +Two boards are supported: NUCLEO-WB55RG (`TARGET_MCU` `stm32wb55`) and NUCLEO-WBA55CG (`TARGET_MCU` `stm32wba55`). +The firmware runs from the default Nucleo clocks of hal-st (`ConfigureDefaultClockNucleoWB55RG`, 64 MHz from the HSI PLL; `ConfigureDefaultClockNucleoWBA55CG`, 100 MHz from the 32 MHz HSE PLL), with every APB prescaler at 1. +The terminal is a `hal::UartStmDuplexDma` on USART1, the ST-LINK virtual COM port, at 921600 8N1 without flow control. + +Aliases name the pins by peripheral function: + +| Alias | NUCLEO-WB55RG | NUCLEO-WBA55CG | Function | +|-----------------------------------------------|-------------------------|-------------------------|--------------------------------------------| +| `terminaltx` `terminalrx` | PB6 PB7 | PB12 PA8 | terminal USART1 TX / RX | +| `ain1` `ain2` `ain3` `ain4` `ain5` `ain6` | PC0 PC1 PC2 PC3 PA0 PA1 | - | ADC1 inputs IN1-IN6 | +| `ain2` `ain3` `ain4` `ain7` `ain8` `ain9` | - | PA7 PA6 PA5 PA2 PA1 PA0 | ADC4 inputs | +| `ain10` | - | PB9 | ADC4 input IN10 | +| `tim1ch1` `tim1ch2` `tim1ch3` `tim1ch4` | PA8 PA9 PA10 PA11 | PA11 PA12 PB4 PB3 | TIM1 channels 1-4 | +| `tim1ch1n` `tim1ch2n` `tim1ch3n` | PA7 PB8 PB9 | PB2 PB1 PB0 | TIM1 complementary channels 1N-3N | +| `tim1bkin` | PB12 | PA2 | TIM1 break input | +| `tim2ch1` `tim2ch2` `tim2ch3` `tim2ch4` | PA15 PB3 PA2 PA3 | - | TIM2 channels 1-4 (32-bit counter) | +| `tim2ch1` `tim2ch3` `tim2ch4` | - | PA5 PA7 PA6 | TIM2 channels 1, 3, 4 (CH2 is the terminal RX pin) | +| `tim3ch1` `tim3ch2` `tim3ch3` `tim3ch4` | - | PA10 PA1 PB14 PB9 | TIM3 channels 1-4 | +| `tim16ch1` `tim17ch1` | PA6 PB9 | PB9 PA1 | TIM16 / TIM17 channel 1 | +| `tim17ch1n` | - | PB3 | TIM17 complementary channel 1N | +| `qei1a` `qei1b` | PA8 PA9 | PA11 PA12 | TIM1 encoder phase A / B | +| `qei1idx` | - | PA15 | index input of the default encoder | +| `qei2a` `qei2b` `qei2idx` | PA15 PB3 PC6 | - | TIM2 encoder phase A / B, index input | +| `qei3a` `qei3b` | - | PA10 PA1 | TIM3 encoder phase A / B | +| `lptim1in1` `lptim1in2` | PC0 PC2 | - | LPTIM1 encoder inputs | +| `spi1clk` `spi1miso` `spi1mosi` `spi1cs` | PA5 PA6 PA7 PA4 | PB4 PB3 PA15 PA12 | SPI1 (Arduino D13/D12/D11/D10) | +| `lpuart1tx` `lpuart1rx` | PA2 PA3 | PB5 PA10 | LPUART1 TX / RX (Arduino D1/D0) | +| `lpuart1rts` `lpuart1cts` | PB12 PA6 | PB9 PB15 | LPUART1 RTS / CTS | +| `usart2tx` `usart2rx` `usart2rts` `usart2cts` | - | PB0 PA11 PB1 PB2 | USART2 | +| `led0` `led1` | PB0 PB1 | PB4 PA9 | user LEDs (green LD2, red LD3 / blue LD1, green LD2) | +| `gpio0` | PC6 | PB14 | general-purpose test pin, watchdog warning toggle | +| `gpio1` `gpio2` | PC10 PC12 | PA5 PA0 | general-purpose test pins | +| `gpio3` `gpio4` | PC13 PE4 | - | general-purpose test pins | +| `sw1` `sw2` `sw3` | PC4 PD0 PD1 | PC13 PB6 PB7 | user buttons (input only, pulled up) | + +- Wherever a pin is expected, an alias from this table may be used instead of `P`; several aliases may name the same pin, and no alias carries a default pull. +- Per-instance default pins apply only when a command gets no pins at all: LPUART1 `lpuart1tx`/`lpuart1rx`; the default encoder (WB55 TIM2 `qei2a`/`qei2b`/`qei2idx`, WBA55 TIM1 `qei1a`/`qei1b`/`qei1idx`). Other UART and encoder instances need their pins; SPI and the ADC always do, PWM needs `channels` or `pins`. +- `board.pins` → `OK =,...` lists the table for the running board. +- The buttons short their pin to ground when pressed; never configure `sw1`-`sw3` as outputs. + +## Framing + +The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix, number, hex and list syntax, open/close semantics) is specified in EMIL's [hardware-in-the-loop terminal documentation](https://github.com/embedded-pro/embedded-infra-lib/blob/main/docs/Hil.md). hal-st adds: + +- After reset the firmware prints `EVT boot board= family= sysclk= reset=` once; `` is `iwdg`, `wwdg`, `sw`, `lpwr`, `obl`, `bor`, `pin` or `unknown` (the RCC reset flags in that priority order; a reset through NRST also sets `pin`, so `pin` is checked last). +- Pins are written as `P`, for example `PA15`, `PB3`, `PH3`: ports A-E and H on STM32WB55 and A-C and H on STM32WBA55, index 0-15; a pin the package does not bond out returns `ERR pin`. +- Instance numbers are the STM32 peripheral numbers: USART 1-2 and LPUART 1 (selected with `lp=1`), SPI 1-3, TIM 1-17 (PWM and encoder) and LPTIM 1 (encoder, `lp=1`), ADC 1 (WB55) or 4 (WBA55), watchdog 0 (the WWDG). A number the running MCU lacks, including 0 where the peripherals start at 1, returns `ERR range`. +- The terminal UART and its pins, the debug LED (WB55 PB5, the blue LD1; WBA55 PB8, the red LD3; blinking while the firmware runs), the SWD pins PA13/PA14, the LSE crystal pins PC14/PC15 and BOOT0 (PH3) are reserved and cannot be opened (`ERR busy`); any other pin, aliased or not, can be reconfigured freely. A pin held by another open instance returns `ERR busy`; a pin the hal-st pinout table does not offer for the requested function and instance returns `ERR pin`. +- A timer serves one group at a time: a timer held by an open PWM, encoder or timer-triggered ADC returns `ERR busy` to the other groups. +- RAM limits how many instances are open at the same time: 1 PWM timer, 1 UART besides the terminal, 1 SPI, 1 ADC, 1 encoder, 1 watchdog and 8 GPIO pins; one more returns `ERR busy`. +- Argument errors (`usage`, `range`, `pin`, `unsupported`) are reported before `ERR busy`. + +## General + +- `ping` → `OK` +- `info` → `OK board= family= sysclk= reset= uid=` (the 96-bit unique device ID) +- `reset` → no final line; the board resets and prints `EVT boot ...` with `reset=sw` +- `delay ` → `OK` after the given time (lets the host synchronise with firmware timing) + +## GPIO (`hal::GpioPinStm`) + +- `gpio.cfg [pull=none|up|down] [drive=low|medium|fast|high]` → `OK`; `out` starts low, `od` starts released and takes no pull; `pull` defaults to `none`, `drive` (the `hal::Speed` of the output stage) to `low` +- `gpio.set <0|1>` → `OK` +- `gpio.get ` → `OK value=<0|1>` +- `gpio.pulse ` → `OK` after `count` toggles of the output, one every `periodMs` (EMIL timer driven); `ERR usage` on an input +- `gpio.irq [type=immediate|dispatched]` → `OK`; each edge increments a counter; `type` defaults to `dispatched` + - an EXTI line serves one port at a time: a pin whose line (its index) already counts edges for a pin of another port returns `ERR unsupported` until that pin's interrupt is turned `off` or the pin is released + - port H returns `ERR unsupported` (`hal::GpioStm` writes the compacted `hal::Port` value into EXTICR, which selects the wrong port for H) +- `gpio.count [clear=0|1]` → `OK count=` +- `gpio.release ` → `OK`; the pin returns to a digital input with its configured pull + +## PWM (`hal::PwmStm`, `sync=1` selects `hal::SynchronousPwmStm`) + +- `pwm.open [channels=[,...]] [pins=[:][,...]] [freq=] [mode=edge|center] [prescaler=] [dead=|off] [inv=0|1] [invn=0|1] [idle=0|1] [idlen=0|1] [brk=] [brkpol=low|high] [brkauto=0|1] [sync=0|1]` → `OK pwmclk=` + - `channels` or `pins` is required (`ERR usage`); 1 to 4 channels, each at most once (`ERR usage`), numbered 1-4 + - a `pins` entry is the channel output, optionally followed by `:` and its complementary output; `-` leaves a position unused (`PA8:PA7` drives CH1 and CH1N, `PA8` or `PA8:-` CH1 only, `-:PA7` CH1N only); with `channels` the entries follow the channel order, without it each channel follows from its pin + - `channels` without `pins` takes the first pin of the pinout table for each channel that is neither reserved nor missing from the package, and no complementary output + - defaults: `freq=10000 mode=edge prescaler=0 dead=off inv=0 invn=0 idle=0 idlen=0 brkpol=high brkauto=0 sync=0` + - `pwmclk` is the counter clock, the timer kernel clock divided by `prescaler + 1`; `prescaler` is 0-65535 + - `mode=center` is the centre-aligned counter (`ERR unsupported` on TIM16/TIM17) + - `dead` is the dead time inserted between a channel and its complementary output, at most 1000000 ns; it saturates at the largest dead time the DTG field encodes + - `inv` and `invn` invert every channel output or complementary output, `idle` and `idlen` set their level while the outputs are disabled + - `brk=` muxes the break input of the timer (`ERR pin` for another pin); `brkpol` is the active level and `brkauto=1` re-enables the outputs automatically after the break input releases + - complementary outputs, `dead`, `idle`, `idlen` and `brk` need a timer with a break function, TIM1, TIM16 or TIM17 (`ERR unsupported` otherwise) + - a channel the timer does not have, or a complementary output it does not have (CH4N; CH2-CH4 on TIM16/TIM17), returns `ERR unsupported` + - each open rebuilds the timer, so no setting of a previous open survives +- `pwm.duty [duty2%] [duty3%] [duty4%]` → `OK`; one duty per opened channel in channel order, or a single duty for all of them, starts the outputs; duty accepts decimals (`12.5`, up to 4 digits), `0` and `100` +- `pwm.freq ` → `OK` +- `pwm.stop ` → `OK` +- A frequency whose period is under 2 counter ticks or does not fit the counter (16 bits, 32 bits on TIM2; half the period in centre-aligned mode) returns `ERR range`, both in `pwm.open` and `pwm.freq` +- `pwm.close ` → `OK` + +## UART (`hal::UartStm`, `dma=1` selects `hal::UartStmDma`, `duplex=1` selects `hal::UartStmDuplexDma`, `sync=1` selects `hal::SynchronousUartStm`) + +- `uart.open [lp=0|1] [tx=] [rx=] [rts=] [cts=] [baud=] [parity=none|even|odd] [flow=none|rts|cts|rtscts] [swap=0|1] [dma=0|1] [duplex=0|1] [sync=0|1]` → `OK` + - `lp=1` selects LPUART`` instead of USART``; USART1 is the terminal (`ERR busy`) + - default 115200 8N1 (8 data bits, plus the parity bit when `parity` is not `none`; one stop bit) + - `baud` is 300-12000000, at most 8000000 on STM32WB55 where the HAL asserts that limit (`ERR range` outside); a rate whose divider does not fit the baud-rate register of the instance at its kernel clock returns `ERR range` (USART: 16 to 65535 with 8× oversampling; LPUART: 0x300 to 0xFFFFF) + - without pins LPUART1 uses `lpuart1tx`/`lpuart1rx`; every other instance needs `tx` and `rx` + - `flow` needs the matching `rts`/`cts` pins; `rts` and `cts` alone are only offered by `sync=1` (`ERR unsupported` otherwise) + - `swap=1` exchanges the TX and RX functions of the two pins (`ERR unsupported` with `sync=1`) + - at most one of `dma`, `duplex` and `sync` (`ERR usage`) + - `sync=1` supports only `parity=none` and not `lp=1` (`ERR unsupported`); `duplex=1` does not support `lp=1` (`ERR unsupported`) +- `uart.send ` → `OK` once the driver reports completion (up to 112 bytes; `ERR timeout` if the driver never completes) +- `uart.recv [timeout=] [len=]` → `OK data=` with everything received since the last `uart.recv`, at most 256 bytes (waits up to `timeout`, default 1000, at most 10000, for `len` bytes when given, and returns what arrived even if fewer) +- `uart.close ` → `OK` + +## SPI master (`hal::SpiMasterStm`, `dma=1` selects `hal::SpiMasterStmDma`, `sync=1` selects `hal::SynchronousSpiMasterStm`) + +- `spi.open clk= mosi= miso= [cs=] [baud=] [mode=0|1|2|3] [dma=0|1] [sync=0|1]` → `OK`; defaults `baud=1000000 mode=0` + - the SPI clock is the fastest `spiclk / 2^n` (n = 1-8) not above `baud`, where `spiclk` is the kernel clock of the instance; `baud` outside `spiclk/256 ... spiclk/2` returns `ERR range` + - `cs` is a GPIO chip select (any free pin, driven by EMIL's `SpiMasterWithChipSelect` or `SynchronousSpiMasterWithChipSelect`): low during a transfer, released after it unless `continue=1` + - `dma=1` with `sync=1` returns `ERR usage` +- `spi.xfer [rx=] [continue=0|1]` → `OK rx=`; with an empty `txHex` (`-`) it receives `rx` bytes; `rx` defaults to the length of `txHex`, the transfer lasts max(tx, `rx`) bytes with `txHex` zero-padded, and the first `rx` received bytes are returned (`rx=0` only transmits); at most 64 bytes +- `spi.close ` → `OK` + +## ADC (`hal::AdcStm` with `hal::AdcDmaMultiChannelStmBase`) + +- `adc.open pins=[,...] [sampling=] [timer=] [rate=]` → `OK` + - `pins` is required (`ERR usage`), one conversion per pin in the given order, at most 8 (`ERR range`); a pin without an ADC channel returns `ERR pin` + - `sampling` is the sampling time of every channel, in ADC clock cycles: WB55 `2.5`, `6.5`, `12.5`, `24.5`, `47.5`, `92.5`, `247.5`, `640.5` (default `2.5`); WBA55 `1.5`, `3.5`, `7.5`, `12.5`, `19.5`, `39.5`, `79.5`, `814.5` (default `3.5`); the value is handed to the driver's per-channel `samplingTime` + - without `timer` each run converts the sequence once from a software trigger (DMA one-shot mode), and the firmware starts the next run from the event loop after the previous one completed + - `timer=` triggers the conversions from the TRGO of timer `t` at `rate` runs per second (default 1000, 1-100000, `ERR range` outside) in DMA circular mode; the timers the driver can trigger from are TIM1 and TIM2 (`ERR unsupported` for others); `rate` without `timer` returns `ERR usage` +- `adc.measure [n=]` → `OK samples=[,...]` (raw 12-bit codes); `n` is the number of sequence runs (default 1), each contributing one value per pin, at most 64 values; returns `ERR timeout` after 1000 ms +- `adc.close ` → `OK` + +## Quadrature encoder (`hal::SynchronousQuadratureEncoderStm`, `lp=1` selects `hal::SynchronousQuadratureEncoderLpTimStm`) + +- `qei.open [lp=0|1] [a=] [b=] [idx=] [res=] [offset=] [inva=0|1] [invb=0|1] [cap=a|b|ab] [filter=<0-15>] [vel=|off]` → `OK` + - defaults `res=4096 offset=0 inva=0 invb=0 cap=ab filter=0 vel=1000`; without pins the default encoder takes its default pins, other instances need `a` and `b` + - `a` and `b` are channels 1 and 2 of the timer; the timer must offer encoder mode (TIM1, TIM2, TIM3; `ERR unsupported` for TIM16/TIM17) + - `res` is the count at which the counter wraps (2 to 65536, up to 4294967295 on TIM2), `offset` the starting count (below `res`, `ERR range` otherwise) + - `cap=ab` counts both edges of both phases, `cap=a` and `cap=b` both edges of one phase + - `vel` is the speed sampling period in µs (1-1000000), `off` leaves speed at 0 + - `idx` is a plain input read by `qei.index`; it never changes the count + - `lp=1` (LPTIM1, WB55 only, `ERR unsupported` elsewhere) takes `a`/`b` on the LPTIM inputs 1/2 (`lptim1in1`/`lptim1in2`), `res` up to 65536, `inva=1` as the mirrored-mounting reversal and `filter` 0, 2, 4 or 8 (consecutive samples); `cap`, `offset` and `invb` return `ERR unsupported` +- `qei.read ` → `OK pos= dir= speed= res=` (`speed` is in counts per second, `res` is the driver's `Resolution()`) +- `qei.index ` → `OK idx=<0|1>`, the level of the index input; `ERR unsupported` when the encoder was opened without `idx` +- `qei.close ` → `OK` + +## Watchdog (`hal::WatchDogStm`, the window watchdog) + +- `wdt.start timeout= [feed=auto|manual] [pin=]` → `OK` + - index 0 is the WWDG; `timeout` 1-30000 + - the firmware picks the smallest WWDG prescaler (1, 2, 4, ..., 128) whose early-warning period, 63 × 4096 × prescaler / PCLK1, is at least `timeout`; a longer `timeout` returns `ERR range` (about 516 ms on WB55 and 330 ms on WBA55) + - with `feed=auto` (default) the firmware refreshes on every early warning; the watchdog resets the board one counter tick after a warning that is not answered + - `pin` is driven low and toggled in the early-warning interrupt, so its period can be measured; it stays claimed until reset + - a started watchdog cannot be stopped: it runs until reset +- `wdt.feed ` → `OK` +- Early warning: `EVT wdt index=0 warning=`. After a watchdog reset the next `EVT boot` reports `reset=wwdg`. + +## Not available on these boards + +hal-st has no comparator, CAN, EEPROM or Ethernet driver for STM32WB55/STM32WBA55, so `comp.open`, `comp.read`, `comp.irq`, `comp.count`, `comp.close`, `can.open`, `can.send`, `can.close`, `eeprom.write`, `eeprom.read`, `eeprom.erase`, `eth.open`, `eth.status` and `eth.close` return `ERR unsupported`. diff --git a/validation/firmware/AdcFactory.cpp b/validation/firmware/AdcFactory.cpp new file mode 100644 index 00000000..bf3f6e5a --- /dev/null +++ b/validation/firmware/AdcFactory.cpp @@ -0,0 +1,341 @@ +#include "validation/firmware/AdcFactory.hpp" +#include "BoardProfile.hpp" +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include "infra/event/EventDispatcher.hpp" +#include "infra/util/Tokenizer.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include +#include +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + using services::HilStatus; + + constexpr std::array openKeys{ { "pins", "sampling", "timer", "rate" } }; + + constexpr uint32_t defaultRate = 1000; + constexpr uint32_t maximumRate = 100000; + constexpr uint32_t maximumPrescale = 1u << 16; + constexpr uint64_t minimumTicks = 2; + + // A Stop from the event loop must not interleave with the DMA interrupt, which disables the ADC on its own + class InterruptsDisabled + { + public: + InterruptsDisabled() + : primask(__get_PRIMASK()) + { + __disable_irq(); + } + + InterruptsDisabled(const InterruptsDisabled& other) = delete; + InterruptsDisabled& operator=(const InterruptsDisabled& other) = delete; + + ~InterruptsDisabled() + { + __set_PRIMASK(primask); + } + + private: + uint32_t primask; + }; + + bool IsTriggerTimer(uint8_t timer) + { + return std::ranges::find(board::adcTriggerTimers, timer) != board::adcTriggerTimers.end(); + } + + uint64_t Distance(uint64_t a, uint64_t b) + { + return a > b ? a - b : b - a; + } + + hal::TimerBaseStm::Timing TriggerTiming(uint8_t timer, uint32_t rate) + { + const uint32_t clock = TimerClock(timer); + const uint64_t maximumTicks = IS_TIM_32B_COUNTER_INSTANCE(hal::peripheralTimer[timer - 1]) ? uint64_t{ 1 } << 32 : uint64_t{ 1 } << 16; + + uint32_t bestPrescale = 1; + uint64_t bestTicks = minimumTicks; + uint64_t bestError = Distance(clock, rate * minimumTicks); + + for (uint32_t prescale = 1; prescale <= maximumPrescale && prescale * minimumTicks * rate <= clock && bestError != 0; ++prescale) + { + const uint32_t floorTicks = clock / (prescale * rate); + + for (const uint64_t ticks : { uint64_t{ floorTicks }, uint64_t{ floorTicks } + 1 }) + { + const uint64_t divider = prescale * ticks; + const uint64_t error = Distance(clock, divider * rate); + + if (ticks <= maximumTicks && error * (bestPrescale * bestTicks) < bestError * divider) + { + bestPrescale = prescale; + bestTicks = ticks; + bestError = error; + } + } + + // Without prescaling every divider up to maximumTicks is available, so the two around the ideal one cannot be beaten + if (prescale == 1 && floorTicks < maximumTicks) + break; + } + + return { bestPrescale - 1, static_cast(bestTicks - 1) }; + } + } + + template + AdcFactoryStm::Sequence::Sequence(infra::MemoryRange samples, infra::MemoryRange inputs, hal::AdcStm& converter, hal::DmaStm::ReceiveStream& receiveStream, ChannelConfigs configs, Mode mode) + : hal::AdcDmaMultiChannelStmBase(samples, inputs, converter, receiveStream, mode) + { + ConfigureChannels(configs); + } + + void AdcFactoryStm::Sequence::Stop() + { + InterruptsDisabled interruptsDisabled; + hal::AdcDmaMultiChannelStmBase::Stop(); + } + + AdcFactoryStm::TriggeredSequence::TriggeredSequence(infra::MemoryRange samples, infra::MemoryRange inputs, hal::AdcStm& converter, hal::DmaStm::ReceiveStream& receiveStream, ChannelConfigs configs, uint8_t oneBasedTimer, hal::TimerBaseStm::Timing timing) + : hal::AdcTimerTriggeredBase(converter, oneBasedTimer, timing) + , Sequence(samples, inputs, converter, receiveStream, configs, hal::AdcDmaMultiChannelStmBase::Unlimited{}) + { + ReconfigureTrigger(); + } + + void AdcFactoryStm::TriggeredSequence::Measure(const infra::Function& onDone) + { + measuring = true; + StartTimer(); + Sequence::Measure(onDone); + } + + void AdcFactoryStm::TriggeredSequence::Stop() + { + Sequence::Stop(); + StopTimer(); + measuring = false; + } + + bool AdcFactoryStm::TriggeredSequence::Measuring() const + { + return measuring; + } + + void AdcFactoryStm::RepeatedConversion::Attach(Sequence& sequence, Samples samples) + { + this->sequence = &sequence; + this->samples = samples; + } + + void AdcFactoryStm::RepeatedConversion::Detach() + { + if (running) + Stop(); + + sequence = nullptr; + } + + void AdcFactoryStm::RepeatedConversion::Measure(const infra::Function& onDone) + { + this->onDone = onDone; + running = true; + Convert(); + } + + void AdcFactoryStm::RepeatedConversion::Stop() + { + running = false; + ++conversion; + sequence->Stop(); + } + + void AdcFactoryStm::RepeatedConversion::Convert() + { + const auto current = ++conversion; + sequence->Measure([this, current](Samples) + { + infra::EventDispatcher::Instance().Schedule([this, current]() + { + Deliver(current); + }); + }); + } + + // Each completion is tagged, so one that was already scheduled when Stop() ran never reports nor re-arms a later measurement + void AdcFactoryStm::RepeatedConversion::Deliver(uint32_t completed) + { + if (!running || completed != conversion) + return; + + onDone(samples); + + if (running && completed == conversion) + Convert(); + } + + AdcFactoryStm::AdcFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma, TimerAllocation& timers) + : naming(naming) + , dma(dma) + , timers(timers) + {} + + std::size_t AdcFactoryStm::KeyPositionals() const + { + return 1; + } + + HilStatus AdcFactoryStm::ParseKey(const services::HilArguments& arguments, uint16_t& key) const + { + uint32_t index = 0; + HilStatus status = HilStatus::done; + arguments.NumberAt(0, index, 0, std::numeric_limits::max(), status); + if (status == HilStatus::done && index != board::adc) + status = HilStatus::range; + + key = static_cast(index); + return status; + } + + infra::MemoryRange AdcFactoryStm::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus AdcFactoryStm::Prepare(uint16_t, const services::HilArguments& arguments) + { + Request request; + return Parse(arguments, request); + } + + HilStatus AdcFactoryStm::Open(std::size_t, uint16_t, const services::HilArguments& arguments, services::HilPinOwner& pins, services::HilAdcHandle& handle) + { + Request request; + HilStatus status = Parse(arguments, request); + + std::array claimed{}; + for (std::size_t i = 0; i != request.count && status == HilStatus::done; ++i) + status = pins.ClaimAnalog(request.pins[i], claimed[i]); + + if (status == HilStatus::done && request.timer) + status = timers.Claim(*request.timer, services::HilOwners::adc); + + if (status != HilStatus::done) + return status; + + timer = request.timer; + Construct(request, claimed, handle); + return HilStatus::done; + } + + void AdcFactoryStm::Close(std::size_t, uint16_t, const infra::Function& onClosed) + { + repeated.Detach(); + + if (auto triggered = std::get_if(&sequence); triggered != nullptr && triggered->Measuring()) + triggered->Stop(); + + sequence.emplace(); + analogPins.clear(); + stream.reset(); + adc.reset(); + + if (timer) + timers.Release(*timer, services::HilOwners::adc); + timer.reset(); + + onClosed(); + } + + HilStatus AdcFactoryStm::Parse(const services::HilArguments& arguments, Request& request) const + { + HilStatus status = HilStatus::done; + request.samplingTime = board::adcDefaultSamplingTime; + request.rate = defaultRate; + + arguments.Select("sampling", request.samplingTime, board::adcSamplingTimes, status); + if (arguments.Has("timer")) + { + uint32_t number = 0; + arguments.Number("timer", number, 0, static_cast(hal::peripheralTimer.size()), status); + request.timer = static_cast(number); + } + arguments.Number("rate", request.rate, 1, maximumRate, status); + if (status != HilStatus::done) + return status; + + if (arguments.Has("rate") && !request.timer) + return HilStatus::usage; + + status = ParsePins(arguments, request); + if (status != HilStatus::done) + return status; + + if (request.timer && !TimerExists(*request.timer)) + return HilStatus::range; + + if (request.timer && !IsTriggerTimer(*request.timer)) + return HilStatus::unsupported; + + return HilStatus::done; + } + + HilStatus AdcFactoryStm::ParsePins(const services::HilArguments& arguments, Request& request) const + { + const auto list = arguments.Key("pins"); + if (!list) + return HilStatus::usage; + + const auto entries = static_cast(std::ranges::count(*list, ',')) + 1; + if (entries > maximumPins) + return HilStatus::range; + + const infra::Tokenizer tokens(*list, ','); + if (tokens.Size() != entries) + return HilStatus::pin; + + for (std::size_t i = 0; i != entries; ++i) + { + const auto pin = services::HilArguments::ParsePin(tokens.Token(i), naming); + if (!pin || !SupportsAnalog(*pin)) + return HilStatus::pin; + + request.pins[i] = *pin; + } + + request.count = entries; + return HilStatus::done; + } + + void AdcFactoryStm::Construct(const Request& request, const std::array& claimed, services::HilAdcHandle& handle) + { + auto& converter = adc.emplace(board::adc); + auto& receiveStream = stream.emplace(dma, hal::DmaChannelId(1, board::adcDmaChannel, board::adcDmaRequest)); + + for (std::size_t i = 0; i != request.count; ++i) + { + analogPins.emplace_back(PinOrDummy(claimed[i])); + configs[i] = hal::detail::AdcStmChannelConfig{ request.samplingTime, false }; + } + + const auto samples = infra::MakeRange(buffer.data(), buffer.data() + request.count); + const ChannelConfigs channels = infra::MakeRange(configs.data(), configs.data() + request.count); + handle.samplesPerRun = request.count; + + if (request.timer) + { + handle.adc = &sequence.emplace(samples, infra::MakeRange(analogPins), converter, receiveStream, channels, *request.timer, TriggerTiming(*request.timer, request.rate)); + return; + } + + repeated.Attach(sequence.emplace(samples, infra::MakeRange(analogPins), converter, receiveStream, channels, hal::AdcDmaMultiChannelStmBase::OneShot{}), samples); + handle.adc = &repeated; + } +} diff --git a/validation/firmware/AdcFactory.hpp b/validation/firmware/AdcFactory.hpp new file mode 100644 index 00000000..a4342d93 --- /dev/null +++ b/validation/firmware/AdcFactory.hpp @@ -0,0 +1,119 @@ +#pragma once + +#include "hal/interfaces/AdcMultiChannel.hpp" +#include "hal_st/stm32fxxx/AdcDmaMultiChannelStm.hpp" +#include "hal_st/stm32fxxx/AdcTimerTriggeredBase.hpp" +#include "hal_st/stm32fxxx/AnalogToDigitalPinStm.hpp" +#include "hal_st/stm32fxxx/DmaStm.hpp" +#include "hal_st/stm32fxxx/GpioStm.hpp" +#include "hal_st/stm32fxxx/TimerStm.hpp" +#include "infra/util/BoundedVector.hpp" +#include "infra/util/Function.hpp" +#include "infra/util/MemoryRange.hpp" +#include "services/hil/commands/HilAdcCommands.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/TimerAllocation.hpp" +#include +#include +#include +#include +#include +#include + +namespace validation +{ + class AdcFactoryStm + : public services::HilAdcFactory + { + public: + static constexpr std::size_t slots = 1; + + AdcFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma, TimerAllocation& timers); + + std::size_t KeyPositionals() const override; + services::HilStatus ParseKey(const services::HilArguments& arguments, uint16_t& key) const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint16_t key, const services::HilArguments& arguments) override; + services::HilStatus Open(std::size_t slot, uint16_t key, const services::HilArguments& arguments, services::HilPinOwner& pins, services::HilAdcHandle& handle) override; + void Close(std::size_t slot, uint16_t key, const infra::Function& onClosed) override; + + private: + static constexpr std::size_t maximumPins = 8; + + using ChannelConfigs = infra::MemoryRange; + + struct Request + { + std::array pins{}; + std::size_t count = 0; + uint32_t samplingTime = 0; + std::optional timer; + uint32_t rate = 0; + }; + + class Sequence + : public hal::AdcDmaMultiChannelStmBase + { + public: + template + Sequence(infra::MemoryRange samples, infra::MemoryRange inputs, hal::AdcStm& converter, hal::DmaStm::ReceiveStream& receiveStream, ChannelConfigs configs, Mode mode); + + void Stop() override; + }; + + class TriggeredSequence + : private hal::AdcTimerTriggeredBase + , public Sequence + { + public: + TriggeredSequence(infra::MemoryRange samples, infra::MemoryRange inputs, hal::AdcStm& converter, hal::DmaStm::ReceiveStream& receiveStream, ChannelConfigs configs, uint8_t oneBasedTimer, hal::TimerBaseStm::Timing timing); + + void Measure(const infra::Function& onDone) override; + void Stop() override; + + bool Measuring() const; + + private: + std::atomic measuring{ false }; + }; + + class RepeatedConversion + : public hal::AdcMultiChannel + { + public: + void Attach(Sequence& sequence, Samples samples); + void Detach(); + + void Measure(const infra::Function& onDone) override; + void Stop() override; + + private: + void Convert(); + void Deliver(uint32_t completed); + + private: + Sequence* sequence = nullptr; + Samples samples; + infra::Function onDone; + bool running = false; + uint32_t conversion = 0; + }; + + services::HilStatus Parse(const services::HilArguments& arguments, Request& request) const; + services::HilStatus ParsePins(const services::HilArguments& arguments, Request& request) const; + void Construct(const Request& request, const std::array& claimed, services::HilAdcHandle& handle); + + private: + const services::HilPinNaming& naming; + hal::DmaStm& dma; + TimerAllocation& timers; + std::optional adc; + std::optional stream; + infra::BoundedVector::WithMaxSize analogPins; + std::array buffer{}; + std::array configs{}; + std::variant sequence; + RepeatedConversion repeated; + std::optional timer; + }; +} diff --git a/validation/firmware/BoardInfoStm.cpp b/validation/firmware/BoardInfoStm.cpp new file mode 100644 index 00000000..92da1cb1 --- /dev/null +++ b/validation/firmware/BoardInfoStm.cpp @@ -0,0 +1,63 @@ +#include "validation/firmware/BoardInfoStm.hpp" +#include "BoardProfile.hpp" +#include "hal_st/stm32fxxx/UniqueDeviceId.hpp" +#include "services/hil/HilArguments.hpp" +#include +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + // A reset through NRST also sets PINRSTF, so the pin is only the cause when nothing else is flagged + constexpr std::array, 7> resetCauses{ { + { "iwdg", RCC_RESET_FLAG_IWDG }, + { "wwdg", RCC_RESET_FLAG_WWDG }, + { "sw", RCC_RESET_FLAG_SW }, + { "lpwr", RCC_RESET_FLAG_LPWR }, + { "obl", RCC_RESET_FLAG_OBL }, + { "bor", RCC_RESET_FLAG_PWR }, + { "pin", RCC_RESET_FLAG_PIN }, + } }; + } + + const char* ReadAndClearResetCause() + { + const uint32_t cause = HAL_RCC_GetResetSource(); + + for (const auto& entry : resetCauses) + if ((cause & entry.value) != 0) + return entry.name; + + return "unknown"; + } + + BoardInfoStm::BoardInfoStm(const char* resetCause) + : resetCause(resetCause) + {} + + const char* BoardInfoStm::Name() const + { + return board::name; + } + + const char* BoardInfoStm::Family() const + { + return board::family; + } + + uint32_t BoardInfoStm::SystemClock() const + { + return SystemCoreClock; + } + + const char* BoardInfoStm::ResetCause() const + { + return resetCause; + } + + infra::ConstByteRange BoardInfoStm::UniqueId() const + { + return hal::UniqueDeviceId(); + } +} diff --git a/validation/firmware/BoardInfoStm.hpp b/validation/firmware/BoardInfoStm.hpp new file mode 100644 index 00000000..7b977d4e --- /dev/null +++ b/validation/firmware/BoardInfoStm.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "services/hil/HilBoardInfo.hpp" + +namespace validation +{ + const char* ReadAndClearResetCause(); + + class BoardInfoStm + : public services::HilBoardInfo + { + public: + explicit BoardInfoStm(const char* resetCause); + + const char* Name() const override; + const char* Family() const override; + uint32_t SystemClock() const override; + const char* ResetCause() const override; + infra::ConstByteRange UniqueId() const override; + + private: + const char* resetCause; + }; +} diff --git a/validation/firmware/BoardTypes.hpp b/validation/firmware/BoardTypes.hpp new file mode 100644 index 00000000..1ca5f240 --- /dev/null +++ b/validation/firmware/BoardTypes.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include "hal_st/stm32fxxx/GpioStm.hpp" +#include "services/hil/HilPinId.hpp" +#include + +namespace validation +{ + using services::HilPinAlias; + using services::HilPinId; + + constexpr HilPinId Pin(hal::Port port, uint8_t index) + { + return HilPinId{ static_cast(port), index }; + } + + constexpr hal::Port PortOf(HilPinId pin) + { + return static_cast(pin.port); + } + + struct UartPins + { + uint8_t index; + bool lpuart; + HilPinId tx; + HilPinId rx; + }; + + struct QeiPins + { + uint8_t timer; + HilPinId a; + HilPinId b; + HilPinId idx; + }; + + struct DmaRequests + { + uint8_t transmit; + uint8_t receive; + }; +} diff --git a/validation/firmware/CMakeLists.txt b/validation/firmware/CMakeLists.txt new file mode 100644 index 00000000..a87a4d12 --- /dev/null +++ b/validation/firmware/CMakeLists.txt @@ -0,0 +1,57 @@ +add_executable(hal_st.validation_firmware) +emil_build_for(hal_st.validation_firmware + TARGET_MCU stm32wb55 + TARGET_MCU stm32wba55 + PREREQUISITE_BOOL HALST_BUILD_EXAMPLES +) + +target_include_directories(hal_st.validation_firmware PRIVATE + boards/${TARGET_MCU} +) + +target_link_libraries(hal_st.validation_firmware PRIVATE + services.hil + services.hil.commands + services.peripheral + services.synchronous_peripheral + services.tracer + hal_st.instantiations + hal_st.stm32fxxx + hal_st.synchronous_stm32fxxx +) + +target_sources(hal_st.validation_firmware PRIVATE + AdcFactory.cpp + AdcFactory.hpp + BoardInfoStm.cpp + BoardInfoStm.hpp + BoardTypes.hpp + Console.cpp + Console.hpp + Main.cpp + PeripheralClocks.cpp + PeripheralClocks.hpp + PinFactoryStm.cpp + PinFactoryStm.hpp + PwmFactory.cpp + PwmFactory.hpp + QeiFactory.cpp + QeiFactory.hpp + SpiFactory.cpp + SpiFactory.hpp + TimerAllocation.cpp + TimerAllocation.hpp + UartFactory.cpp + UartFactory.hpp + UnsupportedGroups.cpp + UnsupportedGroups.hpp + WatchDogFactory.cpp + WatchDogFactory.hpp + $<$:boards/stm32wb55/BoardProfile.hpp> + $<$:boards/stm32wba55/BoardProfile.hpp> +) + +halst_target_default_linker_scripts(hal_st.validation_firmware) +halst_target_bringup(hal_st.validation_firmware) + +emil_generate_artifacts(TARGET hal_st.validation_firmware LST MAP BIN HEX) diff --git a/validation/firmware/Console.cpp b/validation/firmware/Console.cpp new file mode 100644 index 00000000..09fb638e --- /dev/null +++ b/validation/firmware/Console.cpp @@ -0,0 +1,18 @@ +#include "validation/firmware/Console.hpp" +#include "BoardProfile.hpp" + +namespace validation +{ + namespace + { + const hal::UartStmDuplexDma::Config terminalConfig{ board::terminalBaudRate, USART_PARITY_NONE, hal::cortex::InterruptPriority::low }; + } + + Console::Console() + : tx(PortOf(board::terminal.tx), board::terminal.tx.index) + , rx(PortOf(board::terminal.rx), board::terminal.rx.index) + , transmitStream(dma, hal::DmaChannelId{ 1, 1, board::terminalDma.transmit }) + , receiveStream(dma, hal::DmaChannelId{ 1, 2, board::terminalDma.receive }) + , uart(transmitStream, receiveStream, board::terminal.index, tx, rx, terminalConfig) + {} +} diff --git a/validation/firmware/Console.hpp b/validation/firmware/Console.hpp new file mode 100644 index 00000000..80eb6253 --- /dev/null +++ b/validation/firmware/Console.hpp @@ -0,0 +1,37 @@ +#pragma once + +#include "hal_st/stm32fxxx/DmaStm.hpp" +#include "hal_st/stm32fxxx/GpioStm.hpp" +#include "hal_st/stm32fxxx/UartStmDuplexDma.hpp" +#include "infra/stream/OutputStream.hpp" +#include "services/hil/HilResponse.hpp" +#include "services/hil/HilTerminal.hpp" +#include "services/tracer/StreamWriterOnSerialCommunication.hpp" +#include "services/tracer/Tracer.hpp" +#include + +namespace validation +{ + struct Console + { + static constexpr std::size_t receiveBufferSize = 128; + static constexpr std::size_t transmitBufferSize = 1024; + static constexpr std::size_t queueSize = 256; + + static_assert(queueSize >= receiveBufferSize / 2, "terminal queue must absorb a whole DMA half-buffer"); + + Console(); + + hal::DmaStm dma; + hal::GpioPinStm tx; + hal::GpioPinStm rx; + hal::DmaStm::TransmitStream transmitStream; + hal::DmaStm::ReceiveStream receiveStream; + hal::UartStmDuplexDma::WithRxBuffer uart; + services::StreamWriterOnSerialCommunication::WithStorage writer{ uart }; + infra::TextOutputStream::WithErrorPolicy stream{ writer }; + services::TracerToStream tracer{ stream }; + services::HilResponse response{ tracer }; + services::HilTerminal::WithMaxQueueAndMaxHistory terminal{ uart, tracer, response }; + }; +} diff --git a/validation/firmware/Main.cpp b/validation/firmware/Main.cpp new file mode 100644 index 00000000..da467065 --- /dev/null +++ b/validation/firmware/Main.cpp @@ -0,0 +1,75 @@ +#include "BoardProfile.hpp" +#include "hal/cortex_m/Reset.hpp" +#include "hal_st/instantiations/StmEventInfrastructure.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/HilPinNaming.hpp" +#include "services/hil/HilPinPool.hpp" +#include "services/hil/HilSystemCommands.hpp" +#include "services/hil/commands/HilAdcCommands.hpp" +#include "services/hil/commands/HilGpioCommands.hpp" +#include "services/hil/commands/HilPwmCommands.hpp" +#include "services/hil/commands/HilQeiCommands.hpp" +#include "services/hil/commands/HilSpiCommands.hpp" +#include "services/hil/commands/HilUartCommands.hpp" +#include "services/hil/commands/HilWatchDogCommands.hpp" +#include "services/peripheral/DebugLed.hpp" +#include "validation/firmware/AdcFactory.hpp" +#include "validation/firmware/BoardInfoStm.hpp" +#include "validation/firmware/Console.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include "validation/firmware/PwmFactory.hpp" +#include "validation/firmware/QeiFactory.hpp" +#include "validation/firmware/SpiFactory.hpp" +#include "validation/firmware/TimerAllocation.hpp" +#include "validation/firmware/UartFactory.hpp" +#include "validation/firmware/UnsupportedGroups.hpp" +#include "validation/firmware/WatchDogFactory.hpp" +#include + +unsigned int hse_value = 32'000'000; + +int main() +{ + static validation::BoardInfoStm boardInfo{ validation::ReadAndClearResetCause() }; + HAL_Init(); + validation::board::InitializeClocks(); + + static main_::StmEventInfrastructure eventInfrastructure; + static validation::Console console; + static hal::GpioPinStm debugLedPin{ validation::PortOf(validation::board::debugLed), validation::board::debugLed.index }; + static services::DebugLed debugLed{ debugLedPin, std::chrono::milliseconds(100), std::chrono::milliseconds(1400) }; + static validation::PinFactoryStm pinFactory; + static services::HilPinPool::WithCapacity pins{ pinFactory, infra::MakeRange(validation::board::reservedPins) }; + static services::HilPinNamingDefault naming{ validation::board::portLetters, validation::board::maximumPinIndex, infra::MakeRange(validation::board::aliases) }; + static services::HilContext context{ console.response, pins, naming, console.terminal }; + static validation::TimerAllocation timers; + + static hal::cortex::Reset reset; + static services::HilSystemCommands system{ context, boardInfo, reset }; + static services::HilGpioCommands::WithMaxPins<8> gpio{ context }; + + static validation::PwmFactoryStm pwmFactory{ naming, timers }; + static services::HilPwmCommands pwm{ context, pwmFactory }; + + static validation::UartFactoryStm uartFactory{ naming, console.dma }; + static services::HilUartCommands::WithCapacity<256, 112> uart{ context, uartFactory }; + + static validation::SpiFactoryStm spiFactory{ naming, console.dma }; + static services::HilSpiCommands::WithCapacity<64> spi{ context, spiFactory }; + + static validation::AdcFactoryStm adcFactory{ naming, console.dma, timers }; + static services::HilAdcCommands::WithCapacity adc{ context, adcFactory }; + + static validation::QeiFactoryStm qeiFactory{ naming, timers }; + static services::HilQeiCommands qei{ context, qeiFactory }; + static validation::QeiExtensionCommands qeiExtension{ context, qeiFactory }; + + static validation::WatchDogFactoryStm watchDogFactory{ naming, pins }; + static services::HilWatchDogCommands watchDog{ context, watchDogFactory }; + + validation::CreateUnsupportedGroups(context); + + system.PrintBoot(); + eventInfrastructure.Run(); + __builtin_unreachable(); +} diff --git a/validation/firmware/PeripheralClocks.cpp b/validation/firmware/PeripheralClocks.cpp new file mode 100644 index 00000000..057abce5 --- /dev/null +++ b/validation/firmware/PeripheralClocks.cpp @@ -0,0 +1,42 @@ +#include "validation/firmware/PeripheralClocks.hpp" +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + bool IsOnApb2(const TIM_TypeDef* instance) + { +#if defined(TIM1) + if (instance == TIM1) + return true; +#endif +#if defined(TIM16) + if (instance == TIM16) + return true; +#endif +#if defined(TIM17) + if (instance == TIM17) + return true; +#endif + return false; + } + } + + bool TimerExists(uint8_t timer) + { + return timer >= 1 && timer <= hal::peripheralTimer.size() && hal::peripheralTimer[timer - 1] != nullptr; + } + + uint32_t TimerClock(uint8_t timer) + { + const auto hclk = HAL_RCC_GetHCLKFreq(); + const auto peripheralClock = IsOnApb2(hal::peripheralTimer[timer - 1]) ? HAL_RCC_GetPCLK2Freq() : HAL_RCC_GetPCLK1Freq(); + + if (peripheralClock == 0) + return hclk; + + return hclk / peripheralClock == 1 ? peripheralClock : peripheralClock * 2; + } +} diff --git a/validation/firmware/PeripheralClocks.hpp b/validation/firmware/PeripheralClocks.hpp new file mode 100644 index 00000000..3c94adb9 --- /dev/null +++ b/validation/firmware/PeripheralClocks.hpp @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace validation +{ + bool TimerExists(uint8_t timer); + uint32_t TimerClock(uint8_t timer); +} diff --git a/validation/firmware/PinFactoryStm.cpp b/validation/firmware/PinFactoryStm.cpp new file mode 100644 index 00000000..7499a728 --- /dev/null +++ b/validation/firmware/PinFactoryStm.cpp @@ -0,0 +1,172 @@ +#include "validation/firmware/PinFactoryStm.hpp" +#include "BoardProfile.hpp" +#include "generated/stm32fxxx/PinoutTableDefault.hpp" +#include "services/hil/HilArguments.hpp" +#include + +namespace validation +{ + namespace + { + constexpr std::array, 4> speeds{ { + { "low", hal::Speed::Low }, + { "medium", hal::Speed::Medium }, + { "fast", hal::Speed::Fast }, + { "high", hal::Speed::High }, + } }; + + hal::WeakPull ToWeakPull(services::HilPull pull) + { + switch (pull) + { + case services::HilPull::up: + return hal::WeakPull::Up; + case services::HilPull::down: + return hal::WeakPull::Down; + default: + return hal::WeakPull::None; + } + } + } + + ManagedPin::ManagedPin(HilPinId id, hal::Drive drive, hal::Speed speed, hal::WeakPull weakPull, ExtiOwners& extiOwners) + : hal::GpioPinStm(PortOf(id), id.index, drive, speed, weakPull) + , id(id) + , extiOwners(extiOwners) + {} + + void ManagedPin::ConfigAnalog() + { + if (analogUsers++ == 0) + hal::GpioPinStm::ConfigAnalog(); + } + + void ManagedPin::ResetConfig() + { + if (analogUsers > 1) + { + --analogUsers; + return; + } + + analogUsers = 0; + hal::GpioPinStm::ResetConfig(); + } + + void ManagedPin::EnableInterrupt(const infra::Function& action, hal::InterruptTrigger trigger, hal::InterruptType type) + { + extiOwners[id.index] = id; + hal::GpioPinStm::EnableInterrupt(action, trigger, type); + } + + void ManagedPin::DisableInterrupt() + { + if (extiOwners[id.index] != id) + return; + + hal::GpioPinStm::DisableInterrupt(); + extiOwners[id.index] = std::nullopt; + } + + bool IsBonded(HilPinId id) + { + return id.port < board::bondedPins.size() && id.index <= board::maximumPinIndex && (board::bondedPins[id.port] & (1u << id.index)) != 0; + } + + bool IsReserved(HilPinId id) + { + return std::ranges::find(board::reservedPins, id) != board::reservedPins.end(); + } + + bool SupportsFunction(HilPinId id, hal::PinConfigTypeStm function, uint8_t peripheralIndex) + { + if (!IsBonded(id)) + return false; + + for (const auto& subTable : hal::pinoutTableDefaultStm) + for (const auto& table : subTable) + if (table.pinConfigType == function) + for (const auto& position : table.pinPositions) + if (position.peripheralIndex == peripheralIndex && position.port == PortOf(id) && position.pin == id.index) + return true; + + return false; + } + + std::optional FindFunctionPin(hal::PinConfigTypeStm function, uint8_t peripheralIndex) + { + for (const auto& subTable : hal::pinoutTableDefaultStm) + for (const auto& table : subTable) + if (table.pinConfigType == function) + for (const auto& position : table.pinPositions) + { + const auto id = Pin(position.port, position.pin); + if (position.peripheralIndex == peripheralIndex && IsBonded(id) && !IsReserved(id)) + return id; + } + + return std::nullopt; + } + + bool SupportsAnalog(HilPinId id) + { + if (!IsBonded(id)) + return false; + + for (const auto& position : hal::analogTableDefaultStm) + if (position.type == hal::Type::adc && position.instance == board::adc && position.port == PortOf(id) && position.pin == id.index) + return true; + + return false; + } + + hal::GpioPinStm& PinOrDummy(hal::GpioPin* pin) + { + if (pin != nullptr) + return static_cast(*pin); + + return hal::dummyPinStm; + } + + bool PinFactoryStm::IsValid(HilPinId pin) const + { + return IsBonded(pin); + } + + bool PinFactoryStm::SupportsFunction(HilPinId pin, uint16_t function, uint8_t instance) const + { + return validation::SupportsFunction(pin, static_cast(function), instance); + } + + bool PinFactoryStm::SupportsAnalog(HilPinId pin) const + { + return validation::SupportsAnalog(pin); + } + + bool PinFactoryStm::SupportsInterrupt(HilPinId pin) const + { + if (!IsBonded(pin) || PortOf(pin) == hal::Port::H) + return false; + + const auto& owner = extiOwners[pin.index]; + return !owner || *owner == pin; + } + + std::optional PinFactoryStm::ParseDrive(infra::BoundedConstString text) const + { + if (auto speed = services::HilArguments::ParseChoice(text, speeds)) + return infra::enum_cast(*speed); + + return std::nullopt; + } + + hal::GpioPin& PinFactoryStm::Construct(std::size_t slot, HilPinId pin, const services::HilPinOptions& options) + { + return pins[slot].emplace(pin, options.openDrain ? hal::Drive::OpenDrain : hal::Drive::PushPull, static_cast(options.drive), ToWeakPull(options.pull), extiOwners); + } + + void PinFactoryStm::Destroy(std::size_t slot) + { + pins[slot].reset(); + } +} diff --git a/validation/firmware/PinFactoryStm.hpp b/validation/firmware/PinFactoryStm.hpp new file mode 100644 index 00000000..9364841a --- /dev/null +++ b/validation/firmware/PinFactoryStm.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include "hal_st/stm32fxxx/GpioStm.hpp" +#include "infra/util/EnumCast.hpp" +#include "services/hil/HilPinPool.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include +#include +#include +#include + +namespace validation +{ + // One handler per EXTI line serves every port, so a line is owned by at most one pin at a time + using ExtiOwners = std::array, 16>; + + // Several ADC users may share an analog pin, but the driver reserves a pin once per analog user + class ManagedPin + : public hal::GpioPinStm + { + public: + ManagedPin(HilPinId id, hal::Drive drive, hal::Speed speed, hal::WeakPull weakPull, ExtiOwners& extiOwners); + + void ConfigAnalog() override; + void ResetConfig() override; + void EnableInterrupt(const infra::Function& action, hal::InterruptTrigger trigger, hal::InterruptType type = hal::InterruptType::dispatched) override; + void DisableInterrupt() override; + + private: + HilPinId id; + ExtiOwners& extiOwners; + uint8_t analogUsers = 0; + }; + + constexpr uint16_t Function(hal::PinConfigTypeStm function) + { + return static_cast(infra::enum_cast(function)); + } + + bool IsBonded(HilPinId id); + bool IsReserved(HilPinId id); + bool SupportsFunction(HilPinId id, hal::PinConfigTypeStm function, uint8_t peripheralIndex); + std::optional FindFunctionPin(hal::PinConfigTypeStm function, uint8_t peripheralIndex); + bool SupportsAnalog(HilPinId id); + hal::GpioPinStm& PinOrDummy(hal::GpioPin* pin); + + class PinFactoryStm + : public services::HilPinFactory + { + public: + static constexpr std::size_t capacity = 32; + + bool IsValid(HilPinId pin) const override; + bool SupportsFunction(HilPinId pin, uint16_t function, uint8_t instance) const override; + bool SupportsAnalog(HilPinId pin) const override; + bool SupportsInterrupt(HilPinId pin) const override; + std::optional ParseDrive(infra::BoundedConstString text) const override; + + hal::GpioPin& Construct(std::size_t slot, HilPinId pin, const services::HilPinOptions& options) override; + void Destroy(std::size_t slot) override; + + private: + ExtiOwners extiOwners; + std::array, capacity> pins; + }; +} diff --git a/validation/firmware/PwmFactory.cpp b/validation/firmware/PwmFactory.cpp new file mode 100644 index 00000000..5d7274be --- /dev/null +++ b/validation/firmware/PwmFactory.cpp @@ -0,0 +1,474 @@ +#include "validation/firmware/PwmFactory.hpp" +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include "infra/util/ReallyAssert.hpp" +#include "infra/util/Tokenizer.hpp" +#include "services/hil/HilCommand.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include +#include +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + using services::HilChoice; + using services::HilStatus; + + constexpr uint32_t maximumPrescaler = 0xffff; + constexpr uint32_t maximumDeadTimeNs = 1000000; + + constexpr std::array openKeys{ { "channels", "pins", "freq", "mode", "prescaler", "dead", "inv", "invn", "idle", "idlen", "brk", "brkpol", "brkauto", "sync" } }; + + constexpr std::array, 2> alignments{ { + { "edge", false }, + { "center", true }, + } }; + + constexpr std::array, 2> breakPolarities{ { + { "low", false }, + { "high", true }, + } }; + + constexpr std::array channelFunctions{ { + hal::PinConfigTypeStm::timerChannel1, + hal::PinConfigTypeStm::timerChannel2, + hal::PinConfigTypeStm::timerChannel3, + hal::PinConfigTypeStm::timerChannel4, + } }; + + // hal-st has no function code beyond CH3N, so the driver cannot mux CH4N even where the timer has one + constexpr std::array complementaryFunctions{ { + hal::PinConfigTypeStm::timerChannel1N, + hal::PinConfigTypeStm::timerChannel2N, + hal::PinConfigTypeStm::timerChannel3N, + } }; + + constexpr std::array timerChannels{ { + TIM_CHANNEL_1, + TIM_CHANNEL_2, + TIM_CHANNEL_3, + TIM_CHANNEL_4, + } }; + + TIM_TypeDef* Instance(uint8_t timer) + { + return hal::peripheralTimer[timer - 1]; + } + + bool HasChannel(uint8_t timer, uint8_t channel) + { + return IS_TIM_CCX_INSTANCE(Instance(timer), timerChannels[channel - 1]); + } + + bool HasComplementaryChannel(uint8_t timer, uint8_t channel) + { + return channel <= complementaryFunctions.size() && IS_TIM_CCXN_INSTANCE(Instance(timer), timerChannels[channel - 1]); + } + + uint32_t CounterClock(uint8_t timer, uint32_t prescaler) + { + return TimerClock(timer) / (prescaler + 1); + } + + bool ValidFrequency(uint8_t timer, uint32_t counterClock, bool centerAligned, uint32_t hertz) + { + auto ticksPerPeriod = counterClock / hertz; + if (centerAligned) + ticksPerPeriod /= 2; + + const uint32_t maximumCompare = IS_TIM_32B_COUNTER_INSTANCE(Instance(timer)) ? 0xffffffffu : 0xffffu; + return ticksPerPeriod >= 2 && ticksPerPeriod - 1 <= maximumCompare; + } + + std::optional ChannelOfPin(HilPinId pin, infra::MemoryRange functions, uint8_t timer) + { + for (std::size_t i = 0; i != functions.size(); ++i) + if (SupportsFunction(pin, functions[i], timer)) + return static_cast(i + 1); + + return std::nullopt; + } + + std::optional> ParseOptionalPin(infra::BoundedConstString text, const services::HilPinNaming& naming) + { + if (text == "-") + return std::optional(); + + if (auto pin = services::HilArguments::ParsePin(text, naming)) + return std::optional(*pin); + + return std::nullopt; + } + } + + PwmFactoryStm::Handle::Handle(Driver& driver, std::size_t channels) + : driver(driver) + , channels(channels) + {} + + std::size_t PwmFactoryStm::Handle::Channels() const + { + return channels; + } + + void PwmFactoryStm::Handle::Start(infra::MemoryRange dutyCycles) + { + really_assert(dutyCycles.size() == 1 || dutyCycles.size() == channels); + + std::array each; + for (std::size_t i = 0; i != channels; ++i) + each[i] = dutyCycles[dutyCycles.size() == 1 ? 0 : i]; + + services::HilWithDriver(driver, [this, &each](auto& pwm) + { + switch (channels) + { + case 1: + pwm.Start(each[0]); + break; + case 2: + pwm.Start(each[0], each[1]); + break; + case 3: + pwm.Start(each[0], each[1], each[2]); + break; + default: + pwm.Start(each[0], each[1], each[2], each[3]); + break; + } + }); + } + + void PwmFactoryStm::Handle::SetBaseFrequency(hal::Hertz baseFrequency) + { + services::HilWithDriver(driver, [baseFrequency](auto& pwm) + { + pwm.SetBaseFrequency(baseFrequency); + }); + } + + void PwmFactoryStm::Handle::Stop() + { + services::HilWithDriver(driver, [](auto& pwm) + { + pwm.Stop(); + }); + } + + PwmFactoryStm::PwmFactoryStm(const services::HilPinNaming& naming, TimerAllocation& timers) + : naming(naming) + , timers(timers) + {} + + uint8_t PwmFactoryStm::Instances() const + { + return 18; + } + + infra::MemoryRange PwmFactoryStm::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus PwmFactoryStm::Prepare(uint8_t index, const services::HilArguments& arguments) + { + Request request; + return Evaluate(index, arguments, request); + } + + HilStatus PwmFactoryStm::Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins, services::HilPwmHandle*& opened) + { + Request request; + HilStatus status = Evaluate(index, arguments, request); + if (status != HilStatus::done) + return status; + + status = timers.Claim(index, services::HilOwners::pwm); + if (status != HilStatus::done) + return status; + + ClaimedPins claimed; + status = Claim(index, request, pins, claimed); + if (status != HilStatus::done) + { + timers.Release(index, services::HilOwners::pwm); + return status; + } + + timing = Timing{ CounterClock(index, request.prescaler), request.centerAligned }; + opened = &Construct(index, request, claimed); + return HilStatus::done; + } + + void PwmFactoryStm::ReportOpened(uint8_t, services::HilResponse::Line& line) + { + line << " pwmclk=" << timing->counterClock; + } + + HilStatus PwmFactoryStm::ChangeFrequency(uint8_t index, uint32_t hertz) + { + if (!ValidFrequency(index, timing->counterClock, timing->centerAligned, hertz)) + return HilStatus::range; + + return HilStatus::done; + } + + void PwmFactoryStm::Close(uint8_t index, const infra::Function& onClosed) + { + handle.reset(); + driver.emplace(); + timing.reset(); + timers.Release(index, services::HilOwners::pwm); + onClosed(); + } + + HilStatus PwmFactoryStm::Evaluate(uint8_t timer, const services::HilArguments& arguments, Request& request) const + { + if (!TimerExists(timer)) + return HilStatus::range; + + HilStatus status = Parse(arguments, request); + if (status != HilStatus::done) + return status; + + const bool complementary = std::ranges::any_of(request.outputs, [](const Output& output) + { + return output.complementaryPin.has_value(); + }); + + if (request.centerAligned && !IS_TIM_COUNTER_MODE_SELECT_INSTANCE(Instance(timer))) + return HilStatus::unsupported; + + if ((complementary || request.deadTime || request.idleHigh || request.complementaryIdleHigh || request.breakPin) && !IS_TIM_BREAK_INSTANCE(Instance(timer))) + return HilStatus::unsupported; + + status = ResolveOutputs(timer, request); + if (status != HilStatus::done) + return status; + + if (request.breakPin && !SupportsFunction(*request.breakPin, hal::PinConfigTypeStm::timerBreak, timer)) + return HilStatus::pin; + + if (!ValidFrequency(timer, CounterClock(timer, request.prescaler), request.centerAligned, request.frequency)) + return HilStatus::range; + + return HilStatus::done; + } + + HilStatus PwmFactoryStm::Parse(const services::HilArguments& arguments, Request& request) const + { + HilStatus status = HilStatus::done; + arguments.Number("freq", request.frequency, 1, std::numeric_limits::max(), status); + arguments.Select("mode", request.centerAligned, alignments, status); + arguments.Number("prescaler", request.prescaler, 0, maximumPrescaler, status); + + if (auto deadTime = arguments.Key("dead"); deadTime && *deadTime != "off") + { + uint32_t nanoseconds = 0; + arguments.Number("dead", nanoseconds, 0, maximumDeadTimeNs, status); + request.deadTime = nanoseconds; + } + + arguments.Flag("inv", request.inverted, status); + arguments.Flag("invn", request.complementaryInverted, status); + arguments.Flag("idle", request.idleHigh, status); + arguments.Flag("idlen", request.complementaryIdleHigh, status); + arguments.Flag("brkauto", request.breakAutomaticOutput, status); + arguments.Flag("sync", request.synchronous, status); + arguments.Select("brkpol", request.breakActiveHigh, breakPolarities, status); + arguments.Pin("brk", naming, request.breakPin, status); + if (status != HilStatus::done) + return status; + + return ParseOutputs(arguments, request); + } + + HilStatus PwmFactoryStm::ParseOutputs(const services::HilArguments& arguments, Request& request) const + { + const auto channels = arguments.Key("channels"); + const auto pins = arguments.Key("pins"); + if (!channels && !pins) + return HilStatus::usage; + + std::array parsed{}; + std::size_t channelCount = 0; + std::size_t pinCount = 0; + + if (channels) + { + infra::Tokenizer tokens(*channels, ','); + channelCount = tokens.Size(); + + for (std::size_t i = 0; i != channelCount; ++i) + { + auto channel = services::HilArguments::ParseNumber(tokens.Token(i)); + if (!channel) + return HilStatus::usage; + + if (*channel < 1 || *channel > maximumChannels) + return HilStatus::range; + + if (i < parsed.size()) + parsed[i].channel = static_cast(*channel); + } + } + + if (pins) + { + infra::Tokenizer tokens(*pins, ','); + pinCount = tokens.Size(); + + for (std::size_t i = 0; i != pinCount; ++i) + { + Output output; + HilStatus status = ParseOutput(tokens.Token(i), output); + if (status != HilStatus::done) + return status; + + if (i < parsed.size()) + { + parsed[i].pin = output.pin; + parsed[i].complementaryPin = output.complementaryPin; + } + } + } + + const auto count = channels ? channelCount : pinCount; + if (count == 0 || count > maximumChannels || (channels && pins && channelCount != pinCount)) + return HilStatus::usage; + + for (std::size_t i = 0; i != count; ++i) + { + if (channels) + for (std::size_t j = 0; j != i; ++j) + if (parsed[j].channel == parsed[i].channel) + return HilStatus::usage; + + request.outputs.push_back(parsed[i]); + } + + return HilStatus::done; + } + + HilStatus PwmFactoryStm::ParseOutput(infra::BoundedConstString text, Output& output) const + { + const auto separator = text.find(':'); + infra::BoundedConstString complementaryText("-"); + + if (separator != infra::BoundedConstString::npos) + { + complementaryText = text.substr(separator + 1); + if (complementaryText.find(':') != infra::BoundedConstString::npos) + return HilStatus::usage; + } + + auto pin = ParseOptionalPin(text.substr(0, separator), naming); + auto complementaryPin = ParseOptionalPin(complementaryText, naming); + if (!pin || !complementaryPin) + return HilStatus::pin; + + if (!*pin && !*complementaryPin) + return HilStatus::usage; + + output.pin = *pin; + output.complementaryPin = *complementaryPin; + return HilStatus::done; + } + + HilStatus PwmFactoryStm::ResolveOutputs(uint8_t timer, Request& request) const + { + for (auto& output : request.outputs) + { + if (output.channel != 0) + continue; + + auto channel = output.pin ? ChannelOfPin(*output.pin, infra::MakeRange(channelFunctions), timer) : ChannelOfPin(*output.complementaryPin, infra::MakeRange(complementaryFunctions), timer); + if (!channel) + return HilStatus::pin; + + output.channel = *channel; + } + + for (std::size_t i = 0; i != request.outputs.size(); ++i) + for (std::size_t j = 0; j != i; ++j) + if (request.outputs[j].channel == request.outputs[i].channel) + return HilStatus::usage; + + for (auto& output : request.outputs) + { + if (!HasChannel(timer, output.channel) || (output.complementaryPin && !HasComplementaryChannel(timer, output.channel))) + return HilStatus::unsupported; + + const auto function = channelFunctions[output.channel - 1]; + + if (!output.pin && !output.complementaryPin) + { + output.pin = FindFunctionPin(function, timer); + if (!output.pin) + return HilStatus::pin; + } + + if (output.pin && !SupportsFunction(*output.pin, function, timer)) + return HilStatus::pin; + + if (output.complementaryPin && !SupportsFunction(*output.complementaryPin, complementaryFunctions[output.channel - 1], timer)) + return HilStatus::pin; + } + + return HilStatus::done; + } + + HilStatus PwmFactoryStm::Claim(uint8_t timer, const Request& request, services::HilPinOwner& pins, ClaimedPins& claimed) const + { + HilStatus status = HilStatus::done; + + for (std::size_t i = 0; i != request.outputs.size() && status == HilStatus::done; ++i) + { + const auto& output = request.outputs[i]; + status = pins.ClaimFunction(output.pin, Function(channelFunctions[output.channel - 1]), timer, claimed.pins[i]); + if (status == HilStatus::done && output.complementaryPin) + status = pins.ClaimFunction(*output.complementaryPin, Function(complementaryFunctions[output.channel - 1]), timer, claimed.complementaryPins[i]); + } + + if (status == HilStatus::done && request.breakPin) + status = pins.ClaimFunction(*request.breakPin, Function(hal::PinConfigTypeStm::timerBreak), timer, claimed.breakPin); + + return status; + } + + services::HilPwmHandle& PwmFactoryStm::Construct(uint8_t timer, const Request& request, const ClaimedPins& claimed) + { + hal::PwmStmBase::Config config; + // All three centre-aligned modes give the same waveform; they only differ in when CCxIF is set, which nothing here uses + config.alignment = request.centerAligned ? hal::PwmStmBase::Alignment::centerAlignedDownCounting : hal::PwmStmBase::Alignment::edgeAligned; + config.prescaler = static_cast(request.prescaler); + + if (request.deadTime) + config.deadTime.emplace().duration = std::chrono::nanoseconds(*request.deadTime); + + if (request.breakPin) + { + auto& breakInput = config.breakInput.emplace(); + breakInput.activeHigh = request.breakActiveHigh; + breakInput.automaticOutputEnable = request.breakAutomaticOutput; + } + + infra::BoundedVector::WithMaxSize channels; + for (std::size_t i = 0; i != request.outputs.size(); ++i) + channels.emplace_back(request.outputs[i].channel, PinOrDummy(claimed.pins[i]), PinOrDummy(claimed.complementaryPins[i]), + request.inverted, request.complementaryInverted, request.idleHigh, request.complementaryIdleHigh); + + auto& breakPin = PinOrDummy(claimed.breakPin); + if (request.synchronous) + driver.emplace(timer, infra::MakeRange(channels), breakPin, config); + else + driver.emplace(timer, infra::MakeRange(channels), breakPin, config); + + auto& opened = handle.emplace(driver, request.outputs.size()); + opened.SetBaseFrequency(hal::Hertz(request.frequency)); + return opened; + } +} diff --git a/validation/firmware/PwmFactory.hpp b/validation/firmware/PwmFactory.hpp new file mode 100644 index 00000000..b9e780e3 --- /dev/null +++ b/validation/firmware/PwmFactory.hpp @@ -0,0 +1,105 @@ +#pragma once + +#include "hal_st/stm32fxxx/PwmStm.hpp" +#include "hal_st/synchronous_stm32fxxx/SynchronousPwmStm.hpp" +#include "infra/util/BoundedVector.hpp" +#include "services/hil/commands/HilPwmCommands.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/TimerAllocation.hpp" +#include +#include +#include +#include +#include + +namespace validation +{ + class PwmFactoryStm + : public services::HilPwmFactory + { + public: + PwmFactoryStm(const services::HilPinNaming& naming, TimerAllocation& timers); + + uint8_t Instances() const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins, services::HilPwmHandle*& handle) override; + void ReportOpened(uint8_t index, services::HilResponse::Line& line) override; + services::HilStatus ChangeFrequency(uint8_t index, uint32_t hertz) override; + void Close(uint8_t index, const infra::Function& onClosed) override; + + private: + static constexpr std::size_t maximumChannels = services::HilPwmCommands::maximumChannels; + + using Driver = std::variant; + + // PwmStm::Start(duty) asserts one duty per channel, so a single duty is replicated here instead of going through HilPwmAdapter + class Handle + : public services::HilPwmHandle + { + public: + Handle(Driver& driver, std::size_t channels); + + std::size_t Channels() const override; + void Start(infra::MemoryRange dutyCycles) override; + void SetBaseFrequency(hal::Hertz baseFrequency) override; + void Stop() override; + + private: + Driver& driver; + std::size_t channels; + }; + + struct Output + { + uint8_t channel = 0; + std::optional pin; + std::optional complementaryPin; + }; + + struct Request + { + infra::BoundedVector::WithMaxSize outputs; + uint32_t frequency = 10000; + bool centerAligned = false; + uint32_t prescaler = 0; + std::optional deadTime; + bool inverted = false; + bool complementaryInverted = false; + bool idleHigh = false; + bool complementaryIdleHigh = false; + std::optional breakPin; + bool breakActiveHigh = true; + bool breakAutomaticOutput = false; + bool synchronous = false; + }; + + struct ClaimedPins + { + std::array pins{}; + std::array complementaryPins{}; + hal::GpioPin* breakPin = nullptr; + }; + + struct Timing + { + uint32_t counterClock; + bool centerAligned; + }; + + services::HilStatus Evaluate(uint8_t timer, const services::HilArguments& arguments, Request& request) const; + services::HilStatus Parse(const services::HilArguments& arguments, Request& request) const; + services::HilStatus ParseOutputs(const services::HilArguments& arguments, Request& request) const; + services::HilStatus ParseOutput(infra::BoundedConstString text, Output& output) const; + services::HilStatus ResolveOutputs(uint8_t timer, Request& request) const; + services::HilStatus Claim(uint8_t timer, const Request& request, services::HilPinOwner& pins, ClaimedPins& claimed) const; + services::HilPwmHandle& Construct(uint8_t timer, const Request& request, const ClaimedPins& claimed); + + private: + const services::HilPinNaming& naming; + TimerAllocation& timers; + Driver driver; + std::optional handle; + std::optional timing; + }; +} diff --git a/validation/firmware/QeiFactory.cpp b/validation/firmware/QeiFactory.cpp new file mode 100644 index 00000000..4d164895 --- /dev/null +++ b/validation/firmware/QeiFactory.cpp @@ -0,0 +1,337 @@ +#include "validation/firmware/QeiFactory.hpp" +#include "BoardProfile.hpp" +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include +#include +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + using Config = hal::SynchronousQuadratureEncoderStm::Config; + using services::HilChoice; + using services::HilStatus; + + constexpr uint32_t defaultVelocityPeriodUs = 1000; + constexpr uint32_t maximumVelocityPeriodUs = 1000000; + constexpr uint32_t maximumFilter = 15; + constexpr uint32_t maximumResolution16Bit = 65536; + + constexpr std::array openKeys{ { "lp", "a", "b", "idx", "res", "offset", "inva", "invb", "cap", "filter", "vel" } }; + + constexpr std::array, 3> decodeModes{ { + { "a", Config::DecodeMode::x2OnPhaseA }, + { "b", Config::DecodeMode::x2OnPhaseB }, + { "ab", Config::DecodeMode::x4OnBothPhases }, + } }; + + constexpr std::array timerInputs{ { hal::PinConfigTypeStm::timerChannel1, hal::PinConfigTypeStm::timerChannel2 } }; + constexpr std::array lowPowerTimerInputs{ { hal::PinConfigTypeStm::lpTimerInput1, hal::PinConfigTypeStm::lpTimerInput2 } }; + + HilStatus CheckTimer(uint8_t timer) + { + if (!TimerExists(timer)) + return HilStatus::range; + + if (!IS_TIM_ENCODER_INTERFACE_INSTANCE(hal::peripheralTimer[timer - 1])) + return HilStatus::unsupported; + + return HilStatus::done; + } + + // PROTOCOL numbers only the LPTIMs with an encoder interface, so another LPTIM is out of range rather than unsupported + HilStatus CheckLowPowerTimer([[maybe_unused]] uint8_t timer) + { +#if defined(HAS_PERIPHERAL_LPTIMER) + if (timer < 1 || timer > hal::peripheralLpTimer.size() || hal::peripheralLpTimer[timer - 1] == nullptr || !IS_LPTIM_ENCODER_INTERFACE_INSTANCE(hal::peripheralLpTimer[timer - 1])) + return HilStatus::range; + + return HilStatus::done; +#else + return HilStatus::unsupported; +#endif + } + + uint32_t MaximumResolution(uint8_t timer, bool lowPower) + { + if (!lowPower && IS_TIM_32B_COUNTER_INSTANCE(hal::peripheralTimer[timer - 1])) + return std::numeric_limits::max(); + + return maximumResolution16Bit; + } + + bool IsLowPowerFilter(uint8_t samples) + { + return samples == 0 || samples == 2 || samples == 4 || samples == 8; + } + +#if defined(HAS_PERIPHERAL_LPTIMER) + hal::SynchronousQuadratureEncoderLpTimStm::Config::Filter LowPowerFilter(uint8_t samples) + { + using Filter = hal::SynchronousQuadratureEncoderLpTimStm::Config::Filter; + + switch (samples) + { + case 2: + return Filter::twoSamples; + case 4: + return Filter::fourSamples; + case 8: + return Filter::eightSamples; + default: + return Filter::none; + } + } + + hal::SynchronousQuadratureEncoderLpTimStm::Config LowPowerConfig(const Config& config) + { + hal::SynchronousQuadratureEncoderLpTimStm::Config result; + result.resolution = config.resolution; + result.filter = LowPowerFilter(config.filter); + result.reverseForMirroredMounting = config.invertPhaseA; + result.speedSamplePeriod = config.speedSamplePeriod; + return result; + } +#endif + + template + hal::SynchronousQuadratureEncoder& WithIndex(Encoder& encoder, const hal::GpioPin* index) + { + if (index != nullptr) + encoder.EnableIndex(); + + return encoder; + } + } + + QeiFactoryStm::QeiFactoryStm(const services::HilPinNaming& naming, TimerAllocation& timers) + : naming(naming) + , timers(timers) + {} + + uint8_t QeiFactoryStm::Instances() const + { + return 18; + } + + infra::MemoryRange QeiFactoryStm::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus QeiFactoryStm::Prepare(uint8_t index, const services::HilArguments& arguments) + { + Request request; + return Evaluate(index, arguments, request); + } + + HilStatus QeiFactoryStm::Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins, hal::SynchronousQuadratureEncoder*& encoder) + { + Request request; + HilStatus status = Evaluate(index, arguments, request); + if (status != HilStatus::done) + return status; + + if (!request.lowPower) + { + status = timers.Claim(index, services::HilOwners::qei); + if (status != HilStatus::done) + return status; + } + + ClaimedPins claimed; + status = Claim(index, request, pins, claimed); + if (status != HilStatus::done) + { + ReleaseTimer(index, request.lowPower); + return status; + } + + encoder = &Construct(index, request, claimed); + opened = OpenedInstance{ index, request.lowPower, request.index.has_value() }; + return HilStatus::done; + } + + void QeiFactoryStm::Close(uint8_t index, const infra::Function& onClosed) + { + driver.emplace(); + + if (opened) + ReleaseTimer(index, opened->lowPower); + + opened.reset(); + onClosed(); + } + + HilStatus QeiFactoryStm::ReadIndex(uint8_t index, bool& asserted) const + { + if (!opened || opened->timer != index) + return HilStatus::notOpen; + + if (!opened->hasIndex) + return HilStatus::unsupported; + + asserted = std::visit([](const Alternative& alternative) + { + if constexpr (std::is_same_v) + return false; + else + return alternative.IndexAsserted(); + }, + driver); + + return HilStatus::done; + } + + HilStatus QeiFactoryStm::Evaluate(uint8_t timer, const services::HilArguments& arguments, Request& request) const + { + HilStatus status = HilStatus::done; + arguments.Flag("lp", request.lowPower, status); + if (status != HilStatus::done) + return status; + + status = request.lowPower ? CheckLowPowerTimer(timer) : CheckTimer(timer); + if (status != HilStatus::done) + return status; + + status = ParseSettings(timer, arguments, request); + if (status != HilStatus::done) + return status; + + return ParsePins(timer, arguments, request); + } + + HilStatus QeiFactoryStm::ParseSettings(uint8_t timer, const services::HilArguments& arguments, Request& request) const + { + auto& config = request.config; + + HilStatus status = HilStatus::done; + arguments.Number("res", config.resolution, 2, MaximumResolution(timer, request.lowPower), status); + arguments.Number("offset", config.offset, 0, std::numeric_limits::max(), status); + if (status == HilStatus::done && config.offset >= config.resolution) + return HilStatus::range; + + uint32_t filter = 0; + arguments.Flag("inva", config.invertPhaseA, status); + arguments.Flag("invb", config.invertPhaseB, status); + arguments.Select("cap", config.decodeMode, decodeModes, status); + arguments.Number("filter", filter, 0, maximumFilter, status); + config.filter = static_cast(filter); + + if (auto velocity = arguments.Key("vel"); velocity && *velocity == "off") + config.speedSamplePeriod = std::nullopt; + else + { + uint32_t velocityPeriod = defaultVelocityPeriodUs; + arguments.Number("vel", velocityPeriod, 1, maximumVelocityPeriodUs, status); + config.speedSamplePeriod = std::chrono::microseconds(velocityPeriod); + } + + if (status != HilStatus::done) + return status; + + if (request.lowPower && (arguments.Has("cap") || arguments.Has("offset") || arguments.Has("invb"))) + return HilStatus::unsupported; + + if (request.lowPower && !IsLowPowerFilter(config.filter)) + return HilStatus::range; + + return HilStatus::done; + } + + HilStatus QeiFactoryStm::ParsePins(uint8_t timer, const services::HilArguments& arguments, Request& request) const + { + HilStatus status = HilStatus::done; + arguments.Pin("a", naming, request.a, status); + arguments.Pin("b", naming, request.b, status); + arguments.Pin("idx", naming, request.index, status); + if (status != HilStatus::done) + return status; + + if (!request.a && !request.b && !request.index && !request.lowPower && timer == board::defaultQei.timer) + { + request.a = board::defaultQei.a; + request.b = board::defaultQei.b; + request.index = board::defaultQei.idx; + } + + if (!request.a || !request.b) + return HilStatus::usage; + + const auto& inputs = request.lowPower ? lowPowerTimerInputs : timerInputs; + if (!SupportsFunction(*request.a, inputs[0], timer) || !SupportsFunction(*request.b, inputs[1], timer)) + return HilStatus::pin; + + if (request.index && !IsBonded(*request.index)) + return HilStatus::pin; + + return HilStatus::done; + } + + HilStatus QeiFactoryStm::Claim(uint8_t timer, const Request& request, services::HilPinOwner& pins, ClaimedPins& claimed) const + { + const auto& inputs = request.lowPower ? lowPowerTimerInputs : timerInputs; + + HilStatus status = pins.ClaimFunction(*request.a, Function(inputs[0]), timer, claimed.a); + if (status == HilStatus::done) + status = pins.ClaimFunction(*request.b, Function(inputs[1]), timer, claimed.b); + if (status == HilStatus::done && request.index) + status = pins.Claim(*request.index, services::HilPinPool::Use::exclusive, claimed.index); + + return status; + } + + hal::SynchronousQuadratureEncoder& QeiFactoryStm::Construct(uint8_t timer, const Request& request, const ClaimedPins& claimed) + { +#if defined(HAS_PERIPHERAL_LPTIMER) + if (request.lowPower) + return WithIndex(driver.emplace(timer, PinOrDummy(claimed.a), PinOrDummy(claimed.b), PinOrDummy(claimed.index), LowPowerConfig(request.config)), claimed.index); +#endif + + return WithIndex(driver.emplace(timer, PinOrDummy(claimed.a), PinOrDummy(claimed.b), PinOrDummy(claimed.index), request.config), claimed.index); + } + + void QeiFactoryStm::ReleaseTimer(uint8_t timer, bool lowPower) + { + if (!lowPower) + timers.Release(timer, services::HilOwners::qei); + } + + QeiExtensionCommands::QeiExtensionCommands(services::HilContext& context, QeiFactoryStm& factory) + : services::TerminalCommands(context.terminal) + , context(context) + , factory(factory) + , commands{ { + services::HilBind("qei.index", "qei.index ", *this, context.response), + } } + {} + + infra::MemoryRange QeiExtensionCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus QeiExtensionCommands::Index(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + uint32_t index = 0; + HilStatus status = HilStatus::done; + arguments.NumberAt(0, index, 0, factory.Instances() - 1, status); + if (status != HilStatus::done) + return status; + + bool asserted = false; + status = factory.ReadIndex(static_cast(index), asserted); + if (status != HilStatus::done) + return status; + + context.response.Ok() << " idx=" << static_cast(asserted ? 1 : 0); + return HilStatus::done; + } +} diff --git a/validation/firmware/QeiFactory.hpp b/validation/firmware/QeiFactory.hpp new file mode 100644 index 00000000..a4b8ad37 --- /dev/null +++ b/validation/firmware/QeiFactory.hpp @@ -0,0 +1,91 @@ +#pragma once + +#include "hal_st/synchronous_stm32fxxx/SynchronousQuadratureEncoderLpTimStm.hpp" +#include "hal_st/synchronous_stm32fxxx/SynchronousQuadratureEncoderStm.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilQeiCommands.hpp" +#include "services/util/Terminal.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/TimerAllocation.hpp" +#include +#include +#include +#include + +namespace validation +{ + class QeiFactoryStm + : public services::HilQeiFactory + { + public: + QeiFactoryStm(const services::HilPinNaming& naming, TimerAllocation& timers); + + uint8_t Instances() const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins, hal::SynchronousQuadratureEncoder*& encoder) override; + void Close(uint8_t index, const infra::Function& onClosed) override; + + services::HilStatus ReadIndex(uint8_t index, bool& asserted) const; + + private: +#if defined(HAS_PERIPHERAL_LPTIMER) + using Driver = std::variant; +#else + using Driver = std::variant; +#endif + + struct Request + { + bool lowPower = false; + std::optional a; + std::optional b; + std::optional index; + hal::SynchronousQuadratureEncoderStm::Config config; + }; + + struct ClaimedPins + { + hal::GpioPin* a = nullptr; + hal::GpioPin* b = nullptr; + hal::GpioPin* index = nullptr; + }; + + struct OpenedInstance + { + uint8_t timer; + bool lowPower; + bool hasIndex; + }; + + services::HilStatus Evaluate(uint8_t timer, const services::HilArguments& arguments, Request& request) const; + services::HilStatus ParseSettings(uint8_t timer, const services::HilArguments& arguments, Request& request) const; + services::HilStatus ParsePins(uint8_t timer, const services::HilArguments& arguments, Request& request) const; + services::HilStatus Claim(uint8_t timer, const Request& request, services::HilPinOwner& pins, ClaimedPins& claimed) const; + hal::SynchronousQuadratureEncoder& Construct(uint8_t timer, const Request& request, const ClaimedPins& claimed); + void ReleaseTimer(uint8_t timer, bool lowPower); + + private: + const services::HilPinNaming& naming; + TimerAllocation& timers; + Driver driver; + std::optional opened; + }; + + class QeiExtensionCommands + : public services::TerminalCommands + { + public: + QeiExtensionCommands(services::HilContext& context, QeiFactoryStm& factory); + + infra::MemoryRange Commands() override; + + private: + services::HilStatus Index(const services::HilArguments& arguments); + + private: + services::HilContext& context; + QeiFactoryStm& factory; + std::array commands; + }; +} diff --git a/validation/firmware/SpiFactory.cpp b/validation/firmware/SpiFactory.cpp new file mode 100644 index 00000000..d26c6771 --- /dev/null +++ b/validation/firmware/SpiFactory.cpp @@ -0,0 +1,203 @@ +#include "validation/firmware/SpiFactory.hpp" +#include "BoardProfile.hpp" +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include +#include + +namespace validation +{ + namespace + { + using services::HilStatus; + + constexpr std::array openKeys{ { "clk", "mosi", "miso", "cs", "baud", "mode", "dma", "sync" } }; + + constexpr std::array baudRatePrescalers{ { + SPI_BAUDRATEPRESCALER_2, + SPI_BAUDRATEPRESCALER_4, + SPI_BAUDRATEPRESCALER_8, + SPI_BAUDRATEPRESCALER_16, + SPI_BAUDRATEPRESCALER_32, + SPI_BAUDRATEPRESCALER_64, + SPI_BAUDRATEPRESCALER_128, + SPI_BAUDRATEPRESCALER_256, + } }; + + bool InstanceExists(uint8_t index) + { + return index >= 1 && index <= hal::peripheralSpi.size() && hal::peripheralSpi[index - 1] != nullptr; + } + + std::optional BaudRatePrescaler(uint32_t kernelClock, uint32_t baud) + { + const auto rate = static_cast(baud); + if (rate * 2 > kernelClock) + return std::nullopt; + + for (std::size_t n = 0; n != baudRatePrescalers.size(); ++n) + if ((rate << (n + 1)) >= kernelClock) + return baudRatePrescalers[n]; + + return std::nullopt; + } + + bool PinsSupportFunctions(uint8_t index, HilPinId clock, HilPinId mosi, HilPinId miso) + { + return SupportsFunction(clock, hal::PinConfigTypeStm::spiClock, index) && SupportsFunction(mosi, hal::PinConfigTypeStm::spiMosi, index) && SupportsFunction(miso, hal::PinConfigTypeStm::spiMiso, index); + } + + template + Config MakeConfig(const auto& request) + { + Config config; + config.polarityLow = (request.mode & 2) == 0; + config.phase1st = (request.mode & 1) == 0; + config.baudRatePrescaler = request.baudRatePrescaler; + return config; + } + } + + SpiFactoryStm::SpiFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma) + : naming(naming) + , dma(dma) + {} + + uint8_t SpiFactoryStm::Instances() const + { + return 4; + } + + infra::MemoryRange SpiFactoryStm::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus SpiFactoryStm::Prepare(uint8_t index, const services::HilArguments& arguments) + { + Request request; + return Evaluate(index, arguments, request); + } + + HilStatus SpiFactoryStm::Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins, services::HilSpiHandle& handle) + { + Request request; + HilStatus status = Evaluate(index, arguments, request); + if (status != HilStatus::done) + return status; + + ClaimedPins claimed; + status = Claim(index, request, pins, claimed); + if (status != HilStatus::done) + return status; + + Construct(index, request, claimed, handle); + return HilStatus::done; + } + + void SpiFactoryStm::Close(uint8_t, const infra::Function& onClosed) + { + // The driver goes first: an end-of-transfer interrupt must not reach a destroyed chip-select wrapper + driver.emplace(); + chipSelect.reset(); + synchronousChipSelect.reset(); + receiveStream.reset(); + transmitStream.reset(); + onClosed(); + } + + HilStatus SpiFactoryStm::Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const + { + if (!InstanceExists(index)) + return HilStatus::range; + + const HilStatus status = Parse(arguments, request); + if (status != HilStatus::done) + return status; + + if (!request.clock || !request.mosi || !request.miso) + return HilStatus::usage; + + if (request.dma && request.synchronous) + return HilStatus::usage; + + const auto prescaler = BaudRatePrescaler(board::SpiKernelClock(index), request.baud); + if (!prescaler) + return HilStatus::range; + + request.baudRatePrescaler = *prescaler; + + if (!PinsSupportFunctions(index, *request.clock, *request.mosi, *request.miso)) + return HilStatus::pin; + + if (request.chipSelect && !IsBonded(*request.chipSelect)) + return HilStatus::pin; + + if (request.dma && !board::SpiDma(index)) + return HilStatus::unsupported; + + return HilStatus::done; + } + + HilStatus SpiFactoryStm::Parse(const services::HilArguments& arguments, Request& request) const + { + HilStatus status = HilStatus::done; + arguments.Pin("clk", naming, request.clock, status); + arguments.Pin("mosi", naming, request.mosi, status); + arguments.Pin("miso", naming, request.miso, status); + arguments.Pin("cs", naming, request.chipSelect, status); + arguments.Number("baud", request.baud, 1, std::numeric_limits::max(), status); + arguments.Number("mode", request.mode, 0, 3, status); + arguments.Flag("dma", request.dma, status); + arguments.Flag("sync", request.synchronous, status); + return status; + } + + HilStatus SpiFactoryStm::Claim(uint8_t index, const Request& request, services::HilPinOwner& pins, ClaimedPins& claimed) const + { + HilStatus status = pins.ClaimFunction(request.clock, Function(hal::PinConfigTypeStm::spiClock), index, claimed.clock); + if (status == HilStatus::done) + status = pins.ClaimFunction(request.mosi, Function(hal::PinConfigTypeStm::spiMosi), index, claimed.mosi); + if (status == HilStatus::done) + status = pins.ClaimFunction(request.miso, Function(hal::PinConfigTypeStm::spiMiso), index, claimed.miso); + if (status == HilStatus::done && request.chipSelect) + status = pins.Claim(*request.chipSelect, services::HilPinPool::Use::exclusive, claimed.chipSelect); + + return status; + } + + void SpiFactoryStm::Construct(uint8_t index, const Request& request, const ClaimedPins& claimed, services::HilSpiHandle& handle) + { + if (request.synchronous) + { + auto& spi = driver.emplace(index, PinOrDummy(claimed.clock), PinOrDummy(claimed.miso), PinOrDummy(claimed.mosi), MakeConfig(request)); + + if (claimed.chipSelect != nullptr) + handle.synchronous = &synchronousChipSelect.emplace(spi, *claimed.chipSelect); + else + handle.synchronous = &spi; + + return; + } + + auto& spi = ConstructAsynchronous(index, request, claimed); + + if (claimed.chipSelect != nullptr) + handle.spi = &chipSelect.emplace(spi, *claimed.chipSelect); + else + handle.spi = &spi; + } + + hal::SpiMaster& SpiFactoryStm::ConstructAsynchronous(uint8_t index, const Request& request, const ClaimedPins& claimed) + { + if (!request.dma) + return driver.emplace(index, PinOrDummy(claimed.clock), PinOrDummy(claimed.miso), PinOrDummy(claimed.mosi), MakeConfig(request)); + + const DmaRequests requests = *board::SpiDma(index); + transmitStream.emplace(dma, hal::DmaChannelId(1, board::spiDmaChannel, requests.transmit)); + receiveStream.emplace(dma, hal::DmaChannelId(1, static_cast(board::spiDmaChannel + 1), requests.receive)); + + return driver.emplace(*transmitStream, *receiveStream, index, PinOrDummy(claimed.clock), PinOrDummy(claimed.miso), PinOrDummy(claimed.mosi), MakeConfig(request)); + } +} diff --git a/validation/firmware/SpiFactory.hpp b/validation/firmware/SpiFactory.hpp new file mode 100644 index 00000000..412b4d5e --- /dev/null +++ b/validation/firmware/SpiFactory.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include "hal_st/stm32fxxx/DmaStm.hpp" +#include "hal_st/stm32fxxx/SpiMasterStm.hpp" +#include "hal_st/stm32fxxx/SpiMasterStmDma.hpp" +#include "hal_st/synchronous_stm32fxxx/SynchronousSpiMasterStm.hpp" +#include "services/hil/commands/HilSpiCommands.hpp" +#include "services/peripheral/SpiMasterWithChipSelect.hpp" +#include "services/synchronous_peripheral/SynchronousSpiMasterWithChipSelect.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include +#include +#include + +namespace validation +{ + class SpiFactoryStm + : public services::HilSpiFactory + { + public: + SpiFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma); + + uint8_t Instances() const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins, services::HilSpiHandle& handle) override; + void Close(uint8_t index, const infra::Function& onClosed) override; + + private: + struct Request + { + std::optional clock; + std::optional mosi; + std::optional miso; + std::optional chipSelect; + uint32_t baud = 1000000; + uint32_t mode = 0; + bool dma = false; + bool synchronous = false; + uint32_t baudRatePrescaler = SPI_BAUDRATEPRESCALER_2; + }; + + struct ClaimedPins + { + hal::GpioPin* clock = nullptr; + hal::GpioPin* mosi = nullptr; + hal::GpioPin* miso = nullptr; + hal::GpioPin* chipSelect = nullptr; + }; + + services::HilStatus Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const; + services::HilStatus Parse(const services::HilArguments& arguments, Request& request) const; + services::HilStatus Claim(uint8_t index, const Request& request, services::HilPinOwner& pins, ClaimedPins& claimed) const; + void Construct(uint8_t index, const Request& request, const ClaimedPins& claimed, services::HilSpiHandle& handle); + hal::SpiMaster& ConstructAsynchronous(uint8_t index, const Request& request, const ClaimedPins& claimed); + + private: + const services::HilPinNaming& naming; + hal::DmaStm& dma; + std::optional transmitStream; + std::optional receiveStream; + std::variant driver; + std::optional chipSelect; + std::optional synchronousChipSelect; + }; +} diff --git a/validation/firmware/TimerAllocation.cpp b/validation/firmware/TimerAllocation.cpp new file mode 100644 index 00000000..c88289a9 --- /dev/null +++ b/validation/firmware/TimerAllocation.cpp @@ -0,0 +1,24 @@ +#include "validation/firmware/TimerAllocation.hpp" +#include "infra/util/ReallyAssert.hpp" + +namespace validation +{ + services::HilStatus TimerAllocation::Claim(uint8_t timer, services::HilOwner owner) + { + really_assert(timer < owners.size()); + + if (owners[timer] && *owners[timer] != owner) + return services::HilStatus::busy; + + owners[timer] = owner; + return services::HilStatus::done; + } + + void TimerAllocation::Release(uint8_t timer, services::HilOwner owner) + { + really_assert(timer < owners.size()); + + if (owners[timer] == owner) + owners[timer] = std::nullopt; + } +} diff --git a/validation/firmware/TimerAllocation.hpp b/validation/firmware/TimerAllocation.hpp new file mode 100644 index 00000000..7025fb46 --- /dev/null +++ b/validation/firmware/TimerAllocation.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include "services/hil/HilPinPool.hpp" +#include "services/hil/HilStatus.hpp" +#include +#include +#include + +namespace validation +{ + // PWM, encoder and timer-triggered ADC each need a whole timer; this keeps them from sharing one + class TimerAllocation + { + public: + services::HilStatus Claim(uint8_t timer, services::HilOwner owner); + void Release(uint8_t timer, services::HilOwner owner); + + private: + std::array, 32> owners; + }; +} diff --git a/validation/firmware/UartFactory.cpp b/validation/firmware/UartFactory.cpp new file mode 100644 index 00000000..1ad842f8 --- /dev/null +++ b/validation/firmware/UartFactory.cpp @@ -0,0 +1,290 @@ +#include "validation/firmware/UartFactory.hpp" +#include "BoardProfile.hpp" +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include + +namespace validation +{ + namespace + { + using services::HilChoice; + using services::HilStatus; + using FlowControl = hal::SynchronousUartStm::HwFlowControl; + + constexpr std::array openKeys{ { "lp", "tx", "rx", "rts", "cts", "baud", "parity", "flow", "swap", "dma", "duplex", "sync" } }; + + constexpr std::array, 3> parities{ { + { "none", USART_PARITY_NONE }, + { "even", USART_PARITY_EVEN }, + { "odd", USART_PARITY_ODD }, + } }; + + constexpr std::array, 4> flowControls{ { + { "none", FlowControl::hwControlDisable }, + { "rts", FlowControl::hwControlRtsEnable }, + { "cts", FlowControl::hwControlCtsEnable }, + { "rtscts", FlowControl::hwControlRtsCtsEnable }, + } }; + + constexpr uint32_t minimumBaudRate = 300; + constexpr uint32_t maximumBaudRate = 12000000; + + // The HAL keeps its BRR lower limits (UART_BRR_MIN, LPUART_BRR_MIN) private to *_hal_uart.c + constexpr uint32_t usartMinimumDivider = 0x10; + constexpr uint32_t lpuartMinimumDivider = 0x300; + + struct PinFunctions + { + hal::PinConfigTypeStm tx; + hal::PinConfigTypeStm rx; + hal::PinConfigTypeStm rts; + hal::PinConfigTypeStm cts; + }; + + constexpr PinFunctions usartFunctions{ hal::PinConfigTypeStm::uartTx, hal::PinConfigTypeStm::uartRx, hal::PinConfigTypeStm::uartRts, hal::PinConfigTypeStm::uartCts }; + constexpr PinFunctions lpuartFunctions{ hal::PinConfigTypeStm::lpuartTx, hal::PinConfigTypeStm::lpuartRx, hal::PinConfigTypeStm::lpuartRts, hal::PinConfigTypeStm::lpuartCts }; + + struct DriverPins + { + hal::GpioPinStm& tx; + hal::GpioPinStm& rx; + hal::GpioPinStm& rts; + hal::GpioPinStm& cts; + }; + + const PinFunctions& FunctionsOf(bool lpuart) + { + return lpuart ? lpuartFunctions : usartFunctions; + } + + bool UsesRts(FlowControl flow) + { + return flow == FlowControl::hwControlRtsEnable || flow == FlowControl::hwControlRtsCtsEnable; + } + + bool UsesCts(FlowControl flow) + { + return flow == FlowControl::hwControlCtsEnable || flow == FlowControl::hwControlRtsCtsEnable; + } + + bool InstanceExists(uint8_t index, bool lpuart) + { + const auto& table = lpuart ? hal::peripheralLpuart : hal::peripheralUart; + return index >= 1 && index <= table.size() && table[index - 1] != nullptr; + } + + bool BaudRateFits(uint8_t index, bool lpuart, uint32_t baud) + { + if (!IS_UART_BAUDRATE(baud)) + return false; + + const uint32_t clock = board::UartKernelClock(index, lpuart); + + if (lpuart) + { + const uint32_t divider = UART_DIV_LPUART(clock, baud, UART_PRESCALER_DIV1); + return divider >= lpuartMinimumDivider && divider <= static_cast(USART_BRR_LPUART); + } + + const uint32_t divider = UART_DIV_SAMPLING8(clock, baud, UART_PRESCALER_DIV1); + return divider >= usartMinimumDivider && divider <= static_cast(USART_BRR_BRR); + } + + bool PinsSupportFunctions(uint8_t index, const PinFunctions& functions, HilPinId tx, HilPinId rx, const std::optional& rts, const std::optional& cts) + { + return SupportsFunction(tx, functions.tx, index) && SupportsFunction(rx, functions.rx, index) && (!rts || SupportsFunction(*rts, functions.rts, index)) && (!cts || SupportsFunction(*cts, functions.cts, index)); + } + + template + Driver& EmplaceInterruptReceiving(Variant& driver, uint8_t index, bool lpuart, bool handshake, const DriverPins& pins, const hal::UartStm::Config& config, Streams&... streams) + { + if (lpuart) + { + if (handshake) + return driver.template emplace(streams..., index, pins.tx, pins.rx, pins.rts, pins.cts, hal::LpUart{}, config); + + return driver.template emplace(streams..., index, pins.tx, pins.rx, hal::LpUart{}, config); + } + + if (handshake) + return driver.template emplace(streams..., index, pins.tx, pins.rx, pins.rts, pins.cts, config); + + return driver.template emplace(streams..., index, pins.tx, pins.rx, config); + } + } + + UartFactoryStm::UartFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma) + : naming(naming) + , dma(dma) + {} + + uint8_t UartFactoryStm::Instances() const + { + return 3; + } + + infra::MemoryRange UartFactoryStm::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus UartFactoryStm::Prepare(uint8_t index, const services::HilArguments& arguments) + { + Request request; + return Evaluate(index, arguments, request); + } + + HilStatus UartFactoryStm::Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins, hal::TimeKeeper& timeKeeper, services::HilUartHandle& handle) + { + Request request; + HilStatus status = Evaluate(index, arguments, request); + if (status != HilStatus::done) + return status; + + ClaimedPins claimed; + status = Claim(index, request, pins, claimed); + if (status != HilStatus::done) + return status; + + Construct(index, request, claimed, timeKeeper, handle); + return HilStatus::done; + } + + void UartFactoryStm::Close(uint8_t, const infra::Function& onClosed) + { + if (serial != nullptr) + serial->ReceiveData(nullptr); + + serial = nullptr; + driver.emplace(); + receiveStream.reset(); + transmitStream.reset(); + onClosed(); + } + + HilStatus UartFactoryStm::Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const + { + const HilStatus status = Parse(arguments, request); + if (status != HilStatus::done) + return status; + + if (!InstanceExists(index, request.lpuart)) + return HilStatus::range; + + if (request.dma + request.duplex + request.synchronous > 1) + return HilStatus::usage; + + if (UsesRts(request.flow) != request.rts.has_value() || UsesCts(request.flow) != request.cts.has_value()) + return HilStatus::usage; + + if (request.lpuart && (request.duplex || request.synchronous)) + return HilStatus::unsupported; + + if (request.synchronous && (request.parity != USART_PARITY_NONE || request.swap)) + return HilStatus::unsupported; + + if (!request.synchronous && request.flow != FlowControl::hwControlDisable && request.flow != FlowControl::hwControlRtsCtsEnable) + return HilStatus::unsupported; + + if ((request.dma || request.duplex) && !board::UartDma(index, request.lpuart)) + return HilStatus::unsupported; + + if (!BaudRateFits(index, request.lpuart, request.baud)) + return HilStatus::range; + + // The terminal has no default pins: checked after this, a bare uart.open of it would answer usage instead of busy + if (index == board::terminal.index && request.lpuart == board::terminal.lpuart) + return HilStatus::busy; + + const bool noPins = !request.tx && !request.rx && !request.rts && !request.cts; + if (noPins && board::defaultUart && board::defaultUart->index == index && board::defaultUart->lpuart == request.lpuart) + { + request.tx = board::defaultUart->tx; + request.rx = board::defaultUart->rx; + } + + if (!request.tx || !request.rx) + return HilStatus::usage; + + if (!PinsSupportFunctions(index, FunctionsOf(request.lpuart), *request.tx, *request.rx, request.rts, request.cts)) + return HilStatus::pin; + + return HilStatus::done; + } + + HilStatus UartFactoryStm::Parse(const services::HilArguments& arguments, Request& request) const + { + HilStatus status = HilStatus::done; + arguments.Flag("lp", request.lpuart, status); + arguments.Pin("tx", naming, request.tx, status); + arguments.Pin("rx", naming, request.rx, status); + arguments.Pin("rts", naming, request.rts, status); + arguments.Pin("cts", naming, request.cts, status); + arguments.Number("baud", request.baud, minimumBaudRate, maximumBaudRate, status); + arguments.Select("parity", request.parity, parities, status); + arguments.Select("flow", request.flow, flowControls, status); + arguments.Flag("swap", request.swap, status); + arguments.Flag("dma", request.dma, status); + arguments.Flag("duplex", request.duplex, status); + arguments.Flag("sync", request.synchronous, status); + return status; + } + + HilStatus UartFactoryStm::Claim(uint8_t index, const Request& request, services::HilPinOwner& pins, ClaimedPins& claimed) const + { + const auto& functions = FunctionsOf(request.lpuart); + + HilStatus status = pins.ClaimFunction(request.tx, Function(functions.tx), index, claimed.tx); + if (status == HilStatus::done) + status = pins.ClaimFunction(request.rx, Function(functions.rx), index, claimed.rx); + if (status == HilStatus::done) + status = pins.ClaimFunction(request.rts, Function(functions.rts), index, claimed.rts); + if (status == HilStatus::done) + status = pins.ClaimFunction(request.cts, Function(functions.cts), index, claimed.cts); + + return status; + } + + void UartFactoryStm::Construct(uint8_t index, const Request& request, const ClaimedPins& claimed, hal::TimeKeeper& timeKeeper, services::HilUartHandle& handle) + { + const DriverPins pins{ PinOrDummy(claimed.tx), PinOrDummy(claimed.rx), PinOrDummy(claimed.rts), PinOrDummy(claimed.cts) }; + const bool handshake = request.flow != FlowControl::hwControlDisable; + handle.baudRate = request.baud; + + if (request.synchronous) + { + if (handshake) + handle.synchronous = &driver.emplace(index, pins.tx, pins.rx, pins.rts, pins.cts, timeKeeper, request.flow, request.baud); + else + handle.synchronous = &driver.emplace(index, pins.tx, pins.rx, timeKeeper, request.baud); + + return; + } + + const hal::UartStm::Config config{ request.baud, request.parity, hal::cortex::InterruptPriority::normal, request.swap }; + + if (request.dma || request.duplex) + { + const DmaRequests requests = *board::UartDma(index, request.lpuart); + transmitStream.emplace(dma, hal::DmaChannelId(1, board::uartDmaChannel, requests.transmit)); + + if (request.duplex) + receiveStream.emplace(dma, hal::DmaChannelId(1, board::uartDmaChannel + 1, requests.receive)); + } + + if (request.duplex) + { + if (handshake) + serial = &driver.emplace(*transmitStream, *receiveStream, index, pins.tx, pins.rx, pins.rts, pins.cts, config); + else + serial = &driver.emplace(*transmitStream, *receiveStream, index, pins.tx, pins.rx, config); + } + else if (request.dma) + serial = &EmplaceInterruptReceiving(driver, index, request.lpuart, handshake, pins, config, *transmitStream); + else + serial = &EmplaceInterruptReceiving(driver, index, request.lpuart, handshake, pins, config); + + handle.serial = serial; + } +} diff --git a/validation/firmware/UartFactory.hpp b/validation/firmware/UartFactory.hpp new file mode 100644 index 00000000..f7140bda --- /dev/null +++ b/validation/firmware/UartFactory.hpp @@ -0,0 +1,71 @@ +#pragma once + +#include "hal_st/stm32fxxx/DmaStm.hpp" +#include "hal_st/stm32fxxx/UartStm.hpp" +#include "hal_st/stm32fxxx/UartStmDma.hpp" +#include "hal_st/stm32fxxx/UartStmDuplexDma.hpp" +#include "hal_st/synchronous_stm32fxxx/SynchronousUartStm.hpp" +#include "services/hil/commands/HilUartCommands.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include +#include +#include + +namespace validation +{ + class UartFactoryStm + : public services::HilUartFactory + { + public: + UartFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma); + + uint8_t Instances() const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins, hal::TimeKeeper& timeKeeper, services::HilUartHandle& handle) override; + void Close(uint8_t index, const infra::Function& onClosed) override; + + private: + using FlowControl = hal::SynchronousUartStm::HwFlowControl; + using DuplexUart = hal::UartStmDuplexDma::WithRxBuffer<64>; + // The polled driver buffers everything until uart.recv drains it: one ring slot stays empty, uart.recv returns up to 256 bytes + using SynchronousUart = hal::SynchronousUartStm::WithStorage<257>; + + struct Request + { + bool lpuart = false; + std::optional tx; + std::optional rx; + std::optional rts; + std::optional cts; + uint32_t baud = 115200; + uint32_t parity = USART_PARITY_NONE; + FlowControl flow = FlowControl::hwControlDisable; + bool swap = false; + bool dma = false; + bool duplex = false; + bool synchronous = false; + }; + + struct ClaimedPins + { + hal::GpioPin* tx = nullptr; + hal::GpioPin* rx = nullptr; + hal::GpioPin* rts = nullptr; + hal::GpioPin* cts = nullptr; + }; + + services::HilStatus Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const; + services::HilStatus Parse(const services::HilArguments& arguments, Request& request) const; + services::HilStatus Claim(uint8_t index, const Request& request, services::HilPinOwner& pins, ClaimedPins& claimed) const; + void Construct(uint8_t index, const Request& request, const ClaimedPins& claimed, hal::TimeKeeper& timeKeeper, services::HilUartHandle& handle); + + private: + const services::HilPinNaming& naming; + hal::DmaStm& dma; + std::optional transmitStream; + std::optional receiveStream; + std::variant driver; + hal::SerialCommunication* serial = nullptr; + }; +} diff --git a/validation/firmware/UnsupportedGroups.cpp b/validation/firmware/UnsupportedGroups.cpp new file mode 100644 index 00000000..bafc86b2 --- /dev/null +++ b/validation/firmware/UnsupportedGroups.cpp @@ -0,0 +1,31 @@ +#include "validation/firmware/UnsupportedGroups.hpp" +#include "services/hil/commands/HilUnsupportedCommands.hpp" +#include + +namespace validation +{ + namespace + { + constexpr std::array commandNames{ { + "comp.open", + "comp.read", + "comp.irq", + "comp.count", + "comp.close", + "can.open", + "can.send", + "can.close", + "eeprom.write", + "eeprom.read", + "eeprom.erase", + "eth.open", + "eth.status", + "eth.close", + } }; + } + + void CreateUnsupportedGroups(services::HilContext& context) + { + static services::HilUnsupportedCommands::WithMaxCommands unsupported{ context, infra::MakeRange(commandNames) }; + } +} diff --git a/validation/firmware/UnsupportedGroups.hpp b/validation/firmware/UnsupportedGroups.hpp new file mode 100644 index 00000000..eb3aab7a --- /dev/null +++ b/validation/firmware/UnsupportedGroups.hpp @@ -0,0 +1,8 @@ +#pragma once + +#include "services/hil/HilCommand.hpp" + +namespace validation +{ + void CreateUnsupportedGroups(services::HilContext& context); +} diff --git a/validation/firmware/WatchDogFactory.cpp b/validation/firmware/WatchDogFactory.cpp new file mode 100644 index 00000000..b5f1b07e --- /dev/null +++ b/validation/firmware/WatchDogFactory.cpp @@ -0,0 +1,149 @@ +#include "validation/firmware/WatchDogFactory.hpp" +#include +#include +#include + +namespace validation +{ + namespace + { + using services::HilStatus; + + constexpr std::array startKeys{ { "timeout", "feed", "pin" } }; + constexpr services::HilOwner warningPinOwner = services::HilOwners::extension + 1; + constexpr uint32_t maximumTimeoutMs = 30000; + constexpr uint64_t ticksUntilEarlyWarning = WWDG_CR_T - WWDG_CR_T_6; + constexpr uint64_t wwdgClockDivider = 4096; + constexpr uint64_t millisecondsPerSecond = 1000; + + constexpr std::array prescalers{ { + WWDG_PRESCALER_1, + WWDG_PRESCALER_2, + WWDG_PRESCALER_4, + WWDG_PRESCALER_8, + WWDG_PRESCALER_16, + WWDG_PRESCALER_32, + WWDG_PRESCALER_64, + WWDG_PRESCALER_128, + } }; + + std::optional SmallestPrescaler(uint32_t timeoutMs) + { + const uint64_t required = static_cast(timeoutMs) * HAL_RCC_GetPCLK1Freq(); + + for (std::size_t exponent = 0; exponent != prescalers.size(); ++exponent) + if (ticksUntilEarlyWarning * wwdgClockDivider * (uint64_t{ 1 } << exponent) * millisecondsPerSecond >= required) + return prescalers[exponent]; + + return std::nullopt; + } + } + + WatchDogFactoryStm::WarningToggle::WarningToggle(hal::Watchdog& watchDog, hal::GpioPin& pin) + : watchDog(watchDog) + , pin(pin) + {} + + infra::Duration WatchDogFactoryStm::WarningToggle::EarlyWarningPeriod() const + { + return watchDog.EarlyWarningPeriod(); + } + + void WatchDogFactoryStm::WarningToggle::Start(const infra::Function& onEarlyWarning) + { + this->onEarlyWarning = onEarlyWarning; + watchDog.Start([this]() + { + pin.Set(!pin.GetOutputLatch()); + this->onEarlyWarning(); + }); + } + + void WatchDogFactoryStm::WarningToggle::Refresh() + { + watchDog.Refresh(); + } + + WatchDogFactoryStm::WatchDogFactoryStm(const services::HilPinNaming& naming, services::HilPinPool& pins) + : naming(naming) + , warningPins(pins, warningPinOwner) + {} + + uint8_t WatchDogFactoryStm::Instances() const + { + return 1; + } + + infra::MemoryRange WatchDogFactoryStm::StartKeys() const + { + return infra::MakeRange(startKeys); + } + + HilStatus WatchDogFactoryStm::Prepare(uint8_t, const services::HilArguments& arguments) + { + Request request; + HilStatus status = Parse(arguments, request); + if (status != HilStatus::done) + return status; + + uint32_t prescaler = WWDG_PRESCALER_1; + return Validate(request, prescaler); + } + + HilStatus WatchDogFactoryStm::Create(uint8_t, infra::Duration timeout, const services::HilArguments& arguments, hal::Watchdog*& created) + { + Request request; + HilStatus status = Parse(arguments, request); + request.timeoutMs = static_cast(std::chrono::duration_cast(timeout).count()); + + uint32_t prescaler = WWDG_PRESCALER_1; + if (status == HilStatus::done) + status = Validate(request, prescaler); + if (status != HilStatus::done) + return status; + + hal::GpioPin* gpio = nullptr; + if (request.pin) + { + status = warningPins.Claim(*request.pin, services::HilPinPool::Use::exclusive, gpio); + if (status != HilStatus::done) + return status; + + gpio->Config(hal::PinConfigType::output, false); + } + + hal::WatchDogStm::Config config; + config.prescaler = prescaler; + created = &watchDog.emplace(config); + if (gpio != nullptr) + created = &toggle.emplace(*watchDog, *gpio); + + return HilStatus::done; + } + + HilStatus WatchDogFactoryStm::Parse(const services::HilArguments& arguments, Request& request) const + { + HilStatus status = HilStatus::done; + arguments.Number("timeout", request.timeoutMs, 1, maximumTimeoutMs, status); + arguments.Pin("pin", naming, request.pin, status); + return status; + } + + HilStatus WatchDogFactoryStm::Validate(const Request& request, uint32_t& prescaler) const + { + // A missing timeout is left to HilWatchDogCommands, which reports it as usage + if (request.timeoutMs != 0) + { + const auto smallest = SmallestPrescaler(request.timeoutMs); + if (!smallest) + return HilStatus::range; + + prescaler = *smallest; + } + + if (request.pin && !warningPins.Pool().Factory().IsValid(*request.pin)) + return HilStatus::pin; + + return HilStatus::done; + } +} diff --git a/validation/firmware/WatchDogFactory.hpp b/validation/firmware/WatchDogFactory.hpp new file mode 100644 index 00000000..effba6ea --- /dev/null +++ b/validation/firmware/WatchDogFactory.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include "hal_st/stm32fxxx/WatchDogStm.hpp" +#include "services/hil/HilPinPool.hpp" +#include "services/hil/commands/HilWatchDogCommands.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include +#include + +namespace validation +{ + class WatchDogFactoryStm + : public services::HilWatchDogFactory + { + public: + WatchDogFactoryStm(const services::HilPinNaming& naming, services::HilPinPool& pins); + + uint8_t Instances() const override; + infra::MemoryRange StartKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + services::HilStatus Create(uint8_t index, infra::Duration timeout, const services::HilArguments& arguments, hal::Watchdog*& watchDog) override; + + private: + class WarningToggle + : public hal::Watchdog + { + public: + WarningToggle(hal::Watchdog& watchDog, hal::GpioPin& pin); + + infra::Duration EarlyWarningPeriod() const override; + void Start(const infra::Function& onEarlyWarning) override; + void Refresh() override; + + private: + hal::Watchdog& watchDog; + hal::GpioPin& pin; + infra::Function onEarlyWarning; + }; + + struct Request + { + uint32_t timeoutMs = 0; + std::optional pin; + }; + + services::HilStatus Parse(const services::HilArguments& arguments, Request& request) const; + services::HilStatus Validate(const Request& request, uint32_t& prescaler) const; + + private: + const services::HilPinNaming& naming; + services::HilPinOwner warningPins; + std::optional watchDog; + std::optional toggle; + }; +} diff --git a/validation/firmware/boards/stm32wb55/BoardProfile.hpp b/validation/firmware/boards/stm32wb55/BoardProfile.hpp new file mode 100644 index 00000000..0ad490e1 --- /dev/null +++ b/validation/firmware/boards/stm32wb55/BoardProfile.hpp @@ -0,0 +1,151 @@ +#pragma once + +#include "hal_st/stm32fxxx/DefaultClockNucleoWB55RG.hpp" +#include "services/hil/HilArguments.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include +#include +#include +#include DEVICE_HEADER + +namespace validation::board +{ + using hal::Port; + + inline constexpr const char* name = "NUCLEO-WB55RG"; + inline constexpr const char* family = "stm32wb55"; + + inline constexpr const char* portLetters = "ABCDEH"; + inline constexpr uint8_t maximumPinIndex = 15; + static_assert(static_cast(Port::H) == 5, "portLetters must follow hal::Port"); + + // The generated pinout table also lists pins of larger packages; only these are bonded out on the VFQFPN68 + inline constexpr std::array bondedPins{ { 0xffff, 0xffff, 0xfc7f, 0x0003, 0x0010, 0x0008 } }; + + inline constexpr UartPins terminal{ 1, false, Pin(Port::B, 6), Pin(Port::B, 7) }; + inline constexpr uint32_t terminalBaudRate = 921600; + inline constexpr DmaRequests terminalDma{ DMA_REQUEST_USART1_TX, DMA_REQUEST_USART1_RX }; + + inline constexpr HilPinId debugLed = Pin(Port::B, 5); + inline constexpr std::array reservedPins{ { + terminal.tx, + terminal.rx, + debugLed, + Pin(Port::A, 13), + Pin(Port::A, 14), + Pin(Port::C, 14), + Pin(Port::C, 15), + Pin(Port::H, 3), + } }; + + inline constexpr std::optional defaultUart = UartPins{ 1, true, Pin(Port::A, 2), Pin(Port::A, 3) }; + inline constexpr QeiPins defaultQei{ 2, Pin(Port::A, 15), Pin(Port::B, 3), Pin(Port::C, 6) }; + + inline constexpr auto aliases = std::to_array({ + { "terminaltx", terminal.tx }, + { "terminalrx", terminal.rx }, + { "ain1", Pin(Port::C, 0) }, + { "ain2", Pin(Port::C, 1) }, + { "ain3", Pin(Port::C, 2) }, + { "ain4", Pin(Port::C, 3) }, + { "ain5", Pin(Port::A, 0) }, + { "ain6", Pin(Port::A, 1) }, + { "tim1ch1", Pin(Port::A, 8) }, + { "tim1ch2", Pin(Port::A, 9) }, + { "tim1ch3", Pin(Port::A, 10) }, + { "tim1ch4", Pin(Port::A, 11) }, + { "tim1ch1n", Pin(Port::A, 7) }, + { "tim1ch2n", Pin(Port::B, 8) }, + { "tim1ch3n", Pin(Port::B, 9) }, + { "tim1bkin", Pin(Port::B, 12) }, + { "tim2ch1", Pin(Port::A, 15) }, + { "tim2ch2", Pin(Port::B, 3) }, + { "tim2ch3", Pin(Port::A, 2) }, + { "tim2ch4", Pin(Port::A, 3) }, + { "tim16ch1", Pin(Port::A, 6) }, + { "tim17ch1", Pin(Port::B, 9) }, + { "qei1a", Pin(Port::A, 8) }, + { "qei1b", Pin(Port::A, 9) }, + { "qei2a", defaultQei.a }, + { "qei2b", defaultQei.b }, + { "qei2idx", defaultQei.idx }, + { "lptim1in1", Pin(Port::C, 0) }, + { "lptim1in2", Pin(Port::C, 2) }, + { "spi1clk", Pin(Port::A, 5) }, + { "spi1miso", Pin(Port::A, 6) }, + { "spi1mosi", Pin(Port::A, 7) }, + { "spi1cs", Pin(Port::A, 4) }, + { "lpuart1tx", defaultUart->tx }, + { "lpuart1rx", defaultUart->rx }, + { "lpuart1rts", Pin(Port::B, 12) }, + { "lpuart1cts", Pin(Port::A, 6) }, + { "led0", Pin(Port::B, 0) }, + { "led1", Pin(Port::B, 1) }, + { "gpio0", Pin(Port::C, 6) }, + { "gpio1", Pin(Port::C, 10) }, + { "gpio2", Pin(Port::C, 12) }, + { "gpio3", Pin(Port::C, 13) }, + { "gpio4", Pin(Port::E, 4) }, + { "sw1", Pin(Port::C, 4) }, + { "sw2", Pin(Port::D, 0) }, + { "sw3", Pin(Port::D, 1) }, + }); + + inline constexpr uint8_t uartDmaChannel = 3; + inline constexpr uint8_t spiDmaChannel = 5; + inline constexpr uint8_t adcDmaChannel = 7; + + constexpr std::optional UartDma(uint8_t index, bool lpuart) + { + if (index != 1) + return std::nullopt; + + if (lpuart) + return DmaRequests{ DMA_REQUEST_LPUART1_TX, DMA_REQUEST_LPUART1_RX }; + + return DmaRequests{ DMA_REQUEST_USART1_TX, DMA_REQUEST_USART1_RX }; + } + + constexpr std::optional SpiDma(uint8_t index) + { + switch (index) + { + case 1: + return DmaRequests{ DMA_REQUEST_SPI1_TX, DMA_REQUEST_SPI1_RX }; + case 2: + return DmaRequests{ DMA_REQUEST_SPI2_TX, DMA_REQUEST_SPI2_RX }; + default: + return std::nullopt; + } + } + + inline constexpr uint8_t adc = 1; + inline constexpr uint8_t adcDmaRequest = DMA_REQUEST_ADC1; + inline constexpr std::array adcTriggerTimers{ { 1, 2 } }; + inline constexpr uint32_t adcDefaultSamplingTime = ADC_SAMPLETIME_2CYCLES_5; + inline constexpr std::array, 8> adcSamplingTimes{ { + { "2.5", ADC_SAMPLETIME_2CYCLES_5 }, + { "6.5", ADC_SAMPLETIME_6CYCLES_5 }, + { "12.5", ADC_SAMPLETIME_12CYCLES_5 }, + { "24.5", ADC_SAMPLETIME_24CYCLES_5 }, + { "47.5", ADC_SAMPLETIME_47CYCLES_5 }, + { "92.5", ADC_SAMPLETIME_92CYCLES_5 }, + { "247.5", ADC_SAMPLETIME_247CYCLES_5 }, + { "640.5", ADC_SAMPLETIME_640CYCLES_5 }, + } }; + + inline uint32_t UartKernelClock(uint8_t, bool lpuart) + { + return HAL_RCCEx_GetPeriphCLKFreq(lpuart ? RCC_PERIPHCLK_LPUART1 : RCC_PERIPHCLK_USART1); + } + + inline uint32_t SpiKernelClock(uint8_t index) + { + return index == 1 ? HAL_RCC_GetPCLK2Freq() : HAL_RCC_GetPCLK1Freq(); + } + + inline void InitializeClocks() + { + ConfigureDefaultClockNucleoWB55RG(); + } +} diff --git a/validation/firmware/boards/stm32wba55/BoardProfile.hpp b/validation/firmware/boards/stm32wba55/BoardProfile.hpp new file mode 100644 index 00000000..ff60ee63 --- /dev/null +++ b/validation/firmware/boards/stm32wba55/BoardProfile.hpp @@ -0,0 +1,164 @@ +#pragma once + +#include "hal_st/stm32fxxx/DefaultClockNucleoWBA55CG.hpp" +#include "services/hil/HilArguments.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include +#include +#include +#include DEVICE_HEADER + +namespace validation::board +{ + using hal::Port; + + inline constexpr const char* name = "NUCLEO-WBA55CG"; + inline constexpr const char* family = "stm32wba55"; + + inline constexpr const char* portLetters = "ABCH"; + inline constexpr uint8_t maximumPinIndex = 15; + static_assert(static_cast(Port::H) == 3, "portLetters must follow hal::Port"); + + inline constexpr std::array bondedPins{ { 0xffef, 0xffff, 0xe000, 0x0008 } }; + + inline constexpr UartPins terminal{ 1, false, Pin(Port::B, 12), Pin(Port::A, 8) }; + inline constexpr uint32_t terminalBaudRate = 921600; + inline constexpr DmaRequests terminalDma{ GPDMA1_REQUEST_USART1_TX, GPDMA1_REQUEST_USART1_RX }; + + // Red LD3: the blue LD1 is the SPI1 clock pin, and the green LD2 needs a solder bridge on some board revisions + inline constexpr HilPinId debugLed = Pin(Port::B, 8); + inline constexpr std::array reservedPins{ { + terminal.tx, + terminal.rx, + debugLed, + Pin(Port::A, 13), + Pin(Port::A, 14), + Pin(Port::C, 14), + Pin(Port::C, 15), + Pin(Port::H, 3), + } }; + + inline constexpr std::optional defaultUart = UartPins{ 1, true, Pin(Port::B, 5), Pin(Port::A, 10) }; + inline constexpr QeiPins defaultQei{ 1, Pin(Port::A, 11), Pin(Port::A, 12), Pin(Port::A, 15) }; + + inline constexpr auto aliases = std::to_array({ + { "terminaltx", terminal.tx }, + { "terminalrx", terminal.rx }, + { "ain2", Pin(Port::A, 7) }, + { "ain3", Pin(Port::A, 6) }, + { "ain4", Pin(Port::A, 5) }, + { "ain7", Pin(Port::A, 2) }, + { "ain8", Pin(Port::A, 1) }, + { "ain9", Pin(Port::A, 0) }, + { "ain10", Pin(Port::B, 9) }, + { "tim1ch1", defaultQei.a }, + { "tim1ch2", defaultQei.b }, + { "tim1ch3", Pin(Port::B, 4) }, + { "tim1ch4", Pin(Port::B, 3) }, + { "tim1ch1n", Pin(Port::B, 2) }, + { "tim1ch2n", Pin(Port::B, 1) }, + { "tim1ch3n", Pin(Port::B, 0) }, + { "tim1bkin", Pin(Port::A, 2) }, + { "tim2ch1", Pin(Port::A, 5) }, + { "tim2ch3", Pin(Port::A, 7) }, + { "tim2ch4", Pin(Port::A, 6) }, + { "tim3ch1", Pin(Port::A, 10) }, + { "tim3ch2", Pin(Port::A, 1) }, + { "tim3ch3", Pin(Port::B, 14) }, + { "tim3ch4", Pin(Port::B, 9) }, + { "tim16ch1", Pin(Port::B, 9) }, + { "tim17ch1", Pin(Port::A, 1) }, + { "tim17ch1n", Pin(Port::B, 3) }, + { "qei1a", defaultQei.a }, + { "qei1b", defaultQei.b }, + { "qei1idx", defaultQei.idx }, + { "qei3a", Pin(Port::A, 10) }, + { "qei3b", Pin(Port::A, 1) }, + { "spi1clk", Pin(Port::B, 4) }, + { "spi1miso", Pin(Port::B, 3) }, + { "spi1mosi", Pin(Port::A, 15) }, + { "spi1cs", Pin(Port::A, 12) }, + { "lpuart1tx", defaultUart->tx }, + { "lpuart1rx", defaultUart->rx }, + { "lpuart1rts", Pin(Port::B, 9) }, + { "lpuart1cts", Pin(Port::B, 15) }, + { "usart2tx", Pin(Port::B, 0) }, + { "usart2rx", Pin(Port::A, 11) }, + { "usart2rts", Pin(Port::B, 1) }, + { "usart2cts", Pin(Port::B, 2) }, + { "led0", Pin(Port::B, 4) }, + { "led1", Pin(Port::A, 9) }, + { "gpio0", Pin(Port::B, 14) }, + { "gpio1", Pin(Port::A, 5) }, + { "gpio2", Pin(Port::A, 0) }, + { "sw1", Pin(Port::C, 13) }, + { "sw2", Pin(Port::B, 6) }, + { "sw3", Pin(Port::B, 7) }, + }); + + inline constexpr uint8_t uartDmaChannel = 3; + inline constexpr uint8_t spiDmaChannel = 5; + inline constexpr uint8_t adcDmaChannel = 7; + + constexpr std::optional UartDma(uint8_t index, bool lpuart) + { + if (lpuart) + return index == 1 ? std::make_optional(DmaRequests{ GPDMA1_REQUEST_LPUART1_TX, GPDMA1_REQUEST_LPUART1_RX }) : std::nullopt; + + switch (index) + { + case 1: + return DmaRequests{ GPDMA1_REQUEST_USART1_TX, GPDMA1_REQUEST_USART1_RX }; + case 2: + return DmaRequests{ GPDMA1_REQUEST_USART2_TX, GPDMA1_REQUEST_USART2_RX }; + default: + return std::nullopt; + } + } + + constexpr std::optional SpiDma(uint8_t index) + { + switch (index) + { + case 1: + return DmaRequests{ GPDMA1_REQUEST_SPI1_TX, GPDMA1_REQUEST_SPI1_RX }; + case 3: + return DmaRequests{ GPDMA1_REQUEST_SPI3_TX, GPDMA1_REQUEST_SPI3_RX }; + default: + return std::nullopt; + } + } + + inline constexpr uint8_t adc = 4; + inline constexpr uint8_t adcDmaRequest = GPDMA1_REQUEST_ADC4; + inline constexpr std::array adcTriggerTimers{ { 1, 2 } }; + inline constexpr uint32_t adcDefaultSamplingTime = ADC_SAMPLETIME_3CYCLES_5; + inline constexpr std::array, 8> adcSamplingTimes{ { + { "1.5", ADC_SAMPLETIME_1CYCLE_5 }, + { "3.5", ADC_SAMPLETIME_3CYCLES_5 }, + { "7.5", ADC_SAMPLETIME_7CYCLES_5 }, + { "12.5", ADC_SAMPLETIME_12CYCLES_5 }, + { "19.5", ADC_SAMPLETIME_19CYCLES_5 }, + { "39.5", ADC_SAMPLETIME_39CYCLES_5 }, + { "79.5", ADC_SAMPLETIME_79CYCLES_5 }, + { "814.5", ADC_SAMPLETIME_814CYCLES_5 }, + } }; + + inline uint32_t UartKernelClock(uint8_t index, bool lpuart) + { + if (lpuart) + return HAL_RCCEx_GetPeriphCLKFreq(RCC_PERIPHCLK_LPUART1); + + return HAL_RCCEx_GetPeriphCLKFreq(index == 1 ? RCC_PERIPHCLK_USART1 : RCC_PERIPHCLK_USART2); + } + + inline uint32_t SpiKernelClock(uint8_t index) + { + return HAL_RCCEx_GetPeriphCLKFreq(index == 1 ? RCC_PERIPHCLK_SPI1 : RCC_PERIPHCLK_SPI3); + } + + inline void InitializeClocks() + { + ConfigureDefaultClockNucleoWBA55CG(); + } +} From fa1142fa13ebefbb76fca819abdab8da4819d9a2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 05:40:20 +0000 Subject: [PATCH 04/57] fix(validation): harden the SPI, UART, ADC and encoder factories after review - SPI: close waits up to 10 ms for an in-flight transfer before destroying the driver; SpiMasterStm schedules its completion with no stale-event guard, and a WB55 DMA channel left running ignores the next open - UART: USART1 answers busy only after the argument checks, so argument errors keep coming first - ADC: constant-time trigger timing instead of a prescaler search that blocked the event loop; interrupts stay masked through a triggered stop; rate without timer is a usage error before range checks - encoder: lp=1 rejects cap/offset/invb before parsing their values Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- validation/firmware/AdcFactory.cpp | 45 ++++--------- validation/firmware/QeiFactory.cpp | 8 +-- validation/firmware/SpiFactory.cpp | 101 ++++++++++++++++++++++++---- validation/firmware/SpiFactory.hpp | 30 +++++++++ validation/firmware/UartFactory.cpp | 43 +++++++++--- 5 files changed, 167 insertions(+), 60 deletions(-) diff --git a/validation/firmware/AdcFactory.cpp b/validation/firmware/AdcFactory.cpp index bf3f6e5a..46f221ba 100644 --- a/validation/firmware/AdcFactory.cpp +++ b/validation/firmware/AdcFactory.cpp @@ -6,7 +6,6 @@ #include "validation/firmware/PeripheralClocks.hpp" #include "validation/firmware/PinFactoryStm.hpp" #include -#include #include #include DEVICE_HEADER @@ -20,10 +19,9 @@ namespace validation constexpr uint32_t defaultRate = 1000; constexpr uint32_t maximumRate = 100000; - constexpr uint32_t maximumPrescale = 1u << 16; constexpr uint64_t minimumTicks = 2; - // A Stop from the event loop must not interleave with the DMA interrupt, which disables the ADC on its own + // A Stop from the event loop must not interleave with the DMA interrupt, which disables the ADC itself in one-shot mode and stops a timer-triggered sequence from EMIL's last-run callback class InterruptsDisabled { public: @@ -55,38 +53,18 @@ namespace validation return a > b ? a - b : b - a; } + // The smallest prescaler that fits is at most 1 / maximumTicks off the best divider; searching every prescaler for that one blocks the event loop for tens of milliseconds hal::TimerBaseStm::Timing TriggerTiming(uint8_t timer, uint32_t rate) { - const uint32_t clock = TimerClock(timer); + const uint64_t clock = TimerClock(timer); const uint64_t maximumTicks = IS_TIM_32B_COUNTER_INSTANCE(hal::peripheralTimer[timer - 1]) ? uint64_t{ 1 } << 32 : uint64_t{ 1 } << 16; - uint32_t bestPrescale = 1; - uint64_t bestTicks = minimumTicks; - uint64_t bestError = Distance(clock, rate * minimumTicks); + const uint64_t prescale = clock / (rate * (maximumTicks + 1)) + 1; + const uint64_t floorTicks = std::max(clock / (prescale * rate), minimumTicks); + const uint64_t ceilTicks = std::min(floorTicks + 1, maximumTicks); + const bool roundUp = Distance(clock, prescale * ceilTicks * rate) * floorTicks < Distance(clock, prescale * floorTicks * rate) * ceilTicks; - for (uint32_t prescale = 1; prescale <= maximumPrescale && prescale * minimumTicks * rate <= clock && bestError != 0; ++prescale) - { - const uint32_t floorTicks = clock / (prescale * rate); - - for (const uint64_t ticks : { uint64_t{ floorTicks }, uint64_t{ floorTicks } + 1 }) - { - const uint64_t divider = prescale * ticks; - const uint64_t error = Distance(clock, divider * rate); - - if (ticks <= maximumTicks && error * (bestPrescale * bestTicks) < bestError * divider) - { - bestPrescale = prescale; - bestTicks = ticks; - bestError = error; - } - } - - // Without prescaling every divider up to maximumTicks is available, so the two around the ideal one cannot be beaten - if (prescale == 1 && floorTicks < maximumTicks) - break; - } - - return { bestPrescale - 1, static_cast(bestTicks - 1) }; + return { static_cast(prescale - 1), static_cast((roundUp ? ceilTicks : floorTicks) - 1) }; } } @@ -119,6 +97,7 @@ namespace validation void AdcFactoryStm::TriggeredSequence::Stop() { + InterruptsDisabled interruptsDisabled; Sequence::Stop(); StopTimer(); measuring = false; @@ -256,6 +235,9 @@ namespace validation HilStatus AdcFactoryStm::Parse(const services::HilArguments& arguments, Request& request) const { + if (arguments.Has("rate") && !arguments.Has("timer")) + return HilStatus::usage; + HilStatus status = HilStatus::done; request.samplingTime = board::adcDefaultSamplingTime; request.rate = defaultRate; @@ -271,9 +253,6 @@ namespace validation if (status != HilStatus::done) return status; - if (arguments.Has("rate") && !request.timer) - return HilStatus::usage; - status = ParsePins(arguments, request); if (status != HilStatus::done) return status; diff --git a/validation/firmware/QeiFactory.cpp b/validation/firmware/QeiFactory.cpp index 4d164895..a8552bf2 100644 --- a/validation/firmware/QeiFactory.cpp +++ b/validation/firmware/QeiFactory.cpp @@ -207,6 +207,9 @@ namespace validation HilStatus QeiFactoryStm::ParseSettings(uint8_t timer, const services::HilArguments& arguments, Request& request) const { + if (request.lowPower && (arguments.Has("cap") || arguments.Has("offset") || arguments.Has("invb"))) + return HilStatus::unsupported; + auto& config = request.config; HilStatus status = HilStatus::done; @@ -234,9 +237,6 @@ namespace validation if (status != HilStatus::done) return status; - if (request.lowPower && (arguments.Has("cap") || arguments.Has("offset") || arguments.Has("invb"))) - return HilStatus::unsupported; - if (request.lowPower && !IsLowPowerFilter(config.filter)) return HilStatus::range; @@ -306,7 +306,7 @@ namespace validation , context(context) , factory(factory) , commands{ { - services::HilBind("qei.index", "qei.index ", *this, context.response), + services::HilBind("qei.index", "", *this, context.response), } } {} diff --git a/validation/firmware/SpiFactory.cpp b/validation/firmware/SpiFactory.cpp index d26c6771..55645508 100644 --- a/validation/firmware/SpiFactory.cpp +++ b/validation/firmware/SpiFactory.cpp @@ -1,8 +1,10 @@ #include "validation/firmware/SpiFactory.hpp" #include "BoardProfile.hpp" #include "generated/stm32fxxx/PeripheralTable.hpp" +#include "infra/event/EventDispatcher.hpp" #include "validation/firmware/PinFactoryStm.hpp" #include +#include #include #include @@ -12,6 +14,9 @@ namespace validation { using services::HilStatus; + // Longer than the slowest 64-byte transfer (2.05 ms at 250 kHz on WB55); one still running by then never completes + constexpr infra::Duration quiesceTimeout = std::chrono::milliseconds(10); + constexpr std::array openKeys{ { "clk", "mosi", "miso", "cs", "baud", "mode", "dma", "sync" } }; constexpr std::array baudRatePrescalers{ { @@ -47,16 +52,53 @@ namespace validation { return SupportsFunction(clock, hal::PinConfigTypeStm::spiClock, index) && SupportsFunction(mosi, hal::PinConfigTypeStm::spiMosi, index) && SupportsFunction(miso, hal::PinConfigTypeStm::spiMiso, index); } + } - template - Config MakeConfig(const auto& request) - { - Config config; - config.polarityLow = (request.mode & 2) == 0; - config.phase1st = (request.mode & 1) == 0; - config.baudRatePrescaler = request.baudRatePrescaler; - return config; - } + template + Config SpiFactoryStm::MakeConfig(const Request& request) + { + Config config; + config.polarityLow = (request.mode & 2) == 0; + config.phase1st = (request.mode & 1) == 0; + config.baudRatePrescaler = request.baudRatePrescaler; + return config; + } + + SpiFactoryStm::TrackedSpiMaster::TrackedSpiMaster(hal::SpiMaster& spi, const infra::Function& onIdle) + : spi(spi) + , onIdle(onIdle) + {} + + void SpiFactoryStm::TrackedSpiMaster::SendAndReceive(infra::ConstByteRange sendData, infra::ByteRange receiveData, hal::SpiAction nextAction, const infra::Function& onDone) + { + busy = true; + this->onDone = onDone; + spi.SendAndReceive(sendData, receiveData, nextAction, [this]() + { + busy = false; + this->onDone(); + onIdle(); + }); + } + + void SpiFactoryStm::TrackedSpiMaster::SetChipSelectConfigurator(hal::ChipSelectConfigurator& configurator) + { + spi.SetChipSelectConfigurator(configurator); + } + + void SpiFactoryStm::TrackedSpiMaster::SetCommunicationConfigurator(hal::CommunicationConfigurator& configurator) + { + spi.SetCommunicationConfigurator(configurator); + } + + void SpiFactoryStm::TrackedSpiMaster::ResetCommunicationConfigurator() + { + spi.ResetCommunicationConfigurator(); + } + + bool SpiFactoryStm::TrackedSpiMaster::Busy() const + { + return busy; } SpiFactoryStm::SpiFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma) @@ -97,9 +139,39 @@ namespace validation } void SpiFactoryStm::Close(uint8_t, const infra::Function& onClosed) + { + this->onClosed = onClosed; + + // SpiMasterStm schedules its completion from the interrupt with no stale-event guard, and a WB55 DMA + // channel left enabled ignores the next open's count and address: let a running transfer finish first + if (tracked && tracked->Busy()) + quiesceTimer.Start(quiesceTimeout, [this]() + { + Destroy(); + }); + else + Destroy(); + } + + void SpiFactoryStm::TransferDone() + { + if (!quiesceTimer.Armed()) + return; + + quiesceTimer.Cancel(); + + // Called from inside the driver's completion, which must not destroy its own driver + infra::EventDispatcher::Instance().Schedule([this]() + { + Destroy(); + }); + } + + void SpiFactoryStm::Destroy() { // The driver goes first: an end-of-transfer interrupt must not reach a destroyed chip-select wrapper driver.emplace(); + tracked.reset(); chipSelect.reset(); synchronousChipSelect.reset(); receiveStream.reset(); @@ -181,12 +253,15 @@ namespace validation return; } - auto& spi = ConstructAsynchronous(index, request, claimed); + hal::SpiMaster* spi = &ConstructAsynchronous(index, request, claimed); if (claimed.chipSelect != nullptr) - handle.spi = &chipSelect.emplace(spi, *claimed.chipSelect); - else - handle.spi = &spi; + spi = &chipSelect.emplace(*spi, *claimed.chipSelect); + + handle.spi = &tracked.emplace(*spi, [this]() + { + TransferDone(); + }); } hal::SpiMaster& SpiFactoryStm::ConstructAsynchronous(uint8_t index, const Request& request, const ClaimedPins& claimed) diff --git a/validation/firmware/SpiFactory.hpp b/validation/firmware/SpiFactory.hpp index 412b4d5e..33fa7a56 100644 --- a/validation/firmware/SpiFactory.hpp +++ b/validation/firmware/SpiFactory.hpp @@ -4,6 +4,8 @@ #include "hal_st/stm32fxxx/SpiMasterStm.hpp" #include "hal_st/stm32fxxx/SpiMasterStmDma.hpp" #include "hal_st/synchronous_stm32fxxx/SynchronousSpiMasterStm.hpp" +#include "infra/timer/Timer.hpp" +#include "infra/util/AutoResetFunction.hpp" #include "services/hil/commands/HilSpiCommands.hpp" #include "services/peripheral/SpiMasterWithChipSelect.hpp" #include "services/synchronous_peripheral/SynchronousSpiMasterWithChipSelect.hpp" @@ -48,11 +50,36 @@ namespace validation hal::GpioPin* chipSelect = nullptr; }; + class TrackedSpiMaster + : public hal::SpiMaster + { + public: + TrackedSpiMaster(hal::SpiMaster& spi, const infra::Function& onIdle); + + void SendAndReceive(infra::ConstByteRange sendData, infra::ByteRange receiveData, hal::SpiAction nextAction, const infra::Function& onDone) override; + void SetChipSelectConfigurator(hal::ChipSelectConfigurator& configurator) override; + void SetCommunicationConfigurator(hal::CommunicationConfigurator& configurator) override; + void ResetCommunicationConfigurator() override; + + bool Busy() const; + + private: + hal::SpiMaster& spi; + infra::Function onIdle; + infra::AutoResetFunction onDone; + bool busy = false; + }; + + template + static Config MakeConfig(const Request& request); + services::HilStatus Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const; services::HilStatus Parse(const services::HilArguments& arguments, Request& request) const; services::HilStatus Claim(uint8_t index, const Request& request, services::HilPinOwner& pins, ClaimedPins& claimed) const; void Construct(uint8_t index, const Request& request, const ClaimedPins& claimed, services::HilSpiHandle& handle); hal::SpiMaster& ConstructAsynchronous(uint8_t index, const Request& request, const ClaimedPins& claimed); + void TransferDone(); + void Destroy(); private: const services::HilPinNaming& naming; @@ -62,5 +89,8 @@ namespace validation std::variant driver; std::optional chipSelect; std::optional synchronousChipSelect; + std::optional tracked; + infra::TimerSingleShot quiesceTimer; + infra::AutoResetFunction onClosed; }; } diff --git a/validation/firmware/UartFactory.cpp b/validation/firmware/UartFactory.cpp index 1ad842f8..8d0c9e60 100644 --- a/validation/firmware/UartFactory.cpp +++ b/validation/firmware/UartFactory.cpp @@ -3,6 +3,8 @@ #include "generated/stm32fxxx/PeripheralTable.hpp" #include "validation/firmware/PinFactoryStm.hpp" #include +#include +#include namespace validation { @@ -30,7 +32,7 @@ namespace validation constexpr uint32_t minimumBaudRate = 300; constexpr uint32_t maximumBaudRate = 12000000; - // The HAL keeps its BRR lower limits (UART_BRR_MIN, LPUART_BRR_MIN) private to *_hal_uart.c + // The HAL keeps UART_BRR_MIN and LPUART_BRR_MIN private to *_hal_uart.c constexpr uint32_t usartMinimumDivider = 0x10; constexpr uint32_t lpuartMinimumDivider = 0x300; @@ -74,6 +76,28 @@ namespace validation return index >= 1 && index <= table.size() && table[index - 1] != nullptr; } + bool Selects(const UartPins& pins, uint8_t index, bool lpuart) + { + return pins.index == index && pins.lpuart == lpuart; + } + + bool IsTerminal(uint8_t index, bool lpuart) + { + return Selects(board::terminal, index, lpuart); + } + + // The terminal defaults to its own pins, so a bare uart.open of it passes every argument check and answers busy + std::optional DefaultPins(uint8_t index, bool lpuart) + { + if (IsTerminal(index, lpuart)) + return board::terminal; + + if (board::defaultUart && Selects(*board::defaultUart, index, lpuart)) + return board::defaultUart; + + return std::nullopt; + } + bool BaudRateFits(uint8_t index, bool lpuart, uint32_t baud) { if (!IS_UART_BAUDRATE(baud)) @@ -193,15 +217,11 @@ namespace validation if (!BaudRateFits(index, request.lpuart, request.baud)) return HilStatus::range; - // The terminal has no default pins: checked after this, a bare uart.open of it would answer usage instead of busy - if (index == board::terminal.index && request.lpuart == board::terminal.lpuart) - return HilStatus::busy; - - const bool noPins = !request.tx && !request.rx && !request.rts && !request.cts; - if (noPins && board::defaultUart && board::defaultUart->index == index && board::defaultUart->lpuart == request.lpuart) + const auto defaults = DefaultPins(index, request.lpuart); + if (defaults && !request.tx && !request.rx && !request.rts && !request.cts) { - request.tx = board::defaultUart->tx; - request.rx = board::defaultUart->rx; + request.tx = defaults->tx; + request.rx = defaults->rx; } if (!request.tx || !request.rx) @@ -210,6 +230,9 @@ namespace validation if (!PinsSupportFunctions(index, FunctionsOf(request.lpuart), *request.tx, *request.rx, request.rts, request.cts)) return HilStatus::pin; + if (IsTerminal(index, request.lpuart)) + return HilStatus::busy; + return HilStatus::done; } @@ -270,7 +293,7 @@ namespace validation transmitStream.emplace(dma, hal::DmaChannelId(1, board::uartDmaChannel, requests.transmit)); if (request.duplex) - receiveStream.emplace(dma, hal::DmaChannelId(1, board::uartDmaChannel + 1, requests.receive)); + receiveStream.emplace(dma, hal::DmaChannelId(1, static_cast(board::uartDmaChannel + 1), requests.receive)); } if (request.duplex) From cfcabcf6d6ecfc650a79f69fe039537cb06edb6b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 05:40:20 +0000 Subject: [PATCH 05/57] feat(validation): add the hal_st_validation host package and pytest suite Port hal-ti's validation host to hal-st: a Python package that drives the validation firmware terminal and a Digilent Analog Discovery 3 through ad3-waveforms-bench (pinned to the same commit as hal-ti). - board files for NUCLEO-WB55RG and NUCLEO-WBA55CG: alias tables, clocks, AD3 wiring sets (bundle1, plus bundle2 for the WB55 LPTIM encoder) and every test parameter matrix - typed firmware API, STM32 expectation math (PWM quantisation and range, SPI prescaler, UART baud-rate register limits, WWDG period, ADC codes), pairwise/full parameter depth, and a fake firmware for --fake runs - HIL tests for system, GPIO, PWM, UART, SPI, ADC, encoder, watchdog and the unsupported groups; unit tests for the harness itself - known_gaps in the board files: driver bugs found while writing the firmware skip (when they abort or hang the board) or xfail the tests they affect; --run-known-gaps runs them anyway - validation/README.md: setup, wiring tables, running, bridge mode and the known driver gaps; AGENTS.md and CLAUDE.md point to the new app Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- AGENTS.md | 5 +- CLAUDE.md | 2 +- validation/README.md | 330 +++++ validation/host/.gitignore | 5 + validation/host/boards/nucleo_wb55rg.yaml | 665 +++++++++ validation/host/boards/nucleo_wba55cg.yaml | 674 +++++++++ validation/host/hal_st_validation/__init__.py | 24 + validation/host/hal_st_validation/config.py | 384 ++++++ validation/host/hal_st_validation/console.py | 27 + validation/host/hal_st_validation/expect.py | 357 +++++ .../host/hal_st_validation/fake_firmware.py | 1200 +++++++++++++++++ validation/host/hal_st_validation/firmware.py | 451 +++++++ validation/host/hal_st_validation/pairwise.py | 144 ++ validation/host/hal_st_validation/protocol.py | 102 ++ validation/host/pyproject.toml | 55 + validation/host/tests/conftest.py | 354 +++++ validation/host/tests/hil/test_adc.py | 214 +++ validation/host/tests/hil/test_gpio.py | 212 +++ validation/host/tests/hil/test_pwm.py | 419 ++++++ validation/host/tests/hil/test_qei.py | 246 ++++ validation/host/tests/hil/test_spi.py | 207 +++ validation/host/tests/hil/test_system.py | 151 +++ validation/host/tests/hil/test_uart.py | 420 ++++++ validation/host/tests/hil/test_unsupported.py | 11 + validation/host/tests/hil/test_watchdog.py | 166 +++ validation/host/tests/unit/test_config.py | 339 +++++ validation/host/tests/unit/test_expect.py | 336 +++++ .../host/tests/unit/test_fake_firmware.py | 981 ++++++++++++++ validation/host/tests/unit/test_firmware.py | 204 +++ validation/host/tests/unit/test_known_gaps.py | 167 +++ validation/host/tests/unit/test_pairwise.py | 91 ++ validation/host/tests/unit/test_protocol.py | 126 ++ .../tests/unit/test_terminal_alignment.py | 61 + 33 files changed, 9128 insertions(+), 2 deletions(-) create mode 100644 validation/README.md create mode 100644 validation/host/.gitignore create mode 100644 validation/host/boards/nucleo_wb55rg.yaml create mode 100644 validation/host/boards/nucleo_wba55cg.yaml create mode 100644 validation/host/hal_st_validation/__init__.py create mode 100644 validation/host/hal_st_validation/config.py create mode 100644 validation/host/hal_st_validation/console.py create mode 100644 validation/host/hal_st_validation/expect.py create mode 100644 validation/host/hal_st_validation/fake_firmware.py create mode 100644 validation/host/hal_st_validation/firmware.py create mode 100644 validation/host/hal_st_validation/pairwise.py create mode 100644 validation/host/hal_st_validation/protocol.py create mode 100644 validation/host/pyproject.toml create mode 100644 validation/host/tests/conftest.py create mode 100644 validation/host/tests/hil/test_adc.py create mode 100644 validation/host/tests/hil/test_gpio.py create mode 100644 validation/host/tests/hil/test_pwm.py create mode 100644 validation/host/tests/hil/test_qei.py create mode 100644 validation/host/tests/hil/test_spi.py create mode 100644 validation/host/tests/hil/test_system.py create mode 100644 validation/host/tests/hil/test_uart.py create mode 100644 validation/host/tests/hil/test_unsupported.py create mode 100644 validation/host/tests/hil/test_watchdog.py create mode 100644 validation/host/tests/unit/test_config.py create mode 100644 validation/host/tests/unit/test_expect.py create mode 100644 validation/host/tests/unit/test_fake_firmware.py create mode 100644 validation/host/tests/unit/test_firmware.py create mode 100644 validation/host/tests/unit/test_known_gaps.py create mode 100644 validation/host/tests/unit/test_pairwise.py create mode 100644 validation/host/tests/unit/test_protocol.py create mode 100644 validation/host/tests/unit/test_terminal_alignment.py diff --git a/AGENTS.md b/AGENTS.md index d559e91d..ed87d52a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ hal-st is a Hardware Abstraction Layer for ST ARM Cortex-M microcontrollers (F4, - `st/` — CMSIS headers, STM32 HAL driver sources (per family), `hal_conf/`, `ldscripts/` - `services/st_util/` — ST bootloader communicator services - `integration_test/` — hardware-in-the-loop cucumber test rig (`pcb/`, `flasher/`, `tester/`, `tested/`, `runner/`, `logic/`) +- `validation/` — hardware-in-the-loop validation app (NUCLEO-WB55RG, NUCLEO-WBA55CG): `firmware/` (target `hal_st.validation_firmware`, every driver behind EMIL's `services/hil` terminal), `host/` (Python package `hal_st_validation` + pytest suite driving the firmware and a Digilent Analog Discovery 3); command set in `validation/PROTOCOL.md` - `examples/` — `blink`, `helloworld`, `sesame`, `freertos`, `ble_peripheral`, `ble_central` ## Memory — no heap @@ -54,7 +55,7 @@ Full detail lives in `.github/instructions/hal-st-cpp.instructions.md` — read ## Testing -No unit tests in this repo. hal-st is validated by manual testing on Nucleo/Discovery boards, logic-analyser/scope verification, and the `integration_test/` hardware-in-the-loop rig — not by GoogleTest suites. Don't add unit tests for new or changed drivers. (`services/st_util/test/` is a pre-existing exception gated behind `HALST_BUILD_TESTS`; leave it as-is, don't extend the pattern elsewhere.) +No unit tests in this repo. hal-st is validated on real hardware — the `validation/` app (firmware + pytest/AD3 host suite, see `validation/README.md`), manual testing on Nucleo/Discovery boards, logic-analyser/scope verification and the `integration_test/` rig — not by GoogleTest suites. A driver change on STM32WB55/WBA55 should keep `validation/PROTOCOL.md`, the firmware factory and the host tests in step. `validation/host/tests/unit` tests the host harness itself (`pytest validation/host/tests/unit`), not the drivers. Don't add unit tests for new or changed drivers. (`services/st_util/test/` is a pre-existing exception gated behind `HALST_BUILD_TESTS`; leave it as-is, don't extend the pattern elsewhere.) ## Build @@ -65,6 +66,8 @@ cmake --preset stm32f407 && cmake --build --preset stm32f407-RelWithDebInfo # Other target presets: `stm32wb55`, `stm32g070`, `stm32g431`, `stm32f429`, `stm32f746`, `stm32f767`, `stm32g474`, `stm32wba52`, `stm32wba55`, `stm32wba65`, `stm32h563`, `stm32h573`. +Validation firmware (stm32wb55, stm32wba55): `cmake --build --preset stm32wb55-RelWithDebInfo --target hal_st.validation_firmware`. + ## Assistant behavior — be terse - Minimal prose. No preamble/postamble, no restating the plan, no summaries unless asked diff --git a/CLAUDE.md b/CLAUDE.md index 66f0a140..a08e15cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,6 +7,6 @@ No heap — bounded containers / std::array / std::optional; no recursion in dri STM32 HAL/LL — HAL_*/LL_* only, never raw registers; HAL_FOO_Init/DeInit in ctor/dtor (RAII); InterruptHandler/DispatchedInterruptHandler, never NVIC_EnableIRQ directly; PeripheralPinStm for AF pins; DMA_STREAM_BASED vs DMA_CHANNEL_BASED wrappers. Driver Config — inner Config struct, mandatory constexpr Config() {}, oneBasedIndex convention, HAS_PERIPHERAL_xxx guards from generated PeripheralTable.hpp (never hand-edit generated/). Style — Allman braces, 4-space, PascalCase types/methods, camelCase members. No comments except non-obvious why. -No tests — hal-st has no unit test suite; validation is on real hardware (Nucleo/Discovery, logic analyser) and integration_test/. Don't add unit tests for driver changes. +No tests — hal-st has no unit test suite; validation is on real hardware: validation/ (HIL firmware + pytest/AD3 host suite, WB55/WBA55), Nucleo/Discovery boards, logic analyser and integration_test/. Don't add unit tests for driver changes. No exceptions — std::optional/status enums; interfaces virtual ~I() = default. Be terse — minimal prose; report file paths + build pass/fail. diff --git a/validation/README.md b/validation/README.md new file mode 100644 index 00000000..059a97d9 --- /dev/null +++ b/validation/README.md @@ -0,0 +1,330 @@ +# Hardware-in-the-loop validation + +This directory validates the hal-st drivers on real hardware: a NUCLEO-WB55RG or NUCLEO-WBA55CG runs a validation firmware, and a host PC drives that firmware and a Digilent Analog Discovery 3 (AD3) to stimulate and measure every peripheral. +Every driver is exercised generically, with each variant it has (interrupt, DMA, synchronous, low-power instance) and with every option the firmware exposes. +The structure follows hal-ti's `validation/`; the firmware runs on EMIL's hardware-in-the-loop terminal and the host reuses the generic bench code of ad3-waveforms-bench. + +- `firmware/` - C++ firmware on hal-st and EMIL. It exposes the hal-st peripherals through a line-based terminal; the command set is specified in [PROTOCOL.md](PROTOCOL.md). +- `host/` - Python package `hal_st_validation` and a pytest suite that talks to the firmware terminal over a serial port and to the AD3 through the WaveForms SDK. +- The generic bench code (AD3 wrapper over the WaveForms SDK, signal analysis, `OK`/`ERR`/`EVT` terminal client, console, pytest plugin and fakes) lives in the separate [ad3-waveforms-bench](https://github.com/embedded-pro/ad3-waveforms-bench) repository; `hal_st_validation` only adds what is specific to hal-st. +- hal-st has no comparator, CAN, EEPROM or Ethernet driver for these MCUs, so those commands answer `ERR unsupported`; I2C, flash, RNG and AES have no command group in EMIL's terminal yet and are not validated here. + +## Why C++ and Python + +- The firmware has to be C++: it is built from hal-st and EMIL exactly like an application would use them, so what is validated is the real driver code with the real interrupt table, clocks, DMA and pin muxing. +- The host is Python because Digilent ships the WaveForms SDK with official Python bindings and samples, `pyserial` covers the terminal, and pytest brings parametrisation, fixtures, skips and JUnit/HTML reports for free. +- Python's latency does not matter: every timing-critical stimulus or measurement is done by the AD3 hardware (pattern generator, logic analyzer, wavegen, protocol engines) or by the firmware itself; the host only configures, triggers and evaluates. + +## What the AD3 does + +| Protocol or signal | AD3 capability | How the tests use it | +|----------------------------|----------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------| +| UART | Full TX/RX through the SDK protocol UART (`FDwfDigitalUart*`) | Peer in both directions; the logic analyzer decodes the firmware TX line and its bit rate | +| SPI | SPI master in the SDK; SPI slave reported in recent WaveForms, unverified in SDK | Firmware is the master: logic-analyzer decode, static MISO level or MOSI-MISO jumper | +| PWM, encoder, GPIO, WWDG | Pattern generator, logic analyzer with DIO-edge trigger, static DIO | Encoder signals, pulse trains, break inputs and CTS; frequency, duty, dead time and periods | +| ADC | Wavegen W1/W2 (DC) and scope channels 1/2 | Analog levels on the inputs; the scope measures the level actually applied | + +## Hardware setup + +- Connect AD3 GND to the Nucleo GND. All AD3 DIOs are 3.3 V LVCMOS, compatible with the STM32 pins. +- The Nucleo is powered from its ST-LINK USB port. The AD3 V+/V- supplies stay off unless `ad3.vplus`/`ad3.vminus` are set in the board file. +- Wavegen outputs are refused outside `ad3.analog_limits` (0..3.3 V by default) so a wrong parameter cannot overdrive an analog input. +- Run the tests with nothing else connected to the pins of the selected wiring sets: the tests drive them directly. +- Each board has fixed wiring bundles (tables below); wire one, run its tests, then switch: + - `bundle1` wires all 16 DIOs and W1/W2 on two ADC inputs, and runs nearly the whole suite. + - `bundle2` (NUCLEO-WB55RG only) moves DIO9/DIO10 to the LPTIM1 inputs PC0/PC2 for the LPTIM encoder tests; W2 and scope 2 are unplugged because PC2 is then driven by a DIO. +- Each pin serves several tests: the TIM1 outputs are also SPI and encoder pins, the LPUART1 CTS pin is also the SPI1 MISO, and so on. The firmware frees every pin between tests, so a pin can change role from one test to the next. +- Tests whose connections are missing from the selected sets are skipped with the reason. +- The terminal is USART1 on the ST-LINK virtual COM port (`/dev/ttyACM0`, `COMx`): PB6/PB7 on the NUCLEO-WB55RG, PB12/PA8 on the NUCLEO-WBA55CG, at 921600 baud. + If the ST-LINK of a board cannot keep up with 921600 baud, change `terminalBaudRate` in `firmware/boards//BoardProfile.hpp` and `terminal.baud` in the board file together. +- The header positions in the tables come from the Arduino/morpho mapping of the Nucleo-64 boards; those marked "check UM2435" depend on solder bridges and should be checked against the board's user manual before wiring. +- NUCLEO-WB55RG: the firmware runs on the Cortex-M4 alone and never starts the wireless coprocessor; the linker script keeps it below the flash and SRAM the wireless stack uses. + +## Build and flash the firmware + +The target is `hal_st.validation_firmware`, built with the regular presets (`HALST_BUILD_EXAMPLES` is on in them). Like every embedded preset it needs the host tooling package (built by the `host` preset's `package` target) extracted under `install/` first, as `.github/workflows/ci.yml` does: + +```bash +cmake --preset stm32wb55 +cmake --build --preset stm32wb55-RelWithDebInfo --target hal_st.validation_firmware +cmake --preset stm32wba55 +cmake --build --preset stm32wba55-RelWithDebInfo --target hal_st.validation_firmware +``` + +Build it optimized (`RelWithDebInfo`): at `-O0` the interrupt-driven drivers cannot keep up with the 921600 baud terminal. +The artifacts are `build//validation/firmware/RelWithDebInfo/hal_st.validation_firmware.{elf,bin,hex}`. Flash through the on-board ST-LINK, for example with STM32CubeProgrammer: + +```bash +STM32_Programmer_CLI -c port=SWD -w build/stm32wb55/validation/firmware/RelWithDebInfo/hal_st.validation_firmware.hex -v -rst +``` + +After reset the firmware prints `EVT boot board=... family=... sysclk=... reset=...`, and the debug LED (WB55 blue LD1, WBA55 red LD3) blinks. + +## Install the host package + +1. Install the Digilent WaveForms application (it contains the WaveForms runtime `dwf` and the SDK) from the Digilent website; on Linux also install the Adept 2 runtime it depends on. `ad3-waveforms-bench` loads `libdwf.so` / `dwf.dll` / `dwf.framework` from the default location; set `DWF_LIBRARY` to override it. +2. Create a virtual environment and install the package (Python 3.10 or newer): + +```bash +python3 -m venv .venv +. .venv/bin/activate +pip install -e "validation/host[ad3]" +``` + +This also installs `ad3-waveforms-bench` from `git+https://github.com/embedded-pro/ad3-waveforms-bench@dcb3754` (see `host/pyproject.toml`), the same commit hal-ti uses. +To work on both at the same time, install a local checkout first and then this package without dependencies: + +```bash +pip install -e ../ad3-waveforms-bench +pip install -e validation/host --no-deps +``` + +The `ad3` extra adds no Python dependency: the SDK is reached through `ctypes`, so only the WaveForms runtime must be installed. Without it, everything except the AD3 still works and AD3 tests are skipped. + +## Run the tests + +Unit tests run without hardware: + +```bash +pytest validation/host/tests/unit +``` + +Hardware tests need `--port`; without it they are skipped. Select the board file and the wiring sets that are actually connected, and start with the quick depth: + +```bash +pytest validation/host/tests/hil --board nucleo_wb55rg --port /dev/ttyACM0 --wiring-set bundle1 --depth quick +pytest validation/host/tests/hil --board nucleo_wb55rg --port /dev/ttyACM0 --wiring-set bundle1 --depth full +pytest validation/host/tests/hil/test_qei.py --board nucleo_wb55rg --port /dev/ttyACM0 --wiring-set bundle2 +pytest validation/host/tests/hil --board nucleo_wba55cg --port /dev/ttyACM0 --wiring-set bundle1 +pytest validation/host/tests/hil --board nucleo_wb55rg --port /dev/ttyACM0 --no-ad3 +``` + +Run with `--depth quick` first; once it passes, run it again with `--depth full`, which takes much longer. Collect the reports with `--junitxml report---.xml`. + +Options from `tests/conftest.py`: + +- `--board` - board file name in `host/boards/` or a path to a YAML file (default `nucleo_wb55rg`, or `HAL_ST_BOARD`). +- `--port`, `--baud` - firmware terminal serial port (or `HAL_ST_PORT`) and baud rate (default from the board file, 921600). +- `--command-timeout` - seconds to wait for a reply (or `HAL_ST_COMMAND_TIMEOUT`, default from the board file); raise it for slow links such as port-bridge. +- `--wiring-set a,b` - active wiring sets (or `HAL_ST_WIRING`). +- `--with ` - enable optional wiring (`loopback` for the SPI MOSI-MISO jumper), repeatable. +- `--depth quick|full` - `quick` (default, or `HAL_ST_DEPTH`) runs a pairwise subset of every parameter matrix: every pair of values of any two parameters appears in at least one test. `full` runs the complete cartesian products. +- `--set path=value` - override a test parameter, value parsed as YAML: `--set pwm.waveform.freq=[20000] --set uart.transfer.baud=[921600]`. +- `--run-known-gaps` - also run the tests of known driver gaps that abort or hang the firmware (see [Known driver gaps](#known-driver-gaps)); they are skipped by default. + +Options from the `ad3_waveforms_bench` pytest plugin (loaded automatically once the package is installed); `tests/conftest.py` feeds it the `ad3` section of the board file: + +- `--ad3-serial` - pick an AD3 by serial number (or `AD3_SERIAL`); `--ad3-remote host[:port]` uses an AD3 on another machine (or `AD3_REMOTE`); `--no-ad3` skips every test that needs it. +- `--fake` - run the HIL plumbing against the in-memory fakes (`FakeDwfApi` for the AD3, `hal_st_validation.fake_firmware` for the terminal); only useful when changing the test code. + - The fake firmware validates arguments in the firmware's order and models pins, instances, timer sharing, EXTI line ownership, ADC triggers and the watchdog. + - It models no measured signal, so most tests that read the AD3 fail under `--fake --wiring-set ...`; `--fake --no-ad3` passes completely. + +Tests that need no AD3 (system, argument errors, limits, instance and timer sharing, watchdog behaviour) run with any wiring set. +Tests that reset the board on purpose (watchdog, UART swap) are marked `resets_board`; any other unexpected `EVT boot` fails the test that caused it. +Every instance a test opened is closed afterwards and the AD3 outputs are released, so tests are independent (the firmware keeps at most one PWM timer, UART, SPI, ADC, encoder and watchdog open at a time). +Use `-k`, `-m "not slow"` and `--junitxml report.xml` as usual. + +## Known driver gaps + +Writing the firmware against the drivers showed hal-st bugs that the suite runs into. Each board file lists them under `known_gaps` with the test ids they affect: a gap that aborts or hangs the firmware skips its tests unless `--run-known-gaps` is given, any other is an expected failure (`xfail`, not strict). `--fake` ignores them. + +| Board | Driver | Effect | Source | +|-------|--------|--------|--------| +| both | `PwmStm` | Every `pwm.open` aborts: the timer is initialised with `Period = 0`, and the HAL's `IS_TIM_PERIOD` assert (`USE_FULL_ASSERT` outside MinSizeRel) ends in `abort()`. Affects every MCU, not only these two. | `hal_st/stm32fxxx/PwmStm.cpp:256-261` | +| both | `PwmStm` | Dead time, break and idle settings (BDTR, AF1) survive into the next open of the same timer. | `PwmStm.cpp:238-250` | +| both | `PwmStm` | `brkpol=low` sets BKP=0 and BKINP=1, which inverts the input a second time: the break acts active high. | `PwmStm.cpp:318,349` | +| both | `PwmStm` | The centre-aligned period is two counter ticks short (1 MHz at 64 MHz runs at 1.032 MHz); `test_waveform` expects the failure where it leaves the tolerance. | `PwmStm.cpp:381-390` | +| WBA55 | `AdcDmaMultiChannelStm` | `adc.open` aborts unless `sampling=1.5`: a per-channel sampling time is passed where ADC4 takes only `ADC_SAMPLINGTIME_COMMON_1/2`. | `hal_st/stm32fxxx/AdcDmaMultiChannelStm.cpp:105` | +| WBA55 | `AdcStm` | Sequences of more than one pin convert one wrong channel (scan mode off, one conversion). | `AnalogToDigitalPinStm.cpp:183,193`, `AdcDmaMultiChannelStm.cpp:114` | +| WBA55 | `AdcTimerTriggeredBase` | `timer=1` never triggers: the ADC listens to TRGO2 while the timer drives TRGO. | `AdcTimerTriggeredBase.cpp:20-21` | +| WBA55 | `SpiMasterStm`, `SynchronousSpiMasterStm` | A receive-only first transfer never starts (CSTART is not set on the dummy path); the synchronous driver then spins forever. | `SpiMasterStm.cpp:160-168`, `SynchronousSpiMasterStm.cpp:132-139` | +| both | `UartStm` | Closing a UART during a stalled send leaves TXEIE set: the next open of that instance ends in an interrupt storm. | `UartStm.cpp:102` | +| both | `UartStm` | `swap=1` leaves the instance swapped for every later open until reset (CR2.SWAP is only written when swapping). | `UartStm.cpp:82-88` | +| both | `UartStm`, `UartStmDma` | A receive overrun aborts the firmware (`really_assert(!ORE)`); no test is expected to overrun, but a streaming failure that loses the firmware names it. | `UartStm.cpp:177` | +| both | `SynchronousUartStm` | A send with CTS held off blocks the event loop with no timeout; an overrun is never cleared. | `SynchronousUartStm.cpp:58,102` | +| both | `UartStm` | `uart.send` completes while up to 9 bytes are still in the TX FIFO and shift register, so a close right after a send can cut them. | `UartStm.cpp:202-205` | +| WB55 | `SynchronousQuadratureEncoderLpTimStm` | The LPTIM input filter of one open carries over into the next. | `SynchronousQuadratureEncoderLpTimStm.cpp:122` | +| both | `GpioStm` | EXTI on port H selects the wrong port (the compacted `hal::Port` value is written to EXTICR); the firmware answers `ERR unsupported` for `gpio.irq` on port H. | `GpioStm.cpp:541` | +| both | `WatchDogStm` | Only the window watchdog exists: timeouts are limited to about 516 ms (WB55) and 330 ms (WBA55), and there is no IWDG driver. | `WatchDogStm.cpp` | + +Fix the driver, then remove its entry from both board files so the tests guard the fix. + +## Windows host and Docker (bridge mode) + +Use this when the build and the tests run in the hal-st devcontainer (Docker) but the AD3, the Nucleo's virtual COM port and its ST-LINK are plugged into a Windows PC. Docker cannot reach those USB devices, so Windows shares them over TCP and the container uses them through `host.docker.internal` (the devcontainer maps that name to the host): + +```text + Docker container (devcontainer) Windows host + ┌─────────────────────────────────┐ TCP 5025 ┌─────────────────────────────┐ + │ pytest --ad3-remote ... │ ──────────▶ │ ad3-bench-server │── WaveForms ── AD3 (USB) + │ pytest --port socket://...:5000 │ TCP 5000 │ port-bridge (serial) │── COMx ─────── ST-LINK VCP + │ gdb-multiarch (flash, debug) │ TCP 61234 │ ST-LINK GDB server │── ST-LINK ──── Nucleo SWD + └─────────────────────────────────┘ └─────────────────────────────┘ +``` + +| Port | Windows service | Used in the container | +|-------|-----------------|-----------------------| +| 5025 | [`ad3-bench-server`](https://github.com/embedded-pro/ad3-waveforms-bench) (WaveForms, AD3) | `--ad3-remote host.docker.internal:5025` or `AD3_REMOTE` | +| 5000 | [`port-bridge`](https://github.com/gabrielfrasantos/port-bridge) serial (firmware terminal) | `--port socket://host.docker.internal:5000` or `HAL_ST_PORT` | +| 61234 | ST-LINK GDB server of STM32CubeIDE / STM32CubeCLT | `gdb-multiarch ... -ex "target remote host.docker.internal:61234"`, and the `stm32wb55rg` / `stm32wba55cg` VS Code launch configurations | + +On Windows: + +1. Install [WaveForms](https://digilent.com/reference/software/waveforms/waveforms-3/start), plug in the AD3 and check that WaveForms sees it. Close WaveForms afterwards: only one program can own the AD3. +2. Install Python 3.10 or newer, then both bridges (the [ad3-waveforms-bench releases](https://github.com/embedded-pro/ad3-waveforms-bench/releases) and [port-bridge releases](https://github.com/gabrielfrasantos/port-bridge/releases) also provide Windows installers): + + ```powershell + py -m venv $env:USERPROFILE\hil-bridge + & $env:USERPROFILE\hil-bridge\Scripts\Activate.ps1 + pip install "ad3-waveforms-bench @ git+https://github.com/embedded-pro/ad3-waveforms-bench@dcb3754fd4a4997e4e78cd824f48bc13b0c2435a" + pip install "port-bridge @ git+https://github.com/gabrielfrasantos/port-bridge@v0.1.5" + ``` + +3. Start them, in separate terminals: + + ```powershell + ad3-bench-server + port-bridge --serial-port COM5 --serial-baudrate 921600 + ``` + + - `COM5` is the ST-LINK virtual COM port of the Nucleo. The serial bridge is a plain byte stream, so the baud rate is set here and `--baud` has no effect in the container. + - Start the ST-LINK GDB server (from STM32CubeIDE or STM32CubeCLT) on port 61234, accepting connections from the container. + - `port-bridge` holds the COM port while it runs; stop it before opening the port in another tool. `ad3-bench-server --fake` serves a simulated AD3 to try the setup without hardware. + +Both bridges listen on `127.0.0.1` by default, which Docker Desktop reaches through `host.docker.internal`. If the container cannot connect (for example Docker Engine inside WSL2), listen on all interfaces with `ad3-bench-server --host 0.0.0.0 --token ` (and `AD3_REMOTE_TOKEN=` in the container) and `port-bridge --bind 0.0.0.0`, and keep ports 5025, 5000 and 61234 blocked from the network in the Windows firewall: whoever reaches them controls the AD3 and the board. + +In the container: + +```bash +cmake --preset stm32wb55 +cmake --build --preset stm32wb55-RelWithDebInfo --target hal_st.validation_firmware + +gdb-multiarch build/stm32wb55/validation/firmware/RelWithDebInfo/hal_st.validation_firmware.elf -batch \ + -ex "target remote host.docker.internal:61234" \ + -ex "monitor reset" -ex load -ex "monitor reset" -ex detach + +python3 -m venv .venv && . .venv/bin/activate +pip install -e "validation/host[ad3]" + +export AD3_REMOTE=host.docker.internal:5025 +export HAL_ST_PORT=socket://host.docker.internal:5000 +pytest validation/host/tests/hil --board nucleo_wb55rg --wiring-set bundle1 --depth quick +``` + +## Wiring sets + +The tables are generated from the board files; the notes list every function a pin serves and the Arduino/morpho header position. Scope inputs are single ended: connect the `-` input of each used scope channel to GND. + +### NUCLEO-WB55RG wiring + +| Wiring set | AD3 | Pin | Note | +|------------|----------|-------------------|-------------------------------------------------------------------------| +| `bundle1` | DIO0 | PA8 (tim1ch1) | TIM1 CH1, encoder TIM1 A, GPIO (D6) | +| `bundle1` | DIO1 | PA7 (tim1ch1n) | TIM1 CH1N, SPI1 MOSI, GPIO (D11) | +| `bundle1` | DIO2 | PA9 (tim1ch2) | TIM1 CH2, encoder TIM1 B, GPIO (D9) | +| `bundle1` | DIO3 | PB8 (tim1ch2n) | TIM1 CH2N, GPIO (D15) | +| `bundle1` | DIO4 | PA10 (tim1ch3) | TIM1 CH3, TIM17 break, GPIO (D3) | +| `bundle1` | DIO5 | PB9 (tim1ch3n) | TIM1 CH3N, TIM17 CH1, GPIO (D14) | +| `bundle1` | DIO6 | PA5 (spi1clk) | SPI1 SCK, GPIO (D13) | +| `bundle1` | DIO7 | PA6 (spi1miso) | SPI1 MISO, LPUART1 CTS, TIM16 CH1, GPIO (D12) | +| `bundle1` | DIO8 | PA4 (spi1cs) | SPI1 chip select, GPIO (D10, see the solder-bridge note) | +| `bundle1` | DIO9 | PA15 (qei2a) | TIM2 CH1, encoder TIM2 A, GPIO (D5) | +| `bundle1` | DIO10 | PB3 (qei2b) | TIM2 CH2, encoder TIM2 B, GPIO (CN10, check UM2435) | +| `bundle1` | DIO11 | PC6 (gpio0) | GPIO/EXTI, encoder TIM2 index, watchdog warning toggle (D2) | +| `bundle1` | DIO12 | PA2 (lpuart1tx) | LPUART1 TX, TIM2 CH3, GPIO (D1) | +| `bundle1` | DIO13 | PA3 (lpuart1rx) | LPUART1 RX, TIM2 CH4, GPIO (D0) | +| `bundle1` | DIO14 | PB12 (lpuart1rts) | LPUART1 RTS, TIM1 break, GPIO (CN10, check UM2435) | +| `bundle1` | DIO15 | PB0 (led0) | green LED output, GPIO (CN10, check UM2435) | +| `bundle1` | W1 | PC3 (ain4) | ADC1 IN4 (A4) | +| `bundle1` | W2 | PC2 (ain3) | ADC1 IN3 (A5) | +| `bundle1` | Scope 1+ | PC3 (ain4) | | +| `bundle1` | Scope 2+ | PC2 (ain3) | | +| `bundle1` | - | - | `--with loopback`: jumper PA7 (SPI1 MOSI, D11) to PA6 (SPI1 MISO, D12); DIO7 then only listens | +| `bundle1` | - | - | D10 is PA4 only with the default solder bridges (SB41 on, SB42 off; otherwise PB10), check against UM2435 | +| `bundle2` | DIO0-8, DIO11-15, W1, Scope 1+ | as `bundle1` | | +| `bundle2` | DIO9 | PC0 (lptim1in1) | LPTIM1 IN1, encoder LPTIM1 A, ADC1 IN1 (A0) | +| `bundle2` | DIO10 | PC2 (lptim1in2) | LPTIM1 IN2, encoder LPTIM1 B, ADC1 IN3 (A5); W2 and scope 2 unplugged | + +### NUCLEO-WBA55CG wiring + +| Wiring set | AD3 | Pin | Note | +|------------|----------|-------------------|-------------------------------------------------------------------------| +| `bundle1` | DIO0 | PB4 (spi1clk) | SPI1 SCK, TIM1 CH3, blue LED (led0), GPIO (D13) | +| `bundle1` | DIO1 | PB3 (spi1miso) | SPI1 MISO, TIM1 CH4, TIM17 CH1N, GPIO (D12) | +| `bundle1` | DIO2 | PA15 (spi1mosi) | SPI1 MOSI, encoder TIM1 index, TIM17 break, GPIO (D11) | +| `bundle1` | DIO3 | PA12 (spi1cs) | SPI1 chip select, TIM1 CH2, encoder TIM1 B, GPIO (D10) | +| `bundle1` | DIO4 | PA11 (tim1ch1) | TIM1 CH1, encoder TIM1 A, USART2 RX, GPIO (D4) | +| `bundle1` | DIO5 | PB2 (tim1ch1n) | TIM1 CH1N, USART2 CTS, GPIO (D15) | +| `bundle1` | DIO6 | PB1 (tim1ch2n) | TIM1 CH2N, USART2 RTS, GPIO (D14) | +| `bundle1` | DIO7 | PB0 (tim1ch3n) | TIM1 CH3N, USART2 TX, GPIO (D6) | +| `bundle1` | DIO8 | PB5 (lpuart1tx) | LPUART1 TX, GPIO (D1) | +| `bundle1` | DIO9 | PA10 (lpuart1rx) | LPUART1 RX, TIM3 CH1, encoder TIM3 A, GPIO (D0) | +| `bundle1` | DIO10 | PB9 (lpuart1rts) | LPUART1 RTS, TIM3 CH4, TIM16 CH1, GPIO (D7) | +| `bundle1` | DIO11 | PB15 (lpuart1cts) | LPUART1 CTS, TIM16 break, GPIO (D8) | +| `bundle1` | DIO12 | PA1 (tim3ch2) | TIM3 CH2, encoder TIM3 B, TIM17 CH1, GPIO (A3) | +| `bundle1` | DIO13 | PB14 (gpio0) | GPIO/EXTI, TIM3 CH3, encoder TIM3 index, watchdog warning toggle (D5) | +| `bundle1` | DIO14 | PA2 (tim1bkin) | TIM1 break input, GPIO (A2) | +| `bundle1` | DIO15 | PA5 (tim2ch1) | TIM2 CH1, GPIO (A4) | +| `bundle1` | W1 | PA7 (ain2) | ADC4 IN2 (A0) | +| `bundle1` | W2 | PA6 (ain3) | ADC4 IN3 (A1) | +| `bundle1` | Scope 1+ | PA7 (ain2) | | +| `bundle1` | Scope 2+ | PA6 (ain3) | | +| `bundle1` | - | - | `--with loopback`: jumper PA15 (SPI1 MOSI, D11) to PB3 (SPI1 MISO, D12); DIO1 then only listens | +| `bundle1` | - | - | PB4 (D13) also drives the blue LED LD1, which loads the SPI1 clock line | + +## What is tested + +- `test_system.py` - `ping`, `info`, and the `board.pins` alias table against the board file in both directions, every alias accepted as a pin, reserved terminal/SWD/LSE/BOOT0 pins and the debug LED, unbonded pins, pin syntax, the terminal UART, error reasons (`usage`, `busy`, `notopen`, `range`, `unsupported`), missing instances (including 0), `delay`, `reset` and the `EVT boot` cause. +- `test_gpio.py` - output levels with every drive (`hal::Speed`), inputs following the AD3 with every pull, pull-only idle levels, open drain, the user LED, eight pins at a time, pins held by other groups, interrupt counts for edge x handler type x pulse count x frequency against exact AD3 pulse trains, one EXTI line per port at a time, `gpio.pulse` timing. +- `test_pwm.py` - for `PwmStm` and `SynchronousPwmStm`: + - frequency x duty x edge/centre alignment x prescaler, with `ERR range` exactly where the period does not fit the counter (16 or 32 bits); + - 1-4 channels, complementary outputs and complementary-only outputs with dead time and inversion (shoot-through check), the dead-time limit; + - idle levels, the break input with both polarities and automatic re-enable; + - frequency changes and stop, the features each timer offers (`unsupported` elsewhere), argument errors, one timer at a time, and that a re-open forgets dead time and break. +- `test_uart.py` - every baud rate x parity x driver variant (interrupt, DMA, duplex DMA, synchronous) on LPUART1 and USART2, both directions against the AD3 UART, frames decoded from the firmware TX line and the bit rate measured on it, large payloads, full-duplex streaming, RTS/CTS flow control per variant, send timeout with CTS held, re-open with other settings, TX/RX swap, the baud limits of each instance, argument errors and default pins. +- `test_spi.py` - SPI modes 0-3 x baud x driver variant (interrupt, DMA, synchronous) x GPIO chip select, decoded from the logic analyzer (MOSI, MISO, clock polarity and rate, chip-select release), continued sessions, receive-only first transfers, the largest transfer, the baud limits and argument errors. +- `test_adc.py` - wavegen DC levels against raw 12-bit codes (checked against the scope when it is wired), sequences of up to 8 conversions, every sampling time, timer-triggered rates and the measure timeout, timer sharing with PWM and the encoder, unsupported trigger timers, argument errors and limits, and ADC pins against GPIO. +- `test_qei.py` - position counts for frequency x cycles x direction x decoding x phase inversion, physically inverted phases restored by `inva`/`invb`, offset and rollover, speed, the index input through `qei.index`, the LPTIM1 encoder on the NUCLEO-WB55RG, default pins, the resolution limits, argument errors, one encoder at a time and timer sharing. +- `test_watchdog.py` - the window watchdog with timeouts across its range x automatic or manual feeding (warnings, no reset while fed, `reset=wwdg` otherwise), the early-warning period measured on the `pin=` toggle, manual feeding, a single watchdog at a time, the timeout limits and argument errors. +- `test_unsupported.py` - every comparator, CAN, EEPROM and Ethernet command answers `ERR unsupported`. + +## Customising + +Each board file (`host/boards/.yaml`) holds: + +- `terminal`, `clocks` (the kernel clocks the expectations use), `pins` (the PROTOCOL.md alias table, compared with `board.pins`; only the generic names of `hal_st_validation.protocol` are accepted) and `ad3` (supplies, analog limits). +- `wiring_sets` - per set, `dio`, `wavegen` and `scope` maps from AD3 channel to pin or alias. An entry is either a pin or a mapping with `pin`, `jumpered` (pins tied to `pin` with a wire), `role` (a name tests can look up), `note` and `requires` (only used with `--with `); `jumpers` and `options` document extra wiring. +- `known_gaps` - driver gaps with the test ids (wildcards `*` and `?`) they affect, the reason and whether they hang the firmware. +- `tests` - the parameters of every test module: parameter matrices, pins and instances, levels and tolerances. + - `@pytest.mark.matrix("pwm.waveform")` turns every key of that mapping into one test parameter of the same name; `@pytest.mark.board_params("argname", "section.key")` adds one parameter from a list. + - All parameters of a test form one matrix: `--depth full` runs its product, `--depth quick` a pairwise subset (`hal_st_validation.pairwise`); `@pytest.mark.constraint(valid=...)` removes combinations a driver cannot take (for example parity with the synchronous UART). + - Extending a sweep or moving a peripheral to other pins is a YAML change. + +To validate another board, add a board profile under `firmware/boards//` and the MCU to `emil_build_for` in `firmware/CMakeLists.txt`, copy a board file, adapt the pins, wiring sets and parameters, and pass `--board path/to/board.yaml`. + +## Interactive console + +```bash +hal-st-console --port /dev/ttyACM0 +hal-st-console --port /dev/ttyACM0 -c info -c board.pins +``` + +`hal-st-console` is `ad3-bench-console` from ad3-waveforms-bench with the firmware's 921600 baud and its own history file (`ad3-bench-console --port /dev/ttyACM0` works as well). +The console forwards commands, prints final lines and events, and keeps a history in `~/.hal_st_validation_history`. `:wait ` listens for events, `:raw` also shows non-protocol output, `:quit` leaves. + +## Package layout + +In `hal_st_validation` (hal-st specific): + +- `firmware.py` - typed API with one group per PROTOCOL.md section (`fw.system`, `fw.gpio`, `fw.pwm`, `fw.uart`, `fw.spi`, `fw.adc`, `fw.qei`, `fw.wdt`); keyword arguments map 1:1 to protocol options (`continue_` for `continue`). +- `protocol.py` - the hal-st part of the protocol: error reasons, `P` pins (ports A-K, index 0-15) and the generic alias names (`normalize_pin`, `parse_pin_map`). +- `config.py` - board file loading, wiring-set merging, parameter matrices, overrides and known gaps; `expect.py` - expected STM32 values (PWM quantisation and range, SPI prescaler, UART baud-rate register limits, WWDG prescaler and period, ADC codes, encoder counts). +- `pairwise.py` - the full product and the deterministic pairwise generator behind `--depth`. +- `fake_firmware.py` - `FakeFirmware`, an in-memory stand-in for the validation firmware used by the unit tests and `--fake`. +- `console.py` - the `hal-st-console` entry point. + +In the firmware (`firmware/`): + +- `Main.cpp` composes the console, the pin pool and every command group; `Console` is the terminal on USART1; `boards//BoardProfile.hpp` holds the aliases, reserved pins, default pins, DMA request lines, clocks and ADC tables of each board. +- `PinFactoryStm` builds `hal::GpioPinStm`s over the generated pinout tables (bonded pins only, analog sharing, one EXTI line per port); `BoardInfoStm` reports the board, clock and reset cause; `TimerAllocation` keeps PWM, encoder and timer-triggered ADC off each other's timer. +- One factory per command group (`UartFactory`, `SpiFactory`, `AdcFactory`, `PwmFactory`, `QeiFactory` with the `qei.index` command, `WatchDogFactory`) parses the hal-st options and builds the driver; `UnsupportedGroups` answers the rest. diff --git a/validation/host/.gitignore b/validation/host/.gitignore new file mode 100644 index 00000000..b0f7cec1 --- /dev/null +++ b/validation/host/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.egg-info/ +.pytest_cache/ +.ruff_cache/ +.venv/ diff --git a/validation/host/boards/nucleo_wb55rg.yaml b/validation/host/boards/nucleo_wb55rg.yaml new file mode 100644 index 00000000..81ef8579 --- /dev/null +++ b/validation/host/boards/nucleo_wb55rg.yaml @@ -0,0 +1,665 @@ +--- +# NUCLEO-WB55RG (STM32WB55RG, VFQFPN68), 64 MHz from the HSI PLL with every +# APB prescaler at 1. Pins may be written as P or as an alias +# from `pins`. +board: nucleo_wb55rg +firmware_name: NUCLEO-WB55RG +family: stm32wb55 +description: NUCLEO-WB55RG (Nucleo-64 with STM32WB55RG) +sysclk: 64000000 + +# Kernel clocks the expectations use (expect.py), in Hz. +clocks: + sysclk: 64000000 + pclk1: 64000000 + pclk2: 64000000 + timer: 64000000 + spi: + 1: 64000000 + 2: 64000000 + uart: + usart1: 64000000 + lpuart1: 64000000 + +terminal: + baud: 921600 + interface: ST-LINK virtual COM port (USART1) + uart: 1 + pins: [terminaltx, terminalrx] + max_command_length: 255 + command_timeout: 2.0 + boot_timeout: 5.0 + +# PROTOCOL.md board profile, compared with `board.pins` in both directions. +pins: + terminaltx: PB6 + terminalrx: PB7 + ain1: PC0 + ain2: PC1 + ain3: PC2 + ain4: PC3 + ain5: PA0 + ain6: PA1 + tim1ch1: PA8 + tim1ch2: PA9 + tim1ch3: PA10 + tim1ch4: PA11 + tim1ch1n: PA7 + tim1ch2n: PB8 + tim1ch3n: PB9 + tim1bkin: PB12 + tim2ch1: PA15 + tim2ch2: PB3 + tim2ch3: PA2 + tim2ch4: PA3 + tim16ch1: PA6 + tim17ch1: PB9 + qei1a: PA8 + qei1b: PA9 + qei2a: PA15 + qei2b: PB3 + qei2idx: PC6 + lptim1in1: PC0 + lptim1in2: PC2 + spi1clk: PA5 + spi1miso: PA6 + spi1mosi: PA7 + spi1cs: PA4 + lpuart1tx: PA2 + lpuart1rx: PA3 + lpuart1rts: PB12 + lpuart1cts: PA6 + led0: PB0 + led1: PB1 + gpio0: PC6 + gpio1: PC10 + gpio2: PC12 + gpio3: PC13 + gpio4: PE4 + sw1: PC4 + sw2: PD0 + sw3: PD1 + +ad3: + # V+/V- stay off (null): the Nucleo is powered from the ST-LINK USB. + vplus: null + vminus: null + # Wavegen levels are refused outside this range (3.3 V I/O). + analog_limits: [0.0, 3.3] + +# Two fixed bundles: wire one, run its tests, then switch. Header positions +# follow the Nucleo-64 Arduino mapping; the CN10 morpho positions and the +# solder bridges are to be checked against UM2435. Always connect AD3 GND to +# a Nucleo GND pin. +wiring_sets: + bundle1: + description: >- + PWM (TIM1 channels, complementary outputs and break; TIM2; TIM16/17), + SPI1, LPUART1 with flow control, the TIM1/TIM2 encoders, GPIO, EXTI, + LED, watchdog toggle and ADC1; W1/W2 on two ADC inputs + options: + loopback: >- + jumper PA7 (SPI1 MOSI, D11) to PA6 (SPI1 MISO, D12); DIO7 then only + listens + jumpers: + - >- + D10 is PA4 only with the default solder bridges (SB41 on, SB42 off; + otherwise PB10), check against UM2435 + dio: + 0: + pin: tim1ch1 + note: TIM1 CH1, encoder TIM1 A, GPIO (D6) + 1: + pin: tim1ch1n + note: TIM1 CH1N, SPI1 MOSI, GPIO (D11) + 2: + pin: tim1ch2 + note: TIM1 CH2, encoder TIM1 B, GPIO (D9) + 3: + pin: tim1ch2n + note: TIM1 CH2N, GPIO (D15) + 4: + pin: tim1ch3 + note: TIM1 CH3, TIM17 break, GPIO (D3) + 5: + pin: tim1ch3n + note: TIM1 CH3N, TIM17 CH1, GPIO (D14) + 6: + pin: spi1clk + note: SPI1 SCK, GPIO (D13) + 7: + pin: spi1miso + note: SPI1 MISO, LPUART1 CTS, TIM16 CH1, GPIO (D12) + 8: + pin: spi1cs + note: SPI1 chip select, GPIO (D10, see jumpers) + 9: + pin: qei2a + note: TIM2 CH1, encoder TIM2 A, GPIO (D5) + 10: + pin: qei2b + note: TIM2 CH2, encoder TIM2 B, GPIO (CN10, check UM2435) + 11: + pin: gpio0 + note: >- + GPIO/EXTI, encoder TIM2 index, watchdog warning toggle (D2) + 12: + pin: lpuart1tx + note: LPUART1 TX, TIM2 CH3, GPIO (D1) + 13: + pin: lpuart1rx + note: LPUART1 RX, TIM2 CH4, GPIO (D0) + 14: + pin: lpuart1rts + note: LPUART1 RTS, TIM1 break, GPIO (CN10, check UM2435) + 15: + pin: led0 + note: green LED output, GPIO (CN10, check UM2435) + wavegen: + 1: + pin: ain4 + note: ADC1 IN4 (A4) + 2: + pin: ain3 + note: ADC1 IN3 (A5) + scope: + 1: ain4 + 2: ain3 + bundle2: + description: >- + bundle1 with DIO9 on PC0 and DIO10 on PC2 for the LPTIM1 encoder; W2 + and scope 2 are unplugged because DIO10 now drives PC2. Only the + LPTIM encoder tests and tests that need nothing on DIO9/DIO10/W2 run + with it + options: + loopback: >- + jumper PA7 (SPI1 MOSI, D11) to PA6 (SPI1 MISO, D12); DIO7 then only + listens + jumpers: + - >- + D10 is PA4 only with the default solder bridges (SB41 on, SB42 off; + otherwise PB10), check against UM2435 + dio: + 0: + pin: tim1ch1 + note: TIM1 CH1, encoder TIM1 A, GPIO (D6) + 1: + pin: tim1ch1n + note: TIM1 CH1N, SPI1 MOSI, GPIO (D11) + 2: + pin: tim1ch2 + note: TIM1 CH2, encoder TIM1 B, GPIO (D9) + 3: + pin: tim1ch2n + note: TIM1 CH2N, GPIO (D15) + 4: + pin: tim1ch3 + note: TIM1 CH3, TIM17 break, GPIO (D3) + 5: + pin: tim1ch3n + note: TIM1 CH3N, TIM17 CH1, GPIO (D14) + 6: + pin: spi1clk + note: SPI1 SCK, GPIO (D13) + 7: + pin: spi1miso + note: SPI1 MISO, LPUART1 CTS, TIM16 CH1, GPIO (D12) + 8: + pin: spi1cs + note: SPI1 chip select, GPIO (D10, see jumpers) + 9: + pin: lptim1in1 + note: LPTIM1 IN1, encoder LPTIM1 A, ADC1 IN1 (A0) + 10: + pin: lptim1in2 + note: LPTIM1 IN2, encoder LPTIM1 B, ADC1 IN3 (A5) + 11: + pin: gpio0 + note: GPIO/EXTI, watchdog warning toggle (D2) + 12: + pin: lpuart1tx + note: LPUART1 TX, TIM2 CH3, GPIO (D1) + 13: + pin: lpuart1rx + note: LPUART1 RX, TIM2 CH4, GPIO (D0) + 14: + pin: lpuart1rts + note: LPUART1 RTS, TIM1 break, GPIO (CN10, check UM2435) + 15: + pin: led0 + note: green LED output, GPIO (CN10, check UM2435) + wavegen: + 1: + pin: ain4 + note: ADC1 IN4 (A4) + scope: + 1: ain4 + +# Driver gaps the HIL tests run into (tests/conftest.py): `tests` match the +# test ids relative to tests/hil (`*` and `?` are wildcards, brackets are +# literal); `hangs` gaps abort or hang the firmware, so their tests are +# skipped unless --run-known-gaps; the others are expected failures. --fake +# ignores them. The centre-aligned period of PwmStm.cpp:381-390 is off the +# frequency tolerance at short periods only, so test_pwm.py::test_waveform +# expects that failure per case (expect.pwm_frequency_driver). +known_gaps: + - tests: + - test_pwm.py::test_waveform* + - test_pwm.py::test_frequency_limits* + - test_pwm.py::test_channels* + - test_pwm.py::test_complementary* + - test_pwm.py::test_dead_time_limits[1000000] + - test_pwm.py::test_idle_levels* + - test_pwm.py::test_break_input* + - test_pwm.py::test_frequency_change_and_stop* + - test_pwm.py::test_duty_errors + - test_pwm.py::test_one_timer_at_a_time + - test_pwm.py::test_reopen_forgets_dead_time_and_break + reason: >- + hal_st/stm32fxxx/PwmStm.cpp:256-261 - every pwm.open aborts the + firmware: ConfigureTimeBase calls HAL_TIM_PWM_Init with Period = 0, the + IS_TIM_PERIOD assert (USE_FULL_ASSERT) ends in assert_failed and abort() + hangs: true + - tests: + - test_pwm.py::test_reopen_forgets_dead_time_and_break + reason: >- + hal_st/stm32fxxx/PwmStm.cpp:238-250 - dead time, break and idle settings + (BDTR, AF1) survive into the next open of the same timer + hangs: false + - tests: + - test_uart.py::test_close_during_stalled_send[*variant=interrupt*] + reason: >- + hal_st/stm32fxxx/UartStm.cpp:102 - closing a UART during a stalled send + leaves TXEIE set: the next open of that instance ends in an interrupt + storm + hangs: true + - tests: + - test_pwm.py::test_break_input[*brkpol=low*] + reason: >- + hal_st/stm32fxxx/PwmStm.cpp:318,349 - brkpol=low sets BDTR.BKP=0 and + AF1.BKINP=1, which inverts the input a second time: the break acts + active high + hangs: false + - tests: + - test_uart.py::test_swap_does_not_survive_close* + reason: >- + hal_st/stm32fxxx/UartStm.cpp:82-88 - CR2.SWAP is only written when + swapTxRx is set, so a swap=1 session leaves the instance swapped for + every later open until reset + hangs: false + +# Test parameters. A mapping used by `@pytest.mark.matrix` is one dimension +# per key: `--depth full` runs the cartesian product, `--depth quick` a +# pairwise subset. Lists used by `@pytest.mark.board_params` are one +# dimension each; a list of mappings with a `name` uses it as the test id. +tests: + system: + boot_timeout: 5.0 + # USART1 is the terminal: `uart.open 1` answers busy. + reserved_uarts: [1] + # Blinking debug LED (blue LD1), reserved by the firmware. + debug_led: PB5 + # SWD, LSE crystal and BOOT0: reserved like the terminal pins. + reserved_pins: [PA13, PA14, PC14, PC15, PH3] + # Instances the MCU lacks: ERR range (0 too, the peripherals start at 1). + missing_instances: + - uart.open 0 + - spi.open 0 + - pwm.open 0 + - qei.open 0 + - uart.open 2 + - spi.open 3 + - pwm.open 3 + - qei.open 3 + - adc.open 4 + - wdt.start 1 timeout=100 + # Features the MCU lacks: ERR unsupported. + unsupported_instances: + - qei.open 16 + # A pin the package does not bond out: ERR pin. + unbonded_pins: [PC7, PD2, PE0] + # Names that are no pin: ERR pin. + invalid_pins: [PA16, PA08, PF0, LED0, nosuchalias] + delays_ms: [10, 200] + gpio: + # bundle1 pins used as plain GPIO, on ports A, B and C. + loop_pins: [gpio0, PA8, PB8, PA4, PB12] + output_pins: [led0] + # hal::Speed of the output stage. + drives: [low, medium, fast, high] + pulls: [up, down] + input_pulls: [none, up, down] + # RAM holds `limit` GPIO pins; `limit_pins` names one more. + limit: 8 + limit_pins: [gpio0, gpio1, gpio2, gpio3, gpio4, led0, led1, PA8, PB8] + irq: + edge: [rising, falling, both] + handler: [immediate, dispatched] + pulses: [1, 10, 100] + frequency: [100, 1000, 10000] + pulse: + count: 10 + periods_ms: [1, 5, 20] + tolerance: 0.05 + jitter_ms: 0.5 + # Two wired pins on the same EXTI line (index) of different ports: while + # `counting` counts edges, `sharing` answers ERR unsupported. + exti_sharing: + counting: gpio0 + sharing: PA6 + pwm: + # Timers with their wired outputs, in the order the tests use them; + # `npin` is the complementary output, `brk` a wired break input. + timers: + - name: tim1 + timer: 1 + channels: + - channel: 1 + pin: tim1ch1 + npin: tim1ch1n + - channel: 2 + pin: tim1ch2 + npin: tim1ch2n + - channel: 3 + pin: tim1ch3 + npin: tim1ch3n + brk: tim1bkin + - name: tim2 + timer: 2 + channels: + - channel: 1 + pin: tim2ch1 + - channel: 2 + pin: tim2ch2 + - channel: 3 + pin: tim2ch3 + - channel: 4 + pin: tim2ch4 + - name: tim16 + timer: 16 + channels: + - channel: 1 + pin: tim16ch1 + - name: tim17 + timer: 17 + channels: + - channel: 1 + pin: tim17ch1 + brk: PA10 + # One channel of each timer; ERR range exactly where the period does + # not fit (expect.pwm_fits), unsupported for center on TIM16/TIM17. + waveform: + freq: [100, 1000, 10000, 20000, 100000, 1000000] + duty: [0, 12.5, 50, 90, 100] + mode: [edge, center] + prescaler: [0, 1, 63, 999, 65535] + sync: [0, 1] + # 1-4 channels of a timer (as many as it has wired), aligned edges. + channels: + count: [1, 2, 3, 4] + mode: [edge, center] + sync: [0, 1] + channels_frequency: 10000 + channel_duties: [20, 40, 60, 80] + # Channel and complementary output (timers with `npin`): dead time in ns + # (saturating at expect.PWM_DEAD_MAX_TICKS), inversion [inv, invn]. + complementary: + dead: [0, 100, 500, 2000, 20000] + inversion: [[0, 0], [1, 0], [0, 1], [1, 1]] + mode: [edge, center] + sync: [0, 1] + complementary_frequency: 20000 + # Output levels while stopped (timers with a break function). + idle: + idle: [0, 1] + idlen: [0, 1] + # Break input (timers with `brk`) driven by its DIO. + break: + brkpol: [low, high] + brkauto: [0, 1] + break_settle_s: 0.01 + # A pin without any function of the first timer. + foreign_pin: gpio0 + dead_limit_ns: [1000000, 1000001] + frequency_changes: [10000, 25000] + frequency_change: + mode: [edge, center] + sync: [0, 1] + # Lowest and highest frequency of each timer (expect.pwm_frequency_limits). + limits: + mode: [edge, center] + prescaler: [0, 63, 65535] + capture_periods: 20 + tolerance: + frequency: 0.01 + duty: 1.0 + dead_ticks: 2 + alignment_s: 2.0e-7 + static_fraction: 0.99 + uart: + # USART1 is the terminal: LPUART1 is the only UART under test. + instances: + - name: lpuart1 + index: 1 + lp: 1 + tx: lpuart1tx + rx: lpuart1rx + rts: lpuart1rts + cts: lpuart1cts + variants: [interrupt, dma] + # Opens on lpuart1tx/lpuart1rx without pins. + default_pins: true + variants: + interrupt: + dma: 0 + duplex: 0 + sync: 0 + dma: + dma: 1 + duplex: 0 + sync: 0 + duplex: + dma: 0 + duplex: 1 + sync: 0 + sync: + dma: 0 + duplex: 0 + sync: 1 + transfer: + baud: [38400, 57600, 115200, 230400, 460800, 921600] + parity: [none, even, odd] + variant: [interrupt, dma, duplex, sync] + # Asynchronous drivers take rtscts only; rts and cts alone need sync=1. + flow: + flow: [rts, cts, rtscts] + variant: [interrupt, dma, duplex, sync] + swap: + variant: [interrupt, dma, duplex] + # CTS held off: uart.send answers ERR timeout. + stall: + variant: [interrupt, dma, duplex, sync] + stall_payload: 64 + flow_baud: 115200 + reopen_settings: [[38400, even], [460800, none], [921600, odd]] + large: + baud: [115200, 921600] + variant: [interrupt, dma, duplex, sync] + # `uart.open` succeeds exactly when expect.uart_baud_fits (LPUART1 at + # 64 MHz: 15626 to 8000000, the limit the WB HAL asserts). + open_bauds: [299, 300, 15625, 15626, 921600, 8000000, 8000001, 12000001] + payloads: ["55", "00ff", "0123456789abcdef", "48656c6c6f2c20484954"] + # uart.send takes at most 112 bytes, uart.recv returns at most 256. + large_payload: 112 + large_payload_to_firmware: 240 + stream_rounds: 8 + stream_size: 64 + bit_rate_tolerance: 0.03 + spi: + instances: + - name: spi1 + index: 1 + clk: spi1clk + mosi: spi1mosi + miso: spi1miso + cs: spi1cs + variants: + interrupt: + dma: 0 + sync: 0 + dma: + dma: 1 + sync: 0 + sync: + dma: 0 + sync: 1 + # The clock is the fastest spiclk / 2^n not above baud + # (expect.spi_clock): 3 MHz runs at 2 MHz. + transfer: + mode: [0, 1, 2, 3] + baud: [250000, 400000, 1000000, 3000000, 8000000, 32000000] + variant: [interrupt, dma, sync] + cs: [gpio, none] + sessions: + mode: [0, 1, 2, 3] + variant: [interrupt, dma, sync] + receive_only: + variant: [interrupt, dma, sync] + largest: + variant: [interrupt, dma, sync] + session_baud: 250000 + # ERR range outside spiclk/256 .. spiclk/2. + open_bauds: [249999, 250000, 32000000, 32000001] + payloads: ["a5", "0102030405060708", "ff00aa55"] + miso_levels: [0, 1] + max_transfer: 64 + tolerance: + baud: 0.05 + adc: + adc: 1 + vref: 3.3 + bits: 12 + tolerance_codes: 40 + spread_codes: 180 + samples: 16 + # The two inputs driven by W1/W2. + inputs: [ain4, ain3] + # Analog-capable pins the sequence tests may add (unwired, any reading). + spare_inputs: [ain1, ain2, ain5, ain6] + levels: + pin: [ain4, ain3] + level: [0.2, 0.8, 1.65, 2.5, 3.1] + trigger: [software, timer] + # Sequences alternate the two driven inputs; every third position takes + # the next spare input (test_adc.sequence_pins). + sequence: + length: [1, 2, 3, 4, 8] + trigger: [software, timer] + timing: + sampling: ["2.5", "6.5", "12.5", "24.5", "47.5", "92.5", "247.5", "640.5"] + trigger: [software, timer] + # Timer-triggered runs: TRGO of `timer` at `rate` runs per second; + # `trigger_rate` times `adc.measure` against the rate. + trigger_rate: + timer: [1, 2] + rate: [100, 1000, 10000] + trigger: + missing_timers: [3] + unsupported_timers: [16, 17] + timer: 2 + rate: 1000 + runs: 64 + # test_trigger_rate compares n=runs with n=1, the shortest of `repeats` + # round trips each, so the command round trip cancels; `jitter_s` is + # what the link's scheduling leaves on top. + repeats: 3 + rate_tolerance: 0.2 + jitter_s: 0.002 + qei: + instances: + - name: tim2 + index: 2 + a: qei2a + b: qei2b + idx: qei2idx + # TIM2 counts up to 2^32 - 1 (EMIL's number parser stops there). + max_res: 4294967295 + - name: tim1 + index: 1 + a: qei1a + b: qei1b + idx: gpio0 + max_res: 65536 + # Opens on its default pins without a and b. + default_instance: 2 + # LPTIM1 (`lp=1`), wired in bundle2 only. + lp_instances: + - name: lptim1 + index: 1 + a: lptim1in1 + b: lptim1in2 + max_res: 65536 + # LPTIM numbers without an encoder interface: ERR range. + lp_missing_instances: [2] + resolution: 4096 + position: + freq: [100, 1000, 10000, 50000] + cycles: [1, 10, 250] + direction: [fwd, rev] + cap: [ab, a, b] + inva: [0, 1] + invb: [0, 1] + inversions: [[1, 0], [0, 1], [1, 1]] + counts_per_cycle: + ab: 4 + a: 2 + b: 2 + lp_position: + freq: [100, 1000, 10000] + cycles: [1, 10, 250] + direction: [fwd, rev] + inva: [0, 1] + filter: [0, 2, 4, 8] + velocity: + freq: [1000, 10000] + vel: [1000, 10000] + speed_tolerance: 0.03 + rollover: + res: [100, 4096] + offset: [0, 50] + watchdog: + index: 0 + # Warning period 63 * 4096 * prescaler / pclk1 for the smallest + # prescaler not below the timeout (expect.wwdg_warning_period): 5 ms -> + # 8.064 ms, 20 -> 32.256, 100 -> 129.024, 300 -> 516.096. With + # feed=manual the reset one counter tick after the warning can cut the + # EVT wdt line at 5 and 20 ms, so test_behaviour checks the line from + # 100 ms on (expect.wwdg_warning_outruns_reset). + behaviour: + timeout_ms: [5, 20, 100, 300] + feed: [auto, manual] + period: + timeout_ms: [5, 20, 100, 300] + # 516 ms is the longest; above it ERR range. + open_timeouts_ms: [1, 516, 517, 30000, 30001] + pin: gpio0 + manual_timeout_ms: 100 + feed_seconds: 2.0 + observe_periods: 3 + period_tolerance: 0.03 + unsupported: + commands: + - comp.open 1 + - comp.read 1 + - comp.irq 1 rising + - comp.count 1 + - comp.close 1 + - can.open 1 + - can.send 1 0x123 01 + - can.close 1 + - eeprom.write 0 00 + - eeprom.read 0 1 + - eeprom.erase + - eth.open + - eth.status + - eth.close diff --git a/validation/host/boards/nucleo_wba55cg.yaml b/validation/host/boards/nucleo_wba55cg.yaml new file mode 100644 index 00000000..c20ea485 --- /dev/null +++ b/validation/host/boards/nucleo_wba55cg.yaml @@ -0,0 +1,674 @@ +--- +# NUCLEO-WBA55CG (STM32WBA55CG, UFQFPN48), 100 MHz from the 32 MHz HSE PLL +# with every APB prescaler at 1. Pins may be written as P or as +# an alias from `pins`. +board: nucleo_wba55cg +firmware_name: NUCLEO-WBA55CG +family: stm32wba55 +description: NUCLEO-WBA55CG (Nucleo-64 with STM32WBA55CG) +sysclk: 100000000 + +# Kernel clocks the expectations use (expect.py), in Hz. +clocks: + sysclk: 100000000 + pclk1: 100000000 + pclk2: 100000000 + timer: 100000000 + spi: + 1: 100000000 + 3: 100000000 + uart: + usart1: 100000000 + usart2: 100000000 + lpuart1: 100000000 + +terminal: + baud: 921600 + interface: ST-LINK virtual COM port (USART1) + uart: 1 + pins: [terminaltx, terminalrx] + max_command_length: 255 + command_timeout: 2.0 + boot_timeout: 5.0 + +# PROTOCOL.md board profile, compared with `board.pins` in both directions. +pins: + terminaltx: PB12 + terminalrx: PA8 + ain2: PA7 + ain3: PA6 + ain4: PA5 + ain7: PA2 + ain8: PA1 + ain9: PA0 + ain10: PB9 + tim1ch1: PA11 + tim1ch2: PA12 + tim1ch3: PB4 + tim1ch4: PB3 + tim1ch1n: PB2 + tim1ch2n: PB1 + tim1ch3n: PB0 + tim1bkin: PA2 + tim2ch1: PA5 + tim2ch3: PA7 + tim2ch4: PA6 + tim3ch1: PA10 + tim3ch2: PA1 + tim3ch3: PB14 + tim3ch4: PB9 + tim16ch1: PB9 + tim17ch1: PA1 + tim17ch1n: PB3 + qei1a: PA11 + qei1b: PA12 + qei1idx: PA15 + qei3a: PA10 + qei3b: PA1 + spi1clk: PB4 + spi1miso: PB3 + spi1mosi: PA15 + spi1cs: PA12 + lpuart1tx: PB5 + lpuart1rx: PA10 + lpuart1rts: PB9 + lpuart1cts: PB15 + usart2tx: PB0 + usart2rx: PA11 + usart2rts: PB1 + usart2cts: PB2 + led0: PB4 + led1: PA9 + gpio0: PB14 + gpio1: PA5 + gpio2: PA0 + sw1: PC13 + sw2: PB6 + sw3: PB7 + +ad3: + # V+/V- stay off (null): the Nucleo is powered from the ST-LINK USB. + vplus: null + vminus: null + # Wavegen levels are refused outside this range (3.3 V I/O). + analog_limits: [0.0, 3.3] + +# One fixed bundle covers every test. Header positions follow the Nucleo-64 +# Arduino mapping (check against UM3390). Always connect AD3 GND to a Nucleo +# GND pin. +wiring_sets: + bundle1: + description: >- + SPI1, PWM (TIM1 channels, complementary outputs and break; TIM2 CH1; + TIM3; TIM16/17 with break), LPUART1 and USART2 with flow control, the + TIM1/TIM3 encoders, GPIO, EXTI, LED, watchdog toggle and ADC4; W1/W2 + on two ADC inputs + options: + loopback: >- + jumper PA15 (SPI1 MOSI, D11) to PB3 (SPI1 MISO, D12); DIO1 then only + listens + jumpers: + - >- + PB4 (D13) also drives the blue LED LD1, which loads the SPI1 clock + line + dio: + 0: + pin: spi1clk + note: SPI1 SCK, TIM1 CH3, blue LED (led0), GPIO (D13) + 1: + pin: spi1miso + note: SPI1 MISO, TIM1 CH4, TIM17 CH1N, GPIO (D12) + 2: + pin: spi1mosi + note: SPI1 MOSI, encoder TIM1 index, TIM17 break, GPIO (D11) + 3: + pin: spi1cs + note: SPI1 chip select, TIM1 CH2, encoder TIM1 B, GPIO (D10) + 4: + pin: tim1ch1 + note: TIM1 CH1, encoder TIM1 A, USART2 RX, GPIO (D4) + 5: + pin: tim1ch1n + note: TIM1 CH1N, USART2 CTS, GPIO (D15) + 6: + pin: tim1ch2n + note: TIM1 CH2N, USART2 RTS, GPIO (D14) + 7: + pin: tim1ch3n + note: TIM1 CH3N, USART2 TX, GPIO (D6) + 8: + pin: lpuart1tx + note: LPUART1 TX, GPIO (D1) + 9: + pin: lpuart1rx + note: LPUART1 RX, TIM3 CH1, encoder TIM3 A, GPIO (D0) + 10: + pin: lpuart1rts + note: LPUART1 RTS, TIM3 CH4, TIM16 CH1, GPIO (D7) + 11: + pin: lpuart1cts + note: LPUART1 CTS, TIM16 break, GPIO (D8) + 12: + pin: tim3ch2 + note: TIM3 CH2, encoder TIM3 B, TIM17 CH1, GPIO (A3) + 13: + pin: gpio0 + note: >- + GPIO/EXTI, TIM3 CH3, encoder TIM3 index, watchdog warning toggle + (D5) + 14: + pin: tim1bkin + note: TIM1 break input, GPIO (A2) + 15: + pin: tim2ch1 + note: TIM2 CH1, GPIO (A4) + wavegen: + 1: + pin: ain2 + note: ADC4 IN2 (A0) + 2: + pin: ain3 + note: ADC4 IN3 (A1) + scope: + 1: ain2 + 2: ain3 + +# Driver gaps the HIL tests run into (tests/conftest.py): `tests` match the +# test ids relative to tests/hil (`*` and `?` are wildcards, brackets are +# literal); `hangs` gaps abort or hang the firmware, so their tests are +# skipped unless --run-known-gaps; the others are expected failures. --fake +# ignores them. The centre-aligned period of PwmStm.cpp:381-390 is off the +# frequency tolerance at short periods only, so test_pwm.py::test_waveform +# expects that failure per case (expect.pwm_frequency_driver). +known_gaps: + - tests: + - test_pwm.py::test_waveform* + - test_pwm.py::test_frequency_limits* + - test_pwm.py::test_channels* + - test_pwm.py::test_complementary* + - test_pwm.py::test_dead_time_limits[1000000] + - test_pwm.py::test_idle_levels* + - test_pwm.py::test_break_input* + - test_pwm.py::test_frequency_change_and_stop* + - test_pwm.py::test_duty_errors + - test_pwm.py::test_one_timer_at_a_time + - test_pwm.py::test_reopen_forgets_dead_time_and_break + reason: >- + hal_st/stm32fxxx/PwmStm.cpp:256-261 - every pwm.open aborts the + firmware: ConfigureTimeBase calls HAL_TIM_PWM_Init with Period = 0, the + IS_TIM_PERIOD assert (USE_FULL_ASSERT) ends in assert_failed and abort() + hangs: true + - tests: + - test_pwm.py::test_reopen_forgets_dead_time_and_break + reason: >- + hal_st/stm32fxxx/PwmStm.cpp:238-250 - dead time, break and idle settings + (BDTR, AF1) survive into the next open of the same timer + hangs: false + - tests: + - test_uart.py::test_close_during_stalled_send[*variant=interrupt*] + reason: >- + hal_st/stm32fxxx/UartStm.cpp:102 - closing a UART during a stalled send + leaves TXEIE set: the next open of that instance ends in an interrupt + storm + hangs: true + - tests: + - test_uart.py::test_send_timeout_while_cts_held[*variant=sync*] + - test_uart.py::test_close_during_stalled_send[*variant=sync*] + reason: >- + hal_st/synchronous_stm32fxxx/SynchronousUartStm.cpp:58 - with sync=1 + and CTS held off SendData waits for TXE without a timeout and blocks the + event loop for good + hangs: true + - tests: + - test_adc.py::test_dc_levels* + - test_adc.py::test_sequence* + - test_adc.py::test_trigger_rate* + - test_adc.py::test_measure_timeout + - test_adc.py::test_trigger_timer_busy_for_pwm_and_encoder + - test_adc.py::test_measure_limits + - test_adc.py::test_adc_pin_blocks_gpio + - test_adc.py::test_sampling_time[*sampling=3.5*] + - test_adc.py::test_sampling_time[*sampling=7.5*] + - test_adc.py::test_sampling_time[*sampling=12.5*] + - test_adc.py::test_sampling_time[*sampling=19.5*] + - test_adc.py::test_sampling_time[*sampling=39.5*] + - test_adc.py::test_sampling_time[*sampling=79.5*] + - test_adc.py::test_sampling_time[*sampling=814.5*] + reason: >- + hal_st/stm32fxxx/AdcDmaMultiChannelStm.cpp:105 - adc.open aborts unless + sampling=1.5: the per-channel ADC_SAMPLETIME_x reaches + HAL_ADC_ConfigChannel, where ADC4 takes only + ADC_SAMPLINGTIME_COMMON_1/2 (assert_param ends in abort()) + hangs: true + - tests: + - test_adc.py::test_sequence[*length=2*] + - test_adc.py::test_sequence[*length=3*] + - test_adc.py::test_sequence[*length=4*] + - test_adc.py::test_sequence[*length=8*] + - test_adc.py::test_measure_limits + reason: >- + hal_st/stm32fxxx/AnalogToDigitalPinStm.cpp:183,193 and + AdcDmaMultiChannelStm.cpp:114 - a sequence of more than one pin converts + one wrong channel (scan mode off, NbrOfConversion 1): software-triggered + adc.measure answers ERR timeout + hangs: false + - tests: + - test_adc.py::test_trigger_rate[*timer=1*] + reason: >- + hal_st/stm32fxxx/AdcTimerTriggeredBase.cpp:20-21 and TimerStm.cpp:191 - + timer=1 never triggers: the ADC waits for TIM1 TRGO2 while the timer + drives TRGO, so adc.measure answers ERR timeout + hangs: false + - tests: + - test_spi.py::test_receive_only_first[*variant=interrupt*] + reason: >- + hal_st/stm32fxxx/SpiMasterStm.cpp:160-168 - a receive-only first + transfer after spi.open never starts (CSTART is not set on the dummy + transmit path): ERR timeout + hangs: false + - tests: + - test_spi.py::test_receive_only_first[*variant=sync*] + reason: >- + hal_st/synchronous_stm32fxxx/SynchronousSpiMasterStm.cpp:132-139 - a + receive-only first transfer after spi.open never starts (CSTART is not + set on the dummy transmit path) and the polling loop spins forever + hangs: true + - tests: + - test_pwm.py::test_break_input[*brkpol=low*] + reason: >- + hal_st/stm32fxxx/PwmStm.cpp:318,349 - brkpol=low sets BDTR.BKP=0 and + AF1.BKINP=1, which inverts the input a second time: the break acts + active high + hangs: false + - tests: + - test_uart.py::test_swap_does_not_survive_close* + reason: >- + hal_st/stm32fxxx/UartStm.cpp:82-88 - CR2.SWAP is only written when + swapTxRx is set, so a swap=1 session leaves the instance swapped for + every later open until reset + hangs: false + +# Test parameters. A mapping used by `@pytest.mark.matrix` is one dimension +# per key: `--depth full` runs the cartesian product, `--depth quick` a +# pairwise subset. Lists used by `@pytest.mark.board_params` are one +# dimension each; a list of mappings with a `name` uses it as the test id. +tests: + system: + boot_timeout: 5.0 + # USART1 is the terminal: `uart.open 1` answers busy. + reserved_uarts: [1] + # Blinking debug LED (red LD3), reserved by the firmware. + debug_led: PB8 + # SWD, LSE crystal and BOOT0: reserved like the terminal pins. + reserved_pins: [PA13, PA14, PC14, PC15, PH3] + # Instances the MCU lacks: ERR range (0 too, the peripherals start at 1). + missing_instances: + - uart.open 0 + - spi.open 0 + - pwm.open 0 + - qei.open 0 + - spi.open 2 + - adc.open 1 + - pwm.open 4 + - qei.open 4 + - wdt.start 1 timeout=100 + # Features the MCU lacks: ERR unsupported (no LPTIM encoder on the WBA55). + unsupported_instances: + - qei.open 1 lp=1 + - qei.open 16 + # A pin the package does not bond out (or a port it lacks): ERR pin. + unbonded_pins: [PA4, PC0, PD0] + # Names that are no pin: ERR pin. + invalid_pins: [PA16, PA08, PD0, LED0, nosuchalias] + delays_ms: [10, 200] + gpio: + # bundle1 pins used as plain GPIO, on ports A and B. + loop_pins: [gpio0, PA11, PB0, PA12, PA5] + output_pins: [led0] + # hal::Speed of the output stage. + drives: [low, medium, fast, high] + pulls: [up, down] + input_pulls: [none, up, down] + # RAM holds `limit` GPIO pins; `limit_pins` names one more. + limit: 8 + limit_pins: [gpio0, gpio1, gpio2, led0, led1, PA11, PB0, PA12, PB2] + irq: + edge: [rising, falling, both] + handler: [immediate, dispatched] + pulses: [1, 10, 100] + frequency: [100, 1000, 10000] + pulse: + count: 10 + periods_ms: [1, 5, 20] + tolerance: 0.05 + jitter_ms: 0.5 + # Two wired pins on the same EXTI line (index) of different ports: while + # `counting` counts edges, `sharing` answers ERR unsupported. + exti_sharing: + counting: PB2 + sharing: PA2 + pwm: + # Timers with their wired outputs, in the order the tests use them; + # `npin` is the complementary output, `brk` a wired break input. + timers: + - name: tim1 + timer: 1 + channels: + - channel: 1 + pin: tim1ch1 + npin: tim1ch1n + - channel: 2 + pin: tim1ch2 + npin: tim1ch2n + - channel: 3 + pin: tim1ch3 + npin: tim1ch3n + - channel: 4 + pin: tim1ch4 + brk: tim1bkin + - name: tim2 + timer: 2 + channels: + - channel: 1 + pin: tim2ch1 + - name: tim3 + timer: 3 + channels: + - channel: 1 + pin: tim3ch1 + - channel: 2 + pin: tim3ch2 + - channel: 3 + pin: tim3ch3 + - channel: 4 + pin: tim3ch4 + - name: tim16 + timer: 16 + channels: + - channel: 1 + pin: tim16ch1 + brk: PB15 + - name: tim17 + timer: 17 + channels: + - channel: 1 + pin: tim17ch1 + npin: tim17ch1n + brk: PA15 + # One channel of each timer; ERR range exactly where the period does + # not fit (expect.pwm_fits), unsupported for center on TIM16/TIM17. + waveform: + freq: [100, 1000, 10000, 20000, 100000, 1000000] + duty: [0, 12.5, 50, 90, 100] + mode: [edge, center] + prescaler: [0, 1, 63, 999, 65535] + sync: [0, 1] + # 1-4 channels of a timer (as many as it has wired), aligned edges. + channels: + count: [1, 2, 3, 4] + mode: [edge, center] + sync: [0, 1] + channels_frequency: 10000 + channel_duties: [20, 40, 60, 80] + # Channel and complementary output (timers with `npin`): dead time in ns + # (saturating at expect.PWM_DEAD_MAX_TICKS), inversion [inv, invn]. + complementary: + dead: [0, 100, 500, 2000, 20000] + inversion: [[0, 0], [1, 0], [0, 1], [1, 1]] + mode: [edge, center] + sync: [0, 1] + complementary_frequency: 20000 + # Output levels while stopped (timers with a break function). + idle: + idle: [0, 1] + idlen: [0, 1] + # Break input (timers with `brk`) driven by its DIO. + break: + brkpol: [low, high] + brkauto: [0, 1] + break_settle_s: 0.01 + # A pin without any function of the first timer. + foreign_pin: gpio0 + dead_limit_ns: [1000000, 1000001] + frequency_changes: [10000, 25000] + frequency_change: + mode: [edge, center] + sync: [0, 1] + # Lowest and highest frequency of each timer (expect.pwm_frequency_limits). + limits: + mode: [edge, center] + prescaler: [0, 63, 65535] + capture_periods: 20 + tolerance: + frequency: 0.01 + duty: 1.0 + dead_ticks: 2 + alignment_s: 2.0e-7 + static_fraction: 0.99 + uart: + instances: + - name: lpuart1 + index: 1 + lp: 1 + tx: lpuart1tx + rx: lpuart1rx + rts: lpuart1rts + cts: lpuart1cts + variants: [interrupt, dma] + # Opens on lpuart1tx/lpuart1rx without pins. + default_pins: true + - name: usart2 + index: 2 + lp: 0 + tx: usart2tx + rx: usart2rx + rts: usart2rts + cts: usart2cts + variants: [interrupt, dma, duplex, sync] + variants: + interrupt: + dma: 0 + duplex: 0 + sync: 0 + dma: + dma: 1 + duplex: 0 + sync: 0 + duplex: + dma: 0 + duplex: 1 + sync: 0 + sync: + dma: 0 + duplex: 0 + sync: 1 + transfer: + baud: [38400, 57600, 115200, 230400, 460800, 921600] + parity: [none, even, odd] + variant: [interrupt, dma, duplex, sync] + # Asynchronous drivers take rtscts only; rts and cts alone need sync=1. + flow: + flow: [rts, cts, rtscts] + variant: [interrupt, dma, duplex, sync] + swap: + variant: [interrupt, dma, duplex] + # CTS held off: uart.send answers ERR timeout. + stall: + variant: [interrupt, dma, duplex, sync] + stall_payload: 64 + flow_baud: 115200 + reopen_settings: [[38400, even], [460800, none], [921600, odd]] + large: + baud: [115200, 921600] + variant: [interrupt, dma, duplex, sync] + # `uart.open` succeeds exactly when expect.uart_baud_fits (at 100 MHz: + # LPUART1 24415 to 12000000, USART2 3052 to 12000000). + open_bauds: [299, 300, 3051, 3052, 24414, 24415, 921600, 12000000, 12000001] + payloads: ["55", "00ff", "0123456789abcdef", "48656c6c6f2c20484954"] + # uart.send takes at most 112 bytes, uart.recv returns at most 256. + large_payload: 112 + large_payload_to_firmware: 240 + stream_rounds: 8 + stream_size: 64 + bit_rate_tolerance: 0.03 + spi: + instances: + - name: spi1 + index: 1 + clk: spi1clk + mosi: spi1mosi + miso: spi1miso + cs: spi1cs + variants: + interrupt: + dma: 0 + sync: 0 + dma: + dma: 1 + sync: 0 + sync: + dma: 0 + sync: 1 + # The clock is the fastest spiclk / 2^n not above baud + # (expect.spi_clock): 3 MHz runs at 1.5625 MHz. + transfer: + mode: [0, 1, 2, 3] + baud: [390625, 1000000, 3000000, 6250000, 20000000, 50000000] + variant: [interrupt, dma, sync] + cs: [gpio, none] + sessions: + mode: [0, 1, 2, 3] + variant: [interrupt, dma, sync] + receive_only: + variant: [interrupt, dma, sync] + largest: + variant: [interrupt, dma, sync] + session_baud: 390625 + # ERR range outside spiclk/256 .. spiclk/2. + open_bauds: [390624, 390625, 50000000, 50000001] + payloads: ["a5", "0102030405060708", "ff00aa55"] + miso_levels: [0, 1] + max_transfer: 64 + tolerance: + baud: 0.05 + adc: + adc: 4 + vref: 3.3 + bits: 12 + tolerance_codes: 40 + spread_codes: 180 + samples: 16 + # The two inputs driven by W1/W2. + inputs: [ain2, ain3] + # Analog-capable pins the sequence tests may add (any reading). + spare_inputs: [ain4, ain7, ain8, ain9, ain10] + levels: + pin: [ain2, ain3] + level: [0.2, 0.8, 1.65, 2.5, 3.1] + trigger: [software, timer] + # Sequences alternate the two driven inputs; every third position takes + # the next spare input (test_adc.sequence_pins). + sequence: + length: [1, 2, 3, 4, 8] + trigger: [software, timer] + timing: + sampling: ["1.5", "3.5", "7.5", "12.5", "19.5", "39.5", "79.5", "814.5"] + trigger: [software, timer] + # Timer-triggered runs: TRGO of `timer` at `rate` runs per second; + # `trigger_rate` times `adc.measure` against the rate. + trigger_rate: + timer: [1, 2] + rate: [100, 1000, 10000] + trigger: + missing_timers: [4] + unsupported_timers: [3, 16, 17] + timer: 2 + rate: 1000 + runs: 64 + # test_trigger_rate compares n=runs with n=1, the shortest of `repeats` + # round trips each, so the command round trip cancels; `jitter_s` is + # what the link's scheduling leaves on top. + repeats: 3 + rate_tolerance: 0.2 + jitter_s: 0.002 + qei: + instances: + - name: tim1 + index: 1 + a: qei1a + b: qei1b + idx: qei1idx + max_res: 65536 + - name: tim3 + index: 3 + a: qei3a + b: qei3b + idx: gpio0 + max_res: 65536 + # Opens on its default pins without a and b. + default_instance: 1 + # No LPTIM on the STM32WBA55. + lp_instances: [] + lp_missing_instances: [] + resolution: 4096 + position: + freq: [100, 1000, 10000, 50000] + cycles: [1, 10, 250] + direction: [fwd, rev] + cap: [ab, a, b] + inva: [0, 1] + invb: [0, 1] + inversions: [[1, 0], [0, 1], [1, 1]] + counts_per_cycle: + ab: 4 + a: 2 + b: 2 + lp_position: + freq: [100, 1000, 10000] + cycles: [1, 10, 250] + direction: [fwd, rev] + inva: [0, 1] + filter: [0, 2, 4, 8] + velocity: + freq: [1000, 10000] + vel: [1000, 10000] + speed_tolerance: 0.03 + rollover: + res: [100, 4096] + offset: [0, 50] + watchdog: + index: 0 + # Warning period 63 * 4096 * prescaler / pclk1 for the smallest + # prescaler not below the timeout (expect.wwdg_warning_period): 5 ms -> + # 5.161 ms, 20 -> 20.644, 100 -> 165.151, 300 -> 330.301. With + # feed=manual the reset one counter tick after the warning can cut the + # EVT wdt line at 5 and 20 ms, so test_behaviour checks the line from + # 100 ms on (expect.wwdg_warning_outruns_reset). + behaviour: + timeout_ms: [5, 20, 100, 300] + feed: [auto, manual] + period: + timeout_ms: [5, 20, 100, 300] + # 330 ms is the longest; above it ERR range. + open_timeouts_ms: [1, 330, 331, 30000, 30001] + pin: gpio0 + manual_timeout_ms: 100 + feed_seconds: 2.0 + observe_periods: 3 + period_tolerance: 0.03 + unsupported: + commands: + - comp.open 1 + - comp.read 1 + - comp.irq 1 rising + - comp.count 1 + - comp.close 1 + - can.open 1 + - can.send 1 0x123 01 + - can.close 1 + - eeprom.write 0 00 + - eeprom.read 0 1 + - eeprom.erase + - eth.open + - eth.status + - eth.close diff --git a/validation/host/hal_st_validation/__init__.py b/validation/host/hal_st_validation/__init__.py new file mode 100644 index 00000000..14b581a6 --- /dev/null +++ b/validation/host/hal_st_validation/__init__.py @@ -0,0 +1,24 @@ +"""Host side of the hal-st hardware-in-the-loop validation app (instrument, terminal and analysis code comes +from `ad3_waveforms_bench`).""" + +from ad3_waveforms_bench.protocol import Event, Response +from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal, TerminalTimeout + +from .config import BoardConfig, Wiring, load_board +from .firmware import Firmware +from .pairwise import combinations, full_product, pairwise + +__all__ = [ + "BoardConfig", + "Event", + "Firmware", + "FirmwareError", + "FirmwareTerminal", + "Response", + "TerminalTimeout", + "Wiring", + "combinations", + "full_product", + "load_board", + "pairwise", +] diff --git a/validation/host/hal_st_validation/config.py b/validation/host/hal_st_validation/config.py new file mode 100644 index 00000000..3fa2b0ca --- /dev/null +++ b/validation/host/hal_st_validation/config.py @@ -0,0 +1,384 @@ +"""Board description and AD3 wiring loaded from `boards/.yaml`.""" + +from __future__ import annotations + +import copy +import functools +import re +from collections.abc import Iterable, Mapping +from dataclasses import dataclass, field, replace +from pathlib import Path +from typing import Any, Literal + +import yaml + +from .protocol import is_alias, normalize_pin + +BOARDS_DIR = Path(__file__).resolve().parent.parent / "boards" +ChannelKind = Literal["dio", "wavegen", "scope"] +_KINDS: tuple[ChannelKind, ...] = ("dio", "wavegen", "scope") +_MISSING = object() + + +class ConfigError(ValueError): + pass + + +@dataclass(frozen=True) +class Connection: + """One AD3 channel wired to one firmware pin.""" + + kind: ChannelKind + channel: int + pin: str | None + role: str | None = None + note: str = "" + requires: str | None = None + # Pins tied to `pin` by a jumper: the channel reaches them as well. + jumpered: tuple[str, ...] = () + + @property + def pins(self) -> tuple[str, ...]: + return ((self.pin,) if self.pin else ()) + self.jumpered + + def describe(self) -> str: + name = {"dio": "DIO", "wavegen": "W", "scope": "Scope "}[self.kind] + parts = [f"{name}{self.channel}", "+".join(self.pins) or "-"] + if self.role: + parts.append(f"({self.role})") + if self.requires: + parts.append(f"[--with {self.requires}]") + if self.note: + parts.append(f"- {self.note}") + return " ".join(parts) + + +@dataclass(frozen=True) +class WiringSet: + name: str + description: str + connections: tuple[Connection, ...] + options: Mapping[str, str] = field(default_factory=dict) + jumpers: tuple[str, ...] = () + + +@dataclass(frozen=True) +class Wiring: + """The merged active wiring sets, restricted to the optional connections that are enabled.""" + + sets: tuple[str, ...] + connections: tuple[Connection, ...] + enabled: frozenset[str] + jumpers: tuple[str, ...] = () + + def channel(self, kind: ChannelKind, pin: str | None = None, role: str | None = None) -> int | None: + for connection in self.connections: + if connection.kind != kind: + continue + if role is not None and connection.role != role: + continue + if pin is not None and pin not in connection.pins: + continue + return connection.channel + return None + + def dio(self, pin: str | None = None, role: str | None = None) -> int | None: + return self.channel("dio", pin, role) + + def wavegen(self, pin: str | None = None, role: str | None = None) -> int | None: + return self.channel("wavegen", pin, role) + + def scope(self, pin: str | None = None, role: str | None = None) -> int | None: + return self.channel("scope", pin, role) + + def has(self, tag: str) -> bool: + return tag in self.enabled + + def describe(self) -> str: + lines = [f"wiring sets: {', '.join(self.sets) or '(none)'}"] + lines += [f" {connection.describe()}" for connection in self.connections] + lines += [f" jumper: {jumper}" for jumper in self.jumpers] + return "\n".join(lines) + + +@dataclass(frozen=True) +class TerminalConfig: + baud: int = 921600 + interface: str = "" + uart: int | None = None + pins: tuple[str, ...] = () + max_command_length: int = 255 + command_timeout: float = 2.0 + boot_timeout: float = 5.0 + + +@functools.cache +def _gap_regex(pattern: str) -> re.Pattern[str]: + """`*` matches any text and `?` one character; everything else, `[` and `]` of parameter ids included, is + literal (unlike fnmatch, whose brackets are character classes).""" + return re.compile("".join(".*" if char == "*" else "." if char == "?" else re.escape(char) for char in pattern)) + + +@dataclass(frozen=True) +class KnownGap: + """A driver gap of the board (`known_gaps` in the board file) that HIL tests run into. + + `tests` are patterns on the test id relative to tests/hil (`test_pwm.py::test_waveform[*mode=center*]`), `reason` + names the code and the symptom, and `hangs` marks gaps that abort or hang the firmware (such tests are skipped + unless `--run-known-gaps`; the others are expected to fail).""" + + tests: tuple[str, ...] + reason: str + hangs: bool = False + + def matches(self, test_id: str) -> bool: + return any(_gap_regex(pattern).fullmatch(test_id) for pattern in self.tests) + + +def known_gap_outcome(gaps: Iterable[KnownGap], test_id: str, run_hanging: bool = False) -> tuple[str, str] | None: + """How a HIL test meets the known gaps: `("skip", reason)` when a matching gap hangs the firmware (unless + `run_hanging`), `("xfail", reason)` for any other match, None when no gap matches.""" + matched = [gap for gap in gaps if gap.matches(test_id)] + if not matched: + return None + hanging = [gap for gap in matched if gap.hangs] + if hanging and not run_hanging: + return "skip", "known gap (firmware aborts/hangs): " + "; ".join(gap.reason for gap in hanging) + return "xfail", "known gap: " + "; ".join(gap.reason for gap in matched) + + +@dataclass(frozen=True) +class Ad3Config: + vplus: float | None = None + vminus: float | None = None + analog_min: float = 0.0 + analog_max: float = 3.3 + + +@dataclass +class BoardConfig: + name: str + family: str + description: str + sysclk: int | None + terminal: TerminalConfig + pins: dict[str, str] + ad3: Ad3Config + wiring_sets: dict[str, WiringSet] + tests: dict[str, Any] + path: Path | None = None + firmware_name: str | None = None + # Kernel clocks in Hz (`sysclk`, `pclk1`, `pclk2`, `timer`, `spi.`, `uart.`). + clocks: dict[str, Any] = field(default_factory=dict) + known_gaps: tuple[KnownGap, ...] = () + + def matches_firmware_name(self, reported: str) -> bool: + """`info`/`EVT boot` report e.g. `NUCLEO-WB55RG` for the `nucleo_wb55rg` board file.""" + expected = self.firmware_name or self.name + return reported.lower().replace("-", "_") == expected.lower().replace("-", "_") + + def resolve_pin(self, pin: str) -> str: + return normalize_pin(pin, self.pins) + + def clock(self, kind: str, instance: int | str | None = None) -> int: + """`clock("timer")`, `clock("spi", 1)`, `clock("uart", "lpuart1")`: a kernel clock of the `clocks` section.""" + node: Any = self.clocks.get(kind) + if instance is not None and isinstance(node, Mapping): + key = instance.lower() if isinstance(instance, str) else instance + node = node.get(key) + if isinstance(node, bool) or not isinstance(node, int): + suffix = "" if instance is None else f".{instance}" + raise ConfigError(f"{self.name}: clocks.{kind}{suffix} is not configured") + return node + + def param(self, path: str, default: Any = _MISSING) -> Any: + """`param("pwm.frequencies")` → `tests.pwm.frequencies`.""" + node: Any = self.tests + for part in path.split("."): + if isinstance(node, Mapping) and part in node: + node = node[part] + elif default is not _MISSING: + return default + else: + raise ConfigError(f"{self.name}: tests.{path} is not configured") + return node + + def matrix(self, path: str) -> dict[str, list[Any]]: + """`tests.` as an ordered mapping of dimension name to a non-empty list of values.""" + node = self.param(path) + if not isinstance(node, Mapping) or not node: + raise ConfigError(f"{self.name}: tests.{path} must be a mapping of dimension to values") + result: dict[str, list[Any]] = {} + for name, values in node.items(): + values = list(values) if isinstance(values, (list, tuple)) else [values] + if not values: + raise ConfigError(f"{self.name}: tests.{path}.{name} has no values") + result[str(name)] = values + return result + + def aliases_of(self, pin: str) -> list[str]: + """Aliases of the board table that name `pin`.""" + resolved = self.resolve_pin(pin) + return [alias for alias, target in self.pins.items() if target == resolved] + + def set_param(self, path: str, value: Any) -> None: + parts = path.split(".") + node = self.tests + for part in parts[:-1]: + node = node.setdefault(part, {}) + node[parts[-1]] = value + + def apply_overrides(self, overrides: Iterable[str]) -> None: + """`pwm.frequencies=[20000]` style overrides (values parsed as YAML).""" + for override in overrides: + path, sep, text = override.partition("=") + if not sep: + raise ConfigError(f"override must be path=value: {override!r}") + self.set_param(path.strip(), yaml.safe_load(text)) + + def wiring(self, names: Iterable[str], enabled: Iterable[str] = ()) -> Wiring: + tags = frozenset(enabled) + selected: list[Connection] = [] + jumpers: list[str] = [] + used: dict[tuple[str, int], str] = {} + names = tuple(name for name in names if name) + for name in names: + if name not in self.wiring_sets: + known = ", ".join(sorted(self.wiring_sets)) + raise ConfigError(f"{self.name}: unknown wiring set {name!r} (known: {known})") + wiring_set = self.wiring_sets[name] + jumpers += [jumper for jumper in wiring_set.jumpers if jumper not in jumpers] + for connection in wiring_set.connections: + if connection.requires and connection.requires not in tags: + continue + key = (connection.kind, connection.channel) + if key in used and used[key] != name: + position, other = next((i, c) for i, c in enumerate(selected) if (c.kind, c.channel) == key) + if other.pins != connection.pins: + raise ConfigError(f"{connection.kind}{connection.channel} is wired differently in sets {used[key]!r} and {name!r}") + if other.role is None and connection.role is not None: + selected[position] = replace(other, role=connection.role, note=other.note or connection.note) + continue + used[key] = name + selected.append(connection) + return Wiring(names, tuple(selected), tags, tuple(jumpers)) + + +def _connection(kind: ChannelKind, channel: Any, spec: Any, pins: Mapping[str, str]) -> Connection: + if isinstance(spec, str) or spec is None: + spec = {"pin": spec} + if not isinstance(spec, Mapping): + raise ConfigError(f"bad {kind}{channel} entry: {spec!r}") + pin = spec.get("pin") + jumpered = spec.get("jumpered") or () + if isinstance(jumpered, str) or not isinstance(jumpered, (list, tuple)): + raise ConfigError(f"{kind}{channel}: jumpered must be a list of pins") + return Connection( + kind=kind, + channel=int(channel), + pin=None if pin is None else normalize_pin(str(pin), pins, strict=True), + role=spec.get("role"), + note=str(spec.get("note", "")), + requires=spec.get("requires"), + jumpered=tuple(normalize_pin(str(other), pins, strict=True) for other in jumpered), + ) + + +def _wiring_set(name: str, raw: Mapping[str, Any], pins: Mapping[str, str]) -> WiringSet: + connections: list[Connection] = [] + for kind in _KINDS: + for channel, spec in (raw.get(kind) or {}).items(): + connections.append(_connection(kind, channel, spec, pins)) + for connection in connections: + if connection.kind == "dio" and not 0 <= connection.channel <= 15: + raise ConfigError(f"{name}: DIO{connection.channel} does not exist on the AD3") + if connection.kind != "dio" and connection.channel not in (1, 2): + raise ConfigError(f"{name}: {connection.kind} channel must be 1 or 2") + return WiringSet( + name=name, + description=str(raw.get("description", "")), + connections=tuple(connections), + options=dict(raw.get("options") or {}), + jumpers=tuple(raw.get("jumpers") or ()), + ) + + +def _clocks(raw: Mapping[str, Any]) -> dict[str, Any]: + """Clock frequencies as ints; per-instance tables keep int keys (`spi: {1: ...}`) or lower-case names + (`uart: {lpuart1: ...}`).""" + result: dict[str, Any] = {} + for kind, value in raw.items(): + if isinstance(value, Mapping): + result[str(kind)] = {(key.lower() if isinstance(key, str) else int(key)): int(hz) for key, hz in value.items()} + else: + result[str(kind)] = int(value) + return result + + +def _known_gaps(raw: Any) -> tuple[KnownGap, ...]: + if not isinstance(raw, list): + raise ConfigError("known_gaps must be a list") + gaps = [] + for entry in raw: + if not isinstance(entry, Mapping) or set(entry) - {"tests", "reason", "hangs"}: + raise ConfigError(f"known_gaps entries take tests, reason and hangs: {entry!r}") + tests, reason, hangs = entry.get("tests"), entry.get("reason"), entry.get("hangs", False) + if not isinstance(tests, list) or not tests or not all(isinstance(test, str) and "::" in test for test in tests): + raise ConfigError(f"known_gaps tests must be :: patterns: {entry!r}") + if not isinstance(reason, str) or not reason.strip() or not isinstance(hangs, bool): + raise ConfigError(f"known_gaps entry needs a reason and a boolean hangs: {entry!r}") + gaps.append(KnownGap(tuple(tests), reason.strip(), hangs)) + return tuple(gaps) + + +def parse_board(raw: Mapping[str, Any], path: Path | None = None) -> BoardConfig: + try: + pins = {str(alias).lower(): normalize_pin(str(pin)) for alias, pin in (raw.get("pins") or {}).items()} + unknown = sorted(alias for alias in pins if not is_alias(alias)) + if unknown: + raise ConfigError(f"aliases outside the generic naming scheme: {', '.join(unknown)}") + terminal_raw = dict(raw.get("terminal") or {}) + terminal_raw["pins"] = tuple(normalize_pin(str(pin), pins, strict=True) for pin in terminal_raw.get("pins", ())) + ad3_raw = dict(raw.get("ad3") or {}) + limits = ad3_raw.pop("analog_limits", None) + if limits is not None: + ad3_raw["analog_min"], ad3_raw["analog_max"] = (float(value) for value in limits) + return BoardConfig( + name=str(raw["board"]), + family=str(raw["family"]), + description=str(raw.get("description", "")), + sysclk=raw.get("sysclk"), + terminal=TerminalConfig(**terminal_raw), + pins=pins, + ad3=Ad3Config(**ad3_raw), + wiring_sets={name: _wiring_set(name, spec or {}, pins) for name, spec in (raw.get("wiring_sets") or {}).items()}, + tests=copy.deepcopy(dict(raw.get("tests") or {})), + path=path, + firmware_name=raw.get("firmware_name"), + clocks=_clocks(raw.get("clocks") or {}), + known_gaps=_known_gaps(raw.get("known_gaps") or []), + ) + except (KeyError, TypeError, ValueError) as error: + raise ConfigError(f"{path or 'board'}: {error}") from error + + +def board_path(name_or_path: str | Path, boards_dir: Path = BOARDS_DIR) -> Path: + candidate = Path(name_or_path) + if candidate.suffix in (".yaml", ".yml") and candidate.exists(): + return candidate + named = boards_dir / f"{name_or_path}.yaml" + if named.exists(): + return named + known = ", ".join(sorted(path.stem for path in boards_dir.glob("*.yaml"))) + raise ConfigError(f"no board {name_or_path!r} (known: {known})") + + +def load_board(name_or_path: str | Path, boards_dir: Path = BOARDS_DIR) -> BoardConfig: + path = board_path(name_or_path, boards_dir) + with path.open(encoding="utf-8") as stream: + raw = yaml.safe_load(stream) + return parse_board(raw, path) + + +def available_boards(boards_dir: Path = BOARDS_DIR) -> list[str]: + return sorted(path.stem for path in boards_dir.glob("*.yaml")) diff --git a/validation/host/hal_st_validation/console.py b/validation/host/hal_st_validation/console.py new file mode 100644 index 00000000..3a17ef82 --- /dev/null +++ b/validation/host/hal_st_validation/console.py @@ -0,0 +1,27 @@ +"""`hal-st-console`: the `ad3-bench-console` with the validation firmware defaults (921600 baud). + +hal-st-console --port /dev/ttyACM0 +hal-st-console --port COM5 -c info -c board.pins +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from ad3_waveforms_bench import console + + +def main(argv: list[str] | None = None) -> int: + return console.main( + argv, + prog="hal-st-console", + name="hal-st validation console", + prompt="hal-st> ", + baud=921600, + history=Path.home() / ".hal_st_validation_history", + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/validation/host/hal_st_validation/expect.py b/validation/host/hal_st_validation/expect.py new file mode 100644 index 00000000..dd374ea3 --- /dev/null +++ b/validation/host/hal_st_validation/expect.py @@ -0,0 +1,357 @@ +"""Expected values derived from STM32 hardware behaviour and the hal-st drivers, used by the HIL tests and the +fake firmware (pure functions). Frequencies are in Hz, times in seconds unless the name says otherwise. + +Sources: `PwmStmBase::SetBaseFrequencyImpl`/`SetDutyCycle`/`EncodeDeadTime` (hal_st/stm32fxxx/PwmStm.cpp), the +ST HAL `UART_DIV_SAMPLING8`/`UART_DIV_LPUART` macros and `IS_UART_BAUDRATE`, the SPI baud-rate prescaler and the +WWDG counter (validation/PROTOCOL.md and the factories in validation/firmware). The `pwm_*` expectations are the +ones the protocol asks for; `*_driver` variants give what the current driver does where it has a known gap. +""" + +from __future__ import annotations + +import math +from typing import Literal + +from ad3_waveforms_bench import analysis + +PwmMode = Literal["edge", "center"] + +# Timer features of STM32WB55/STM32WBA55 (TIM1 advanced; TIM2 32-bit; TIM2/TIM3 general purpose; +# TIM16/TIM17 one channel with complementary output and break). +TIMERS_32BIT = frozenset({2}) +BREAK_TIMERS = frozenset({1, 16, 17}) +SINGLE_CHANNEL_TIMERS = frozenset({16, 17}) +ENCODER_TIMERS = frozenset({1, 2, 3}) +ADC_TRIGGER_TIMERS = frozenset({1, 2}) + +PWM_PRESCALER_MAX = 0xFFFF +PWM_DEAD_MAX_NS = 1_000_000 +# BDTR.DTG at its largest: (32 + 31) * 16 timer clocks. +PWM_DEAD_MAX_TICKS = 1008 + + +def timer_counter_max(timer: int) -> int: + return 0xFFFFFFFF if timer in TIMERS_32BIT else 0xFFFF + + +def timer_has_break(timer: int) -> bool: + """Complementary outputs, dead time, idle levels and the break input need a timer with a break function.""" + return timer in BREAK_TIMERS + + +def timer_has_center_mode(timer: int) -> bool: + return timer not in SINGLE_CHANNEL_TIMERS + + +def timer_has_channel(timer: int, channel: int) -> bool: + """`IS_TIM_CCX_INSTANCE`: TIM16/TIM17 have channel 1 only.""" + return 1 <= channel <= (1 if timer in SINGLE_CHANNEL_TIMERS else 4) + + +def timer_has_complementary(timer: int, channel: int) -> bool: + """`IS_TIM_CCXN_INSTANCE` up to CH3N (hal-st has no CH4N pin function): CH1N-CH3N on TIM1, CH1N on TIM16/TIM17.""" + return timer in BREAK_TIMERS and 1 <= channel <= (1 if timer in SINGLE_CHANNEL_TIMERS else 3) + + +def pwm_clock(timer_clock: int, prescaler: int = 0) -> int: + """`pwmclk` of `pwm.open`: the counter clock.""" + return timer_clock // (prescaler + 1) + + +def pwm_ticks(pwmclk: int, frequency: int, mode: PwmMode) -> int: + """Counter ticks per period as the driver computes them (ARR + 1); halved in centre-aligned mode.""" + ticks = pwmclk // frequency + return ticks // 2 if mode == "center" else ticks + + +def pwm_fits(pwmclk: int, frequency: int, mode: PwmMode, counter_max: int = 0xFFFF) -> bool: + """`pwm.open`/`pwm.freq` answer `ERR range` otherwise: at least 2 ticks, and ARR within the counter.""" + if frequency <= 0: + return False + ticks = pwm_ticks(pwmclk, frequency, mode) + return ticks >= 2 and ticks - 1 <= counter_max + + +def pwm_frequency(pwmclk: int, frequency: int, mode: PwmMode) -> float: + """Output frequency the protocol asks for after quantisation to whole counter ticks: `ticks` per period edge + aligned, `2 * ticks` centre aligned (the counter runs 0 .. ARR .. 0 with ARR = ticks).""" + ticks = pwm_ticks(pwmclk, frequency, mode) + return pwmclk / (2 * ticks if mode == "center" else ticks) + + +def pwm_frequency_driver(pwmclk: int, frequency: int, mode: PwmMode) -> float: + """Output frequency `PwmStm` produces today: centre aligned it halves the ticks and still writes ARR = ticks - 1, + so the period is 2 * (ticks - 1) (hal_st/stm32fxxx/PwmStm.cpp:381-390, a known gap).""" + ticks = pwm_ticks(pwmclk, frequency, mode) + return pwmclk / (2 * (ticks - 1) if mode == "center" else ticks) + + +def pwm_frequency_limits(pwmclk: int, mode: PwmMode, counter_max: int = 0xFFFF) -> tuple[int, int]: + """Lowest and highest frequency `pwm.open`/`pwm.freq` accept at `pwmclk` (at least 2 ticks, ARR within the + counter); the accepted frequencies form one interval.""" + divider = 2 if mode == "center" else 1 + lowest = max(1, pwmclk // (divider * (counter_max + 2)) + 1) + highest = pwmclk // (2 * divider) + while lowest > 1 and pwm_fits(pwmclk, lowest - 1, mode, counter_max): + lowest -= 1 + while not pwm_fits(pwmclk, lowest, mode, counter_max) and lowest <= highest: + lowest += 1 + if lowest > highest: + raise ValueError(f"no frequency fits pwmclk {pwmclk} Hz ({mode})") + return lowest, highest + + +def pwm_duty_counts(pwmclk: int, frequency: int, mode: PwmMode, duty: float) -> int: + """CCR for a duty in percent (`DutyCycle::ToCounts` rounds to nearest over the ticks of a period).""" + return math.floor(pwm_ticks(pwmclk, frequency, mode) * duty / 100 + 0.5) + + +def pwm_duty(pwmclk: int, frequency: int, mode: PwmMode, duty: float) -> float: + """Duty in percent after rounding to whole counts (PWM mode 1, active while CNT < CCR).""" + full = pwm_ticks(pwmclk, frequency, mode) + return 100 * min(pwm_duty_counts(pwmclk, frequency, mode, duty), full) / full + + +def pwm_duty_driver(pwmclk: int, frequency: int, mode: PwmMode, duty: float) -> float: + """Duty `PwmStm` produces today: centre aligned the CCR of `pwm_duty_counts` is compared against ARR = ticks - 1.""" + full = pwm_ticks(pwmclk, frequency, mode) - (1 if mode == "center" else 0) + return 100 * min(pwm_duty_counts(pwmclk, frequency, mode, duty), full) / full + + +def pwm_duty_step(pwmclk: int, frequency: int, mode: PwmMode) -> float: + """Duty resolution in percent: one compare count.""" + return 100 / pwm_ticks(pwmclk, frequency, mode) + + +def pwm_duty_resolvable(pwmclk: int, frequency: int, mode: PwmMode, duty: float) -> bool: + """Whether a duty between 0 and 100 % keeps both edges after rounding to whole counts.""" + return 0 < pwm_duty_counts(pwmclk, frequency, mode, duty) < pwm_ticks(pwmclk, frequency, mode) + + +def pwm_prescaler_for(timer_clock: int, frequency: int, mode: PwmMode, counter_max: int = 0xFFFF) -> int: + """Smallest prescaler whose counter holds `frequency`.""" + for prescaler in range(PWM_PRESCALER_MAX + 1): + pwmclk = pwm_clock(timer_clock, prescaler) + if pwm_fits(pwmclk, frequency, mode, counter_max): + return prescaler + if pwm_ticks(pwmclk, frequency, mode) < 2: + break + raise ValueError(f"{frequency} Hz does not fit any prescaler at {timer_clock} Hz ({mode})") + + +def pwm_dead_fits(dead_ns: int) -> bool: + """`dead` above 1 ms is `ERR range`; longer than the DTG field can encode saturates instead.""" + return 0 <= dead_ns <= PWM_DEAD_MAX_NS + + +def pwm_dead_ticks(dead_ns: int, timer_clock: int) -> int: + """Timer clocks of dead time the DTG field encodes for `dead_ns`, rounded up (`EncodeDeadTime`).""" + if dead_ns <= 0 or timer_clock <= 0: + return 0 + if dead_ns > PWM_DEAD_MAX_TICKS * 1_000_000_000 // timer_clock: + return PWM_DEAD_MAX_TICKS + ticks = -(-dead_ns * timer_clock // 1_000_000_000) + for limit, step in ((127, 1), (254, 2), (504, 8), (PWM_DEAD_MAX_TICKS, 16)): + if ticks <= limit: + return -(-ticks // step) * step + return PWM_DEAD_MAX_TICKS + + +def pwm_dead_time(dead_ns: int, timer_clock: int) -> float: + """Dead time the timer inserts, in seconds.""" + return pwm_dead_ticks(dead_ns, timer_clock) / timer_clock + + +SPI_DIVIDERS = tuple(2**n for n in range(1, 9)) + + +def spi_baud_fits(spiclk: int, baud: int) -> bool: + """`spi.open` answers `ERR range` for `baud` outside spiclk/256 .. spiclk/2.""" + return spiclk <= 256 * baud and 2 * baud <= spiclk + + +def spi_divider(spiclk: int, baud: int) -> int: + """The baud-rate prescaler: the fastest spiclk / 2^n (n = 1-8) not above `baud`.""" + if not spi_baud_fits(spiclk, baud): + raise ValueError(f"{baud} Hz is outside {spiclk}/256 .. {spiclk}/2") + return next(divider for divider in SPI_DIVIDERS if spiclk <= baud * divider) + + +def spi_clock(spiclk: int, baud: int) -> float: + """SCK frequency `spi.open baud=` produces.""" + return spiclk / spi_divider(spiclk, baud) + + +UART_BAUD_MIN = 300 +UART_BAUD_MAX = 12_000_000 +# `IS_UART_BAUDRATE` of the ST HAL, which `HAL_UART_Init` asserts (the firmware is built with USE_FULL_ASSERT). +UART_HAL_BAUD_MAX = {"stm32wb55": 8_000_000, "stm32wba55": 12_499_999} +USART_BRR_MIN = 16 +USART_BRR_MAX = 0xFFFF +LPUART_BRR_MIN = 0x300 +LPUART_BRR_MAX = 0xFFFFF + + +def uart_clock_name(index: int, lp: bool = False) -> str: + """Key of the board file's `clocks.uart` table: `usart2`, `lpuart1`.""" + return f"{'lpuart' if lp else 'usart'}{index}" + + +def uart_baud_max(family: str) -> int: + """Highest `baud` of `uart.open` on `family`: 12000000, or the lower limit the HAL asserts (8000000 on the + STM32WB55).""" + return min(UART_BAUD_MAX, UART_HAL_BAUD_MAX.get(family, UART_BAUD_MAX)) + + +def uart_divider(clock: int, baud: int, lp: bool = False) -> int: + """BRR as the ST HAL computes it: `UART_DIV_SAMPLING8` on a USART, `UART_DIV_LPUART` (256 * clock / baud) on an + LPUART, both rounded to nearest.""" + return ((256 if lp else 2) * clock + baud // 2) // baud + + +def uart_baud_fits(clock: int, baud: int, lp: bool = False, maximum: int = UART_BAUD_MAX) -> bool: + """`uart.open` answers `ERR range` otherwise: `baud` within 300 .. `maximum` (`uart_baud_max`) and the divider + within the baud-rate register (`UartFactory.cpp` `BaudRateFits`).""" + if not UART_BAUD_MIN <= baud <= maximum: + return False + divider = uart_divider(clock, baud, lp) + if lp: + return LPUART_BRR_MIN <= divider <= LPUART_BRR_MAX + return USART_BRR_MIN <= divider <= USART_BRR_MAX + + +def uart_baud_limits(clock: int, lp: bool = False, maximum: int = UART_BAUD_MAX) -> tuple[int, int]: + """Lowest and highest baud rate `uart.open` accepts at `clock` (the accepted rates form one interval).""" + probe = next((baud for baud in (115200, 921600, 9600, 3_000_000) if uart_baud_fits(clock, baud, lp, maximum)), None) + if probe is None: + raise ValueError(f"no baud rate fits {clock} Hz") + low, high = UART_BAUD_MIN, probe + while low < high: + middle = (low + high) // 2 + low, high = (low, middle) if uart_baud_fits(clock, middle, lp, maximum) else (middle + 1, high) + lowest = low + low, high = probe, maximum + while low < high: + middle = (low + high + 1) // 2 + low, high = (middle, high) if uart_baud_fits(clock, middle, lp, maximum) else (low, middle - 1) + return lowest, low + + +def uart_actual_baud(clock: int, baud: int, lp: bool = False) -> float: + return (256 if lp else 2) * clock / uart_divider(clock, baud, lp) + + +def uart_frame_bits(parity: str, stop_bits: int = 1, data_bits: int = 8) -> int: + return 1 + data_bits + (parity != "none") + stop_bits + + +def uart_transfer_time(size: int, baud: int, parity: str = "none", stop_bits: int = 1) -> float: + return size * uart_frame_bits(parity, stop_bits) / baud + + +WWDG_PRESCALERS = (1, 2, 4, 8, 16, 32, 64, 128) +# Counter ticks from a refresh (0x7F) to the early warning (0x40). +WWDG_WARNING_TICKS = 63 +WDT_TIMEOUT_MAX_MS = 30000 + + +def wwdg_tick(pclk1: int, prescaler: int) -> float: + return 4096 * prescaler / pclk1 + + +def wwdg_period(pclk1: int, prescaler: int) -> float: + """Early-warning period: 63 * 4096 * prescaler / PCLK1.""" + return WWDG_WARNING_TICKS * wwdg_tick(pclk1, prescaler) + + +def wwdg_prescaler_for(timeout_ms: int, pclk1: int) -> int | None: + """Smallest prescaler whose early-warning period is at least `timeout_ms`; None means `ERR range`.""" + for prescaler in WWDG_PRESCALERS: + if WWDG_WARNING_TICKS * 4096 * prescaler * 1000 >= timeout_ms * pclk1: + return prescaler + return None + + +def wwdg_warning_period(timeout_ms: int, pclk1: int) -> float: + """Period of `EVT wdt` (and of the `pin=` toggle) for `wdt.start timeout=`.""" + prescaler = wwdg_prescaler_for(timeout_ms, pclk1) + if prescaler is None: + raise ValueError(f"{timeout_ms} ms is beyond the WWDG at {pclk1} Hz") + return wwdg_period(pclk1, prescaler) + + +def wwdg_max_timeout_ms(pclk1: int) -> int: + """Largest `timeout` `wdt.start` accepts (516 ms at 64 MHz, 330 ms at 100 MHz).""" + return WWDG_WARNING_TICKS * 4096 * WWDG_PRESCALERS[-1] * 1000 // pclk1 + + +def terminal_line_time(line: str, baud: int) -> float: + """Time the terminal (8N1) takes to send `line` with the `\\r\\n` before and after it.""" + return uart_transfer_time(len(line) + 4, baud) + + +def wwdg_warning_line(index: int, warnings: int) -> str: + return f"EVT wdt index={index} warning={warnings}" + + +def wwdg_warning_outruns_reset(timeout_ms: int, pclk1: int, baud: int, margin: float = 1.0) -> bool: + """Whether the `EVT wdt` line of an early warning nobody answers (`feed=manual`) reaches the host: the WWDG resets + one counter tick after the warning, while the interrupt only schedules the line, so that tick has to outlast the + line's transmission `margin` times (room for the event loop's latency). False for the shortest timeouts (5 ms on + both boards: a tick of 128 us at 64 MHz, 82 us at 100 MHz against 315 us for the line at 921600 Bd).""" + prescaler = wwdg_prescaler_for(timeout_ms, pclk1) + if prescaler is None: + raise ValueError(f"{timeout_ms} ms is beyond the WWDG at {pclk1} Hz") + return wwdg_tick(pclk1, prescaler) > margin * terminal_line_time(wwdg_warning_line(0, 1), baud) + + +ADC_SAMPLING_TIMES = { + "stm32wb55": ("2.5", "6.5", "12.5", "24.5", "47.5", "92.5", "247.5", "640.5"), + "stm32wba55": ("1.5", "3.5", "7.5", "12.5", "19.5", "39.5", "79.5", "814.5"), +} +ADC_DEFAULT_SAMPLING = {"stm32wb55": "2.5", "stm32wba55": "3.5"} +ADC_MAX_PINS = 8 +ADC_MAX_VALUES = 64 +ADC_RATE_MAX = 100_000 +ADC_DEFAULT_RATE = 1000 + + +def adc_code(volts: float, vref: float = 3.3, bits: int = 12) -> int: + """Ideal raw code for `volts` (1 LSB = vref / 2^bits).""" + return analysis.adc_code(volts, vref, bits) + + +def adc_max_runs(pins: int) -> int: + """Largest `adc.measure n=` for a sequence of `pins` conversions (at most 64 values).""" + return ADC_MAX_VALUES // pins + + +def adc_measure_time(runs: int, rate: int) -> float: + """Time a timer-triggered `adc.measure n=` needs at `rate` runs per second: the measurement starts the + timer, whose first trigger comes one period later.""" + return runs / rate + + +QEI_COUNTS_PER_CYCLE = {"ab": 4, "a": 2, "b": 2} + + +def qei_counts(cycles: int, cap: str = "ab", direction: str = "fwd", inva: bool = False, invb: bool = False) -> int: + """Signed count change for `cycles` quadrature cycles; inverting exactly one phase reverses the direction.""" + forward = (direction == "fwd") != (bool(inva) != bool(invb)) + return QEI_COUNTS_PER_CYCLE[cap] * cycles * (1 if forward else -1) + + +def wrap_delta(delta: int, modulus: int) -> int: + """Signed difference of two positions on a counter that wraps at `modulus`, in (-modulus/2, modulus/2].""" + delta %= modulus + return delta - modulus if delta > modulus // 2 else delta + + +def wrap_position(position: int, modulus: int) -> int: + """Position on a counter that wraps at `modulus` (`res`).""" + return position % modulus + + +def max_hex_payload(max_command_length: int, command_prefix: str) -> int: + """Largest byte count whose hex encoding still fits the firmware's command line.""" + return max(0, (max_command_length - len(command_prefix) - 1) // 2) diff --git a/validation/host/hal_st_validation/fake_firmware.py b/validation/host/hal_st_validation/fake_firmware.py new file mode 100644 index 00000000..12df99e1 --- /dev/null +++ b/validation/host/hal_st_validation/fake_firmware.py @@ -0,0 +1,1200 @@ +"""Offline double of the validation firmware: `FakeFirmware` answers the validation/PROTOCOL.md commands behind +the generic `FakeTerminalDevice` (echo, prompt, `EVT` lines); connect it with `FakeSerial`. + +It validates arguments like EMIL's command groups and the hal-st factories of validation/firmware, in their +order (`usage`, `range`, `pin`, `unsupported` before `busy`, `notopen`), and models what needs no hardware: the +board profiles (ports, bonded pins, reserved pins, instances, the pin functions of the generated pinout tables, +analog pins), pin ownership, the one-instance-per-group limits, timers shared between PWM, encoder and +timer-triggered ADC, EXTI line ownership, ADC trigger timing and WWDG warnings and resets over a (fake) clock. +It models the protocol, not the driver gaps of the board files' `known_gaps`. +Measured signals (DIO levels, analog codes, UART/SPI peers, encoder counts) are not emulated; tests preset +`gpio_levels`, `adc_codes`, `uart_rx` and `spi_miso` instead. +""" + +from __future__ import annotations + +import math +import re +import time +from collections.abc import Callable, Iterable, Mapping +from dataclasses import dataclass, field +from typing import Any + +from ad3_waveforms_bench.fake_terminal import FakeSerial as _FakeSerial +from ad3_waveforms_bench.fake_terminal import FakeTerminalDevice, Handler +from ad3_waveforms_bench.protocol import format_hex + +from . import expect + +__all__ = ["FakeFirmware", "FakeSerial", "UNSUPPORTED_COMMANDS", "WB55_PINS", "WBA55_PINS"] + +# The alias tables of PROTOCOL.md, in the order `board.pins` prints them. +WB55_PINS: dict[str, str] = { + "terminaltx": "PB6", + "terminalrx": "PB7", + "ain1": "PC0", + "ain2": "PC1", + "ain3": "PC2", + "ain4": "PC3", + "ain5": "PA0", + "ain6": "PA1", + "tim1ch1": "PA8", + "tim1ch2": "PA9", + "tim1ch3": "PA10", + "tim1ch4": "PA11", + "tim1ch1n": "PA7", + "tim1ch2n": "PB8", + "tim1ch3n": "PB9", + "tim1bkin": "PB12", + "tim2ch1": "PA15", + "tim2ch2": "PB3", + "tim2ch3": "PA2", + "tim2ch4": "PA3", + "tim16ch1": "PA6", + "tim17ch1": "PB9", + "qei1a": "PA8", + "qei1b": "PA9", + "qei2a": "PA15", + "qei2b": "PB3", + "qei2idx": "PC6", + "lptim1in1": "PC0", + "lptim1in2": "PC2", + "spi1clk": "PA5", + "spi1miso": "PA6", + "spi1mosi": "PA7", + "spi1cs": "PA4", + "lpuart1tx": "PA2", + "lpuart1rx": "PA3", + "lpuart1rts": "PB12", + "lpuart1cts": "PA6", + "led0": "PB0", + "led1": "PB1", + "gpio0": "PC6", + "gpio1": "PC10", + "gpio2": "PC12", + "gpio3": "PC13", + "gpio4": "PE4", + "sw1": "PC4", + "sw2": "PD0", + "sw3": "PD1", +} + +WBA55_PINS: dict[str, str] = { + "terminaltx": "PB12", + "terminalrx": "PA8", + "ain2": "PA7", + "ain3": "PA6", + "ain4": "PA5", + "ain7": "PA2", + "ain8": "PA1", + "ain9": "PA0", + "ain10": "PB9", + "tim1ch1": "PA11", + "tim1ch2": "PA12", + "tim1ch3": "PB4", + "tim1ch4": "PB3", + "tim1ch1n": "PB2", + "tim1ch2n": "PB1", + "tim1ch3n": "PB0", + "tim1bkin": "PA2", + "tim2ch1": "PA5", + "tim2ch3": "PA7", + "tim2ch4": "PA6", + "tim3ch1": "PA10", + "tim3ch2": "PA1", + "tim3ch3": "PB14", + "tim3ch4": "PB9", + "tim16ch1": "PB9", + "tim17ch1": "PA1", + "tim17ch1n": "PB3", + "qei1a": "PA11", + "qei1b": "PA12", + "qei1idx": "PA15", + "qei3a": "PA10", + "qei3b": "PA1", + "spi1clk": "PB4", + "spi1miso": "PB3", + "spi1mosi": "PA15", + "spi1cs": "PA12", + "lpuart1tx": "PB5", + "lpuart1rx": "PA10", + "lpuart1rts": "PB9", + "lpuart1cts": "PB15", + "usart2tx": "PB0", + "usart2rx": "PA11", + "usart2rts": "PB1", + "usart2cts": "PB2", + "led0": "PB4", + "led1": "PA9", + "gpio0": "PB14", + "gpio1": "PA5", + "gpio2": "PA0", + "sw1": "PC13", + "sw2": "PB6", + "sw3": "PB7", +} + +UNSUPPORTED_COMMANDS = ( + "comp.open", + "comp.read", + "comp.irq", + "comp.count", + "comp.close", + "can.open", + "can.send", + "can.close", + "eeprom.write", + "eeprom.read", + "eeprom.erase", + "eth.open", + "eth.status", + "eth.close", +) + +Table = dict[int, tuple[str, ...]] + + +def _table(**instances: str) -> Table: + """`_table(i1="PA9 PB6")` → `{1: ("PA9", "PB6")}`.""" + return {int(name[1:]): tuple(pins.split()) for name, pins in instances.items()} + + +# Pin functions of the generated pinout tables (hal_st/stm32fxxx/PinoutTableDefault), one-based instances. +# The WB55 table also lists pins of larger packages; `bonded` filters them like the firmware does. +_WB55_FUNCTIONS: dict[str, Table] = { + "uartTx": _table(i1="PA9 PB6"), + "uartRx": _table(i1="PA10 PB7"), + "uartRts": _table(i1="PA12 PB3"), + "uartCts": _table(i1="PA11 PB4"), + "lpuartTx": _table(i1="PA2 PB5 PB11 PC1"), + "lpuartRx": _table(i1="PA3 PA12 PB10 PC0"), + "lpuartRts": _table(i1="PB1 PB12"), + "lpuartCts": _table(i1="PA6 PB13"), + "spiClock": _table(i1="PA1 PA5 PB3", i2="PA9 PB10 PB13 PD1 PD3"), + "spiMiso": _table(i1="PA6 PA11 PB4", i2="PB14 PC2 PD3"), + "spiMosi": _table(i1="PA7 PA12 PB5", i2="PB15 PC1 PC3 PD4"), + "timerChannel1": _table(i1="PA8 PD14", i2="PA0 PA5 PA15", i16="PA6 PB8 PE0", i17="PA7 PB9 PE1"), + "timerChannel2": _table(i1="PA9 PD15", i2="PA1 PB3"), + "timerChannel3": _table(i1="PA10", i2="PA2 PB10"), + "timerChannel4": _table(i1="PA11", i2="PA3 PB11"), + "timerChannel1N": _table(i1="PA7 PB13", i16="PB6", i17="PB7"), + "timerChannel2N": _table(i1="PB8 PB14"), + "timerChannel3N": _table(i1="PB9 PB15"), + "timerBreak": _table(i1="PA6 PB7 PB12 PC9", i16="PB5", i17="PA10 PB4"), + "lpTimerInput1": _table(i1="PB5 PC0", i2="PB1 PC0 PD12"), + "lpTimerInput2": _table(i1="PB7 PC2"), +} + +_WBA55_FUNCTIONS: dict[str, Table] = { + "uartTx": _table(i1="PB12 PB14", i2="PA12 PA14 PB0"), + "uartRx": _table(i1="PA8", i2="PA11 PB4 PB8"), + "uartRts": _table(i1="PA2 PA3 PA6", i2="PA15 PB1"), + "uartCts": _table(i1="PA7", i2="PB2 PB15"), + "lpuartTx": _table(i1="PA2 PB5 PB11"), + "lpuartRx": _table(i1="PA1 PA10"), + "lpuartRts": _table(i1="PA9 PB9"), + "lpuartCts": _table(i1="PA0 PB15"), + "spiClock": _table(i1="PB4", i3="PA0"), + "spiMiso": _table(i1="PB3", i3="PB9"), + "spiMosi": _table(i1="PA15", i3="PB8"), + "timerChannel1": _table(i1="PA11 PB8", i2="PA5 PB6 PB12", i3="PA2 PA10 PB5", i16="PA2 PB9", i17="PA1 PB4"), + "timerChannel2": _table(i1="PA12", i2="PA8", i3="PA1 PA9"), + "timerChannel3": _table(i1="PB4", i2="PA7", i3="PA0 PB14"), + "timerChannel4": _table(i1="PB3", i2="PA6", i3="PB9 PB13"), + "timerChannel1N": _table(i1="PA1 PB2", i16="PA3 PB8", i17="PB3"), + "timerChannel2N": _table(i1="PA0 PB1"), + "timerChannel3N": _table(i1="PB0 PB9"), + "timerBreak": _table(i1="PA2", i16="PB10 PB15", i17="PA15"), + "lpTimerInput1": {}, + "lpTimerInput2": {}, +} + + +@dataclass(frozen=True) +class _Family: + family: str + board: str + sysclk: int + ports: str + bonded: dict[str, int] + pins: dict[str, str] + debug_led: str + reserved: tuple[str, ...] + usarts: frozenset[int] + lpuarts: frozenset[int] + # (lp, index) of the UARTs with DMA requests (`board::UartDma`). + uart_dma: frozenset[tuple[bool, int]] + spis: frozenset[int] + timers: frozenset[int] + lptims: frozenset[int] + adc: int + default_lpuart: tuple[str, str] + default_qei: tuple[int, str, str, str] + functions: dict[str, Table] + analog: dict[str, int] + + @property + def sampling(self) -> tuple[str, ...]: + return expect.ADC_SAMPLING_TIMES[self.family] + + +_FAMILIES: dict[str, _Family] = { + "stm32wb55": _Family( + family="stm32wb55", + board="NUCLEO-WB55RG", + sysclk=64_000_000, + ports="ABCDEH", + bonded={"A": 0xFFFF, "B": 0xFFFF, "C": 0xFC7F, "D": 0x0003, "E": 0x0010, "H": 0x0008}, + pins=WB55_PINS, + debug_led="PB5", + reserved=("PA13", "PA14", "PC14", "PC15", "PH3"), + usarts=frozenset({1}), + lpuarts=frozenset({1}), + uart_dma=frozenset({(False, 1), (True, 1)}), + spis=frozenset({1, 2}), + timers=frozenset({1, 2, 16, 17}), + lptims=frozenset({1}), + adc=1, + default_lpuart=("PA2", "PA3"), + default_qei=(2, "PA15", "PB3", "PC6"), + functions=_WB55_FUNCTIONS, + analog={ + **{"PA0": 5, "PA1": 6, "PA2": 7, "PA3": 8, "PA4": 9, "PA5": 10, "PA6": 11, "PA7": 12, "PA8": 15, "PA9": 16}, + **{"PC0": 1, "PC1": 2, "PC2": 3, "PC3": 4, "PC4": 13, "PC5": 14}, + }, + ), + "stm32wba55": _Family( + family="stm32wba55", + board="NUCLEO-WBA55CG", + sysclk=100_000_000, + ports="ABCH", + bonded={"A": 0xFFEF, "B": 0xFFFF, "C": 0xE000, "H": 0x0008}, + pins=WBA55_PINS, + debug_led="PB8", + reserved=("PA13", "PA14", "PC14", "PC15", "PH3"), + usarts=frozenset({1, 2}), + lpuarts=frozenset({1}), + uart_dma=frozenset({(False, 1), (False, 2), (True, 1)}), + spis=frozenset({1, 3}), + timers=frozenset({1, 2, 3, 16, 17}), + lptims=frozenset(), + adc=4, + default_lpuart=("PB5", "PA10"), + default_qei=(1, "PA11", "PA12", "PA15"), + functions=_WBA55_FUNCTIONS, + analog={"PA0": 9, "PA1": 8, "PA2": 7, "PA3": 6, "PA5": 4, "PA6": 3, "PA7": 2, "PA8": 1, "PB9": 10}, + ), +} + +_TERMINAL_USART = 1 +_DEFAULT_LPUART = 1 +# `Instances()` of the factories: instance numbers 0 .. n-1 parse, the others answer `ERR range`. +_INSTANCES = {"uart": 3, "spi": 4, "pwm": 18, "qei": 18, "wdt": 1} +# `hal::peripheralTimer` holds TIM1 .. TIM17. +_TIMER_TABLE_SIZE = 17 +_ADC_KEY_MAX = 0xFFFF +_OPEN_LIMITS = {"pwm": 1, "uart": 1, "spi": 1, "adc": 1, "qei": 1, "gpio": 8} +_PWM_CHANNELS_MAX = 4 +_UART_RECEIVE_CAPACITY = 256 +_UART_TRANSMIT_CAPACITY = 112 +_SPI_CAPACITY = 64 +_DELAY_MAX_MS = 600_000 +_PULSES_MAX = 1_000_000 +_PULSE_PERIOD_MAX_MS = 60_000 +_RECEIVE_TIMEOUT_MAX_MS = 10_000 +_ADC_MEASURE_TIMEOUT = 1.0 +_QEI_RESOLUTION_16BIT = 65536 +_QEI_VELOCITY_MAX_US = 1_000_000 +_LPTIM_FILTERS = (0, 2, 4, 8) +# `HilPinNamingDefault`: a port letter and up to three digits without leading zero. +_PIN_RE = re.compile(r"^[Pp]([A-Za-z])(0|[1-9][0-9]{0,2})$") +_UINT32_MAX = 0xFFFFFFFF + + +class FakeSerial(_FakeSerial): + """`FakeSerial` that lets the device produce time-driven output (watchdog warnings and resets) on reads.""" + + @property + def in_waiting(self) -> int: + self._poll() + return super().in_waiting + + def read(self, size: int = 1) -> bytes: + self._poll() + return super().read(size) + + def write(self, data: bytes) -> int: + self._poll() + return super().write(data) + + def _poll(self) -> None: + poll = getattr(self.device, "poll", None) + if poll is not None: + poll() + + +class _Error(Exception): + def __init__(self, reason: str) -> None: + super().__init__(reason) + self.reason = reason + + +def _fail(reason: str) -> None: + raise _Error(reason) + + +def _parse_uint(text: str) -> int: + """`HilArguments::ParseNumber`: decimal or `0x` hex, unsigned 32 bits.""" + base, digits = 10, "0123456789" + if len(text) > 2 and text[0] == "0" and text[1] in "xX": + base, digits, text = 16, "0123456789abcdefABCDEF", text[2:] + if not text or any(char not in digits for char in text): + _fail("usage") + value = int(text, base) + if value > _UINT32_MAX: + _fail("usage") + return value + + +def _number(text: str | None, low: int = 0, high: int = _UINT32_MAX) -> int: + if text is None: + _fail("usage") + assert text is not None + value = _parse_uint(text) + if not low <= value <= high: + _fail("range") + return value + + +def _flag(options: Mapping[str, str], key: str, default: bool = False) -> bool: + if key not in options: + return default + return _number(options[key], 0, 1) == 1 + + +def _choice(options: Mapping[str, str], key: str, choices: Iterable[str], default: str) -> str: + value = options.get(key, default) + if value not in tuple(choices): + _fail("usage") + return value + + +def _shape(args: list[str], options: Mapping[str, str], low: int, high: int, keys: Iterable[str] = ()) -> None: + """`HilArguments::Shape`: positional count and known keys, else `ERR usage`.""" + allowed = tuple(keys) + if not low <= len(args) <= high or any(key not in allowed for key in options): + _fail("usage") + + +def _tokens(text: str) -> list[str]: + """`infra::Tokenizer` on `,`: empty entries are skipped.""" + return [token for token in text.split(",") if token] + + +def _hex(text: str, capacity: int) -> bytes: + """`HilArguments::ParseHex`: `-` is empty, odd or non-hex is `usage`, beyond `capacity` is `range`.""" + if text == "-": + return b"" + if not text or len(text) % 2: + _fail("usage") + if len(text) // 2 > capacity: + _fail("range") + try: + return bytes.fromhex(text) + except ValueError: + raise _Error("usage") from None + + +def _duty(text: str) -> float: + """`HilArguments::ParseDutyCycle`: decimal percent 0-100 with up to 4 decimals.""" + integer, dot, fraction = text.partition(".") + if not integer.isdigit() or (dot and not (1 <= len(fraction) <= 4 and fraction.isdigit())): + _fail("usage") + value = float(text) + if value > 100: + _fail("usage") + return value + + +@dataclass +class _PwmOutput: + """One entry of `pwm.open`: channel 0 until it is inferred from the pin.""" + + channel: int + pin: str | None = None + npin: str | None = None + + +@dataclass +class FakeFirmware(FakeTerminalDevice): + """Emulates the validation firmware behind `services::HilTerminal`; `style`, `noise` and `emit()` come from + `FakeTerminalDevice`. `board`, `sysclk` and `pins` default to the profile of `family` (every kernel clock + equals `sysclk`, as every APB prescaler is 1). + + The argument checks follow the order of `validation/firmware/*Factory.cpp` and EMIL's command groups, so the + first error of a command line is the one the firmware reports.""" + + board: str | None = None + family: str = "stm32wb55" + sysclk: int | None = None + pins: dict[str, str] | None = None + unrecognized: str = "ERR usage" + uid: str = "0123456789abcdef01234567" + gpio_levels: dict[str, int] = field(default_factory=dict) + gpio_counts: dict[str, int] = field(default_factory=dict) + adc_codes: dict[str, int] = field(default_factory=dict) + uart_rx: dict[int, bytearray] = field(default_factory=dict) + spi_miso: int = 0x00 + opened: dict[tuple[str, str], dict[str, Any]] = field(default_factory=dict) + reset_cause: str = "pin" + # The terminal's line rate: what the reset leaves of a watchdog warning, the time an `adc.measure` reply takes. + terminal_baud: int = 921600 + clock: Callable[[], float] = time.monotonic + sleep: Callable[[float], None] = time.sleep + + def __post_init__(self) -> None: + if self.family not in _FAMILIES: + raise ValueError(f"unknown family {self.family!r}") + spec = _FAMILIES[self.family] + self.board = self.board or spec.board + self.sysclk = self.sysclk or spec.sysclk + if self.pins is None: + self.pins = dict(spec.pins) + self.claims: dict[str, tuple[tuple[str, str], bool]] = {} + self.timer_owners: dict[int, tuple[str, str]] = {} + self.exti: dict[int, str] = {} + self.watchdog: dict[str, Any] | None = None + super().__post_init__() + + @property + def spec(self) -> _Family: + return _FAMILIES[self.family] + + @property + def kernel_clock(self) -> int: + assert self.sysclk is not None + return self.sysclk + + def boot(self) -> None: + self.opened.clear() + self.claims = {} + self.timer_owners = {} + self.exti = {} + self.watchdog = None + super().boot() + + def event(self, line: str) -> None: + """Print an asynchronous `EVT` line now, as a complete line whatever the output style.""" + self._write(f"\r\n{line}\r\n") + + def boot_message(self) -> str: + return f"EVT boot board={self.board} family={self.family} sysclk={self.sysclk} reset={self.reset_cause}" + + def lookup(self, name: str) -> Handler | None: + handler = self.handlers.get(name) + if handler is not None: + return handler + if name in UNSUPPORTED_COMMANDS: + return lambda device, args, options: "ERR unsupported" + method = getattr(self, "_cmd_" + name.replace(".", "_"), None) + if method is None: + return None + + def run(device: FakeTerminalDevice, args: list[str], options: dict[str, str]) -> str | list[str] | None: + try: + return method(args, options) + except _Error as error: + return f"ERR {error.reason}" + + return run + + # pins and instances + + def pin(self, text: str | None) -> str | None: + """`HilPinNamingDefault::Parse`: canonical name of an alias or of `P` with a port of the MCU + and an index up to 15, else `ERR pin`. Bonding is checked where the pin is used, as the firmware does.""" + if text is None: + return None + resolved = (self.pins or {}).get(text, text) + match = _PIN_RE.match(resolved) + if match is None: + _fail("pin") + assert match is not None + port, index = match.group(1).upper(), int(match.group(2)) + if port not in self.spec.ports or index > 15: + _fail("pin") + return f"P{port}{index}" + + def bonded(self, pin: str) -> bool: + """`IsBonded`: the package bonds the pin out.""" + return bool(self.spec.bonded.get(pin[1], 0) & (1 << int(pin[2:]))) + + def reserved(self) -> set[str]: + terminal = {pin for alias, pin in (self.pins or {}).items() if alias in ("terminaltx", "terminalrx")} + return terminal | {self.spec.debug_led, *self.spec.reserved} + + def supports(self, function: str, instance: int, pin: str | None) -> bool: + """`SupportsFunction`: a bonded pin the pinout table offers for `function` of `instance`.""" + return pin is not None and self.bonded(pin) and pin in self.spec.functions.get(function, {}).get(instance, ()) + + def supports_analog(self, pin: str) -> bool: + return self.bonded(pin) and pin in self.spec.analog + + def _check_function(self, function: str, instance: int, pin: str | None) -> None: + if pin is not None and not self.supports(function, instance, pin): + _fail("pin") + + def _first_function_pin(self, function: str, instance: int) -> str | None: + """`FindFunctionPin`: the first bonded pin of the table that is not reserved.""" + for pin in self.spec.functions.get(function, {}).get(instance, ()): + if self.bonded(pin) and pin not in self.reserved(): + return pin + return None + + def _check_pins(self, owner: tuple[str, str], pins: Iterable[str | None], analog: bool = False) -> None: + """`HilPinPool::Claim`: `pin` for a pin the package lacks, `busy` for a reserved or held pin (analog users + share a pin).""" + seen: set[str] = set() + for pin in pins: + if pin is None: + continue + if not self.bonded(pin): + _fail("pin") + if pin in self.reserved(): + _fail("busy") + holder = self.claims.get(pin) + if holder is not None and holder[0] != owner and not (analog and holder[1]): + _fail("busy") + if pin in seen and not analog: + _fail("busy") + seen.add(pin) + + def _claim(self, owner: tuple[str, str], pins: Iterable[str | None], analog: bool = False) -> None: + for pin in pins: + if pin is not None: + self.claims.setdefault(pin, (owner, analog)) + + def _release(self, owner: tuple[str, str]) -> None: + self.claims = {pin: holder for pin, holder in self.claims.items() if holder[0] != owner} + self.timer_owners = {timer: holder for timer, holder in self.timer_owners.items() if holder != owner} + + def _timer_exists(self, timer: int) -> bool: + return timer in self.spec.timers + + def _open( + self, + group: str, + key: str, + state: dict[str, Any], + pins: Iterable[str | None], + analog: bool = False, + timer: int | None = None, + ) -> None: + """Claims an instance after its arguments were validated: everything here answers `ERR busy`.""" + owner = (group, key) + pins = list(pins) + if owner in self.opened or sum(1 for opened_group, _ in self.opened if opened_group == group) >= _OPEN_LIMITS[group]: + _fail("busy") + if timer is not None and self.timer_owners.get(timer, owner) != owner: + _fail("busy") + self._check_pins(owner, pins, analog) + self._claim(owner, pins, analog) + if timer is not None: + self.timer_owners[timer] = owner + self.opened[owner] = state + + def _index(self, group: str, text: str) -> int: + """`HilSingleInstance::Parse`: the instance number of the command.""" + return _number(text, 0, _INSTANCES[group] - 1) + + def _find(self, group: str, text: str) -> tuple[str, dict[str, Any]]: + key = str(self._index(group, text)) + state = self.opened.get((group, key)) + if state is None: + _fail("notopen") + assert state is not None + return key, state + + def _close(self, group: str, key: str) -> str: + del self.opened[(group, key)] + self._release((group, key)) + return "OK" + + # general + + def _cmd_ping(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 0, 0) + return "OK" + + def _cmd_info(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 0, 0) + return f"OK board={self.board} family={self.family} sysclk={self.sysclk} reset={self.reset_cause} uid={self.uid}" + + def _cmd_board_pins(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 0, 0) + return "OK " + ",".join(f"{alias}={pin}" for alias, pin in (self.pins or {}).items()) + + def _cmd_delay(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + self.sleep(_number(args[0], 0, _DELAY_MAX_MS) / 1000) + return "OK" + + def _cmd_reset(self, args: list[str], options: dict[str, str]) -> None: + self.reset_cause = "sw" + self.boot() + return None + + # gpio (`HilGpioCommands`) + + def _gpio(self, text: str) -> tuple[str, dict[str, Any]]: + """`HilGpioCommands::Find`: `pin` for a name that is no pin, `notopen` for a pin it does not hold.""" + pin = self.pin(text) + assert pin is not None + state = self.opened.get(("gpio", pin)) + if state is None: + _fail("notopen") + assert state is not None + return pin, state + + def _gpio_free(self, pin: str) -> None: + line = int(pin[2:]) + if self.exti.get(line) == pin: + del self.exti[line] + del self.opened[("gpio", pin)] + self._release(("gpio", pin)) + + def _cmd_gpio_cfg(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 2, ("pull", "drive")) + pin = self.pin(args[0]) + assert pin is not None + mode = args[1] + if mode not in ("in", "out", "od"): + _fail("usage") + pull = _choice(options, "pull", ("none", "up", "down"), "none") + drive = _choice(options, "drive", ("low", "medium", "fast", "high"), "low") + if mode == "od" and pull != "none": + _fail("usage") + if ("gpio", pin) in self.opened: + self._gpio_free(pin) + self._open("gpio", pin, {"mode": mode, "pull": pull, "drive": drive, "irq": "off"}, [pin]) + if mode == "out": + self.gpio_levels[pin] = 0 + elif mode == "od": + self.gpio_levels[pin] = 1 + else: + self.gpio_levels.setdefault(pin, 1 if pull == "up" else 0) + self.gpio_counts[pin] = 0 + return "OK" + + def _cmd_gpio_set(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 2) + pin, _ = self._gpio(args[0]) + if args[1] not in ("0", "1"): + _fail("usage") + self.gpio_levels[pin] = int(args[1]) + return "OK" + + def _cmd_gpio_get(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + pin, _ = self._gpio(args[0]) + return f"OK value={self.gpio_levels.get(pin, 0)}" + + def _cmd_gpio_pulse(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 3, 3) + pin, state = self._gpio(args[0]) + count = _number(args[1], 1, _PULSES_MAX) + _number(args[2], 1, _PULSE_PERIOD_MAX_MS) + if state["mode"] == "in": + _fail("usage") + self.gpio_levels[pin] = self.gpio_levels.get(pin, 0) ^ (count & 1) + return "OK" + + def supports_interrupt(self, pin: str) -> bool: + """Port H is refused (`hal::GpioStm` writes the compacted port number into EXTICR); an EXTI line + serves one port at a time.""" + owner = self.exti.get(int(pin[2:])) + return self.bonded(pin) and pin[1] != "H" and (owner is None or owner == pin) + + def _cmd_gpio_irq(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 2, ("type",)) + pin, state = self._gpio(args[0]) + edge = args[1] + if edge not in ("rising", "falling", "both", "off"): + _fail("usage") + _choice(options, "type", ("immediate", "dispatched"), "dispatched") + if not self.supports_interrupt(pin): + _fail("unsupported") + line = int(pin[2:]) + if edge == "off": + self.exti.pop(line, None) + else: + self.exti[line] = pin + state["irq"] = edge + return "OK" + + def _cmd_gpio_count(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("clear",)) + pin, _ = self._gpio(args[0]) + clear = _flag(options, "clear") + count = self.gpio_counts.get(pin, 0) + if clear: + self.gpio_counts[pin] = 0 + return f"OK count={count}" + + def _cmd_gpio_release(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + pin, _ = self._gpio(args[0]) + self._gpio_free(pin) + return "OK" + + # pwm (`PwmFactory.cpp` `Evaluate`) + + def _pwm_entry(self, text: str) -> _PwmOutput: + """`ParseOutput`: `[:]`, `-` for an unused position.""" + first, separator, second = text.partition(":") + if not separator: + second = "-" + elif ":" in second: + _fail("usage") + pin = None if first == "-" else self.pin(first) + npin = None if second == "-" else self.pin(second) + if pin is None and npin is None: + _fail("usage") + return _PwmOutput(0, pin, npin) + + def _pwm_outputs(self, options: Mapping[str, str]) -> list[_PwmOutput]: + """`ParseOutputs`: the `channels`/`pins` lists (`usage`, `range`, `pin` for names that are no pin).""" + channels_text, pins_text = options.get("channels"), options.get("pins") + if channels_text is None and pins_text is None: + _fail("usage") + channels = None if channels_text is None else [_number(token, 1, _PWM_CHANNELS_MAX) for token in _tokens(channels_text)] + entries = None if pins_text is None else [self._pwm_entry(token) for token in _tokens(pins_text)] + count = len(channels) if channels is not None else len(entries or []) + if not 1 <= count <= _PWM_CHANNELS_MAX or (channels is not None and entries is not None and len(entries) != len(channels)): + _fail("usage") + if channels is not None and len(set(channels)) != len(channels): + _fail("usage") + outputs = entries if entries is not None else [_PwmOutput(0) for _ in range(count)] + for position, output in enumerate(outputs): + if channels is not None: + output.channel = channels[position] + return outputs + + def _pwm_channel_of(self, timer: int, output: _PwmOutput) -> int | None: + """`ChannelOfPin`: the first channel (or complementary channel) the pin offers on `timer`.""" + if output.pin is not None: + return next((channel for channel in range(1, 5) if self.supports(f"timerChannel{channel}", timer, output.pin)), None) + return next((channel for channel in range(1, 4) if self.supports(f"timerChannel{channel}N", timer, output.npin)), None) + + def _pwm_resolve(self, timer: int, outputs: list[_PwmOutput]) -> None: + """`ResolveOutputs`: channels from pins, the timer's channels and the pins of each channel.""" + for output in outputs: + if output.channel == 0: + channel = self._pwm_channel_of(timer, output) + if channel is None: + _fail("pin") + assert channel is not None + output.channel = channel + if len({output.channel for output in outputs}) != len(outputs): + _fail("usage") + for output in outputs: + channel = output.channel + if not expect.timer_has_channel(timer, channel) or ( + output.npin is not None and not expect.timer_has_complementary(timer, channel) + ): + _fail("unsupported") + if output.pin is None and output.npin is None: + output.pin = self._first_function_pin(f"timerChannel{channel}", timer) + if output.pin is None: + _fail("pin") + self._check_function(f"timerChannel{channel}", timer, output.pin) + self._check_function(f"timerChannel{channel}N", timer, output.npin) + + def _cmd_pwm_open(self, args: list[str], options: dict[str, str]) -> str: + keys = ("channels", "pins", "freq", "mode", "prescaler", "dead", "inv", "invn", "idle", "idlen", "brk", "brkpol", "brkauto", "sync") + _shape(args, options, 1, 1, keys) + timer = self._index("pwm", args[0]) + if not self._timer_exists(timer): + _fail("range") + freq = _number(options.get("freq", "10000"), 1) + mode = _choice(options, "mode", ("edge", "center"), "edge") + prescaler = _number(options.get("prescaler", "0"), 0, expect.PWM_PRESCALER_MAX) + dead = None if options.get("dead", "off") == "off" else _number(options["dead"], 0, expect.PWM_DEAD_MAX_NS) + flags = {key: _flag(options, key) for key in ("inv", "invn", "idle", "idlen", "brkauto", "sync")} + _choice(options, "brkpol", ("low", "high"), "high") + brk = self.pin(options.get("brk")) + outputs = self._pwm_outputs(options) + if mode == "center" and not expect.timer_has_center_mode(timer): + _fail("unsupported") + complementary = any(output.npin is not None for output in outputs) + needs_break = complementary or dead is not None or flags["idle"] or flags["idlen"] or brk is not None + if needs_break and not expect.timer_has_break(timer): + _fail("unsupported") + self._pwm_resolve(timer, outputs) + self._check_function("timerBreak", timer, brk) + pwmclk = expect.pwm_clock(self.kernel_clock, prescaler) + counter_max = expect.timer_counter_max(timer) + if not expect.pwm_fits(pwmclk, freq, mode, counter_max): + _fail("range") + channels = [(output.channel, output.pin, output.npin) for output in outputs] + pins = [pin for _, first, second in channels for pin in (first, second)] + [brk] + state = {"channels": channels, "freq": freq, "mode": mode, "pwmclk": pwmclk, "counter_max": counter_max, "running": False} + state.update(dead=dead, brk=brk, sync=flags["sync"], duties=None) + self._open("pwm", str(timer), state, pins, timer=timer) + return f"OK pwmclk={pwmclk}" + + def _cmd_pwm_duty(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 1 + _PWM_CHANNELS_MAX) + _, state = self._find("pwm", args[0]) + duties = args[1:] + if len(duties) not in (1, len(state["channels"])): + _fail("usage") + state["duties"] = [_duty(duty) for duty in duties] + state["running"] = True + return "OK" + + def _cmd_pwm_freq(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 2) + _, state = self._find("pwm", args[0]) + freq = _number(args[1], 1) + if not expect.pwm_fits(state["pwmclk"], freq, state["mode"], state["counter_max"]): + _fail("range") + state["freq"] = freq + return "OK" + + def _cmd_pwm_stop(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + _, state = self._find("pwm", args[0]) + state["running"] = False + return "OK" + + def _cmd_pwm_close(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + key, _ = self._find("pwm", args[0]) + return self._close("pwm", key) + + # uart (`UartFactory.cpp` `Evaluate`) + + def _cmd_uart_open(self, args: list[str], options: dict[str, str]) -> str: + keys = ("lp", "tx", "rx", "rts", "cts", "baud", "parity", "flow", "swap", "dma", "duplex", "sync") + _shape(args, options, 1, 1, keys) + index = self._index("uart", args[0]) + lp = _flag(options, "lp") + tx, rx, rts, cts = (self.pin(options.get(key)) for key in ("tx", "rx", "rts", "cts")) + baud = _number(options.get("baud", "115200"), expect.UART_BAUD_MIN, expect.UART_BAUD_MAX) + parity = _choice(options, "parity", ("none", "even", "odd"), "none") + flow = _choice(options, "flow", ("none", "rts", "cts", "rtscts"), "none") + swap, dma, duplex, sync = (_flag(options, key) for key in ("swap", "dma", "duplex", "sync")) + if index not in (self.spec.lpuarts if lp else self.spec.usarts): + _fail("range") + if dma + duplex + sync > 1: + _fail("usage") + if (flow in ("rts", "rtscts")) != (rts is not None) or (flow in ("cts", "rtscts")) != (cts is not None): + _fail("usage") + if (lp and (duplex or sync)) or (sync and (parity != "none" or swap)) or (not sync and flow not in ("none", "rtscts")): + _fail("unsupported") + if (dma or duplex) and (lp, index) not in self.spec.uart_dma: + _fail("unsupported") + if not expect.uart_baud_fits(self.kernel_clock, baud, lp, expect.uart_baud_max(self.family)): + _fail("range") + terminal = not lp and index == _TERMINAL_USART + defaults = self._terminal_pins() if terminal else self.spec.default_lpuart if lp and index == _DEFAULT_LPUART else None + if defaults is not None and tx is None and rx is None and rts is None and cts is None: + tx, rx = defaults + if tx is None or rx is None: + _fail("usage") + prefix = "lpuart" if lp else "uart" + for function, pin in (("Tx", tx), ("Rx", rx), ("Rts", rts), ("Cts", cts)): + self._check_function(prefix + function, index, pin) + if terminal: + _fail("busy") + state = {"lp": lp, "baud": baud, "parity": parity, "flow": flow, "swap": swap, "dma": dma, "duplex": duplex, "sync": sync} + self._open("uart", str(index), state, [tx, rx, rts, cts]) + self.uart_rx[index] = bytearray() + return "OK" + + def _terminal_pins(self) -> tuple[str, str]: + """The terminal USART1 defaults to its own pins, so a bare `uart.open 1` passes the argument checks.""" + pins = {**self.spec.pins, **(self.pins or {})} + return pins["terminaltx"], pins["terminalrx"] + + def _cmd_uart_send(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 2) + self._find("uart", args[0]) + if not _hex(args[1], _UART_TRANSMIT_CAPACITY): + _fail("usage") + return "OK" + + def _cmd_uart_recv(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("timeout", "len")) + key, _ = self._find("uart", args[0]) + _number(options.get("timeout", "1000"), 0, _RECEIVE_TIMEOUT_MAX_MS) + if "len" in options: + _number(options["len"], 1, _UART_RECEIVE_CAPACITY) + buffer = self.uart_rx.setdefault(int(key), bytearray()) + data = bytes(buffer[:_UART_RECEIVE_CAPACITY]) + del buffer[: len(data)] + return f"OK data={format_hex(data) or '-'}" + + def _cmd_uart_close(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + key, _ = self._find("uart", args[0]) + return self._close("uart", key) + + # spi (`SpiFactory.cpp` `Evaluate`) + + def _cmd_spi_open(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("clk", "mosi", "miso", "cs", "baud", "mode", "dma", "sync")) + index = self._index("spi", args[0]) + if index not in self.spec.spis: + _fail("range") + clk, mosi, miso, cs = (self.pin(options.get(key)) for key in ("clk", "mosi", "miso", "cs")) + baud = _number(options.get("baud", "1000000"), 1) + mode = _number(options.get("mode", "0"), 0, 3) + dma, sync = _flag(options, "dma"), _flag(options, "sync") + if clk is None or mosi is None or miso is None: + _fail("usage") + if dma and sync: + _fail("usage") + if not expect.spi_baud_fits(self.kernel_clock, baud): + _fail("range") + for function, pin in (("spiClock", clk), ("spiMosi", mosi), ("spiMiso", miso)): + self._check_function(function, index, pin) + if cs is not None and not self.bonded(cs): + _fail("pin") + state = {"baud": baud, "clock": expect.spi_clock(self.kernel_clock, baud), "mode": mode, "dma": dma, "sync": sync, "cs": cs} + self._open("spi", str(index), state, [clk, mosi, miso, cs]) + return "OK" + + def _cmd_spi_xfer(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 2, ("rx", "continue")) + self._find("spi", args[0]) + data = _hex(args[1], _SPI_CAPACITY) + size = _number(options.get("rx", str(len(data))), 0, _SPI_CAPACITY) + _flag(options, "continue") + if max(len(data), size) == 0: + _fail("usage") + return f"OK rx={format_hex(bytes([self.spi_miso]) * size) or '-'}" + + def _cmd_spi_close(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + key, _ = self._find("spi", args[0]) + return self._close("spi", key) + + # adc (`AdcFactory.cpp` `ParseKey` and `Parse`) + + def _adc_key(self, args: list[str]) -> str: + adc = _number(args[0], 0, _ADC_KEY_MAX) + if adc != self.spec.adc: + _fail("range") + return str(adc) + + def _adc_find(self, args: list[str]) -> tuple[str, dict[str, Any]]: + key = self._adc_key(args) + state = self.opened.get(("adc", key)) + if state is None: + _fail("notopen") + assert state is not None + return key, state + + def _cmd_adc_open(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("pins", "sampling", "timer", "rate")) + key = self._adc_key(args) + _choice(options, "sampling", self.spec.sampling, expect.ADC_DEFAULT_SAMPLING[self.family]) + timer = _number(options["timer"], 0, _TIMER_TABLE_SIZE) if "timer" in options else None + rate = _number(options.get("rate", str(expect.ADC_DEFAULT_RATE)), 1, expect.ADC_RATE_MAX) + if "rate" in options and timer is None: + _fail("usage") + if "pins" not in options: + _fail("usage") + entries = options["pins"].split(",") + if len(entries) > expect.ADC_MAX_PINS: + _fail("range") + pins: list[str] = [] + for entry in entries: + pin = self.pin(entry) + assert pin is not None + if not self.supports_analog(pin): + _fail("pin") + pins.append(pin) + if timer is not None and not self._timer_exists(timer): + _fail("range") + if timer is not None and timer not in expect.ADC_TRIGGER_TIMERS: + _fail("unsupported") + state = {"pins": pins, "timer": timer, "rate": rate} + self._open("adc", key, state, pins, analog=True, timer=timer) + return "OK" + + def _cmd_adc_measure(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("n",)) + _, state = self._adc_find(args) + runs = _number(options.get("n", "1"), 1, expect.ADC_MAX_VALUES) + if runs * len(state["pins"]) > expect.ADC_MAX_VALUES: + _fail("range") + if state["timer"] is not None: + duration = expect.adc_measure_time(runs, state["rate"]) + if duration > _ADC_MEASURE_TIMEOUT: + self.sleep(_ADC_MEASURE_TIMEOUT) + return "ERR timeout" + self.sleep(duration) + samples = [self.adc_codes.get(pin, 2048) for _ in range(runs) for pin in state["pins"]] + reply = "OK samples=" + ",".join(str(sample) for sample in samples) + # Tests time this reply, which is long enough for its transmission to count. + self.sleep(expect.terminal_line_time(reply, self.terminal_baud)) + return reply + + def _cmd_adc_close(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + key, _ = self._adc_find(args) + return self._close("adc", key) + + # qei (`QeiFactory.cpp` `Evaluate`) + + def _cmd_qei_open(self, args: list[str], options: dict[str, str]) -> str: + keys = ("lp", "a", "b", "idx", "res", "offset", "inva", "invb", "cap", "filter", "vel") + _shape(args, options, 1, 1, keys) + timer = self._index("qei", args[0]) + lp = _flag(options, "lp") + if lp and not self.spec.lptims: + _fail("unsupported") + if lp and timer not in self.spec.lptims: + _fail("range") + if not lp and not self._timer_exists(timer): + _fail("range") + if not lp and timer not in expect.ENCODER_TIMERS: + _fail("unsupported") + if lp and any(key in options for key in ("cap", "offset", "invb")): + _fail("unsupported") + maximum = _UINT32_MAX if not lp and timer in expect.TIMERS_32BIT else _QEI_RESOLUTION_16BIT + res = _number(options.get("res", "4096"), 2, maximum) + offset = _number(options.get("offset", "0")) + if offset >= res: + _fail("range") + _flag(options, "inva") + _flag(options, "invb") + _choice(options, "cap", ("a", "b", "ab"), "ab") + filter_samples = _number(options.get("filter", "0"), 0, 15) + if options.get("vel") != "off": + _number(options.get("vel", "1000"), 1, _QEI_VELOCITY_MAX_US) + if lp and filter_samples not in _LPTIM_FILTERS: + _fail("range") + a, b, idx = (self.pin(options.get(key)) for key in ("a", "b", "idx")) + default = self.spec.default_qei + if a is None and b is None and idx is None and not lp and timer == default[0]: + a, b, idx = default[1:] + if a is None or b is None: + _fail("usage") + inputs = ("lpTimerInput1", "lpTimerInput2") if lp else ("timerChannel1", "timerChannel2") + if not self.supports(inputs[0], timer, a) or not self.supports(inputs[1], timer, b): + _fail("pin") + if idx is not None and not self.bonded(idx): + _fail("pin") + state = {"lp": lp, "res": res, "pos": offset, "dir": "fwd", "speed": 0, "idx": idx} + self._open("qei", str(timer), state, [a, b, idx], timer=None if lp else timer) + return "OK" + + def _cmd_qei_read(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + _, state = self._find("qei", args[0]) + return f"OK pos={state['pos']} dir={state['dir']} speed={state['speed']} res={state['res']}" + + def _cmd_qei_index(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + _, state = self._find("qei", args[0]) + if state["idx"] is None: + _fail("unsupported") + return f"OK idx={self.gpio_levels.get(state['idx'], 0)}" + + def _cmd_qei_close(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + key, _ = self._find("qei", args[0]) + return self._close("qei", key) + + # watchdog (`HilWatchDogCommands::Parse` around `WatchDogFactory.cpp` `Prepare`) + + @property + def pclk1(self) -> int: + return self.kernel_clock + + def _cmd_wdt_start(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("timeout", "feed", "pin")) + index = self._index("wdt", args[0]) + timeout = _number(options["timeout"], 1, expect.WDT_TIMEOUT_MAX_MS) if "timeout" in options else None + pin = self.pin(options.get("pin")) + prescaler = None if timeout is None else expect.wwdg_prescaler_for(timeout, self.pclk1) + if timeout is not None and prescaler is None: + _fail("range") + if pin is not None and not self.bonded(pin): + _fail("pin") + feed = _choice(options, "feed", ("auto", "manual"), "auto") + if timeout is None: + _fail("usage") + assert prescaler is not None + if self.watchdog is not None: + _fail("busy") + owner = ("wdt", str(index)) + self._check_pins(owner, [pin]) + self._claim(owner, [pin]) + if pin is not None: + self.gpio_levels[pin] = 0 + period = expect.wwdg_period(self.pclk1, prescaler) + self.watchdog = { + "index": index, + "feed": feed, + "pin": pin, + "period": period, + "tick": expect.wwdg_tick(self.pclk1, prescaler), + "next": self.clock() + period, + "reset_at": None, + "warnings": 0, + "sending": None, + } + return "OK" + + def _cmd_wdt_feed(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + self._index("wdt", args[0]) + watchdog = self.watchdog + if watchdog is None: + _fail("notopen") + assert watchdog is not None + watchdog["next"] = self.clock() + watchdog["period"] + watchdog["reset_at"] = None + if watchdog["sending"] is not None: + self.event(watchdog["sending"]) + watchdog["sending"] = None + return "OK" + + def poll(self) -> None: + """Early warnings every period; `feed=auto` refreshes on each, otherwise the WWDG resets the board one + counter tick after a warning that was not answered with `wdt.feed`. A tick shorter than the transmission + of the `EVT wdt` line cuts the line: the host gets the part sent before the reset.""" + watchdog = self.watchdog + if watchdog is None: + return + now = self.clock() + for _ in range(1000): + if watchdog["reset_at"] is not None and now >= watchdog["reset_at"]: + if watchdog["sending"] is not None: + sent = int(watchdog["tick"] * self.terminal_baud / expect.uart_frame_bits("none")) + # The line break that starts the boot banner ends the fragment. + self._write(f"\r\n{watchdog['sending']}"[:sent] + "\r\n") + self.reset_cause = "wwdg" + self.boot() + return + if watchdog["next"] > now: + return + watchdog["warnings"] += 1 + if watchdog["pin"] is not None: + self.gpio_levels[watchdog["pin"]] = 1 - self.gpio_levels.get(watchdog["pin"], 0) + line = expect.wwdg_warning_line(watchdog["index"], watchdog["warnings"]) + if watchdog["feed"] == "auto": + self.event(line) + watchdog["next"] += watchdog["period"] + continue + watchdog["reset_at"] = watchdog["next"] + watchdog["tick"] + watchdog["next"] = math.inf + if expect.terminal_line_time(line, self.terminal_baud) <= watchdog["tick"]: + self.event(line) + else: + watchdog["sending"] = line diff --git a/validation/host/hal_st_validation/firmware.py b/validation/host/hal_st_validation/firmware.py new file mode 100644 index 00000000..3de213db --- /dev/null +++ b/validation/host/hal_st_validation/firmware.py @@ -0,0 +1,451 @@ +"""Typed API over the validation firmware commands (validation/PROTOCOL.md); keyword arguments map 1:1 to +protocol options.""" + +from __future__ import annotations + +import time +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Literal + +from ad3_waveforms_bench.protocol import Event, Response, format_command, format_decimal +from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal, PendingCommand, TerminalError + +from .protocol import normalize_pin, parse_pin_map + +Pin = str +Level = Literal[0, 1] +Edge = Literal["rising", "falling", "both", "off"] +Drive = Literal["low", "medium", "fast", "high"] +PwmOutput = Pin | None | tuple[Pin | None, Pin | None] + + +@dataclass(frozen=True) +class BootInfo: + board: str + family: str + sysclk: int + reset: str + raw: str + + @classmethod + def from_values(cls, source: Response | Event) -> BootInfo: + return cls( + board=source["board"], + family=source["family"], + sysclk=source.as_int("sysclk"), + reset=source["reset"], + raw=source.raw, + ) + + +@dataclass(frozen=True) +class Info(BootInfo): + uid: str | None = None + + +@dataclass(frozen=True) +class QeiReading: + pos: int + dir: str + speed: int + res: int + + +def settle(pending: PendingCommand, timeout: float | None = None) -> Response | None: + """Finish a command started with `FirmwareTerminal.begin` whatever its outcome, so a failing test cannot + leave it pending; returns its final line, or None when it timed out or was already finished.""" + try: + return pending.wait(timeout, check=False) + except TerminalError: + return None + + +def quiesce(terminal: FirmwareTerminal, quiet: float = 0.1, limit: float = 3.0) -> bool: + """Read until the line has been silent for `quiet` seconds (at most `limit`), so a late reply to an earlier + command is read now and dropped by the next `begin` instead of being taken as that command's reply.""" + deadline = time.monotonic() + limit + while time.monotonic() < deadline: + if terminal.pump(quiet) == 0: + return True + return False + + +class _Group: + prefix = "" + + def __init__(self, firmware: Firmware) -> None: + self._fw = firmware + + def _cmd(self, name: str, *args: object, cmd_timeout: float | None = None, **options: object) -> Response: + return self._fw.command(f"{self.prefix}.{name}", *args, cmd_timeout=cmd_timeout, **options) + + def _pin(self, pin: Pin | None) -> str | None: + return None if pin is None else self._fw.pin(pin) + + def _pins(self, pins: Sequence[Pin] | None) -> list[str] | None: + return None if pins is None else [self._fw.pin(pin) for pin in pins] + + +class System(_Group): + def ping(self) -> None: + self._fw.command("ping") + + def info(self) -> Info: + response = self._fw.command("info") + uid = response.get("uid") + base = BootInfo.from_values(response) + return Info(**base.__dict__, uid=None if uid in (None, "none") else uid) + + def pins(self) -> dict[str, str]: + return parse_pin_map(self._fw.command("board.pins")) + + def delay(self, ms: int) -> None: + self._fw.command("delay", ms, cmd_timeout=self._fw.terminal.timeout + ms / 1000) + + def reset(self, timeout: float = 5.0) -> BootInfo: + terminal = self._fw.terminal + terminal.drain_events("boot") + terminal.send_nowait("reset") + self._fw.forget_open() + return BootInfo.from_values(terminal.wait_boot(timeout)) + + def wait_boot(self, timeout: float = 5.0) -> BootInfo: + self._fw.forget_open() + return BootInfo.from_values(self._fw.terminal.wait_boot(timeout)) + + +class Gpio(_Group): + prefix = "gpio" + + def cfg(self, pin: Pin, mode: Literal["in", "out", "od"], pull: str | None = None, drive: Drive | None = None) -> None: + """`drive` is the output speed (`hal::Speed`) of the pin.""" + pin = self._fw.pin(pin) + self._cmd("cfg", pin, mode, pull=pull, drive=drive) + self._fw.track(("gpio", pin), "gpio.release", pin) + + def set(self, pin: Pin, value: int | bool) -> None: + self._cmd("set", self._fw.pin(pin), int(bool(value))) + + def get(self, pin: Pin) -> int: + return self._cmd("get", self._fw.pin(pin)).as_int("value") + + def pulse(self, pin: Pin, count: int, period_ms: int, cmd_timeout: float | None = None) -> None: + duration = count * period_ms / 1000 + self._cmd("pulse", self._fw.pin(pin), count, period_ms, cmd_timeout=cmd_timeout or self._fw.terminal.timeout + duration * 1.5) + + def irq(self, pin: Pin, edge: Edge, type: Literal["immediate", "dispatched"] | None = None) -> None: + self._cmd("irq", self._fw.pin(pin), edge, type=type) + + def count(self, pin: Pin, clear: bool | None = None) -> int: + return self._cmd("count", self._fw.pin(pin), clear=clear).as_int("count") + + def release(self, pin: Pin) -> None: + pin = self._fw.pin(pin) + self._cmd("release", pin) + self._fw.untrack(("gpio", pin)) + + +class Pwm(_Group): + prefix = "pwm" + + def open( + self, + timer: int, + channels: Sequence[int] | None = None, + pins: Sequence[PwmOutput] | None = None, + freq: int | None = None, + mode: Literal["edge", "center"] | None = None, + prescaler: int | None = None, + dead: int | Literal["off"] | None = None, + inv: bool | None = None, + invn: bool | None = None, + idle: bool | None = None, + idlen: bool | None = None, + brk: Pin | None = None, + brkpol: Literal["low", "high"] | None = None, + brkauto: bool | None = None, + sync: bool | None = None, + ) -> int: + """Returns `pwmclk`. A `pins` entry is the channel output, `(output, complementary)` or None (`-`); a None + inside the tuple leaves that position unused (`-:PA7` drives only CH1N).""" + response = self._cmd( + "open", + timer, + channels=list(channels) if channels is not None else None, + pins=None if pins is None else [self._output(entry) for entry in pins], + freq=freq, + mode=mode, + prescaler=prescaler, + dead=dead, + inv=inv, + invn=invn, + idle=idle, + idlen=idlen, + brk=self._pin(brk), + brkpol=brkpol, + brkauto=brkauto, + sync=sync, + ) + self._fw.track(("pwm", timer), "pwm.close", timer) + return response.as_int("pwmclk") + + def _output(self, entry: PwmOutput) -> str: + if isinstance(entry, tuple): + first, second = entry + return f"{self._pin_or_dash(first)}:{self._pin_or_dash(second)}" + return self._pin_or_dash(entry) + + def _pin_or_dash(self, pin: Pin | None) -> str: + return "-" if pin is None else self._fw.pin(pin) + + def duty(self, timer: int, *duties: float) -> None: + """One duty per opened channel (in channel order), or a single duty for all of them; up to 4 decimals.""" + if not 1 <= len(duties) <= 4: + raise ValueError("one to four duties") + self._cmd("duty", timer, *[format_decimal(float(duty), 4) for duty in duties]) + + def freq(self, timer: int, hz: int) -> None: + self._cmd("freq", timer, hz) + + def stop(self, timer: int) -> None: + self._cmd("stop", timer) + + def close(self, timer: int) -> None: + self._cmd("close", timer) + self._fw.untrack(("pwm", timer)) + + +class Uart(_Group): + prefix = "uart" + + def open( + self, + index: int, + lp: bool | None = None, + tx: Pin | None = None, + rx: Pin | None = None, + rts: Pin | None = None, + cts: Pin | None = None, + baud: int | None = None, + parity: Literal["none", "even", "odd"] | None = None, + flow: Literal["none", "rts", "cts", "rtscts"] | None = None, + swap: bool | None = None, + dma: bool | None = None, + duplex: bool | None = None, + sync: bool | None = None, + ) -> None: + """`lp=True` selects LPUART; the group keeps one instance, addressed by `index` afterwards.""" + self._cmd( + "open", + index, + lp=lp, + tx=self._pin(tx), + rx=self._pin(rx), + rts=self._pin(rts), + cts=self._pin(cts), + baud=baud, + parity=parity, + flow=flow, + swap=swap, + dma=dma, + duplex=duplex, + sync=sync, + ) + self._fw.track(("uart", index), "uart.close", index) + + def send(self, index: int, data: bytes, cmd_timeout: float | None = None) -> None: + if not data: + raise ValueError("uart.send needs at least one byte") + self._cmd("send", index, bytes(data), cmd_timeout=cmd_timeout) + + def recv(self, index: int, timeout: int | None = None, len: int | None = None) -> bytes: + wait = self._fw.terminal.timeout + (timeout or 0) / 1000 + return self._cmd("recv", index, timeout=timeout, len=len, cmd_timeout=wait).as_bytes("data") + + def close(self, index: int) -> None: + self._cmd("close", index) + self._fw.untrack(("uart", index)) + + +class Spi(_Group): + prefix = "spi" + + def open( + self, + index: int, + clk: Pin | None = None, + mosi: Pin | None = None, + miso: Pin | None = None, + cs: Pin | None = None, + baud: int | None = None, + mode: int | None = None, + dma: bool | None = None, + sync: bool | None = None, + ) -> None: + self._cmd( + "open", + index, + clk=self._pin(clk), + mosi=self._pin(mosi), + miso=self._pin(miso), + cs=self._pin(cs), + baud=baud, + mode=mode, + dma=dma, + sync=sync, + ) + self._fw.track(("spi", index), "spi.close", index) + + def xfer(self, index: int, tx: bytes, rx: int | None = None, continue_: bool | None = None) -> bytes: + return self._cmd("xfer", index, bytes(tx), rx=rx, continue_=continue_).as_bytes("rx") + + def close(self, index: int) -> None: + self._cmd("close", index) + self._fw.untrack(("spi", index)) + + +class Adc(_Group): + prefix = "adc" + + def open( + self, + adc: int, + pins: Sequence[Pin] | None = None, + sampling: str | float | None = None, + timer: int | None = None, + rate: int | None = None, + ) -> None: + """`sampling` in ADC clock cycles (`2.5`, `"640.5"`); `timer` triggers the runs at `rate` per second.""" + self._cmd("open", adc, pins=self._pins(pins), sampling=sampling, timer=timer, rate=rate) + self._fw.track(("adc", adc), "adc.close", adc) + + def measure(self, adc: int, n: int | None = None, cmd_timeout: float | None = None) -> list[int]: + return self._cmd("measure", adc, n=n, cmd_timeout=cmd_timeout).as_ints("samples") + + def close(self, adc: int) -> None: + self._cmd("close", adc) + self._fw.untrack(("adc", adc)) + + +class Qei(_Group): + prefix = "qei" + + def open( + self, + timer: int, + lp: bool | None = None, + a: Pin | None = None, + b: Pin | None = None, + idx: Pin | None = None, + res: int | None = None, + offset: int | None = None, + inva: bool | None = None, + invb: bool | None = None, + cap: Literal["a", "b", "ab"] | None = None, + filter: int | None = None, + vel: int | Literal["off"] | None = None, + ) -> None: + """`lp=True` selects LPTIM; `vel` is the speed sampling period in µs.""" + self._cmd( + "open", + timer, + lp=lp, + a=self._pin(a), + b=self._pin(b), + idx=self._pin(idx), + res=res, + offset=offset, + inva=inva, + invb=invb, + cap=cap, + filter=filter, + vel=vel, + ) + self._fw.track(("qei", timer), "qei.close", timer) + + def read(self, timer: int) -> QeiReading: + response = self._cmd("read", timer) + return QeiReading( + pos=response.as_int("pos"), + dir=response["dir"], + speed=response.as_int("speed"), + res=response.as_int("res"), + ) + + def index(self, timer: int) -> int: + """Level of the index input.""" + return self._cmd("index", timer).as_int("idx") + + def close(self, timer: int) -> None: + self._cmd("close", timer) + self._fw.untrack(("qei", timer)) + + +class Watchdog(_Group): + prefix = "wdt" + + def start(self, index: int, timeout: int, feed: Literal["auto", "manual"] | None = None, pin: Pin | None = None) -> None: + """The WWDG cannot be stopped; `pin` toggles on every early warning and stays claimed until reset.""" + self._cmd("start", index, timeout=timeout, feed=feed, pin=self._pin(pin)) + + def feed(self, index: int) -> None: + self._cmd("feed", index) + + def wait_warning(self, index: int, timeout: float) -> Event: + return self._fw.terminal.wait_event("wdt", lambda event: event.as_int("index") == index, timeout) + + def warnings(self, index: int) -> list[Event]: + return [event for event in self._fw.terminal.drain_events("wdt") if event.as_int("index") == index] + + +class Firmware: + """Entry point: `fw.gpio.set("led0", 1)`, `fw.pwm.open(1, channels=[1], freq=20000)`, ... + + Open instances are tracked so `close_all()` can restore a clean state between tests. + Pins are sent as `P` after resolving aliases with `aliases` (when given). + """ + + def __init__(self, terminal: FirmwareTerminal, aliases: Mapping[str, str] | None = None) -> None: + self.terminal = terminal + self.aliases = dict(aliases or {}) + self._open: dict[tuple[object, ...], tuple[str, tuple[object, ...]]] = {} + self.system = System(self) + self.gpio = Gpio(self) + self.pwm = Pwm(self) + self.uart = Uart(self) + self.spi = Spi(self) + self.adc = Adc(self) + self.qei = Qei(self) + self.wdt = Watchdog(self) + + def command(self, name: str, *args: object, cmd_timeout: float | None = None, **options: object) -> Response: + return self.terminal.command(format_command(name, *args, **options), timeout=cmd_timeout) + + def pin(self, pin: Pin) -> str: + return normalize_pin(pin, self.aliases or None) + + def track(self, key: tuple[object, ...], close_command: str, *args: object) -> None: + self._open[key] = (close_command, args) + + def untrack(self, key: tuple[object, ...]) -> None: + self._open.pop(key, None) + + def forget_open(self) -> None: + self._open.clear() + + @property + def open_instances(self) -> list[tuple[object, ...]]: + return list(self._open) + + def close_all(self) -> list[str]: + """Close everything opened through this object; returns the commands that failed.""" + failures: list[str] = [] + for key, (name, args) in reversed(list(self._open.items())): + try: + self.command(name, *args) + except FirmwareError as error: + if error.reason != "notopen": + failures.append(f"{error.command}: {error.reason}") + self._open.pop(key, None) + return failures diff --git a/validation/host/hal_st_validation/pairwise.py b/validation/host/hal_st_validation/pairwise.py new file mode 100644 index 00000000..f18aa1c2 --- /dev/null +++ b/validation/host/hal_st_validation/pairwise.py @@ -0,0 +1,144 @@ +"""Test matrices: the full cartesian product (`--depth full`) or a deterministic pairwise subset (`--depth quick`). + +A matrix is an ordered mapping of dimension name to its values. `valid` receives a (possibly partial) assignment +of dimension name to value and returns False only when that assignment can never be part of a valid combination, +so the same predicate prunes partial and complete combinations. +""" + +from __future__ import annotations + +import itertools +from collections.abc import Callable, Iterator, Mapping, Sequence +from typing import Any, Literal + +Depth = Literal["quick", "full"] +Combination = dict[str, Any] +Predicate = Callable[[Mapping[str, Any]], bool] + +DEPTHS: tuple[Depth, ...] = ("quick", "full") + + +def _always(_: Mapping[str, Any]) -> bool: + return True + + +def full_product(dimensions: Mapping[str, Sequence[Any]], valid: Predicate = _always) -> list[Combination]: + names = list(dimensions) + result = [] + for values in itertools.product(*(dimensions[name] for name in names)): + combination = dict(zip(names, values)) + if valid(combination): + result.append(combination) + return result + + +def _pairs(sizes: Sequence[int]) -> Iterator[tuple[int, int, int, int]]: + for first, second in itertools.combinations(range(len(sizes)), 2): + for a in range(sizes[first]): + for b in range(sizes[second]): + yield first, a, second, b + + +def pairwise(dimensions: Mapping[str, Sequence[Any]], valid: Predicate = _always) -> list[Combination]: + """Combinations covering every pair of values of every two dimensions that some valid combination can hold. + + Greedy and deterministic: each round seeds a combination with the first uncovered pair and then assigns the + remaining dimensions in order, picking the value that covers the most uncovered pairs (the first on ties). + A seed pair that cannot be completed into a valid combination is dropped. + """ + names = list(dimensions) + values = [list(dimensions[name]) for name in names] + if any(not options for options in values): + return [] + if len(names) < 2: + return full_product(dimensions, valid) + + sizes = [len(options) for options in values] + uncovered = dict.fromkeys(_pairs(sizes)) + + def assignment(indices: Mapping[int, int]) -> Combination: + return {names[dim]: values[dim][index] for dim, index in sorted(indices.items())} + + def gain(indices: Mapping[int, int], dim: int, index: int) -> int: + count = 0 + for other, other_index in indices.items(): + key = (other, other_index, dim, index) if other < dim else (dim, index, other, other_index) + count += key in uncovered + return count + + result: list[Combination] = [] + seen: set[tuple[int, ...]] = set() + while uncovered: + first, a, second, b = next(iter(uncovered)) + indices = {first: a, second: b} + if not valid(assignment(indices)): + del uncovered[(first, a, second, b)] + continue + complete = True + for dim in range(len(names)): + if dim in indices: + continue + best = None + best_gain = -1 + for index in range(sizes[dim]): + candidate = {**indices, dim: index} + if not valid(assignment(candidate)): + continue + score = gain(indices, dim, index) + if score > best_gain: + best, best_gain = index, score + if best is None: + complete = False + break + indices[dim] = best + if not complete: + fallback = _complete(sizes, {first: a, second: b}, lambda candidate: valid(assignment(candidate))) + if fallback is None: + del uncovered[(first, a, second, b)] + continue + indices = fallback + for x, y in itertools.combinations(range(len(names)), 2): + uncovered.pop((x, indices[x], y, indices[y]), None) + key = tuple(indices[dim] for dim in range(len(names))) + if key not in seen: + seen.add(key) + result.append(assignment(indices)) + return result + + +_SEARCH_LIMIT = 200_000 + + +def _complete(sizes: Sequence[int], fixed: Mapping[int, int], valid: Callable[[Mapping[int, int]], bool]) -> dict[int, int] | None: + """First valid combination holding `fixed` in product order, when the greedy choice ran into a dead end.""" + free = [dim for dim in range(len(sizes)) if dim not in fixed] + for tried, choice in enumerate(itertools.product(*(range(sizes[dim]) for dim in free))): + if tried >= _SEARCH_LIMIT: + return None + candidate = {**fixed, **dict(zip(free, choice))} + if valid(candidate): + return candidate + return None + + +def combinations(dimensions: Mapping[str, Sequence[Any]], depth: Depth = "quick", valid: Predicate = _always) -> list[Combination]: + if depth not in DEPTHS: + raise ValueError(f"depth must be one of {DEPTHS}, not {depth!r}") + return full_product(dimensions, valid) if depth == "full" else pairwise(dimensions, valid) + + +def covered_pairs(combinations: Sequence[Mapping[str, Any]], names: Sequence[str]) -> set[tuple[str, Any, str, Any]]: + """Every (name, value, name, value) pair held by some combination; values must be hashable or reprs are used.""" + result: set[tuple[str, Any, str, Any]] = set() + for combination in combinations: + for first, second in itertools.combinations(names, 2): + result.add((first, _key(combination[first]), second, _key(combination[second]))) + return result + + +def _key(value: Any) -> Any: + try: + hash(value) + except TypeError: + return repr(value) + return value diff --git a/validation/host/hal_st_validation/protocol.py b/validation/host/hal_st_validation/protocol.py new file mode 100644 index 00000000..c18878eb --- /dev/null +++ b/validation/host/hal_st_validation/protocol.py @@ -0,0 +1,102 @@ +"""hal-st specifics of the validation terminal protocol (see validation/PROTOCOL.md): error reasons, pins and +aliases. Framing, parsing and formatting come from `ad3_waveforms_bench.protocol`. + +The alias table of a board comes from its board file (`pins`, compared with the firmware's `board.pins`); +`PIN_ALIASES` is the generic naming scheme those tables use, so a known alias can be passed through to the +firmware unresolved when no table is at hand. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping + +from ad3_waveforms_bench.protocol import ProtocolError, Response + +ERROR_REASONS = frozenset({"usage", "pin", "busy", "notopen", "unsupported", "range", "timeout", "failed"}) + +_ALIAS_PATTERNS = ( + r"terminal(tx|rx)", + r"ain(1[0-9]|[0-9])", + r"tim(1[0-7]|[1-9])ch[1-4]", + r"tim(1[0-7]|[1-9])ch[1-3]n", + r"tim(1[0-7]|[1-9])bkin", + r"qei(1[0-7]|[1-9])(a|b|idx)", + r"lptim[12]in[12]", + r"spi[1-3](clk|miso|mosi|cs)", + r"usart[1-3](tx|rx|rts|cts)", + r"lpuart1(tx|rx|rts|cts)", + r"led[0-7]", + r"gpio(1[0-5]|[0-9])", + r"sw[1-3]", +) +_ALIAS_RE = re.compile("^(?:" + "|".join(_ALIAS_PATTERNS) + ")$") + + +def _generic_aliases() -> frozenset[str]: + names = {f"terminal{signal}" for signal in ("tx", "rx")} + names |= {f"ain{channel}" for channel in range(20)} + for timer in range(1, 18): + names |= {f"tim{timer}ch{channel}" for channel in range(1, 5)} + names |= {f"tim{timer}ch{channel}n" for channel in range(1, 4)} + names |= {f"tim{timer}bkin"} + names |= {f"qei{timer}{signal}" for signal in ("a", "b", "idx")} + names |= {f"lptim{index}in{line}" for index in (1, 2) for line in (1, 2)} + names |= {f"spi{index}{signal}" for index in range(1, 4) for signal in ("clk", "miso", "mosi", "cs")} + names |= {f"usart{index}{signal}" for index in range(1, 4) for signal in ("tx", "rx", "rts", "cts")} + names |= {f"lpuart1{signal}" for signal in ("tx", "rx", "rts", "cts")} + names |= {f"led{index}" for index in range(8)} + names |= {f"gpio{index}" for index in range(16)} + names |= {f"sw{index}" for index in range(1, 4)} + assert all(_ALIAS_RE.match(name) for name in names) + return frozenset(names) + + +PIN_ALIASES = _generic_aliases() + +# STM32 ports A-K, index 0-15 without leading zero (EMIL's `HilPinNamingDefault` refuses `PA05`). +_PIN_RE = re.compile(r"^P([A-K])(1[0-5]|[0-9])$", re.IGNORECASE) + + +def is_pin(text: str) -> bool: + return bool(_PIN_RE.match(text)) + + +def is_alias(text: str) -> bool: + return text.lower() in PIN_ALIASES + + +def pin_parts(pin: str) -> tuple[str, int]: + """(`port letter`, `index`) of a `P` pin.""" + match = _PIN_RE.match(pin) + if match is None: + raise ProtocolError(f"not a pin: {pin!r}") + return match.group(1).upper(), int(match.group(2)) + + +def normalize_pin(pin: str, aliases: Mapping[str, str] | None = None, strict: bool = False) -> str: + """Canonical `P` for a pin or an alias of `aliases`. + + A generic alias that `aliases` does not resolve is returned lower case (the firmware resolves it), unless + `strict` requires it to be in `aliases`. + """ + match = _PIN_RE.match(pin) + if match: + return f"P{match.group(1).upper()}{int(match.group(2))}" + name = pin.lower() + if aliases is not None and name in aliases: + return normalize_pin(aliases[name]) + if not strict and name in PIN_ALIASES: + return name + raise ProtocolError(f"not a pin or alias: {pin!r}") + + +def parse_pin_map(response: Response) -> dict[str, str]: + """`board.pins` → `OK alias=pin,alias=pin,...` (space separated pairs are accepted too).""" + body = response.raw.split(maxsplit=1)[1] if " " in response.raw else "" + result: dict[str, str] = {} + for item in re.split(r"[\s,]+", body): + key, sep, value = item.partition("=") + if sep and key: + result[key.lower()] = value.upper() + return result diff --git a/validation/host/pyproject.toml b/validation/host/pyproject.toml new file mode 100644 index 00000000..b09bed68 --- /dev/null +++ b/validation/host/pyproject.toml @@ -0,0 +1,55 @@ +[build-system] +requires = ["setuptools>=64"] +build-backend = "setuptools.build_meta" + +[project] +name = "hal_st_validation" +version = "0.1.0" +description = "Hardware-in-the-loop validation of hal-st drivers with a Digilent Analog Discovery 3" +requires-python = ">=3.10" +# ad3-waveforms-bench brings the AD3 wrapper, the terminal client and the pytest plugin (--ad3-serial, +# --no-ad3, --fake, the ad3 fixture). Pinned past v0.2.0 for the UART parity and logic-analyzer reset fixes; +# move back to a tag with the next release. +dependencies = [ + "ad3-waveforms-bench @ git+https://github.com/embedded-pro/ad3-waveforms-bench@dcb3754fd4a4997e4e78cd824f48bc13b0c2435a", + "pyserial>=3.5", + "pyyaml>=6.0", + "pytest>=7.4", +] + +[project.optional-dependencies] +# The WaveForms SDK is reached through ctypes (in ad3-waveforms-bench); it needs the Digilent WaveForms +# runtime installed system wide, not a Python package, so this extra is empty and only marks the intent. +ad3 = [] +dev = ["ruff>=0.5"] + +[project.scripts] +hal-st-console = "hal_st_validation.console:main" + +[tool.setuptools.packages.find] +include = ["hal_st_validation*"] + +[tool.pytest.ini_options] +minversion = "7.4" +testpaths = ["tests"] +pythonpath = ["."] +addopts = "-ra --strict-markers --import-mode=importlib" +markers = [ + "hil: needs the firmware on a Nucleo board (skipped without --port)", + "board_params(argnames, path=None, values=None): one parameter dimension from tests. of the board YAML or from values", + "matrix(path): one parameter dimension per key of the tests. mapping of the board YAML", + "constraint(valid): keep a parameter combination only when valid(argname -> value) is true", + "family(name): only run on boards of this family (stm32wb55 or stm32wba55)", + "requires_option(tag): only run when enabled with --with ", + "resets_board: the test resets the board on purpose", + "slow: takes more than a few seconds", +] +filterwarnings = ["error::pytest.PytestUnknownMarkWarning"] + +[tool.ruff] +line-length = 140 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP", "SIM"] +ignore = ["B008", "B905"] diff --git a/validation/host/tests/conftest.py b/validation/host/tests/conftest.py new file mode 100644 index 00000000..b9dbebba --- /dev/null +++ b/validation/host/tests/conftest.py @@ -0,0 +1,354 @@ +"""Shared pytest plumbing: board/wiring options, firmware fixtures, YAML-driven parametrisation. + +Parametrisation (see `pytest_generate_tests`): every `board_params` marker is one dimension and every `matrix` +marker adds the dimensions of a YAML mapping; `--depth full` runs their cartesian product and `--depth quick` +(the default) a pairwise subset. `constraint` markers drop combinations the driver cannot take. + +`--ad3-serial`, `--no-ad3`, `--fake`, the `ad3` marker and the `ad3` fixture come from the +`ad3_waveforms_bench` pytest plugin; `ad3_settings` below feeds it the board file's AD3 section. + +The board file's `known_gaps` mark the HIL tests that run into a driver gap: a gap that aborts or hangs the +firmware skips its tests (run them with `--run-known-gaps`), any other is an expected failure (xfail, not strict). +They describe the firmware, so `--fake` ignores them. +""" + +from __future__ import annotations + +import os +from collections.abc import Iterator +from pathlib import Path +from typing import Any + +import pytest +from ad3_waveforms_bench.pytest_plugin import Ad3Settings +from ad3_waveforms_bench.terminal import FirmwareTerminal, TerminalError + +from hal_st_validation.config import BoardConfig, ConfigError, Wiring, known_gap_outcome, load_board +from hal_st_validation.firmware import Firmware, quiesce +from hal_st_validation.pairwise import DEPTHS, combinations + +HIL_DIR = Path(__file__).parent / "hil" +_BOARD_KEY = pytest.StashKey[BoardConfig]() + + +def pytest_addoption(parser: pytest.Parser) -> None: + group = parser.getgroup("hal-st validation") + group.addoption("--board", default=os.environ.get("HAL_ST_BOARD", "nucleo_wb55rg"), help="board YAML name or path") + group.addoption("--port", default=os.environ.get("HAL_ST_PORT"), help="firmware terminal serial port; HIL tests skip without it") + group.addoption("--baud", type=int, default=None, help="terminal baud rate (default from the board YAML)") + group.addoption( + "--command-timeout", + type=float, + default=float(os.environ["HAL_ST_COMMAND_TIMEOUT"]) if os.environ.get("HAL_ST_COMMAND_TIMEOUT") else None, + help="seconds to wait for a command's reply (default from the board YAML); raise it for slow links such as port-bridge", + ) + group.addoption("--wiring-set", default=os.environ.get("HAL_ST_WIRING", ""), help="comma separated wiring sets from the board YAML") + group.addoption("--with", dest="with_tags", action="append", default=[], help="enable an optional wiring tag (repeatable)") + group.addoption("--set", dest="overrides", action="append", default=[], help="override a test parameter: pwm.waveform.freq=[20000]") + group.addoption( + "--run-known-gaps", + action="store_true", + help="also run the tests of known gaps that abort or hang the firmware (they are skipped by default)", + ) + group.addoption( + "--depth", + choices=DEPTHS, + default=os.environ.get("HAL_ST_DEPTH", "quick"), + help="quick: pairwise subset of every parameter matrix (default); full: complete cartesian products", + ) + + +def board_config(config: pytest.Config) -> BoardConfig: + if _BOARD_KEY not in config.stash: + board = load_board(config.getoption("--board")) + board.apply_overrides(config.getoption("overrides")) + config.stash[_BOARD_KEY] = board + return config.stash[_BOARD_KEY] + + +def _ids(value: Any) -> str: + if isinstance(value, dict): + if "name" in value: + return str(value["name"]) + if "index" in value: + return f"index{value['index']}" + return "-".join(f"{key}={value[key]}" for key in value) + if isinstance(value, (list, tuple)): + return ":".join(str(item) for item in value) + return str(value) + + +class _Dimension: + """One axis of a test's parameter space: `names` are the argnames it sets, `values` one tuple per option.""" + + def __init__(self, label: str, names: list[str], values: list[tuple[Any, ...]], ids: list[str]) -> None: + self.label = label + self.names = names + self.values = values + self.ids = ids + + +def _split(value: Any, names: list[str]) -> tuple[Any, ...]: + if len(names) == 1: + return (value,) + if isinstance(value, dict): + return tuple(value.get(name) for name in names) + return tuple(value) + + +def _dimensions(metafunc: pytest.Metafunc, board: BoardConfig) -> tuple[list[_Dimension], str | None]: + """The dimensions of the `board_params`/`matrix` markers (in source order) and a skip reason.""" + dimensions: list[_Dimension] = [] + markers = [marker for marker in metafunc.definition.iter_markers() if marker.name in ("board_params", "matrix")] + for marker in reversed(markers): + if marker.name == "matrix": + path = marker.args[0] + if board.param(path, None) is None: + return dimensions, f"tests.{path} not configured" + for name, values in board.matrix(path).items(): + dimensions.append(_Dimension(name, [name], [(value,) for value in values], [f"{name}={_ids(value)}" for value in values])) + continue + argnames = marker.args[0] + names = [name.strip() for name in argnames.split(",")] + if "values" in marker.kwargs: + values = marker.kwargs["values"] + else: + path = marker.args[1] + values = board.param(path, None) + if values is None: + return dimensions, f"tests.{path} not configured" + if not isinstance(values, (list, tuple)): + values = [values] + if not values: + return dimensions, f"no values for {argnames}" + dimensions.append(_Dimension(argnames, names, [_split(value, names) for value in values], [_ids(value) for value in values])) + return dimensions, None + + +def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: + """`@pytest.mark.board_params("freq", "pwm.frequencies")` adds one dimension from the board YAML (or from + `values=[...]`); for several argnames each item is a mapping (picked by name) or a sequence (positional). + `@pytest.mark.matrix("pwm.waveform")` adds one dimension per key of a YAML mapping, named like the argnames. + `@pytest.mark.constraint(valid=predicate)` keeps a combination only when `predicate(values)` is true; it receives + a possibly partial `argname -> value` mapping and must return False only for impossible assignments. + """ + try: + board = board_config(metafunc.config) + dimensions, skip = _dimensions(metafunc, board) + except ConfigError as error: + raise pytest.UsageError(str(error)) from error + if not dimensions and skip is None: + return + argnames = [name for dimension in dimensions for name in dimension.names] + if skip is not None: + names = argnames + [name for name in _pending_names(metafunc) if name not in argnames] + metafunc.parametrize(names, [pytest.param(*([None] * len(names)), marks=pytest.mark.skip(reason=skip))]) + return + duplicates = {name for name in argnames if argnames.count(name) > 1} + if duplicates: + raise pytest.UsageError(f"{metafunc.definition.nodeid}: argnames set twice: {sorted(duplicates)}") + missing = [name for name in argnames if name not in metafunc.fixturenames] + if missing: + raise pytest.UsageError(f"{metafunc.definition.nodeid}: no argument for {missing}") + predicates = [marker.kwargs["valid"] for marker in metafunc.definition.iter_markers("constraint")] + by_label = {dimension.label: dimension for dimension in dimensions} + + def flatten(assignment: dict[str, Any]) -> dict[str, Any]: + flat: dict[str, Any] = {} + for label, index in assignment.items(): + flat.update(zip(by_label[label].names, by_label[label].values[index])) + return flat + + def valid(assignment: dict[str, Any]) -> bool: + flat = flatten(assignment) + return all(predicate(flat) for predicate in predicates) + + space = {dimension.label: list(range(len(dimension.values))) for dimension in dimensions} + chosen = combinations(space, metafunc.config.getoption("--depth"), valid) + params = [] + for assignment in chosen: + values = [value for dimension in dimensions for value in dimension.values[assignment[dimension.label]]] + ids = "-".join(dimension.ids[assignment[dimension.label]] for dimension in dimensions) + params.append(pytest.param(*values, id=ids)) + if not params: + params = [pytest.param(*([None] * len(argnames)), marks=pytest.mark.skip(reason="no valid combination"))] + metafunc.parametrize(argnames, params) + + +def _pending_names(metafunc: pytest.Metafunc) -> list[str]: + """Arguments that no fixture provides: the ones a skipped parametrisation still has to set.""" + fixtures = getattr(metafunc, "_arg2fixturedefs", {}) + return [name for name in metafunc.fixturenames if name not in fixtures and name != "request"] + + +def hil_test_id(item: pytest.Item) -> str: + """The node id relative to tests/hil (`test_pwm.py::test_waveform[...]`), which `known_gaps` patterns match.""" + relative = Path(str(item.path)).relative_to(HIL_DIR).as_posix() + return f"{relative}::{item.nodeid.partition('::')[2]}" + + +def _apply_known_gaps(config: pytest.Config, item: pytest.Item, board: BoardConfig | None) -> None: + if board is None or config.getoption("--fake"): + return + outcome = known_gap_outcome(board.known_gaps, hil_test_id(item), config.getoption("--run-known-gaps")) + if outcome is None: + return + kind, reason = outcome + item.add_marker(pytest.mark.skip(reason=reason) if kind == "skip" else pytest.mark.xfail(strict=False, reason=reason)) + + +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + port = config.getoption("--port") + tags = set(config.getoption("with_tags")) + try: + board: BoardConfig | None = board_config(config) + except ConfigError: + board = None + family = None if board is None else board.family + for item in items: + if HIL_DIR in Path(str(item.path)).parents: + item.add_marker(pytest.mark.hil) + _apply_known_gaps(config, item, board) + if item.get_closest_marker("hil") and not (port or config.getoption("--fake")): + item.add_marker(pytest.mark.skip(reason="HIL test: pass --port")) + marker = item.get_closest_marker("family") + if marker and family and marker.args[0] != family: + item.add_marker(pytest.mark.skip(reason=f"only for {marker.args[0]}")) + marker = item.get_closest_marker("requires_option") + if marker and marker.args[0] not in tags: + item.add_marker(pytest.mark.skip(reason=f"enable with --with {marker.args[0]}")) + + +@pytest.fixture(scope="session") +def board_cfg(pytestconfig: pytest.Config) -> BoardConfig: + return board_config(pytestconfig) + + +@pytest.fixture(scope="session") +def wiring(pytestconfig: pytest.Config, board_cfg: BoardConfig) -> Wiring: + names = [name.strip() for name in pytestconfig.getoption("--wiring-set").split(",") if name.strip()] + return board_cfg.wiring(names, pytestconfig.getoption("with_tags")) + + +class Need: + """Wiring lookups that skip the test when the active wiring lacks the connection.""" + + def __init__(self, wiring: Wiring, board: BoardConfig) -> None: + self.wiring = wiring + self.board = board + + def _found(self, value: int | None, what: str) -> int: + if value is None: + pytest.skip(f"{what} is not wired in wiring set(s) {', '.join(self.wiring.sets) or '(none)'}") + return value + + def dio(self, pin: str | None = None, role: str | None = None) -> int: + resolved = None if pin is None else self.board.resolve_pin(pin) + return self._found(self.wiring.dio(resolved, role), f"DIO for {pin or role}") + + def wavegen(self, pin: str | None = None, role: str | None = None) -> int: + resolved = None if pin is None else self.board.resolve_pin(pin) + return self._found(self.wiring.wavegen(resolved, role), f"wavegen for {pin or role}") + + def scope(self, pin: str | None = None, role: str | None = None) -> int: + resolved = None if pin is None else self.board.resolve_pin(pin) + return self._found(self.wiring.scope(resolved, role), f"scope for {pin or role}") + + def optional_scope(self, pin: str) -> int | None: + return self.wiring.scope(self.board.resolve_pin(pin)) + + def optional_dio(self, pin: str | None = None, role: str | None = None) -> int | None: + resolved = None if pin is None else self.board.resolve_pin(pin) + return self.wiring.dio(resolved, role) + + def tag(self, tag: str) -> None: + if not self.wiring.has(tag): + pytest.skip(f"enable with --with {tag}") + + +@pytest.fixture(scope="session") +def need(wiring: Wiring, board_cfg: BoardConfig) -> Need: + return Need(wiring, board_cfg) + + +@pytest.fixture(scope="session") +def depth(pytestconfig: pytest.Config) -> str: + """`--depth`: tests that loop over YAML lists internally use the first entry only with `quick`.""" + return pytestconfig.getoption("--depth") + + +@pytest.fixture(scope="session") +def terminal(pytestconfig: pytest.Config, board_cfg: BoardConfig) -> Iterator[FirmwareTerminal]: + port = pytestconfig.getoption("--port") + serial = None + if pytestconfig.getoption("--fake"): + from hal_st_validation.fake_firmware import FakeFirmware, FakeSerial + + # Final lines end with a line break, as the HIL terminal prints them while processing a command, so + # the fake's time-driven events (watchdog warnings) cannot join a final line and its prompt. + fake = FakeFirmware( + board=board_cfg.firmware_name or board_cfg.name, + family=board_cfg.family, + sysclk=board_cfg.sysclk or 0, + pins=dict(board_cfg.pins), + style="line", + ) + serial = FakeSerial(fake) + elif not port: + pytest.skip("pass --port") + baud = pytestconfig.getoption("--baud") or board_cfg.terminal.baud + timeout = pytestconfig.getoption("--command-timeout") or board_cfg.terminal.command_timeout + with FirmwareTerminal( + port, + baud, + timeout=timeout, + serial=serial, + max_command_length=board_cfg.terminal.max_command_length, + ) as term: + # A link with latency (port-bridge, ST-LINK VCP) can deliver a reply after the next command was written, + # which would hand every later command its predecessor's reply: sync with the full command timeout and + # let the line go quiet so the session starts with no reply in flight. + try: + term.sync(timeout=timeout) + except TerminalError as error: + pytest.exit(f"firmware on {port} does not answer ping: {error}", returncode=3) + quiesce(term, quiet=0.3) + yield term + + +@pytest.fixture(scope="session") +def fw(terminal: FirmwareTerminal, board_cfg: BoardConfig) -> Firmware: + firmware = Firmware(terminal, board_cfg.pins) + info = firmware.system.info() + if not board_cfg.matches_firmware_name(info.board): + pytest.exit(f"--board {board_cfg.name} but the firmware reports {info.board}", returncode=3) + return firmware + + +@pytest.fixture(scope="session") +def ad3_settings(board_cfg: BoardConfig) -> Ad3Settings: + return Ad3Settings( + analog_limits=(board_cfg.ad3.analog_min, board_cfg.ad3.analog_max), + vplus=board_cfg.ad3.vplus, + vminus=board_cfg.ad3.vminus, + ) + + +@pytest.fixture(autouse=True) +def _hil_isolation(request: pytest.FixtureRequest) -> Iterator[None]: + """Clear stale events before a HIL test; afterwards close what it opened and reset the AD3 outputs.""" + if request.node.get_closest_marker("hil") is None: + yield + return + firmware: Firmware = request.getfixturevalue("fw") + quiesce(firmware.terminal) + firmware.terminal.drain_events() + yield + boots = firmware.terminal.drain_events("boot") + failures = firmware.close_all() + if "ad3" in request.fixturenames: + request.getfixturevalue("ad3").reset_outputs() + if boots and request.node.get_closest_marker("resets_board") is None: + pytest.fail(f"unexpected reset during the test: {boots[-1].raw}") + if failures: + pytest.fail(f"cleanup failed: {failures}") diff --git a/validation/host/tests/hil/test_adc.py b/validation/host/tests/hil/test_adc.py new file mode 100644 index 00000000..a8cb70d6 --- /dev/null +++ b/validation/host/tests/hil/test_adc.py @@ -0,0 +1,214 @@ +"""ADC (`hal::AdcStm` with `hal::AdcDmaMultiChannelStmBase`): wavegen DC levels against raw 12-bit codes. + +Wiring set `bundle1`: W1/W2 on the two inputs of `tests.adc.inputs`; the scope on the same pins measures the actual +level, which then replaces the programmed one as reference. Without `timer` each run is software triggered; with +`timer` the TRGO of that timer triggers the runs at `rate` per second. +""" + +from __future__ import annotations + +import math +import statistics +import time + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import expect + + +@pytest.fixture +def adc_cfg(board_cfg): + return board_cfg.param("adc") + + +def apply_level(ad3, need, pin, volts): + """Drive `pin` to `volts`; returns the measured level (scope) or the programmed one. + + The scope reads on a 5 V range centred on mid-supply and averages 100 ms, whole periods of both 50 and 60 Hz, + so mains hum does not move the reference. + """ + ad3.wavegen.dc(need.wavegen(pin), volts) + time.sleep(0.02) + scope = need.optional_scope(pin) + if scope is None: + return volts + return statistics.fmean(ad3.scope.acquire([scope], rate=5e4, samples=5000, range_v=5.0, offset_v=1.65)[scope]) + + +def trigger_options(adc_cfg, trigger): + if trigger == "software": + return {} + return {"timer": adc_cfg["trigger"]["timer"], "rate": adc_cfg["trigger"]["rate"]} + + +def check_codes(samples, volts, adc_cfg): + expected = expect.adc_code(volts, adc_cfg["vref"], adc_cfg["bits"]) + result = analysis.stats(samples) + assert result.mean == pytest.approx(expected, abs=adc_cfg["tolerance_codes"]), f"{volts:.3f} V: {result}" + assert result.maximum - result.minimum <= adc_cfg["spread_codes"], f"noisy samples {result}" + + +@pytest.mark.ad3 +@pytest.mark.matrix("adc.levels") +def test_dc_levels(fw, ad3, need, adc_cfg, pin, level, trigger): + volts = apply_level(ad3, need, pin, level) + fw.adc.open(adc_cfg["adc"], pins=[pin], **trigger_options(adc_cfg, trigger)) + check_codes(fw.adc.measure(adc_cfg["adc"], n=adc_cfg["samples"]), volts, adc_cfg) + + +def sequence_pins(driven, spares, length): + """`length` conversions: the driven inputs alternate, and every third position takes the next spare input, so a + sequence holds more distinct channels than the two driven ones.""" + pins = [] + for step in range(length): + if step % 3 == 2 and spares: + pins.append(spares[(step // 3) % len(spares)]) + else: + pins.append(driven[(step - step // 3) % len(driven)]) + return pins + + +@pytest.mark.ad3 +@pytest.mark.matrix("adc.sequence") +def test_sequence(fw, ad3, need, adc_cfg, length, trigger): + """One conversion per pin in the given order: the driven inputs alternate, interleaved with unwired spare inputs + whose codes are not checked.""" + first, second = adc_cfg["inputs"] + levels = {first: apply_level(ad3, need, first, 0.8), second: apply_level(ad3, need, second, 2.4)} + pins = sequence_pins((first, second), adc_cfg.get("spare_inputs", []), length) + fw.adc.open(adc_cfg["adc"], pins=pins, **trigger_options(adc_cfg, trigger)) + runs = min(adc_cfg["samples"], expect.adc_max_runs(length)) + samples = fw.adc.measure(adc_cfg["adc"], n=runs) + assert len(samples) == runs * length + for pin, channel in zip(pins, analysis.deinterleave(samples, length)): + if pin in levels: + check_codes(channel, levels[pin], adc_cfg) + + +@pytest.mark.ad3 +@pytest.mark.matrix("adc.timing") +def test_sampling_time(fw, ad3, need, adc_cfg, sampling, trigger): + pin = adc_cfg["inputs"][0] + volts = apply_level(ad3, need, pin, 1.65) + fw.adc.open(adc_cfg["adc"], pins=[pin], sampling=sampling, **trigger_options(adc_cfg, trigger)) + check_codes(fw.adc.measure(adc_cfg["adc"], n=adc_cfg["samples"]), volts, adc_cfg) + + +def timed_measure(fw, adc, runs, repeats, baud): + """Shortest of `repeats` round trips of `adc.measure n=` (the minimum drops the scheduling jitter of the + host and the link), less the transmission of the reply's samples.""" + shortest = math.inf + for _ in range(repeats): + start = time.monotonic() + samples = fw.adc.measure(adc, n=runs, cmd_timeout=3.0) + shortest = min(shortest, time.monotonic() - start) + assert len(samples) == runs + return shortest - expect.uart_transfer_time(len(",".join(str(sample) for sample in samples)), baud) + + +@pytest.mark.matrix("adc.trigger_rate") +def test_trigger_rate(fw, board_cfg, adc_cfg, timer, rate): + """Timer-triggered runs arrive at `rate` per second: `adc.measure n=` takes (runs - 1) / rate longer than + `n=1` (`expect.adc_measure_time`), and the difference cancels the command round trip.""" + settings = adc_cfg["trigger"] + adc, baud = adc_cfg["adc"], board_cfg.terminal.baud + runs = min(settings["runs"], expect.adc_max_runs(1), max(2, int(rate * 0.5))) + fw.adc.open(adc, pins=[adc_cfg["inputs"][0]], timer=timer, rate=rate) + single = timed_measure(fw, adc, 1, settings["repeats"], baud) + measured = timed_measure(fw, adc, runs, settings["repeats"], baud) - single + expected = expect.adc_measure_time(runs, rate) - expect.adc_measure_time(1, rate) + tolerance, jitter = settings["rate_tolerance"], settings["jitter_s"] + assert (1 - tolerance) * expected - jitter <= measured <= (1 + tolerance) * expected + jitter, ( + f"{runs - 1} more runs took {measured * 1000:.2f} ms, {expected * 1000:.2f} ms at {rate} Hz" + ) + + +def test_measure_timeout(fw, adc_cfg): + """`adc.measure` gives up after 1000 ms: two runs at one per second answer `ERR timeout`.""" + adc = adc_cfg["adc"] + fw.adc.open(adc, pins=[adc_cfg["inputs"][0]], timer=adc_cfg["trigger"]["timer"], rate=1) + with pytest.raises(FirmwareError) as error: + fw.adc.measure(adc, n=2, cmd_timeout=3.0) + assert error.value.reason == "timeout" + + +def test_trigger_timer_busy_for_pwm_and_encoder(fw, board_cfg, adc_cfg): + """A timer serves one group at a time: while it triggers the ADC, PWM and the encoder get `ERR busy`.""" + encoder = next(instance for instance in board_cfg.param("qei.instances") if instance["index"] in expect.ADC_TRIGGER_TIMERS) + timer = encoder["index"] + fw.adc.open(adc_cfg["adc"], pins=[adc_cfg["inputs"][0]], timer=timer) + with pytest.raises(FirmwareError) as error: + fw.pwm.open(timer, channels=[3]) + assert error.value.reason == "busy", "PWM" + with pytest.raises(FirmwareError) as error: + fw.qei.open(timer, a=encoder["a"], b=encoder["b"]) + assert error.value.reason == "busy", "encoder" + + +@pytest.mark.board_params("timer", "adc.trigger.unsupported_timers") +def test_unsupported_trigger_timer(fw, adc_cfg, timer): + with pytest.raises(FirmwareError) as error: + fw.adc.open(adc_cfg["adc"], pins=[adc_cfg["inputs"][0]], timer=timer) + assert error.value.reason == "unsupported" + + +def test_open_errors(fw, board_cfg, adc_cfg): + adc, pin = adc_cfg["adc"], adc_cfg["inputs"][0] + sampling = expect.ADC_SAMPLING_TIMES[board_cfg.family] + other_family = next(times for family, times in expect.ADC_SAMPLING_TIMES.items() if family != board_cfg.family) + cases = [ + ({}, "usage"), + ({"pins": [pin], "rate": 1000}, "usage"), + ({"pins": [pin], "sampling": next(time for time in other_family if time not in sampling)}, "usage"), + ({"pins": [pin] * (expect.ADC_MAX_PINS + 1)}, "range"), + ({"pins": [pin], "timer": 2, "rate": 0}, "range"), + ({"pins": [pin], "timer": 2, "rate": expect.ADC_RATE_MAX + 1}, "range"), + ({"pins": [pin], "timer": 18}, "range"), + ({"pins": [pin, board_cfg.param("gpio.output_pins")[0]]}, "pin"), + *[({"pins": [pin], "timer": timer}, "range") for timer in adc_cfg["trigger"]["missing_timers"]], + ] + for options, reason in cases: + with pytest.raises(FirmwareError) as error: + fw.adc.open(adc, **options) + assert error.value.reason == reason, options + missing = next(number for number in range(8) if number != adc) + with pytest.raises(FirmwareError) as error: + fw.adc.open(missing, pins=[pin]) + assert error.value.reason == "range", "another ADC number" + + +def test_measure_limits(fw, adc_cfg): + adc = adc_cfg["adc"] + with pytest.raises(FirmwareError) as error: + fw.adc.measure(adc) + assert error.value.reason == "notopen" + pins = adc_cfg["inputs"] + fw.adc.open(adc, pins=pins) + with pytest.raises(FirmwareError) as error: + fw.adc.open(adc, pins=pins) + assert error.value.reason == "busy", "one ADC at a time" + largest = expect.adc_max_runs(len(pins)) + assert len(fw.adc.measure(adc, n=largest)) == largest * len(pins) + for runs in (0, largest + 1, expect.ADC_MAX_VALUES + 1): + with pytest.raises(FirmwareError) as error: + fw.adc.measure(adc, n=runs) + assert error.value.reason == "range", runs + + +def test_gpio_pin_blocks_adc(fw, adc_cfg): + pin = adc_cfg["inputs"][0] + fw.gpio.cfg(pin, "in") + with pytest.raises(FirmwareError) as error: + fw.adc.open(adc_cfg["adc"], pins=[pin]) + assert error.value.reason == "busy" + + +def test_adc_pin_blocks_gpio(fw, adc_cfg): + """Analog users share a pin (a sequence may repeat it); a GPIO cannot take it.""" + pin = adc_cfg["inputs"][0] + fw.adc.open(adc_cfg["adc"], pins=[pin, pin]) + with pytest.raises(FirmwareError) as error: + fw.gpio.cfg(pin, "in") + assert error.value.reason == "busy" diff --git a/validation/host/tests/hil/test_gpio.py b/validation/host/tests/hil/test_gpio.py new file mode 100644 index 00000000..d7b9b740 --- /dev/null +++ b/validation/host/tests/hil/test_gpio.py @@ -0,0 +1,212 @@ +"""GPIO (`hal::GpioPinStm`): levels, drive strengths, pulls, open drain, EXTI interrupts and timer-driven pulses. + +Wiring set `bundle1`: the pins of `tests.gpio.loop_pins`/`output_pins` are pins of other peripherals, used here as +plain GPIO; `output_pins` is the user LED. An EXTI line serves one port at a time: `tests.gpio.exti_sharing` names +two wired pins with the same index on different ports. +""" + +import statistics + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + + +def check_output(fw, ad3, pin, dio, drive=None): + fw.gpio.cfg(pin, "out", drive=drive) + for level in (0, 1, 0, 1, 0): + fw.gpio.set(pin, level) + assert ad3.dio.read(dio) == level, f"{pin} set to {level}" + + +def check_input(fw, ad3, pin, dio, pull="none"): + fw.gpio.cfg(pin, "in", pull=pull) + for level in (1, 0, 1, 0): + ad3.dio.drive(dio, level) + assert fw.gpio.get(pin) == level, f"DIO{dio} driven {level}" + ad3.dio.release(dio) + + +@pytest.mark.ad3 +@pytest.mark.board_params("pin", "gpio.loop_pins") +@pytest.mark.board_params("drive", "gpio.drives") +def test_output_levels(fw, ad3, need, pin, drive): + check_output(fw, ad3, pin, need.dio(pin), drive) + + +@pytest.mark.ad3 +@pytest.mark.board_params("pin", "gpio.output_pins") +def test_output_pins(fw, ad3, need, pin): + check_output(fw, ad3, pin, need.dio(pin)) + + +@pytest.mark.ad3 +@pytest.mark.board_params("pin", "gpio.loop_pins") +@pytest.mark.board_params("pull", "gpio.input_pulls") +def test_input_follows_ad3(fw, ad3, need, pin, pull): + check_input(fw, ad3, pin, need.dio(pin), pull) + + +@pytest.mark.ad3 +@pytest.mark.board_params("pin", "gpio.loop_pins") +@pytest.mark.board_params("pull", "gpio.pulls") +def test_pull_sets_idle_level(fw, ad3, need, pin, pull): + dio = need.dio(pin) + ad3.dio.release(dio) + fw.gpio.cfg(pin, "in", pull=pull) + expected = 1 if pull == "up" else 0 + assert fw.gpio.get(pin) == expected + assert ad3.dio.read(dio) == expected + + +@pytest.mark.ad3 +@pytest.mark.board_params("pin", "gpio.loop_pins") +def test_open_drain(fw, ad3, need, pin): + dio = need.dio(pin) + ad3.dio.release(dio) + fw.gpio.cfg(pin, "od") + fw.gpio.set(pin, 0) + assert ad3.dio.read(dio) == 0, "open drain must pull low" + fw.gpio.set(pin, 1) + for level in (1, 0, 1): + ad3.dio.drive(dio, level) + assert fw.gpio.get(pin) == level, "released open-drain pin must follow the external level" + ad3.dio.release(dio) + + +def test_configuration_errors(fw, board_cfg): + pin = board_cfg.param("gpio.loop_pins")[0] + for options, reason in (({"pull": "up"}, "usage"), ({"drive": "fastest"}, "usage")): + with pytest.raises(FirmwareError) as error: + fw.gpio.cfg(pin, "od", **options) + assert error.value.reason == reason, options + fw.gpio.cfg(pin, "od", pull="none") + fw.gpio.cfg(pin, "out", drive="high") + assert fw.gpio.get(pin) == 0, "out starts low" + with pytest.raises(FirmwareError) as error: + fw.command("gpio.set", fw.pin(pin), 2) + assert error.value.reason == "usage" + fw.gpio.cfg(pin, "in") + with pytest.raises(FirmwareError) as error: + fw.gpio.pulse(pin, 1, 1) + assert error.value.reason == "usage", "gpio.pulse needs an output" + fw.gpio.release(pin) + for name, args in (("gpio.get", ()), ("gpio.set", (1,)), ("gpio.count", ()), ("gpio.irq", ("rising",)), ("gpio.release", ())): + with pytest.raises(FirmwareError) as error: + fw.command(name, fw.pin(pin), *args) + assert error.value.reason == "notopen", name + + +def test_eight_pins_at_a_time(fw, board_cfg): + """RAM limits the GPIO group to `tests.gpio.limit` pins; reconfiguring a pin needs no new entry.""" + pins = board_cfg.param("gpio.limit_pins") + limit = board_cfg.param("gpio.limit") + assert len(pins) > limit + for pin in pins[:limit]: + fw.gpio.cfg(pin, "in") + with pytest.raises(FirmwareError) as error: + fw.gpio.cfg(pins[limit], "in") + assert error.value.reason == "busy" + fw.gpio.cfg(pins[0], "in", pull="up") + fw.gpio.release(pins[0]) + fw.gpio.cfg(pins[limit], "in") + + +def test_pins_held_by_other_groups(fw, board_cfg): + instance = board_cfg.param("spi.instances")[0] + fw.spi.open(instance["index"], clk=instance["clk"], mosi=instance["mosi"], miso=instance["miso"], cs=instance["cs"]) + for key in ("clk", "cs"): + with pytest.raises(FirmwareError) as error: + fw.gpio.cfg(instance[key], "in") + assert error.value.reason == "busy", key + fw.spi.close(instance["index"]) + fw.gpio.cfg(instance["cs"], "in") + + +def expected_edges(edge, pulses): + return {"rising": pulses, "falling": pulses, "both": 2 * pulses}[edge] + + +@pytest.mark.ad3 +@pytest.mark.board_params("pin", "gpio.loop_pins") +@pytest.mark.matrix("gpio.irq") +def test_interrupt_counts(fw, ad3, need, pin, edge, handler, pulses, frequency): + dio = need.dio(pin) + ad3.dio.release(dio) + fw.gpio.cfg(pin, "in", pull="down") + fw.gpio.irq(pin, edge, type=handler) + fw.gpio.count(pin, clear=True) + ad3.pattern.pulses(dio, pulses, frequency) + ad3.pattern.wait_done(timeout=pulses / frequency + 2) + fw.system.delay(10) + assert fw.gpio.count(pin) == expected_edges(edge, pulses) + fw.gpio.irq(pin, "off") + ad3.pattern.pulses(dio, 3, frequency) + ad3.pattern.wait_done(timeout=3 / frequency + 2) + assert fw.gpio.count(pin) == expected_edges(edge, pulses), "counting must stop after irq off" + + +def test_exti_line_serves_one_port(fw, board_cfg): + """While a pin counts edges on its EXTI line, the pin of another port with the same index is refused.""" + sharing = board_cfg.param("gpio.exti_sharing") + counting, other = sharing["counting"], sharing["sharing"] + fw.gpio.cfg(counting, "in", pull="down") + fw.gpio.cfg(other, "in", pull="down") + fw.gpio.irq(counting, "rising") + fw.gpio.irq(counting, "both", type="immediate") + for edge in ("rising", "off"): + with pytest.raises(FirmwareError) as error: + fw.gpio.irq(other, edge) + assert error.value.reason == "unsupported", edge + fw.gpio.irq(counting, "off") + fw.gpio.irq(other, "falling") + with pytest.raises(FirmwareError) as error: + fw.gpio.irq(counting, "rising") + assert error.value.reason == "unsupported" + fw.gpio.release(other) + fw.gpio.irq(counting, "rising") + + +@pytest.mark.ad3 +def test_exti_owner_keeps_counting(fw, ad3, need, board_cfg): + """The refused pin leaves the owner's interrupt alone, and edges on it are not counted.""" + sharing = board_cfg.param("gpio.exti_sharing") + counting, other = sharing["counting"], sharing["sharing"] + counting_dio, other_dio = need.dio(counting), need.dio(other) + ad3.dio.release(counting_dio, other_dio) + fw.gpio.cfg(counting, "in", pull="down") + fw.gpio.cfg(other, "in", pull="down") + fw.gpio.irq(counting, "rising") + with pytest.raises(FirmwareError): + fw.gpio.irq(other, "rising") + fw.gpio.count(counting, clear=True) + for dio, pulses in ((other_dio, 7), (counting_dio, 5)): + ad3.pattern.pulses(dio, pulses, 1000) + ad3.pattern.wait_done(timeout=pulses / 1000 + 2) + fw.system.delay(10) + assert fw.gpio.count(counting) == 5 + assert fw.gpio.count(other) == 0 + + +@pytest.mark.ad3 +@pytest.mark.board_params("pin", "gpio.loop_pins") +@pytest.mark.board_params("period_ms", "gpio.pulse.periods_ms") +def test_pulse_timing(fw, ad3, need, board_cfg, pin, period_ms): + dio = need.dio(pin) + count = board_cfg.param("gpio.pulse.count") + tolerance = board_cfg.param("gpio.pulse.tolerance") + jitter = board_cfg.param("gpio.pulse.jitter_ms", 0.5) / 1000 + fw.gpio.cfg(pin, "out") + fw.gpio.set(pin, 0) + duration = (count + 1) * period_ms / 1000 + rate = min(ad3.logic.clock_hz, ad3.logic.buffer_size / (duration * 1.2)) + capture = ad3.logic.arm(rate, int(duration * 1.2 * rate), trigger=(dio, "rising"), pretrigger=0.02) + fw.gpio.pulse(pin, count, period_ms) + bits = capture.wait(timeout=duration + 2).channel(dio) + found = analysis.edges(bits) + assert len(found) == count, f"{len(found)} toggles instead of {count}" + intervals = [(b.index - a.index) / rate for a, b in zip(found, found[1:])] + if intervals: + assert statistics.fmean(intervals) == pytest.approx(period_ms / 1000, rel=tolerance) + worst = max(abs(interval - period_ms / 1000) for interval in intervals) + assert worst <= jitter, f"toggle interval off by {worst * 1000:.3f} ms" diff --git a/validation/host/tests/hil/test_pwm.py b/validation/host/tests/hil/test_pwm.py new file mode 100644 index 00000000..ba95c092 --- /dev/null +++ b/validation/host/tests/hil/test_pwm.py @@ -0,0 +1,419 @@ +"""PWM (`hal::PwmStm` / `SynchronousPwmStm`): waveforms, channels, complementary outputs with dead time, idle +levels, the break input, frequency changes and the argument checks. + +Wiring set `bundle1`: the outputs of `tests.pwm.timers` (channel pins, complementary `npin`s and `brk` inputs) on +DIOs. Frequencies and duties are compared with what the protocol asks for after quantisation to whole counter +ticks (`expect.pwm_frequency`/`pwm_duty`); the centre-aligned period of the current driver is a known gap +(`expect.pwm_frequency_driver`), an expected failure of `test_waveform` where it leaves the frequency tolerance. +""" + +from __future__ import annotations + +import statistics +import time + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import expect + + +@pytest.fixture +def pwm_cfg(board_cfg): + return board_cfg.param("pwm") + + +@pytest.fixture(scope="module") +def timer_clock(board_cfg): + return board_cfg.clock("timer") + + +def supported(values): + """Centre alignment needs a timer with a counter mode select; `count` channels must be wired.""" + timer = values.get("timer") + if timer is None: + return True + if values.get("mode") == "center" and not expect.timer_has_center_mode(timer["timer"]): + return False + return values.get("count") is None or values["count"] <= len(timer["channels"]) + + +def complementary_timer(values): + timer = values.get("timer") + return supported(values) and (timer is None or bool(timer["channels"][0].get("npin"))) + + +def break_timer(values): + timer = values.get("timer") + return supported(values) and (timer is None or bool(timer.get("brk"))) + + +def tolerance(pwm_cfg, key): + return pwm_cfg["tolerance"][key] + + +def record(ad3, pwm_cfg, frequency, trigger_dio=None, periods=None): + periods = periods or pwm_cfg["capture_periods"] + trigger = None if trigger_dio is None else (trigger_dio, "rising") + return ad3.logic.record_for(periods / frequency, trigger=trigger, timeout=periods / frequency + 2) + + +def check_waveform(capture, dio, frequency, duty, pwm_cfg, step=0.0): + """`frequency` and `duty` are the expected (quantised) values; `step` is the duty resolution in percent.""" + if duty in (0, 100): + bits = capture.channel(dio) + level = 1 if duty == 100 else 0 + fraction = bits.count(level) / len(bits) + assert fraction >= tolerance(pwm_cfg, "static_fraction"), f"DIO{dio}: {fraction:.4f} of the samples at {level} for duty {duty}" + return + assert capture.frequency(dio) == pytest.approx(frequency, rel=tolerance(pwm_cfg, "frequency")), f"DIO{dio} frequency" + quantisation = 100 * 2 * frequency / capture.rate + step + assert capture.duty(dio) * 100 == pytest.approx(duty, abs=tolerance(pwm_cfg, "duty") + quantisation), f"DIO{dio} duty" + + +def check_aligned(capture, dios, feature, allowed): + def times(dio): + bits = capture.channel(dio) + if feature == "rising": + return analysis.rising_times(bits, capture.rate) + return analysis.pulse_centers(bits, capture.rate) + + reference = times(dios[0])[1:-1] + assert reference, "no complete pulses captured" + for dio in dios[1:]: + offsets = analysis.nearest_offsets(reference, times(dio)) + worst = max(abs(offset) for offset in offsets) + assert worst <= allowed + 2 / capture.rate, f"DIO{dio} {feature} edges off by {worst * 1e9:.0f} ns" + + +CENTER_PERIOD_GAP = ( + "known gap: hal_st/stm32fxxx/PwmStm.cpp:381-390 - centre aligned the period is 2 x (ticks/2 - 1) counter ticks " + "instead of ticks (expect.pwm_frequency_driver)" +) + + +def expect_center_period_gap(request, pwm_cfg, pwmclk, frequency, mode): + """The centre-aligned period gap fails the frequency check only where the driver's period leaves the tolerance + (short periods; the duty error stays within one step there); `--fake` ignores it like the board files' gaps.""" + if mode != "center" or request.config.getoption("--fake"): + return + error = abs(expect.pwm_frequency_driver(pwmclk, frequency, mode) / expect.pwm_frequency(pwmclk, frequency, mode) - 1) + if error > tolerance(pwm_cfg, "frequency"): + request.applymarker(pytest.mark.xfail(strict=False, reason=CENTER_PERIOD_GAP)) + + +def expected_waveform(pwmclk, frequency, mode, duty): + return ( + expect.pwm_frequency(pwmclk, frequency, mode), + expect.pwm_duty(pwmclk, frequency, mode, duty), + expect.pwm_duty_step(pwmclk, frequency, mode), + ) + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "pwm.timers") +@pytest.mark.matrix("pwm.waveform") +@pytest.mark.constraint(valid=supported) +def test_waveform(request, fw, ad3, need, pwm_cfg, timer_clock, timer, freq, duty, mode, prescaler, sync): + """One channel: `ERR range` exactly where the period does not fit the counter, else frequency and duty.""" + channel = timer["channels"][0] + dio = need.dio(channel["pin"]) + pwmclk = expect.pwm_clock(timer_clock, prescaler) + fits = expect.pwm_fits(pwmclk, freq, mode, expect.timer_counter_max(timer["timer"])) + if fits and duty not in (0, 100) and not expect.pwm_duty_resolvable(pwmclk, freq, mode, duty): + pytest.skip(f"{duty} % rounds to a static level at {freq} Hz with pwmclk {pwmclk} Hz ({mode})") + try: + reported = fw.pwm.open(timer["timer"], pins=[channel["pin"]], freq=freq, mode=mode, prescaler=prescaler, sync=sync) + except FirmwareError as error: + assert error.reason == "range" and not fits, f"ERR {error.reason} although {freq} Hz fits pwmclk {pwmclk} Hz" + return + assert fits, "the firmware accepted a period outside the counter" + assert reported == pwmclk + if duty not in (0, 100): + expect_center_period_gap(request, pwm_cfg, pwmclk, freq, mode) + fw.pwm.duty(timer["timer"], duty) + capture = record(ad3, pwm_cfg, freq, None if duty in (0, 100) else dio) + frequency, quantised, step = expected_waveform(pwmclk, freq, mode, duty) + check_waveform(capture, dio, frequency, quantised, pwm_cfg, step) + + +@pytest.mark.board_params("timer", "pwm.timers") +@pytest.mark.matrix("pwm.limits") +@pytest.mark.constraint(valid=supported) +def test_frequency_limits(fw, timer_clock, timer, mode, prescaler): + """The lowest and highest frequency that fit open and change; one step beyond is `ERR range`, in `pwm.open` + and in `pwm.freq` (under 2 counter ticks, or beyond 16 bits, 32 bits on TIM2, half the period centre aligned).""" + number, pin = timer["timer"], timer["channels"][0]["pin"] + pwmclk = expect.pwm_clock(timer_clock, prescaler) + low, high = expect.pwm_frequency_limits(pwmclk, mode, expect.timer_counter_max(number)) + for freq in (low - 1, high + 1): + with pytest.raises(FirmwareError) as error: + fw.pwm.open(number, pins=[pin], freq=freq, mode=mode, prescaler=prescaler) + assert error.value.reason == "range", freq + assert fw.pwm.open(number, pins=[pin], freq=low, mode=mode, prescaler=prescaler) == pwmclk + fw.pwm.freq(number, high) + for freq in (low - 1, high + 1): + with pytest.raises(FirmwareError) as error: + fw.pwm.freq(number, freq) + assert error.value.reason == "range", freq + fw.pwm.close(number) + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "pwm.timers") +@pytest.mark.matrix("pwm.channels") +@pytest.mark.constraint(valid=supported) +def test_channels(fw, ad3, need, pwm_cfg, timer, count, mode, sync): + """1-4 channels of one timer with their own duties (in the order of the command) and common edges or centres.""" + selected = timer["channels"][:count] + dios = [need.dio(channel["pin"]) for channel in selected] + frequency = pwm_cfg["channels_frequency"] + duties = pwm_cfg["channel_duties"][:count] + pins = [channel["pin"] for channel in reversed(selected)] + pwmclk = fw.pwm.open(timer["timer"], pins=pins, freq=frequency, mode=mode, sync=sync) + fw.pwm.duty(timer["timer"], *reversed(duties)) + capture = record(ad3, pwm_cfg, frequency, dios[0]) + for dio, duty in zip(dios, duties): + expected, quantised, step = expected_waveform(pwmclk, frequency, mode, duty) + check_waveform(capture, dio, expected, quantised, pwm_cfg, step) + check_aligned(capture, dios, "center" if mode == "center" else "rising", tolerance(pwm_cfg, "alignment_s")) + + +def logical(capture, dio, inverted): + bits = capture.channel(dio) + return [1 - bit for bit in bits] if inverted else bits + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "pwm.timers") +@pytest.mark.matrix("pwm.complementary") +@pytest.mark.constraint(valid=complementary_timer) +def test_complementary(fw, ad3, need, pwm_cfg, timer_clock, timer, dead, inversion, mode, sync): + """Channel and complementary output never active together; each switch-over waits the dead time (counted in + timer kernel clocks, saturating at the largest DTG value); `inv`/`invn` are undone before comparing.""" + channel = timer["channels"][0] + a, b = need.dio(channel["pin"]), need.dio(channel["npin"]) + inv, invn = inversion + frequency = pwm_cfg["complementary_frequency"] + pwmclk = fw.pwm.open( + timer["timer"], pins=[(channel["pin"], channel["npin"])], freq=frequency, mode=mode, dead=dead, inv=inv, invn=invn, sync=sync + ) + fw.pwm.duty(timer["timer"], 40) + capture = record(ad3, pwm_cfg, frequency) + bits_a, bits_b = logical(capture, a, inv), logical(capture, b, invn) + rate = capture.rate + assert analysis.overlap_samples(bits_a, bits_b) == 0, "channel and complementary output active together (shoot-through)" + split = analysis.dead_times_split(bits_a, bits_b, rate) + dead_time = expect.pwm_dead_time(dead, timer_clock) + allowed = tolerance(pwm_cfg, "dead_ticks") / timer_clock + 2 / rate + for name, times in (("output off -> complementary on", split.a_off_to_b_on), ("complementary off -> output on", split.b_off_to_a_on)): + assert times, f"no {name} transitions captured" + assert statistics.median(times) == pytest.approx(dead_time, abs=allowed), name + high_a = statistics.median(analysis.high_low_times(bits_a, rate)[0]) + high_b = statistics.median(analysis.high_low_times(bits_b, rate)[0]) + period = 1 / expect.pwm_frequency(pwmclk, frequency, mode) + assert high_a + high_b + 2 * dead_time == pytest.approx(period, abs=2 * allowed + 4 / rate) + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "pwm.timers") +@pytest.mark.constraint(valid=complementary_timer) +def test_complementary_only(fw, ad3, need, pwm_cfg, timer): + """`-:` drives the complementary output only (the complement of the channel's reference, as the driver + enables both outputs); the channel pin stays a GPIO driven low.""" + channel = timer["channels"][0] + a, b = need.dio(channel["pin"]), need.dio(channel["npin"]) + fw.gpio.cfg(channel["pin"], "out") + frequency = pwm_cfg["channels_frequency"] + pwmclk = fw.pwm.open(timer["timer"], pins=[(None, channel["npin"])], freq=frequency) + fw.pwm.duty(timer["timer"], 30) + capture = record(ad3, pwm_cfg, frequency, b) + assert capture.edge_count(a) == 0 and capture.channel(a)[0] == 0, "the unused channel pin changed" + expected, _, step = expected_waveform(pwmclk, frequency, "edge", 30) + check_waveform(capture, b, expected, 100 - expect.pwm_duty(pwmclk, frequency, "edge", 30), pwm_cfg, step) + + +@pytest.mark.board_params("dead", "pwm.dead_limit_ns") +def test_dead_time_limits(fw, pwm_cfg, dead): + """`dead` up to 1 ms opens (saturating at the largest DTG value); beyond it `ERR range`.""" + timer = next(timer for timer in pwm_cfg["timers"] if timer["channels"][0].get("npin")) + channel = timer["channels"][0] + try: + fw.pwm.open(timer["timer"], pins=[(channel["pin"], channel["npin"])], dead=dead) + except FirmwareError as error: + assert error.reason == "range" and not expect.pwm_dead_fits(dead), f"dead={dead}: ERR {error.reason}" + return + assert expect.pwm_dead_fits(dead), f"dead={dead} accepted" + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "pwm.timers") +@pytest.mark.matrix("pwm.idle") +@pytest.mark.constraint(valid=complementary_timer) +def test_idle_levels(fw, ad3, need, pwm_cfg, timer, idle, idlen): + """`idle`/`idlen` are the levels of the output and the complementary output while the outputs are disabled.""" + channel = timer["channels"][0] + a, b = need.dio(channel["pin"]), need.dio(channel["npin"]) + frequency = pwm_cfg["channels_frequency"] + fw.pwm.open(timer["timer"], pins=[(channel["pin"], channel["npin"])], freq=frequency, idle=idle, idlen=idlen) + fw.pwm.duty(timer["timer"], 50) + running = record(ad3, pwm_cfg, frequency) + assert running.edge_count(a) > 0 and running.edge_count(b) > 0, "outputs must run before the stop" + fw.pwm.stop(timer["timer"]) + stopped = record(ad3, pwm_cfg, frequency) + assert stopped.edge_count(a) == 0 and stopped.edge_count(b) == 0, "outputs switch after pwm.stop" + assert (stopped.channel(a)[-1], stopped.channel(b)[-1]) == (idle, idlen) + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "pwm.timers") +@pytest.mark.matrix("pwm.break") +@pytest.mark.constraint(valid=break_timer) +def test_break_input(fw, ad3, need, pwm_cfg, timer, brkpol, brkauto): + """The break input at its active level (`brkpol`) disables the outputs; with `brkauto=1` they come back when + it releases, otherwise they stay off until the next `pwm.duty`.""" + channel = timer["channels"][0] + dio, brk = need.dio(channel["pin"]), need.dio(timer["brk"]) + active = 1 if brkpol == "high" else 0 + frequency = pwm_cfg["channels_frequency"] + ad3.dio.drive(brk, 1 - active) + fw.pwm.open(timer["timer"], pins=[channel["pin"]], freq=frequency, brk=timer["brk"], brkpol=brkpol, brkauto=brkauto) + fw.pwm.duty(timer["timer"], 50) + assert record(ad3, pwm_cfg, frequency).edge_count(dio) > 0, "outputs must run while the break input is inactive" + ad3.dio.drive(brk, active) + time.sleep(pwm_cfg["break_settle_s"]) + assert record(ad3, pwm_cfg, frequency).edge_count(dio) == 0, "outputs keep switching during a break" + ad3.dio.drive(brk, 1 - active) + time.sleep(pwm_cfg["break_settle_s"]) + resumed = record(ad3, pwm_cfg, frequency).edge_count(dio) + assert (resumed > 0) is bool(brkauto), f"{resumed} edges after the break released with brkauto={brkauto}" + fw.pwm.duty(timer["timer"], 50) + assert record(ad3, pwm_cfg, frequency).edge_count(dio) > 0, "pwm.duty must restart the outputs" + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "pwm.timers") +@pytest.mark.matrix("pwm.frequency_change") +@pytest.mark.constraint(valid=supported) +def test_frequency_change_and_stop(fw, ad3, need, pwm_cfg, timer, mode, sync): + channel = timer["channels"][0] + dio = need.dio(channel["pin"]) + changes = pwm_cfg["frequency_changes"] + pwmclk = fw.pwm.open(timer["timer"], pins=[channel["pin"]], freq=changes[0], mode=mode, sync=sync) + fw.pwm.duty(timer["timer"], 50) + for frequency in changes: + fw.pwm.freq(timer["timer"], frequency) + capture = record(ad3, pwm_cfg, frequency, dio) + expected, quantised, step = expected_waveform(pwmclk, frequency, mode, 50) + check_waveform(capture, dio, expected, quantised, pwm_cfg, step) + fw.pwm.stop(timer["timer"]) + assert record(ad3, pwm_cfg, changes[-1]).edge_count(dio) == 0, "the output toggles after pwm.stop" + + +def test_open_errors(fw, pwm_cfg): + timer = pwm_cfg["timers"][0] + number, channel = timer["timer"], timer["channels"][0] + foreign = pwm_cfg["foreign_pin"] + cases = [ + ({}, "usage"), + ({"channels": [5]}, "range"), + ({"channels": [0]}, "range"), + ({"channels": [1, 1]}, "usage"), + ({"channels": [1, 2, 3, 4, 1]}, "usage"), + ({"channels": [1, 2], "pins": [channel["pin"]]}, "usage"), + ({"pins": [(None, None)]}, "usage"), + ({"pins": [foreign]}, "pin"), + ({"pins": [channel["pin"], channel["pin"]]}, "usage"), + ({"channels": [channel["channel"]], "mode": "sideways"}, "usage"), + ({"channels": [channel["channel"]], "prescaler": expect.PWM_PRESCALER_MAX + 1}, "range"), + ({"channels": [channel["channel"]], "freq": 0}, "range"), + ({"channels": [channel["channel"]], "dead": "fast"}, "usage"), + ({"channels": [channel["channel"]], "dead": expect.PWM_DEAD_MAX_NS + 1}, "range"), + ({"channels": [channel["channel"]], "inv": 2}, "range"), + ({"channels": [channel["channel"]], "brkpol": "sideways"}, "usage"), + ({"channels": [channel["channel"]], "brk": foreign}, "pin"), + ] + for options, reason in cases: + with pytest.raises(FirmwareError) as error: + fw.pwm.open(number, **options) + assert error.value.reason == reason, options + with pytest.raises(FirmwareError) as error: + fw.command("pwm.open", number, pins=f"{fw.pin(channel['pin'])}:-:-") + assert error.value.reason == "usage", "one complementary output per entry" + for name, args in (("pwm.duty", (50,)), ("pwm.freq", (1000,)), ("pwm.stop", ()), ("pwm.close", ())): + with pytest.raises(FirmwareError) as error: + fw.command(name, number, *args) + assert error.value.reason == "notopen", name + + +@pytest.mark.board_params("timer", "pwm.timers") +def test_feature_support(fw, pwm_cfg, timer): + """What the timer lacks is `ERR unsupported`: centre alignment on TIM16/TIM17, channels beyond the timer's, + CH4N, and complementary outputs, dead time, idle levels and the break input on timers without a break function.""" + number, channel = timer["timer"], timer["channels"][0] + foreign = pwm_cfg["foreign_pin"] + cases = [] + if not expect.timer_has_center_mode(number): + cases.append({"channels": [channel["channel"]], "mode": "center"}) + if not expect.timer_has_channel(number, 2): + cases.append({"channels": [2]}) + if expect.timer_has_break(number) and expect.timer_has_channel(number, 4): + cases.append({"channels": [4], "pins": [(None, channel.get("npin") or foreign)]}) + if not expect.timer_has_break(number): + cases += [ + {"pins": [channel["pin"]], "dead": 0}, + {"pins": [channel["pin"]], "idle": 1}, + {"pins": [channel["pin"]], "idlen": 1}, + {"pins": [channel["pin"]], "brk": foreign}, + {"pins": [(channel["pin"], foreign)]}, + ] + for options in cases: + with pytest.raises(FirmwareError) as error: + fw.pwm.open(number, **options) + assert error.value.reason == "unsupported", options + + +def test_duty_errors(fw, pwm_cfg): + timer = next(timer for timer in pwm_cfg["timers"] if len(timer["channels"]) >= 2) + number = timer["timer"] + fw.pwm.open(number, pins=[channel["pin"] for channel in timer["channels"][:2]]) + for duties in (("10", "20", "30"), ("101",), ("100.5",), ("12.34567",), ("0x10",), (".5",), ("-1",)): + with pytest.raises(FirmwareError) as error: + fw.command("pwm.duty", number, *duties) + assert error.value.reason == "usage", duties + fw.command("pwm.duty", number, "12.5", "100") + fw.command("pwm.duty", number, "0.0001") + + +def test_one_timer_at_a_time(fw, pwm_cfg): + first, second = pwm_cfg["timers"][:2] + fw.pwm.open(first["timer"], pins=[first["channels"][0]["pin"]]) + for timer in (first, second): + with pytest.raises(FirmwareError) as error: + fw.pwm.open(timer["timer"], pins=[timer["channels"][0]["pin"]]) + assert error.value.reason == "busy", timer["name"] + + +@pytest.mark.ad3 +def test_reopen_forgets_dead_time_and_break(fw, ad3, need, pwm_cfg, timer_clock): + """Each open rebuilds the timer: a previous open's dead time, break input and idle levels do not survive.""" + timer = next(timer for timer in pwm_cfg["timers"] if timer.get("brk") and timer["channels"][0].get("npin")) + channel = timer["channels"][0] + a, b, brk = need.dio(channel["pin"]), need.dio(channel["npin"]), need.dio(timer["brk"]) + frequency = pwm_cfg["complementary_frequency"] + pins = [(channel["pin"], channel["npin"])] + ad3.dio.drive(brk, 1) + fw.pwm.open(timer["timer"], pins=pins, freq=frequency, dead=2000, brk=timer["brk"], brkpol="low", idle=1, idlen=1) + fw.pwm.duty(timer["timer"], 40) + fw.pwm.close(timer["timer"]) + ad3.dio.drive(brk, 0) + fw.pwm.open(timer["timer"], pins=pins, freq=frequency) + fw.pwm.duty(timer["timer"], 40) + capture = record(ad3, pwm_cfg, frequency) + assert capture.edge_count(a) > 0 and capture.edge_count(b) > 0, "outputs held off by the previous break settings" + split = analysis.dead_times_split(capture.channel(a), capture.channel(b), capture.rate) + times = split.a_off_to_b_on + split.b_off_to_a_on + assert times, "no switch-overs captured" + allowed = tolerance(pwm_cfg, "dead_ticks") / timer_clock + 2 / capture.rate + assert statistics.median(times) == pytest.approx(0, abs=allowed), "dead time of the previous open" diff --git a/validation/host/tests/hil/test_qei.py b/validation/host/tests/hil/test_qei.py new file mode 100644 index 00000000..d42f19ac --- /dev/null +++ b/validation/host/tests/hil/test_qei.py @@ -0,0 +1,246 @@ +"""Quadrature encoder (`hal::SynchronousQuadratureEncoderStm`, LPTIM `SynchronousQuadratureEncoderLpTimStm`) +driven by the AD3 pattern generator. + +Wiring set `bundle1`: A, B and index of each `tests.qei.instances` entry on DIOs; the LPTIM1 encoder of the +NUCLEO-WB55RG (`tests.qei.lp_instances`) is wired in `bundle2`. The pattern generator produces an exact number of +4-state cycles (A leads B for `fwd`). +""" + +from __future__ import annotations + +import time + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import expect + + +@pytest.fixture +def qei(board_cfg): + return board_cfg.param("qei") + + +def instance_dios(need, instance, keys=("a", "b", "idx")): + return {key: need.dio(instance[key]) for key in keys} + + +def open_qei(fw, qei, instance, **options): + options.setdefault("res", qei["resolution"]) + fw.qei.open(instance["index"], a=instance["a"], b=instance["b"], idx=instance.get("idx"), **options) + + +def run(ad3, dios, frequency, cycles, direction="fwd"): + ad3.pattern.quadrature(dios["a"], dios["b"], frequency, cycles, direction) + ad3.pattern.wait_done(timeout=cycles / frequency + 2) + + +def run_inverted(ad3, dios, frequency, cycles, invert_a, invert_b, direction="fwd"): + """Quadrature with physically inverted phases, rotated so the run ends in the idle (low, low) state.""" + pattern = analysis.quadrature_pattern(1, direction) + states = [(a ^ invert_a, b ^ invert_b) for a, b in zip(pattern["a"], pattern["b"])] + while states[-1] != (0, 0): + states = states[1:] + states[:1] + ad3.pattern.custom({dios["a"]: [a for a, _ in states], dios["b"]: [b for _, b in states]}, frequency * 4, run_samples=4 * cycles) + ad3.pattern.wait_done(timeout=cycles / frequency + 2) + + +def check_counts(fw, qei, index, before, counts, resolution=None): + resolution = resolution or qei["resolution"] + after = fw.qei.read(index) + assert expect.wrap_delta(after.pos - before, resolution) == expect.wrap_delta(counts, resolution) + assert after.dir == ("fwd" if counts > 0 else "rev") + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "qei.instances") +@pytest.mark.matrix("qei.position") +def test_position_counts(fw, ad3, need, qei, instance, freq, cycles, direction, cap, inva, invb): + """Counts per cycle follow the capture mode; inverting exactly one phase reverses the direction.""" + dios = instance_dios(need, instance) + open_qei(fw, qei, instance, cap=cap, inva=inva, invb=invb) + before = fw.qei.read(instance["index"]).pos + run(ad3, dios, freq, cycles, direction) + check_counts(fw, qei, instance["index"], before, expect.qei_counts(cycles, cap, direction, inva, invb)) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "qei.instances") +@pytest.mark.board_params("inva,invb", "qei.inversions") +def test_inverted_inputs_restore_the_signal(fw, ad3, need, qei, instance, inva, invb): + """Physically inverted phases with the matching `inva`/`invb` count like the plain signal.""" + dios = instance_dios(need, instance) + open_qei(fw, qei, instance, inva=inva, invb=invb) + before = fw.qei.read(instance["index"]).pos + cycles = 25 + run_inverted(ad3, dios, 1000, cycles, inva, invb) + check_counts(fw, qei, instance["index"], before, qei["counts_per_cycle"]["ab"] * cycles) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "qei.instances") +@pytest.mark.matrix("qei.rollover") +def test_offset_and_rollover(fw, ad3, need, qei, instance, res, offset): + dios = instance_dios(need, instance) + open_qei(fw, qei, instance, res=res, offset=offset) + reading = fw.qei.read(instance["index"]) + assert (reading.pos, reading.res) == (offset, res) + cycles = 30 + run(ad3, dios, 1000, cycles) + assert fw.qei.read(instance["index"]).pos == expect.wrap_position(offset + 4 * cycles, res) + run(ad3, dios, 1000, cycles, "rev") + assert fw.qei.read(instance["index"]).pos == offset + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "qei.instances") +@pytest.mark.matrix("qei.velocity") +def test_speed(fw, ad3, need, qei, instance, freq, vel): + """`speed` is in counts per second, sampled every `vel` µs.""" + dios = instance_dios(need, instance) + open_qei(fw, qei, instance, vel=vel, cap="ab") + ad3.pattern.quadrature(dios["a"], dios["b"], freq, 0) + try: + time.sleep(3 * vel / 1e6 + 0.05) + speed = fw.qei.read(instance["index"]).speed + finally: + ad3.pattern.stop() + expected = qei["counts_per_cycle"]["ab"] * freq + assert speed == pytest.approx(expected, rel=qei["speed_tolerance"], abs=1e6 / vel) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "qei.instances") +def test_index_input(fw, ad3, need, qei, instance): + """`qei.index` reads the level of the index input, which never changes the count.""" + dios = instance_dios(need, instance) + open_qei(fw, qei, instance) + position = fw.qei.read(instance["index"]).pos + for level in (1, 0, 1, 0): + ad3.dio.drive(dios["idx"], level) + assert fw.qei.index(instance["index"]) == level + ad3.dio.release(dios["idx"]) + assert fw.qei.read(instance["index"]).pos == position + + +@pytest.mark.ad3 +@pytest.mark.family("stm32wb55") +@pytest.mark.board_params("instance", "qei.lp_instances") +@pytest.mark.matrix("qei.lp_position") +def test_lptim_position(fw, ad3, need, qei, instance, freq, cycles, direction, inva, filter): + """LPTIM1 (`lp=1`) counts both edges of both inputs; `inva=1` reverses the direction (mirrored mounting).""" + dios = instance_dios(need, instance, ("a", "b")) + resolution = instance["max_res"] + fw.qei.open(instance["index"], lp=True, a=instance["a"], b=instance["b"], res=resolution, inva=inva, filter=filter) + before = fw.qei.read(instance["index"]).pos + run(ad3, dios, freq, cycles, direction) + check_counts(fw, qei, instance["index"], before, expect.qei_counts(cycles, "ab", direction, inva), resolution) + + +def test_default_instance(fw, qei): + """The default encoder opens on its default pins (with the index input) when no pin is given.""" + index = qei["default_instance"] + fw.qei.open(index) + reading = fw.qei.read(index) + assert (reading.res, reading.speed) == (qei["resolution"], 0) + assert fw.qei.index(index) in (0, 1) + fw.qei.close(index) + with pytest.raises(FirmwareError) as error: + fw.qei.read(index) + assert error.value.reason == "notopen" + + +@pytest.mark.board_params("instance", "qei.instances") +def test_instance_needs_pins(fw, qei, instance): + if instance["index"] == qei["default_instance"]: + pytest.skip("the default encoder has default pins") + with pytest.raises(FirmwareError) as error: + fw.qei.open(instance["index"]) + assert error.value.reason == "usage" + with pytest.raises(FirmwareError) as error: + fw.qei.open(instance["index"], a=instance["a"]) + assert error.value.reason == "usage" + + +@pytest.mark.board_params("instance", "qei.instances") +def test_resolution_limits(fw, instance): + """`res` is 2 to 65536, up to 4294967295 on the 32-bit TIM2; `offset` must stay below it.""" + maximum, index = instance["max_res"], instance["index"] + pins = {"a": instance["a"], "b": instance["b"]} + beyond = "usage" if maximum + 1 > 0xFFFFFFFF else "range" + for options, reason in (({"res": 1}, "range"), ({"res": maximum + 1}, beyond), ({"res": 100, "offset": 100}, "range")): + with pytest.raises(FirmwareError) as error: + fw.qei.open(index, **pins, **options) + assert error.value.reason == reason, options + fw.qei.open(index, **pins, res=maximum, offset=maximum - 1) + reading = fw.qei.read(index) + assert (reading.pos, reading.res) == (maximum - 1, maximum) + + +@pytest.mark.board_params("instance", "qei.instances") +def test_open_errors(fw, instance): + pins = {"a": instance["a"], "b": instance["b"]} + cases = [ + ({**pins, "cap": "x"}, "usage"), + ({**pins, "filter": 16}, "range"), + ({**pins, "vel": 0}, "range"), + ({**pins, "vel": 1000001}, "range"), + ({**pins, "inva": 2}, "range"), + ({"a": instance["b"], "b": instance["a"]}, "pin"), + ] + for options, reason in cases: + with pytest.raises(FirmwareError) as error: + fw.qei.open(instance["index"], **options) + assert error.value.reason == reason, options + with pytest.raises(FirmwareError) as error: + fw.qei.index(instance["index"]) + assert error.value.reason == "notopen" + fw.qei.open(instance["index"], **pins, vel="off", filter=15) + with pytest.raises(FirmwareError) as error: + fw.qei.index(instance["index"]) + assert error.value.reason == "unsupported", "opened without idx" + + +@pytest.mark.family("stm32wb55") +@pytest.mark.board_params("instance", "qei.lp_instances") +def test_lptim_errors(fw, qei, instance): + pins = {"lp": True, "a": instance["a"], "b": instance["b"]} + cases = [ + ({**pins, "filter": 3}, "range"), + ({**pins, "res": instance["max_res"] + 1}, "range"), + ({**pins, "cap": "ab"}, "unsupported"), + ({**pins, "offset": 0}, "unsupported"), + ({**pins, "invb": 0}, "unsupported"), + ({"lp": True}, "usage"), + ({"lp": True, "a": instance["b"], "b": instance["a"]}, "pin"), + ] + for options, reason in cases: + with pytest.raises(FirmwareError) as error: + fw.qei.open(instance["index"], **options) + assert error.value.reason == reason, options + for index in qei["lp_missing_instances"]: + with pytest.raises(FirmwareError) as error: + fw.qei.open(index, **pins) + assert error.value.reason == "range", f"LPTIM{index}" + + +def test_one_encoder_at_a_time(fw, qei): + first, second = qei["instances"][:2] + open_qei(fw, qei, first) + with pytest.raises(FirmwareError) as error: + open_qei(fw, qei, second) + assert error.value.reason == "busy" + + +def test_timer_shared_with_pwm_and_adc(fw, board_cfg, qei): + """A timer serves one group at a time: while it counts an encoder, PWM and the ADC trigger get `ERR busy`.""" + instance = next(instance for instance in qei["instances"] if instance["index"] in expect.ADC_TRIGGER_TIMERS) + timer = instance["index"] + open_qei(fw, qei, instance) + with pytest.raises(FirmwareError) as error: + fw.pwm.open(timer, channels=[3]) + assert error.value.reason == "busy", "PWM" + with pytest.raises(FirmwareError) as error: + fw.adc.open(board_cfg.param("adc.adc"), pins=[board_cfg.param("adc.inputs")[0]], timer=timer) + assert error.value.reason == "busy", "timer-triggered ADC" diff --git a/validation/host/tests/hil/test_spi.py b/validation/host/tests/hil/test_spi.py new file mode 100644 index 00000000..314637b7 --- /dev/null +++ b/validation/host/tests/hil/test_spi.py @@ -0,0 +1,207 @@ +"""SPI master (`hal::SpiMasterStm`, `SpiMasterStmDma`, `SynchronousSpiMasterStm`), decoded from a logic-analyzer +capture. + +Wiring set `bundle1`: CLK, CS, MOSI and MISO of `tests.spi.instances` on DIOs. The AD3 SDK has no verified +SPI-slave mode, so the firmware master is observed with the logic analyzer: MISO is driven to a static level by +the AD3, or, with `--with loopback` and a MOSI-MISO jumper, only monitored (the firmware must then read back what +it sent). The clock is the fastest spiclk / 2^n not above `baud` (`expect.spi_clock`). +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import expect + + +@pytest.fixture +def spi_cfg(board_cfg): + return board_cfg.param("spi") + + +def spi_dios(need, instance): + return {key: need.dio(instance[key]) for key in ("clk", "cs", "mosi", "miso")} + + +def measured_clock(clk, rate, baud): + """Clock frequency over each burst of edges (a gap of more than 1.5 periods separates bursts).""" + edges = [edge.index for edge in analysis.edges(clk)] + gap = 1.5 * rate / baud + bursts, current = [], edges[:1] + for index in edges[1:]: + if index - current[-1] > gap: + bursts.append(current) + current = [index] + else: + current.append(index) + bursts.append(current) + periods = sum((len(burst) - 1) / 2 for burst in bursts if len(burst) > 2) + samples = sum(burst[-1] - burst[0] for burst in bursts if len(burst) > 2) + return periods * rate / samples if samples else 0.0 + + +def open_spi(fw, spi_cfg, instance, variant, use_cs=True, **options): + driver = spi_cfg["variants"][variant] + fw.spi.open( + instance["index"], + clk=instance["clk"], + mosi=instance["mosi"], + miso=instance["miso"], + cs=instance["cs"] if use_cs else None, + dma=driver["dma"], + sync=driver["sync"], + **options, + ) + + +def expected_rx(payload, loopback, miso_level): + return payload if loopback else bytes([0xFF if miso_level else 0x00] * len(payload)) + + +def prepare_miso(ad3, wiring, dios, miso_level): + loopback = wiring.has("loopback") + if loopback and miso_level: + pytest.skip("MISO follows MOSI with the loopback jumper") + if not loopback: + ad3.dio.drive(dios["miso"], miso_level) + return loopback + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spi.instances") +@pytest.mark.matrix("spi.transfer") +@pytest.mark.board_params("miso_level", "spi.miso_levels") +def test_transfer(fw, ad3, need, wiring, board_cfg, spi_cfg, instance, mode, baud, variant, cs, miso_level): + dios = spi_dios(need, instance) + loopback = prepare_miso(ad3, wiring, dios, miso_level) + use_cs = cs == "gpio" + open_spi(fw, spi_cfg, instance, variant, use_cs, baud=baud, mode=mode) + clock = expect.spi_clock(board_cfg.clock("spi", instance["index"]), baud) + cs_dio = dios["cs"] if use_cs else None + for text in spi_cfg["payloads"]: + payload = bytes.fromhex(text) + wanted = expected_rx(payload, loopback, miso_level) + duration = len(payload) * 8 / clock * 4 + 200e-6 + rate = min(ad3.logic.clock_hz, 20 * clock, ad3.logic.buffer_size / duration) + decodable = rate >= 4 * clock + trigger = (dios["cs"], "falling") if use_cs else (dios["clk"], "either") + capture = ad3.logic.arm(rate, int(duration * rate), trigger=trigger, pretrigger=0.05) if decodable else None + assert fw.spi.xfer(instance["index"], payload) == wanted + if capture is None: + continue + result = capture.wait(timeout=2.0) + frames = result.spi(dios["clk"], dios["mosi"], dios["miso"], cs_dio, mode) + mosi, miso = analysis.spi_join(frames) + assert mosi == payload, f"decoded MOSI {mosi.hex()} (frames: {len(frames)})" + assert miso == wanted + clk = result.channel(dios["clk"]) + cpol, _ = analysis.spi_mode_bits(mode) + cs_bits = None if cs_dio is None else result.channel(cs_dio) + assert analysis.clock_idle_level(clk, cs_bits) == cpol, "clock idle level (CPOL)" + assert measured_clock(clk, result.rate, clock) == pytest.approx(clock, rel=spi_cfg["tolerance"]["baud"]) + if cs_bits is not None: + assert cs_bits[-1] == 1, "the chip select must be released after the transfer" + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spi.instances") +@pytest.mark.matrix("spi.sessions") +def test_continued_session(fw, ad3, need, spi_cfg, instance, mode, variant): + """`continue=1` keeps the chip select low for the next `spi.xfer`; the bytes of both arrive in one session.""" + dios = spi_dios(need, instance) + ad3.dio.drive(dios["miso"], 0) + baud = spi_cfg["session_baud"] + open_spi(fw, spi_cfg, instance, variant, baud=baud, mode=mode) + duration = 0.2 + rate = min(ad3.logic.clock_hz, ad3.logic.buffer_size / duration) + if rate < 4 * baud: + pytest.skip("the session does not fit the logic analyzer buffer") + capture = ad3.logic.arm(rate, int(duration * rate), trigger=(dios["cs"], "falling"), pretrigger=0.01) + fw.spi.xfer(instance["index"], b"\x12\x34", continue_=True) + fw.spi.xfer(instance["index"], b"\x56", continue_=False) + result = capture.wait(timeout=3.0) + cs_bits = result.channel(dios["cs"]) + assert analysis.edge_count(cs_bits[result.trigger_index or 0 :], "rising") == 1, "one session: CS rises once" + frames = result.spi(dios["clk"], dios["mosi"], None, dios["cs"], mode) + assert analysis.spi_join(frames)[0] == b"\x12\x34\x56" + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spi.instances") +@pytest.mark.matrix("spi.receive_only") +def test_receive_only_first(fw, ad3, need, wiring, spi_cfg, instance, variant): + """A receive-only transfer (`spi.xfer - rx=`) right after `spi.open` clocks out zeros and returns MISO.""" + dios = spi_dios(need, instance) + loopback = prepare_miso(ad3, wiring, dios, 0 if wiring.has("loopback") else 1) + open_spi(fw, spi_cfg, instance, variant, baud=1000000) + assert fw.spi.xfer(instance["index"], b"", rx=4) == (b"\x00" * 4 if loopback else b"\xff" * 4) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spi.instances") +@pytest.mark.matrix("spi.largest") +def test_largest_transfer(fw, ad3, need, wiring, spi_cfg, instance, variant): + dios = spi_dios(need, instance) + loopback = prepare_miso(ad3, wiring, dios, 0 if wiring.has("loopback") else 1) + open_spi(fw, spi_cfg, instance, variant, baud=1000000) + size = spi_cfg["max_transfer"] + payload = bytes((i * 11 + 5) & 0xFF for i in range(size)) + assert fw.spi.xfer(instance["index"], payload) == (payload if loopback else b"\xff" * size) + assert fw.spi.xfer(instance["index"], payload[:8], rx=0) == b"" + assert fw.spi.xfer(instance["index"], payload[:2], rx=6) == (payload[:2] + b"\x00" * 4 if loopback else b"\xff" * 6) + assert fw.spi.xfer(instance["index"], b"", rx=4) == (b"\x00" * 4 if loopback else b"\xff" * 4) + + +@pytest.mark.board_params("instance", "spi.instances") +@pytest.mark.board_params("baud", "spi.open_bauds") +def test_open_bauds(fw, board_cfg, instance, baud): + """`baud` outside spiclk/256 .. spiclk/2 is `ERR range`.""" + fits = expect.spi_baud_fits(board_cfg.clock("spi", instance["index"]), baud) + try: + fw.spi.open(instance["index"], clk=instance["clk"], mosi=instance["mosi"], miso=instance["miso"], baud=baud) + except FirmwareError as error: + assert error.reason == "range" and not fits, f"ERR {error.reason} although {baud} Hz fits" + return + assert fits, f"{baud} Hz accepted outside the limits" + fw.spi.close(instance["index"]) + + +@pytest.mark.board_params("instance", "spi.instances") +def test_open_errors(fw, board_cfg, instance): + pins = {"clk": instance["clk"], "mosi": instance["mosi"], "miso": instance["miso"]} + cases = [ + ({"clk": instance["clk"], "mosi": instance["mosi"]}, "usage"), + ({**pins, "dma": True, "sync": True}, "usage"), + ({**pins, "mode": 4}, "range"), + ({**pins, "baud": 0}, "range"), + ({"clk": instance["miso"], "mosi": instance["mosi"], "miso": instance["clk"]}, "pin"), + ({**pins, "cs": board_cfg.terminal.pins[0]}, "busy"), + ({**pins, "cs": instance["clk"]}, "busy"), + ] + for options, reason in cases: + with pytest.raises(FirmwareError) as error: + fw.spi.open(instance["index"], **options) + assert error.value.reason == reason, options + fw.spi.open(instance["index"], **pins, cs=instance["cs"], sync=True) + + +@pytest.mark.board_params("instance", "spi.instances") +def test_transfer_errors(fw, spi_cfg, instance): + with pytest.raises(FirmwareError) as error: + fw.spi.xfer(instance["index"], b"\x00") + assert error.value.reason == "notopen" + fw.spi.open(instance["index"], clk=instance["clk"], mosi=instance["mosi"], miso=instance["miso"]) + size = spi_cfg["max_transfer"] + for line, reason in ( + (f"spi.xfer {instance['index']} -", "usage"), + (f"spi.xfer {instance['index']} 00 rx=0x", "usage"), + (f"spi.xfer {instance['index']} 123", "usage"), + (f"spi.xfer {instance['index']} " + "00" * (size + 1), "range"), + (f"spi.xfer {instance['index']} - rx={size + 1}", "range"), + (f"spi.xfer {instance['index']} 00 continue=2", "range"), + ): + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line diff --git a/validation/host/tests/hil/test_system.py b/validation/host/tests/hil/test_system.py new file mode 100644 index 00000000..19601b33 --- /dev/null +++ b/validation/host/tests/hil/test_system.py @@ -0,0 +1,151 @@ +"""General commands, framing, pins and error semantics (no AD3 needed).""" + +import re +import time + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.protocol import is_alias + + +def reason_of(fw, line): + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + return error.value.reason + + +def test_ping(fw): + fw.system.ping() + + +def test_info_matches_board(fw, board_cfg): + info = fw.system.info() + assert board_cfg.matches_firmware_name(info.board), info.raw + assert info.family == board_cfg.family + assert info.sysclk == board_cfg.sysclk == board_cfg.clock("sysclk") + assert info.reset in ("iwdg", "wwdg", "sw", "lpwr", "obl", "bor", "pin", "unknown") + assert info.uid is not None and re.fullmatch(r"[0-9a-fA-F]{24}", info.uid), "the 96-bit unique device ID" + + +def test_board_pins_match_yaml(fw, board_cfg): + """`board.pins` and the board file's `pins` are the same alias table, in both directions.""" + reported = fw.system.pins() + mismatches = {alias: (pin, reported.get(alias)) for alias, pin in board_cfg.pins.items() if reported.get(alias) != pin} + assert not mismatches, f"alias: (yaml, firmware) {mismatches}" + extra = {alias: pin for alias, pin in reported.items() if alias not in board_cfg.pins} + assert not extra, f"aliases the board file lacks: {extra}" + + +def test_aliases_are_generic(board_cfg): + assert all(is_alias(alias) for alias in board_cfg.pins), sorted(board_cfg.pins) + assert {"terminaltx", "terminalrx"} <= set(board_cfg.pins) + + +def test_every_alias_is_accepted_as_pin(fw, board_cfg): + """Each alias names its pin in commands; the terminal pins stay reserved.""" + for alias, pin in board_cfg.pins.items(): + if pin in board_cfg.terminal.pins: + assert reason_of(fw, f"gpio.cfg {alias} in") == "busy", alias + continue + fw.command("gpio.cfg", alias, "in") + fw.command("gpio.get", pin) + fw.command("gpio.release", alias) + + +@pytest.mark.board_params("pin", "system.reserved_pins") +def test_reserved_pins_are_busy(fw, pin): + """SWD, the LSE crystal and BOOT0 cannot be opened.""" + assert reason_of(fw, f"gpio.cfg {pin} in") == "busy" + + +def test_terminal_pins_and_debug_led_are_busy(fw, board_cfg): + for pin in [*board_cfg.terminal.pins, board_cfg.param("system.debug_led")]: + assert reason_of(fw, f"gpio.cfg {pin} in") == "busy", pin + + +@pytest.mark.board_params("pin", "system.unbonded_pins") +def test_unbonded_pin(fw, pin): + """A pin the package does not bond out, or of a port the MCU lacks, is `ERR pin`.""" + assert reason_of(fw, f"gpio.cfg {pin} in") == "pin" + + +@pytest.mark.board_params("pin", "system.invalid_pins") +def test_pin_syntax(fw, pin): + """`P` without leading zero and index 0-15, or an alias of the board (case-sensitive).""" + assert reason_of(fw, f"gpio.cfg {pin} in") == "pin" + + +@pytest.mark.board_params("index", "system.reserved_uarts") +def test_terminal_uart_is_reserved(fw, index): + with pytest.raises(FirmwareError) as error: + fw.uart.open(index) + assert error.value.reason == "busy" + + +def test_unknown_command_and_key(fw): + assert reason_of(fw, "no.such.command") == "usage" + with pytest.raises(FirmwareError) as error: + fw.command("ping", nosuchkey=1) + assert error.value.reason == "usage" + + +@pytest.mark.board_params("line", "system.missing_instances") +def test_nonexistent_instance(fw, line): + assert reason_of(fw, line) == "range" + + +@pytest.mark.board_params("line", "system.unsupported_instances") +def test_unsupported_instance(fw, line): + assert reason_of(fw, line) == "unsupported" + + +@pytest.mark.parametrize( + ("name", "args", "reason"), + [ + ("gpio.set", (), "usage"), + ("ping", ("extra",), "usage"), + ("info", ("extra",), "usage"), + ("delay", (), "usage"), + ("delay", ("abc",), "usage"), + ("delay", ("600001",), "range"), + ("gpio.cfg", ("gpio0", "sideways"), "usage"), + ("gpio.cfg", ("gpio0", "in", "pull=sideways"), "usage"), + ], +) +def test_usage_errors(fw, name, args, reason): + assert reason_of(fw, " ".join((name, *args))) == reason + + +def test_notopen_and_busy(fw, board_cfg): + instance = board_cfg.param("spi.instances")[0] + pins = {key: instance[key] for key in ("clk", "mosi", "miso")} + with pytest.raises(FirmwareError) as error: + fw.spi.close(instance["index"]) + assert error.value.reason == "notopen" + fw.spi.open(instance["index"], **pins) + with pytest.raises(FirmwareError) as error: + fw.spi.open(instance["index"], **pins) + assert error.value.reason == "busy" + with pytest.raises(FirmwareError) as error: + fw.spi.open(instance["index"], **pins, baud=1) + assert error.value.reason == "range", "argument errors come before busy" + fw.spi.close(instance["index"]) + + +@pytest.mark.board_params("ms", "system.delays_ms") +def test_delay(fw, ms): + start = time.monotonic() + fw.system.delay(ms) + assert time.monotonic() - start >= ms / 1000 * 0.95 + + +@pytest.mark.resets_board +def test_reset_reports_boot(fw, board_cfg): + boot = fw.system.reset(timeout=board_cfg.param("system.boot_timeout", 5.0)) + assert board_cfg.matches_firmware_name(boot.board) + assert boot.family == board_cfg.family + assert boot.sysclk == board_cfg.sysclk + assert boot.reset == "sw" + fw.system.ping() + assert fw.system.info().reset == "sw" diff --git a/validation/host/tests/hil/test_uart.py b/validation/host/tests/hil/test_uart.py new file mode 100644 index 00000000..d879378b --- /dev/null +++ b/validation/host/tests/hil/test_uart.py @@ -0,0 +1,420 @@ +"""UART (`hal::UartStm`, `UartStmDma`, `UartStmDuplexDma`, `SynchronousUartStm`) against the AD3 protocol UART. + +Wiring set `bundle1`: TX, RX, RTS and CTS of every `tests.uart.instances` entry on DIOs; the logic analyzer also +records the firmware TX line to decode the frames and measure the bit rate. USART1 is the terminal, so the +instances under test are LPUART1 (both boards) and USART2 (NUCLEO-WBA55CG). A receive overrun aborts +`UartStm`/`UartStmDma` (`really_assert(!ORE)`, hal_st/stm32fxxx/UartStm.cpp:177): a test that loses the firmware +while streaming names it. +""" + +from __future__ import annotations + +import statistics +import time + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.protocol import format_command +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import expect +from hal_st_validation.firmware import settle + +OVERRUN_HINT = "if the firmware stopped answering: a receive overrun aborts UartStm/UartStmDma (UartStm.cpp:177)" + + +@pytest.fixture +def uart_cfg(board_cfg): + return board_cfg.param("uart") + + +def variant_of_instance(values): + """The variant must be one the instance offers; `sync=1` takes `parity=none` only; asynchronous drivers + take `flow=rtscts` only.""" + instance, variant = values.get("instance"), values.get("variant") + if instance is not None and variant is not None and variant not in instance["variants"]: + return False + if variant == "sync" and values.get("parity", "none") != "none": + return False + return variant in (None, "sync") or values.get("flow", "rtscts") == "rtscts" + + +def options_of(uart_cfg, variant): + driver = uart_cfg["variants"][variant] + return {"dma": driver["dma"], "duplex": driver["duplex"], "sync": driver["sync"]} + + +def open_pair(fw, ad3, need, uart_cfg, instance, baud, parity="none", variant="interrupt", **extra): + """Open the firmware UART and point the AD3 UART at its pins; returns the DIO of the firmware TX line.""" + ad3_rx = need.dio(instance["tx"]) + ad3_tx = need.dio(instance["rx"]) + fw.uart.open( + instance["index"], + lp=instance["lp"], + tx=instance["tx"], + rx=instance["rx"], + baud=baud, + parity=parity, + **options_of(uart_cfg, variant), + **extra, + ) + ad3.uart.configure(tx=ad3_tx, rx=ad3_rx, baud=baud, parity=parity) + ad3.uart.flush() + fw.uart.recv(instance["index"]) + return ad3_rx + + +def firmware_to_ad3(fw, ad3, index, payload, baud, parity="none"): + ad3.uart.flush() + transfer = expect.uart_transfer_time(len(payload), baud, parity) + fw.uart.send(index, payload, cmd_timeout=2.0 + transfer) + received = ad3.uart.read(len(payload), timeout=transfer + 1.0) + assert received == payload + assert ad3.uart.parity_errors == 0 + + +def ad3_to_firmware(fw, ad3, index, payload, baud, parity="none"): + fw.uart.recv(index) + ad3.uart.write(payload) + wait_ms = min(10000, int(expect.uart_transfer_time(len(payload), baud, parity) * 1000) + 500) + assert fw.uart.recv(index, timeout=wait_ms, len=len(payload)) == payload + + +def measured_bit_rate(bits, starts, rate, baud): + """Bit rate from each frame's start-bit edge to the edge that starts data bit 7 (8 bit times for 0x55).""" + per_bit = rate / baud + edges = [edge.index for edge in analysis.edges(bits)] + times = [] + for start in starts: + target = start + 8 * per_bit + closest = min(edges, key=lambda index: abs(index - target)) + if abs(closest - target) < per_bit / 2: + times.append((closest - start) / 8 / rate) + assert times, "no complete frames to measure" + return 1 / statistics.fmean(times) + + +def check_tx_waveform(fw, ad3, uart_cfg, index, dio, baud, parity): + """Decode the firmware TX line: the bytes, the parity and stop bits, and the bit rate.""" + payload = b"\x55" * 16 + duration = expect.uart_transfer_time(len(payload), baud, parity) * 1.5 + 2e-3 + rate = min(ad3.logic.clock_hz, ad3.logic.buffer_size / duration) + if rate < 16 * baud: + pytest.skip(f"{baud} baud does not fit the logic analyzer buffer at 16 samples per bit") + capture = ad3.logic.arm(rate, int(duration * rate), trigger=(dio, "falling"), pretrigger=0.01) + fw.uart.send(index, payload) + result = capture.wait(timeout=duration + 2.0) + decoded = result.uart(dio, baud, parity, 1) + assert bytes(byte.value for byte in decoded) == payload + assert not any(byte.parity_error or byte.framing_error for byte in decoded), "parity or framing error on the wire" + measured = measured_bit_rate(result.channel(dio), [byte.start_index for byte in decoded], result.rate, baud) + assert measured == pytest.approx(baud, rel=uart_cfg["bit_rate_tolerance"]) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "uart.instances") +@pytest.mark.matrix("uart.transfer") +@pytest.mark.constraint(valid=variant_of_instance) +def test_transfer(fw, ad3, need, uart_cfg, instance, baud, parity, variant): + tx_dio = open_pair(fw, ad3, need, uart_cfg, instance, baud, parity, variant) + for text in uart_cfg["payloads"]: + payload = bytes.fromhex(text) + firmware_to_ad3(fw, ad3, instance["index"], payload, baud, parity) + ad3_to_firmware(fw, ad3, instance["index"], payload, baud, parity) + check_tx_waveform(fw, ad3, uart_cfg, instance["index"], tx_dio, baud, parity) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "uart.instances") +@pytest.mark.matrix("uart.large") +@pytest.mark.constraint(valid=variant_of_instance) +def test_large_payloads(fw, ad3, need, board_cfg, uart_cfg, instance, baud, variant): + open_pair(fw, ad3, need, uart_cfg, instance, baud, variant=variant) + prefix = format_command("uart.send", instance["index"]) + size = min(uart_cfg["large_payload"], expect.max_hex_payload(board_cfg.terminal.max_command_length, prefix)) + firmware_to_ad3(fw, ad3, instance["index"], bytes(range(256))[:size], baud) + inbound = uart_cfg["large_payload_to_firmware"] + ad3_to_firmware(fw, ad3, instance["index"], bytes((i * 7) & 0xFF for i in range(inbound)), baud) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "uart.instances") +@pytest.mark.matrix("uart.large") +@pytest.mark.constraint(valid=variant_of_instance) +def test_full_duplex_stream(fw, ad3, need, uart_cfg, instance, baud, variant): + """Both directions at once, round after round: nothing may be lost or reordered.""" + open_pair(fw, ad3, need, uart_cfg, instance, baud, variant=variant) + size = uart_cfg["stream_size"] + transfer = expect.uart_transfer_time(size, baud) + for round_number in range(uart_cfg["stream_rounds"]): + outbound = bytes((round_number * 31 + i) & 0xFF for i in range(size)) + inbound = bytes((round_number * 17 + 3 * i) & 0xFF for i in range(size)) + pending = fw.terminal.begin(format_command("uart.send", instance["index"], outbound), timeout=2.0 + transfer) + try: + ad3.uart.write(inbound) + received = ad3.uart.read(size, timeout=transfer + 1.0) + finally: + response = settle(pending) + assert response is not None and response.ok, f"round {round_number}: uart.send {response} ({OVERRUN_HINT})" + assert received == outbound, f"round {round_number}: firmware to AD3" + assert fw.uart.recv(instance["index"], timeout=int(transfer * 1000) + 500, len=size) == inbound, ( + f"round {round_number}: AD3 to firmware ({OVERRUN_HINT})" + ) + + +def open_with_flow(fw, ad3, need, uart_cfg, instance, flow, variant): + """Open with `flow`; CTS (when used) starts deasserted (high). Returns the RTS and CTS DIOs (None if unused).""" + uses_rts, uses_cts = flow in ("rts", "rtscts"), flow in ("cts", "rtscts") + rts = need.dio(instance["rts"]) if uses_rts else None + cts = need.dio(instance["cts"]) if uses_cts else None + if cts is not None: + ad3.dio.drive(cts, 1) + open_pair( + fw, + ad3, + need, + uart_cfg, + instance, + uart_cfg["flow_baud"], + variant=variant, + rts=instance["rts"] if uses_rts else None, + cts=instance["cts"] if uses_cts else None, + flow=flow, + ) + return rts, cts + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "uart.instances") +@pytest.mark.matrix("uart.flow") +@pytest.mark.constraint(valid=variant_of_instance) +def test_flow_control(fw, ad3, need, uart_cfg, instance, flow, variant): + """CTS deasserted (high) holds the firmware's transmitter; RTS is asserted (low) while it can receive. A + stalled send is released before anything else happens, so `sync=1` (which blocks the firmware) recovers.""" + baud = uart_cfg["flow_baud"] + rts, cts = open_with_flow(fw, ad3, need, uart_cfg, instance, flow, variant) + payload = bytes.fromhex("0123456789abcdef") + if rts is not None: + assert ad3.dio.read(rts) == 0, "RTS must be asserted (low) while the firmware can receive" + ad3_to_firmware(fw, ad3, instance["index"], payload, baud) + if cts is None: + firmware_to_ad3(fw, ad3, instance["index"], payload, baud) + return + pending = fw.terminal.begin(format_command("uart.send", instance["index"], payload), timeout=5.0) + try: + held = ad3.uart.read(len(payload), timeout=0.2) + finally: + ad3.dio.drive(cts, 0) + try: + released = ad3.uart.read(len(payload), timeout=1.0) + finally: + response = settle(pending) + assert held == b"", "data sent while CTS is deasserted" + assert released == payload + assert response is not None and response.ok, f"uart.send {response}" + + +def stall(fw, ad3, need, uart_cfg, instance, variant): + """Open with flow control and CTS held off, then send: the driver never completes. Returns the CTS DIO.""" + _, cts = open_with_flow(fw, ad3, need, uart_cfg, instance, "rtscts", variant) + payload = bytes(range(uart_cfg["stall_payload"])) + transfer = expect.uart_transfer_time(len(payload), uart_cfg["flow_baud"]) + with pytest.raises(FirmwareError) as error: + fw.uart.send(instance["index"], payload, cmd_timeout=3.0 + transfer) + assert error.value.reason == "timeout", "uart.send must answer ERR timeout when the driver never completes" + return cts, payload + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "uart.instances") +@pytest.mark.matrix("uart.stall") +@pytest.mark.constraint(valid=variant_of_instance) +def test_send_timeout_while_cts_held(fw, ad3, need, uart_cfg, instance, variant): + """PROTOCOL: `uart.send` answers `ERR timeout` if the driver never completes; releasing CTS lets the stalled + data out and the instance keeps working.""" + try: + cts, payload = stall(fw, ad3, need, uart_cfg, instance, variant) + finally: + ad3.dio.drive(need.dio(instance["cts"]), 0) + assert ad3.uart.read(len(payload), timeout=1.0) == payload, "the stalled data must leave once CTS is asserted" + firmware_to_ad3(fw, ad3, instance["index"], b"\xa5\x5a", uart_cfg["flow_baud"]) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "uart.instances") +@pytest.mark.matrix("uart.stall") +@pytest.mark.constraint(valid=variant_of_instance) +def test_close_during_stalled_send(fw, ad3, need, uart_cfg, instance, variant): + """Closing during a stalled send and opening again gives a working instance (each open rebuilds it).""" + try: + stall(fw, ad3, need, uart_cfg, instance, variant) + fw.uart.close(instance["index"]) + finally: + ad3.dio.drive(need.dio(instance["cts"]), 0) + time.sleep(0.05) + open_pair(fw, ad3, need, uart_cfg, instance, uart_cfg["flow_baud"], variant=variant) + fw.system.ping() + payload = bytes.fromhex(uart_cfg["payloads"][-1]) + firmware_to_ad3(fw, ad3, instance["index"], payload, uart_cfg["flow_baud"]) + ad3_to_firmware(fw, ad3, instance["index"], payload, uart_cfg["flow_baud"]) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "uart.instances") +def test_reopen_with_other_settings(fw, ad3, need, uart_cfg, instance): + for baud, parity in uart_cfg["reopen_settings"]: + open_pair(fw, ad3, need, uart_cfg, instance, baud, parity) + firmware_to_ad3(fw, ad3, instance["index"], b"\x5a\xa5", baud, parity) + fw.uart.close(instance["index"]) + time.sleep(0.01) + + +@pytest.mark.board_params("instance", "uart.instances") +@pytest.mark.board_params("baud", "uart.open_bauds") +def test_open_bauds(fw, board_cfg, instance, baud): + """`uart.open` succeeds exactly where the divider fits the baud-rate register at the kernel clock (and below + the HAL limit of the family).""" + lp = bool(instance["lp"]) + clock = board_cfg.clock("uart", expect.uart_clock_name(instance["index"], lp)) + fits = expect.uart_baud_fits(clock, baud, lp, expect.uart_baud_max(board_cfg.family)) + try: + fw.uart.open(instance["index"], lp=lp, tx=instance["tx"], rx=instance["rx"], baud=baud) + except FirmwareError as error: + assert error.reason == "range" and not fits, f"ERR {error.reason} although {baud} Bd fits" + return + assert fits, f"{baud} Bd accepted outside the limits" + fw.uart.close(instance["index"]) + + +def open_errors(instance): + pins = {"tx": instance["tx"], "rx": instance["rx"]} + cases = [ + ({**pins, "parity": "mark"}, "usage"), + ({**pins, "flow": "sideways"}, "usage"), + ({**pins, "baud": "fast"}, "usage"), + ({**pins, "baud": 299}, "range"), + ({**pins, "baud": 12000001}, "range"), + ({**pins, "dma": 1, "sync": 1}, "usage"), + ({**pins, "dma": 1, "duplex": 1}, "usage"), + ({**pins, "flow": "rtscts"}, "usage"), + ({**pins, "flow": "rtscts", "rts": instance["rts"]}, "usage"), + ({**pins, "rts": instance["rts"]}, "usage"), + ({**pins, "flow": "rts"}, "usage"), + ({**pins, "flow": "rts", "rts": instance["rts"]}, "unsupported"), + ({**pins, "flow": "cts", "cts": instance["cts"]}, "unsupported"), + ({**pins, "sync": 1, "parity": "even"}, "unsupported"), + ({**pins, "sync": 1, "swap": 1}, "unsupported"), + ({"tx": instance["rx"], "rx": instance["tx"]}, "pin"), + ({"tx": instance["tx"]}, "usage"), + ] + if instance["lp"]: + cases += [({**pins, "duplex": 1}, "unsupported"), ({**pins, "sync": 1}, "unsupported")] + return cases + + +@pytest.mark.board_params("instance", "uart.instances") +def test_open_errors(fw, instance): + for options, reason in open_errors(instance): + with pytest.raises(FirmwareError) as error: + fw.command("uart.open", instance["index"], lp=instance["lp"], **{key: fw_pin(fw, key, value) for key, value in options.items()}) + assert error.value.reason == reason, options + with pytest.raises(FirmwareError) as error: + fw.command("uart.open", instance["index"], lp=instance["lp"], stop=2) + assert error.value.reason == "usage", "no stop key" + + +def fw_pin(fw, key, value): + return fw.pin(value) if key in ("tx", "rx", "rts", "cts") else value + + +@pytest.mark.board_params("instance", "uart.instances") +def test_default_pins(fw, instance): + """Only LPUART1 has default pins; every other instance needs `tx` and `rx`.""" + if instance.get("default_pins"): + fw.uart.open(instance["index"], lp=instance["lp"]) + fw.uart.close(instance["index"]) + return + with pytest.raises(FirmwareError) as error: + fw.uart.open(instance["index"], lp=instance["lp"]) + assert error.value.reason == "usage" + + +def test_one_uart_at_a_time(fw, uart_cfg): + instances = uart_cfg["instances"] + if len(instances) < 2: + pytest.skip("one UART under test on this board") + first, second = instances[:2] + fw.uart.open(first["index"], lp=first["lp"], tx=first["tx"], rx=first["rx"]) + with pytest.raises(FirmwareError) as error: + fw.uart.open(second["index"], lp=second["lp"], tx=second["tx"], rx=second["rx"]) + assert error.value.reason == "busy" + + +@pytest.mark.board_params("instance", "uart.instances") +def test_send_and_receive_limits(fw, uart_cfg, instance): + with pytest.raises(FirmwareError) as error: + fw.uart.send(instance["index"], b"\x55") + assert error.value.reason == "notopen" + fw.uart.open(instance["index"], lp=instance["lp"], tx=instance["tx"], rx=instance["rx"]) + for line, reason in ( + (f"uart.send {instance['index']} -", "usage"), + (f"uart.send {instance['index']} 5", "usage"), + (f"uart.send {instance['index']} " + "00" * (uart_cfg["large_payload"] + 1), "range"), + (f"uart.recv {instance['index']} timeout=10001", "range"), + (f"uart.recv {instance['index']} len=0", "range"), + (f"uart.recv {instance['index']} len=257", "range"), + ): + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line + + +# Swapping leaves the instance swapped until reset (a known gap), so these run last and reset the board. +@pytest.fixture +def reset_afterwards(fw, board_cfg): + yield + boot_timeout = board_cfg.param("system.boot_timeout", 5.0) + fw.terminal.drain_events() + try: + fw.system.ping() + fw.system.reset(timeout=boot_timeout) + except Exception: # noqa: BLE001 - the board may be rebooting right now + fw.system.wait_boot(timeout=boot_timeout) + + +@pytest.mark.ad3 +@pytest.mark.resets_board +@pytest.mark.usefixtures("reset_afterwards") +@pytest.mark.board_params("instance", "uart.instances") +@pytest.mark.matrix("uart.swap") +@pytest.mark.constraint(valid=variant_of_instance) +def test_swap(fw, ad3, need, uart_cfg, instance, variant): + """`swap=1` exchanges the functions of the two pins: the firmware transmits on its `rx` pin.""" + baud = 115200 + tx_line, rx_line = need.dio(instance["rx"]), need.dio(instance["tx"]) + fw.uart.open( + instance["index"], lp=instance["lp"], tx=instance["tx"], rx=instance["rx"], baud=baud, swap=True, **options_of(uart_cfg, variant) + ) + ad3.uart.configure(tx=rx_line, rx=tx_line, baud=baud) + ad3.uart.flush() + fw.uart.recv(instance["index"]) + payload = bytes.fromhex(uart_cfg["payloads"][-1]) + firmware_to_ad3(fw, ad3, instance["index"], payload, baud) + ad3_to_firmware(fw, ad3, instance["index"], payload, baud) + + +@pytest.mark.ad3 +@pytest.mark.resets_board +@pytest.mark.usefixtures("reset_afterwards") +@pytest.mark.board_params("instance", "uart.instances") +def test_swap_does_not_survive_close(fw, ad3, need, uart_cfg, instance): + """After a `swap=1` session is closed, a plain open transmits on its `tx` pin again.""" + baud = 115200 + pins = {"lp": instance["lp"], "tx": instance["tx"], "rx": instance["rx"], "baud": baud} + fw.uart.open(instance["index"], swap=True, **pins) + fw.uart.close(instance["index"]) + fw.uart.open(instance["index"], **pins) + ad3.uart.configure(tx=need.dio(instance["rx"]), rx=need.dio(instance["tx"]), baud=baud) + ad3.uart.flush() + payload = bytes.fromhex(uart_cfg["payloads"][-1]) + firmware_to_ad3(fw, ad3, instance["index"], payload, baud) diff --git a/validation/host/tests/hil/test_unsupported.py b/validation/host/tests/hil/test_unsupported.py new file mode 100644 index 00000000..c93d0842 --- /dev/null +++ b/validation/host/tests/hil/test_unsupported.py @@ -0,0 +1,11 @@ +"""Groups hal-st has no driver for on these boards (comparator, CAN, EEPROM, Ethernet) answer `ERR unsupported`.""" + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + + +@pytest.mark.board_params("line", "unsupported.commands") +def test_unsupported_commands(fw, line): + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == "unsupported" diff --git a/validation/host/tests/hil/test_watchdog.py b/validation/host/tests/hil/test_watchdog.py new file mode 100644 index 00000000..45b06df6 --- /dev/null +++ b/validation/host/tests/hil/test_watchdog.py @@ -0,0 +1,166 @@ +"""Watchdog (`hal::WatchDogStm`, the window watchdog): early warnings, feeding, resets and the warning period. + +A started watchdog cannot be stopped, so every test resets the board afterwards. The warning period is +63 * 4096 * prescaler / PCLK1 for the smallest prescaler whose period is at least the timeout +(`expect.wwdg_warning_period`). `tests.watchdog.pin` is a bundle1 pin: the `pin=` toggle output is on its DIO, so the +logic analyzer measures the warning period. +""" + +from __future__ import annotations + +import statistics +import time + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import expect + +pytestmark = pytest.mark.resets_board + +# The counter tick between an unanswered warning and the reset must outlast the `EVT wdt` line this many times, which +# leaves room for the event loop's latency before the line starts. +WARNING_LINE_MARGIN = 2 + + +@pytest.fixture +def wdt_cfg(board_cfg): + return board_cfg.param("watchdog") + + +@pytest.fixture +def pclk1(board_cfg): + return board_cfg.clock("pclk1") + + +@pytest.fixture(autouse=True) +def reset_afterwards(fw, board_cfg): + yield + boot_timeout = board_cfg.param("system.boot_timeout", 5.0) + fw.terminal.drain_events() + try: + fw.system.ping() + fw.system.reset(timeout=boot_timeout) + except Exception: # noqa: BLE001 - the board may be rebooting right now + fw.system.wait_boot(timeout=boot_timeout) + fw.terminal.drain_events() + + +def observation(wdt_cfg, period): + return max(0.3, wdt_cfg["observe_periods"] * period) + + +@pytest.mark.slow +@pytest.mark.matrix("watchdog.behaviour") +def test_behaviour(fw, board_cfg, wdt_cfg, pclk1, timeout_ms, feed): + """`feed=auto` keeps the board alive with one warning per period; with `feed=manual` the warning that is not + answered is followed by a reset reported as `reset=wwdg`. The reset comes one counter tick after the warning, + which at the shortest timeouts is too short to send the `EVT wdt` line: the line is only checked where the tick + leaves time for it (`expect.wwdg_warning_outruns_reset`).""" + index = wdt_cfg["index"] + period = expect.wwdg_warning_period(timeout_ms, pclk1) + boot_timeout = board_cfg.param("system.boot_timeout", 5.0) + fw.wdt.start(index, timeout=timeout_ms, feed=feed) + if feed == "manual": + if expect.wwdg_warning_outruns_reset(timeout_ms, pclk1, board_cfg.terminal.baud, WARNING_LINE_MARGIN): + warning = fw.wdt.wait_warning(index, timeout=period * 2 + 1) + assert warning.as_int("warning") == 1 + boot = fw.system.wait_boot(timeout=period * 3 + boot_timeout) + assert boot.reset == "wwdg" + assert fw.system.info().reset == "wwdg" + return + window = observation(wdt_cfg, period) + warnings = fw.terminal.collect_events("wdt", window) + assert not fw.terminal.events("boot"), "the board reset although it was fed" + assert warnings, "no early warning" + assert all(event.as_int("index") == index for event in warnings) + expected = window / period + assert expected * 0.5 <= len(warnings) <= expected * 1.5 + 1, f"{len(warnings)} warnings in {window} s" + fw.system.ping() + + +@pytest.mark.ad3 +@pytest.mark.slow +@pytest.mark.matrix("watchdog.period") +def test_warning_period(fw, ad3, need, wdt_cfg, pclk1, timeout_ms): + """The `pin=` output toggles on every early warning: the toggle interval is the warning period.""" + pin = wdt_cfg["pin"] + dio = need.dio(pin) + period = expect.wwdg_warning_period(timeout_ms, pclk1) + periods = wdt_cfg["observe_periods"] + duration = (periods + 1.5) * period + rate = min(ad3.logic.clock_hz, ad3.logic.buffer_size / duration) + capture = ad3.logic.arm(rate, int(duration * rate), trigger=(dio, "either"), pretrigger=0.02) + fw.wdt.start(wdt_cfg["index"], timeout=timeout_ms, feed="auto", pin=pin) + result = capture.wait(timeout=duration + period + 2.0) + edges = [edge.index for edge in analysis.edges(result.channel(dio))] + assert len(edges) >= periods, f"{len(edges)} toggles in {duration:.3f} s" + intervals = [(b - a) / result.rate for a, b in zip(edges, edges[1:])] + measured = statistics.median(intervals) + assert measured == pytest.approx(period, rel=wdt_cfg["period_tolerance"], abs=2 / result.rate) + assert not fw.terminal.events("boot"), "the board reset although it was fed" + + +def test_manual_feed(fw, board_cfg, wdt_cfg, pclk1): + """`wdt.feed` faster than the warning period keeps the board alive; when it stops, the board resets.""" + index, timeout_ms = wdt_cfg["index"], wdt_cfg["manual_timeout_ms"] + period = expect.wwdg_warning_period(timeout_ms, pclk1) + fw.wdt.start(index, timeout=timeout_ms, feed="manual") + end = time.monotonic() + wdt_cfg["feed_seconds"] + while time.monotonic() < end: + fw.wdt.feed(index) + time.sleep(period / 4) + assert not fw.terminal.events("boot"), "reset although fed" + boot = fw.system.wait_boot(timeout=period * 3 + board_cfg.param("system.boot_timeout", 5.0)) + assert boot.reset == "wwdg" + + +def test_only_one_watchdog(fw, wdt_cfg): + """A started watchdog cannot be stopped, so a second start is refused; its pin stays claimed.""" + pin = wdt_cfg["pin"] + fw.wdt.start(wdt_cfg["index"], timeout=wdt_cfg["manual_timeout_ms"], pin=pin) + with pytest.raises(FirmwareError) as error: + fw.wdt.start(wdt_cfg["index"], timeout=wdt_cfg["manual_timeout_ms"]) + assert error.value.reason == "busy" + with pytest.raises(FirmwareError) as error: + fw.gpio.cfg(pin, "in") + assert error.value.reason == "busy", "the toggle pin stays claimed until reset" + + +@pytest.mark.board_params("timeout_ms", "watchdog.open_timeouts_ms") +def test_start_timeouts(fw, wdt_cfg, pclk1, timeout_ms): + """`timeout` is 1-30000 ms, and at most what the WWDG reaches at PCLK1 (`ERR range` beyond).""" + fits = timeout_ms <= expect.WDT_TIMEOUT_MAX_MS and expect.wwdg_prescaler_for(timeout_ms, pclk1) is not None + try: + fw.wdt.start(wdt_cfg["index"], timeout=timeout_ms) + except FirmwareError as error: + assert error.reason == "range" and not fits, f"ERR {error.reason} for {timeout_ms} ms" + return + assert fits, f"{timeout_ms} ms accepted beyond {expect.wwdg_max_timeout_ms(pclk1)} ms" + + +def test_start_errors(fw, board_cfg, wdt_cfg, pclk1): + index = wdt_cfg["index"] + cases = [ + ({"timeout": 0}, "range"), + ({"timeout": expect.WDT_TIMEOUT_MAX_MS + 1}, "range"), + ({"timeout": expect.wwdg_max_timeout_ms(pclk1) + 1, "pin": board_cfg.terminal.pins[0]}, "range"), + ({"timeout": 100, "feed": "sometimes"}, "usage"), + ({"timeout": 100, "pin": board_cfg.param("system.unbonded_pins")[0]}, "pin"), + ({"timeout": 100, "pin": board_cfg.terminal.pins[0]}, "busy"), + ] + for options, reason in cases: + with pytest.raises(FirmwareError) as error: + fw.wdt.start(index, **options) + assert error.value.reason == reason, options + for line, reason in ( + (f"wdt.start {index}", "usage"), + (f"wdt.start {index} timeout=100 reset=0", "usage"), + (f"wdt.start {index + 1} timeout=100", "range"), + (f"wdt.feed {index}", "notopen"), + (f"wdt.feed {index + 1}", "range"), + ): + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line diff --git a/validation/host/tests/unit/test_config.py b/validation/host/tests/unit/test_config.py new file mode 100644 index 00000000..d38284af --- /dev/null +++ b/validation/host/tests/unit/test_config.py @@ -0,0 +1,339 @@ +import pytest + +from hal_st_validation import expect +from hal_st_validation.config import ConfigError, available_boards, load_board, parse_board +from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS +from hal_st_validation.pairwise import pairwise +from hal_st_validation.protocol import is_alias + +BOARDS = ["nucleo_wb55rg", "nucleo_wba55cg"] +FIRMWARE_TABLES = {"nucleo_wb55rg": WB55_PINS, "nucleo_wba55cg": WBA55_PINS} +FAMILIES = {"nucleo_wb55rg": "stm32wb55", "nucleo_wba55cg": "stm32wba55"} +CLOCKS = {"nucleo_wb55rg": 64_000_000, "nucleo_wba55cg": 100_000_000} + + +def test_available_boards(): + assert set(BOARDS) <= set(available_boards()) + + +@pytest.mark.parametrize("name", BOARDS) +def test_board_files_load(name): + board = load_board(name) + assert board.name == name + assert board.family == FAMILIES[name] + assert board.sysclk == CLOCKS[name] + assert board.terminal.baud == 921600 + assert board.terminal.uart == 1 + assert board.ad3.vplus is None + assert board.ad3.analog_max == 3.3 + assert board.matches_firmware_name(name.upper().replace("_", "-")) + assert board.matches_firmware_name(board.firmware_name) + assert all(is_alias(alias) for alias in board.pins) + assert board.terminal.pins == (board.pins["terminaltx"], board.pins["terminalrx"]) + for alias in ("tim1ch1", "tim1bkin", "spi1clk", "lpuart1tx", "led0", "gpio0", "sw1"): + assert board.resolve_pin(alias).startswith("P") + + +@pytest.mark.parametrize("name", BOARDS) +def test_alias_table_matches_protocol(name): + """The board file's `pins` is the PROTOCOL.md table the fake firmware also serves, in the same order.""" + board = load_board(name) + assert board.pins == FIRMWARE_TABLES[name] + assert list(board.pins) == list(FIRMWARE_TABLES[name]) + + +@pytest.mark.parametrize("name", BOARDS) +def test_clocks(name): + board = load_board(name) + hz = CLOCKS[name] + assert board.clock("sysclk") == board.clock("pclk1") == board.clock("timer") == hz + assert board.clock("spi", 1) == hz + assert board.clock("uart", "lpuart1") == board.clock("uart", "LPUART1") == hz + assert board.clock("uart", expect.uart_clock_name(1, lp=True)) == hz + with pytest.raises(ConfigError): + board.clock("spi", 2 if name == "nucleo_wba55cg" else 3) + with pytest.raises(ConfigError): + board.clock("nosuchclock") + + +def test_clock_tables_follow_the_instances(): + assert set(load_board("nucleo_wb55rg").clocks["spi"]) == {1, 2} + assert set(load_board("nucleo_wba55cg").clocks["spi"]) == {1, 3} + assert set(load_board("nucleo_wba55cg").clocks["uart"]) == {"usart1", "usart2", "lpuart1"} + + +@pytest.mark.parametrize("name", BOARDS) +def test_bundles_wire_each_dio_once(name): + """Each bundle uses an AD3 channel at most once, each DIO on its own pin, and all 16 DIOs; no bundle wires + a reserved pin.""" + board = load_board(name) + reserved = set(board.terminal.pins) | {board.resolve_pin(board.param("system.debug_led"))} + reserved |= {board.resolve_pin(pin) for pin in board.param("system.reserved_pins")} + for bundle in board.wiring_sets: + connections = board.wiring([bundle]).connections + dios = [connection for connection in connections if connection.kind == "dio"] + assert len({connection.channel for connection in dios}) == len(dios) == 16 + assert len({connection.pin for connection in dios}) == len(dios) + assert not {pin for connection in connections for pin in connection.pins} & reserved + + +def test_bundle_sets(): + assert set(load_board("nucleo_wb55rg").wiring_sets) == {"bundle1", "bundle2"} + assert set(load_board("nucleo_wba55cg").wiring_sets) == {"bundle1"} + + +def test_wb55_bundle2_moves_the_encoder_inputs(): + board = load_board("nucleo_wb55rg") + bundle1, bundle2 = board.wiring(["bundle1"]), board.wiring(["bundle2"]) + assert bundle1.dio("PA15") == 9 and bundle1.dio("PB3") == 10 + assert bundle2.dio("PC0") == 9 and bundle2.dio("PC2") == 10 + assert bundle2.wavegen("PC2") is None and bundle2.scope("PC2") is None + assert bundle1.wavegen("PC2") == 2 + with pytest.raises(ConfigError): + board.wiring(["bundle1", "bundle2"]) + + +@pytest.mark.parametrize("name", BOARDS) +def test_parameters_reference_wired_pins(name): + """Pins in the test parameters are wired in bundle1 (the LPTIM encoder in bundle2).""" + board = load_board(name) + + def wired(pin, kind="dio", bundle="bundle1"): + return board.wiring([bundle]).channel(kind, board.resolve_pin(pin)) is not None + + for pin in board.param("gpio.loop_pins") + board.param("gpio.output_pins"): + assert wired(pin), pin + sharing = board.param("gpio.exti_sharing") + counting, other = board.resolve_pin(sharing["counting"]), board.resolve_pin(sharing["sharing"]) + assert wired(counting) and wired(other) + assert counting[2:] == other[2:] and counting[1] != other[1], "same EXTI line, other port" + for timer in board.param("pwm.timers"): + for channel in timer["channels"]: + assert wired(channel["pin"]), channel + assert channel.get("npin") is None or wired(channel["npin"]), channel + assert timer.get("brk") is None or wired(timer["brk"]), timer + for instance in board.param("uart.instances"): + assert all(wired(instance[key]) for key in ("tx", "rx", "rts", "cts")), instance + for instance in board.param("spi.instances"): + assert all(wired(instance[key]) for key in ("clk", "cs", "mosi", "miso")), instance + for instance in board.param("qei.instances"): + assert all(wired(instance[key]) for key in ("a", "b", "idx")), instance + for instance in board.param("qei.lp_instances"): + assert all(wired(instance[key], bundle="bundle2") for key in ("a", "b")), instance + assert wired(board.param("watchdog.pin")) + for pin in board.param("adc.inputs"): + assert wired(pin, "wavegen") and wired(pin, "scope"), pin + + +@pytest.mark.parametrize("name", BOARDS) +def test_parameters_use_the_pin_functions(name): + """The pins the tests open for a function carry it in the generated pinout tables (as the fake models them).""" + from hal_st_validation.fake_firmware import FakeFirmware + + board = load_board(name) + fake = FakeFirmware(family=board.family) + pin = board.resolve_pin + for timer in board.param("pwm.timers"): + number = timer["timer"] + for channel in timer["channels"]: + assert fake.supports(f"timerChannel{channel['channel']}", number, pin(channel["pin"])), channel + if channel.get("npin"): + assert fake.supports(f"timerChannel{channel['channel']}N", number, pin(channel["npin"])), channel + if timer.get("brk"): + assert fake.supports("timerBreak", number, pin(timer["brk"])), timer + for instance in board.param("uart.instances"): + prefix = "lpuart" if instance["lp"] else "uart" + for key in ("tx", "rx", "rts", "cts"): + assert fake.supports(prefix + key.capitalize(), instance["index"], pin(instance[key])), (instance, key) + for instance in board.param("spi.instances"): + for key, function in (("clk", "spiClock"), ("mosi", "spiMosi"), ("miso", "spiMiso")): + assert fake.supports(function, instance["index"], pin(instance[key])), (instance, key) + for instance in board.param("qei.instances"): + assert fake.supports("timerChannel1", instance["index"], pin(instance["a"])) + assert fake.supports("timerChannel2", instance["index"], pin(instance["b"])) + for instance in board.param("qei.lp_instances"): + assert fake.supports("lpTimerInput1", instance["index"], pin(instance["a"])) + assert fake.supports("lpTimerInput2", instance["index"], pin(instance["b"])) + for alias in board.param("adc.inputs") + board.param("adc.spare_inputs"): + assert pin(alias) in fake.spec.analog, alias + + +@pytest.mark.parametrize("name", BOARDS) +def test_matrices_load(name): + board = load_board(name) + for path in ( + "gpio.irq", + "pwm.waveform", + "pwm.channels", + "pwm.complementary", + "pwm.idle", + "pwm.break", + "pwm.limits", + "pwm.frequency_change", + ): + assert board.matrix(path) + for path in ( + "uart.transfer", + "uart.flow", + "uart.swap", + "uart.large", + "uart.stall", + "spi.transfer", + "spi.sessions", + "spi.receive_only", + "spi.largest", + ): + assert board.matrix(path) + for path in ( + "adc.levels", + "adc.sequence", + "adc.timing", + "adc.trigger_rate", + "qei.position", + "qei.lp_position", + "qei.velocity", + "qei.rollover", + ): + assert board.matrix(path) + for path in ("watchdog.behaviour", "watchdog.period"): + assert board.matrix(path) + assert board.matrix("gpio.irq")["handler"] == ["immediate", "dispatched"] + assert board.param("gpio.drives") == ["low", "medium", "fast", "high"] + assert board.matrix("adc.timing")["sampling"] == list(expect.ADC_SAMPLING_TIMES[board.family]) + assert set(board.matrix("uart.transfer")["variant"]) == set(board.param("uart.variants")) + assert len(pairwise(board.matrix("pwm.waveform"))) < 100 + with pytest.raises(ConfigError): + board.matrix("pwm.timers") + + +@pytest.mark.parametrize("name", BOARDS) +def test_unsupported_commands_cover_the_protocol(name): + """`tests.unsupported.commands` names every command PROTOCOL.md lists as unavailable.""" + from hal_st_validation.fake_firmware import UNSUPPORTED_COMMANDS + + board = load_board(name) + assert {line.split()[0] for line in board.param("unsupported.commands")} == set(UNSUPPORTED_COMMANDS) + + +@pytest.mark.parametrize("name", BOARDS) +def test_gpio_limit_pins(name): + board = load_board(name) + pins = [board.resolve_pin(pin) for pin in board.param("gpio.limit_pins")] + assert len(set(pins)) == len(pins) == board.param("gpio.limit") + 1 + reserved = set(board.terminal.pins) | {board.resolve_pin(pin) for pin in board.param("system.reserved_pins")} + assert not set(pins) & reserved + + +@pytest.mark.parametrize("name", BOARDS) +def test_qei_resolution_limits(name): + """TIM2 counts up to 2^32 - 1 (EMIL's number parser), the 16-bit timers and LPTIM1 up to 65536.""" + board = load_board(name) + for instance in board.param("qei.instances") + board.param("qei.lp_instances"): + assert instance["max_res"] == (0xFFFFFFFF if instance["index"] in expect.TIMERS_32BIT and "lp" not in instance["name"] else 65536) + + +@pytest.mark.parametrize("name", BOARDS) +def test_uart_instance_variants(name): + board = load_board(name) + variants = board.param("uart.variants") + for instance in board.param("uart.instances"): + assert set(instance["variants"]) <= set(variants) + if instance["lp"]: + assert not {"duplex", "sync"} & set(instance["variants"]), "LPUART takes neither duplex nor sync" + + +@pytest.mark.parametrize("name", BOARDS) +def test_numeric_parameters_match_the_clocks(name): + """The parameter lists straddle the limits expect.py derives from the board clocks.""" + board = load_board(name) + spiclk = board.clock("spi", 1) + assert [expect.spi_baud_fits(spiclk, baud) for baud in board.param("spi.open_bauds")] == [False, True, True, False] + assert all(expect.spi_baud_fits(spiclk, baud) for baud in board.matrix("spi.transfer")["baud"]) + assert expect.spi_baud_fits(spiclk, board.param("spi.session_baud")) + timeouts = board.param("watchdog.open_timeouts_ms") + fits = [expect.wwdg_prescaler_for(timeout, board.clock("pclk1")) is not None for timeout in timeouts if timeout <= 30000] + assert fits == [True, True, False, False] + for timeout in board.matrix("watchdog.behaviour")["timeout_ms"]: + assert expect.wwdg_prescaler_for(timeout, board.clock("pclk1")) is not None + maximum = expect.uart_baud_max(board.family) + for instance in board.param("uart.instances"): + lp = bool(instance["lp"]) + clock = board.clock("uart", expect.uart_clock_name(instance["index"], lp)) + bauds = board.matrix("uart.transfer")["baud"] + board.matrix("uart.large")["baud"] + [board.param("uart.flow_baud")] + bauds += [baud for baud, _ in board.param("uart.reopen_settings")] + for baud in bauds: + assert expect.uart_baud_fits(clock, baud, lp, maximum), (instance["name"], baud) + results = {baud: expect.uart_baud_fits(clock, baud, lp, maximum) for baud in board.param("uart.open_bauds")} + assert True in results.values() and False in results.values() + low, high = expect.uart_baud_limits(clock, lp, maximum) + assert {low - 1, low, high, high + 1} <= set(board.param("uart.open_bauds")) | {299, 12000001}, instance["name"] + pwm_clock = board.clock("timer") + for freq in board.param("pwm.frequency_changes") + [board.param("pwm.channels_frequency"), board.param("pwm.complementary_frequency")]: + for timer in board.param("pwm.timers"): + assert expect.pwm_fits(pwm_clock, freq, "center", expect.timer_counter_max(timer["timer"])), (timer["name"], freq) + assert expect.wwdg_prescaler_for(board.param("watchdog.manual_timeout_ms"), board.clock("pclk1")) is not None + + +def test_wiring_lookup_and_conflicts(): + board = load_board("nucleo_wb55rg") + wiring = board.wiring(["bundle1"], ["loopback"]) + assert wiring.dio(board.resolve_pin("tim1ch1")) == 0 + assert wiring.dio(board.resolve_pin("spi1mosi")) == 1, "PA7 is TIM1 CH1N and SPI1 MOSI" + assert wiring.wavegen(board.resolve_pin("ain4")) == 1 + assert wiring.scope(board.resolve_pin("ain3")) == 2 + assert wiring.has("loopback") + assert "DIO15" in wiring.describe() + with pytest.raises(ConfigError): + board.wiring(["nosuchset"]) + other = load_board("nucleo_wba55cg") + assert other.wiring(["bundle1"]).dio("PB4") == 0 + assert other.wiring(["bundle1"]).wavegen("PA7") == 1 + + +def test_optional_connections_and_roles(): + raw = { + "board": "x", + "family": "stm32wb55", + "pins": {"gpio0": "PC6"}, + "wiring_sets": {"s": {"dio": {0: "gpio0", 1: {"pin": "PB0", "role": "probe", "requires": "extra"}}}}, + } + board = parse_board(raw) + assert board.wiring(["s"]).dio(role="probe") is None + assert board.wiring(["s"], ["extra"]).dio(role="probe") == 1 + raw["wiring_sets"]["j"] = {"wavegen": {1: {"pin": "PC3", "jumpered": ["PC2"]}}} + assert parse_board(raw).wiring(["j"]).wavegen("PC2") == 1 + raw["wiring_sets"]["j"] = {"wavegen": {1: {"pin": "PC3", "jumpered": "PC2"}}} + with pytest.raises(ConfigError): + parse_board(raw) + + +def test_params_and_overrides(): + board = load_board("nucleo_wb55rg") + assert board.param("watchdog.index") == 0 + assert board.param("pwm.missing", 5) == 5 + with pytest.raises(ConfigError): + board.param("pwm.missing") + board.apply_overrides(["pwm.waveform.freq=[20000]", "new.value=1.5"]) + assert board.matrix("pwm.waveform")["freq"] == [20000] + assert board.param("new.value") == 1.5 + with pytest.raises(ConfigError): + board.apply_overrides(["novalue"]) + assert board.aliases_of("PA8") == ["tim1ch1", "qei1a"] + assert load_board("nucleo_wb55rg").matrix("pwm.waveform")["freq"] != [20000], "overrides stay in one BoardConfig" + + +def test_invalid_boards_rejected(): + raw = {"board": "x", "family": "stm32wb55", "wiring_sets": {"bad": {"dio": {16: "PA0"}}}} + with pytest.raises(ConfigError): + parse_board(raw) + raw = {"board": "x", "family": "stm32wb55", "wiring_sets": {"bad": {"wavegen": {3: "PA0"}}}} + with pytest.raises(ConfigError): + parse_board(raw) + with pytest.raises(ConfigError, match="generic"): + parse_board({"board": "x", "family": "stm32wb55", "pins": {"phasea": "PC3"}}) + with pytest.raises(ConfigError): + parse_board({"board": "x", "family": "stm32wb55", "pins": {"gpio0": "PA16"}}) + with pytest.raises(ConfigError): + parse_board({"board": "x", "family": "stm32wb55", "pins": {"ain1": "PC0"}, "wiring_sets": {"s": {"dio": {0: "ain2"}}}}) + with pytest.raises(ConfigError): + parse_board({"board": "x", "family": "stm32wb55", "clocks": {"spi": {1: "fast"}}}) diff --git a/validation/host/tests/unit/test_expect.py b/validation/host/tests/unit/test_expect.py new file mode 100644 index 00000000..e5df08b0 --- /dev/null +++ b/validation/host/tests/unit/test_expect.py @@ -0,0 +1,336 @@ +import pytest + +from hal_st_validation import expect + +WB55 = 64_000_000 +WBA55 = 100_000_000 + + +def test_timer_features(): + assert expect.timer_counter_max(2) == 0xFFFFFFFF + assert expect.timer_counter_max(1) == expect.timer_counter_max(16) == 0xFFFF + assert [timer for timer in (1, 2, 3, 16, 17) if expect.timer_has_break(timer)] == [1, 16, 17] + assert [timer for timer in (1, 2, 3, 16, 17) if expect.timer_has_center_mode(timer)] == [1, 2, 3] + assert sorted(expect.ENCODER_TIMERS) == [1, 2, 3] + assert sorted(expect.ADC_TRIGGER_TIMERS) == [1, 2] + + +def test_pwm_clock_and_ticks(): + assert expect.pwm_clock(WB55) == 64_000_000 + assert expect.pwm_clock(WB55, 63) == 1_000_000 + assert expect.pwm_clock(WBA55, 999) == 100_000 + assert expect.pwm_clock(WB55, 65535) == 976 + assert expect.pwm_ticks(WB55, 10000, "edge") == 6400 + assert expect.pwm_ticks(WB55, 10000, "center") == 3200 + assert expect.pwm_ticks(WBA55, 30000, "edge") == 3333 + assert expect.pwm_ticks(WBA55, 30000, "center") == 1666 + + +@pytest.mark.parametrize( + ("pwmclk", "freq", "mode", "counter_max", "fits"), + [ + (WB55, 1000, "edge", 0xFFFF, True), + (WB55, 976, "edge", 0xFFFF, False), + (WB55, 977, "edge", 0xFFFF, True), + (WB55, 100, "edge", 0xFFFF, False), + (WB55, 100, "edge", 0xFFFFFFFF, True), + (WB55, 489, "center", 0xFFFF, True), + (WB55, 488, "center", 0xFFFF, False), + (WB55, 32_000_000, "edge", 0xFFFF, True), + (WB55, 32_000_001, "edge", 0xFFFF, False), + (WB55, 16_000_000, "center", 0xFFFF, True), + (WB55, 16_000_001, "center", 0xFFFF, False), + (WBA55, 1000, "edge", 0xFFFF, False), + (WBA55, 1000, "center", 0xFFFF, True), + (WBA55, 1526, "edge", 0xFFFF, True), + (WBA55, 1525, "edge", 0xFFFF, False), + (976, 100, "edge", 0xFFFF, True), + (976, 1000, "edge", 0xFFFF, False), + (WB55, 0, "edge", 0xFFFF, False), + ], +) +def test_pwm_fits(pwmclk, freq, mode, counter_max, fits): + assert expect.pwm_fits(pwmclk, freq, mode, counter_max) is fits + + +def test_pwm_frequency_quantisation(): + assert expect.pwm_frequency(WB55, 10000, "edge") == pytest.approx(10000) + assert expect.pwm_frequency(WBA55, 30000, "edge") == pytest.approx(WBA55 / 3333) + assert expect.pwm_frequency(WB55, 10000, "center") == pytest.approx(10000), "2 * ARR ticks per period, ARR = 3200" + assert expect.pwm_frequency(WBA55, 30000, "center") == pytest.approx(WBA55 / 3332) + assert expect.pwm_frequency(1_000_000, 300_000, "edge") == pytest.approx(333_333.33, rel=1e-6) + + +def test_pwm_frequency_of_the_driver(): + """PwmStm writes ARR = ticks - 1 centre aligned too: 2 * (ticks - 1) counts per period.""" + assert expect.pwm_frequency_driver(WB55, 10000, "edge") == expect.pwm_frequency(WB55, 10000, "edge") + assert expect.pwm_frequency_driver(WB55, 10000, "center") == pytest.approx(WB55 / 6398) + assert expect.pwm_frequency_driver(WB55, 1_000_000, "center") == pytest.approx(1_032_258, rel=1e-6) + assert expect.pwm_duty_driver(WB55, 10000, "center", 50) == pytest.approx(100 * 1600 / 3199) + assert expect.pwm_duty_driver(WB55, 10000, "edge", 50) == expect.pwm_duty(WB55, 10000, "edge", 50) + + +@pytest.mark.parametrize( + ("pwmclk", "mode", "counter_max", "limits"), + [ + (WB55, "edge", 0xFFFF, (977, 32_000_000)), + (WB55, "center", 0xFFFF, (489, 16_000_000)), + (WB55, "edge", 0xFFFFFFFF, (1, 32_000_000)), + (WBA55, "edge", 0xFFFF, (1526, 50_000_000)), + (WBA55, "center", 0xFFFF, (763, 25_000_000)), + (976, "edge", 0xFFFF, (1, 488)), + (976, "center", 0xFFFF, (1, 244)), + ], +) +def test_pwm_frequency_limits(pwmclk, mode, counter_max, limits): + assert expect.pwm_frequency_limits(pwmclk, mode, counter_max) == limits + low, high = limits + assert expect.pwm_fits(pwmclk, low, mode, counter_max) and expect.pwm_fits(pwmclk, high, mode, counter_max) + assert not expect.pwm_fits(pwmclk, low - 1, mode, counter_max) + assert not expect.pwm_fits(pwmclk, high + 1, mode, counter_max) + + +def test_pwm_duty(): + assert expect.pwm_duty_counts(WB55, 10000, "edge", 12.5) == 800 + assert expect.pwm_duty(WB55, 10000, "edge", 12.5) == pytest.approx(12.5) + assert expect.pwm_duty_counts(1_000_000, 100_000, "edge", 12.5) == 1, "10 ticks: 1.25 rounds to 1" + assert expect.pwm_duty(1_000_000, 100_000, "edge", 12.5) == pytest.approx(10) + assert expect.pwm_duty(WB55, 10000, "center", 100) == pytest.approx(100) + assert expect.pwm_duty(1_000_000, 100_000, "center", 50) == pytest.approx(60), "5 ticks: 2.5 rounds to 3" + assert expect.pwm_duty_step(WB55, 10000, "edge") == pytest.approx(100 / 6400) + assert expect.pwm_duty_step(WB55, 10000, "center") == pytest.approx(100 / 3200) + + +@pytest.mark.parametrize( + ("pwmclk", "freq", "mode", "duty", "resolvable"), + [ + (1_000_000, 100_000, "edge", 50, True), + (1_000_000, 100_000, "edge", 4, False), + (1_000_000, 100_000, "edge", 96, False), + (1_000_000, 100_000, "edge", 90, True), + (1_000_000, 100_000, "center", 90, False), + (1_000_000, 100_000, "center", 80, True), + (1_000_000, 100_000, "center", 12.5, True), + (1_000_000, 100_000, "center", 5, False), + (1_000_000, 50_000, "center", 12.5, True), + (WB55, 10000, "edge", 12.5, True), + ], +) +def test_pwm_duty_resolvable(pwmclk, freq, mode, duty, resolvable): + """10 ticks edge aligned (CCR 1-9 keep both edges), 5 ticks centre aligned (ARR 5: CCR 1-4).""" + assert expect.pwm_duty_resolvable(pwmclk, freq, mode, duty) is resolvable + + +def test_pwm_prescaler_for(): + assert expect.pwm_prescaler_for(WB55, 1000, "edge") == 0 + assert expect.pwm_prescaler_for(WB55, 100, "edge") == 9 + assert expect.pwm_prescaler_for(WB55, 100, "edge", 0xFFFFFFFF) == 0 + assert expect.pwm_prescaler_for(WBA55, 1000, "edge") == 1 + assert expect.pwm_prescaler_for(WBA55, 1, "center") == 762 + with pytest.raises(ValueError): + expect.pwm_prescaler_for(WB55, 40_000_000, "edge") + + +@pytest.mark.parametrize( + ("dead_ns", "clock", "ticks"), + [ + (0, WB55, 0), + (1, WB55, 1), + (100, WB55, 7), + (1984, WB55, 127), + (2000, WB55, 128), + (2010, WB55, 130), + (5000, WB55, 320), + (10000, WB55, 640), + (15750, WB55, 1008), + (15751, WB55, 1008), + (20000, WB55, 1008), + (1_000_000, WB55, 1008), + (100, WBA55, 10), + (1280, WBA55, 128), + (2550, WBA55, 256), + (5050, WBA55, 512), + (10080, WBA55, 1008), + ], +) +def test_pwm_dead_ticks(dead_ns, clock, ticks): + """DTG encodes 0-127 ticks, then 2-, 8- and 16-tick steps up to 1008, rounding up.""" + assert expect.pwm_dead_ticks(dead_ns, clock) == ticks + + +def test_pwm_dead_time_and_limits(): + assert expect.pwm_dead_time(100, WB55) == pytest.approx(7 / WB55) + assert expect.pwm_dead_time(20000, WB55) == pytest.approx(15.75e-6) + assert expect.pwm_dead_time(20000, WBA55) == pytest.approx(10.08e-6) + assert expect.pwm_dead_fits(1_000_000) + assert not expect.pwm_dead_fits(1_000_001) + assert not expect.pwm_dead_fits(-1) + + +@pytest.mark.parametrize( + ("spiclk", "baud", "divider"), + [ + (WB55, 32_000_000, 2), + (WB55, 31_999_999, 4), + (WB55, 3_000_000, 32), + (WB55, 1_000_000, 64), + (WB55, 250_000, 256), + (WBA55, 50_000_000, 2), + (WBA55, 3_000_000, 64), + (WBA55, 6_250_000, 16), + (WBA55, 390_625, 256), + ], +) +def test_spi_divider(spiclk, baud, divider): + assert expect.spi_divider(spiclk, baud) == divider + assert expect.spi_clock(spiclk, baud) == spiclk / divider + assert expect.spi_clock(spiclk, baud) <= baud + + +def test_spi_range(): + assert expect.spi_baud_fits(WB55, 250_000) + assert not expect.spi_baud_fits(WB55, 249_999) + assert expect.spi_baud_fits(WB55, 32_000_000) + assert not expect.spi_baud_fits(WB55, 32_000_001) + assert not expect.spi_baud_fits(WBA55, 390_624) + with pytest.raises(ValueError): + expect.spi_divider(WBA55, 50_000_001) + + +def test_uart_dividers(): + assert expect.uart_divider(WB55, 115200, lp=True) == 142222 + assert expect.uart_divider(WB55, 115200) == 1111 + assert expect.uart_divider(WBA55, 921600) == 217 + assert expect.uart_actual_baud(WBA55, 921600) == pytest.approx(921659, rel=1e-6) + assert expect.uart_clock_name(1, lp=True) == "lpuart1" + assert expect.uart_clock_name(2) == "usart2" + + +@pytest.mark.parametrize( + ("clock", "lp", "maximum", "limits"), + [ + (WB55, True, 8_000_000, (15626, 8_000_000)), + (WB55, True, 12_000_000, (15626, 12_000_000)), + (WBA55, True, 12_000_000, (24415, 12_000_000)), + (WBA55, False, 12_000_000, (3052, 12_000_000)), + (WB55, False, 12_000_000, (1954, 8_258_064)), + (1_000_000, True, 12_000_000, (300, 333_550)), + ], +) +def test_uart_baud_limits(clock, lp, maximum, limits): + """LPUART: BRR 0x300-0xFFFFF; USART (8x oversampling): BRR 16-65535; both rounded to nearest like the HAL.""" + assert expect.uart_baud_limits(clock, lp, maximum) == limits + low, high = limits + assert expect.uart_baud_fits(clock, low, lp, maximum) and expect.uart_baud_fits(clock, high, lp, maximum) + assert not expect.uart_baud_fits(clock, low - 1, lp, maximum) + assert not expect.uart_baud_fits(clock, high + 1, lp, maximum) + + +def test_uart_baud_max_follows_the_hal(): + """The WB HAL asserts baud < 8000001 (stm32wbxx_hal_uart.h IS_UART_BAUDRATE), the WBA HAL baud < 12500000.""" + assert expect.uart_baud_max("stm32wb55") == 8_000_000 + assert expect.uart_baud_max("stm32wba55") == 12_000_000 + assert expect.uart_baud_max("other") == 12_000_000 + assert not expect.uart_baud_fits(WB55, 8_000_001, lp=True, maximum=expect.uart_baud_max("stm32wb55")) + + +def test_uart_frames(): + assert expect.uart_frame_bits("none") == 10 + assert expect.uart_frame_bits("even") == 11 + assert expect.uart_transfer_time(10, 1000) == pytest.approx(0.1) + assert expect.uart_transfer_time(10, 1100, "odd") == pytest.approx(0.1) + + +@pytest.mark.parametrize( + ("timeout_ms", "pclk1", "prescaler", "period"), + [ + (1, WB55, 1, 4.032e-3), + (5, WB55, 2, 8.064e-3), + (20, WB55, 8, 32.256e-3), + (100, WB55, 32, 129.024e-3), + (300, WB55, 128, 516.096e-3), + (516, WB55, 128, 516.096e-3), + (5, WBA55, 2, 5.16096e-3), + (20, WBA55, 8, 20.64384e-3), + (100, WBA55, 64, 165.15072e-3), + (330, WBA55, 128, 330.30144e-3), + ], +) +def test_wwdg_prescaler_and_period(timeout_ms, pclk1, prescaler, period): + assert expect.wwdg_prescaler_for(timeout_ms, pclk1) == prescaler + assert expect.wwdg_warning_period(timeout_ms, pclk1) == pytest.approx(period) + assert expect.wwdg_warning_period(timeout_ms, pclk1) >= timeout_ms / 1000 + + +def test_wwdg_limits(): + assert expect.wwdg_max_timeout_ms(WB55) == 516 + assert expect.wwdg_max_timeout_ms(WBA55) == 330 + assert expect.wwdg_prescaler_for(517, WB55) is None + assert expect.wwdg_prescaler_for(331, WBA55) is None + with pytest.raises(ValueError): + expect.wwdg_warning_period(30000, WB55) + assert expect.wwdg_tick(WB55, 1) == pytest.approx(64e-6) + + +@pytest.mark.parametrize( + ("timeout_ms", "pclk1", "margin", "outruns"), + [ + (5, WB55, 1, False), + (20, WB55, 1, True), + (20, WB55, 2, False), + (100, WB55, 2, True), + (300, WB55, 2, True), + (5, WBA55, 1, False), + (20, WBA55, 1, True), + (20, WBA55, 2, False), + (100, WBA55, 2, True), + ], +) +def test_wwdg_warning_line_against_the_reset(timeout_ms, pclk1, margin, outruns): + """The line `\\r\\nEVT wdt index=0 warning=1\\r\\n` takes 29 * 10 / 921600 = 314.7 us; the tick before the reset is + 128 us (WB55, 5 ms), 512 us (WB55, 20 ms), 81.9 us (WBA55, 5 ms) or 327.7 us (WBA55, 20 ms).""" + assert expect.terminal_line_time(expect.wwdg_warning_line(0, 1), 921600) == pytest.approx(314.7e-6, abs=0.1e-6) + assert expect.wwdg_warning_outruns_reset(timeout_ms, pclk1, 921600, margin) is outruns + + +def test_adc(): + assert expect.adc_code(0.0) == 0 + assert expect.adc_code(1.65) == 2048 + assert expect.adc_code(3.3) == 4095 + assert expect.adc_code(0.2) == 248 + assert expect.adc_max_runs(1) == 64 + assert expect.adc_max_runs(3) == 21 + assert expect.adc_max_runs(8) == 8 + assert expect.adc_measure_time(32, 1000) == pytest.approx(0.032) + assert expect.ADC_DEFAULT_SAMPLING["stm32wb55"] in expect.ADC_SAMPLING_TIMES["stm32wb55"] + assert expect.ADC_DEFAULT_SAMPLING["stm32wba55"] == expect.ADC_SAMPLING_TIMES["stm32wba55"][1] + assert all(len(times) == 8 for times in expect.ADC_SAMPLING_TIMES.values()) + + +@pytest.mark.parametrize( + ("cycles", "cap", "direction", "inva", "invb", "counts"), + [ + (10, "ab", "fwd", 0, 0, 40), + (10, "ab", "rev", 0, 0, -40), + (10, "a", "fwd", 0, 0, 20), + (10, "b", "rev", 0, 0, -20), + (10, "ab", "fwd", 1, 0, -40), + (10, "ab", "fwd", 0, 1, -40), + (10, "ab", "fwd", 1, 1, 40), + (3, "a", "rev", 1, 0, 6), + ], +) +def test_qei_counts(cycles, cap, direction, inva, invb, counts): + assert expect.qei_counts(cycles, cap, direction, inva, invb) == counts + + +@pytest.mark.parametrize(("delta", "modulus", "wrapped"), [(10, 100, 10), (-10, 100, -10), (95, 100, -5), (50, 100, 50), (-60, 100, 40)]) +def test_wrap_delta(delta, modulus, wrapped): + assert expect.wrap_delta(delta, modulus) == wrapped + + +def test_wrap_position_and_payload(): + assert expect.wrap_position(50 + 4 * 30, 100) == 70 + assert expect.wrap_position(-4, 4096) == 4092 + assert expect.wrap_position(2**32 + 5, 2**32) == 5 + assert expect.max_hex_payload(255, "uart.send 1") == 121 diff --git a/validation/host/tests/unit/test_fake_firmware.py b/validation/host/tests/unit/test_fake_firmware.py new file mode 100644 index 00000000..dafdc4bb --- /dev/null +++ b/validation/host/tests/unit/test_fake_firmware.py @@ -0,0 +1,981 @@ +import pytest +from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal + +from hal_st_validation import expect +from hal_st_validation.config import load_board +from hal_st_validation.fake_firmware import UNSUPPORTED_COMMANDS, WB55_PINS, WBA55_PINS, FakeFirmware, FakeSerial + +BOARDS = {"stm32wb55": "nucleo_wb55rg", "stm32wba55": "nucleo_wba55cg"} + + +class Clock: + def __init__(self) -> None: + self.now = 100.0 + + def __call__(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.now += seconds + + +def make_terminal(style="line", chunk=0, noise=False, **kwargs): + firmware = FakeFirmware(style=style, noise=noise, **kwargs) + serial = FakeSerial(firmware, chunk=chunk) + return FirmwareTerminal(serial=serial, timeout=0.5), firmware + + +def timed_terminal(**kwargs): + clock = Clock() + terminal, firmware = make_terminal(clock=clock, sleep=clock.sleep, **kwargs) + return terminal, firmware, clock + + +def reason(terminal, line): + response = terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +def wba(): + return make_terminal(family="stm32wba55") + + +# framing and general commands + + +@pytest.mark.parametrize("style", ["line", "trace"]) +@pytest.mark.parametrize("chunk", [0, 3]) +@pytest.mark.parametrize("noise", [False, True]) +def test_command_framing(style, chunk, noise): + terminal, firmware = make_terminal(style, chunk, noise) + boot = terminal.wait_boot(1.0) + assert boot["board"] == "NUCLEO-WB55RG" + assert boot["family"] == "stm32wb55" + assert boot["reset"] == "pin" + assert terminal.command("ping").ok + terminal.command("gpio.cfg led0 in") + firmware.gpio_levels["PB0"] = 1 + assert terminal.command("gpio.get PB0").as_int("value") == 1 + info = terminal.command("info") + assert info["board"] == "NUCLEO-WB55RG" + assert info["sysclk"] == "64000000" + assert len(info["uid"]) == 24 + + +@pytest.mark.parametrize( + ("family", "board", "sysclk"), [("stm32wb55", "NUCLEO-WB55RG", 64_000_000), ("stm32wba55", "NUCLEO-WBA55CG", 100_000_000)] +) +def test_profiles(family, board, sysclk): + terminal, firmware = make_terminal(family=family) + info = terminal.command("info") + assert (info["board"], info["family"], info.as_int("sysclk")) == (board, family, sysclk) + assert firmware.pins == (WB55_PINS if family == "stm32wb55" else WBA55_PINS) + pins = terminal.command("board.pins").raw + assert pins.startswith("OK terminaltx=PB6,terminalrx=PB7" if family == "stm32wb55" else "OK terminaltx=PB12,terminalrx=PA8") + with pytest.raises(ValueError): + FakeFirmware(family="stm32f4") + + +def test_board_overrides_profile_defaults(): + terminal, firmware = make_terminal(board="custom", sysclk=0, pins={"terminaltx": "PB6", "terminalrx": "PB7"}) + assert firmware.sysclk == 64_000_000 + assert terminal.command("board.pins").raw == "OK terminaltx=PB6,terminalrx=PB7" + assert terminal.command("info")["board"] == "custom" + + +def test_general_command_shapes(): + terminal, _, clock = timed_terminal() + assert reason(terminal, "ping extra") == "usage" + assert reason(terminal, "ping nosuchkey=1") == "usage" + assert reason(terminal, "info extra") == "usage" + assert reason(terminal, "delay") == "usage" + assert reason(terminal, "delay abc") == "usage" + assert reason(terminal, "delay -5") == "usage" + assert reason(terminal, "delay 600001") == "range" + start = clock.now + assert reason(terminal, "delay 0x64") == "ok" + assert clock.now - start == pytest.approx(0.1) + + +def test_unknown_command_is_usage(): + """EMIL's `HilTerminal` answers `ERR usage` to unknown commands.""" + terminal, _ = make_terminal() + with pytest.raises(FirmwareError) as error: + terminal.command("nosuch.command") + assert error.value.reason == "usage" + + +def test_reset_reports_sw_and_closes_instances(): + terminal, firmware = make_terminal("trace") + terminal.wait_boot(1.0) + terminal.command("spi.open 1 clk=PA5 mosi=PA7 miso=PA6") + terminal.command("gpio.cfg gpio0 out") + terminal.send_nowait("reset") + boot = terminal.wait_boot(1.0) + assert boot["reset"] == "sw" + assert not firmware.opened + assert not firmware.claims + assert reason(terminal, "spi.close 1") == "notopen" + + +def test_sync_clears_partial_input(): + terminal, firmware = make_terminal() + terminal.write_raw(b"gpio.se") + terminal.sync() + assert firmware.received[-1] == "ping" + + +@pytest.mark.parametrize("command", UNSUPPORTED_COMMANDS) +def test_unsupported_groups(command): + terminal, _ = make_terminal() + assert reason(terminal, command) == "unsupported" + assert reason(terminal, f"{command} 0 key=1") == "unsupported" + + +# board profiles against the board files + + +@pytest.mark.parametrize("family", sorted(BOARDS)) +def test_board_file_system_lines(family): + """The system test lines of the board file get the answers PROTOCOL.md promises.""" + board = load_board(BOARDS[family]) + terminal, _ = make_terminal(family=family) + for line in board.param("system.missing_instances"): + assert reason(terminal, line) == "range", line + for line in board.param("system.unsupported_instances"): + assert reason(terminal, line) == "unsupported", line + for pin in board.param("system.unbonded_pins"): + assert reason(terminal, f"gpio.cfg {pin} in") == "pin", pin + for pin in [*board.param("system.reserved_pins"), board.param("system.debug_led"), *board.terminal.pins]: + assert reason(terminal, f"gpio.cfg {pin} in") == "busy", pin + for index in board.param("system.reserved_uarts"): + assert reason(terminal, f"uart.open {index}") == "busy" + for line in board.param("unsupported.commands"): + assert reason(terminal, line) == "unsupported", line + + +@pytest.mark.parametrize("family", sorted(BOARDS)) +def test_every_alias_is_a_pin(family): + board = load_board(BOARDS[family]) + terminal, _ = make_terminal(family=family) + for alias, pin in board.pins.items(): + expected = "busy" if pin in board.terminal.pins else "ok" + assert reason(terminal, f"gpio.cfg {alias} in") == expected, alias + if expected == "ok": + assert reason(terminal, f"gpio.get {pin}") == "ok" + assert reason(terminal, f"gpio.release {alias}") == "ok" + + +def test_pin_syntax(): + terminal, _ = make_terminal() + assert reason(terminal, "gpio.cfg pa8 in") == "ok", "port letters are case-insensitive" + assert reason(terminal, "gpio.release PA8") == "ok" + assert reason(terminal, "gpio.cfg PA08 in") == "pin", "no leading zero" + assert reason(terminal, "gpio.cfg PA16 in") == "pin" + assert reason(terminal, "gpio.cfg PF0 in") == "pin", "no port F on the WB55" + assert reason(terminal, "gpio.cfg LED0 in") == "pin", "aliases are case-sensitive" + assert reason(terminal, "gpio.cfg nosuchalias in") == "pin" + assert reason(terminal, "gpio.cfg PE0 in") == "pin", "PE0 is not bonded out on the VFQFPN68" + assert reason(terminal, "gpio.get PE0") == "notopen", "the name parses; the pin is just not configured" + assert reason(terminal, "gpio.cfg PE0 sideways") == "usage", "the mode is checked before the pin is claimed" + terminal, _ = wba() + assert reason(terminal, "gpio.cfg PD0 in") == "pin", "no port D on the WBA55" + assert reason(terminal, "gpio.cfg PA4 in") == "pin", "PA4 is not bonded out on the UFQFPN48" + assert reason(terminal, "gpio.cfg PC13 in") == "ok" + + +# gpio + + +def test_gpio_configuration(): + terminal, firmware = make_terminal() + assert reason(terminal, "gpio.cfg PA8") == "usage" + assert reason(terminal, "gpio.cfg PA8 sideways") == "usage" + assert reason(terminal, "gpio.cfg PA8 in pull=sideways") == "usage" + assert reason(terminal, "gpio.cfg PA8 out drive=8") == "usage" + assert reason(terminal, "gpio.cfg PA8 od pull=up") == "usage" + assert reason(terminal, "gpio.cfg PA8 od pull=none") == "ok" + assert terminal.command("gpio.get PA8").as_int("value") == 1, "open drain starts released" + assert reason(terminal, "gpio.cfg PA8 out drive=high") == "ok", "reconfiguring a pin is allowed" + assert terminal.command("gpio.get PA8").as_int("value") == 0, "out starts low" + assert reason(terminal, "gpio.set PA8 2") == "usage" + assert reason(terminal, "gpio.set PA8 1") == "ok" + assert terminal.command("gpio.get PA8").as_int("value") == 1 + assert reason(terminal, "gpio.pulse PA8 3 0") == "range" + assert reason(terminal, "gpio.pulse PA8 3 10") == "ok" + assert terminal.command("gpio.get PA8").as_int("value") == 0, "an odd number of toggles" + assert reason(terminal, "gpio.cfg PA9 in pull=up") == "ok" + assert terminal.command("gpio.get PA9").as_int("value") == 1 + assert reason(terminal, "gpio.pulse PA9 1 1") == "usage", "pulse needs an output" + firmware.gpio_counts["PA9"] = 7 + assert terminal.command("gpio.count PA9 clear=1").as_int("count") == 7 + assert terminal.command("gpio.count PA9").as_int("count") == 0 + assert reason(terminal, "gpio.count PA9 clear=2") == "range" + assert reason(terminal, "gpio.release PA9") == "ok" + for line in ("gpio.get PA9", "gpio.set PA9 1", "gpio.count PA9", "gpio.irq PA9 rising", "gpio.release PA9"): + assert reason(terminal, line) == "notopen", line + + +def test_gpio_limit_is_eight_pins(): + terminal, _ = make_terminal() + for index in range(8): + assert reason(terminal, f"gpio.cfg PA{index} in") == "ok" + assert reason(terminal, "gpio.cfg PA8 in") == "busy" + assert reason(terminal, "gpio.cfg PA0 out") == "ok", "reconfiguring needs no new entry" + assert reason(terminal, "gpio.release PA1") == "ok" + assert reason(terminal, "gpio.cfg PA8 in") == "ok" + + +def test_gpio_exti_line_serves_one_port(): + terminal, _ = make_terminal() + terminal.command("gpio.cfg PC6 in") + terminal.command("gpio.cfg PA6 in") + assert reason(terminal, "gpio.irq PC6 sideways") == "usage" + assert reason(terminal, "gpio.irq PC6 rising type=later") == "usage" + assert reason(terminal, "gpio.irq PC6 rising") == "ok" + assert reason(terminal, "gpio.irq PC6 both type=immediate") == "ok", "the owner can rearm" + assert reason(terminal, "gpio.irq PA6 rising") == "unsupported" + assert reason(terminal, "gpio.irq PA6 off") == "unsupported", "the line is checked whatever the edge" + assert reason(terminal, "gpio.irq PC6 off") == "ok" + assert reason(terminal, "gpio.irq PA6 falling") == "ok" + assert reason(terminal, "gpio.irq PC6 rising") == "unsupported" + assert reason(terminal, "gpio.cfg PA6 out") == "ok", "reconfiguring disables the interrupt" + assert reason(terminal, "gpio.irq PC6 rising") == "ok" + assert reason(terminal, "gpio.release PC6") == "ok", "releasing frees the line" + assert reason(terminal, "gpio.irq PA6 rising") == "ok" + + +def test_gpio_port_h_has_no_interrupt(): + """PH3 (BOOT0) is the only port H pin and is reserved, so the rule is checked on the model.""" + _, firmware = make_terminal() + assert not firmware.supports_interrupt("PH3") + assert firmware.supports_interrupt("PC13") + + +def test_gpio_pins_held_by_other_groups(): + terminal, _ = make_terminal() + terminal.command("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 cs=PA4") + assert reason(terminal, "gpio.cfg PA4 in") == "busy" + assert reason(terminal, "gpio.cfg spi1clk in") == "busy" + terminal.command("spi.close 1") + assert reason(terminal, "gpio.cfg PA4 in") == "ok" + + +# pwm + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("pwm.open 1", "usage"), + ("pwm.open", "usage"), + ("pwm.open 1 channels=1 nosuchkey=1", "usage"), + ("pwm.open 0 channels=1", "range"), + ("pwm.open 18 channels=1", "range"), + ("pwm.open 3 channels=1", "range"), + ("pwm.open 1 channels=0", "range"), + ("pwm.open 1 channels=5", "range"), + ("pwm.open 1 channels=1,1", "usage"), + ("pwm.open 1 channels=1,2,3,4,1", "usage"), + ("pwm.open 1 channels=1,2 pins=PA8", "usage"), + ("pwm.open 1 pins=-:-", "usage"), + ("pwm.open 1 pins=PA8:PA7:PB8", "usage"), + ("pwm.open 1 pins=PB0", "pin"), + ("pwm.open 1 channels=2 pins=PA8", "pin"), + ("pwm.open 1 pins=PA8:PB8", "pin"), + ("pwm.open 1 pins=PA8,PD14", "pin"), + ("pwm.open 1 pins=PA8,PA8", "usage"), + ("pwm.open 16 channels=2", "unsupported"), + ("pwm.open 1 channels=4 pins=PA11:PB13", "unsupported"), + ("pwm.open 1 pins=PA8,,PA9", "ok"), + ("pwm.open 1 channels=1,,2", "ok"), + ("pwm.open 1 channels=", "usage"), + ("pwm.open 1 pins=:PA7", "pin"), + ("pwm.open 2 channels=1 brkpol=low brkauto=1", "ok"), + ("pwm.open 2 channels=1 dead=0", "unsupported"), + ("pwm.open 1 channels=1 brk=PA0", "pin"), + ("pwm.open 1 channels=1 freq=0", "range"), + ("pwm.open 1 channels=1 freq=976", "range"), + ("pwm.open 1 channels=1 freq=977", "ok"), + ("pwm.open 1 channels=1 freq=100 prescaler=9", "ok"), + ("pwm.open 1 channels=1 freq=32000001", "range"), + ("pwm.open 1 channels=1 mode=center freq=488", "range"), + ("pwm.open 1 channels=1 mode=sideways", "usage"), + ("pwm.open 1 channels=1 prescaler=65536", "range"), + ("pwm.open 2 channels=1 freq=100", "ok"), + ("pwm.open 2 channels=1 freq=1", "ok"), + ("pwm.open 1 channels=1 dead=1000000", "ok"), + ("pwm.open 1 channels=1 dead=1000001", "range"), + ("pwm.open 1 channels=1 dead=fast", "usage"), + ("pwm.open 1 channels=1 inv=2", "range"), + ("pwm.open 1 channels=1 brkpol=sideways", "usage"), + ("pwm.open 1 pins=PA8:PA7,PA9:PB8,PA10:PB9 dead=500 inv=1 invn=1 idle=1 idlen=1 brk=PB12 brkpol=low brkauto=1", "ok"), + ("pwm.open 1 pins=-:PB13", "ok"), + ("pwm.open 2 channels=1 dead=100", "unsupported"), + ("pwm.open 2 channels=1 idle=1", "unsupported"), + ("pwm.open 2 channels=1 dead=off idle=0 idlen=0", "ok"), + ("pwm.open 16 channels=1 mode=center", "unsupported"), + ("pwm.open 17 pins=PB9 brk=PA10 dead=100", "ok"), + ("pwm.open 16 pins=PA6 brk=PB5", "busy"), + ("pwm.open 17 pins=PB9:PB7", "busy"), + ("pwm.open 1 channels=1,2,3,4 sync=1", "ok"), + ], +) +def test_pwm_open_validation_wb55(line, expected): + terminal, _ = make_terminal() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("pwm.open 3 channels=1,2,3,4", "ok"), + ("pwm.open 3 pins=PA10:PB2", "unsupported"), + ("pwm.open 3 pins=-:PB2", "unsupported"), + ("pwm.open 2 channels=2", "pin"), + ("pwm.open 2 pins=PA8", "busy"), + ("pwm.open 2 pins=PA5", "ok"), + ("pwm.open 1 channels=1 freq=1000", "range"), + ("pwm.open 1 channels=1 freq=1000 mode=center", "ok"), + ("pwm.open 17 pins=PA1:PB3 brk=PA15 dead=20000", "ok"), + ("pwm.open 16 pins=PB9 brk=PB15", "ok"), + ("pwm.open 4 channels=1", "range"), + ("pwm.open 16 channels=1,2", "unsupported"), + ("pwm.open 1 channels=4 pins=PB3:PB2", "unsupported"), + ], +) +def test_pwm_open_validation_wba55(line, expected): + """WBA55: TIM3 exists; TIM2 CH2 is only the terminal RX pin (reserved: busy).""" + terminal, _ = wba() + assert reason(terminal, line) == expected + + +def test_pwm_duties_follow_the_command_order(): + terminal, firmware = make_terminal() + terminal.command("pwm.open 1 pins=PA10,PA8") + assert [channel for channel, _, _ in firmware.opened[("pwm", "1")]["channels"]] == [3, 1] + terminal.command("pwm.duty 1 30 10") + assert firmware.opened[("pwm", "1")]["duties"] == [30, 10] + + +def test_pwm_channels_without_pins_take_the_first_free_table_pin(): + terminal, firmware = make_terminal() + terminal.command("pwm.open 2 channels=4,1") + assert firmware.opened[("pwm", "2")]["channels"] == [(4, "PA3", None), (1, "PA0", None)] + terminal.command("pwm.close 2") + terminal.command("pwm.open 17 channels=1") + assert firmware.opened[("pwm", "17")]["channels"] == [(1, "PA7", None)] + + +@pytest.mark.parametrize("prescaler", [0, 1, 63, 999, 65535]) +@pytest.mark.parametrize("freq", [100, 1000, 10000, 1000000]) +@pytest.mark.parametrize("mode", ["edge", "center"]) +@pytest.mark.parametrize("timer", [1, 2]) +def test_pwm_range_matches_expect(prescaler, freq, mode, timer): + terminal, _ = make_terminal() + pwmclk = expect.pwm_clock(64_000_000, prescaler) + fits = expect.pwm_fits(pwmclk, freq, mode, expect.timer_counter_max(timer)) + response = terminal.command(f"pwm.open {timer} channels=1 freq={freq} mode={mode} prescaler={prescaler}", check=False) + assert response.ok is fits + if fits: + assert response.as_int("pwmclk") == pwmclk + + +def test_pwm_duty_frequency_stop_close(): + terminal, firmware = make_terminal() + assert reason(terminal, "pwm.duty 1 50") == "notopen" + assert reason(terminal, "pwm.duty 99 50") == "range" + assert terminal.command("pwm.open 1 channels=1,2 prescaler=63")["pwmclk"] == "1000000" + assert reason(terminal, "pwm.duty 1") == "usage" + assert reason(terminal, "pwm.duty 1 10 20 30") == "usage", "one duty per channel or one for all" + for duty, expected in ( + ("100.5", "usage"), + ("101", "usage"), + ("12.34567", "usage"), + ("0x10", "usage"), + ("-1", "usage"), + (".5", "usage"), + ): + assert reason(terminal, f"pwm.duty 1 {duty}") == expected, duty + assert reason(terminal, "pwm.duty 1 12.5 100") == "ok" + assert reason(terminal, "pwm.duty 1 0.0001") == "ok" + assert firmware.opened[("pwm", "1")]["running"] + assert reason(terminal, "pwm.freq 1 0") == "range" + assert reason(terminal, "pwm.freq 1 15") == "range", "1 MHz / 15 Hz overflows the 16-bit counter" + assert reason(terminal, "pwm.freq 1 16") == "ok" + assert reason(terminal, "pwm.freq 1 500001") == "range" + assert reason(terminal, "pwm.freq 1 500000") == "ok" + assert reason(terminal, "pwm.stop 1") == "ok" + assert not firmware.opened[("pwm", "1")]["running"] + assert reason(terminal, "pwm.close 2") == "notopen" + assert reason(terminal, "pwm.close 1") == "ok" + assert reason(terminal, "pwm.close 1") == "notopen" + + +def test_one_pwm_timer_at_a_time(): + terminal, _ = make_terminal() + terminal.command("pwm.open 16 channels=1") + assert reason(terminal, "pwm.open 1 channels=1") == "busy" + assert reason(terminal, "pwm.open 16 channels=1") == "busy" + assert reason(terminal, "pwm.open 3 channels=1") == "range", "argument errors come before busy" + assert reason(terminal, "pwm.open 1 channels=5") == "range" + assert reason(terminal, "pwm.open 1 pins=PB0") == "pin" + assert reason(terminal, "pwm.open 2 channels=1 dead=100") == "unsupported" + + +def test_timer_shared_between_groups(): + """A timer serves one of PWM, encoder and timer-triggered ADC at a time.""" + terminal, _ = make_terminal() + terminal.command("qei.open 2") + assert reason(terminal, "pwm.open 2 channels=3") == "busy" + assert reason(terminal, "adc.open 1 pins=PC3 timer=2") == "busy" + assert reason(terminal, "adc.open 1 pins=PC3 timer=1") == "ok" + assert reason(terminal, "pwm.open 1 channels=4") == "busy" + assert reason(terminal, "pwm.open 16 channels=1") == "ok" + terminal.command("qei.close 2") + terminal.command("pwm.close 16") + assert reason(terminal, "pwm.open 2 channels=3") == "ok" + assert reason(terminal, "qei.open 1 a=PA8 b=PA9") == "busy", "TIM1 triggers the ADC" + terminal.command("adc.close 1") + assert reason(terminal, "qei.open 1 a=PA8 b=PA9") == "ok" + + +# uart + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("uart.open", "usage"), + ("uart.open 1 lp=1 nosuchkey=1", "usage"), + ("uart.open 1", "busy"), + ("uart.open 1 baud=1", "range"), + ("uart.open 1 parity=mark", "usage"), + ("uart.open 1 tx=PB0 rx=PB0", "pin"), + ("uart.open 1 tx=PB6", "usage"), + ("uart.open 1 tx=PA9 rx=PA10", "busy"), + ("uart.open 1 dma=1", "busy"), + ("uart.open 2", "range"), + ("uart.open 0 lp=1", "range"), + ("uart.open 2 lp=1", "range"), + ("uart.open 1 lp=2", "range"), + ("uart.open 1 lp=1", "ok"), + ("uart.open 1 lp=1 baud=fast", "usage"), + ("uart.open 1 lp=1 baud=299", "range"), + ("uart.open 1 lp=1 baud=15625", "range"), + ("uart.open 1 lp=1 baud=15626", "ok"), + ("uart.open 1 lp=1 baud=8000000", "ok"), + ("uart.open 1 lp=1 baud=8000001", "range"), + ("uart.open 1 lp=1 baud=12000000", "range"), + ("uart.open 1 lp=1 baud=12000001", "range"), + ("uart.open 1 lp=1 parity=mark", "usage"), + ("uart.open 1 lp=1 parity=odd", "ok"), + ("uart.open 1 lp=1 stop=2", "usage"), + ("uart.open 1 lp=1 flow=sideways", "usage"), + ("uart.open 1 lp=1 dma=1 duplex=1", "usage"), + ("uart.open 1 lp=1 dma=1", "ok"), + ("uart.open 1 lp=1 duplex=1", "unsupported"), + ("uart.open 1 lp=1 sync=1", "unsupported"), + ("uart.open 1 lp=1 swap=1", "ok"), + ("uart.open 1 lp=1 tx=PA2", "usage"), + ("uart.open 1 lp=1 tx=PB11 rx=PB10", "ok"), + ("uart.open 1 lp=1 tx=PB5 rx=PB10", "busy"), + ("uart.open 1 lp=1 tx=PB6 rx=PB7", "pin"), + ("uart.open 1 lp=1 tx=PA3 rx=PA2", "pin"), + ("uart.open 1 lp=1 flow=rtscts", "usage"), + ("uart.open 1 lp=1 flow=rtscts rts=PB12", "usage"), + ("uart.open 1 lp=1 rts=PB12", "usage"), + ("uart.open 1 lp=1 flow=rts", "usage"), + ("uart.open 1 lp=1 flow=rtscts rts=PB12 cts=PA6", "usage"), + ("uart.open 1 lp=1 tx=PA2 rx=PA3 flow=rtscts rts=PB12 cts=PA6", "ok"), + ("uart.open 1 lp=1 tx=PA2 rx=PA3 flow=rtscts rts=PB12 cts=PA11", "pin"), + ("uart.open 1 lp=1 flow=rts rts=PB12", "unsupported"), + ("uart.open 1 lp=1 flow=cts cts=PA6", "unsupported"), + ("uart.open 1 lp=1 tx=PB11 rx=PB10 rts=PB1 flow=rtscts cts=PB13", "ok"), + ("uart.open 1 lp=1 tx=PB5 rx=PB5", "pin"), + ("uart.open 1 lp=1 tx=PA2 rx=PA12 rts=PB12 cts=PB13 flow=rtscts", "ok"), + ], +) +def test_uart_open_validation_wb55(line, expected): + terminal, _ = make_terminal() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("uart.open 1", "busy"), + ("uart.open 2", "usage"), + ("uart.open 3", "range"), + ("uart.open 2 tx=PB0 rx=PA11", "ok"), + ("uart.open 2 tx=PA11 rx=PB0", "pin"), + ("uart.open 2 tx=PB0 rx=PA11 baud=3051", "range"), + ("uart.open 2 tx=PB0 rx=PA11 baud=3052", "ok"), + ("uart.open 1 lp=1 baud=24414", "range"), + ("uart.open 1 lp=1 baud=24415", "ok"), + ("uart.open 2 tx=PB0 rx=PA11 duplex=1", "ok"), + ("uart.open 2 tx=PB0 rx=PA11 sync=1", "ok"), + ("uart.open 2 tx=PB0 rx=PA11 sync=1 parity=even", "unsupported"), + ("uart.open 2 tx=PB0 rx=PA11 sync=1 swap=1", "unsupported"), + ("uart.open 2 tx=PB0 rx=PA11 dma=1 sync=1", "usage"), + ("uart.open 2 tx=PB0 rx=PA11 sync=1 flow=rts", "usage"), + ("uart.open 2 tx=PB0 rx=PA11 sync=1 flow=rts rts=PB1 cts=PB2", "usage"), + ("uart.open 2 tx=PB0 rx=PA11 flow=cts", "usage"), + ("uart.open 2 tx=PB0 rx=PA11 baud=12000000", "ok"), + ("uart.open 2 tx=PB0 rx=PA11 sync=1 flow=rts rts=PB1", "ok"), + ("uart.open 2 tx=PB0 rx=PA11 sync=1 flow=cts cts=PB2", "ok"), + ("uart.open 2 tx=PB0 rx=PA11 flow=rtscts rts=PB1 cts=PB2 dma=1", "ok"), + ("uart.open 2 tx=PB0 rx=PA11 flow=rts rts=PB1", "unsupported"), + ("uart.open 2 tx=PB0 rx=PA11 flow=rtscts rts=PB2 cts=PB1", "pin"), + ("uart.open 2 tx=PB0 rx=PA11 swap=1", "ok"), + ("uart.open 1 lp=1", "ok"), + ("uart.open 1 lp=1 tx=PA2 rx=PA1", "ok"), + ], +) +def test_uart_open_validation_wba55(line, expected): + terminal, _ = wba() + assert reason(terminal, line) == expected + + +def test_uart_send_and_receive(): + terminal, firmware = make_terminal() + assert reason(terminal, "uart.send 1 55") == "notopen" + assert reason(terminal, "uart.recv 1") == "notopen" + terminal.command("uart.open 1 lp=1") + assert reason(terminal, "uart.open 1 lp=1") == "busy" + assert reason(terminal, "uart.send 1") == "usage" + assert reason(terminal, "uart.send 1 -") == "usage" + assert reason(terminal, "uart.send 1 5") == "usage" + assert reason(terminal, "uart.send 1 zz") == "usage" + assert reason(terminal, "uart.send 1 " + "00" * 112) == "ok" + assert reason(terminal, "uart.send 1 " + "00" * 113) == "range" + assert reason(terminal, "uart.recv 1 timeout=10001") == "range" + assert reason(terminal, "uart.recv 1 len=0") == "range" + assert reason(terminal, "uart.recv 1 len=257") == "range" + assert reason(terminal, "uart.recv 1 sideways=1") == "usage" + firmware.uart_rx[1] += bytes(range(10)) + assert terminal.command("uart.recv 1 len=4 timeout=0").as_bytes("data") == bytes(range(10)) + assert terminal.command("uart.recv 1").raw == "OK data=-" + firmware.uart_rx[1] += b"\x55" * 300 + assert len(terminal.command("uart.recv 1").as_bytes("data")) == 256 + assert len(terminal.command("uart.recv 1").as_bytes("data")) == 44 + assert reason(terminal, "uart.close 1") == "ok" + assert reason(terminal, "uart.close 1") == "notopen" + + +def test_uart_pins_and_busy_ordering(): + terminal, _ = make_terminal() + terminal.command("gpio.cfg PA2 in") + assert reason(terminal, "uart.open 1 lp=1 tx=PA3 rx=PA2") == "pin", "argument errors before busy" + assert reason(terminal, "uart.open 1 lp=1") == "busy", "PA2 is held by the GPIO group" + terminal.command("gpio.release PA2") + terminal.command("spi.open 1 clk=PA5 mosi=PA7 miso=PA6") + assert reason(terminal, "uart.open 1 lp=1 flow=rtscts rts=PB12 cts=PA6") == "usage", "rts and cts are pins: no defaults" + assert reason(terminal, "uart.open 1 lp=1 tx=PA2 rx=PA3 flow=rtscts rts=PB12 cts=PA6") == "busy" + assert reason(terminal, "uart.open 1 lp=1 tx=PA2 rx=PA3 flow=rtscts rts=PB12 cts=PB13") == "ok" + + +# spi + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("spi.open 1", "usage"), + ("spi.open 1 clk=PA5 mosi=PA7", "usage"), + ("spi.open 0 clk=PA5 mosi=PA7 miso=PA6", "range"), + ("spi.open 3 clk=PA5 mosi=PA7 miso=PA6", "range"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6", "ok"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 cs=PC13", "ok"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 cs=PB6", "busy"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 cs=PA5", "busy"), + ("spi.open 1 clk=PA6 mosi=PA7 miso=PA5", "pin"), + ("spi.open 1 clk=PB13 mosi=PA7 miso=PA6", "pin"), + ("spi.open 2 clk=PB13 mosi=PB15 miso=PB14", "ok"), + ("spi.open 2 clk=PD3 mosi=PB15 miso=PB14", "pin"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 baud=249999", "range"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 baud=250000", "ok"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 baud=32000000", "ok"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 baud=32000001", "range"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 mode=4", "range"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 dma=1 sync=1", "usage"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 baud=0", "range"), + ("spi.open 1 clk=PA5 mosi=PA7 baud=1", "usage"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 cs=PE0", "pin"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 cs=PA16", "pin"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 sync=1 cs=PA4", "ok"), + ], +) +def test_spi_open_validation_wb55(line, expected): + terminal, _ = make_terminal() + assert reason(terminal, line) == expected + + +def test_spi_wba55_instances_and_clock(): + terminal, firmware = wba() + assert reason(terminal, "spi.open 2 clk=PB4 mosi=PA15 miso=PB3") == "range", "no SPI2 on the WBA55" + assert reason(terminal, "spi.open 1 clk=PB4 mosi=PA15 miso=PB3 baud=390624") == "range" + assert reason(terminal, "spi.open 1 clk=PB4 mosi=PA15 miso=PB3 baud=3000000") == "ok" + assert firmware.opened[("spi", "1")]["clock"] == 1_562_500 + terminal.command("spi.close 1") + assert reason(terminal, "spi.open 3 clk=PA0 mosi=PB8 miso=PB9") == "busy", "PB8 is the debug LED" + + +def test_spi_transfers(): + terminal, firmware = make_terminal() + assert reason(terminal, "spi.xfer 1 00") == "notopen" + terminal.command("spi.open 1 clk=PA5 mosi=PA7 miso=PA6") + assert reason(terminal, "spi.open 2 clk=PB13 mosi=PB15 miso=PB14") == "busy", "one SPI at a time" + assert reason(terminal, "spi.xfer 1 -") == "usage" + assert reason(terminal, "spi.xfer 1 - rx=0") == "usage" + assert reason(terminal, "spi.xfer 1 123") == "usage" + assert reason(terminal, "spi.xfer 1 " + "00" * 65) == "range" + assert reason(terminal, "spi.xfer 1 - rx=65") == "range" + assert reason(terminal, "spi.xfer 1 00 continue=2") == "range" + firmware.spi_miso = 0xA5 + assert terminal.command("spi.xfer 1 " + "00" * 64).as_bytes("rx") == b"\xa5" * 64 + assert terminal.command("spi.xfer 1 0102 rx=4").as_bytes("rx") == b"\xa5" * 4 + assert terminal.command("spi.xfer 1 0102 rx=0").raw == "OK rx=-" + + +# adc + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("adc.open 1", "usage"), + ("adc.open 1 pins=PC3 nosuchkey=1", "usage"), + ("adc.open 0 pins=PC3", "range"), + ("adc.open 2 pins=PC3", "range"), + ("adc.open 1 pins=PC3", "ok"), + ("adc.open 1 pins=ain4,ain3,ain1,ain2,ain5,ain6,PA2,PA3", "ok"), + ("adc.open 1 pins=ain4,ain3,ain1,ain2,ain5,ain6,PA2,PA3,PA4", "range"), + ("adc.open 1 pins=PC3,PC3,PC2,PC3", "ok"), + ("adc.open 1 pins=PB0", "pin"), + ("adc.open 1 pins=PC3,PC6", "pin"), + ("adc.open 1 pins=PC3 sampling=2.5", "ok"), + ("adc.open 1 pins=PC3 sampling=640.5", "ok"), + ("adc.open 1 pins=PC3 sampling=3.5", "usage"), + ("adc.open 1 pins=PC3 rate=1000", "usage"), + ("adc.open 1 pins=PC3 timer=1", "ok"), + ("adc.open 1 pins=PC3 timer=2 rate=100000", "ok"), + ("adc.open 1 pins=PC3 timer=2 rate=100001", "range"), + ("adc.open 1 pins=PC3 timer=2 rate=0", "range"), + ("adc.open 1 pins=PC3 timer=3", "range"), + ("adc.open 1 pins=PC3 timer=16", "unsupported"), + ("adc.open 1 pins=PC3 timer=18", "range"), + ], +) +def test_adc_open_validation_wb55(line, expected): + terminal, _ = make_terminal() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("adc.open 1 pins=PA7", "range"), + ("adc.open 4 pins=PA7,PA6,PB9", "ok"), + ("adc.open 4 pins=PA8", "busy"), + ("adc.open 4 pins=PA4", "pin"), + ("adc.open 4 pins=PB0", "pin"), + ("adc.open 4 pins=PA7 sampling=814.5", "ok"), + ("adc.open 4 pins=PA7 sampling=2.5", "usage"), + ("adc.open 4 pins=PA7 timer=3", "unsupported"), + ("adc.open 4 pins=PA7 timer=2 rate=500", "ok"), + ], +) +def test_adc_open_validation_wba55(line, expected): + terminal, _ = wba() + assert reason(terminal, line) == expected + + +def test_adc_measure_software_trigger(): + terminal, firmware, clock = timed_terminal() + assert reason(terminal, "adc.measure 1") == "notopen" + terminal.command("adc.open 1 pins=ain4,ain3") + assert reason(terminal, "adc.open 1 pins=ain1") == "busy", "one ADC at a time" + firmware.adc_codes.update(PC3=100, PC2=3000) + start = clock.now + assert terminal.command("adc.measure 1 n=3").as_ints("samples") == [100, 3000] * 3 + assert clock.now - start == pytest.approx(expect.terminal_line_time("OK samples=100,3000,100,3000,100,3000", 921600)) + assert reason(terminal, "adc.measure 1 n=32") == "ok" + assert reason(terminal, "adc.measure 1 n=33") == "range", "at most 64 values" + assert reason(terminal, "adc.measure 1 n=0") == "range" + assert reason(terminal, "adc.measure 1 n=65") == "range" + assert reason(terminal, "adc.measure 0") == "range", "the ADC number is checked before the slot" + assert reason(terminal, "adc.measure 2") == "range" + assert reason(terminal, "adc.close 1") == "ok" + + +def test_adc_timer_trigger_rate(): + terminal, firmware, clock = timed_terminal() + terminal.command("adc.open 1 pins=ain4 timer=2 rate=50") + firmware.adc_codes["PC3"] = 1234 + start = clock.now + assert terminal.command("adc.measure 1 n=25").as_ints("samples") == [1234] * 25 + reply = "OK samples=" + ",".join(["1234"] * 25) + assert clock.now - start == pytest.approx(0.5 + expect.terminal_line_time(reply, 921600)), "the reply is sent at 921600 Bd" + start = clock.now + assert reason(terminal, "adc.measure 1 n=64") == "timeout", "64 runs at 50 Hz take longer than 1 s" + assert clock.now - start == pytest.approx(1.0) + terminal.command("adc.close 1") + terminal.command("adc.open 1 pins=ain4,ain3 timer=1") + start = clock.now + assert len(terminal.command("adc.measure 1 n=10").as_ints("samples")) == 20 + reply = "OK samples=" + ",".join(["1234", "2048"] * 10) + assert clock.now - start == pytest.approx(10 / expect.ADC_DEFAULT_RATE + expect.terminal_line_time(reply, 921600)) + + +def test_adc_pins_are_shared_only_with_analog_users(): + terminal, _ = make_terminal() + terminal.command("gpio.cfg PC3 in") + assert reason(terminal, "adc.open 1 pins=PC3") == "busy" + terminal.command("gpio.release PC3") + terminal.command("adc.open 1 pins=PC3,PC3") + assert reason(terminal, "gpio.cfg PC3 in") == "busy" + + +# qei + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("qei.open 2", "ok"), + ("qei.open 2 res=1", "range"), + ("qei.open 2 res=4294967295", "ok"), + ("qei.open 2 res=4294967296", "usage"), + ("qei.open 1 res=65537 a=PA8 b=PA9", "range"), + ("qei.open 1 res=65536 a=PA8 b=PA9", "ok"), + ("qei.open 2 res=100 offset=100", "range"), + ("qei.open 2 res=100 offset=99", "ok"), + ("qei.open 2 cap=x", "usage"), + ("qei.open 2 cap=b", "ok"), + ("qei.open 2 filter=16", "range"), + ("qei.open 2 filter=15 vel=off", "ok"), + ("qei.open 2 vel=0", "range"), + ("qei.open 2 vel=1000001", "range"), + ("qei.open 2 inva=1 invb=1", "ok"), + ("qei.open 1", "usage"), + ("qei.open 1 a=PA8", "usage"), + ("qei.open 1 a=PA8 b=PA9", "ok"), + ("qei.open 1 a=PA9 b=PA8", "pin"), + ("qei.open 2 idx=PC6", "usage"), + ("qei.open 2 a=PA15 b=PB3 idx=PC13", "ok"), + ("qei.open 2 a=PA0 b=PA1", "ok"), + ("qei.open 3", "range"), + ("qei.open 16 a=PA6 b=PB8", "unsupported"), + ("qei.open 17", "unsupported"), + ("qei.open 18", "range"), + ("qei.open 1 lp=1", "usage"), + ("qei.open 1 lp=1 a=PC0 b=PC2", "ok"), + ("qei.open 1 lp=1 a=PB5 b=PB7", "busy"), + ("qei.open 1 lp=1 a=PC2 b=PC0", "pin"), + ("qei.open 2 lp=1 a=PC0 b=PC2", "range"), + ("qei.open 1 lp=1 a=PC0 b=PC2 res=65537", "range"), + ("qei.open 1 lp=1 a=PC0 b=PC2 cap=a", "unsupported"), + ("qei.open 1 lp=1 a=PC0 b=PC2 offset=1", "unsupported"), + ("qei.open 1 lp=1 a=PC0 b=PC2 invb=1", "unsupported"), + ("qei.open 1 lp=1 a=PC0 b=PC2 offset=0", "unsupported"), + ("qei.open 1 lp=1 a=PC0 b=PC2 invb=0", "unsupported"), + ("qei.open 1 lp=1 a=PC0 b=PC2 cap=ab", "unsupported"), + ("qei.open 0 lp=1 a=PC0 b=PC2", "range"), + ("qei.open 1 lp=1 a=PC0 b=PC2 inva=1 filter=8", "ok"), + ("qei.open 1 lp=1 a=PC0 b=PC2 filter=3", "range"), + ], +) +def test_qei_open_validation_wb55(line, expected): + terminal, _ = make_terminal() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("qei.open 1", "ok"), + ("qei.open 1 res=4294967296", "usage"), + ("qei.open 1 res=65537", "range"), + ("qei.open 3 a=PA10 b=PA1", "ok"), + ("qei.open 3", "usage"), + ("qei.open 2 a=PA5 b=PA8", "busy"), + ("qei.open 1 lp=1 a=PA0 b=PB3", "unsupported"), + ("qei.open 0 lp=1", "unsupported"), + ("qei.open 17 lp=1 filter=3", "unsupported"), + ("qei.open 4", "range"), + ], +) +def test_qei_open_validation_wba55(line, expected): + terminal, _ = wba() + assert reason(terminal, line) == expected + + +def test_qei_read_index_and_close(): + terminal, firmware = make_terminal() + assert reason(terminal, "qei.read 2") == "notopen" + assert reason(terminal, "qei.index 2") == "notopen" + assert reason(terminal, "qei.index 18") == "range" + terminal.command("qei.open 2 res=1000 offset=10") + assert reason(terminal, "qei.open 1 a=PA8 b=PA9") == "busy", "one encoder at a time" + reading = terminal.command("qei.read 2") + assert (reading.as_int("pos"), reading["dir"], reading.as_int("speed"), reading.as_int("res")) == (10, "fwd", 0, 1000) + assert terminal.command("qei.index 2")["idx"] == "0" + firmware.gpio_levels["PC6"] = 1 + assert terminal.command("qei.index 2")["idx"] == "1" + assert reason(terminal, "qei.index 1") == "notopen" + assert reason(terminal, "gpio.cfg qei2idx in") == "busy" + terminal.command("qei.close 2") + terminal.command("qei.open 2 a=PA15 b=PB3") + assert reason(terminal, "qei.index 2") == "unsupported", "opened without idx" + terminal.command("qei.close 2") + terminal.command("qei.open 1 lp=1 a=PC0 b=PC2") + assert terminal.command("qei.read 1").as_int("res") == 4096 + + +# watchdog + + +@pytest.mark.parametrize( + ("family", "line", "expected"), + [ + ("stm32wb55", "wdt.start 0", "usage"), + ("stm32wb55", "wdt.start 0 feed=auto", "usage"), + ("stm32wb55", "wdt.start 0 timeout=0", "range"), + ("stm32wb55", "wdt.start 0 timeout=30001", "range"), + ("stm32wb55", "wdt.start 0 timeout=517", "range"), + ("stm32wb55", "wdt.start 0 timeout=516", "ok"), + ("stm32wb55", "wdt.start 1 timeout=100", "range"), + ("stm32wb55", "wdt.start 0 timeout=100 feed=sometimes", "usage"), + ("stm32wb55", "wdt.start 0 timeout=100 reset=0", "usage"), + ("stm32wb55", "wdt.start 0 timeout=100 pin=terminaltx", "busy"), + ("stm32wb55", "wdt.start 0 timeout=100 pin=PA16", "pin"), + ("stm32wb55", "wdt.start 0 timeout=517 pin=terminaltx", "range"), + ("stm32wb55", "wdt.start 0 timeout=517 pin=PE0", "range"), + ("stm32wb55", "wdt.start 0 timeout=100 pin=PE0", "pin"), + ("stm32wb55", "wdt.feed 0", "notopen"), + ("stm32wb55", "wdt.feed 1", "range"), + ("stm32wba55", "wdt.start 0 timeout=331", "range"), + ("stm32wba55", "wdt.start 0 timeout=330 pin=gpio0", "ok"), + ], +) +def test_watchdog_start_validation(family, line, expected): + terminal, _, _ = timed_terminal(family=family) + assert reason(terminal, line) == expected + + +def test_watchdog_auto_feed_warns_every_period_and_never_resets(): + terminal, firmware, clock = timed_terminal() + terminal.wait_boot(0.5) + terminal.command("wdt.start 0 timeout=20 pin=gpio0") + assert reason(terminal, "gpio.cfg gpio0 in") == "busy", "the toggle pin stays claimed" + assert reason(terminal, "wdt.start 0 timeout=20") == "busy" + period = expect.wwdg_warning_period(20, 64_000_000) + assert period == pytest.approx(0.032256) + clock.now += 10 * period + period / 2 + terminal.pump() + warnings = terminal.drain_events("wdt") + assert [event.as_int("warning") for event in warnings] == list(range(1, 11)) + assert all(event.as_int("index") == 0 for event in warnings) + assert firmware.gpio_levels["PC6"] == 0, "ten toggles" + assert not terminal.events("boot") + assert reason(terminal, "wdt.feed 0") == "ok" + + +def test_watchdog_manual_feed_then_reset(): + terminal, firmware, clock = timed_terminal() + terminal.wait_boot(0.5) + terminal.command("wdt.start 0 timeout=100 feed=manual") + period = expect.wwdg_warning_period(100, 64_000_000) + for _ in range(5): + clock.now += period * 0.9 + terminal.command("wdt.feed 0") + assert not terminal.drain_events("wdt") + clock.now += period + terminal.pump() + assert [event.as_int("warning") for event in terminal.drain_events("wdt")] == [1] + assert not terminal.events("boot"), "the reset comes one counter tick after the warning" + clock.now += expect.wwdg_tick(64_000_000, 32) + boot = terminal.wait_boot(0.5) + assert boot["reset"] == "wwdg" + assert firmware.watchdog is None + assert reason(terminal, "wdt.feed 0") == "notopen" + assert terminal.command("info")["reset"] == "wwdg" + + +def test_watchdog_feed_after_warning_cancels_reset(): + terminal, _, clock = timed_terminal() + terminal.wait_boot(0.5) + terminal.command("wdt.start 0 timeout=100 feed=manual") + clock.now += expect.wwdg_warning_period(100, 64_000_000) + terminal.pump() + assert terminal.drain_events("wdt") + terminal.command("wdt.feed 0") + clock.now += expect.wwdg_tick(64_000_000, 32) * 2 + terminal.pump() + assert not terminal.events("boot") + + +@pytest.mark.parametrize(("family", "pclk1"), [("stm32wb55", 64_000_000), ("stm32wba55", 100_000_000)]) +def test_watchdog_reset_cuts_the_warning_line(family, pclk1): + """At 5 ms the counter tick between the warning and the reset is shorter than the `EVT wdt` line: the host gets + a fragment, then the boot banner; a feed within that tick lets the line complete.""" + terminal, _, clock = timed_terminal(family=family) + terminal.wait_boot(0.5) + prescaler = expect.wwdg_prescaler_for(5, pclk1) + assert not expect.wwdg_warning_outruns_reset(5, pclk1, 921600) + terminal.command("wdt.start 0 timeout=5 feed=manual") + clock.now += expect.wwdg_warning_period(5, pclk1) + terminal.pump() + assert not terminal.events("wdt"), "the line is still being sent" + clock.now += expect.wwdg_tick(pclk1, prescaler) + assert terminal.wait_boot(0.5)["reset"] == "wwdg" + assert all("warning=" not in event.raw for event in terminal.drain_events("wdt")) + terminal.command("wdt.start 0 timeout=5 feed=manual") + clock.now += expect.wwdg_warning_period(5, pclk1) + terminal.pump() + terminal.command("wdt.feed 0") + assert [event.as_int("warning") for event in terminal.drain_events("wdt")] == [1] + clock.now += expect.wwdg_tick(pclk1, prescaler) * 2 + terminal.pump() + assert not terminal.events("boot") + + +# error ordering + + +def test_argument_errors_come_before_busy(): + """PROTOCOL.md: `usage`, `range`, `pin` and `unsupported` are reported before `ERR busy`.""" + terminal, _ = make_terminal() + terminal.command("spi.open 1 clk=PA5 mosi=PA7 miso=PA6") + terminal.command("uart.open 1 lp=1") + terminal.command("pwm.open 1 channels=1") + terminal.command("qei.open 2") + terminal.command("adc.open 1 pins=PC3") + terminal.command("wdt.start 0 timeout=500") + cases = [ + ("spi.open 1 clk=PA5 mosi=PA7", "usage"), + ("spi.open 3 clk=PA5 mosi=PA7 miso=PA6", "range"), + ("spi.open 1 clk=PB0 mosi=PA7 miso=PA6", "pin"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 baud=1", "range"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6", "busy"), + ("uart.open 1 lp=1 baud=300", "range"), + ("uart.open 1 lp=1 duplex=1", "unsupported"), + ("uart.open 1 lp=1 tx=PB0 rx=PA3", "pin"), + ("uart.open 1 lp=1", "busy"), + ("pwm.open 2 channels=1 dead=100", "unsupported"), + ("pwm.open 2 channels=1 freq=0", "range"), + ("pwm.open 16 pins=PB0", "pin"), + ("pwm.open 16 channels=1", "busy"), + ("qei.open 16", "unsupported"), + ("qei.open 1 a=PA9 b=PA8", "pin"), + ("qei.open 1 a=PA8 b=PA9", "busy"), + ("adc.open 1 pins=PB0", "pin"), + ("adc.open 1 pins=PC2 timer=16", "unsupported"), + ("adc.open 1 pins=PC2", "busy"), + ("wdt.start 0 timeout=600", "range"), + ("wdt.start 0 timeout=100 feed=never", "usage"), + ("wdt.start 0 timeout=100", "busy"), + ] + for line, expected in cases: + assert reason(terminal, line) == expected, line diff --git a/validation/host/tests/unit/test_firmware.py b/validation/host/tests/unit/test_firmware.py new file mode 100644 index 00000000..3c92ef7d --- /dev/null +++ b/validation/host/tests/unit/test_firmware.py @@ -0,0 +1,204 @@ +import pytest +from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal + +from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS, FakeFirmware, FakeSerial +from hal_st_validation.firmware import Firmware + + +@pytest.fixture +def fake(): + return FakeFirmware() + + +@pytest.fixture +def fw(fake): + return Firmware(FirmwareTerminal(serial=FakeSerial(fake, chunk=4), timeout=0.5), WB55_PINS) + + +@pytest.fixture +def fake_wba(): + return FakeFirmware(family="stm32wba55") + + +@pytest.fixture +def fw_wba(fake_wba): + return Firmware(FirmwareTerminal(serial=FakeSerial(fake_wba, chunk=4), timeout=0.5), WBA55_PINS) + + +def last(fake): + return fake.received[-1] + + +def test_system(fw, fake): + fw.system.ping() + info = fw.system.info() + assert info.board == "NUCLEO-WB55RG" + assert info.family == "stm32wb55" + assert info.sysclk == 64_000_000 + assert info.reset == "pin" + assert info.uid == fake.uid + assert fw.system.pins() == WB55_PINS + fw.system.delay(5) + assert last(fake) == "delay 5" + boot = fw.system.reset(timeout=1.0) + assert boot.reset == "sw" + assert boot.board == "NUCLEO-WB55RG" + + +def test_system_wba55(fw_wba): + info = fw_wba.system.info() + assert (info.board, info.family, info.sysclk) == ("NUCLEO-WBA55CG", "stm32wba55", 100_000_000) + assert fw_wba.system.pins() == WBA55_PINS + + +def test_gpio_resolves_aliases_and_tracks_release(fw, fake): + fw.gpio.cfg("led0", "out", drive="fast") + fw.gpio.set("led0", 1) + assert fake.received[-2:] == ["gpio.cfg PB0 out drive=fast", "gpio.set PB0 1"] + assert fw.gpio.get("PB0") == 1 + fw.gpio.cfg("gpio0", "in", pull="up") + assert last(fake) == "gpio.cfg PC6 in pull=up" + fw.gpio.irq("gpio0", "both", type="immediate") + assert last(fake) == "gpio.irq PC6 both type=immediate" + fake.gpio_counts["PC6"] = 3 + assert fw.gpio.count("gpio0", clear=True) == 3 + assert last(fake) == "gpio.count PC6 clear=1" + fw.gpio.pulse("led0", 3, 10) + assert last(fake) == "gpio.pulse PB0 3 10" + assert fw.open_instances == [("gpio", "PB0"), ("gpio", "PC6")] + fw.gpio.release("gpio0") + assert fw.open_instances == [("gpio", "PB0")] + fw.close_all() + assert last(fake) == "gpio.release PB0" + assert fw.open_instances == [] + + +@pytest.mark.parametrize( + ("kwargs", "line"), + [ + ({"channels": [1, 2]}, "pwm.open 1 channels=1,2"), + ({"pins": ["tim1ch1", ("tim1ch2", "tim1ch2n"), (None, "tim1ch3n")]}, "pwm.open 1 pins=PA8,PA9:PB8,-:PB9"), + ({"channels": [3], "pins": [("PA10", None)]}, "pwm.open 1 channels=3 pins=PA10:-"), + ({"channels": [1], "freq": 20000, "mode": "center", "prescaler": 63}, "pwm.open 1 channels=1 freq=20000 mode=center prescaler=63"), + ({"channels": [1], "pins": [("PA8", "PA7")], "dead": 500}, "pwm.open 1 channels=1 pins=PA8:PA7 dead=500"), + ({"channels": [1], "dead": "off", "sync": True}, "pwm.open 1 channels=1 dead=off sync=1"), + ({"channels": [1], "inv": True, "invn": False, "idle": True, "idlen": False}, "pwm.open 1 channels=1 inv=1 invn=0 idle=1 idlen=0"), + ({"channels": [1], "brk": "tim1bkin", "brkpol": "low", "brkauto": True}, "pwm.open 1 channels=1 brk=PB12 brkpol=low brkauto=1"), + ], +) +def test_pwm_open_formatting(fw, fake, kwargs, line): + assert fw.pwm.open(1, **kwargs) == 64_000_000 // (kwargs.get("prescaler", 0) + 1) + assert last(fake) == line + fw.pwm.close(1) + assert fw.open_instances == [] + + +def test_pwm_commands(fw, fake): + fw.pwm.open(2, channels=[1, 2, 3, 4], freq=1000) + fw.pwm.duty(2, 12.5, 100, 0, 33.33333) + assert last(fake) == "pwm.duty 2 12.5 100 0 33.3333" + fw.pwm.duty(2, 50) + assert last(fake) == "pwm.duty 2 50" + fw.pwm.freq(2, 2000) + assert last(fake) == "pwm.freq 2 2000" + fw.pwm.stop(2) + assert last(fake) == "pwm.stop 2" + with pytest.raises(ValueError): + fw.pwm.duty(2) + with pytest.raises(FirmwareError) as error: + fw.pwm.open(1, channels=[1]) + assert error.value.reason == "busy" + assert fw.open_instances == [("pwm", 2)] + + +def test_close_all_ignores_notopen(fw, fake): + fw.uart.open(1, lp=True) + fake.opened.clear() + assert fw.close_all() == [] + + +def test_close_all_reports_other_failures(fw, fake): + fw.track(("pwm", 5), "pwm.close", 99) + assert fw.close_all() == ["pwm.close 99: range"] + + +def test_uart_formatting(fw, fake): + fw.uart.open( + 1, lp=True, tx="lpuart1tx", rx="lpuart1rx", rts="lpuart1rts", cts="lpuart1cts", baud=921600, parity="even", flow="rtscts", dma=True + ) + assert last(fake) == "uart.open 1 lp=1 tx=PA2 rx=PA3 rts=PB12 cts=PA6 baud=921600 parity=even flow=rtscts dma=1" + fw.uart.send(1, b"\x01\xff") + assert last(fake) == "uart.send 1 01ff" + fake.uart_rx[1] += b"\xaa" + assert fw.uart.recv(1, timeout=10, len=1) == b"\xaa" + assert last(fake) == "uart.recv 1 timeout=10 len=1" + assert fw.uart.recv(1) == b"" + with pytest.raises(ValueError): + fw.uart.send(1, b"") + fw.uart.close(1) + assert fw.open_instances == [] + + +def test_uart_variant_formatting(fw_wba, fake_wba): + fw_wba.uart.open(2, tx="usart2tx", rx="usart2rx", swap=True, duplex=True) + assert last(fake_wba) == "uart.open 2 tx=PB0 rx=PA11 swap=1 duplex=1" + fw_wba.uart.close(2) + fw_wba.uart.open(2, tx="usart2tx", rx="usart2rx", rts="usart2rts", flow="rts", sync=True) + assert last(fake_wba) == "uart.open 2 tx=PB0 rx=PA11 rts=PB1 flow=rts sync=1" + + +def test_spi_formatting(fw, fake): + fw.spi.open(1, clk="spi1clk", mosi="spi1mosi", miso="spi1miso", cs="spi1cs", baud=4000000, mode=3, dma=True) + assert last(fake) == "spi.open 1 clk=PA5 mosi=PA7 miso=PA6 cs=PA4 baud=4000000 mode=3 dma=1" + fake.spi_miso = 0xFF + assert fw.spi.xfer(1, b"", rx=2) == b"\xff\xff" + assert last(fake) == "spi.xfer 1 - rx=2" + assert fw.spi.xfer(1, b"\x12", continue_=True) == b"\xff" + assert last(fake) == "spi.xfer 1 12 continue=1" + assert fw.spi.xfer(1, b"\x12\x34", rx=0) == b"" + + +def test_adc_formatting(fw, fake): + fw.adc.open(1, pins=["ain4", "pc2", "ain4"], sampling=640.5) + assert last(fake) == "adc.open 1 pins=PC3,PC2,PC3 sampling=640.5" + fake.adc_codes["PC3"] = 1234 + fake.adc_codes["PC2"] = 99 + assert fw.adc.measure(1, n=2) == [1234, 99, 1234] * 2 + assert last(fake) == "adc.measure 1 n=2" + fw.adc.close(1) + fw.adc.open(1, pins=["ain1"], sampling="2.5", timer=2, rate=10000) + assert last(fake) == "adc.open 1 pins=PC0 sampling=2.5 timer=2 rate=10000" + assert fw.open_instances == [("adc", 1)] + + +def test_qei_formatting(fw, fake): + fw.qei.open(2, a="qei2a", b="qei2b", idx="qei2idx", res=100, offset=5, inva=True, invb=False, cap="b", filter=3, vel=500) + assert last(fake) == "qei.open 2 a=PA15 b=PB3 idx=PC6 res=100 offset=5 inva=1 invb=0 cap=b filter=3 vel=500" + reading = fw.qei.read(2) + assert (reading.pos, reading.dir, reading.speed, reading.res) == (5, "fwd", 0, 100) + fake.gpio_levels["PC6"] = 1 + assert fw.qei.index(2) == 1 + assert last(fake) == "qei.index 2" + fw.qei.close(2) + fw.qei.open(1, lp=True, a="lptim1in1", b="lptim1in2", filter=4, vel="off") + assert last(fake) == "qei.open 1 lp=1 a=PC0 b=PC2 filter=4 vel=off" + + +def test_watchdog_formatting(fw, fake): + fake.clock = lambda: 0.0 + fw.wdt.start(0, timeout=250, feed="manual", pin="gpio0") + assert last(fake) == "wdt.start 0 timeout=250 feed=manual pin=PC6" + fw.wdt.feed(0) + assert last(fake) == "wdt.feed 0" + assert fw.open_instances == [], "a started watchdog cannot be closed" + fake.event("EVT wdt index=0 warning=1") + assert fw.wdt.wait_warning(0, timeout=0.5).as_int("warning") == 1 + fake.event("EVT wdt index=0 warning=2") + fw.system.ping() + assert [event.as_int("warning") for event in fw.wdt.warnings(0)] == [2] + + +def test_unsupported_groups_through_raw_commands(fw): + with pytest.raises(FirmwareError) as error: + fw.command("can.open", 1, bitrate=500000) + assert error.value.reason == "unsupported" diff --git a/validation/host/tests/unit/test_known_gaps.py b/validation/host/tests/unit/test_known_gaps.py new file mode 100644 index 00000000..6dfb8f9c --- /dev/null +++ b/validation/host/tests/unit/test_known_gaps.py @@ -0,0 +1,167 @@ +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from hal_st_validation.config import ConfigError, KnownGap, known_gap_outcome, load_board, parse_board + +HOST_DIR = Path(__file__).resolve().parents[2] +BOARDS = ["nucleo_wb55rg", "nucleo_wba55cg"] + + +@pytest.mark.parametrize( + ("pattern", "test_id", "matches"), + [ + ("test_pwm.py::*", "test_pwm.py::test_waveform[tim1-freq=100]", True), + ("test_pwm.py::*", "test_pwm.py::test_open_errors", True), + ("test_pwm.py::*", "test_qei.py::test_speed", False), + ("test_pwm.py::test_waveform*", "test_pwm.py::test_waveform[tim1]", True), + ("test_pwm.py::test_waveform", "test_pwm.py::test_waveform[tim1]", False), + ("test_adc.py::test_sampling_time[*sampling=3.5*]", "test_adc.py::test_sampling_time[sampling=3.5-trigger=timer]", True), + ("test_adc.py::test_sampling_time[*sampling=3.5*]", "test_adc.py::test_sampling_time[sampling=1.5-trigger=timer]", False), + ("test_adc.py::test_sampling_time[*sampling=3.5*]", "test_adc.py::test_sampling_time[sampling=315-trigger=timer]", False), + ("test_spi.py::test_receive_only_first[*variant=sync*]", "test_spi.py::test_receive_only_first[spi1-variant=sync]", True), + ("test_spi.py::test_receive_only_first[*variant=sync*]", "test_spi.py::test_receive_only_first[spi1-variant=dma]", False), + ("test_?.py::t", "test_a.py::t", True), + ("test_?.py::t", "test_ab.py::t", False), + ], +) +def test_patterns_match_whole_ids_with_literal_brackets(pattern, test_id, matches): + """`*` and `?` are the only wildcards: the brackets of parameter ids are literal, unlike fnmatch.""" + assert KnownGap((pattern,), "reason").matches(test_id) is matches + + +def test_outcome_skips_hanging_gaps_and_xfails_the_others(): + hangs = KnownGap(("test_pwm.py::*",), "PwmStm.cpp:256 - pwm.open aborts", hangs=True) + fails = KnownGap(("test_pwm.py::test_waveform[*mode=center*]",), "PwmStm.cpp:381 - centre period", hangs=False) + gaps = [hangs, fails] + assert known_gap_outcome(gaps, "test_qei.py::test_speed") is None + assert known_gap_outcome(gaps, "test_pwm.py::test_open_errors") == ( + "skip", + "known gap (firmware aborts/hangs): PwmStm.cpp:256 - pwm.open aborts", + ) + assert known_gap_outcome(gaps, "test_pwm.py::test_waveform[tim1-mode=center]")[0] == "skip" + kind, reason = known_gap_outcome(gaps, "test_pwm.py::test_waveform[tim1-mode=center]", run_hanging=True) + assert kind == "xfail" + assert reason == "known gap: PwmStm.cpp:256 - pwm.open aborts; PwmStm.cpp:381 - centre period" + assert known_gap_outcome([fails], "test_pwm.py::test_waveform[tim1-mode=center]") == ( + "xfail", + "known gap: PwmStm.cpp:381 - centre period", + ) + assert known_gap_outcome([fails], "test_pwm.py::test_waveform[tim1-mode=edge]") is None + + +def _raw(gaps): + return {"board": "x", "family": "stm32wb55", "known_gaps": gaps} + + +def test_parsing(): + board = parse_board( + _raw([{"tests": ["test_a.py::*"], "reason": " a.cpp:1 - b ", "hangs": True}, {"tests": ["test_b.py::t"], "reason": "c"}]) + ) + assert board.known_gaps == (KnownGap(("test_a.py::*",), "a.cpp:1 - b", True), KnownGap(("test_b.py::t",), "c", False)) + assert parse_board({"board": "x", "family": "stm32wb55"}).known_gaps == () + + +@pytest.mark.parametrize( + "gaps", + [ + {"tests": ["test_a.py::*"], "reason": "r"}, + [{"tests": [], "reason": "r"}], + [{"tests": "test_a.py::*", "reason": "r"}], + [{"tests": ["test_a.py"], "reason": "r"}], + [{"tests": ["test_a.py::*"]}], + [{"tests": ["test_a.py::*"], "reason": " "}], + [{"tests": ["test_a.py::*"], "reason": "r", "hangs": "yes"}], + [{"tests": ["test_a.py::*"], "reason": "r", "xfail": True}], + ], +) +def test_invalid_entries_are_rejected(gaps): + with pytest.raises(ConfigError): + parse_board(_raw(gaps)) + + +@pytest.mark.parametrize("name", BOARDS) +def test_board_files_cite_code(name): + """Every gap names its source (`file:line`) and the symptom.""" + gaps = load_board(name).known_gaps + assert gaps + for gap in gaps: + assert ".cpp:" in gap.reason and " - " in gap.reason, gap.reason + + +@pytest.fixture(scope="module") +def collected(): + """The HIL test ids (relative to tests/hil) of each board at the default depth.""" + ids = {} + for name in BOARDS: + result = subprocess.run( + [sys.executable, "-m", "pytest", "tests/hil", "--co", "-q", "-p", "no:cacheprovider", "--board", name], + cwd=HOST_DIR, + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stdout + result.stderr + ids[name] = [line.removeprefix("tests/hil/") for line in result.stdout.splitlines() if line.startswith("tests/hil/")] + return ids + + +@pytest.mark.parametrize("name", BOARDS) +def test_every_pattern_matches_a_collected_test(name, collected): + """A pattern that matches nothing is a typo or a renamed test: the gap would silently stop applying.""" + assert collected[name] + for gap in load_board(name).known_gaps: + for pattern in gap.tests: + assert any(KnownGap((pattern,), gap.reason).matches(test_id) for test_id in collected[name]), pattern + + +MARK_REPORTER = """ +def pytest_collection_finish(session): + for item in session.items: + for mark in item.iter_markers(): + if mark.name in ("skip", "xfail"): + print("GAPMARK", item.nodeid.partition("tests/hil/")[2], mark.name, mark.kwargs.get("reason", "")) +""" + + +def collected_marks(tmp_path, board, *options): + """The skip/xfail marks conftest.py puts on the HIL tests, as {test id: [(mark, reason), ...]}.""" + (tmp_path / "gap_mark_reporter.py").write_text(MARK_REPORTER, encoding="utf-8") + environment = {**os.environ, "PYTHONPATH": os.pathsep.join(filter(None, [str(tmp_path), os.environ.get("PYTHONPATH")]))} + command = [sys.executable, "-m", "pytest", "tests/hil", "--co", "-q", "-p", "no:cacheprovider", "-p", "gap_mark_reporter"] + result = subprocess.run( + [*command, "--board", board, *options], cwd=HOST_DIR, capture_output=True, text=True, env=environment, check=False + ) + assert result.returncode == 0, result.stdout + result.stderr + marks = {} + for line in result.stdout.splitlines(): + if line.startswith("GAPMARK "): + _, test_id, name, reason = line.split(" ", 3) + marks.setdefault(test_id, []).append((name, reason)) + return marks + + +def test_conftest_applies_the_gaps_of_the_board(tmp_path): + marks = collected_marks(tmp_path, "nucleo_wba55cg", "--port", "nosuchport") + receive_only = {test_id: found for test_id, found in marks.items() if test_id.startswith("test_spi.py::test_receive_only_first[")} + assert [name for name, _ in receive_only["test_spi.py::test_receive_only_first[spi1-variant=interrupt]"]] == ["xfail"] + assert "SpiMasterStm.cpp:160-168" in receive_only["test_spi.py::test_receive_only_first[spi1-variant=interrupt]"][0][1] + sync = receive_only["test_spi.py::test_receive_only_first[spi1-variant=sync]"] + assert sync == [("skip", sync[0][1])] and sync[0][1].startswith("known gap (firmware aborts/hangs): ") + assert "test_spi.py::test_receive_only_first[spi1-variant=dma]" not in marks + assert "test_pwm.py::test_open_errors" not in marks, "argument errors never reach the driver" + assert [name for name, _ in marks["test_pwm.py::test_duty_errors"]] == ["skip"] + + +def test_run_known_gaps_turns_skips_into_expected_failures(tmp_path): + marks = collected_marks(tmp_path, "nucleo_wb55rg", "--port", "nosuchport", "--run-known-gaps") + assert [name for name, _ in marks["test_pwm.py::test_duty_errors"]] == ["xfail"] + assert not any(name == "skip" and "known gap" in reason for found in marks.values() for name, reason in found) + + +def test_fake_ignores_the_gaps(tmp_path): + marks = collected_marks(tmp_path, "nucleo_wba55cg", "--fake") + assert not any("known gap" in reason for found in marks.values() for _, reason in found) diff --git a/validation/host/tests/unit/test_pairwise.py b/validation/host/tests/unit/test_pairwise.py new file mode 100644 index 00000000..40bdb123 --- /dev/null +++ b/validation/host/tests/unit/test_pairwise.py @@ -0,0 +1,91 @@ +import itertools + +import pytest + +from hal_st_validation.pairwise import combinations, covered_pairs, full_product, pairwise + +MATRIX = { + "mode": ["edge", "center"], + "div": [1, 2, 4, 8, 16, 32, 64], + "freq": [100, 1000, 10000, 20000, 50000, 200000], + "duty": [0, 12.5, 50, 90, 100], + "sync": [0, 1], +} + + +def all_pairs(dimensions, valid=lambda values: True): + names = list(dimensions) + result = set() + for combination in full_product(dimensions, valid): + for first, second in itertools.combinations(names, 2): + result.add((first, combination[first], second, combination[second])) + return result + + +def test_full_product_is_the_cartesian_product(): + result = full_product(MATRIX) + assert len(result) == 2 * 7 * 6 * 5 * 2 + assert result[0] == {"mode": "edge", "div": 1, "freq": 100, "duty": 0, "sync": 0} + assert len({tuple(combination.values()) for combination in result}) == len(result) + + +def test_pairwise_covers_every_pair(): + result = pairwise(MATRIX) + assert covered_pairs(result, list(MATRIX)) == all_pairs(MATRIX) + assert len(result) < len(full_product(MATRIX)) / 10 + assert len(result) >= 7 * 6, "at least the product of the two largest dimensions" + + +def test_pairwise_is_deterministic_and_ordered(): + first = pairwise(MATRIX) + assert first == pairwise(MATRIX) + assert all(list(combination) == list(MATRIX) for combination in first) + + +def test_pairwise_respects_constraints(): + matrix = {"baud": [600, 9600, 921600], "parity": ["none", "even", "odd"], "stop": [1, 2], "variant": ["interrupt", "dma", "sync"]} + + def valid(values): + return values.get("variant") != "sync" or (values.get("parity", "none") == "none" and values.get("stop", 1) == 1) + + result = pairwise(matrix, valid) + assert all(valid(combination) for combination in result) + assert covered_pairs(result, list(matrix)) == all_pairs(matrix, valid) + assert {"baud": 921600, "parity": "none", "stop": 1, "variant": "sync"} in result + + +def test_constraint_needing_backtracking(): + """A greedy choice for `a` can make `c` impossible; the pair is still covered through the fallback search.""" + matrix = {"a": [0, 1], "b": [0, 1], "c": [0, 1]} + + def valid(values): + if "a" in values and "c" in values: + return values["a"] == values["c"] + return True + + result = pairwise(matrix, valid) + assert covered_pairs(result, list(matrix)) == all_pairs(matrix, valid) + + +def test_small_and_degenerate_matrices(): + assert pairwise({}) == [{}] + assert pairwise({"only": [1, 2, 3]}) == [{"only": 1}, {"only": 2}, {"only": 3}] + assert pairwise({"a": [1, 2], "b": [3]}) == [{"a": 1, "b": 3}, {"a": 2, "b": 3}] + assert pairwise({"a": [1, 2], "b": []}) == [] + two = {"x": [1, 2, 3], "y": ["p", "q"]} + assert pairwise(two) == full_product(two), "two dimensions: pairwise is the full product" + + +def test_unhashable_values(): + matrix = {"dead": [250, [500, 1000]], "inversion": [[0, 0], [1, 1]], "sync": [0, 1]} + result = pairwise(matrix) + assert len(covered_pairs(result, list(matrix))) == 12 + + +@pytest.mark.parametrize("depth", ["quick", "full"]) +def test_combinations_by_depth(depth): + result = combinations(MATRIX, depth) + expected = pairwise(MATRIX) if depth == "quick" else full_product(MATRIX) + assert result == expected + with pytest.raises(ValueError): + combinations(MATRIX, "deep") diff --git a/validation/host/tests/unit/test_protocol.py b/validation/host/tests/unit/test_protocol.py new file mode 100644 index 00000000..b0ac4d06 --- /dev/null +++ b/validation/host/tests/unit/test_protocol.py @@ -0,0 +1,126 @@ +import pytest +from ad3_waveforms_bench.protocol import ProtocolError, parse_response + +from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS +from hal_st_validation.protocol import ERROR_REASONS, PIN_ALIASES, is_alias, is_pin, normalize_pin, parse_pin_map, pin_parts + + +@pytest.mark.parametrize("pin", ["PA0", "pa15", "PB9", "PC10", "PH3", "PK15", "pe4"]) +def test_valid_pins(pin): + assert is_pin(pin) + + +@pytest.mark.parametrize("pin", ["PA16", "PA05", "PL0", "PZ1", "P1", "PA", "A1", "PA1x", "PA-1", "led0", ""]) +def test_invalid_pins(pin): + assert not is_pin(pin) + + +def test_normalize_pins(): + assert normalize_pin("pa15") == "PA15" + assert normalize_pin("Pc0") == "PC0" + assert normalize_pin("PK15") == "PK15" + assert normalize_pin("tim1ch1") == "tim1ch1", "a generic alias passes through unresolved" + assert normalize_pin("TIM1CH1", {"tim1ch1": "PA8"}) == "PA8" + assert normalize_pin("qei2idx", {"tim1ch1": "PA8"}) == "qei2idx" + assert normalize_pin("gpio0", {"gpio0": "pc6"}) == "PC6", "table entries are normalised too" + for bad in ("PA16", "PL0", "PA05", "nosuchalias", "phasea"): + with pytest.raises(ProtocolError): + normalize_pin(bad) + with pytest.raises(ProtocolError): + normalize_pin("qei2idx", {"tim1ch1": "PA8"}, strict=True) + + +def test_pin_parts(): + assert pin_parts("PA15") == ("A", 15) + assert pin_parts("ph3") == ("H", 3) + with pytest.raises(ProtocolError): + pin_parts("led0") + + +@pytest.mark.parametrize( + "alias", + [ + "terminaltx", + "terminalrx", + "ain0", + "ain19", + "tim1ch1", + "tim17ch4", + "tim1ch3n", + "tim17ch1n", + "tim16bkin", + "qei1a", + "qei17idx", + "lptim1in1", + "lptim2in2", + "spi3cs", + "spi1miso", + "usart2rts", + "usart3cts", + "lpuart1tx", + "lpuart1cts", + "led7", + "gpio15", + "sw1", + "sw3", + ], +) +def test_generic_aliases(alias): + assert is_alias(alias) + assert is_alias(alias.upper()) + + +@pytest.mark.parametrize( + "name", + [ + "ain20", + "tim0ch1", + "tim18ch1", + "tim1ch5", + "tim1ch4n", + "tim1ch0", + "qei0a", + "qei1c", + "lptim3in1", + "lptim1in3", + "spi0clk", + "spi4clk", + "spi1sck", + "usart4tx", + "uart1tx", + "lpuart2tx", + "led8", + "gpio16", + "sw0", + "sw4", + "m0pwm0", + "can0rx", + "phasea", + "button1", + ], +) +def test_not_aliases(name): + assert not is_alias(name) + + +def test_alias_scheme_size(): + """terminal 2, ain 20, timers 17 * (4 + 3 + 1), qei 17 * 3, lptim 4, spi 12, usart 12, lpuart 4, led 8, gpio 16, sw 3.""" + assert len(PIN_ALIASES) == 2 + 20 + 17 * 8 + 17 * 3 + 4 + 12 + 12 + 4 + 8 + 16 + 3 + + +@pytest.mark.parametrize("table", [WB55_PINS, WBA55_PINS]) +def test_board_tables_use_the_scheme(table): + assert all(is_alias(alias) for alias in table) + assert all(is_pin(pin) and normalize_pin(pin) == pin for pin in table.values()) + + +def test_error_reasons(): + assert {"usage", "pin", "busy", "notopen", "unsupported", "range", "timeout", "failed"} == ERROR_REASONS + + +def test_pin_map(): + response = parse_response("OK terminaltx=PB6,terminalrx=PB7,ain4=PC3,led0=PB0") + assert parse_pin_map(response) == {"terminaltx": "PB6", "terminalrx": "PB7", "ain4": "PC3", "led0": "PB0"} + spaced = parse_response("OK ain2=PA7 ain3=pa6") + assert parse_pin_map(spaced) == {"ain2": "PA7", "ain3": "PA6"} + assert parse_pin_map(parse_response("OK")) == {} diff --git a/validation/host/tests/unit/test_terminal_alignment.py b/validation/host/tests/unit/test_terminal_alignment.py new file mode 100644 index 00000000..3280db1f --- /dev/null +++ b/validation/host/tests/unit/test_terminal_alignment.py @@ -0,0 +1,61 @@ +import time + +from ad3_waveforms_bench.terminal import FirmwareTerminal + +from hal_st_validation.firmware import quiesce + + +class SlowLink: + """A serial link that answers every command after `delay` seconds and can deliver a stray reply late.""" + + def __init__(self, delay: float) -> None: + self.delay = delay + self.scheduled: list[tuple[float, bytes]] = [] + + def later(self, data: bytes, delay: float) -> None: + self.scheduled.append((time.monotonic() + delay, data)) + + def _due(self) -> bytes: + now = time.monotonic() + ready = [data for at, data in self.scheduled if at <= now] + self.scheduled = [(at, data) for at, data in self.scheduled if at > now] + return b"".join(ready) + + @property + def in_waiting(self) -> int: + return 0 + + def read(self, size: int = 1) -> bytes: + data = self._due() + if not data: + time.sleep(0.005) + return data + + def write(self, data: bytes) -> int: + line = data.decode().strip("\r\x03") + if line: + reply = {"ping": b"OK\r\n> ", "info": b"OK board=NUCLEO-WB55RG\r\n> "}.get(line, b"ERR usage\r\n> ") + self.later(reply, self.delay) + return len(data) + + def close(self) -> None: + pass + + +def test_late_stray_reply_is_dropped_after_quiesce(): + link = SlowLink(delay=0.02) + terminal = FirmwareTerminal(serial=link, timeout=1.0) + terminal.sync(timeout=1.0) + link.later(b"ERR usage\r\n> ", 0.15) + assert quiesce(terminal, quiet=0.3) + assert terminal.command("info")["board"] == "NUCLEO-WB55RG" + + +def test_without_quiesce_a_late_reply_is_taken_by_the_next_command(): + """The failure seen through port-bridge: the stray reply lands on the next command.""" + link = SlowLink(delay=0.2) + terminal = FirmwareTerminal(serial=link, timeout=1.0) + terminal.sync(timeout=1.0) + link.later(b"ERR usage\r\n> ", 0.05) + response = terminal.command("info", check=False) + assert not response.ok and response.reason == "usage" From 7ff9dbcedcf6fab55a652f3480e3c2e592fd17d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:52:03 +0000 Subject: [PATCH 06/57] fix(pwm): make PwmStm constructible and reset timer state between users - ConfigureTimeBase initialised the timer with Period = 0, which fails IS_TIM_PERIOD under USE_FULL_ASSERT and aborted every construction. It now initialises with Period = 1 and loads the real period in Start. - The counter direction is written only after CMS and SMS are cleared, as CR1.DIR ignores writes while centre-aligned or in encoder mode. - Centre-aligned: ARR is ticks / 2 (period 2 x ARR) instead of ticks / 2 - 1, and duty is scaled to ARR; edge-aligned down-counting compensates the extra active tick. - TRGO (MMS/MMS2), BDTR and the BKIN source are rewritten on every construction, so dead time, break, idle and trigger settings of an earlier user no longer survive. BKINP stays non-inverted, so an active-low break is no longer inverted twice. - An update event on the first Start loads the preloaded ARR/CCR. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/PwmStm.cpp | 85 ++++++++++++++++++++++++++----------- hal_st/stm32fxxx/PwmStm.hpp | 1 + 2 files changed, 61 insertions(+), 25 deletions(-) diff --git a/hal_st/stm32fxxx/PwmStm.cpp b/hal_st/stm32fxxx/PwmStm.cpp index 45b4418c..c8770f7a 100644 --- a/hal_st/stm32fxxx/PwmStm.cpp +++ b/hal_st/stm32fxxx/PwmStm.cpp @@ -229,17 +229,20 @@ namespace hal ConfigureTimeBase(); - if (config.triggerOutput) + // Neither HAL_TIM_PWM_Init nor HAL_TIM_PWM_DeInit touches CR2.MMS, so an earlier + // user's trigger output would otherwise keep firing. + if (config.triggerOutput || IS_TIM_MASTER_INSTANCE(instance)) ConfigureTriggerOutput(); for (std::size_t i = 0; i != channels.size(); ++i) ConfigureChannel(channels[i], channelConfigs[i]); - if (config.deadTime || config.breakInput || idleStateRequested) + // Neither HAL_TIM_PWM_DeInit nor gating the clock resets BDTR or the break input + // sources, so they are rewritten even when nothing is requested. + if (IS_TIM_BREAK_INSTANCE(instance)) ConfigureBreakAndDeadTime(); - if (breakPin) - ConfigureBreakInputSource(); + ConfigureBreakInputSource(); } PwmStmBase::~PwmStmBase() @@ -252,8 +255,9 @@ namespace hal void PwmStmBase::ConfigureTimeBase() { handle.Init.Prescaler = config.prescaler; - handle.Init.CounterMode = static_cast(config.alignment); - handle.Init.Period = 0; + handle.Init.CounterMode = TIM_COUNTERMODE_UP; + // The shortest period HAL_TIM_PWM_Init accepts; StartImpl loads the real one. + handle.Init.Period = 1; handle.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; handle.Init.AutoReloadPreload = config.preloadEnabled ? TIM_AUTORELOAD_PRELOAD_ENABLE : TIM_AUTORELOAD_PRELOAD_DISABLE; handle.Init.RepetitionCounter = 0; @@ -267,12 +271,22 @@ namespace hal clockSource.ClockSource = TIM_CLOCKSOURCE_INTERNAL; result = HAL_TIM_ConfigClockSource(&handle, &clockSource); really_assert(result == HAL_OK); + + // CR1.DIR ignores writes while centre-aligned or in encoder mode, either of which an + // earlier user may have left, so the direction is only written once CMS and SMS are + // cleared by the two calls above. + handle.Init.CounterMode = static_cast(config.alignment); + result = HAL_TIM_PWM_Init(&handle); + really_assert(result == HAL_OK); + + // Zero marks the base frequency as not set yet. + handle.Init.Period = 0; } void PwmStmBase::ConfigureTriggerOutput() { TIM_MasterConfigTypeDef master{}; - master.MasterOutputTrigger = static_cast(*config.triggerOutput); + master.MasterOutputTrigger = static_cast(config.triggerOutput.value_or(TriggerOutput::reset)); master.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; auto result = HAL_TIMEx_MasterConfigSynchronization(&handle, &master); @@ -305,11 +319,15 @@ namespace hal { TIM_BreakDeadTimeConfigTypeDef init{}; + // The LOCK bits are write-once until reset; any other level would freeze BDTR for + // every later construction on this timer. + init.LockLevel = TIM_LOCKLEVEL_OFF; + // The idle levels only reach the pins while the timer keeps controlling them after // MOE clears, which is what the off-state selections enable. - init.OffStateRunMode = TIM_OSSR_ENABLE; - init.OffStateIDLEMode = TIM_OSSI_ENABLE; - init.LockLevel = TIM_LOCKLEVEL_OFF; + const auto offStateControl = config.deadTime || config.breakInput || idleStateRequested; + init.OffStateRunMode = offStateControl ? TIM_OSSR_ENABLE : TIM_OSSR_DISABLE; + init.OffStateIDLEMode = offStateControl ? TIM_OSSI_ENABLE : TIM_OSSI_DISABLE; init.DeadTime = config.deadTime ? EncodeDeadTime(config.deadTime->duration, TimerClockFrequency(), handle.Init.ClockDivision) : 0; if (config.breakInput) @@ -322,14 +340,14 @@ namespace hal else { init.BreakState = TIM_BREAK_DISABLE; - init.BreakPolarity = TIM_BREAKPOLARITY_HIGH; + init.BreakPolarity = TIM_BREAKPOLARITY_LOW; init.BreakFilter = 0; init.AutomaticOutput = TIM_AUTOMATICOUTPUT_DISABLE; } #if defined(TIM_BDTR_BK2E) init.Break2State = TIM_BREAK2_DISABLE; - init.Break2Polarity = TIM_BREAK2POLARITY_HIGH; + init.Break2Polarity = TIM_BREAK2POLARITY_LOW; init.Break2Filter = 0; #endif @@ -340,13 +358,18 @@ namespace hal void PwmStmBase::ConfigureBreakInputSource() { #if defined(TIM_BREAKINPUTSOURCE_BKIN) - really_assert(IS_TIM_BREAKSOURCE_INSTANCE(handle.Instance)); + really_assert(!breakPin || IS_TIM_BREAKSOURCE_INSTANCE(handle.Instance)); - // BDTR.BKE alone only arms the reaction; the external pin is a separate mux source. + if (!IS_TIM_BREAKSOURCE_INSTANCE(handle.Instance)) + return; + + // BDTR.BKE alone only arms the reaction; the external pin is a separate mux source, + // enabled out of reset. BKINP inverts the pin ahead of BDTR.BKP, so it stays + // non-inverted (the HAL's POLARITY_HIGH) and BKP alone selects the active level. TIMEx_BreakInputConfigTypeDef source{}; source.Source = TIM_BREAKINPUTSOURCE_BKIN; source.Enable = TIM_BREAKINPUTSOURCE_ENABLE; - source.Polarity = config.breakInput->activeHigh ? TIM_BREAKINPUTSOURCE_POLARITY_HIGH : TIM_BREAKINPUTSOURCE_POLARITY_LOW; + source.Polarity = TIM_BREAKINPUTSOURCE_POLARITY_HIGH; auto result = HAL_TIMEx_ConfigBreakInput(&handle, TIM_BREAKINPUT_BRK, &source); really_assert(result == HAL_OK); @@ -379,15 +402,14 @@ namespace hal really_assert(baseFrequency.Value() != 0); const auto counterClock = TimerClockFrequency() / (static_cast(config.prescaler) + 1); - auto ticksPerPeriod = counterClock / baseFrequency.Value(); - - if (IsCenterAligned(handle.Init.CounterMode)) - ticksPerPeriod /= 2; - + const auto ticksPerPeriod = counterClock / baseFrequency.Value(); really_assert(ticksPerPeriod >= 2); - really_assert(ticksPerPeriod - 1 <= MaximumCompare()); - handle.Init.Period = ticksPerPeriod - 1; + // A centre-aligned counter runs 0..ARR..0, a period of 2 x ARR ticks instead of ARR + 1. + const auto autoReload = IsCenterAligned(handle.Init.CounterMode) ? ticksPerPeriod / 2 : ticksPerPeriod - 1; + really_assert(autoReload <= MaximumCompare()); + + handle.Init.Period = autoReload; __HAL_TIM_SET_AUTORELOAD(&handle, handle.Init.Period); for (auto& channel : channels) @@ -401,10 +423,18 @@ namespace hal channel.dutyCycle = dutyCycle; + // Centre-aligned PWM is active for 2 x CCR of the 2 x ARR ticks, so ARR is the full + // scale there; edge-aligned it is ARR + 1, where CCR > ARR holds the output active. + const auto period = static_cast(handle.Init.Period) + (IsCenterAligned(handle.Init.CounterMode) ? 0 : 1); + auto counts = dutyCycle.ToCounts(period); + + // Counting down, PWM mode 1 is active while CNT <= CCR, one tick more than CCR. + if (handle.Init.CounterMode == TIM_COUNTERMODE_DOWN && counts != 0) + --counts; + // Clamped rather than taken modulo: at the widest period the full-duty value is one // past what the compare register holds, and would otherwise wrap to no output at all. - const auto period = static_cast(handle.Init.Period) + 1; - const auto compare = std::min(dutyCycle.ToCounts(period), MaximumCompare()); + const auto compare = std::min(counts, MaximumCompare()); __HAL_TIM_SET_COMPARE(&handle, TimerChannel(channel.index), static_cast(compare)); } @@ -427,9 +457,14 @@ namespace hal return; } + // With preload the counter would otherwise start on the placeholder period and the + // compare values latched at init, possibly a previous construction's last duty. + auto result = HAL_TIM_GenerateEvent(&handle, TIM_EVENTSOURCE_UPDATE); + really_assert(result == HAL_OK); + for (const auto& channel : channels) { - auto result = HAL_TIM_PWM_Start(&handle, TimerChannel(channel.index)); + result = HAL_TIM_PWM_Start(&handle, TimerChannel(channel.index)); really_assert(result == HAL_OK); if (channel.complementary) diff --git a/hal_st/stm32fxxx/PwmStm.hpp b/hal_st/stm32fxxx/PwmStm.hpp index a81bbd3b..61385d77 100644 --- a/hal_st/stm32fxxx/PwmStm.hpp +++ b/hal_st/stm32fxxx/PwmStm.hpp @@ -20,6 +20,7 @@ namespace hal enum class Alignment : uint32_t { edgeAligned = TIM_COUNTERMODE_UP, + // Cannot reach 0 %: counting down, the output is active for at least one tick per period. edgeAlignedDownCounting = TIM_COUNTERMODE_DOWN, centerAlignedDownCounting = TIM_COUNTERMODE_CENTERALIGNED1, centerAlignedUpCounting = TIM_COUNTERMODE_CENTERALIGNED2, From 349d49c6fdf2d9122ddf12e3b1f48267e563f0d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:52:03 +0000 Subject: [PATCH 07/57] fix(adc): support multi-channel sequences and TIM1 triggers on ADC4 On the WBA ADC4 (CHSELR sequencer, common sampling times): - AdcDmaMultiChannelStm passed ADC_SAMPLETIME_x where HAL_ADC_ConfigChannel takes ADC_SAMPLINGTIME_COMMON_1/2 (assert, abort). Per-channel sampling times are now mapped onto the two common groups; a sequence can use at most two distinct sampling times, and MaxChannels is 8 on ADC4. - Scan mode and NbrOfConversion are set for the sequence, and the sequencer length is written with the LL encoding instead of the rank count. - AdcStm::SelectSingleConversion restores single-channel scan and SMP1 for the single-pin, temperature and timer-triggered DMA users of the same AdcStm; the temperature path uses the common sampling time. - The ADC triggers from TIM1 TRGO2 there, so AdcTimerTriggeredBase now configures TRGO2 = update for TIM1 (new TimerBaseStm::Trigger:: TriggerOutput2). - The trigger table lacked a TIM10 slot, which shifted the triggers of TIM11 and later timers by one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/AdcDmaMultiChannelStm.cpp | 53 +++++++++++++++++++++- hal_st/stm32fxxx/AdcDmaMultiChannelStm.hpp | 4 ++ hal_st/stm32fxxx/AdcDmaStm.cpp | 1 + hal_st/stm32fxxx/AdcTimerTriggeredBase.cpp | 19 +++++++- hal_st/stm32fxxx/AnalogToDigitalPinStm.cpp | 20 ++++++++ hal_st/stm32fxxx/AnalogToDigitalPinStm.hpp | 1 + hal_st/stm32fxxx/TimerStm.cpp | 3 ++ hal_st/stm32fxxx/TimerStm.hpp | 12 +++++ 8 files changed, 111 insertions(+), 2 deletions(-) diff --git a/hal_st/stm32fxxx/AdcDmaMultiChannelStm.cpp b/hal_st/stm32fxxx/AdcDmaMultiChannelStm.cpp index c7013b7e..1066e02d 100644 --- a/hal_st/stm32fxxx/AdcDmaMultiChannelStm.cpp +++ b/hal_st/stm32fxxx/AdcDmaMultiChannelStm.cpp @@ -1,7 +1,11 @@ #include "hal_st/stm32fxxx/AdcDmaMultiChannelStm.hpp" #include "hal_st/stm32fxxx/AnalogToDigitalPinStm.hpp" #include "hal_st/stm32fxxx/DmaStm.hpp" +#include "infra/util/ReallyAssert.hpp" +#include #include +#include +#include #include #include #include DEVICE_HEADER @@ -16,6 +20,39 @@ namespace ADC_REGULAR_RANK_13, ADC_REGULAR_RANK_14, ADC_REGULAR_RANK_15, ADC_REGULAR_RANK_16 #endif }; + + constexpr std::array sequencerLength = { + LL_ADC_REG_SEQ_SCAN_DISABLE, LL_ADC_REG_SEQ_SCAN_ENABLE_2RANKS, LL_ADC_REG_SEQ_SCAN_ENABLE_3RANKS, LL_ADC_REG_SEQ_SCAN_ENABLE_4RANKS, + LL_ADC_REG_SEQ_SCAN_ENABLE_5RANKS, LL_ADC_REG_SEQ_SCAN_ENABLE_6RANKS, LL_ADC_REG_SEQ_SCAN_ENABLE_7RANKS, LL_ADC_REG_SEQ_SCAN_ENABLE_8RANKS, +#if defined(ADC_REGULAR_RANK_9) + LL_ADC_REG_SEQ_SCAN_ENABLE_9RANKS, LL_ADC_REG_SEQ_SCAN_ENABLE_10RANKS, LL_ADC_REG_SEQ_SCAN_ENABLE_11RANKS, LL_ADC_REG_SEQ_SCAN_ENABLE_12RANKS, + LL_ADC_REG_SEQ_SCAN_ENABLE_13RANKS, LL_ADC_REG_SEQ_SCAN_ENABLE_14RANKS, LL_ADC_REG_SEQ_SCAN_ENABLE_15RANKS, LL_ADC_REG_SEQ_SCAN_ENABLE_16RANKS +#endif + }; + +#if defined(ADC_SMPR_SMP1) + constexpr std::array samplingTimeCommon = { ADC_SAMPLINGTIME_COMMON_1, ADC_SAMPLINGTIME_COMMON_2 }; + + uint32_t SelectSamplingTimeCommon(ADC_TypeDef* adc, std::array, samplingTimeCommon.size()>& assigned, uint32_t samplingTime) + { + really_assert(IS_ADC_SAMPLE_TIME(samplingTime)); + + auto group = std::find_if(assigned.begin(), assigned.end(), [samplingTime](const auto& assignedTime) + { + return !assignedTime || *assignedTime == samplingTime; + }); + really_assert(group != assigned.end()); + + auto common = samplingTimeCommon[std::distance(assigned.begin(), group)]; + if (!*group) + { + *group = samplingTime; + LL_ADC_SetSamplingTimeCommonChannels(adc, common, samplingTime); + } + + return common; + } +#endif } namespace hal @@ -94,15 +131,29 @@ namespace hal void AdcDmaMultiChannelStmBase::ConfigureChannels(infra::MemoryRange configs) { + really_assert(!analogPins.empty() && analogPins.size() <= rank.size()); + ADC_ChannelConfTypeDef channelConfig = { 0 }; #ifdef ADC_OFFSET_NONE channelConfig.OffsetNumber = ADC_OFFSET_NONE; channelConfig.Offset = 0; #endif +#if defined(ADC_CFGR1_CHSELRMOD) + // HAL_ADC_ConfigChannel only writes ranks up to NbrOfConversion into CHSELR + adc.Handle().Init.ScanConvMode = ADC_SCAN_ENABLE; + adc.Handle().Init.NbrOfConversion = analogPins.size(); +#endif +#if defined(ADC_SMPR_SMP1) + std::array, samplingTimeCommon.size()> assignedSamplingTimes; +#endif for (std::size_t i = 0; i != analogPins.size(); ++i) { +#if defined(ADC_SMPR_SMP1) + channelConfig.SamplingTime = SelectSamplingTimeCommon(adc.Handle().Instance, assignedSamplingTimes, configs[i].samplingTime); +#else channelConfig.SamplingTime = configs[i].samplingTime; +#endif #ifdef ADC_SINGLE_ENDED channelConfig.SingleDiff = configs[i].differential ? ADC_DIFFERENTIAL_ENDED : ADC_SINGLE_ENDED; #endif @@ -111,7 +162,7 @@ namespace hal auto result = HAL_ADC_ConfigChannel(&adc.Handle(), &channelConfig); assert(result == HAL_OK); } - LL_ADC_REG_SetSequencerLength(adc.Handle().Instance, analogPins.size() - 1); + LL_ADC_REG_SetSequencerLength(adc.Handle().Instance, sequencerLength[analogPins.size() - 1]); } void AdcDmaMultiChannelStmBase::TransferDone() diff --git a/hal_st/stm32fxxx/AdcDmaMultiChannelStm.hpp b/hal_st/stm32fxxx/AdcDmaMultiChannelStm.hpp index fa6cd4a0..817967ab 100644 --- a/hal_st/stm32fxxx/AdcDmaMultiChannelStm.hpp +++ b/hal_st/stm32fxxx/AdcDmaMultiChannelStm.hpp @@ -15,7 +15,11 @@ namespace hal : public AdcMultiChannel { public: +#if defined(ADC_REGULAR_RANK_8) && !defined(ADC_REGULAR_RANK_9) + static constexpr std::size_t MaxChannels{ 8 }; +#else static constexpr std::size_t MaxChannels{ 16 }; +#endif struct OneShot {}; diff --git a/hal_st/stm32fxxx/AdcDmaStm.cpp b/hal_st/stm32fxxx/AdcDmaStm.cpp index c1485c94..106f8640 100644 --- a/hal_st/stm32fxxx/AdcDmaStm.cpp +++ b/hal_st/stm32fxxx/AdcDmaStm.cpp @@ -41,6 +41,7 @@ namespace hal #endif ReconfigureTrigger(); + adc.SelectSingleConversion(); auto result = HAL_ADC_ConfigChannel(&adc.Handle(), &channelConfig); assert(result == HAL_OK); diff --git a/hal_st/stm32fxxx/AdcTimerTriggeredBase.cpp b/hal_st/stm32fxxx/AdcTimerTriggeredBase.cpp index c54c4f31..0d8079a9 100644 --- a/hal_st/stm32fxxx/AdcTimerTriggeredBase.cpp +++ b/hal_st/stm32fxxx/AdcTimerTriggeredBase.cpp @@ -78,6 +78,13 @@ namespace #else TriggerUnsupported, #endif +#if defined(ADC_EXTERNALTRIGCONV_T10_TRGO) + ADC_EXTERNALTRIGCONV_T10_TRGO, +#elif defined(ADC_EXTERNALTRIG_T10_TRGO) + ADC_EXTERNALTRIG_T10_TRGO, +#else + TriggerUnsupported, +#endif #if defined(ADC_EXTERNALTRIGCONV_T11_TRGO) ADC_EXTERNALTRIGCONV_T11_TRGO, #elif defined(ADC_EXTERNALTRIG_T11_TRGO) @@ -121,13 +128,23 @@ namespace assert(triggers[index] != TriggerUnsupported); return triggers[index]; } + + hal::TimerBaseStm::Trigger TimerTrigger(uint8_t index) + { + hal::TimerBaseStm::Trigger trigger{ hal::TimerBaseStm::Trigger::TriggerOutput::update, false }; +#if defined(ADC_EXTERNALTRIG_T1_TRGO2) && defined(TIM_TRGO2_RESET) + if (index == 0 && triggers[index] == ADC_EXTERNALTRIG_T1_TRGO2) + trigger.triggerOutput2 = hal::TimerBaseStm::Trigger::TriggerOutput2::update; +#endif + return trigger; + } } namespace hal { AdcTimerTriggeredBase::AdcTimerTriggeredBase(AdcStm& adc, uint8_t oneBasedTimerIndex, TimerBaseStm::Timing timing) : adc(adc) - , timer(oneBasedTimerIndex, timing, { TimerBaseStm::CounterMode::up, std:: make_optional({ TimerBaseStm::Trigger::TriggerOutput::update, false }) }) + , timer(oneBasedTimerIndex, timing, { TimerBaseStm::CounterMode::up, std::make_optional(TimerTrigger(oneBasedTimerIndex - 1)) }) , timerIndex(oneBasedTimerIndex - 1) {} diff --git a/hal_st/stm32fxxx/AnalogToDigitalPinStm.cpp b/hal_st/stm32fxxx/AnalogToDigitalPinStm.cpp index daa5891c..9ee247c9 100644 --- a/hal_st/stm32fxxx/AnalogToDigitalPinStm.cpp +++ b/hal_st/stm32fxxx/AnalogToDigitalPinStm.cpp @@ -111,6 +111,7 @@ namespace hal #ifdef ADC_OFFSET_1 channelConfig.Offset = 0; #endif + adc.SelectSingleConversion(); HAL_StatusTypeDef result = HAL_ADC_ConfigChannel(&adc.Handle(), &channelConfig); assert(result == HAL_OK); @@ -150,8 +151,13 @@ namespace hal #ifdef ADC_OFFSET_1 channelConfig.Offset = 0; #endif +#ifdef ADC_SMPR_SMP1 + channelConfig.SamplingTime = ADC_SAMPLINGTIME_COMMON_1; +#else channelConfig.SamplingTime = config.samplingTime; +#endif + adc.SelectSingleConversion(); HAL_StatusTypeDef result = HAL_ADC_ConfigChannel(&adc.Handle(), &channelConfig); assert(result == HAL_OK); @@ -235,6 +241,20 @@ namespace hal return handle; } + // AdcDmaMultiChannelStm on the same AdcStm leaves a multi-rank CHSELR sequence and its own SMP1 + void AdcStm::SelectSingleConversion() + { +#if defined(ADC_CFGR1_CHSELRMOD) + handle.Init.ScanConvMode = ADC_SCAN_DISABLE; + handle.Init.NbrOfConversion = 1; + if (LL_ADC_REG_GetSequencerLength(handle.Instance) != LL_ADC_REG_SEQ_SCAN_DISABLE) + LL_ADC_REG_SetSequencerLength(handle.Instance, LL_ADC_REG_SEQ_SCAN_DISABLE); +#endif +#if defined(ADC_SMPR_SMP1) + LL_ADC_SetSamplingTimeCommonChannels(handle.Instance, LL_ADC_SAMPLINGTIME_COMMON_1, handle.Init.SamplingTimeCommon1); +#endif + } + void AdcStm::EnableOverrunInterrupt() { __HAL_ADC_ENABLE_IT(&handle, ADC_IT_OVR); diff --git a/hal_st/stm32fxxx/AnalogToDigitalPinStm.hpp b/hal_st/stm32fxxx/AnalogToDigitalPinStm.hpp index b5c3473d..bc2fc40d 100644 --- a/hal_st/stm32fxxx/AnalogToDigitalPinStm.hpp +++ b/hal_st/stm32fxxx/AnalogToDigitalPinStm.hpp @@ -81,6 +81,7 @@ namespace hal uint32_t Channel(const hal::AnalogPinStm& pin) const; ADC_HandleTypeDef& Handle(); + void SelectSingleConversion(); void EnableOverrunInterrupt(); diff --git a/hal_st/stm32fxxx/TimerStm.cpp b/hal_st/stm32fxxx/TimerStm.cpp index 9d268b10..55632abc 100644 --- a/hal_st/stm32fxxx/TimerStm.cpp +++ b/hal_st/stm32fxxx/TimerStm.cpp @@ -189,6 +189,9 @@ namespace hal TIM_MasterConfigTypeDef masterConfig = { 0 }; masterConfig.MasterOutputTrigger = infra::enum_cast(config.trigger->triggerOutput); +#if defined(TIM_TRGO2_RESET) + masterConfig.MasterOutputTrigger2 = infra::enum_cast(config.trigger->triggerOutput2); +#endif masterConfig.MasterSlaveMode = config.trigger->isSlaveMode ? TIM_MASTERSLAVEMODE_ENABLE : TIM_MASTERSLAVEMODE_DISABLE; auto result = HAL_TIMEx_MasterConfigSynchronization(&handle, &masterConfig); assert(result == HAL_OK); diff --git a/hal_st/stm32fxxx/TimerStm.hpp b/hal_st/stm32fxxx/TimerStm.hpp index 7ac852b4..0b2054b9 100644 --- a/hal_st/stm32fxxx/TimerStm.hpp +++ b/hal_st/stm32fxxx/TimerStm.hpp @@ -37,8 +37,20 @@ namespace hal update = TIM_TRGO_UPDATE, }; +#if defined(TIM_TRGO2_RESET) + enum class TriggerOutput2 : uint32_t + { + reset = TIM_TRGO2_RESET, + enable = TIM_TRGO2_ENABLE, + update = TIM_TRGO2_UPDATE, + }; +#endif + TriggerOutput triggerOutput; bool isSlaveMode; +#if defined(TIM_TRGO2_RESET) + TriggerOutput2 triggerOutput2{ TriggerOutput2::reset }; +#endif }; struct Config From cb03b770fb2a0f305550b02ad9624ad45acff9a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:52:03 +0000 Subject: [PATCH 08/57] fix(spi): start SPI v2 transfers that begin with a receive CSTART was only set when a byte was written to TXDR, so a receive-only first transfer on H5/WBA never started (SpiMasterStm timed out, SynchronousSpiMasterStm spun forever). CSTART is now set once per transfer after the interrupts are enabled. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/SpiMasterStm.cpp | 4 +++- hal_st/synchronous_stm32fxxx/SynchronousSpiMasterStm.cpp | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/hal_st/stm32fxxx/SpiMasterStm.cpp b/hal_st/stm32fxxx/SpiMasterStm.cpp index 1d421f08..a5886a0f 100644 --- a/hal_st/stm32fxxx/SpiMasterStm.cpp +++ b/hal_st/stm32fxxx/SpiMasterStm.cpp @@ -92,6 +92,9 @@ namespace hal #else peripheralSpi[spiInstance]->CR2 |= SPI_CR2_TXEIE; peripheralSpi[spiInstance]->CR2 |= SPI_CR2_RXNEIE; +#endif +#ifdef SPI_CR1_CSTART + peripheralSpi[spiInstance]->CR1 |= SPI_CR1_CSTART; #endif } @@ -172,7 +175,6 @@ namespace hal reinterpret_cast(peripheralSpi[spiInstance]->DR) = sendData.front(); #else reinterpret_cast(peripheralSpi[spiInstance]->TXDR) = sendData.front(); - peripheralSpi[spiInstance]->CR1 |= SPI_CR1_CSTART; #endif sendData.pop_front(); } diff --git a/hal_st/synchronous_stm32fxxx/SynchronousSpiMasterStm.cpp b/hal_st/synchronous_stm32fxxx/SynchronousSpiMasterStm.cpp index 6308397b..f202a1b2 100644 --- a/hal_st/synchronous_stm32fxxx/SynchronousSpiMasterStm.cpp +++ b/hal_st/synchronous_stm32fxxx/SynchronousSpiMasterStm.cpp @@ -80,6 +80,9 @@ namespace hal peripheralSpi[spiInstance]->IER |= SPI_IER_TXPIE; peripheralSpi[spiInstance]->IER |= SPI_IER_RXPIE; #endif +#ifdef SPI_CR1_CSTART + peripheralSpi[spiInstance]->CR1 |= SPI_CR1_CSTART; +#endif while (sending || receiving || dummyToSend != 0 || dummyToReceive != 0) HandleInterrupt(); @@ -144,7 +147,6 @@ namespace hal reinterpret_cast(peripheralSpi[spiInstance]->DR) = sendData.front(); #else reinterpret_cast(peripheralSpi[spiInstance]->TXDR) = sendData.front(); - peripheralSpi[spiInstance]->CR1 |= SPI_CR1_CSTART; #endif sendData.pop_front(); } From 01ae3f9cbf3cd309486a41aeaa9c8eb8d615d42d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:52:03 +0000 Subject: [PATCH 09/57] fix(dma): stop DMA streams without a late completion - Stream-based DMA (F4/F7) sets TCIF once clearing EN takes effect, which reported an aborted transfer as complete. StopTransfer now masks TC/HT, waits for EN to clear, clears the flags and restores the enables, as HAL_DMA_Abort does. - GPDMA: SUSPF is cleared after the channel reset, so a stale flag no longer ends the next suspend wait before the channel is suspended. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/DmaStm.cpp | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/hal_st/stm32fxxx/DmaStm.cpp b/hal_st/stm32fxxx/DmaStm.cpp index 5ad3d5ab..c8116951 100644 --- a/hal_st/stm32fxxx/DmaStm.cpp +++ b/hal_st/stm32fxxx/DmaStm.cpp @@ -568,8 +568,20 @@ namespace hal bool DmaStm::Stream::StopTransfer() const { bool finished = Finished(); +#if defined(DMA_SxCR_EN) + // A stream sets TCIF once clearing EN has taken effect, which would report the aborted transfer as complete + auto streamRegister = DmaChannel[dmaIndex][streamIndex]; + auto interrupts = streamRegister->CR & (DMA_SxCR_TCIE | DMA_SxCR_HTIE); + streamRegister->CR &= ~interrupts; + Disable(); + while (!Finished()) + ; + *dmaIFCR[dmaIndex][streamIndex] |= streamToTCIF[streamIndex] | streamToHTIF[streamIndex]; + streamRegister->CR |= interrupts; +#else Disable(); *dmaIFCR[dmaIndex][streamIndex] |= streamToTCIF[streamIndex] | streamToHTIF[streamIndex]; +#endif return !finished; } @@ -584,6 +596,8 @@ namespace hal ; } streamRegister->CCR |= DMA_CCR_RESET; + // A stale SUSPF would end the wait above early on the next stop, before the channel is suspended + streamRegister->CFCR = DMA_CFCR_SUSPF; #elif defined(DMA_CCR_EN) streamRegister->CCR &= ~DMA_CCR_EN; #else From df2de251abfe8c3e876ebd26e4b948cc1b822d64 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:52:03 +0000 Subject: [PATCH 10/57] fix(uart): leave no UART state behind and survive overruns - SWAP is always written, so a swapped instance is no longer swapped for later users (UartStm, SynchronousUartStm). - Destructors clear TXEIE/TCIE/RXNEIE (and RTOEN for the duplex DMA variant), and ~UartStmDma stops its transmit DMA, so a close during a stalled send no longer leaves an interrupt storm for the next user. - CR1 enable bits are set and cleared atomically: a TXEIE clear from the interrupt during ReceiveData's read-modify-write was undone. - A receive overrun is cleared instead of asserting (UartStm) or being left set (SynchronousUartStm), which looped on the ORE interrupt. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/UartStm.cpp | 32 +++++++++---------- hal_st/stm32fxxx/UartStmDma.cpp | 1 + hal_st/stm32fxxx/UartStmDuplexDma.cpp | 1 + .../SynchronousUartStm.cpp | 21 ++++++++++-- 4 files changed, 37 insertions(+), 18 deletions(-) diff --git a/hal_st/stm32fxxx/UartStm.cpp b/hal_st/stm32fxxx/UartStm.cpp index 44ba9aef..176e32eb 100644 --- a/hal_st/stm32fxxx/UartStm.cpp +++ b/hal_st/stm32fxxx/UartStm.cpp @@ -80,11 +80,8 @@ namespace hal uartHandle.AdvancedInit = {}; #if defined(UART_ADVFEATURE_SWAP_INIT) - if (config.swapTxRx) - { - uartHandle.AdvancedInit.AdvFeatureInit = UART_ADVFEATURE_SWAP_INIT; - uartHandle.AdvancedInit.Swap = UART_ADVFEATURE_SWAP_ENABLE; - } + uartHandle.AdvancedInit.AdvFeatureInit = UART_ADVFEATURE_SWAP_INIT; + uartHandle.AdvancedInit.Swap = config.swapTxRx ? UART_ADVFEATURE_SWAP_ENABLE : UART_ADVFEATURE_SWAP_DISABLE; #endif #endif @@ -101,7 +98,7 @@ namespace hal UartStm::~UartStm() { - uartArray[uartIndex]->CR1 &= ~(USART_CR1_TE | USART_CR1_RE); + uartArray[uartIndex]->CR1 &= ~(USART_CR1_TXEIE | USART_CR1_TCIE | USART_CR1_RXNEIE | USART_CR1_TE | USART_CR1_RE); #if defined(HAS_PERIPHERAL_LPUART) if (uartArray.begin() == peripheralLpuart.begin()) @@ -122,17 +119,18 @@ namespace hal __DMB(); - uartArray[uartIndex]->CR1 |= USART_CR1_TXEIE; + ATOMIC_SET_BIT(uartArray[uartIndex]->CR1, USART_CR1_TXEIE); } void UartStm::ReceiveData(infra::Function dataReceived) { this->dataReceived = dataReceived; + // Atomic, otherwise a TXEIE clear by Invoke during the read-modify-write is undone and TXE interrupts never stop if (dataReceived == nullptr) - uartArray[uartIndex]->CR1 &= ~(USART_CR1_RE | USART_CR1_RXNEIE); + ATOMIC_CLEAR_BIT(uartArray[uartIndex]->CR1, USART_CR1_RE | USART_CR1_RXNEIE); else - uartArray[uartIndex]->CR1 |= USART_CR1_RE | USART_CR1_RXNEIE; + ATOMIC_SET_BIT(uartArray[uartIndex]->CR1, USART_CR1_RE | USART_CR1_RXNEIE); } void UartStm::RegisterInterrupt(const Config& config) @@ -172,17 +170,19 @@ namespace hal buffer.push_back(receivedByte); } - // If buffer is empty then interrupt was raised by Overrun Error (ORE) and we miss data. -#if defined(USART_ISR_ORE) - really_assert(!(uartArray[uartIndex]->ISR & USART_ISR_ORE)); -#else - really_assert(!(uartArray[uartIndex]->SR & USART_SR_ORE)); -#endif - if (dataReceived != nullptr) dataReceived(buffer.range()); } +#if defined(USART_ICR_ORECF) + if (uartArray[uartIndex]->ISR & USART_ISR_ORE) + uartArray[uartIndex]->ICR = USART_ICR_ORECF; +#else + // An SR then DR read clears ORE; while RXNE is set the receive loop does that read and keeps the byte + if ((uartArray[uartIndex]->SR & (USART_SR_ORE | USART_SR_RXNE)) == USART_SR_ORE) + static_cast(uartArray[uartIndex]->DR); +#endif + if (sending && ((uartArray[uartIndex]->CR1 & USART_CR1_TXEIE) != 0)) { #if defined(USART_ISR_TXE) diff --git a/hal_st/stm32fxxx/UartStmDma.cpp b/hal_st/stm32fxxx/UartStmDma.cpp index 136adc47..5c3593bb 100644 --- a/hal_st/stm32fxxx/UartStmDma.cpp +++ b/hal_st/stm32fxxx/UartStmDma.cpp @@ -55,6 +55,7 @@ namespace hal UartStmDma::~UartStmDma() { + transmitDmaChannel.StopTransfer(); uartArray[uartIndex]->CR3 &= ~USART_CR3_DMAT; } diff --git a/hal_st/stm32fxxx/UartStmDuplexDma.cpp b/hal_st/stm32fxxx/UartStmDuplexDma.cpp index d9492b3d..9c236b61 100644 --- a/hal_st/stm32fxxx/UartStmDuplexDma.cpp +++ b/hal_st/stm32fxxx/UartStmDuplexDma.cpp @@ -68,6 +68,7 @@ namespace hal receiveDmaChannel.StopTransfer(); uartArray[uartIndex]->CR3 &= ~USART_CR3_DMAT & ~USART_CR3_DMAR; uartArray[uartIndex]->CR1 &= ~USART_CR1_RTOIE; + uartArray[uartIndex]->CR2 &= ~USART_CR2_RTOEN; } void UartStmDuplexDma::ReceiveData(infra::Function dataReceived) diff --git a/hal_st/synchronous_stm32fxxx/SynchronousUartStm.cpp b/hal_st/synchronous_stm32fxxx/SynchronousUartStm.cpp index 67632cf3..dfc04773 100644 --- a/hal_st/synchronous_stm32fxxx/SynchronousUartStm.cpp +++ b/hal_st/synchronous_stm32fxxx/SynchronousUartStm.cpp @@ -38,6 +38,10 @@ namespace hal uartHandle.Init.OneBitSampling = UART_ONE_BIT_SAMPLE_ENABLE; #endif uartHandle.Init.OverSampling = UART_OVERSAMPLING_8; +#if defined(UART_ADVFEATURE_SWAP_INIT) + uartHandle.AdvancedInit.AdvFeatureInit = UART_ADVFEATURE_SWAP_INIT; + uartHandle.AdvancedInit.Swap = UART_ADVFEATURE_SWAP_DISABLE; +#endif HAL_UART_Init(&uartHandle); @@ -47,7 +51,7 @@ namespace hal SynchronousUartStm::~SynchronousUartStm() { - peripheralUart[uartIndex]->CR1 &= ~(USART_CR1_TE | USART_CR1_RE); + peripheralUart[uartIndex]->CR1 &= ~(USART_CR1_RXNEIE | USART_CR1_TE | USART_CR1_RE); } void SynchronousUartStm::SendData(infra::ConstByteRange data) @@ -122,6 +126,15 @@ namespace hal ++contentsEnd; } } + +#if defined(USART_ICR_ORECF) + if (peripheralUart[uartIndex]->ISR & USART_ISR_ORE) + peripheralUart[uartIndex]->ICR = USART_ICR_ORECF; +#else + // An SR then DR read clears ORE; while RXNE is set the receive loop does that read and keeps the byte + if ((peripheralUart[uartIndex]->SR & (USART_SR_ORE | USART_SR_RXNE)) == USART_SR_ORE) + static_cast(peripheralUart[uartIndex]->DR); +#endif } bool SynchronousUartStm::Full() const @@ -171,7 +184,7 @@ namespace hal SynchronousUartStmSendOnly::~SynchronousUartStmSendOnly() { - uartBase->CR1 &= ~(USART_CR1_TE | USART_CR1_RE); + uartBase->CR1 &= ~(USART_CR1_RXNEIE | USART_CR1_TE | USART_CR1_RE); } void SynchronousUartStmSendOnly::SendData(infra::ConstByteRange data) @@ -221,6 +234,10 @@ namespace hal uartHandle.Init.OneBitSampling = UART_ONE_BIT_SAMPLE_ENABLE; #else uartHandle.Init.OverSampling = UART_OVERSAMPLING_8; +#endif +#if defined(UART_ADVFEATURE_SWAP_INIT) + uartHandle.AdvancedInit.AdvFeatureInit = UART_ADVFEATURE_SWAP_INIT; + uartHandle.AdvancedInit.Swap = UART_ADVFEATURE_SWAP_DISABLE; #endif HAL_UART_Init(&uartHandle); From 4692418fd6fc78deb0b01ee7523103b97bd8cc0e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:52:03 +0000 Subject: [PATCH 11/57] fix(gpio): select the right port in EXTICR EnableInterrupt wrote the compacted hal::Port value into EXTICR, which selects the wrong port for ports after a gap (port H on WB/WBA). It now writes GPIO_GET_INDEX of the port, with the field width taken from the CMSIS EXTI/SYSCFG EXTICR positions (8 bits on G0, H5 and WBA). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/GpioStm.cpp | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/hal_st/stm32fxxx/GpioStm.cpp b/hal_st/stm32fxxx/GpioStm.cpp index 4606b6ee..fb03aaf3 100644 --- a/hal_st/stm32fxxx/GpioStm.cpp +++ b/hal_st/stm32fxxx/GpioStm.cpp @@ -531,14 +531,16 @@ namespace hal void GpioStm::EnableInterrupt(Port port, uint8_t index, const infra::Function& action, InterruptTrigger trigger, InterruptType type) { -#if defined(STM32WBA) || defined(STM32H5) - uint8_t pos = 3; +#if defined(EXTI_EXTICR1_EXTI0) + uint32_t shift = (index & 0x03) * EXTI_EXTICR1_EXTI1_Pos; #else - uint8_t pos = 2; + uint32_t shift = (index & 0x03) * SYSCFG_EXTICR1_EXTI1_Pos; #endif - uint32_t extiMask = 0xf << ((index & 0x03) << pos); - uint32_t extiValue = static_cast(port) << ((index & 0x03) << pos); + // hal::Port only enumerates the ports a device has; EXTICR expects the code of the port's address slot + GPIO_TypeDef* gpio = portToGPIOPort[static_cast(port)]; + uint32_t extiMask = 0xf << shift; + uint32_t extiValue = GPIO_GET_INDEX(gpio) << shift; #if defined(EXTI_EXTICR1_EXTI0) EXTI->EXTICR[index >> 2] = (EXTI->EXTICR[index >> 2] & ~extiMask) | extiValue; From 4c5084a9251168f772d0e61988186cb27d74ec15 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:52:03 +0000 Subject: [PATCH 12/57] fix(qei): reset the LPTIM before configuring the encoder HAL_LPTIM_Init ORs the clock and trigger filters into CFGR without clearing them, so a previous user's filter persisted. The LPTIM is now reset through RCC before it is initialised. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- .../SynchronousQuadratureEncoderLpTimStm.cpp | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/hal_st/synchronous_stm32fxxx/SynchronousQuadratureEncoderLpTimStm.cpp b/hal_st/synchronous_stm32fxxx/SynchronousQuadratureEncoderLpTimStm.cpp index 379ad173..725c39c7 100644 --- a/hal_st/synchronous_stm32fxxx/SynchronousQuadratureEncoderLpTimStm.cpp +++ b/hal_st/synchronous_stm32fxxx/SynchronousQuadratureEncoderLpTimStm.cpp @@ -10,6 +10,52 @@ namespace really_assert(oneBasedIndex >= 1 && oneBasedIndex <= hal::peripheralLpTimer.size()); return oneBasedIndex - 1; } + + void ResetLpTimer(const LPTIM_TypeDef* instance) + { +#if defined(LPTIM1) + if (instance == LPTIM1) + { + __HAL_RCC_LPTIM1_FORCE_RESET(); + __HAL_RCC_LPTIM1_RELEASE_RESET(); + } +#endif +#if defined(LPTIM2) + if (instance == LPTIM2) + { + __HAL_RCC_LPTIM2_FORCE_RESET(); + __HAL_RCC_LPTIM2_RELEASE_RESET(); + } +#endif +#if defined(LPTIM3) + if (instance == LPTIM3) + { + __HAL_RCC_LPTIM3_FORCE_RESET(); + __HAL_RCC_LPTIM3_RELEASE_RESET(); + } +#endif +#if defined(LPTIM4) + if (instance == LPTIM4) + { + __HAL_RCC_LPTIM4_FORCE_RESET(); + __HAL_RCC_LPTIM4_RELEASE_RESET(); + } +#endif +#if defined(LPTIM5) + if (instance == LPTIM5) + { + __HAL_RCC_LPTIM5_FORCE_RESET(); + __HAL_RCC_LPTIM5_RELEASE_RESET(); + } +#endif +#if defined(LPTIM6) + if (instance == LPTIM6) + { + __HAL_RCC_LPTIM6_FORCE_RESET(); + __HAL_RCC_LPTIM6_RELEASE_RESET(); + } +#endif + } } namespace hal @@ -28,6 +74,8 @@ namespace hal really_assert(IS_LPTIM_ENCODER_INTERFACE_INSTANCE(instance)); EnableClockLpTimer(timerIndex); + // HAL_LPTIM_Init ORs CKFLT/TRGFLT into CFGR without clearing them, so a previous user's filter would persist + ResetLpTimer(instance); handle.Instance = instance; handle.Init.Clock.Source = LPTIM_CLOCKSOURCE_APBCLOCK_LPOSC; From e2ba88591a8a2739cef4c5bb81f1566f29748b09 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 09:52:04 +0000 Subject: [PATCH 13/57] test(validation): guard the driver fixes instead of expecting the gaps - Remove the fixed known gaps from both board files; only the SynchronousUartStm CTS-held send (WBA55) remains. - PWM: range check and host expectations follow the fixed ARR (ticks - 1 edge, ticks / 2 centre); the centre-period xfail is gone. - GPIO: port H interrupts are no longer refused. - README and PROTOCOL list the remaining gaps and the PWM range. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- validation/PROTOCOL.md | 3 +- validation/README.md | 21 +--- validation/firmware/PinFactoryStm.cpp | 2 +- validation/firmware/PwmFactory.cpp | 8 +- validation/host/boards/nucleo_wb55rg.yaml | 50 +-------- validation/host/boards/nucleo_wba55cg.yaml | 104 +----------------- validation/host/hal_st_validation/expect.py | 39 +++---- .../host/hal_st_validation/fake_firmware.py | 5 +- validation/host/tests/hil/test_pwm.py | 23 +--- validation/host/tests/hil/test_uart.py | 11 +- validation/host/tests/unit/test_expect.py | 24 ++-- .../host/tests/unit/test_fake_firmware.py | 4 +- validation/host/tests/unit/test_known_gaps.py | 25 +++-- 13 files changed, 68 insertions(+), 251 deletions(-) diff --git a/validation/PROTOCOL.md b/validation/PROTOCOL.md index 4abc33c2..1bbf32af 100644 --- a/validation/PROTOCOL.md +++ b/validation/PROTOCOL.md @@ -72,7 +72,6 @@ The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix - `gpio.pulse ` → `OK` after `count` toggles of the output, one every `periodMs` (EMIL timer driven); `ERR usage` on an input - `gpio.irq [type=immediate|dispatched]` → `OK`; each edge increments a counter; `type` defaults to `dispatched` - an EXTI line serves one port at a time: a pin whose line (its index) already counts edges for a pin of another port returns `ERR unsupported` until that pin's interrupt is turned `off` or the pin is released - - port H returns `ERR unsupported` (`hal::GpioStm` writes the compacted `hal::Port` value into EXTICR, which selects the wrong port for H) - `gpio.count [clear=0|1]` → `OK count=` - `gpio.release ` → `OK`; the pin returns to a digital input with its configured pull @@ -94,7 +93,7 @@ The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix - `pwm.duty [duty2%] [duty3%] [duty4%]` → `OK`; one duty per opened channel in channel order, or a single duty for all of them, starts the outputs; duty accepts decimals (`12.5`, up to 4 digits), `0` and `100` - `pwm.freq ` → `OK` - `pwm.stop ` → `OK` -- A frequency whose period is under 2 counter ticks or does not fit the counter (16 bits, 32 bits on TIM2; half the period in centre-aligned mode) returns `ERR range`, both in `pwm.open` and `pwm.freq` +- A frequency whose period is under 2 counter ticks, or whose auto-reload does not fit the counter (16 bits, 32 bits on TIM2), returns `ERR range`, both in `pwm.open` and `pwm.freq`; with `ticks = pwmclk / hz` (rounded down) the auto-reload is `ticks - 1` edge aligned and `ticks / 2` centre aligned, where the counter runs up and down for a period of `2 x ARR` ticks - `pwm.close ` → `OK` ## UART (`hal::UartStm`, `dma=1` selects `hal::UartStmDma`, `duplex=1` selects `hal::UartStmDuplexDma`, `sync=1` selects `hal::SynchronousUartStm`) diff --git a/validation/README.md b/validation/README.md index 059a97d9..c5788fe1 100644 --- a/validation/README.md +++ b/validation/README.md @@ -130,22 +130,11 @@ Writing the firmware against the drivers showed hal-st bugs that the suite runs | Board | Driver | Effect | Source | |-------|--------|--------|--------| -| both | `PwmStm` | Every `pwm.open` aborts: the timer is initialised with `Period = 0`, and the HAL's `IS_TIM_PERIOD` assert (`USE_FULL_ASSERT` outside MinSizeRel) ends in `abort()`. Affects every MCU, not only these two. | `hal_st/stm32fxxx/PwmStm.cpp:256-261` | -| both | `PwmStm` | Dead time, break and idle settings (BDTR, AF1) survive into the next open of the same timer. | `PwmStm.cpp:238-250` | -| both | `PwmStm` | `brkpol=low` sets BKP=0 and BKINP=1, which inverts the input a second time: the break acts active high. | `PwmStm.cpp:318,349` | -| both | `PwmStm` | The centre-aligned period is two counter ticks short (1 MHz at 64 MHz runs at 1.032 MHz); `test_waveform` expects the failure where it leaves the tolerance. | `PwmStm.cpp:381-390` | -| WBA55 | `AdcDmaMultiChannelStm` | `adc.open` aborts unless `sampling=1.5`: a per-channel sampling time is passed where ADC4 takes only `ADC_SAMPLINGTIME_COMMON_1/2`. | `hal_st/stm32fxxx/AdcDmaMultiChannelStm.cpp:105` | -| WBA55 | `AdcStm` | Sequences of more than one pin convert one wrong channel (scan mode off, one conversion). | `AnalogToDigitalPinStm.cpp:183,193`, `AdcDmaMultiChannelStm.cpp:114` | -| WBA55 | `AdcTimerTriggeredBase` | `timer=1` never triggers: the ADC listens to TRGO2 while the timer drives TRGO. | `AdcTimerTriggeredBase.cpp:20-21` | -| WBA55 | `SpiMasterStm`, `SynchronousSpiMasterStm` | A receive-only first transfer never starts (CSTART is not set on the dummy path); the synchronous driver then spins forever. | `SpiMasterStm.cpp:160-168`, `SynchronousSpiMasterStm.cpp:132-139` | -| both | `UartStm` | Closing a UART during a stalled send leaves TXEIE set: the next open of that instance ends in an interrupt storm. | `UartStm.cpp:102` | -| both | `UartStm` | `swap=1` leaves the instance swapped for every later open until reset (CR2.SWAP is only written when swapping). | `UartStm.cpp:82-88` | -| both | `UartStm`, `UartStmDma` | A receive overrun aborts the firmware (`really_assert(!ORE)`); no test is expected to overrun, but a streaming failure that loses the firmware names it. | `UartStm.cpp:177` | -| both | `SynchronousUartStm` | A send with CTS held off blocks the event loop with no timeout; an overrun is never cleared. | `SynchronousUartStm.cpp:58,102` | -| both | `UartStm` | `uart.send` completes while up to 9 bytes are still in the TX FIFO and shift register, so a close right after a send can cut them. | `UartStm.cpp:202-205` | -| WB55 | `SynchronousQuadratureEncoderLpTimStm` | The LPTIM input filter of one open carries over into the next. | `SynchronousQuadratureEncoderLpTimStm.cpp:122` | -| both | `GpioStm` | EXTI on port H selects the wrong port (the compacted `hal::Port` value is written to EXTICR); the firmware answers `ERR unsupported` for `gpio.irq` on port H. | `GpioStm.cpp:541` | -| both | `WatchDogStm` | Only the window watchdog exists: timeouts are limited to about 516 ms (WB55) and 330 ms (WBA55), and there is no IWDG driver. | `WatchDogStm.cpp` | +| WBA55 | `SynchronousUartStm` | A send with CTS held off blocks the event loop with no timeout. | `hal_st/synchronous_stm32fxxx/SynchronousUartStm.cpp:62` | +| both | `UartStm` | `uart.send` completes while up to 9 bytes are still in the TX FIFO and shift register, so a close right after a send can cut them. | `hal_st/stm32fxxx/UartStm.cpp:202-205` | +| both | `WatchDogStm` | Only the window watchdog exists: timeouts are limited to about 516 ms (WB55) and 330 ms (WBA55), and there is no IWDG driver. | `hal_st/stm32fxxx/WatchDogStm.cpp` | + +The gaps found earlier in `PwmStm`, `AdcStm`/`AdcDmaMultiChannelStm`/`AdcTimerTriggeredBase` (WBA55 ADC4), `SpiMasterStm`/`SynchronousSpiMasterStm` (receive-only start), `UartStm` (TXEIE after close, SWAP, overrun), `SynchronousQuadratureEncoderLpTimStm` (filter carry-over) and `GpioStm` (EXTI on port H) are fixed, and the tests that exposed them now guard the fixes. The port H fix has no HIL test: PH3 (BOOT0) is the only port H pin on both boards and is reserved. Fix the driver, then remove its entry from both board files so the tests guard the fix. diff --git a/validation/firmware/PinFactoryStm.cpp b/validation/firmware/PinFactoryStm.cpp index 7499a728..d4fdcab1 100644 --- a/validation/firmware/PinFactoryStm.cpp +++ b/validation/firmware/PinFactoryStm.cpp @@ -145,7 +145,7 @@ namespace validation bool PinFactoryStm::SupportsInterrupt(HilPinId pin) const { - if (!IsBonded(pin) || PortOf(pin) == hal::Port::H) + if (!IsBonded(pin)) return false; const auto& owner = extiOwners[pin.index]; diff --git a/validation/firmware/PwmFactory.cpp b/validation/firmware/PwmFactory.cpp index 5d7274be..9d4e92fb 100644 --- a/validation/firmware/PwmFactory.cpp +++ b/validation/firmware/PwmFactory.cpp @@ -75,12 +75,12 @@ namespace validation bool ValidFrequency(uint8_t timer, uint32_t counterClock, bool centerAligned, uint32_t hertz) { - auto ticksPerPeriod = counterClock / hertz; - if (centerAligned) - ticksPerPeriod /= 2; + const auto ticksPerPeriod = counterClock / hertz; + if (ticksPerPeriod < 2) + return false; const uint32_t maximumCompare = IS_TIM_32B_COUNTER_INSTANCE(Instance(timer)) ? 0xffffffffu : 0xffffu; - return ticksPerPeriod >= 2 && ticksPerPeriod - 1 <= maximumCompare; + return (centerAligned ? ticksPerPeriod / 2 : ticksPerPeriod - 1) <= maximumCompare; } std::optional ChannelOfPin(HilPinId pin, infra::MemoryRange functions, uint8_t timer) diff --git a/validation/host/boards/nucleo_wb55rg.yaml b/validation/host/boards/nucleo_wb55rg.yaml index 81ef8579..e45a13d6 100644 --- a/validation/host/boards/nucleo_wb55rg.yaml +++ b/validation/host/boards/nucleo_wb55rg.yaml @@ -239,54 +239,8 @@ wiring_sets: # test ids relative to tests/hil (`*` and `?` are wildcards, brackets are # literal); `hangs` gaps abort or hang the firmware, so their tests are # skipped unless --run-known-gaps; the others are expected failures. --fake -# ignores them. The centre-aligned period of PwmStm.cpp:381-390 is off the -# frequency tolerance at short periods only, so test_pwm.py::test_waveform -# expects that failure per case (expect.pwm_frequency_driver). -known_gaps: - - tests: - - test_pwm.py::test_waveform* - - test_pwm.py::test_frequency_limits* - - test_pwm.py::test_channels* - - test_pwm.py::test_complementary* - - test_pwm.py::test_dead_time_limits[1000000] - - test_pwm.py::test_idle_levels* - - test_pwm.py::test_break_input* - - test_pwm.py::test_frequency_change_and_stop* - - test_pwm.py::test_duty_errors - - test_pwm.py::test_one_timer_at_a_time - - test_pwm.py::test_reopen_forgets_dead_time_and_break - reason: >- - hal_st/stm32fxxx/PwmStm.cpp:256-261 - every pwm.open aborts the - firmware: ConfigureTimeBase calls HAL_TIM_PWM_Init with Period = 0, the - IS_TIM_PERIOD assert (USE_FULL_ASSERT) ends in assert_failed and abort() - hangs: true - - tests: - - test_pwm.py::test_reopen_forgets_dead_time_and_break - reason: >- - hal_st/stm32fxxx/PwmStm.cpp:238-250 - dead time, break and idle settings - (BDTR, AF1) survive into the next open of the same timer - hangs: false - - tests: - - test_uart.py::test_close_during_stalled_send[*variant=interrupt*] - reason: >- - hal_st/stm32fxxx/UartStm.cpp:102 - closing a UART during a stalled send - leaves TXEIE set: the next open of that instance ends in an interrupt - storm - hangs: true - - tests: - - test_pwm.py::test_break_input[*brkpol=low*] - reason: >- - hal_st/stm32fxxx/PwmStm.cpp:318,349 - brkpol=low sets BDTR.BKP=0 and - AF1.BKINP=1, which inverts the input a second time: the break acts - active high - hangs: false - - tests: - - test_uart.py::test_swap_does_not_survive_close* - reason: >- - hal_st/stm32fxxx/UartStm.cpp:82-88 - CR2.SWAP is only written when - swapTxRx is set, so a swap=1 session leaves the instance swapped for - every later open until reset - hangs: false +# ignores them. +known_gaps: [] # Test parameters. A mapping used by `@pytest.mark.matrix` is one dimension # per key: `--depth full` runs the cartesian product, `--depth quick` a diff --git a/validation/host/boards/nucleo_wba55cg.yaml b/validation/host/boards/nucleo_wba55cg.yaml index c20ea485..abae32c1 100644 --- a/validation/host/boards/nucleo_wba55cg.yaml +++ b/validation/host/boards/nucleo_wba55cg.yaml @@ -177,116 +177,16 @@ wiring_sets: # test ids relative to tests/hil (`*` and `?` are wildcards, brackets are # literal); `hangs` gaps abort or hang the firmware, so their tests are # skipped unless --run-known-gaps; the others are expected failures. --fake -# ignores them. The centre-aligned period of PwmStm.cpp:381-390 is off the -# frequency tolerance at short periods only, so test_pwm.py::test_waveform -# expects that failure per case (expect.pwm_frequency_driver). +# ignores them. known_gaps: - - tests: - - test_pwm.py::test_waveform* - - test_pwm.py::test_frequency_limits* - - test_pwm.py::test_channels* - - test_pwm.py::test_complementary* - - test_pwm.py::test_dead_time_limits[1000000] - - test_pwm.py::test_idle_levels* - - test_pwm.py::test_break_input* - - test_pwm.py::test_frequency_change_and_stop* - - test_pwm.py::test_duty_errors - - test_pwm.py::test_one_timer_at_a_time - - test_pwm.py::test_reopen_forgets_dead_time_and_break - reason: >- - hal_st/stm32fxxx/PwmStm.cpp:256-261 - every pwm.open aborts the - firmware: ConfigureTimeBase calls HAL_TIM_PWM_Init with Period = 0, the - IS_TIM_PERIOD assert (USE_FULL_ASSERT) ends in assert_failed and abort() - hangs: true - - tests: - - test_pwm.py::test_reopen_forgets_dead_time_and_break - reason: >- - hal_st/stm32fxxx/PwmStm.cpp:238-250 - dead time, break and idle settings - (BDTR, AF1) survive into the next open of the same timer - hangs: false - - tests: - - test_uart.py::test_close_during_stalled_send[*variant=interrupt*] - reason: >- - hal_st/stm32fxxx/UartStm.cpp:102 - closing a UART during a stalled send - leaves TXEIE set: the next open of that instance ends in an interrupt - storm - hangs: true - tests: - test_uart.py::test_send_timeout_while_cts_held[*variant=sync*] - test_uart.py::test_close_during_stalled_send[*variant=sync*] reason: >- - hal_st/synchronous_stm32fxxx/SynchronousUartStm.cpp:58 - with sync=1 + hal_st/synchronous_stm32fxxx/SynchronousUartStm.cpp:62 - with sync=1 and CTS held off SendData waits for TXE without a timeout and blocks the event loop for good hangs: true - - tests: - - test_adc.py::test_dc_levels* - - test_adc.py::test_sequence* - - test_adc.py::test_trigger_rate* - - test_adc.py::test_measure_timeout - - test_adc.py::test_trigger_timer_busy_for_pwm_and_encoder - - test_adc.py::test_measure_limits - - test_adc.py::test_adc_pin_blocks_gpio - - test_adc.py::test_sampling_time[*sampling=3.5*] - - test_adc.py::test_sampling_time[*sampling=7.5*] - - test_adc.py::test_sampling_time[*sampling=12.5*] - - test_adc.py::test_sampling_time[*sampling=19.5*] - - test_adc.py::test_sampling_time[*sampling=39.5*] - - test_adc.py::test_sampling_time[*sampling=79.5*] - - test_adc.py::test_sampling_time[*sampling=814.5*] - reason: >- - hal_st/stm32fxxx/AdcDmaMultiChannelStm.cpp:105 - adc.open aborts unless - sampling=1.5: the per-channel ADC_SAMPLETIME_x reaches - HAL_ADC_ConfigChannel, where ADC4 takes only - ADC_SAMPLINGTIME_COMMON_1/2 (assert_param ends in abort()) - hangs: true - - tests: - - test_adc.py::test_sequence[*length=2*] - - test_adc.py::test_sequence[*length=3*] - - test_adc.py::test_sequence[*length=4*] - - test_adc.py::test_sequence[*length=8*] - - test_adc.py::test_measure_limits - reason: >- - hal_st/stm32fxxx/AnalogToDigitalPinStm.cpp:183,193 and - AdcDmaMultiChannelStm.cpp:114 - a sequence of more than one pin converts - one wrong channel (scan mode off, NbrOfConversion 1): software-triggered - adc.measure answers ERR timeout - hangs: false - - tests: - - test_adc.py::test_trigger_rate[*timer=1*] - reason: >- - hal_st/stm32fxxx/AdcTimerTriggeredBase.cpp:20-21 and TimerStm.cpp:191 - - timer=1 never triggers: the ADC waits for TIM1 TRGO2 while the timer - drives TRGO, so adc.measure answers ERR timeout - hangs: false - - tests: - - test_spi.py::test_receive_only_first[*variant=interrupt*] - reason: >- - hal_st/stm32fxxx/SpiMasterStm.cpp:160-168 - a receive-only first - transfer after spi.open never starts (CSTART is not set on the dummy - transmit path): ERR timeout - hangs: false - - tests: - - test_spi.py::test_receive_only_first[*variant=sync*] - reason: >- - hal_st/synchronous_stm32fxxx/SynchronousSpiMasterStm.cpp:132-139 - a - receive-only first transfer after spi.open never starts (CSTART is not - set on the dummy transmit path) and the polling loop spins forever - hangs: true - - tests: - - test_pwm.py::test_break_input[*brkpol=low*] - reason: >- - hal_st/stm32fxxx/PwmStm.cpp:318,349 - brkpol=low sets BDTR.BKP=0 and - AF1.BKINP=1, which inverts the input a second time: the break acts - active high - hangs: false - - tests: - - test_uart.py::test_swap_does_not_survive_close* - reason: >- - hal_st/stm32fxxx/UartStm.cpp:82-88 - CR2.SWAP is only written when - swapTxRx is set, so a swap=1 session leaves the instance swapped for - every later open until reset - hangs: false # Test parameters. A mapping used by `@pytest.mark.matrix` is one dimension # per key: `--depth full` runs the cartesian product, `--depth quick` a diff --git a/validation/host/hal_st_validation/expect.py b/validation/host/hal_st_validation/expect.py index dd374ea3..96b1054f 100644 --- a/validation/host/hal_st_validation/expect.py +++ b/validation/host/hal_st_validation/expect.py @@ -3,8 +3,7 @@ Sources: `PwmStmBase::SetBaseFrequencyImpl`/`SetDutyCycle`/`EncodeDeadTime` (hal_st/stm32fxxx/PwmStm.cpp), the ST HAL `UART_DIV_SAMPLING8`/`UART_DIV_LPUART` macros and `IS_UART_BAUDRATE`, the SPI baud-rate prescaler and the -WWDG counter (validation/PROTOCOL.md and the factories in validation/firmware). The `pwm_*` expectations are the -ones the protocol asks for; `*_driver` variants give what the current driver does where it has a known gap. +WWDG counter (validation/PROTOCOL.md and the factories in validation/firmware). """ from __future__ import annotations @@ -59,17 +58,24 @@ def pwm_clock(timer_clock: int, prescaler: int = 0) -> int: def pwm_ticks(pwmclk: int, frequency: int, mode: PwmMode) -> int: - """Counter ticks per period as the driver computes them (ARR + 1); halved in centre-aligned mode.""" + """Compare full scale of a period: ARR + 1 edge aligned, ARR centre aligned (the counter runs 0 .. ARR .. 0, + 2 * ARR ticks per period).""" ticks = pwmclk // frequency return ticks // 2 if mode == "center" else ticks +def pwm_auto_reload(pwmclk: int, frequency: int, mode: PwmMode) -> int: + """ARR `PwmStm` writes for `frequency`.""" + ticks = pwmclk // frequency + return ticks // 2 if mode == "center" else ticks - 1 + + def pwm_fits(pwmclk: int, frequency: int, mode: PwmMode, counter_max: int = 0xFFFF) -> bool: - """`pwm.open`/`pwm.freq` answer `ERR range` otherwise: at least 2 ticks, and ARR within the counter.""" + """`pwm.open`/`pwm.freq` answer `ERR range` otherwise: at least 2 counter ticks per period, and ARR within the + counter.""" if frequency <= 0: return False - ticks = pwm_ticks(pwmclk, frequency, mode) - return ticks >= 2 and ticks - 1 <= counter_max + return pwmclk // frequency >= 2 and pwm_auto_reload(pwmclk, frequency, mode) <= counter_max def pwm_frequency(pwmclk: int, frequency: int, mode: PwmMode) -> float: @@ -79,19 +85,12 @@ def pwm_frequency(pwmclk: int, frequency: int, mode: PwmMode) -> float: return pwmclk / (2 * ticks if mode == "center" else ticks) -def pwm_frequency_driver(pwmclk: int, frequency: int, mode: PwmMode) -> float: - """Output frequency `PwmStm` produces today: centre aligned it halves the ticks and still writes ARR = ticks - 1, - so the period is 2 * (ticks - 1) (hal_st/stm32fxxx/PwmStm.cpp:381-390, a known gap).""" - ticks = pwm_ticks(pwmclk, frequency, mode) - return pwmclk / (2 * (ticks - 1) if mode == "center" else ticks) - - def pwm_frequency_limits(pwmclk: int, mode: PwmMode, counter_max: int = 0xFFFF) -> tuple[int, int]: """Lowest and highest frequency `pwm.open`/`pwm.freq` accept at `pwmclk` (at least 2 ticks, ARR within the counter); the accepted frequencies form one interval.""" - divider = 2 if mode == "center" else 1 - lowest = max(1, pwmclk // (divider * (counter_max + 2)) + 1) - highest = pwmclk // (2 * divider) + longest = 2 * counter_max + 1 if mode == "center" else counter_max + 1 + lowest = max(1, pwmclk // (longest + 1) + 1) + highest = pwmclk // 2 while lowest > 1 and pwm_fits(pwmclk, lowest - 1, mode, counter_max): lowest -= 1 while not pwm_fits(pwmclk, lowest, mode, counter_max) and lowest <= highest: @@ -112,12 +111,6 @@ def pwm_duty(pwmclk: int, frequency: int, mode: PwmMode, duty: float) -> float: return 100 * min(pwm_duty_counts(pwmclk, frequency, mode, duty), full) / full -def pwm_duty_driver(pwmclk: int, frequency: int, mode: PwmMode, duty: float) -> float: - """Duty `PwmStm` produces today: centre aligned the CCR of `pwm_duty_counts` is compared against ARR = ticks - 1.""" - full = pwm_ticks(pwmclk, frequency, mode) - (1 if mode == "center" else 0) - return 100 * min(pwm_duty_counts(pwmclk, frequency, mode, duty), full) / full - - def pwm_duty_step(pwmclk: int, frequency: int, mode: PwmMode) -> float: """Duty resolution in percent: one compare count.""" return 100 / pwm_ticks(pwmclk, frequency, mode) @@ -134,7 +127,7 @@ def pwm_prescaler_for(timer_clock: int, frequency: int, mode: PwmMode, counter_m pwmclk = pwm_clock(timer_clock, prescaler) if pwm_fits(pwmclk, frequency, mode, counter_max): return prescaler - if pwm_ticks(pwmclk, frequency, mode) < 2: + if pwmclk // frequency < 2: break raise ValueError(f"{frequency} Hz does not fit any prescaler at {timer_clock} Hz ({mode})") diff --git a/validation/host/hal_st_validation/fake_firmware.py b/validation/host/hal_st_validation/fake_firmware.py index 12df99e1..f444a5ee 100644 --- a/validation/host/hal_st_validation/fake_firmware.py +++ b/validation/host/hal_st_validation/fake_firmware.py @@ -710,10 +710,9 @@ def _cmd_gpio_pulse(self, args: list[str], options: dict[str, str]) -> str: return "OK" def supports_interrupt(self, pin: str) -> bool: - """Port H is refused (`hal::GpioStm` writes the compacted port number into EXTICR); an EXTI line - serves one port at a time.""" + """An EXTI line serves one port at a time.""" owner = self.exti.get(int(pin[2:])) - return self.bonded(pin) and pin[1] != "H" and (owner is None or owner == pin) + return self.bonded(pin) and (owner is None or owner == pin) def _cmd_gpio_irq(self, args: list[str], options: dict[str, str]) -> str: _shape(args, options, 2, 2, ("type",)) diff --git a/validation/host/tests/hil/test_pwm.py b/validation/host/tests/hil/test_pwm.py index ba95c092..674ec4ed 100644 --- a/validation/host/tests/hil/test_pwm.py +++ b/validation/host/tests/hil/test_pwm.py @@ -3,8 +3,7 @@ Wiring set `bundle1`: the outputs of `tests.pwm.timers` (channel pins, complementary `npin`s and `brk` inputs) on DIOs. Frequencies and duties are compared with what the protocol asks for after quantisation to whole counter -ticks (`expect.pwm_frequency`/`pwm_duty`); the centre-aligned period of the current driver is a known gap -(`expect.pwm_frequency_driver`), an expected failure of `test_waveform` where it leaves the frequency tolerance. +ticks (`expect.pwm_frequency`/`pwm_duty`). """ from __future__ import annotations @@ -87,22 +86,6 @@ def times(dio): assert worst <= allowed + 2 / capture.rate, f"DIO{dio} {feature} edges off by {worst * 1e9:.0f} ns" -CENTER_PERIOD_GAP = ( - "known gap: hal_st/stm32fxxx/PwmStm.cpp:381-390 - centre aligned the period is 2 x (ticks/2 - 1) counter ticks " - "instead of ticks (expect.pwm_frequency_driver)" -) - - -def expect_center_period_gap(request, pwm_cfg, pwmclk, frequency, mode): - """The centre-aligned period gap fails the frequency check only where the driver's period leaves the tolerance - (short periods; the duty error stays within one step there); `--fake` ignores it like the board files' gaps.""" - if mode != "center" or request.config.getoption("--fake"): - return - error = abs(expect.pwm_frequency_driver(pwmclk, frequency, mode) / expect.pwm_frequency(pwmclk, frequency, mode) - 1) - if error > tolerance(pwm_cfg, "frequency"): - request.applymarker(pytest.mark.xfail(strict=False, reason=CENTER_PERIOD_GAP)) - - def expected_waveform(pwmclk, frequency, mode, duty): return ( expect.pwm_frequency(pwmclk, frequency, mode), @@ -115,7 +98,7 @@ def expected_waveform(pwmclk, frequency, mode, duty): @pytest.mark.board_params("timer", "pwm.timers") @pytest.mark.matrix("pwm.waveform") @pytest.mark.constraint(valid=supported) -def test_waveform(request, fw, ad3, need, pwm_cfg, timer_clock, timer, freq, duty, mode, prescaler, sync): +def test_waveform(fw, ad3, need, pwm_cfg, timer_clock, timer, freq, duty, mode, prescaler, sync): """One channel: `ERR range` exactly where the period does not fit the counter, else frequency and duty.""" channel = timer["channels"][0] dio = need.dio(channel["pin"]) @@ -130,8 +113,6 @@ def test_waveform(request, fw, ad3, need, pwm_cfg, timer_clock, timer, freq, dut return assert fits, "the firmware accepted a period outside the counter" assert reported == pwmclk - if duty not in (0, 100): - expect_center_period_gap(request, pwm_cfg, pwmclk, freq, mode) fw.pwm.duty(timer["timer"], duty) capture = record(ad3, pwm_cfg, freq, None if duty in (0, 100) else dio) frequency, quantised, step = expected_waveform(pwmclk, freq, mode, duty) diff --git a/validation/host/tests/hil/test_uart.py b/validation/host/tests/hil/test_uart.py index d879378b..51ba5c04 100644 --- a/validation/host/tests/hil/test_uart.py +++ b/validation/host/tests/hil/test_uart.py @@ -2,9 +2,8 @@ Wiring set `bundle1`: TX, RX, RTS and CTS of every `tests.uart.instances` entry on DIOs; the logic analyzer also records the firmware TX line to decode the frames and measure the bit rate. USART1 is the terminal, so the -instances under test are LPUART1 (both boards) and USART2 (NUCLEO-WBA55CG). A receive overrun aborts -`UartStm`/`UartStmDma` (`really_assert(!ORE)`, hal_st/stm32fxxx/UartStm.cpp:177): a test that loses the firmware -while streaming names it. +instances under test are LPUART1 (both boards) and USART2 (NUCLEO-WBA55CG). A receive overrun drops bytes +(`UartStm` clears ORE and goes on), so a streaming failure names it. """ from __future__ import annotations @@ -20,7 +19,7 @@ from hal_st_validation import expect from hal_st_validation.firmware import settle -OVERRUN_HINT = "if the firmware stopped answering: a receive overrun aborts UartStm/UartStmDma (UartStm.cpp:177)" +OVERRUN_HINT = "bytes missing: a receive overrun drops them" @pytest.fixture @@ -155,7 +154,7 @@ def test_full_duplex_stream(fw, ad3, need, uart_cfg, instance, baud, variant): received = ad3.uart.read(size, timeout=transfer + 1.0) finally: response = settle(pending) - assert response is not None and response.ok, f"round {round_number}: uart.send {response} ({OVERRUN_HINT})" + assert response is not None and response.ok, f"round {round_number}: uart.send {response}" assert received == outbound, f"round {round_number}: firmware to AD3" assert fw.uart.recv(instance["index"], timeout=int(transfer * 1000) + 500, len=size) == inbound, ( f"round {round_number}: AD3 to firmware ({OVERRUN_HINT})" @@ -369,7 +368,7 @@ def test_send_and_receive_limits(fw, uart_cfg, instance): assert error.value.reason == reason, line -# Swapping leaves the instance swapped until reset (a known gap), so these run last and reset the board. +# These run last and reset the board, so a swap that survived a close cannot leak into other tests. @pytest.fixture def reset_afterwards(fw, board_cfg): yield diff --git a/validation/host/tests/unit/test_expect.py b/validation/host/tests/unit/test_expect.py index e5df08b0..2ccb3f6c 100644 --- a/validation/host/tests/unit/test_expect.py +++ b/validation/host/tests/unit/test_expect.py @@ -38,8 +38,9 @@ def test_pwm_clock_and_ticks(): (WB55, 488, "center", 0xFFFF, False), (WB55, 32_000_000, "edge", 0xFFFF, True), (WB55, 32_000_001, "edge", 0xFFFF, False), - (WB55, 16_000_000, "center", 0xFFFF, True), - (WB55, 16_000_001, "center", 0xFFFF, False), + (WB55, 32_000_000, "center", 0xFFFF, True), + (WB55, 32_000_001, "center", 0xFFFF, False), + (WB55, 21_333_334, "center", 0xFFFF, True), (WBA55, 1000, "edge", 0xFFFF, False), (WBA55, 1000, "center", 0xFFFF, True), (WBA55, 1526, "edge", 0xFFFF, True), @@ -61,25 +62,24 @@ def test_pwm_frequency_quantisation(): assert expect.pwm_frequency(1_000_000, 300_000, "edge") == pytest.approx(333_333.33, rel=1e-6) -def test_pwm_frequency_of_the_driver(): - """PwmStm writes ARR = ticks - 1 centre aligned too: 2 * (ticks - 1) counts per period.""" - assert expect.pwm_frequency_driver(WB55, 10000, "edge") == expect.pwm_frequency(WB55, 10000, "edge") - assert expect.pwm_frequency_driver(WB55, 10000, "center") == pytest.approx(WB55 / 6398) - assert expect.pwm_frequency_driver(WB55, 1_000_000, "center") == pytest.approx(1_032_258, rel=1e-6) - assert expect.pwm_duty_driver(WB55, 10000, "center", 50) == pytest.approx(100 * 1600 / 3199) - assert expect.pwm_duty_driver(WB55, 10000, "edge", 50) == expect.pwm_duty(WB55, 10000, "edge", 50) +def test_pwm_auto_reload(): + """ARR = ticks - 1 edge aligned, ticks / 2 centre aligned (2 * ARR ticks per period).""" + assert expect.pwm_auto_reload(WB55, 10000, "edge") == 6399 + assert expect.pwm_auto_reload(WB55, 10000, "center") == 3200 + assert expect.pwm_auto_reload(WB55, 32_000_000, "center") == 1 + assert expect.pwm_frequency(WB55, 21_333_334, "center") == pytest.approx(32_000_000), "3 ticks: ARR 1" @pytest.mark.parametrize( ("pwmclk", "mode", "counter_max", "limits"), [ (WB55, "edge", 0xFFFF, (977, 32_000_000)), - (WB55, "center", 0xFFFF, (489, 16_000_000)), + (WB55, "center", 0xFFFF, (489, 32_000_000)), (WB55, "edge", 0xFFFFFFFF, (1, 32_000_000)), (WBA55, "edge", 0xFFFF, (1526, 50_000_000)), - (WBA55, "center", 0xFFFF, (763, 25_000_000)), + (WBA55, "center", 0xFFFF, (763, 50_000_000)), (976, "edge", 0xFFFF, (1, 488)), - (976, "center", 0xFFFF, (1, 244)), + (976, "center", 0xFFFF, (1, 488)), ], ) def test_pwm_frequency_limits(pwmclk, mode, counter_max, limits): diff --git a/validation/host/tests/unit/test_fake_firmware.py b/validation/host/tests/unit/test_fake_firmware.py index dafdc4bb..2472fe24 100644 --- a/validation/host/tests/unit/test_fake_firmware.py +++ b/validation/host/tests/unit/test_fake_firmware.py @@ -245,10 +245,10 @@ def test_gpio_exti_line_serves_one_port(): assert reason(terminal, "gpio.irq PA6 rising") == "ok" -def test_gpio_port_h_has_no_interrupt(): +def test_gpio_port_h_has_interrupt(): """PH3 (BOOT0) is the only port H pin and is reserved, so the rule is checked on the model.""" _, firmware = make_terminal() - assert not firmware.supports_interrupt("PH3") + assert firmware.supports_interrupt("PH3") assert firmware.supports_interrupt("PC13") diff --git a/validation/host/tests/unit/test_known_gaps.py b/validation/host/tests/unit/test_known_gaps.py index 6dfb8f9c..f383a9fe 100644 --- a/validation/host/tests/unit/test_known_gaps.py +++ b/validation/host/tests/unit/test_known_gaps.py @@ -86,9 +86,7 @@ def test_invalid_entries_are_rejected(gaps): @pytest.mark.parametrize("name", BOARDS) def test_board_files_cite_code(name): """Every gap names its source (`file:line`) and the symptom.""" - gaps = load_board(name).known_gaps - assert gaps - for gap in gaps: + for gap in load_board(name).known_gaps: assert ".cpp:" in gap.reason and " - " in gap.reason, gap.reason @@ -144,24 +142,29 @@ def collected_marks(tmp_path, board, *options): return marks +CTS_HELD = "test_uart.py::test_send_timeout_while_cts_held[usart2-variant=" + + def test_conftest_applies_the_gaps_of_the_board(tmp_path): marks = collected_marks(tmp_path, "nucleo_wba55cg", "--port", "nosuchport") - receive_only = {test_id: found for test_id, found in marks.items() if test_id.startswith("test_spi.py::test_receive_only_first[")} - assert [name for name, _ in receive_only["test_spi.py::test_receive_only_first[spi1-variant=interrupt]"]] == ["xfail"] - assert "SpiMasterStm.cpp:160-168" in receive_only["test_spi.py::test_receive_only_first[spi1-variant=interrupt]"][0][1] - sync = receive_only["test_spi.py::test_receive_only_first[spi1-variant=sync]"] + sync = marks[CTS_HELD + "sync]"] assert sync == [("skip", sync[0][1])] and sync[0][1].startswith("known gap (firmware aborts/hangs): ") - assert "test_spi.py::test_receive_only_first[spi1-variant=dma]" not in marks + assert "SynchronousUartStm.cpp" in sync[0][1] + assert CTS_HELD + "interrupt]" not in marks assert "test_pwm.py::test_open_errors" not in marks, "argument errors never reach the driver" - assert [name for name, _ in marks["test_pwm.py::test_duty_errors"]] == ["skip"] def test_run_known_gaps_turns_skips_into_expected_failures(tmp_path): - marks = collected_marks(tmp_path, "nucleo_wb55rg", "--port", "nosuchport", "--run-known-gaps") - assert [name for name, _ in marks["test_pwm.py::test_duty_errors"]] == ["xfail"] + marks = collected_marks(tmp_path, "nucleo_wba55cg", "--port", "nosuchport", "--run-known-gaps") + assert [name for name, _ in marks[CTS_HELD + "sync]"]] == ["xfail"] assert not any(name == "skip" and "known gap" in reason for found in marks.values() for name, reason in found) +def test_a_board_without_gaps_marks_nothing(tmp_path): + marks = collected_marks(tmp_path, "nucleo_wb55rg", "--port", "nosuchport") + assert not any("known gap" in reason for found in marks.values() for _, reason in found) + + def test_fake_ignores_the_gaps(tmp_path): marks = collected_marks(tmp_path, "nucleo_wba55cg", "--fake") assert not any("known gap" in reason for found in marks.values() for _, reason in found) From 63133a7b58ba8b21a76782f25ad0396d4b919f06 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:10:48 +0000 Subject: [PATCH 14/57] fix(validation): wire the WB55 TIM2 encoder B to PA1 and document header positions PB3 (TIM2 CH2, SWO) is on no header of a stock NUCLEO-WB55RG: CN10-30 carries PB13 unless SB12/SB13 are reworked (UM2435 Rev 2, Fig. 8, Table 11). TIM2 CH2 and the default encoder B move to PA1 (A2, CN7-32 via SB14), the only other TIM2 CH2 pin. Every WB55 wiring row now names its morpho pin and Arduino socket from UM2435 Rev 2 (MB1355C), plus the ground pins, the solder bridges on the path, the D10 bridge note (PA4 by default, confirmed) and the terminal pins to keep free. The loopback note says what the SPI tests do with it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- validation/PROTOCOL.md | 4 +- validation/README.md | 65 ++++---- .../boards/stm32wb55/BoardProfile.hpp | 4 +- validation/host/boards/nucleo_wb55rg.yaml | 146 ++++++++++++------ validation/host/boards/nucleo_wba55cg.yaml | 4 +- .../host/hal_st_validation/fake_firmware.py | 6 +- validation/host/tests/unit/test_config.py | 2 +- validation/host/tests/unit/test_firmware.py | 2 +- 8 files changed, 142 insertions(+), 91 deletions(-) diff --git a/validation/PROTOCOL.md b/validation/PROTOCOL.md index 1bbf32af..67e70596 100644 --- a/validation/PROTOCOL.md +++ b/validation/PROTOCOL.md @@ -20,14 +20,14 @@ Aliases name the pins by peripheral function: | `tim1ch1` `tim1ch2` `tim1ch3` `tim1ch4` | PA8 PA9 PA10 PA11 | PA11 PA12 PB4 PB3 | TIM1 channels 1-4 | | `tim1ch1n` `tim1ch2n` `tim1ch3n` | PA7 PB8 PB9 | PB2 PB1 PB0 | TIM1 complementary channels 1N-3N | | `tim1bkin` | PB12 | PA2 | TIM1 break input | -| `tim2ch1` `tim2ch2` `tim2ch3` `tim2ch4` | PA15 PB3 PA2 PA3 | - | TIM2 channels 1-4 (32-bit counter) | +| `tim2ch1` `tim2ch2` `tim2ch3` `tim2ch4` | PA15 PA1 PA2 PA3 | - | TIM2 channels 1-4 (32-bit counter) | | `tim2ch1` `tim2ch3` `tim2ch4` | - | PA5 PA7 PA6 | TIM2 channels 1, 3, 4 (CH2 is the terminal RX pin) | | `tim3ch1` `tim3ch2` `tim3ch3` `tim3ch4` | - | PA10 PA1 PB14 PB9 | TIM3 channels 1-4 | | `tim16ch1` `tim17ch1` | PA6 PB9 | PB9 PA1 | TIM16 / TIM17 channel 1 | | `tim17ch1n` | - | PB3 | TIM17 complementary channel 1N | | `qei1a` `qei1b` | PA8 PA9 | PA11 PA12 | TIM1 encoder phase A / B | | `qei1idx` | - | PA15 | index input of the default encoder | -| `qei2a` `qei2b` `qei2idx` | PA15 PB3 PC6 | - | TIM2 encoder phase A / B, index input | +| `qei2a` `qei2b` `qei2idx` | PA15 PA1 PC6 | - | TIM2 encoder phase A / B, index input | | `qei3a` `qei3b` | - | PA10 PA1 | TIM3 encoder phase A / B | | `lptim1in1` `lptim1in2` | PC0 PC2 | - | LPTIM1 encoder inputs | | `spi1clk` `spi1miso` `spi1mosi` `spi1cs` | PA5 PA6 PA7 PA4 | PB4 PB3 PA15 PA12 | SPI1 (Arduino D13/D12/D11/D10) | diff --git a/validation/README.md b/validation/README.md index c5788fe1..7fc21441 100644 --- a/validation/README.md +++ b/validation/README.md @@ -37,7 +37,7 @@ The structure follows hal-ti's `validation/`; the firmware runs on EMIL's hardwa - Tests whose connections are missing from the selected sets are skipped with the reason. - The terminal is USART1 on the ST-LINK virtual COM port (`/dev/ttyACM0`, `COMx`): PB6/PB7 on the NUCLEO-WB55RG, PB12/PA8 on the NUCLEO-WBA55CG, at 921600 baud. If the ST-LINK of a board cannot keep up with 921600 baud, change `terminalBaudRate` in `firmware/boards//BoardProfile.hpp` and `terminal.baud` in the board file together. -- The header positions in the tables come from the Arduino/morpho mapping of the Nucleo-64 boards; those marked "check UM2435" depend on solder bridges and should be checked against the board's user manual before wiring. +- The NUCLEO-WB55RG positions come from its user manual, UM2435 Rev 2 (board MB1355C: Table 10, Table 11, Fig. 8 and Fig. 24), cross-checked with the STM32CubeWB example readmes. A later board revision (MB1355D, user manual UM2819) may differ: check the solder bridges named in the table (SB1, SB5, SB8, SB11, SB14, SB15, SB41) before wiring. - NUCLEO-WB55RG: the firmware runs on the Cortex-M4 alone and never starts the wireless coprocessor; the linker script keeps it below the flash and SRAM the wireless stack uses. ## Build and flash the firmware @@ -202,37 +202,42 @@ pytest validation/host/tests/hil --board nucleo_wb55rg --wiring-set bundle1 --de ## Wiring sets -The tables are generated from the board files; the notes list every function a pin serves and the Arduino/morpho header position. Scope inputs are single ended: connect the `-` input of each used scope channel to GND. +The tables follow the `wiring_sets` of the board files (keep both in step); the notes list every function a pin serves. Scope inputs are single ended: connect the `-` input of each used scope channel to GND. ### NUCLEO-WB55RG wiring -| Wiring set | AD3 | Pin | Note | -|------------|----------|-------------------|-------------------------------------------------------------------------| -| `bundle1` | DIO0 | PA8 (tim1ch1) | TIM1 CH1, encoder TIM1 A, GPIO (D6) | -| `bundle1` | DIO1 | PA7 (tim1ch1n) | TIM1 CH1N, SPI1 MOSI, GPIO (D11) | -| `bundle1` | DIO2 | PA9 (tim1ch2) | TIM1 CH2, encoder TIM1 B, GPIO (D9) | -| `bundle1` | DIO3 | PB8 (tim1ch2n) | TIM1 CH2N, GPIO (D15) | -| `bundle1` | DIO4 | PA10 (tim1ch3) | TIM1 CH3, TIM17 break, GPIO (D3) | -| `bundle1` | DIO5 | PB9 (tim1ch3n) | TIM1 CH3N, TIM17 CH1, GPIO (D14) | -| `bundle1` | DIO6 | PA5 (spi1clk) | SPI1 SCK, GPIO (D13) | -| `bundle1` | DIO7 | PA6 (spi1miso) | SPI1 MISO, LPUART1 CTS, TIM16 CH1, GPIO (D12) | -| `bundle1` | DIO8 | PA4 (spi1cs) | SPI1 chip select, GPIO (D10, see the solder-bridge note) | -| `bundle1` | DIO9 | PA15 (qei2a) | TIM2 CH1, encoder TIM2 A, GPIO (D5) | -| `bundle1` | DIO10 | PB3 (qei2b) | TIM2 CH2, encoder TIM2 B, GPIO (CN10, check UM2435) | -| `bundle1` | DIO11 | PC6 (gpio0) | GPIO/EXTI, encoder TIM2 index, watchdog warning toggle (D2) | -| `bundle1` | DIO12 | PA2 (lpuart1tx) | LPUART1 TX, TIM2 CH3, GPIO (D1) | -| `bundle1` | DIO13 | PA3 (lpuart1rx) | LPUART1 RX, TIM2 CH4, GPIO (D0) | -| `bundle1` | DIO14 | PB12 (lpuart1rts) | LPUART1 RTS, TIM1 break, GPIO (CN10, check UM2435) | -| `bundle1` | DIO15 | PB0 (led0) | green LED output, GPIO (CN10, check UM2435) | -| `bundle1` | W1 | PC3 (ain4) | ADC1 IN4 (A4) | -| `bundle1` | W2 | PC2 (ain3) | ADC1 IN3 (A5) | -| `bundle1` | Scope 1+ | PC3 (ain4) | | -| `bundle1` | Scope 2+ | PC2 (ain3) | | -| `bundle1` | - | - | `--with loopback`: jumper PA7 (SPI1 MOSI, D11) to PA6 (SPI1 MISO, D12); DIO7 then only listens | -| `bundle1` | - | - | D10 is PA4 only with the default solder bridges (SB41 on, SB42 off; otherwise PB10), check against UM2435 | -| `bundle2` | DIO0-8, DIO11-15, W1, Scope 1+ | as `bundle1` | | -| `bundle2` | DIO9 | PC0 (lptim1in1) | LPTIM1 IN1, encoder LPTIM1 A, ADC1 IN1 (A0) | -| `bundle2` | DIO10 | PC2 (lptim1in2) | LPTIM1 IN2, encoder LPTIM1 B, ADC1 IN3 (A5); W2 and scope 2 unplugged | +`CN7-n`/`CN10-n` are the male ST morpho pins and the easiest to reach with AD3 flywires; `Dn`/`An` are the same nets on the female Arduino sockets. "via SBn" names a solder bridge that is closed on a stock board. PB3 (SWO) is on no header of a stock board, so TIM2 CH2 and the TIM2 encoder B use PA1. + +| Wiring set | AD3 | Pin | Nucleo header | Note | +|------------|----------|-------------------|---------------------------|-------------------------------------------------------------------------| +| both | GND | GND | CN10-9 and CN10-20 | common ground, at least two leads | +| `bundle1` | DIO0 | PA8 (tim1ch1) | CN10-25 (D6) | TIM1 CH1, encoder TIM1 A, GPIO | +| `bundle1` | DIO1 | PA7 (tim1ch1n) | CN10-15 via SB1 (D11) | TIM1 CH1N, SPI1 MOSI, GPIO | +| `bundle1` | DIO2 | PA9 (tim1ch2) | CN10-19 via SB8 (D9) | TIM1 CH2, encoder TIM1 B, GPIO | +| `bundle1` | DIO3 | PB8 (tim1ch2n) | CN10-3 (D15) | TIM1 CH2N, GPIO | +| `bundle1` | DIO4 | PA10 (tim1ch3) | CN10-31 via SB11 (D3) | TIM1 CH3, TIM17 break, GPIO | +| `bundle1` | DIO5 | PB9 (tim1ch3n) | CN10-5 (D14) | TIM1 CH3N, TIM17 CH1, GPIO; CN10-6 next to it is the terminal RX (PB7) | +| `bundle1` | DIO6 | PA5 (spi1clk) | CN10-11 (D13) | SPI1 SCK, GPIO | +| `bundle1` | DIO7 | PA6 (spi1miso) | CN10-13 (D12) | SPI1 MISO, LPUART1 CTS, TIM16 CH1, EXTI line 6 partner of PC6, GPIO | +| `bundle1` | DIO8 | PA4 (spi1cs) | CN10-17 via SB5 (D10 via SB41) | SPI1 chip select, GPIO | +| `bundle1` | DIO9 | PA15 (qei2a) | CN10-27 (D5) | TIM2 CH1, encoder TIM2 A, GPIO | +| `bundle1` | DIO10 | PA1 (qei2b) | CN7-32 via SB14 (A2) | TIM2 CH2, encoder TIM2 B, GPIO | +| `bundle1` | DIO11 | PC6 (gpio0) | CN10-33 (D2) | GPIO/EXTI, encoder TIM1 and TIM2 index, watchdog warning toggle; CN10-34 next to it is the terminal TX (PB6) | +| `bundle1` | DIO12 | PA2 (lpuart1tx) | CN10-35 via SB15 (D1) | LPUART1 TX, TIM2 CH3, GPIO | +| `bundle1` | DIO13 | PA3 (lpuart1rx) | CN10-37 (D0) | LPUART1 RX, TIM2 CH4, GPIO | +| `bundle1` | DIO14 | PB12 (lpuart1rts) | CN10-16 | LPUART1 RTS, TIM1 break, GPIO | +| `bundle1` | DIO15 | PB0 (led0) | CN10-22 | green LED2 output, GPIO; LED2 and its 680 ohm resistor load the pin | +| `bundle1` | W1 | PC3 (ain4) | CN7-36 (A4) | ADC1 IN4 | +| `bundle1` | W2 | PC2 (ain3) | CN7-38 (A5) | ADC1 IN3 | +| `bundle1` | Scope 1+ | PC3 (ain4) | A4 (CN8-5) | same net as W1 | +| `bundle1` | Scope 2+ | PC2 (ain3) | A5 (CN8-6) | same net as W2 | +| `bundle1` | Scope 1-, 2- | GND | CN7-19, CN7-20 (or CN7-8) | not CN7-22: Fig. 24 calls it GND, the schematic leaves it unconnected | +| `bundle1` | - | - | CN10-15 to CN10-13 | `--with loopback`: jumper PA7 (SPI1 MOSI) to PA6 (SPI1 MISO), or D11 to D12; the SPI tests then leave DIO7 an input and check the read-back | +| `bundle1` | - | - | - | D10 and CN10-17 carry PA4 with the default solder bridges (SB41 and SB5 closed, SB42 and SB6 open; PB10 otherwise) | +| `bundle1` | - | - | CN10-6, CN10-34, CN7-7, CN7-13, CN7-15 | keep free: terminal RX/TX to the ST-LINK (PB7, PB6), BOOT0 (PH3) and SWD (PA13, PA14) | +| `bundle2` | everything else | as `bundle1` | | including GND, the loopback option and the D10 note | +| `bundle2` | DIO9 | PC0 (lptim1in1) | CN7-28 (A0) | LPTIM1 IN1, encoder LPTIM1 A, ADC1 IN1 | +| `bundle2` | DIO10 | PC2 (lptim1in2) | CN7-38 (A5) | LPTIM1 IN2, encoder LPTIM1 B, ADC1 IN3; W2 and scope 2 unplugged | ### NUCLEO-WBA55CG wiring @@ -258,7 +263,7 @@ The tables are generated from the board files; the notes list every function a p | `bundle1` | W2 | PA6 (ain3) | ADC4 IN3 (A1) | | `bundle1` | Scope 1+ | PA7 (ain2) | | | `bundle1` | Scope 2+ | PA6 (ain3) | | -| `bundle1` | - | - | `--with loopback`: jumper PA15 (SPI1 MOSI, D11) to PB3 (SPI1 MISO, D12); DIO1 then only listens | +| `bundle1` | - | - | `--with loopback`: jumper PA15 (SPI1 MOSI, D11) to PB3 (SPI1 MISO, D12); the SPI tests then leave DIO1 an input and check the read-back | | `bundle1` | - | - | PB4 (D13) also drives the blue LED LD1, which loads the SPI1 clock line | ## What is tested diff --git a/validation/firmware/boards/stm32wb55/BoardProfile.hpp b/validation/firmware/boards/stm32wb55/BoardProfile.hpp index 0ad490e1..394481ce 100644 --- a/validation/firmware/boards/stm32wb55/BoardProfile.hpp +++ b/validation/firmware/boards/stm32wb55/BoardProfile.hpp @@ -39,7 +39,7 @@ namespace validation::board } }; inline constexpr std::optional defaultUart = UartPins{ 1, true, Pin(Port::A, 2), Pin(Port::A, 3) }; - inline constexpr QeiPins defaultQei{ 2, Pin(Port::A, 15), Pin(Port::B, 3), Pin(Port::C, 6) }; + inline constexpr QeiPins defaultQei{ 2, Pin(Port::A, 15), Pin(Port::A, 1), Pin(Port::C, 6) }; inline constexpr auto aliases = std::to_array({ { "terminaltx", terminal.tx }, @@ -59,7 +59,7 @@ namespace validation::board { "tim1ch3n", Pin(Port::B, 9) }, { "tim1bkin", Pin(Port::B, 12) }, { "tim2ch1", Pin(Port::A, 15) }, - { "tim2ch2", Pin(Port::B, 3) }, + { "tim2ch2", Pin(Port::A, 1) }, { "tim2ch3", Pin(Port::A, 2) }, { "tim2ch4", Pin(Port::A, 3) }, { "tim16ch1", Pin(Port::A, 6) }, diff --git a/validation/host/boards/nucleo_wb55rg.yaml b/validation/host/boards/nucleo_wb55rg.yaml index e45a13d6..1ba007d2 100644 --- a/validation/host/boards/nucleo_wb55rg.yaml +++ b/validation/host/boards/nucleo_wb55rg.yaml @@ -49,7 +49,7 @@ pins: tim1ch3n: PB9 tim1bkin: PB12 tim2ch1: PA15 - tim2ch2: PB3 + tim2ch2: PA1 tim2ch3: PA2 tim2ch4: PA3 tim16ch1: PA6 @@ -57,7 +57,7 @@ pins: qei1a: PA8 qei1b: PA9 qei2a: PA15 - qei2b: PB3 + qei2b: PA1 qei2idx: PC6 lptim1in1: PC0 lptim1in2: PC2 @@ -87,10 +87,13 @@ ad3: # Wavegen levels are refused outside this range (3.3 V I/O). analog_limits: [0.0, 3.3] -# Two fixed bundles: wire one, run its tests, then switch. Header positions -# follow the Nucleo-64 Arduino mapping; the CN10 morpho positions and the -# solder bridges are to be checked against UM2435. Always connect AD3 GND to -# a Nucleo GND pin. +# Two fixed bundles: wire one, run its tests, then switch. Positions are from +# UM2435 Rev 2 (MB1355C) Table 10, Table 11, Fig. 8 and Fig. 24: CNx-n is +# a male ST morpho pin (CN7, CN10), Dn/An the female Arduino socket (CN5, +# CN6, CN8, CN9). "via SBn" names a solder bridge that is closed on a +# stock board. CN10-6 (PB7) and CN10-34 (PB6) next to DIO5 and DIO11 are +# the terminal to the ST-LINK: keep them free. PB3 (SWO) is on no header +# of a stock board, so TIM2 CH2 / encoder B use PA1. wiring_sets: bundle1: description: >- @@ -99,69 +102,90 @@ wiring_sets: LED, watchdog toggle and ADC1; W1/W2 on two ADC inputs options: loopback: >- - jumper PA7 (SPI1 MOSI, D11) to PA6 (SPI1 MISO, D12); DIO7 then only - listens + jumper CN10-15 (PA7, SPI1 MOSI) to CN10-13 (PA6, SPI1 MISO), or D11 + to D12; the SPI tests then leave DIO7 an input and check the + read-back jumpers: - >- - D10 is PA4 only with the default solder bridges (SB41 on, SB42 off; - otherwise PB10), check against UM2435 + AD3 GND to CN10-9 and CN10-20; scope 1- and 2- to CN7-19 and CN7-20 + (or CN7-8); not CN7-22, which the schematic leaves unconnected + - >- + D10 and CN10-17 carry PA4 with the default solder bridges (SB41 and + SB5 closed, SB42 and SB6 open; PB10 otherwise), UM2435 Fig. 8 dio: 0: pin: tim1ch1 - note: TIM1 CH1, encoder TIM1 A, GPIO (D6) + note: >- + TIM1 CH1, encoder TIM1 A, GPIO (CN10-25; D6 = CN9-7) 1: pin: tim1ch1n - note: TIM1 CH1N, SPI1 MOSI, GPIO (D11) + note: >- + TIM1 CH1N, SPI1 MOSI, GPIO (CN10-15 via SB1; D11 = CN5-4) 2: pin: tim1ch2 - note: TIM1 CH2, encoder TIM1 B, GPIO (D9) + note: >- + TIM1 CH2, encoder TIM1 B, GPIO (CN10-19 via SB8; D9 = CN5-2) 3: pin: tim1ch2n - note: TIM1 CH2N, GPIO (D15) + note: >- + TIM1 CH2N, GPIO (CN10-3; D15 = CN5-10) 4: pin: tim1ch3 - note: TIM1 CH3, TIM17 break, GPIO (D3) + note: >- + TIM1 CH3, TIM17 break, GPIO (CN10-31 via SB11; D3 = CN9-4) 5: pin: tim1ch3n - note: TIM1 CH3N, TIM17 CH1, GPIO (D14) + note: >- + TIM1 CH3N, TIM17 CH1, GPIO (CN10-5; D14 = CN5-9) 6: pin: spi1clk - note: SPI1 SCK, GPIO (D13) + note: >- + SPI1 SCK, GPIO (CN10-11; D13 = CN5-6) 7: pin: spi1miso - note: SPI1 MISO, LPUART1 CTS, TIM16 CH1, GPIO (D12) + note: >- + SPI1 MISO, LPUART1 CTS, TIM16 CH1, EXTI line 6 partner of PC6, + GPIO (CN10-13; D12 = CN5-5) 8: pin: spi1cs - note: SPI1 chip select, GPIO (D10, see jumpers) + note: >- + SPI1 chip select, GPIO (CN10-17 via SB5; D10 = CN5-3 via SB41) 9: pin: qei2a - note: TIM2 CH1, encoder TIM2 A, GPIO (D5) + note: >- + TIM2 CH1, encoder TIM2 A, GPIO (CN10-27; D5 = CN9-6) 10: pin: qei2b - note: TIM2 CH2, encoder TIM2 B, GPIO (CN10, check UM2435) + note: >- + TIM2 CH2, encoder TIM2 B, GPIO (CN7-32 via SB14; A2 = CN8-3) 11: pin: gpio0 note: >- - GPIO/EXTI, encoder TIM2 index, watchdog warning toggle (D2) + GPIO/EXTI, encoder TIM1 and TIM2 index, watchdog warning toggle + (CN10-33; D2 = CN9-3) 12: pin: lpuart1tx - note: LPUART1 TX, TIM2 CH3, GPIO (D1) + note: >- + LPUART1 TX, TIM2 CH3, GPIO (CN10-35 via SB15; D1 = CN9-2) 13: pin: lpuart1rx - note: LPUART1 RX, TIM2 CH4, GPIO (D0) + note: >- + LPUART1 RX, TIM2 CH4, GPIO (CN10-37; D0 = CN9-1) 14: pin: lpuart1rts - note: LPUART1 RTS, TIM1 break, GPIO (CN10, check UM2435) + note: >- + LPUART1 RTS, TIM1 break, GPIO (CN10-16) 15: pin: led0 - note: green LED output, GPIO (CN10, check UM2435) + note: >- + green LED2 output, GPIO; LED2 and 680 ohm load the pin (CN10-22) wavegen: 1: pin: ain4 - note: ADC1 IN4 (A4) + note: ADC1 IN4 (CN7-36; A4 = CN8-5) 2: pin: ain3 - note: ADC1 IN3 (A5) + note: ADC1 IN3 (CN7-38; A5 = CN8-6) scope: 1: ain4 2: ain3 @@ -173,65 +197,87 @@ wiring_sets: with it options: loopback: >- - jumper PA7 (SPI1 MOSI, D11) to PA6 (SPI1 MISO, D12); DIO7 then only - listens + jumper CN10-15 (PA7, SPI1 MOSI) to CN10-13 (PA6, SPI1 MISO), or D11 + to D12; the SPI tests then leave DIO7 an input and check the + read-back jumpers: - >- - D10 is PA4 only with the default solder bridges (SB41 on, SB42 off; - otherwise PB10), check against UM2435 + AD3 GND to CN10-9 and CN10-20; scope 1- and 2- to CN7-19 and CN7-20 + (or CN7-8); not CN7-22, which the schematic leaves unconnected + - >- + D10 and CN10-17 carry PA4 with the default solder bridges (SB41 and + SB5 closed, SB42 and SB6 open; PB10 otherwise), UM2435 Fig. 8 dio: 0: pin: tim1ch1 - note: TIM1 CH1, encoder TIM1 A, GPIO (D6) + note: >- + TIM1 CH1, encoder TIM1 A, GPIO (CN10-25; D6 = CN9-7) 1: pin: tim1ch1n - note: TIM1 CH1N, SPI1 MOSI, GPIO (D11) + note: >- + TIM1 CH1N, SPI1 MOSI, GPIO (CN10-15 via SB1; D11 = CN5-4) 2: pin: tim1ch2 - note: TIM1 CH2, encoder TIM1 B, GPIO (D9) + note: >- + TIM1 CH2, encoder TIM1 B, GPIO (CN10-19 via SB8; D9 = CN5-2) 3: pin: tim1ch2n - note: TIM1 CH2N, GPIO (D15) + note: >- + TIM1 CH2N, GPIO (CN10-3; D15 = CN5-10) 4: pin: tim1ch3 - note: TIM1 CH3, TIM17 break, GPIO (D3) + note: >- + TIM1 CH3, TIM17 break, GPIO (CN10-31 via SB11; D3 = CN9-4) 5: pin: tim1ch3n - note: TIM1 CH3N, TIM17 CH1, GPIO (D14) + note: >- + TIM1 CH3N, TIM17 CH1, GPIO (CN10-5; D14 = CN5-9) 6: pin: spi1clk - note: SPI1 SCK, GPIO (D13) + note: >- + SPI1 SCK, GPIO (CN10-11; D13 = CN5-6) 7: pin: spi1miso - note: SPI1 MISO, LPUART1 CTS, TIM16 CH1, GPIO (D12) + note: >- + SPI1 MISO, LPUART1 CTS, TIM16 CH1, EXTI line 6 partner of PC6, + GPIO (CN10-13; D12 = CN5-5) 8: pin: spi1cs - note: SPI1 chip select, GPIO (D10, see jumpers) + note: >- + SPI1 chip select, GPIO (CN10-17 via SB5; D10 = CN5-3 via SB41) 9: pin: lptim1in1 - note: LPTIM1 IN1, encoder LPTIM1 A, ADC1 IN1 (A0) + note: >- + LPTIM1 IN1, encoder LPTIM1 A, ADC1 IN1 (CN7-28; A0 = CN8-1) 10: pin: lptim1in2 - note: LPTIM1 IN2, encoder LPTIM1 B, ADC1 IN3 (A5) + note: >- + LPTIM1 IN2, encoder LPTIM1 B, ADC1 IN3 (CN7-38; A5 = CN8-6) 11: pin: gpio0 - note: GPIO/EXTI, watchdog warning toggle (D2) + note: >- + GPIO/EXTI, encoder TIM1 and TIM2 index, watchdog warning toggle + (CN10-33; D2 = CN9-3) 12: pin: lpuart1tx - note: LPUART1 TX, TIM2 CH3, GPIO (D1) + note: >- + LPUART1 TX, TIM2 CH3, GPIO (CN10-35 via SB15; D1 = CN9-2) 13: pin: lpuart1rx - note: LPUART1 RX, TIM2 CH4, GPIO (D0) + note: >- + LPUART1 RX, TIM2 CH4, GPIO (CN10-37; D0 = CN9-1) 14: pin: lpuart1rts - note: LPUART1 RTS, TIM1 break, GPIO (CN10, check UM2435) + note: >- + LPUART1 RTS, TIM1 break, GPIO (CN10-16) 15: pin: led0 - note: green LED output, GPIO (CN10, check UM2435) + note: >- + green LED2 output, GPIO; LED2 and 680 ohm load the pin (CN10-22) wavegen: 1: pin: ain4 - note: ADC1 IN4 (A4) + note: ADC1 IN4 (CN7-36; A4 = CN8-5) scope: 1: ain4 @@ -498,7 +544,7 @@ tests: samples: 16 # The two inputs driven by W1/W2. inputs: [ain4, ain3] - # Analog-capable pins the sequence tests may add (unwired, any reading). + # Analog-capable pins the sequence tests may add (codes not checked). spare_inputs: [ain1, ain2, ain5, ain6] levels: pin: [ain4, ain3] diff --git a/validation/host/boards/nucleo_wba55cg.yaml b/validation/host/boards/nucleo_wba55cg.yaml index abae32c1..9f2149fa 100644 --- a/validation/host/boards/nucleo_wba55cg.yaml +++ b/validation/host/boards/nucleo_wba55cg.yaml @@ -105,8 +105,8 @@ wiring_sets: on two ADC inputs options: loopback: >- - jumper PA15 (SPI1 MOSI, D11) to PB3 (SPI1 MISO, D12); DIO1 then only - listens + jumper PA15 (SPI1 MOSI, D11) to PB3 (SPI1 MISO, D12); the SPI tests + then leave DIO1 an input and check the read-back jumpers: - >- PB4 (D13) also drives the blue LED LD1, which loads the SPI1 clock diff --git a/validation/host/hal_st_validation/fake_firmware.py b/validation/host/hal_st_validation/fake_firmware.py index f444a5ee..6a9f86f6 100644 --- a/validation/host/hal_st_validation/fake_firmware.py +++ b/validation/host/hal_st_validation/fake_firmware.py @@ -47,7 +47,7 @@ "tim1ch3n": "PB9", "tim1bkin": "PB12", "tim2ch1": "PA15", - "tim2ch2": "PB3", + "tim2ch2": "PA1", "tim2ch3": "PA2", "tim2ch4": "PA3", "tim16ch1": "PA6", @@ -55,7 +55,7 @@ "qei1a": "PA8", "qei1b": "PA9", "qei2a": "PA15", - "qei2b": "PB3", + "qei2b": "PA1", "qei2idx": "PC6", "lptim1in1": "PC0", "lptim1in2": "PC2", @@ -256,7 +256,7 @@ def sampling(self) -> tuple[str, ...]: lptims=frozenset({1}), adc=1, default_lpuart=("PA2", "PA3"), - default_qei=(2, "PA15", "PB3", "PC6"), + default_qei=(2, "PA15", "PA1", "PC6"), functions=_WB55_FUNCTIONS, analog={ **{"PA0": 5, "PA1": 6, "PA2": 7, "PA3": 8, "PA4": 9, "PA5": 10, "PA6": 11, "PA7": 12, "PA8": 15, "PA9": 16}, diff --git a/validation/host/tests/unit/test_config.py b/validation/host/tests/unit/test_config.py index d38284af..99c43d41 100644 --- a/validation/host/tests/unit/test_config.py +++ b/validation/host/tests/unit/test_config.py @@ -85,7 +85,7 @@ def test_bundle_sets(): def test_wb55_bundle2_moves_the_encoder_inputs(): board = load_board("nucleo_wb55rg") bundle1, bundle2 = board.wiring(["bundle1"]), board.wiring(["bundle2"]) - assert bundle1.dio("PA15") == 9 and bundle1.dio("PB3") == 10 + assert bundle1.dio("PA15") == 9 and bundle1.dio("PA1") == 10 assert bundle2.dio("PC0") == 9 and bundle2.dio("PC2") == 10 assert bundle2.wavegen("PC2") is None and bundle2.scope("PC2") is None assert bundle1.wavegen("PC2") == 2 diff --git a/validation/host/tests/unit/test_firmware.py b/validation/host/tests/unit/test_firmware.py index 3c92ef7d..c404ca01 100644 --- a/validation/host/tests/unit/test_firmware.py +++ b/validation/host/tests/unit/test_firmware.py @@ -173,7 +173,7 @@ def test_adc_formatting(fw, fake): def test_qei_formatting(fw, fake): fw.qei.open(2, a="qei2a", b="qei2b", idx="qei2idx", res=100, offset=5, inva=True, invb=False, cap="b", filter=3, vel=500) - assert last(fake) == "qei.open 2 a=PA15 b=PB3 idx=PC6 res=100 offset=5 inva=1 invb=0 cap=b filter=3 vel=500" + assert last(fake) == "qei.open 2 a=PA15 b=PA1 idx=PC6 res=100 offset=5 inva=1 invb=0 cap=b filter=3 vel=500" reading = fw.qei.read(2) assert (reading.pos, reading.dir, reading.speed, reading.res) == (5, "fwd", 0, 100) fake.gpio_levels["PC6"] = 1 From 4fe360043fb30d4cf6c7ef54ef2996a53e42cb75 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:13:22 +0000 Subject: [PATCH 15/57] fix(uart): deinitialise the UART before gating its clock AGENTS.md asks for HAL_FOO_DeInit and a clock disable in every driver destructor. UartStm gated the clock without HAL_UART_DeInit, and SynchronousUartStm/SynchronousUartStmSendOnly did neither. The explicit interrupt-enable clear stays: the F4 HAL_UART_DeInit leaves CR1 as is. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/UartStm.cpp | 1 + .../SynchronousUartStm.cpp | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/hal_st/stm32fxxx/UartStm.cpp b/hal_st/stm32fxxx/UartStm.cpp index 176e32eb..0c0aa787 100644 --- a/hal_st/stm32fxxx/UartStm.cpp +++ b/hal_st/stm32fxxx/UartStm.cpp @@ -99,6 +99,7 @@ namespace hal UartStm::~UartStm() { uartArray[uartIndex]->CR1 &= ~(USART_CR1_TXEIE | USART_CR1_TCIE | USART_CR1_RXNEIE | USART_CR1_TE | USART_CR1_RE); + HAL_UART_DeInit(&uartHandle); #if defined(HAS_PERIPHERAL_LPUART) if (uartArray.begin() == peripheralLpuart.begin()) diff --git a/hal_st/synchronous_stm32fxxx/SynchronousUartStm.cpp b/hal_st/synchronous_stm32fxxx/SynchronousUartStm.cpp index dfc04773..123ff7cc 100644 --- a/hal_st/synchronous_stm32fxxx/SynchronousUartStm.cpp +++ b/hal_st/synchronous_stm32fxxx/SynchronousUartStm.cpp @@ -52,6 +52,11 @@ namespace hal SynchronousUartStm::~SynchronousUartStm() { peripheralUart[uartIndex]->CR1 &= ~(USART_CR1_RXNEIE | USART_CR1_TE | USART_CR1_RE); + + UART_HandleTypeDef uartHandle = {}; + uartHandle.Instance = peripheralUart[uartIndex]; + HAL_UART_DeInit(&uartHandle); + DisableClockUart(uartIndex); } void SynchronousUartStm::SendData(infra::ConstByteRange data) @@ -185,6 +190,23 @@ namespace hal SynchronousUartStmSendOnly::~SynchronousUartStmSendOnly() { uartBase->CR1 &= ~(USART_CR1_RXNEIE | USART_CR1_TE | USART_CR1_RE); + + UART_HandleTypeDef uartHandle = {}; + uartHandle.Instance = uartBase; + HAL_UART_DeInit(&uartHandle); + +#if defined(HAS_PERIPHERAL_LPUART) + for (std::size_t i = 0; i != peripheralLpuart.size(); ++i) + if (peripheralLpuart[i] == uartBase) + { + DisableClockLpuart(i); + return; + } +#endif + + for (std::size_t i = 0; i != peripheralUart.size(); ++i) + if (peripheralUart[i] == uartBase) + DisableClockUart(i); } void SynchronousUartStmSendOnly::SendData(infra::ConstByteRange data) From d93f14bf4a8edaf5b93af0c939aec00b1dc38a0d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:13:22 +0000 Subject: [PATCH 16/57] fix(adc): reject a repeated channel with a different sampling time The sampling time (SMPRx, or SMPSEL on ADC4) belongs to the channel, not to the rank, so configuring a repeated channel with another sampling time silently changed the earlier rank too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/AdcDmaMultiChannelStm.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/hal_st/stm32fxxx/AdcDmaMultiChannelStm.cpp b/hal_st/stm32fxxx/AdcDmaMultiChannelStm.cpp index 1066e02d..b07f3726 100644 --- a/hal_st/stm32fxxx/AdcDmaMultiChannelStm.cpp +++ b/hal_st/stm32fxxx/AdcDmaMultiChannelStm.cpp @@ -149,6 +149,12 @@ namespace hal for (std::size_t i = 0; i != analogPins.size(); ++i) { + channelConfig.Channel = adc.Channel(analogPins[i]); + + // The sampling time is selected per channel, not per rank + for (std::size_t j = 0; j != i; ++j) + really_assert(adc.Channel(analogPins[j]) != channelConfig.Channel || configs[j].samplingTime == configs[i].samplingTime); + #if defined(ADC_SMPR_SMP1) channelConfig.SamplingTime = SelectSamplingTimeCommon(adc.Handle().Instance, assignedSamplingTimes, configs[i].samplingTime); #else @@ -157,7 +163,6 @@ namespace hal #ifdef ADC_SINGLE_ENDED channelConfig.SingleDiff = configs[i].differential ? ADC_DIFFERENTIAL_ENDED : ADC_SINGLE_ENDED; #endif - channelConfig.Channel = adc.Channel(analogPins[i]); channelConfig.Rank = rank[i]; auto result = HAL_ADC_ConfigChannel(&adc.Handle(), &channelConfig); assert(result == HAL_OK); From e7dfbd4af1f26b0a7f6ceaa6545dda0fbde2dc2d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 11:13:23 +0000 Subject: [PATCH 17/57] docs(validation): align the markdown tables Formatted with markdown-table-formatter, as MegaLinter suggests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- validation/PROTOCOL.md | 54 ++++++++-------- validation/README.md | 138 ++++++++++++++++++++--------------------- 2 files changed, 96 insertions(+), 96 deletions(-) diff --git a/validation/PROTOCOL.md b/validation/PROTOCOL.md index 67e70596..65b8fb8a 100644 --- a/validation/PROTOCOL.md +++ b/validation/PROTOCOL.md @@ -11,34 +11,34 @@ The terminal is a `hal::UartStmDuplexDma` on USART1, the ST-LINK virtual COM por Aliases name the pins by peripheral function: -| Alias | NUCLEO-WB55RG | NUCLEO-WBA55CG | Function | -|-----------------------------------------------|-------------------------|-------------------------|--------------------------------------------| -| `terminaltx` `terminalrx` | PB6 PB7 | PB12 PA8 | terminal USART1 TX / RX | -| `ain1` `ain2` `ain3` `ain4` `ain5` `ain6` | PC0 PC1 PC2 PC3 PA0 PA1 | - | ADC1 inputs IN1-IN6 | -| `ain2` `ain3` `ain4` `ain7` `ain8` `ain9` | - | PA7 PA6 PA5 PA2 PA1 PA0 | ADC4 inputs | -| `ain10` | - | PB9 | ADC4 input IN10 | -| `tim1ch1` `tim1ch2` `tim1ch3` `tim1ch4` | PA8 PA9 PA10 PA11 | PA11 PA12 PB4 PB3 | TIM1 channels 1-4 | -| `tim1ch1n` `tim1ch2n` `tim1ch3n` | PA7 PB8 PB9 | PB2 PB1 PB0 | TIM1 complementary channels 1N-3N | -| `tim1bkin` | PB12 | PA2 | TIM1 break input | -| `tim2ch1` `tim2ch2` `tim2ch3` `tim2ch4` | PA15 PA1 PA2 PA3 | - | TIM2 channels 1-4 (32-bit counter) | -| `tim2ch1` `tim2ch3` `tim2ch4` | - | PA5 PA7 PA6 | TIM2 channels 1, 3, 4 (CH2 is the terminal RX pin) | -| `tim3ch1` `tim3ch2` `tim3ch3` `tim3ch4` | - | PA10 PA1 PB14 PB9 | TIM3 channels 1-4 | -| `tim16ch1` `tim17ch1` | PA6 PB9 | PB9 PA1 | TIM16 / TIM17 channel 1 | -| `tim17ch1n` | - | PB3 | TIM17 complementary channel 1N | -| `qei1a` `qei1b` | PA8 PA9 | PA11 PA12 | TIM1 encoder phase A / B | -| `qei1idx` | - | PA15 | index input of the default encoder | -| `qei2a` `qei2b` `qei2idx` | PA15 PA1 PC6 | - | TIM2 encoder phase A / B, index input | -| `qei3a` `qei3b` | - | PA10 PA1 | TIM3 encoder phase A / B | -| `lptim1in1` `lptim1in2` | PC0 PC2 | - | LPTIM1 encoder inputs | -| `spi1clk` `spi1miso` `spi1mosi` `spi1cs` | PA5 PA6 PA7 PA4 | PB4 PB3 PA15 PA12 | SPI1 (Arduino D13/D12/D11/D10) | -| `lpuart1tx` `lpuart1rx` | PA2 PA3 | PB5 PA10 | LPUART1 TX / RX (Arduino D1/D0) | -| `lpuart1rts` `lpuart1cts` | PB12 PA6 | PB9 PB15 | LPUART1 RTS / CTS | -| `usart2tx` `usart2rx` `usart2rts` `usart2cts` | - | PB0 PA11 PB1 PB2 | USART2 | +| Alias | NUCLEO-WB55RG | NUCLEO-WBA55CG | Function | +|-----------------------------------------------|-------------------------|-------------------------|------------------------------------------------------| +| `terminaltx` `terminalrx` | PB6 PB7 | PB12 PA8 | terminal USART1 TX / RX | +| `ain1` `ain2` `ain3` `ain4` `ain5` `ain6` | PC0 PC1 PC2 PC3 PA0 PA1 | - | ADC1 inputs IN1-IN6 | +| `ain2` `ain3` `ain4` `ain7` `ain8` `ain9` | - | PA7 PA6 PA5 PA2 PA1 PA0 | ADC4 inputs | +| `ain10` | - | PB9 | ADC4 input IN10 | +| `tim1ch1` `tim1ch2` `tim1ch3` `tim1ch4` | PA8 PA9 PA10 PA11 | PA11 PA12 PB4 PB3 | TIM1 channels 1-4 | +| `tim1ch1n` `tim1ch2n` `tim1ch3n` | PA7 PB8 PB9 | PB2 PB1 PB0 | TIM1 complementary channels 1N-3N | +| `tim1bkin` | PB12 | PA2 | TIM1 break input | +| `tim2ch1` `tim2ch2` `tim2ch3` `tim2ch4` | PA15 PA1 PA2 PA3 | - | TIM2 channels 1-4 (32-bit counter) | +| `tim2ch1` `tim2ch3` `tim2ch4` | - | PA5 PA7 PA6 | TIM2 channels 1, 3, 4 (CH2 is the terminal RX pin) | +| `tim3ch1` `tim3ch2` `tim3ch3` `tim3ch4` | - | PA10 PA1 PB14 PB9 | TIM3 channels 1-4 | +| `tim16ch1` `tim17ch1` | PA6 PB9 | PB9 PA1 | TIM16 / TIM17 channel 1 | +| `tim17ch1n` | - | PB3 | TIM17 complementary channel 1N | +| `qei1a` `qei1b` | PA8 PA9 | PA11 PA12 | TIM1 encoder phase A / B | +| `qei1idx` | - | PA15 | index input of the default encoder | +| `qei2a` `qei2b` `qei2idx` | PA15 PA1 PC6 | - | TIM2 encoder phase A / B, index input | +| `qei3a` `qei3b` | - | PA10 PA1 | TIM3 encoder phase A / B | +| `lptim1in1` `lptim1in2` | PC0 PC2 | - | LPTIM1 encoder inputs | +| `spi1clk` `spi1miso` `spi1mosi` `spi1cs` | PA5 PA6 PA7 PA4 | PB4 PB3 PA15 PA12 | SPI1 (Arduino D13/D12/D11/D10) | +| `lpuart1tx` `lpuart1rx` | PA2 PA3 | PB5 PA10 | LPUART1 TX / RX (Arduino D1/D0) | +| `lpuart1rts` `lpuart1cts` | PB12 PA6 | PB9 PB15 | LPUART1 RTS / CTS | +| `usart2tx` `usart2rx` `usart2rts` `usart2cts` | - | PB0 PA11 PB1 PB2 | USART2 | | `led0` `led1` | PB0 PB1 | PB4 PA9 | user LEDs (green LD2, red LD3 / blue LD1, green LD2) | -| `gpio0` | PC6 | PB14 | general-purpose test pin, watchdog warning toggle | -| `gpio1` `gpio2` | PC10 PC12 | PA5 PA0 | general-purpose test pins | -| `gpio3` `gpio4` | PC13 PE4 | - | general-purpose test pins | -| `sw1` `sw2` `sw3` | PC4 PD0 PD1 | PC13 PB6 PB7 | user buttons (input only, pulled up) | +| `gpio0` | PC6 | PB14 | general-purpose test pin, watchdog warning toggle | +| `gpio1` `gpio2` | PC10 PC12 | PA5 PA0 | general-purpose test pins | +| `gpio3` `gpio4` | PC13 PE4 | - | general-purpose test pins | +| `sw1` `sw2` `sw3` | PC4 PD0 PD1 | PC13 PB6 PB7 | user buttons (input only, pulled up) | - Wherever a pin is expected, an alias from this table may be used instead of `P`; several aliases may name the same pin, and no alias carries a default pull. - Per-instance default pins apply only when a command gets no pins at all: LPUART1 `lpuart1tx`/`lpuart1rx`; the default encoder (WB55 TIM2 `qei2a`/`qei2b`/`qei2idx`, WBA55 TIM1 `qei1a`/`qei1b`/`qei1idx`). Other UART and encoder instances need their pins; SPI and the ADC always do, PWM needs `channels` or `pins`. diff --git a/validation/README.md b/validation/README.md index 7fc21441..b03f907a 100644 --- a/validation/README.md +++ b/validation/README.md @@ -17,12 +17,12 @@ The structure follows hal-ti's `validation/`; the firmware runs on EMIL's hardwa ## What the AD3 does -| Protocol or signal | AD3 capability | How the tests use it | -|----------------------------|----------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------| -| UART | Full TX/RX through the SDK protocol UART (`FDwfDigitalUart*`) | Peer in both directions; the logic analyzer decodes the firmware TX line and its bit rate | -| SPI | SPI master in the SDK; SPI slave reported in recent WaveForms, unverified in SDK | Firmware is the master: logic-analyzer decode, static MISO level or MOSI-MISO jumper | -| PWM, encoder, GPIO, WWDG | Pattern generator, logic analyzer with DIO-edge trigger, static DIO | Encoder signals, pulse trains, break inputs and CTS; frequency, duty, dead time and periods | -| ADC | Wavegen W1/W2 (DC) and scope channels 1/2 | Analog levels on the inputs; the scope measures the level actually applied | +| Protocol or signal | AD3 capability | How the tests use it | +|--------------------------|----------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------| +| UART | Full TX/RX through the SDK protocol UART (`FDwfDigitalUart*`) | Peer in both directions; the logic analyzer decodes the firmware TX line and its bit rate | +| SPI | SPI master in the SDK; SPI slave reported in recent WaveForms, unverified in SDK | Firmware is the master: logic-analyzer decode, static MISO level or MOSI-MISO jumper | +| PWM, encoder, GPIO, WWDG | Pattern generator, logic analyzer with DIO-edge trigger, static DIO | Encoder signals, pulse trains, break inputs and CTS; frequency, duty, dead time and periods | +| ADC | Wavegen W1/W2 (DC) and scope channels 1/2 | Analog levels on the inputs; the scope measures the level actually applied | ## Hardware setup @@ -128,11 +128,11 @@ Use `-k`, `-m "not slow"` and `--junitxml report.xml` as usual. Writing the firmware against the drivers showed hal-st bugs that the suite runs into. Each board file lists them under `known_gaps` with the test ids they affect: a gap that aborts or hangs the firmware skips its tests unless `--run-known-gaps` is given, any other is an expected failure (`xfail`, not strict). `--fake` ignores them. -| Board | Driver | Effect | Source | -|-------|--------|--------|--------| -| WBA55 | `SynchronousUartStm` | A send with CTS held off blocks the event loop with no timeout. | `hal_st/synchronous_stm32fxxx/SynchronousUartStm.cpp:62` | -| both | `UartStm` | `uart.send` completes while up to 9 bytes are still in the TX FIFO and shift register, so a close right after a send can cut them. | `hal_st/stm32fxxx/UartStm.cpp:202-205` | -| both | `WatchDogStm` | Only the window watchdog exists: timeouts are limited to about 516 ms (WB55) and 330 ms (WBA55), and there is no IWDG driver. | `hal_st/stm32fxxx/WatchDogStm.cpp` | +| Board | Driver | Effect | Source | +|-------|----------------------|------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------| +| WBA55 | `SynchronousUartStm` | A send with CTS held off blocks the event loop with no timeout. | `hal_st/synchronous_stm32fxxx/SynchronousUartStm.cpp:62` | +| both | `UartStm` | `uart.send` completes while up to 9 bytes are still in the TX FIFO and shift register, so a close right after a send can cut them. | `hal_st/stm32fxxx/UartStm.cpp:202-205` | +| both | `WatchDogStm` | Only the window watchdog exists: timeouts are limited to about 516 ms (WB55) and 330 ms (WBA55), and there is no IWDG driver. | `hal_st/stm32fxxx/WatchDogStm.cpp` | The gaps found earlier in `PwmStm`, `AdcStm`/`AdcDmaMultiChannelStm`/`AdcTimerTriggeredBase` (WBA55 ADC4), `SpiMasterStm`/`SynchronousSpiMasterStm` (receive-only start), `UartStm` (TXEIE after close, SWAP, overrun), `SynchronousQuadratureEncoderLpTimStm` (filter carry-over) and `GpioStm` (EXTI on port H) are fixed, and the tests that exposed them now guard the fixes. The port H fix has no HIL test: PH3 (BOOT0) is the only port H pin on both boards and is reserved. @@ -151,11 +151,11 @@ Use this when the build and the tests run in the hal-st devcontainer (Docker) bu └─────────────────────────────────┘ └─────────────────────────────┘ ``` -| Port | Windows service | Used in the container | -|-------|-----------------|-----------------------| -| 5025 | [`ad3-bench-server`](https://github.com/embedded-pro/ad3-waveforms-bench) (WaveForms, AD3) | `--ad3-remote host.docker.internal:5025` or `AD3_REMOTE` | -| 5000 | [`port-bridge`](https://github.com/gabrielfrasantos/port-bridge) serial (firmware terminal) | `--port socket://host.docker.internal:5000` or `HAL_ST_PORT` | -| 61234 | ST-LINK GDB server of STM32CubeIDE / STM32CubeCLT | `gdb-multiarch ... -ex "target remote host.docker.internal:61234"`, and the `stm32wb55rg` / `stm32wba55cg` VS Code launch configurations | +| Port | Windows service | Used in the container | +|-------|---------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------| +| 5025 | [`ad3-bench-server`](https://github.com/embedded-pro/ad3-waveforms-bench) (WaveForms, AD3) | `--ad3-remote host.docker.internal:5025` or `AD3_REMOTE` | +| 5000 | [`port-bridge`](https://github.com/gabrielfrasantos/port-bridge) serial (firmware terminal) | `--port socket://host.docker.internal:5000` or `HAL_ST_PORT` | +| 61234 | ST-LINK GDB server of STM32CubeIDE / STM32CubeCLT | `gdb-multiarch ... -ex "target remote host.docker.internal:61234"`, and the `stm32wb55rg` / `stm32wba55cg` VS Code launch configurations | On Windows: @@ -208,63 +208,63 @@ The tables follow the `wiring_sets` of the board files (keep both in step); the `CN7-n`/`CN10-n` are the male ST morpho pins and the easiest to reach with AD3 flywires; `Dn`/`An` are the same nets on the female Arduino sockets. "via SBn" names a solder bridge that is closed on a stock board. PB3 (SWO) is on no header of a stock board, so TIM2 CH2 and the TIM2 encoder B use PA1. -| Wiring set | AD3 | Pin | Nucleo header | Note | -|------------|----------|-------------------|---------------------------|-------------------------------------------------------------------------| -| both | GND | GND | CN10-9 and CN10-20 | common ground, at least two leads | -| `bundle1` | DIO0 | PA8 (tim1ch1) | CN10-25 (D6) | TIM1 CH1, encoder TIM1 A, GPIO | -| `bundle1` | DIO1 | PA7 (tim1ch1n) | CN10-15 via SB1 (D11) | TIM1 CH1N, SPI1 MOSI, GPIO | -| `bundle1` | DIO2 | PA9 (tim1ch2) | CN10-19 via SB8 (D9) | TIM1 CH2, encoder TIM1 B, GPIO | -| `bundle1` | DIO3 | PB8 (tim1ch2n) | CN10-3 (D15) | TIM1 CH2N, GPIO | -| `bundle1` | DIO4 | PA10 (tim1ch3) | CN10-31 via SB11 (D3) | TIM1 CH3, TIM17 break, GPIO | -| `bundle1` | DIO5 | PB9 (tim1ch3n) | CN10-5 (D14) | TIM1 CH3N, TIM17 CH1, GPIO; CN10-6 next to it is the terminal RX (PB7) | -| `bundle1` | DIO6 | PA5 (spi1clk) | CN10-11 (D13) | SPI1 SCK, GPIO | -| `bundle1` | DIO7 | PA6 (spi1miso) | CN10-13 (D12) | SPI1 MISO, LPUART1 CTS, TIM16 CH1, EXTI line 6 partner of PC6, GPIO | -| `bundle1` | DIO8 | PA4 (spi1cs) | CN10-17 via SB5 (D10 via SB41) | SPI1 chip select, GPIO | -| `bundle1` | DIO9 | PA15 (qei2a) | CN10-27 (D5) | TIM2 CH1, encoder TIM2 A, GPIO | -| `bundle1` | DIO10 | PA1 (qei2b) | CN7-32 via SB14 (A2) | TIM2 CH2, encoder TIM2 B, GPIO | -| `bundle1` | DIO11 | PC6 (gpio0) | CN10-33 (D2) | GPIO/EXTI, encoder TIM1 and TIM2 index, watchdog warning toggle; CN10-34 next to it is the terminal TX (PB6) | -| `bundle1` | DIO12 | PA2 (lpuart1tx) | CN10-35 via SB15 (D1) | LPUART1 TX, TIM2 CH3, GPIO | -| `bundle1` | DIO13 | PA3 (lpuart1rx) | CN10-37 (D0) | LPUART1 RX, TIM2 CH4, GPIO | -| `bundle1` | DIO14 | PB12 (lpuart1rts) | CN10-16 | LPUART1 RTS, TIM1 break, GPIO | -| `bundle1` | DIO15 | PB0 (led0) | CN10-22 | green LED2 output, GPIO; LED2 and its 680 ohm resistor load the pin | -| `bundle1` | W1 | PC3 (ain4) | CN7-36 (A4) | ADC1 IN4 | -| `bundle1` | W2 | PC2 (ain3) | CN7-38 (A5) | ADC1 IN3 | -| `bundle1` | Scope 1+ | PC3 (ain4) | A4 (CN8-5) | same net as W1 | -| `bundle1` | Scope 2+ | PC2 (ain3) | A5 (CN8-6) | same net as W2 | -| `bundle1` | Scope 1-, 2- | GND | CN7-19, CN7-20 (or CN7-8) | not CN7-22: Fig. 24 calls it GND, the schematic leaves it unconnected | -| `bundle1` | - | - | CN10-15 to CN10-13 | `--with loopback`: jumper PA7 (SPI1 MOSI) to PA6 (SPI1 MISO), or D11 to D12; the SPI tests then leave DIO7 an input and check the read-back | -| `bundle1` | - | - | - | D10 and CN10-17 carry PA4 with the default solder bridges (SB41 and SB5 closed, SB42 and SB6 open; PB10 otherwise) | -| `bundle1` | - | - | CN10-6, CN10-34, CN7-7, CN7-13, CN7-15 | keep free: terminal RX/TX to the ST-LINK (PB7, PB6), BOOT0 (PH3) and SWD (PA13, PA14) | -| `bundle2` | everything else | as `bundle1` | | including GND, the loopback option and the D10 note | -| `bundle2` | DIO9 | PC0 (lptim1in1) | CN7-28 (A0) | LPTIM1 IN1, encoder LPTIM1 A, ADC1 IN1 | -| `bundle2` | DIO10 | PC2 (lptim1in2) | CN7-38 (A5) | LPTIM1 IN2, encoder LPTIM1 B, ADC1 IN3; W2 and scope 2 unplugged | +| Wiring set | AD3 | Pin | Nucleo header | Note | +|------------|-----------------|-------------------|----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------| +| both | GND | GND | CN10-9 and CN10-20 | common ground, at least two leads | +| `bundle1` | DIO0 | PA8 (tim1ch1) | CN10-25 (D6) | TIM1 CH1, encoder TIM1 A, GPIO | +| `bundle1` | DIO1 | PA7 (tim1ch1n) | CN10-15 via SB1 (D11) | TIM1 CH1N, SPI1 MOSI, GPIO | +| `bundle1` | DIO2 | PA9 (tim1ch2) | CN10-19 via SB8 (D9) | TIM1 CH2, encoder TIM1 B, GPIO | +| `bundle1` | DIO3 | PB8 (tim1ch2n) | CN10-3 (D15) | TIM1 CH2N, GPIO | +| `bundle1` | DIO4 | PA10 (tim1ch3) | CN10-31 via SB11 (D3) | TIM1 CH3, TIM17 break, GPIO | +| `bundle1` | DIO5 | PB9 (tim1ch3n) | CN10-5 (D14) | TIM1 CH3N, TIM17 CH1, GPIO; CN10-6 next to it is the terminal RX (PB7) | +| `bundle1` | DIO6 | PA5 (spi1clk) | CN10-11 (D13) | SPI1 SCK, GPIO | +| `bundle1` | DIO7 | PA6 (spi1miso) | CN10-13 (D12) | SPI1 MISO, LPUART1 CTS, TIM16 CH1, EXTI line 6 partner of PC6, GPIO | +| `bundle1` | DIO8 | PA4 (spi1cs) | CN10-17 via SB5 (D10 via SB41) | SPI1 chip select, GPIO | +| `bundle1` | DIO9 | PA15 (qei2a) | CN10-27 (D5) | TIM2 CH1, encoder TIM2 A, GPIO | +| `bundle1` | DIO10 | PA1 (qei2b) | CN7-32 via SB14 (A2) | TIM2 CH2, encoder TIM2 B, GPIO | +| `bundle1` | DIO11 | PC6 (gpio0) | CN10-33 (D2) | GPIO/EXTI, encoder TIM1 and TIM2 index, watchdog warning toggle; CN10-34 next to it is the terminal TX (PB6) | +| `bundle1` | DIO12 | PA2 (lpuart1tx) | CN10-35 via SB15 (D1) | LPUART1 TX, TIM2 CH3, GPIO | +| `bundle1` | DIO13 | PA3 (lpuart1rx) | CN10-37 (D0) | LPUART1 RX, TIM2 CH4, GPIO | +| `bundle1` | DIO14 | PB12 (lpuart1rts) | CN10-16 | LPUART1 RTS, TIM1 break, GPIO | +| `bundle1` | DIO15 | PB0 (led0) | CN10-22 | green LED2 output, GPIO; LED2 and its 680 ohm resistor load the pin | +| `bundle1` | W1 | PC3 (ain4) | CN7-36 (A4) | ADC1 IN4 | +| `bundle1` | W2 | PC2 (ain3) | CN7-38 (A5) | ADC1 IN3 | +| `bundle1` | Scope 1+ | PC3 (ain4) | A4 (CN8-5) | same net as W1 | +| `bundle1` | Scope 2+ | PC2 (ain3) | A5 (CN8-6) | same net as W2 | +| `bundle1` | Scope 1-, 2- | GND | CN7-19, CN7-20 (or CN7-8) | not CN7-22: Fig. 24 calls it GND, the schematic leaves it unconnected | +| `bundle1` | - | - | CN10-15 to CN10-13 | `--with loopback`: jumper PA7 (SPI1 MOSI) to PA6 (SPI1 MISO), or D11 to D12; the SPI tests then leave DIO7 an input and check the read-back | +| `bundle1` | - | - | - | D10 and CN10-17 carry PA4 with the default solder bridges (SB41 and SB5 closed, SB42 and SB6 open; PB10 otherwise) | +| `bundle1` | - | - | CN10-6, CN10-34, CN7-7, CN7-13, CN7-15 | keep free: terminal RX/TX to the ST-LINK (PB7, PB6), BOOT0 (PH3) and SWD (PA13, PA14) | +| `bundle2` | everything else | as `bundle1` | | including GND, the loopback option and the D10 note | +| `bundle2` | DIO9 | PC0 (lptim1in1) | CN7-28 (A0) | LPTIM1 IN1, encoder LPTIM1 A, ADC1 IN1 | +| `bundle2` | DIO10 | PC2 (lptim1in2) | CN7-38 (A5) | LPTIM1 IN2, encoder LPTIM1 B, ADC1 IN3; W2 and scope 2 unplugged | ### NUCLEO-WBA55CG wiring -| Wiring set | AD3 | Pin | Note | -|------------|----------|-------------------|-------------------------------------------------------------------------| -| `bundle1` | DIO0 | PB4 (spi1clk) | SPI1 SCK, TIM1 CH3, blue LED (led0), GPIO (D13) | -| `bundle1` | DIO1 | PB3 (spi1miso) | SPI1 MISO, TIM1 CH4, TIM17 CH1N, GPIO (D12) | -| `bundle1` | DIO2 | PA15 (spi1mosi) | SPI1 MOSI, encoder TIM1 index, TIM17 break, GPIO (D11) | -| `bundle1` | DIO3 | PA12 (spi1cs) | SPI1 chip select, TIM1 CH2, encoder TIM1 B, GPIO (D10) | -| `bundle1` | DIO4 | PA11 (tim1ch1) | TIM1 CH1, encoder TIM1 A, USART2 RX, GPIO (D4) | -| `bundle1` | DIO5 | PB2 (tim1ch1n) | TIM1 CH1N, USART2 CTS, GPIO (D15) | -| `bundle1` | DIO6 | PB1 (tim1ch2n) | TIM1 CH2N, USART2 RTS, GPIO (D14) | -| `bundle1` | DIO7 | PB0 (tim1ch3n) | TIM1 CH3N, USART2 TX, GPIO (D6) | -| `bundle1` | DIO8 | PB5 (lpuart1tx) | LPUART1 TX, GPIO (D1) | -| `bundle1` | DIO9 | PA10 (lpuart1rx) | LPUART1 RX, TIM3 CH1, encoder TIM3 A, GPIO (D0) | -| `bundle1` | DIO10 | PB9 (lpuart1rts) | LPUART1 RTS, TIM3 CH4, TIM16 CH1, GPIO (D7) | -| `bundle1` | DIO11 | PB15 (lpuart1cts) | LPUART1 CTS, TIM16 break, GPIO (D8) | -| `bundle1` | DIO12 | PA1 (tim3ch2) | TIM3 CH2, encoder TIM3 B, TIM17 CH1, GPIO (A3) | -| `bundle1` | DIO13 | PB14 (gpio0) | GPIO/EXTI, TIM3 CH3, encoder TIM3 index, watchdog warning toggle (D5) | -| `bundle1` | DIO14 | PA2 (tim1bkin) | TIM1 break input, GPIO (A2) | -| `bundle1` | DIO15 | PA5 (tim2ch1) | TIM2 CH1, GPIO (A4) | -| `bundle1` | W1 | PA7 (ain2) | ADC4 IN2 (A0) | -| `bundle1` | W2 | PA6 (ain3) | ADC4 IN3 (A1) | -| `bundle1` | Scope 1+ | PA7 (ain2) | | -| `bundle1` | Scope 2+ | PA6 (ain3) | | +| Wiring set | AD3 | Pin | Note | +|------------|----------|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------| +| `bundle1` | DIO0 | PB4 (spi1clk) | SPI1 SCK, TIM1 CH3, blue LED (led0), GPIO (D13) | +| `bundle1` | DIO1 | PB3 (spi1miso) | SPI1 MISO, TIM1 CH4, TIM17 CH1N, GPIO (D12) | +| `bundle1` | DIO2 | PA15 (spi1mosi) | SPI1 MOSI, encoder TIM1 index, TIM17 break, GPIO (D11) | +| `bundle1` | DIO3 | PA12 (spi1cs) | SPI1 chip select, TIM1 CH2, encoder TIM1 B, GPIO (D10) | +| `bundle1` | DIO4 | PA11 (tim1ch1) | TIM1 CH1, encoder TIM1 A, USART2 RX, GPIO (D4) | +| `bundle1` | DIO5 | PB2 (tim1ch1n) | TIM1 CH1N, USART2 CTS, GPIO (D15) | +| `bundle1` | DIO6 | PB1 (tim1ch2n) | TIM1 CH2N, USART2 RTS, GPIO (D14) | +| `bundle1` | DIO7 | PB0 (tim1ch3n) | TIM1 CH3N, USART2 TX, GPIO (D6) | +| `bundle1` | DIO8 | PB5 (lpuart1tx) | LPUART1 TX, GPIO (D1) | +| `bundle1` | DIO9 | PA10 (lpuart1rx) | LPUART1 RX, TIM3 CH1, encoder TIM3 A, GPIO (D0) | +| `bundle1` | DIO10 | PB9 (lpuart1rts) | LPUART1 RTS, TIM3 CH4, TIM16 CH1, GPIO (D7) | +| `bundle1` | DIO11 | PB15 (lpuart1cts) | LPUART1 CTS, TIM16 break, GPIO (D8) | +| `bundle1` | DIO12 | PA1 (tim3ch2) | TIM3 CH2, encoder TIM3 B, TIM17 CH1, GPIO (A3) | +| `bundle1` | DIO13 | PB14 (gpio0) | GPIO/EXTI, TIM3 CH3, encoder TIM3 index, watchdog warning toggle (D5) | +| `bundle1` | DIO14 | PA2 (tim1bkin) | TIM1 break input, GPIO (A2) | +| `bundle1` | DIO15 | PA5 (tim2ch1) | TIM2 CH1, GPIO (A4) | +| `bundle1` | W1 | PA7 (ain2) | ADC4 IN2 (A0) | +| `bundle1` | W2 | PA6 (ain3) | ADC4 IN3 (A1) | +| `bundle1` | Scope 1+ | PA7 (ain2) | | +| `bundle1` | Scope 2+ | PA6 (ain3) | | | `bundle1` | - | - | `--with loopback`: jumper PA15 (SPI1 MOSI, D11) to PB3 (SPI1 MISO, D12); the SPI tests then leave DIO1 an input and check the read-back | -| `bundle1` | - | - | PB4 (D13) also drives the blue LED LD1, which loads the SPI1 clock line | +| `bundle1` | - | - | PB4 (D13) also drives the blue LED LD1, which loads the SPI1 clock line | ## What is tested From 1a622fa5c399697c408c41c762588da046704ea5 Mon Sep 17 00:00:00 2001 From: Gabriel Santos Date: Sun, 4 Oct 2026 11:41:30 +0000 Subject: [PATCH 18/57] devcontainer updated --- .devcontainer/Dockerfile | 2 +- .devcontainer/devcontainer-lock.json | 14 ++++++++++++++ .devcontainer/devcontainer.json | 6 +++++- 3 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 .devcontainer/devcontainer-lock.json diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 99a758f1..d8709d18 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,3 +1,3 @@ -FROM gabrielfrasantos/embedded-devcontainer-cpp:latest@sha256:4f92e9c86dfc5a9688b6134944e7a40c9eb3c658022ea62dc9e57e4c3b056940 +FROM gabrielfrasantos/embedded-devcontainer-cpp:v7.3.0@sha256:4f92e9c86dfc5a9688b6134944e7a40c9eb3c658022ea62dc9e57e4c3b056940 HEALTHCHECK NONE diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 00000000..f950fd46 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,14 @@ +{ + "features": { + "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": { + "version": "1.0.5", + "resolved": "ghcr.io/anthropics/devcontainer-features/claude-code@sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a", + "integrity": "sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a" + }, + "ghcr.io/devcontainers/features/node:1": { + "version": "1.7.1", + "resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6", + "integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 9ff68693..05d91df0 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,5 +3,9 @@ "context": "..", "dockerfile": "Dockerfile" }, - "runArgs": ["--add-host=host.docker.internal:host-gateway"] + "runArgs": ["--add-host=host.docker.internal:host-gateway"], + "features": { + "ghcr.io/devcontainers/features/node:1": {}, + "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {} + } } From 49d3731d5498196882219fcf29c90e460623ae90 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 12:03:39 +0000 Subject: [PATCH 19/57] docs: wrap markdown lines that exceed MegaLinter's 400-character limit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- AGENTS.md | 5 ++++- validation/PROTOCOL.md | 4 +++- validation/README.md | 12 +++++++++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ed87d52a..eb8fa93b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,7 +55,10 @@ Full detail lives in `.github/instructions/hal-st-cpp.instructions.md` — read ## Testing -No unit tests in this repo. hal-st is validated on real hardware — the `validation/` app (firmware + pytest/AD3 host suite, see `validation/README.md`), manual testing on Nucleo/Discovery boards, logic-analyser/scope verification and the `integration_test/` rig — not by GoogleTest suites. A driver change on STM32WB55/WBA55 should keep `validation/PROTOCOL.md`, the firmware factory and the host tests in step. `validation/host/tests/unit` tests the host harness itself (`pytest validation/host/tests/unit`), not the drivers. Don't add unit tests for new or changed drivers. (`services/st_util/test/` is a pre-existing exception gated behind `HALST_BUILD_TESTS`; leave it as-is, don't extend the pattern elsewhere.) +No unit tests in this repo. hal-st is validated on real hardware — the `validation/` app (firmware + pytest/AD3 host suite, see `validation/README.md`), manual testing on Nucleo/Discovery boards, +logic-analyser/scope verification and the `integration_test/` rig — not by GoogleTest suites. A driver change on STM32WB55/WBA55 should keep `validation/PROTOCOL.md`, the firmware factory and the host +tests in step. `validation/host/tests/unit` tests the host harness itself (`pytest validation/host/tests/unit`), not the drivers. Don't add unit tests for new or changed drivers. +(`services/st_util/test/` is a pre-existing exception gated behind `HALST_BUILD_TESTS`; leave it as-is, don't extend the pattern elsewhere.) ## Build diff --git a/validation/PROTOCOL.md b/validation/PROTOCOL.md index 65b8fb8a..99435e0f 100644 --- a/validation/PROTOCOL.md +++ b/validation/PROTOCOL.md @@ -52,7 +52,9 @@ The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix - After reset the firmware prints `EVT boot board= family= sysclk= reset=` once; `` is `iwdg`, `wwdg`, `sw`, `lpwr`, `obl`, `bor`, `pin` or `unknown` (the RCC reset flags in that priority order; a reset through NRST also sets `pin`, so `pin` is checked last). - Pins are written as `P`, for example `PA15`, `PB3`, `PH3`: ports A-E and H on STM32WB55 and A-C and H on STM32WBA55, index 0-15; a pin the package does not bond out returns `ERR pin`. - Instance numbers are the STM32 peripheral numbers: USART 1-2 and LPUART 1 (selected with `lp=1`), SPI 1-3, TIM 1-17 (PWM and encoder) and LPTIM 1 (encoder, `lp=1`), ADC 1 (WB55) or 4 (WBA55), watchdog 0 (the WWDG). A number the running MCU lacks, including 0 where the peripherals start at 1, returns `ERR range`. -- The terminal UART and its pins, the debug LED (WB55 PB5, the blue LD1; WBA55 PB8, the red LD3; blinking while the firmware runs), the SWD pins PA13/PA14, the LSE crystal pins PC14/PC15 and BOOT0 (PH3) are reserved and cannot be opened (`ERR busy`); any other pin, aliased or not, can be reconfigured freely. A pin held by another open instance returns `ERR busy`; a pin the hal-st pinout table does not offer for the requested function and instance returns `ERR pin`. +- The terminal UART and its pins, the debug LED (WB55 PB5, the blue LD1; WBA55 PB8, the red LD3; blinking while the firmware runs), the SWD pins PA13/PA14, the LSE crystal pins PC14/PC15 and BOOT0 (PH3) + are reserved and cannot be opened (`ERR busy`); any other pin, aliased or not, can be reconfigured freely. A pin held by another open instance returns `ERR busy`; a pin the hal-st pinout table does + not offer for the requested function and instance returns `ERR pin`. - A timer serves one group at a time: a timer held by an open PWM, encoder or timer-triggered ADC returns `ERR busy` to the other groups. - RAM limits how many instances are open at the same time: 1 PWM timer, 1 UART besides the terminal, 1 SPI, 1 ADC, 1 encoder, 1 watchdog and 8 GPIO pins; one more returns `ERR busy`. - Argument errors (`usage`, `range`, `pin`, `unsupported`) are reported before `ERR busy`. diff --git a/validation/README.md b/validation/README.md index b03f907a..b30c50ff 100644 --- a/validation/README.md +++ b/validation/README.md @@ -134,7 +134,9 @@ Writing the firmware against the drivers showed hal-st bugs that the suite runs | both | `UartStm` | `uart.send` completes while up to 9 bytes are still in the TX FIFO and shift register, so a close right after a send can cut them. | `hal_st/stm32fxxx/UartStm.cpp:202-205` | | both | `WatchDogStm` | Only the window watchdog exists: timeouts are limited to about 516 ms (WB55) and 330 ms (WBA55), and there is no IWDG driver. | `hal_st/stm32fxxx/WatchDogStm.cpp` | -The gaps found earlier in `PwmStm`, `AdcStm`/`AdcDmaMultiChannelStm`/`AdcTimerTriggeredBase` (WBA55 ADC4), `SpiMasterStm`/`SynchronousSpiMasterStm` (receive-only start), `UartStm` (TXEIE after close, SWAP, overrun), `SynchronousQuadratureEncoderLpTimStm` (filter carry-over) and `GpioStm` (EXTI on port H) are fixed, and the tests that exposed them now guard the fixes. The port H fix has no HIL test: PH3 (BOOT0) is the only port H pin on both boards and is reserved. +The gaps found earlier in `PwmStm`, `AdcStm`/`AdcDmaMultiChannelStm`/`AdcTimerTriggeredBase` (WBA55 ADC4), `SpiMasterStm`/`SynchronousSpiMasterStm` (receive-only start), `UartStm` (TXEIE after close, +SWAP, overrun), `SynchronousQuadratureEncoderLpTimStm` (filter carry-over) and `GpioStm` (EXTI on port H) are fixed, and the tests that exposed them now guard the fixes. The port H fix has no HIL +test: PH3 (BOOT0) is the only port H pin on both boards and is reserved. Fix the driver, then remove its entry from both board files so the tests guard the fix. @@ -180,7 +182,9 @@ On Windows: - Start the ST-LINK GDB server (from STM32CubeIDE or STM32CubeCLT) on port 61234, accepting connections from the container. - `port-bridge` holds the COM port while it runs; stop it before opening the port in another tool. `ad3-bench-server --fake` serves a simulated AD3 to try the setup without hardware. -Both bridges listen on `127.0.0.1` by default, which Docker Desktop reaches through `host.docker.internal`. If the container cannot connect (for example Docker Engine inside WSL2), listen on all interfaces with `ad3-bench-server --host 0.0.0.0 --token ` (and `AD3_REMOTE_TOKEN=` in the container) and `port-bridge --bind 0.0.0.0`, and keep ports 5025, 5000 and 61234 blocked from the network in the Windows firewall: whoever reaches them controls the AD3 and the board. +Both bridges listen on `127.0.0.1` by default, which Docker Desktop reaches through `host.docker.internal`. If the container cannot connect (for example Docker Engine inside WSL2), listen on all +interfaces with `ad3-bench-server --host 0.0.0.0 --token ` (and `AD3_REMOTE_TOKEN=` in the container) and `port-bridge --bind 0.0.0.0`, and keep ports 5025, 5000 and 61234 blocked from +the network in the Windows firewall: whoever reaches them controls the AD3 and the board. In the container: @@ -275,7 +279,9 @@ The tables follow the `wiring_sets` of the board files (keep both in step); the - 1-4 channels, complementary outputs and complementary-only outputs with dead time and inversion (shoot-through check), the dead-time limit; - idle levels, the break input with both polarities and automatic re-enable; - frequency changes and stop, the features each timer offers (`unsupported` elsewhere), argument errors, one timer at a time, and that a re-open forgets dead time and break. -- `test_uart.py` - every baud rate x parity x driver variant (interrupt, DMA, duplex DMA, synchronous) on LPUART1 and USART2, both directions against the AD3 UART, frames decoded from the firmware TX line and the bit rate measured on it, large payloads, full-duplex streaming, RTS/CTS flow control per variant, send timeout with CTS held, re-open with other settings, TX/RX swap, the baud limits of each instance, argument errors and default pins. +- `test_uart.py` - every baud rate x parity x driver variant (interrupt, DMA, duplex DMA, synchronous) on LPUART1 and USART2, both directions against the AD3 UART, frames decoded from the firmware TX + line and the bit rate measured on it, large payloads, full-duplex streaming, RTS/CTS flow control per variant, send timeout with CTS held, re-open with other settings, TX/RX swap, the baud limits of + each instance, argument errors and default pins. - `test_spi.py` - SPI modes 0-3 x baud x driver variant (interrupt, DMA, synchronous) x GPIO chip select, decoded from the logic analyzer (MOSI, MISO, clock polarity and rate, chip-select release), continued sessions, receive-only first transfers, the largest transfer, the baud limits and argument errors. - `test_adc.py` - wavegen DC levels against raw 12-bit codes (checked against the scope when it is wired), sequences of up to 8 conversions, every sampling time, timer-triggered rates and the measure timeout, timer sharing with PWM and the encoder, unsupported trigger timers, argument errors and limits, and ADC pins against GPIO. - `test_qei.py` - position counts for frequency x cycles x direction x decoding x phase inversion, physically inverted phases restored by `inva`/`invb`, offset and rollover, speed, the index input through `qei.index`, the LPTIM1 encoder on the NUCLEO-WB55RG, default pins, the resolution limits, argument errors, one encoder at a time and timer sharing. From e6a871cf6d3db339a434b412df0dcbece0f871cf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:12:37 +0000 Subject: [PATCH 20/57] build(gen): generate leading-underscore IPs so STM32WB55 gets QUADSPI STM32WB55RGVx.xml names the QUADSPI IP "_QUADSPI" (InstanceName "QUADSPI"). GeneratePeripheralTableStructure.xsl matched items on @Name = $ip-name only, so PeripheralTableWbxx.xml's QUADSPI item was dropped and the WB55 table said #undef HAS_PERIPHERAL_QUADSPI, leaving QuadSpiStm, QuadSpiStmDma and SingleSpeedQuadSpiStmDma compiled empty. Also accept @Name = concat('_', $ip-name), and derive the instance position from $ip-name instead of @Name. Both are identical for every existing match; _QUADSPI is the only underscore IP in mcu/*.xml. Regenerating all 13 presets changes only stm32wb55 PeripheralTable.hpp/.cpp (HAS_PERIPHERAL_QUADSPI, peripheralQuadSpi*, Enable/DisableClockQuadSpi); PinoutTableDefault is unchanged everywhere. CRLF line endings kept. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/GeneratePeripheralTableStructure.xsl | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/hal_st/stm32fxxx/GeneratePeripheralTableStructure.xsl b/hal_st/stm32fxxx/GeneratePeripheralTableStructure.xsl index 2bca896a..4074a11f 100644 --- a/hal_st/stm32fxxx/GeneratePeripheralTableStructure.xsl +++ b/hal_st/stm32fxxx/GeneratePeripheralTableStructure.xsl @@ -68,15 +68,15 @@ - + - + 1 - - + + From b60d0e45c984a69d27dcb8e168efaf447f4e116e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:14:27 +0000 Subject: [PATCH 21/57] build(gen): add LPTIM to the STM32WBA peripheral table STM32WBA52CGUx.xml lists LPTIM1/LPTIM2 and the WBA pinout table already carries the LPTIM pins, but PeripheralTableWbaxx.xml had no LpTimer entry, so LpTimerStm, LpTimerPwmStm and SynchronousQuadratureEncoderLpTimStm compiled empty on WBA. Add the same LpTimer entry as PeripheralTableWbxx.xml. Regenerating changes only stm32wba52/55/65 PeripheralTable.hpp/.cpp (HAS_PERIPHERAL_LPTIMER, peripheralLpTimer*, Enable/DisableClockLpTimer). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/PeripheralTableWbaxx.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hal_st/stm32fxxx/PeripheralTableWbaxx.xml b/hal_st/stm32fxxx/PeripheralTableWbaxx.xml index e686e5a9..a89e8875 100644 --- a/hal_st/stm32fxxx/PeripheralTableWbaxx.xml +++ b/hal_st/stm32fxxx/PeripheralTableWbaxx.xml @@ -28,4 +28,8 @@ + + + + From 00a90b97a889e4d3442ac57aedb6fd3cb35ebce0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:14:55 +0000 Subject: [PATCH 22/57] fix(i2c): recover I2cStm from NACK, bus error and arbitration loss on STM32WB/WBA All behaviour changes are guarded with STM32WB || STM32WBA inside the I2C v2 paths; F2/F4 (v1) and F0/F3/F7/G0/G4/H5 keep today's code. a. NACK: clear NACKF (I2C_FLAG_AF), flush TXDR, end the session (the IP sends STOP) and return before the TXIS/TCR/TC checks, so the next transfer no longer sees a stale NACK. b. Address NACK completes the armed onSent/onReceived after DeviceNotFound(), so the next transfer no longer trips the "callback still armed" assert. c. Data NACK reports the acknowledged bytes: a byte still in TXDR is not counted, and a NACK of the last byte reports onSent instead of calling an empty onReceived. d. TCR with both buffers empty completes onReceived for a receive with continueSession instead of an empty onSent. e. BERR/ARLO: disable the interrupts, flush TXDR, end the session, reset the IP (PE low for >= 3 APB cycles), call the hook, then complete an active transfer with Result::busError. f. Default Config::timing is 0x70b03d3d on WB/WBA: Standard mode within spec for every kernel clock up to 100 MHz (0x00304d4d gives a 62 ns SCLDEL at 64 MHz, below Fast mode's 100 ns tSU;DAT plus rise time). g. i2cHandle is value-initialised; the destructor calls HAL_I2C_DeInit before gating the clock. h. SendData/ReceiveData reset both counters; every scheduled completion calls a callback only while it is still armed. i. Own address 1 is disabled after HAL_I2C_Init, so an idle master does not ACK address 0x00 and hold SCL. Behaviour change: WB/WBA users of the default Config drop from ~340 kHz to 63.8 kHz at a 64 MHz kernel clock (99.2 kHz at 100 MHz); pass Config::timing for Fast mode. The two hpp includes are swapped into clang-format order (no code change). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/I2cStm.cpp | 116 ++++++++++++++++++++++++++++++++++++ hal_st/stm32fxxx/I2cStm.hpp | 8 ++- 2 files changed, 122 insertions(+), 2 deletions(-) diff --git a/hal_st/stm32fxxx/I2cStm.cpp b/hal_st/stm32fxxx/I2cStm.cpp index 49d3b6c1..ea531eb8 100644 --- a/hal_st/stm32fxxx/I2cStm.cpp +++ b/hal_st/stm32fxxx/I2cStm.cpp @@ -3,6 +3,12 @@ #include "infra/event/EventDispatcher.hpp" #include "infra/util/ReallyAssert.hpp" +#if defined(STM32WB) +#include "stm32wbxx_ll_i2c.h" +#elif defined(STM32WBA) +#include "stm32wbaxx_ll_i2c.h" +#endif + namespace hal { I2cStm::I2cStm(uint8_t oneBasedI2cIndex, hal::GpioPinStm& scl, hal::GpioPinStm& sda, const Config& config) @@ -36,6 +42,9 @@ namespace hal i2cHandle.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE; i2cHandle.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE; HAL_I2C_Init(&i2cHandle); +#if defined(STM32WB) || defined(STM32WBA) + LL_I2C_DisableOwnAddress1(i2cHandle.Instance); +#endif #if defined(STM32F427xx) || defined(STM32F437xx) || defined(STM32F429xx) || defined(STM32F439xx) || \ defined(STM32F401xC) || defined(STM32F401xE) || defined(STM32F411xE) || defined(STM32F446xx) || \ @@ -47,6 +56,9 @@ namespace hal I2cStm::~I2cStm() { +#if defined(STM32WB) || defined(STM32WBA) + HAL_I2C_DeInit(&i2cHandle); +#endif DisableClockI2c(instance); } @@ -62,6 +74,9 @@ namespace hal this->address = address; #endif sent = 0; +#if defined(STM32WB) || defined(STM32WBA) + received = 0; +#endif __DMB(); @@ -90,6 +105,9 @@ namespace hal this->address = address; #endif received = 0; +#if defined(STM32WB) || defined(STM32WBA) + sent = 0; +#endif __DMB(); @@ -129,6 +147,33 @@ namespace hal #if defined(I2C_ISR_TXE) if ((peripheralI2c[instance]->ISR & I2C_ISR_NACKF) != 0) { +#if defined(STM32WB) || defined(STM32WBA) + const bool txPending = (peripheralI2c[instance]->ISR & I2C_ISR_TXE) == 0; + peripheralI2c[instance]->CR1 &= ~(I2C_CR1_TXIE | I2C_CR1_RXIE | I2C_CR1_TCIE | I2C_CR1_NACKIE | I2C_CR1_ERRIE); + __HAL_I2C_CLEAR_FLAG(&i2cHandle, I2C_FLAG_AF); + __HAL_I2C_CLEAR_FLAG(&i2cHandle, I2C_FLAG_TXE); + continuingPrevious = false; + + infra::EventDispatcher::Instance().Schedule([this, txPending]() + { + const uint32_t shifted = (txPending && sent != 0) ? sent - 1 : sent; + if (onSent != nullptr) + { + if (shifted == 0) + DeviceNotFound(); + sendData.clear(); + onSent(hal::Result::partialComplete, shifted == 0 ? 0 : shifted - 1); + } + else if (onReceived != nullptr) + { + if (received == 0) + DeviceNotFound(); + receiveData.clear(); + onReceived(hal::Result::partialComplete); + } + }); + return; +#else peripheralI2c[instance]->CR1 &= ~(I2C_CR1_TXIE | I2C_CR1_RXIE | I2C_CR1_TCIE | I2C_CR1_NACKIE | I2C_CR1_ERRIE); infra::EventDispatcher::Instance().Schedule([this]() @@ -146,6 +191,7 @@ namespace hal onReceived(hal::Result::partialComplete); } }); +#endif } if ((peripheralI2c[instance]->ISR & I2C_ISR_TXIS) != 0) @@ -166,10 +212,25 @@ namespace hal if (nextAction == Action::stop) peripheralI2c[instance]->CR2 = I2C_CR2_STOP; continuingPrevious = nextAction == Action::continueSession; +#if defined(STM32WB) || defined(STM32WBA) + if (onSent != nullptr) + infra::EventDispatcher::Instance().Schedule([this]() + { + if (onSent != nullptr) + onSent(hal::Result::complete, sent); + }); + else + infra::EventDispatcher::Instance().Schedule([this]() + { + if (onReceived != nullptr) + onReceived(hal::Result::complete); + }); +#else infra::EventDispatcher::Instance().Schedule([this]() { onSent(hal::Result::complete, sent); }); +#endif } else if (!receiveData.empty()) peripheralI2c[instance]->CR2 = (std::min(receiveData.size(), 255) << 16) | (receiveData.size() > 255 || nextAction == Action::continueSession ? I2C_CR2_RELOAD : 0); @@ -186,6 +247,20 @@ namespace hal if (nextAction == Action::stop) peripheralI2c[instance]->CR2 = I2C_CR2_STOP; continuingPrevious = nextAction == Action::continueSession; +#if defined(STM32WB) || defined(STM32WBA) + if (onSent != nullptr) + infra::EventDispatcher::Instance().Schedule([this]() + { + if (onSent != nullptr) + onSent(hal::Result::complete, sent); + }); + else + infra::EventDispatcher::Instance().Schedule([this]() + { + if (onReceived != nullptr) + onReceived(hal::Result::complete); + }); +#else if (onSent != nullptr) infra::EventDispatcher::Instance().Schedule([this]() { @@ -196,6 +271,7 @@ namespace hal { onReceived(hal::Result::complete); }); +#endif } #else auto sr1 = peripheralI2c[instance]->SR1; @@ -309,6 +385,45 @@ namespace hal void I2cStm::ErrorInterrupt() { #if defined(I2C_ISR_BERR) +#if defined(STM32WB) || defined(STM32WBA) + const bool active = (peripheralI2c[instance]->CR1 & I2C_CR1_TCIE) != 0; + const bool busError = (peripheralI2c[instance]->ISR & I2C_ISR_BERR) != 0; + const bool arbitrationLost = (peripheralI2c[instance]->ISR & I2C_ISR_ARLO) != 0; + + if (busError) + peripheralI2c[instance]->ICR |= I2C_ISR_BERR; + + if (arbitrationLost) + peripheralI2c[instance]->ICR |= I2C_ISR_ARLO; + + really_assert( + (peripheralI2c[instance]->ISR & I2C_ISR_OVR) == 0 && (peripheralI2c[instance]->ISR & I2C_ISR_PECERR) == 0 && (peripheralI2c[instance]->ISR & I2C_ISR_TIMEOUT) == 0 && (peripheralI2c[instance]->ISR & I2C_ISR_ALERT) == 0); + + if (busError || arbitrationLost) + { + peripheralI2c[instance]->CR1 &= ~(I2C_CR1_TXIE | I2C_CR1_RXIE | I2C_CR1_TCIE | I2C_CR1_NACKIE | I2C_CR1_ERRIE); + __HAL_I2C_CLEAR_FLAG(&i2cHandle, I2C_FLAG_TXE); + continuingPrevious = false; + + __HAL_I2C_DISABLE(&i2cHandle); + // Each APB access lasts at least one APB clock, so PE stays low for >= 3 APB cycles + (void)peripheralI2c[instance]->CR1; + (void)peripheralI2c[instance]->CR1; + (void)peripheralI2c[instance]->CR1; + __HAL_I2C_ENABLE(&i2cHandle); + + if (busError) + BusError(); + + if (arbitrationLost) + ArbitrationLost(); + + if (active && onSent != nullptr) + onSent(hal::Result::busError, sent); + else if (active && onReceived != nullptr) + onReceived(hal::Result::busError); + } +#else if ((peripheralI2c[instance]->ISR & I2C_ISR_BERR) != 0) { peripheralI2c[instance]->ICR |= I2C_ISR_BERR; @@ -323,6 +438,7 @@ namespace hal really_assert( (peripheralI2c[instance]->ISR & I2C_ISR_OVR) == 0 && (peripheralI2c[instance]->ISR & I2C_ISR_PECERR) == 0 && (peripheralI2c[instance]->ISR & I2C_ISR_TIMEOUT) == 0 && (peripheralI2c[instance]->ISR & I2C_ISR_ALERT) == 0); +#endif #else if ((peripheralI2c[instance]->SR1 & I2C_SR1_AF) != 0) { diff --git a/hal_st/stm32fxxx/I2cStm.hpp b/hal_st/stm32fxxx/I2cStm.hpp index 726e640e..5f73ac28 100644 --- a/hal_st/stm32fxxx/I2cStm.hpp +++ b/hal_st/stm32fxxx/I2cStm.hpp @@ -2,8 +2,8 @@ #define HAL_I2C_STM_HPP #include DEVICE_HEADER -#include "hal/interfaces/I2c.hpp" #include "hal/cortex_m/InterruptCortex.hpp" +#include "hal/interfaces/I2c.hpp" #include "hal_st/stm32fxxx/GpioStm.hpp" #include "infra/util/AutoResetFunction.hpp" @@ -19,8 +19,12 @@ namespace hal {} #if defined(I2C_TIMINGR_PRESC) +#if defined(STM32WB) || defined(STM32WBA) + uint32_t timing = 0x70b03d3d; +#else uint32_t timing = 0x00304d4d; #endif +#endif #if defined(I2C_CCR_CCR) uint32_t clockSpeed = 400000; #endif @@ -52,7 +56,7 @@ namespace hal cortex::ImmediateInterruptHandler evInterruptHandler; cortex::DispatchedInterruptHandler erInterruptHandler; - I2C_HandleTypeDef i2cHandle; + I2C_HandleTypeDef i2cHandle{}; Action nextAction; #if defined(STM32F2) || defined(STM32F4) From 85da30076d52dda266463ecfe6b86b0a552ccb40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:15:11 +0000 Subject: [PATCH 23/57] fix(spi): run SpiSlaveStmDma on STM32WB/WBA and clear SPE between transfers SPE was only set under STM32H5 and HAL_SPI_Init does not set it, so on STM32WB/WBA SpiSlaveStmDma never moved a byte. DisableSpi() also set SPE instead of clearing it, so the slave kept receiving between transfers and after its destructor. WB and WBA now take the H5 sequence: clear SPE before arming the DMA streams, set it once both are armed, clear it when the transfer is done (RX DMA completion means the last frame was clocked, so no BSY wait). DisableSpi() clears SPE with LL_SPI_Disable on WB/WBA. On WB (SPI v2) the RX FIFO keeps its content while SPE=0, so frames a master clocks past the armed length would lead the next transfer: drain the RX FIFO and clear OVR before arming. H5 keeps its DisableSpi(); F4/F7/G4/H5 compile to identical code. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/SpiSlaveStmDma.cpp | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/hal_st/stm32fxxx/SpiSlaveStmDma.cpp b/hal_st/stm32fxxx/SpiSlaveStmDma.cpp index 6bbaf579..b5f988f5 100644 --- a/hal_st/stm32fxxx/SpiSlaveStmDma.cpp +++ b/hal_st/stm32fxxx/SpiSlaveStmDma.cpp @@ -2,6 +2,12 @@ #include "generated/stm32fxxx/PeripheralTable.hpp" #include "infra/util/BitLogic.hpp" +#if defined(STM32WB) +#include "stm32wbxx_ll_spi.h" +#elif defined(STM32WBA) +#include "stm32wbaxx_ll_spi.h" +#endif + namespace hal { SpiSlaveStmDma::SpiSlaveStmDma(hal::DmaStm::TransmitStream& transmitStream, hal::DmaStm::ReceiveStream& receiveStream, uint8_t oneBasedSpiIndex, GpioPinStm& clock, GpioPinStm& miso, GpioPinStm& mosi, GpioPinStm& slaveSelect) @@ -68,9 +74,15 @@ namespace hal receiveDone = false; sendDone = false; -#ifdef STM32H5 +#if defined(STM32H5) || defined(STM32WB) || defined(STM32WBA) DisableSpi(); #endif +#if defined(STM32WB) + // SPI v2 keeps RXFIFO data while SPE=0, so frames clocked after the previous transfer would lead this one + while (LL_SPI_GetRxFIFOLevel(peripheralSpi[spiInstance]) != LL_SPI_RX_FIFO_EMPTY) + LL_SPI_ReceiveData8(peripheralSpi[spiInstance]); + LL_SPI_ClearFlag_OVR(peripheralSpi[spiInstance]); +#endif if (!sendData.empty() && !receiveData.empty()) { @@ -91,7 +103,7 @@ namespace hal else std::abort(); -#ifdef STM32H5 +#if defined(STM32H5) || defined(STM32WB) || defined(STM32WBA) EnableSpi(); #endif } @@ -114,7 +126,7 @@ namespace hal void SpiSlaveStmDma::TransferDone() { -#ifdef STM32H5 +#if defined(STM32H5) || defined(STM32WB) || defined(STM32WBA) DisableSpi(); #endif onDone(); @@ -165,7 +177,11 @@ namespace hal void SpiSlaveStmDma::DisableSpi() { +#if defined(STM32WB) || defined(STM32WBA) + LL_SPI_Disable(peripheralSpi[spiInstance]); +#else peripheralSpi[spiInstance]->CR1 |= SPI_CR1_SPE; +#endif } void SpiSlaveStmDma::EnableDma() From c4c02ae9e6f9f8d528d84fb47908c3acdbe0631b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:15:26 +0000 Subject: [PATCH 24/57] fix(spi): check the SPI v3 data size range SetDataSize asserted dataSizeInBits >= 4 || dataSizeInBits <= 32 on SPI v3 (CFG1.DSIZE), which holds for every value. Use && like the SPI v2 branch. Debug builds only; release code is unchanged on every family. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/SpiMasterStmDma.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hal_st/stm32fxxx/SpiMasterStmDma.cpp b/hal_st/stm32fxxx/SpiMasterStmDma.cpp index 2b0c1312..070dcc73 100644 --- a/hal_st/stm32fxxx/SpiMasterStmDma.cpp +++ b/hal_st/stm32fxxx/SpiMasterStmDma.cpp @@ -169,7 +169,7 @@ namespace hal assert(dataSizeInBits >= 4 && dataSizeInBits <= 16); peripheralSpi[spiInstance]->CR2 = (peripheralSpi[spiInstance]->CR2 & ~SPI_CR2_DS) | ((dataSizeInBits - 1) << POSITION_VAL(SPI_CR2_DS)) | (dataSizeInBits <= 8 ? SPI_CR2_FRXTH : 0); #elif defined(SPI_CFG1_DSIZE) - assert(dataSizeInBits >= 4 || dataSizeInBits <= 32); + assert(dataSizeInBits >= 4 && dataSizeInBits <= 32); peripheralSpi[spiInstance]->CFG1 = (peripheralSpi[spiInstance]->CFG1 & ~SPI_CFG1_DSIZE) | ((dataSizeInBits - 1) << POSITION_VAL(SPI_CFG1_DSIZE)); #else assert(dataSizeInBits == 8 || dataSizeInBits == 16); From b731025e91ec68043dd8195e890bf4a28fe88b0d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:15:41 +0000 Subject: [PATCH 25/57] fix(flash): erase the pages of the flash region on STM32WB/WBA FlashInternalStmBase::EraseSectors passed the region-relative sector index to HAL_FLASHEx_Erase as the absolute page number, and the sector count as the page count. Unless the region started at FLASH_BASE and every sector was one page, it erased pages outside the region (the running image at the start of flash) and left the region intact. On STM32WB/WBA the page now is (region offset from FLASH_BASE + AddressOfSector(beginIndex)) / FLASH_PAGE_SIZE and the count is the byte size of [beginIndex, endIndex) / FLASH_PAGE_SIZE, so multi-page sectors of FlashInternalStm are erased completely too. This also fixes FlashCoordinatedWithWirelessStack, which erases through this driver. G0/G4 keep the old arithmetic (not validated on hardware); F4/F7/G0/G4/H5 compile to identical code. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/FlashInternalStm.cpp | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/hal_st/stm32fxxx/FlashInternalStm.cpp b/hal_st/stm32fxxx/FlashInternalStm.cpp index db113d00..18a57409 100644 --- a/hal_st/stm32fxxx/FlashInternalStm.cpp +++ b/hal_st/stm32fxxx/FlashInternalStm.cpp @@ -94,8 +94,14 @@ namespace hal FLASH_EraseInitTypeDef eraseInitStruct; eraseInitStruct.TypeErase = FLASH_TYPEERASE_PAGES; +#if defined(STM32WB) || defined(STM32WBA) + const auto regionOffset = reinterpret_cast(flashMemory.begin()) - FLASH_BASE; + eraseInitStruct.Page = (regionOffset + AddressOfSector(beginIndex)) / FLASH_PAGE_SIZE; + eraseInitStruct.NbPages = (AddressOfSector(endIndex) - AddressOfSector(beginIndex)) / FLASH_PAGE_SIZE; +#else eraseInitStruct.Page = beginIndex; eraseInitStruct.NbPages = endIndex - beginIndex; +#endif auto result = HAL_FLASHEx_Erase(&eraseInitStruct, &pageError); really_assert(result == HAL_OK); From 57c42ea297c952a2fb3251258a2e7249eb6e6d60 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:15:51 +0000 Subject: [PATCH 26/57] fix(flash): erase the pages of the synchronous flash region on STM32WB/WBA SynchronousFlashInternalStmBase::EraseSectors had the same defect as FlashInternalStmBase: the region-relative sector range was erased as an absolute page range. On STM32WB/WBA without FLASH_DBANK_SUPPORT the erase now starts at the absolute page of the first sector and covers the pages of [beginIndex, endIndex), as in the asynchronous driver. The dual-bank path (G4, WBA65) and the other families are unchanged and compile to identical code. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/synchronous_stm32fxxx/SynchronousFlashInternalStm.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hal_st/synchronous_stm32fxxx/SynchronousFlashInternalStm.cpp b/hal_st/synchronous_stm32fxxx/SynchronousFlashInternalStm.cpp index 4b2adadf..bb9b6165 100644 --- a/hal_st/synchronous_stm32fxxx/SynchronousFlashInternalStm.cpp +++ b/hal_st/synchronous_stm32fxxx/SynchronousFlashInternalStm.cpp @@ -98,6 +98,9 @@ namespace hal erase(beginIndex, FLASH_PAGE_NB - beginIndex, FLASH_BANK_1); erase(0, endIndex - FLASH_PAGE_NB + 1, FLASH_BANK_2); } +#elif defined(STM32WB) || defined(STM32WBA) + const auto regionOffset = reinterpret_cast(flashMemory.begin()) - FLASH_BASE; + erase((regionOffset + AddressOfSector(beginIndex)) / FLASH_PAGE_SIZE, (AddressOfSector(endIndex) - AddressOfSector(beginIndex)) / FLASH_PAGE_SIZE, 0); #else erase(beginIndex, endIndex - beginIndex, 0); #endif From 95dced2c2422cad8647d3e77f8ca20ba1076869d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:15:55 +0000 Subject: [PATCH 27/57] fix(flash): erase the page of an ECC error EccErrorHandler passed FLASH_ECCR.ADDR_ECC to the region-relative SectorOfAddress and erased the resulting sector index as an absolute page. On STM32WB55 ADDR_ECC is a double-word address (stm32wb55xx.h:4150-4152: "double-word address ECC fail", 17 bits x 8 bytes = 1 MB), so the page is (ADDR_ECC * 8) / FLASH_PAGE_SIZE. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/FlashCoordinatedWithWirelessStack.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hal_st/stm32fxxx/FlashCoordinatedWithWirelessStack.cpp b/hal_st/stm32fxxx/FlashCoordinatedWithWirelessStack.cpp index 578bd02e..a452399a 100644 --- a/hal_st/stm32fxxx/FlashCoordinatedWithWirelessStack.cpp +++ b/hal_st/stm32fxxx/FlashCoordinatedWithWirelessStack.cpp @@ -211,7 +211,7 @@ namespace hal __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_ECCD); const uint32_t errorAddress = READ_BIT(FLASH->ECCR, FLASH_ECCR_ADDR_ECC); uint32_t pageError = 0; - FLASH_EraseInitTypeDef eraseInitStruct{ .TypeErase = FLASH_TYPEERASE_PAGES, .Page = flash.SectorOfAddress(errorAddress), .NbPages = 1 }; + FLASH_EraseInitTypeDef eraseInitStruct{ .TypeErase = FLASH_TYPEERASE_PAGES, .Page = (errorAddress * 8U) / FLASH_PAGE_SIZE, .NbPages = 1 }; HAL_FLASHEx_Erase(&eraseInitStruct, &pageError); } } From 067e89fb5011815c119c631ff2712b8e50dea54d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:15:59 +0000 Subject: [PATCH 28/57] fix(rng): keep HSI48 running across a synchronized RNG read when it was already on Hsi48Enabler enabled HSI48 and switched it off again on every read when semaphore 5 is not held. On STM32WB the default clock keeps HSI48 on as the RNG kernel clock (CLK48 = HSI48), so one synchronized read left SynchronousRandomDataGeneratorStm and RandomDataGeneratorStm without a clock. Only enable HSI48, and only switch it off at the end of the read, when it was not running before. The locked branch is unchanged. Until now IsLockedByCurrentCore() always returned true, which hid this; the next commit fixes that query. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- .../SynchronousSynchronizedRandomDataGeneratorStm.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hal_st/synchronous_stm32fxxx/SynchronousSynchronizedRandomDataGeneratorStm.cpp b/hal_st/synchronous_stm32fxxx/SynchronousSynchronizedRandomDataGeneratorStm.cpp index 21bf5e0d..ab52ddc6 100644 --- a/hal_st/synchronous_stm32fxxx/SynchronousSynchronizedRandomDataGeneratorStm.cpp +++ b/hal_st/synchronous_stm32fxxx/SynchronousSynchronizedRandomDataGeneratorStm.cpp @@ -28,11 +28,12 @@ namespace hal // HSI48 should already be enabled when the clock configuration is locked. Don't manage its lifecycle. really_assert(LL_RCC_HSI48_IsReady()); } - else + else if (!LL_RCC_HSI48_IsReady()) { LL_RCC_HSI48_Enable(); while (!LL_RCC_HSI48_IsReady()) - {} + { + } disableHsi48OnFinalization = true; } From 48da16fd476f6abe5f5fe07d75b47f4cd9514f33 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:16:01 +0000 Subject: [PATCH 29/57] fix(hsem): wait for the semaphore and query it without locking IsLockedByCurrentCore() compared RLR == HSEM_R_LOCK | COREID, which parses as (RLR == HSEM_R_LOCK) | COREID and is always true, so WaitLock() never waited for a semaphore held by the other core. Reading RLR is also a 1-step lock attempt, so using it as a query took the semaphore and never released it. WaitLock() now loops on HAL_HSEM_FastTake() (1-step lock, process 0, which Release() frees). IsLockedByCurrentCore() reads R through LL_HSEM_IsSemaphoreLocked()/LL_HSEM_GetCoreId(), which has no side effect. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- .../SynchronousHardwareSemaphoreStm.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/hal_st/synchronous_stm32fxxx/SynchronousHardwareSemaphoreStm.cpp b/hal_st/synchronous_stm32fxxx/SynchronousHardwareSemaphoreStm.cpp index d0e4212a..4a4fccd0 100644 --- a/hal_st/synchronous_stm32fxxx/SynchronousHardwareSemaphoreStm.cpp +++ b/hal_st/synchronous_stm32fxxx/SynchronousHardwareSemaphoreStm.cpp @@ -1,5 +1,6 @@ #include "hal_st/synchronous_stm32fxxx/SynchronousHardwareSemaphoreStm.hpp" #include "stm32wbxx.h" +#include "stm32wbxx_ll_hsem.h" namespace hal { @@ -17,8 +18,9 @@ namespace hal { HSEM->C1IER |= 1 << static_cast(semaphore); - while (!IsLockedByCurrentCore(semaphore)) - {} + while (HAL_HSEM_FastTake(static_cast(semaphore)) != HAL_OK) + { + } } void SynchronousHardwareSemaphoreMasterStm::Release(hal::Semaphore semaphore) const @@ -31,7 +33,7 @@ namespace hal bool SynchronousHardwareSemaphoreMasterStm::IsLockedByCurrentCore(hal::Semaphore semaphore) const { - return HSEM->RLR[static_cast(semaphore)] == HSEM_R_LOCK | HSEM_CR_COREID_CURRENT; + return LL_HSEM_IsSemaphoreLocked(HSEM, static_cast(semaphore)) != 0 && LL_HSEM_GetCoreId(HSEM, static_cast(semaphore)) == LL_HSEM_COREID; } SynchronousHardwareSemaphoreStm::SynchronousHardwareSemaphoreStm(SynchronousHardwareSemaphoreMasterStm& synchronousHardwareSemaphoreMaster, Semaphore semaphore) From 9d5e381e8e7e0c41c8fd7b09afb95af1691a1ad9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:16:26 +0000 Subject: [PATCH 30/57] fix(dma): encode 16- and 32-bit DMA data widths on STM32WB and STM32WBA GPDMA (STM32WBA): SetPeripheralDataSize/SetMemoryDataSize masked the size with 0x03 (4 bytes became 0), shifted size >> 1 (not a log2 for 4) and ORed the field into CTR1 without clearing it, so a 16-bit width stuck after a later 8-bit setting and 32 bit was impossible. They now write log2(size) into SDW_LOG2/DDW_LOG2 with MODIFY_REG, with the same direction-based side selection. DMA v1 (STM32WB, channel based): 4 bytes were encoded as the reserved PSIZE/MSIZE value 0b11; DataSize() then returned 8 and SetTransferSize halved CNDTR. 2 bytes now encode as PSIZE_0/MSIZE_0 and 4 bytes as PSIZE_1/MSIZE_1. Other families keep their code: H5 (GPDMA), F4/F7 (stream based) and G4 (channel based) compile to identical objects. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/DmaStm.cpp | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/hal_st/stm32fxxx/DmaStm.cpp b/hal_st/stm32fxxx/DmaStm.cpp index c8116951..e2c51411 100644 --- a/hal_st/stm32fxxx/DmaStm.cpp +++ b/hal_st/stm32fxxx/DmaStm.cpp @@ -531,6 +531,13 @@ namespace hal auto streamRegister = DmaChannel[dmaIndex][streamIndex]; #if defined(GPDMA1) +#if defined(STM32WBA) + const uint32_t log2 = dataSizeInBytes == 4 ? 2 : (dataSizeInBytes == 2 ? 1 : 0); + if (!(streamRegister->CTR2 & DMA_MEMORY_TO_MEMORY) && (streamRegister->CTR2 & DMA_MEMORY_TO_PERIPH)) + MODIFY_REG(streamRegister->CTR1, DMA_CTR1_DDW_LOG2, log2 << DMA_CTR1_DDW_LOG2_Pos); + else if (!(streamRegister->CTR2 & DMA_MEMORY_TO_MEMORY)) + MODIFY_REG(streamRegister->CTR1, DMA_CTR1_SDW_LOG2, log2 << DMA_CTR1_SDW_LOG2_Pos); +#else dataSizeInBytes &= 0x03; uint32_t mask{ 0 }; if (!(streamRegister->CTR2 & DMA_MEMORY_TO_MEMORY) && (streamRegister->CTR2 & DMA_MEMORY_TO_PERIPH)) @@ -539,8 +546,11 @@ namespace hal mask = (dataSizeInBytes >> 1) << DMA_CTR1_SDW_LOG2_Pos; streamRegister->CTR1 = (streamRegister->CTR1 & ~mask) | (streamRegister->CTR1 | mask); +#endif #elif defined(DMA_SxCR_PSIZE) streamRegister->CR = (streamRegister->CR & ~DMA_SxCR_PSIZE) | ((dataSizeInBytes & 1) == 0 ? DMA_SxCR_PSIZE_0 : 0) | (dataSizeInBytes > 2 ? DMA_SxCR_PSIZE_1 : 0); +#elif defined(STM32WB) + streamRegister->CCR = (streamRegister->CCR & ~DMA_CCR_PSIZE) | (dataSizeInBytes == 2 ? DMA_CCR_PSIZE_0 : 0) | (dataSizeInBytes == 4 ? DMA_CCR_PSIZE_1 : 0); #else streamRegister->CCR = (streamRegister->CCR & ~DMA_CCR_PSIZE) | ((dataSizeInBytes & 1) == 0 ? DMA_CCR_PSIZE_0 : 0) | (dataSizeInBytes > 2 ? DMA_CCR_PSIZE_1 : 0); #endif @@ -550,6 +560,13 @@ namespace hal { auto streamRegister = DmaChannel[dmaIndex][streamIndex]; #if defined(GPDMA1) +#if defined(STM32WBA) + const uint32_t log2 = dataSizeInBytes == 4 ? 2 : (dataSizeInBytes == 2 ? 1 : 0); + if (!(streamRegister->CTR2 & DMA_MEMORY_TO_MEMORY) && (streamRegister->CTR2 & DMA_MEMORY_TO_PERIPH)) + MODIFY_REG(streamRegister->CTR1, DMA_CTR1_SDW_LOG2, log2 << DMA_CTR1_SDW_LOG2_Pos); + else if (!(streamRegister->CTR2 & DMA_MEMORY_TO_MEMORY)) + MODIFY_REG(streamRegister->CTR1, DMA_CTR1_DDW_LOG2, log2 << DMA_CTR1_DDW_LOG2_Pos); +#else dataSizeInBytes &= 0x03; uint32_t mask{ 0 }; if (!(streamRegister->CTR2 & DMA_MEMORY_TO_MEMORY) && (streamRegister->CTR2 & DMA_MEMORY_TO_PERIPH)) @@ -558,8 +575,11 @@ namespace hal mask = (dataSizeInBytes >> 1) << DMA_CTR1_DDW_LOG2_Pos; streamRegister->CTR1 = (streamRegister->CTR1 & ~mask) | (streamRegister->CTR1 | mask); +#endif #elif defined(DMA_SxCR_MSIZE) streamRegister->CR = (streamRegister->CR & ~DMA_SxCR_MSIZE) | ((dataSizeInBytes & 1) == 0 ? DMA_SxCR_MSIZE_0 : 0) | (dataSizeInBytes > 2 ? DMA_SxCR_MSIZE_1 : 0); +#elif defined(STM32WB) + streamRegister->CCR = (streamRegister->CCR & ~DMA_CCR_MSIZE) | (dataSizeInBytes == 2 ? DMA_CCR_MSIZE_0 : 0) | (dataSizeInBytes == 4 ? DMA_CCR_MSIZE_1 : 0); #else streamRegister->CCR = (streamRegister->CCR & ~DMA_CCR_MSIZE) | ((dataSizeInBytes & 1) == 0 ? DMA_CCR_MSIZE_0 : 0) | (dataSizeInBytes > 2 ? DMA_CCR_MSIZE_1 : 0); #endif From bf98e6f61849d32f415932853ca450b9680906eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:16:40 +0000 Subject: [PATCH 31/57] fix(rng): build RandomDataGeneratorStm The header tested HAS_PERIPHERAL_RNG without including the generated peripheral table, so the class was never compiled on any target. The constructor also registered HASH_RNG_IRQn everywhere but STM32F7, which STM32WB/WBA/G4/H5 do not have; only STM32F4 keeps HASH_RNG_IRQn, every other family registers RNG_IRQn (STM32F7 already did). The class now exists on every target with an RNG (STM32G070 has none). The two hal/ includes are swapped into clang-format order. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/RandomDataGeneratorStm.cpp | 6 +++--- hal_st/stm32fxxx/RandomDataGeneratorStm.hpp | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/hal_st/stm32fxxx/RandomDataGeneratorStm.cpp b/hal_st/stm32fxxx/RandomDataGeneratorStm.cpp index 758ec804..10c0b638 100644 --- a/hal_st/stm32fxxx/RandomDataGeneratorStm.cpp +++ b/hal_st/stm32fxxx/RandomDataGeneratorStm.cpp @@ -7,10 +7,10 @@ namespace hal { RandomDataGeneratorStm::RandomDataGeneratorStm() { -#if defined(STM32F7) - Register(RNG_IRQn); -#else +#if defined(STM32F4) Register(HASH_RNG_IRQn); +#else + Register(RNG_IRQn); #endif __HAL_RCC_RNG_CLK_ENABLE(); RNG->CR |= RNG_CR_RNGEN; diff --git a/hal_st/stm32fxxx/RandomDataGeneratorStm.hpp b/hal_st/stm32fxxx/RandomDataGeneratorStm.hpp index 89ad4666..78d72c59 100644 --- a/hal_st/stm32fxxx/RandomDataGeneratorStm.hpp +++ b/hal_st/stm32fxxx/RandomDataGeneratorStm.hpp @@ -1,8 +1,9 @@ #ifndef HAL_RANDOM_DATA_GENERATOR_STM_HPP #define HAL_RANDOM_DATA_GENERATOR_STM_HPP -#include "hal/interfaces/RandomDataGenerator.hpp" +#include "generated/stm32fxxx/PeripheralTable.hpp" #include "hal/cortex_m/InterruptCortex.hpp" +#include "hal/interfaces/RandomDataGenerator.hpp" #if defined(HAS_PERIPHERAL_RNG) From a1d379257e6945c7b468373b4ffcc1c9fb6c28d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:16:42 +0000 Subject: [PATCH 32/57] fix(clock): clock the STM32WBA55 RNG from HSI in the Nucleo default clock RNGSEL resets to LSE and the NUCLEO-WBA55CG default clock never starts LSE, so the RNG never produced data (or flagged a clock error) and PkaStm waited for INITOK forever. After the system clock switch, enable HSI explicitly and select it as the RNG kernel clock, as the WBA link-layer platform does (LinkLayerPlatformWba::ConfigureRandomDataGeneratorClock). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/DefaultClockNucleoWBA55CG.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/hal_st/stm32fxxx/DefaultClockNucleoWBA55CG.cpp b/hal_st/stm32fxxx/DefaultClockNucleoWBA55CG.cpp index e0ccfa8b..f41a62ff 100644 --- a/hal_st/stm32fxxx/DefaultClockNucleoWBA55CG.cpp +++ b/hal_st/stm32fxxx/DefaultClockNucleoWBA55CG.cpp @@ -16,6 +16,7 @@ * PLL_Q = 2 * PLL_R = 2 * Flash Latency(WS) = 3 + * RNG kernel clock = HSI */ void ConfigureDefaultClockNucleoWBA55CG() { @@ -52,4 +53,14 @@ void ConfigureDefaultClockNucleoWBA55CG() RCC_ClkInitStruct.AHB5_PLL1_CLKDivider = RCC_SYSCLK_PLL1_DIV4; RCC_ClkInitStruct.AHB5_HSEHSI_CLKDivider = RCC_SYSCLK_HSEHSI_DIV1; HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_3); + + LL_RCC_HSI_Enable(); + while (!LL_RCC_HSI_IsReady()) + { + } + + RCC_PeriphCLKInitTypeDef RCC_PeriphClkInitStruct = { 0 }; + RCC_PeriphClkInitStruct.PeriphClockSelection = RCC_PERIPHCLK_RNG; + RCC_PeriphClkInitStruct.RngClockSelection = RCC_RNGCLKSOURCE_HSI; + HAL_RCCEx_PeriphCLKConfig(&RCC_PeriphClkInitStruct); } From d042cc2e4df097ea082e68059b09dcb25aa49901 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:16:51 +0000 Subject: [PATCH 33/57] fix(backupram): expose BackupRamStm as hal::BackupRam and enable TAMP access on STM32WBA BackupRamStm inherited hal::BackupRam privately, so it could not be passed as a hal::BackupRam& ("inaccessible base"). Inherit publicly; hal::BackupRam has a virtual destructor. On STM32WBA the TAMP backup registers need the RTC APB clock and backup-domain write access, and neither the driver nor the default clock enabled them. Add a constructor that, on STM32WBA only, calls __HAL_RCC_RTCAPB_CLK_ENABLE() and HAL_PWR_EnableBkUpAccess(). Other families get an empty constructor; Get() is unchanged everywhere. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/BackupRamStm.cpp | 8 ++++++++ hal_st/stm32fxxx/BackupRamStm.hpp | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/hal_st/stm32fxxx/BackupRamStm.cpp b/hal_st/stm32fxxx/BackupRamStm.cpp index f74b6901..3100239c 100644 --- a/hal_st/stm32fxxx/BackupRamStm.cpp +++ b/hal_st/stm32fxxx/BackupRamStm.cpp @@ -3,6 +3,14 @@ namespace hal { + BackupRamStm::BackupRamStm() + { +#if defined(STM32WBA) + __HAL_RCC_RTCAPB_CLK_ENABLE(); + HAL_PWR_EnableBkUpAccess(); +#endif + } + infra::MemoryRange BackupRamStm::Get() const { #if defined(STM32G0) diff --git a/hal_st/stm32fxxx/BackupRamStm.hpp b/hal_st/stm32fxxx/BackupRamStm.hpp index 64e6377d..0c53a1ae 100644 --- a/hal_st/stm32fxxx/BackupRamStm.hpp +++ b/hal_st/stm32fxxx/BackupRamStm.hpp @@ -7,9 +7,11 @@ namespace hal { class BackupRamStm - : hal::BackupRam + : public hal::BackupRam { public: + BackupRamStm(); + infra::MemoryRange Get() const override; }; } From 966a2194ea450ac0b1132a6b797d5cc554dacdf2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:17:01 +0000 Subject: [PATCH 34/57] fix(adc): measure the requested number of samples and stop the conversions after a measurement AdcTriggeredByTimerWithDma::Measure ignored numberOfSamples and always filled and reported the whole buffer. On STM32WB/WBA it now starts the DMA on the first min(numberOfSamples, buffer size) elements (really_assert non-zero), keeps that range and reports it on completion. TransferDone called ADC_Disable while ADSTART was still set: in external-trigger mode ADSTART stays set after the one-shot DMA, so ADC_Disable returned HAL_ERROR (the assert is compiled out) and left the ADC enabled with conversions pending for the next Measure. On STM32WB/WBA TransferDone now stops the regular conversions and waits for ADSTP to clear before disabling the ADC. STM32H5 is unchanged (identical object code); the same defects remain there until validated on hardware. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/AdcDmaStm.cpp | 20 ++++++++++++++++++++ hal_st/stm32fxxx/AdcDmaStm.hpp | 3 +++ 2 files changed, 23 insertions(+) diff --git a/hal_st/stm32fxxx/AdcDmaStm.cpp b/hal_st/stm32fxxx/AdcDmaStm.cpp index 106f8640..4827e78d 100644 --- a/hal_st/stm32fxxx/AdcDmaStm.cpp +++ b/hal_st/stm32fxxx/AdcDmaStm.cpp @@ -1,5 +1,7 @@ #include "hal_st/stm32fxxx/AdcDmaStm.hpp" #include "infra/event/EventDispatcher.hpp" +#include "infra/util/ReallyAssert.hpp" +#include namespace hal { @@ -59,7 +61,14 @@ namespace hal timer.Start(); Configure(); +#if defined(STM32WB) || defined(STM32WBA) + const auto samples = std::min(numberOfSamples, buffer.size()); + really_assert(samples != 0); + measurement = infra::Head(buffer, samples); + dmaStream.StartReceive(measurement); +#else dmaStream.StartReceive(buffer); +#endif LL_ADC_REG_StartConversion(adc.Handle().Instance); } @@ -91,6 +100,13 @@ namespace hal void AdcTriggeredByTimerWithDma::TransferDone() { +#if defined(STM32WB) || defined(STM32WBA) + // External-trigger mode keeps ADSTART set, and ADC_Disable refuses while it is set + LL_ADC_REG_StopConversion(adc.Handle().Instance); + while (LL_ADC_REG_IsStopConversionOngoing(adc.Handle().Instance)) + { + } +#endif auto result = ADC_Disable(&adc.Handle()); assert(result == HAL_OK); @@ -99,7 +115,11 @@ namespace hal if (this->onDone) infra::EventDispatcher::Instance().Schedule([this]() { +#if defined(STM32WB) || defined(STM32WBA) + onDone(measurement); +#else onDone(buffer); +#endif }); } } diff --git a/hal_st/stm32fxxx/AdcDmaStm.hpp b/hal_st/stm32fxxx/AdcDmaStm.hpp index 08caaa29..4903b31f 100644 --- a/hal_st/stm32fxxx/AdcDmaStm.hpp +++ b/hal_st/stm32fxxx/AdcDmaStm.hpp @@ -32,6 +32,9 @@ namespace hal AnalogPinStm analogPin; FreeRunningTimerStm timer; infra::AutoResetFunction)> onDone; +#if defined(STM32WB) || defined(STM32WBA) + infra::MemoryRange measurement; +#endif }; } From b8a15222bd0fa2237641f5e72f4c96255836500a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:17:07 +0000 Subject: [PATCH 35/57] fix(gpio): index SynchronousGpioStm ports by their letter on STM32WB/WBA hal::Port is a fixed A..I enumeration, but portToGPIOPort only listed the ports a device has. On STM32WB (A-E, H) and STM32WBA (A-C, H; WBA65 also D, E, G) every letter after the first missing port indexed the wrong port or past the end of the table (Port::H on WB55 and WBA55). On STM32WB/WBA the table now has one entry per letter A..I, nullptr for a port the device lacks, and SynchronousOutputPinStm and SmallPeripheralPinStm really_assert that their port exists. Other families keep the compacted table (identical object code). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- .../SynchronousGpioStm.cpp | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/hal_st/synchronous_stm32fxxx/SynchronousGpioStm.cpp b/hal_st/synchronous_stm32fxxx/SynchronousGpioStm.cpp index 8d45bb9a..6d35dac0 100644 --- a/hal_st/synchronous_stm32fxxx/SynchronousGpioStm.cpp +++ b/hal_st/synchronous_stm32fxxx/SynchronousGpioStm.cpp @@ -1,4 +1,5 @@ #include "hal_st/synchronous_stm32fxxx/SynchronousGpioStm.hpp" +#include "infra/util/ReallyAssert.hpp" namespace hal { @@ -10,21 +11,33 @@ namespace hal GPIOC, #if defined(GPIOD) GPIOD, +#elif defined(STM32WB) || defined(STM32WBA) + nullptr, #endif #if defined(GPIOE) GPIOE, +#elif defined(STM32WB) || defined(STM32WBA) + nullptr, #endif #if defined(GPIOF) GPIOF, +#elif defined(STM32WB) || defined(STM32WBA) + nullptr, #endif #if defined(GPIOG) GPIOG, +#elif defined(STM32WB) || defined(STM32WBA) + nullptr, #endif #if defined(GPIOH) GPIOH, +#elif defined(STM32WB) || defined(STM32WBA) + nullptr, #endif #if defined(GPIOI) GPIOI, +#elif defined(STM32WB) || defined(STM32WBA) + nullptr, #endif #if defined(GPIOJ) GPIOJ, @@ -83,6 +96,9 @@ namespace hal : port(port) , index(index) { +#if defined(STM32WB) || defined(STM32WBA) + really_assert(portToGPIOPort[static_cast(port)] != nullptr); +#endif GPIO_InitTypeDef GPIO_InitStructure = {}; GPIO_InitStructure.Pin = pinToGPIOPin[index]; GPIO_InitStructure.Mode = driveToOutputMode[static_cast(drive)]; @@ -118,6 +134,9 @@ namespace hal void SmallPeripheralPinStm::ConfigPeripheralPin(uint8_t af, Drive drive, Speed speed, WeakPull weakPull) { +#if defined(STM32WB) || defined(STM32WBA) + really_assert(portToGPIOPort[static_cast(port)] != nullptr); +#endif GPIO_InitTypeDef GPIO_InitStructure = {}; GPIO_InitStructure.Pin = pinToGPIOPin[index]; GPIO_InitStructure.Mode = driveToAFMode[static_cast(drive)]; From 1b656272d0854be439ab28a1654e6520bed58fad Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:17:22 +0000 Subject: [PATCH 36/57] fix(qspi): complete QuadSpiStmDma writes on transfer complete and keep reserved CR bits clear on STM32WB An indirect write completed on the DMA transfer-complete, i.e. when the last byte entered the QUADSPI FIFO, not when it left the pins. onDone (and SingleSpeedQuadSpiStmDma's ChipSelectConfigurator::EndSession) ran while the FIFO still drained, so a configurator-driven chip select could end the session early and the next command's CCR write could meet BUSY=1. On STM32WB the DMA completion of a write (CCR.FMODE = 0) now clears DMAEN and enables TCIE without clearing TCF; the existing TCF interrupt handler completes the write, as ST's HAL does. Reads keep their path. The constructor wrote (32 << 8) into CR. FTHRES is 4 bits on STM32WB (CR[11:8]), so the term only set reserved bit 13 and left the threshold at 1 byte. It is dropped on STM32WB; the threshold is unchanged. Other families keep today's code (F746/F767 compile to identical code). Proved on NUCLEO-WB55RG by test_qspi.py::test_write_completes_after_last_byte[dma,spi] (FIFO level 0 in the completion callback at prescaler 255 with >= 32 bytes) and test_qspi.py::test_back_to_back_writes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/QuadSpiStmDma.cpp | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/hal_st/stm32fxxx/QuadSpiStmDma.cpp b/hal_st/stm32fxxx/QuadSpiStmDma.cpp index c5c53266..e44dd190 100644 --- a/hal_st/stm32fxxx/QuadSpiStmDma.cpp +++ b/hal_st/stm32fxxx/QuadSpiStmDma.cpp @@ -22,7 +22,11 @@ namespace hal { EnableClockQuadSpi(0); +#if defined(STM32WB) + QUADSPI->CR = (config.prescaler << 24) | QUADSPI_CR_APMS | QSPI_SAMPLE_SHIFTING_HALFCYCLE | QUADSPI_CR_EN; +#else QUADSPI->CR = (config.prescaler << 24) | QUADSPI_CR_APMS | QSPI_SAMPLE_SHIFTING_HALFCYCLE | (32 << 8) | QUADSPI_CR_EN; +#endif QUADSPI->DCR = ((config.flashSizeLog2 - 1) << 16) | QSPI_CS_HIGH_TIME_2_CYCLE; } @@ -154,6 +158,15 @@ namespace hal void QuadSpiStmDma::OnDmaTransferDone() { +#if defined(STM32WB) + // A write's DMA completes when the last byte enters the FIFO, not when it has left the pins + if (READ_BIT(QUADSPI->CCR, QUADSPI_CCR_FMODE) == 0) + { + MODIFY_REG(QUADSPI->CR, QUADSPI_CR_DMAEN, QUADSPI_CR_TCIE); + return; + } +#endif + QUADSPI->FCR |= QUADSPI_FCR_CTCF; QUADSPI->CR &= ~QUADSPI_CR_DMAEN; onDone(); From 6d2d77f218942ef21e01fbd8916e0df402f4d08f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:20:46 +0000 Subject: [PATCH 37/57] feat(validation): add resource allocation, payloads and board data for the new groups Firmware infrastructure shared by the new HIL command groups: - Owners.hpp: owner ids 16..30 for the new groups (17 stays the WWDG warning pin), shared by the pin pool, TimerAllocation and ResourceAllocation. - ResourceAllocation: one holder per I2C/SPI/ADC/LPTIM instance, per shared DMA channel and per HSEM semaphore across groups (i2c, i2cs and eeprom; spi and spis; adc and ain; qei, lptim and lptpwm; flash coord and hsem.lock). A second claim answers ERR busy. - TimerAllocation::Owner() so adc.open trgo= can check that pwm holds the trigger timer. - Payload: firmware-generated payloads (inc, const, xorshift32 prbs), CRC-32 and out=hex|crc, so commands stay within 255 characters. - ChannelPins: contiguous GpioPinStm/DummyPinStm storage, as TimerPwmWithChannels indexes one MemoryRange by channel. - Stopwatch: microseconds from the DWT cycle counter, usable in ISRs. - HsemMaster (WB55): one HSEM master that is never destroyed, since its destructor gates the HSEM clock. - PeripheralClocks: I2C/SPI/LPTIM existence, SPI limited instance, I2C and LPTIM kernel clocks. - Board profiles: DMA allocation, scaffold timer, flash scratch region, low-power pins, I2C and QSPI pins, and the new pin aliases. - WBA55: PA3, PB10, PB11 and PB13 are SMPS/VDD11 pads on the UFQFPN48 and leave the bonded mask; the debug LED moves from PB8 (red LD3) to PA9 (green LD2), freeing the only SPI3 MOSI and TIM16 CH1N pin. LD2 is not connected on a stock board (SB28 open), so the heartbeat stays dark unless SB28 is closed. Board files describe optional wiring (--with) as mappings: jumpered pins per tag, loaded pins, pull-ups and mutually exclusive options. --with tags are checked against the selected wiring sets at collection (a usage error otherwise), and need skips a test that resolves a pin an enabled option loads unless the test is marked uses_option (requires_option implies it); pins an option jumpers to a channel resolve only for those tests. --board-extra (or HAL_ST_BOARD_EXTRA) deep-merges YAML over the board file, with key! to replace a value. conflicts_option skips a test while an option is enabled; the AD3 pulls and the I2C/SPI protocol engines are reset after each AD3 test. tests/hil/test_wiring.py checks the wiring with GPIO commands only: continuity of each enabled option's jumpers, external pull-ups, the jumpers of offered options that are not enabled, and pins nothing else may drive (tests.wiring.undriven). Command group wrappers in groups/*.py (GROUPS) are attached to Firmware, and fake groups in fakes/*.py (FakeGroup) answer . in the fake firmware, sharing pin claims, timer owners and a model of ResourceAllocation. patterns.py is the host twin of the firmware payload generator and CRC. The fake firmware follows the firmware changes: E.4 aliases, the WBA55 bonding mask (PA3, PA4, PB10, PB11, PB13 unbonded) and debug LED PA9, the pin functions of the generated tables (I2C, SPI NSS, LPTIM channels and inputs, QUADSPI), per-MCU unsupported names (eeprom.* served; WBA55 without hsem, qspi, flash.stack, clock.mco/hsi48; WB55 without lptpwm), spi bits/lsb/nss, pwm alignment modes, preload, brkfilter and trgo, adc trgo, uart sendonly and the WBA55 LPTIM encoder with cap=rise|fall. The board files' tests.unsupported.commands follow the per-MCU names. The board files take the E.4 aliases, the WBA55 debug LED PA9, the unbonded PA3/PB10/PB11/PB13 and the GPIO limit list without led1 here, so they match the board profiles of this commit (the rest of the docs-bench wiring text follows in its own commit). test_system.py treats the debug LED like the terminal pins when it opens every alias: on the WBA55 led1 is the debug LED now. With LPTIM in the WBA55 table (gen) the encoder opens with lp=1 there, so qei.open 1 lp=1 leaves the WBA55 unsupported instances, and test_qei.py::test_lptim_errors expects cap=a (not cap=ab, which the LPTIM encoder takes) to be unsupported. validation/README.md documents --board-extra, --with, the load gate, the option keys, test_wiring.py and the new host and firmware modules. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- validation/README.md | 19 +- validation/firmware/BoardTypes.hpp | 33 ++ validation/firmware/CMakeLists.txt | 9 + validation/firmware/ChannelPins.hpp | 102 ++++ validation/firmware/HsemMaster.cpp | 25 + validation/firmware/HsemMaster.hpp | 12 + validation/firmware/Main.cpp | 2 + validation/firmware/Owners.hpp | 24 + validation/firmware/Payload.cpp | 120 +++++ validation/firmware/Payload.hpp | 41 ++ validation/firmware/PeripheralClocks.cpp | 54 ++ validation/firmware/PeripheralClocks.hpp | 9 + validation/firmware/ResourceAllocation.cpp | 32 ++ validation/firmware/ResourceAllocation.hpp | 38 ++ validation/firmware/Stopwatch.hpp | 46 ++ validation/firmware/TimerAllocation.cpp | 7 + validation/firmware/TimerAllocation.hpp | 1 + .../boards/stm32wb55/BoardProfile.hpp | 32 ++ .../boards/stm32wba55/BoardProfile.hpp | 39 +- validation/host/boards/nucleo_wb55rg.yaml | 25 +- validation/host/boards/nucleo_wba55cg.yaml | 46 +- validation/host/hal_st_validation/config.py | 231 ++++++++- .../host/hal_st_validation/fake_firmware.py | 488 ++++++++++++------ .../host/hal_st_validation/fakes/__init__.py | 28 + .../host/hal_st_validation/fakes/base.py | 130 +++++ validation/host/hal_st_validation/firmware.py | 58 ++- .../host/hal_st_validation/groups/__init__.py | 29 ++ .../host/hal_st_validation/groups/base.py | 37 ++ validation/host/hal_st_validation/patterns.py | 45 ++ validation/host/hal_st_validation/protocol.py | 14 +- validation/host/pyproject.toml | 5 +- validation/host/tests/conftest.py | 161 +++++- validation/host/tests/hil/test_qei.py | 2 +- validation/host/tests/hil/test_system.py | 5 +- validation/host/tests/hil/test_wiring.py | 89 ++++ validation/host/tests/unit/test_config.py | 10 +- .../host/tests/unit/test_config_options.py | 221 ++++++++ .../host/tests/unit/test_fake_firmware.py | 329 +++++++++++- .../host/tests/unit/test_fake_groups.py | 216 ++++++++ validation/host/tests/unit/test_firmware.py | 40 +- validation/host/tests/unit/test_load_gate.py | 174 +++++++ validation/host/tests/unit/test_patterns.py | 42 ++ validation/host/tests/unit/test_protocol.py | 26 +- 43 files changed, 2815 insertions(+), 281 deletions(-) create mode 100644 validation/firmware/ChannelPins.hpp create mode 100644 validation/firmware/HsemMaster.cpp create mode 100644 validation/firmware/HsemMaster.hpp create mode 100644 validation/firmware/Owners.hpp create mode 100644 validation/firmware/Payload.cpp create mode 100644 validation/firmware/Payload.hpp create mode 100644 validation/firmware/ResourceAllocation.cpp create mode 100644 validation/firmware/ResourceAllocation.hpp create mode 100644 validation/firmware/Stopwatch.hpp create mode 100644 validation/host/hal_st_validation/fakes/__init__.py create mode 100644 validation/host/hal_st_validation/fakes/base.py create mode 100644 validation/host/hal_st_validation/groups/__init__.py create mode 100644 validation/host/hal_st_validation/groups/base.py create mode 100644 validation/host/hal_st_validation/patterns.py create mode 100644 validation/host/tests/hil/test_wiring.py create mode 100644 validation/host/tests/unit/test_config_options.py create mode 100644 validation/host/tests/unit/test_fake_groups.py create mode 100644 validation/host/tests/unit/test_load_gate.py create mode 100644 validation/host/tests/unit/test_patterns.py diff --git a/validation/README.md b/validation/README.md index b30c50ff..c9f77c84 100644 --- a/validation/README.md +++ b/validation/README.md @@ -104,10 +104,11 @@ Run with `--depth quick` first; once it passes, run it again with `--depth full` Options from `tests/conftest.py`: - `--board` - board file name in `host/boards/` or a path to a YAML file (default `nucleo_wb55rg`, or `HAL_ST_BOARD`). +- `--board-extra ` - YAML deep-merged over the board file, repeatable (or `HAL_ST_BOARD_EXTRA`, a list separated by `os.pathsep`): mappings merge, lists append, a scalar replaces, and a key ending in `!` replaces the value of the key without it (`limit_pins!: [...]`). - `--port`, `--baud` - firmware terminal serial port (or `HAL_ST_PORT`) and baud rate (default from the board file, 921600). - `--command-timeout` - seconds to wait for a reply (or `HAL_ST_COMMAND_TIMEOUT`, default from the board file); raise it for slow links such as port-bridge. - `--wiring-set a,b` - active wiring sets (or `HAL_ST_WIRING`). -- `--with ` - enable optional wiring (`loopback` for the SPI MOSI-MISO jumper), repeatable. +- `--with ` - enable optional wiring, repeatable. The tag must be an option of a selected `--wiring-set`, and options that exclude each other cannot be enabled together; otherwise the run stops with a usage error. - `--depth quick|full` - `quick` (default, or `HAL_ST_DEPTH`) runs a pairwise subset of every parameter matrix: every pair of values of any two parameters appears in at least one test. `full` runs the complete cartesian products. - `--set path=value` - override a test parameter, value parsed as YAML: `--set pwm.waveform.freq=[20000] --set uart.transfer.baud=[921600]`. - `--run-known-gaps` - also run the tests of known driver gaps that abort or hang the firmware (see [Known driver gaps](#known-driver-gaps)); they are skipped by default. @@ -116,10 +117,12 @@ Options from the `ad3_waveforms_bench` pytest plugin (loaded automatically once - `--ad3-serial` - pick an AD3 by serial number (or `AD3_SERIAL`); `--ad3-remote host[:port]` uses an AD3 on another machine (or `AD3_REMOTE`); `--no-ad3` skips every test that needs it. - `--fake` - run the HIL plumbing against the in-memory fakes (`FakeDwfApi` for the AD3, `hal_st_validation.fake_firmware` for the terminal); only useful when changing the test code. - - The fake firmware validates arguments in the firmware's order and models pins, instances, timer sharing, EXTI line ownership, ADC triggers and the watchdog. + - The fake firmware validates arguments in the firmware's order and models pins, instances, timer sharing, the peripherals and DMA channels groups share, EXTI line ownership, ADC triggers and the watchdog; the groups of `fakes/*.py` add the newer command groups. - It models no measured signal, so most tests that read the AD3 fail under `--fake --wiring-set ...`; `--fake --no-ad3` passes completely. Tests that need no AD3 (system, argument errors, limits, instance and timer sharing, watchdog behaviour) run with any wiring set. +Optional wiring loads pins: a test that resolves an AD3 channel (`need.dio`, `need.wavegen`, `need.scope`) for a pin an enabled option loads skips ("pin X loaded by --with T") unless it is marked `@pytest.mark.uses_option("T")` or `@pytest.mark.requires_option("T")`. +Pins an option ties to a channel with a jumper resolve only for such tests. `requires_option` skips the test without the option, `conflicts_option` skips it with the option. Tests that reset the board on purpose (watchdog, UART swap) are marked `resets_board`; any other unexpected `EVT boot` fails the test that caused it. Every instance a test opened is closed afterwards and the AD3 outputs are released, so tests are independent (the firmware keeps at most one PWM timer, UART, SPI, ADC, encoder and watchdog open at a time). Use `-k`, `-m "not slow"` and `--junitxml report.xml` as usual. @@ -272,6 +275,7 @@ The tables follow the `wiring_sets` of the board files (keep both in step); the ## What is tested +- `test_wiring.py` - the bench wiring, with GPIO commands only and the AD3 outputs and pulls off: continuity of the jumpers of every enabled option, its external pull-ups, the jumpers of offered options that are not enabled ("pass --with or remove the wiring"), and that the pins of `tests.wiring.undriven` follow both MCU pulls. Run it first after wiring the board; it skips with `--fake`. - `test_system.py` - `ping`, `info`, and the `board.pins` alias table against the board file in both directions, every alias accepted as a pin, reserved terminal/SWD/LSE/BOOT0 pins and the debug LED, unbonded pins, pin syntax, the terminal UART, error reasons (`usage`, `busy`, `notopen`, `range`, `unsupported`), missing instances (including 0), `delay`, `reset` and the `EVT boot` cause. - `test_gpio.py` - output levels with every drive (`hal::Speed`), inputs following the AD3 with every pull, pull-only idle levels, open drain, the user LED, eight pins at a time, pins held by other groups, interrupt counts for edge x handler type x pulse count x frequency against exact AD3 pulse trains, one EXTI line per port at a time, `gpio.pulse` timing. - `test_pwm.py` - for `PwmStm` and `SynchronousPwmStm`: @@ -293,12 +297,15 @@ The tables follow the `wiring_sets` of the board files (keep both in step); the Each board file (`host/boards/.yaml`) holds: - `terminal`, `clocks` (the kernel clocks the expectations use), `pins` (the PROTOCOL.md alias table, compared with `board.pins`; only the generic names of `hal_st_validation.protocol` are accepted) and `ad3` (supplies, analog limits). -- `wiring_sets` - per set, `dio`, `wavegen` and `scope` maps from AD3 channel to pin or alias. An entry is either a pin or a mapping with `pin`, `jumpered` (pins tied to `pin` with a wire), `role` (a name tests can look up), `note` and `requires` (only used with `--with `); `jumpers` and `options` document extra wiring. +- `wiring_sets` - per set, `dio`, `wavegen` and `scope` maps from AD3 channel to pin or alias. An entry is either a pin or a mapping with `pin`, `jumpered` (pins tied to `pin` with a wire), `role` (a name tests can look up), `note` and `requires` (only used with `--with `, which the set must offer); `jumpers` documents extra wiring. +- `wiring_sets..options.` - the optional wiring a set offers: a description, or a mapping with `description`, `jumpered` (key pin -> pins the wiring ties to it; the self-check drives the key), `loads` (pins the wiring loads), `pullups` (pins it pulls up to 3V3) and `excludes` (options that cannot be fitted at the same time). - `known_gaps` - driver gaps with the test ids (wildcards `*` and `?`) they affect, the reason and whether they hang the firmware. - `tests` - the parameters of every test module: parameter matrices, pins and instances, levels and tolerances. - `@pytest.mark.matrix("pwm.waveform")` turns every key of that mapping into one test parameter of the same name; `@pytest.mark.board_params("argname", "section.key")` adds one parameter from a list. - All parameters of a test form one matrix: `--depth full` runs its product, `--depth quick` a pairwise subset (`hal_st_validation.pairwise`); `@pytest.mark.constraint(valid=...)` removes combinations a driver cannot take (for example parity with the synchronous UART). - Extending a sweep or moving a peripheral to other pins is a YAML change. + - `tests.wiring.undriven` lists pins nothing on the board may drive (solder bridges to ST-LINK lines); `test_wiring.py` checks them. + - `@pytest.mark.wiring_options("tag")` runs a test once per enabled option (`enabled=False`: per offered option that is not enabled), each case marked `uses_option`. To validate another board, add a board profile under `firmware/boards//` and the MCU to `emil_build_for` in `firmware/CMakeLists.txt`, copy a board file, adapt the pins, wiring sets and parameters, and pass `--board path/to/board.yaml`. @@ -316,7 +323,10 @@ The console forwards commands, prints final lines and events, and keeps a histor In `hal_st_validation` (hal-st specific): -- `firmware.py` - typed API with one group per PROTOCOL.md section (`fw.system`, `fw.gpio`, `fw.pwm`, `fw.uart`, `fw.spi`, `fw.adc`, `fw.qei`, `fw.wdt`); keyword arguments map 1:1 to protocol options (`continue_` for `continue`). +- `firmware.py` - typed API with one group per PROTOCOL.md section (`fw.system`, `fw.gpio`, `fw.pwm`, `fw.uart`, `fw.spi`, `fw.adc`, `fw.qei`, `fw.wdt`); keyword arguments map 1:1 to protocol options (`continue_` for `continue`). The groups of `groups/*.py` are attached under the names their `GROUPS` mapping exports. +- `groups/` - `Group` (`groups/base.py`: `_cmd`, `begin` for commands whose final line comes later, pin resolution) and one module per area exporting `GROUPS = {"name": GroupClass}`. +- `fakes/` - `FakeGroup` and the argument helpers (`fakes/base.py`); one module per area with the fake firmware's model of its command groups, found automatically. +- `patterns.py` - the payloads the firmware generates (`len=`, `pattern=inc|const|prbs`, `seed=`) and their CRC-32 (`crc=`). - `protocol.py` - the hal-st part of the protocol: error reasons, `P` pins (ports A-K, index 0-15) and the generic alias names (`normalize_pin`, `parse_pin_map`). - `config.py` - board file loading, wiring-set merging, parameter matrices, overrides and known gaps; `expect.py` - expected STM32 values (PWM quantisation and range, SPI prescaler, UART baud-rate register limits, WWDG prescaler and period, ADC codes, encoder counts). - `pairwise.py` - the full product and the deterministic pairwise generator behind `--depth`. @@ -327,4 +337,5 @@ In the firmware (`firmware/`): - `Main.cpp` composes the console, the pin pool and every command group; `Console` is the terminal on USART1; `boards//BoardProfile.hpp` holds the aliases, reserved pins, default pins, DMA request lines, clocks and ADC tables of each board. - `PinFactoryStm` builds `hal::GpioPinStm`s over the generated pinout tables (bonded pins only, analog sharing, one EXTI line per port); `BoardInfoStm` reports the board, clock and reset cause; `TimerAllocation` keeps PWM, encoder and timer-triggered ADC off each other's timer. +- `ResourceAllocation` keeps the groups that share an I2C, SPI, ADC or LPTIM instance, a DMA channel or an HSEM semaphore off each other, with the owner ids of `Owners.hpp`; `Payload` generates the `len=`/`pattern=` payloads and the `out=crc` CRCs; `ChannelPins`, `Stopwatch` (microseconds from the cycle counter) and `HsemMaster` (the one HSEM master of the STM32WB55) serve the newer groups. - One factory per command group (`UartFactory`, `SpiFactory`, `AdcFactory`, `PwmFactory`, `QeiFactory` with the `qei.index` command, `WatchDogFactory`) parses the hal-st options and builds the driver; `UnsupportedGroups` answers the rest. diff --git a/validation/firmware/BoardTypes.hpp b/validation/firmware/BoardTypes.hpp index 1ca5f240..dd436105 100644 --- a/validation/firmware/BoardTypes.hpp +++ b/validation/firmware/BoardTypes.hpp @@ -2,6 +2,7 @@ #include "hal_st/stm32fxxx/GpioStm.hpp" #include "services/hil/HilPinId.hpp" +#include #include namespace validation @@ -40,4 +41,36 @@ namespace validation uint8_t transmit; uint8_t receive; }; + + struct DmaChannel + { + uint8_t dma; + uint8_t channel; + }; + + struct DmaPair + { + DmaChannel transmit; + DmaChannel receive; + }; + + struct I2cPins + { + uint8_t index; + HilPinId scl; + HilPinId sda; + }; + + struct QuadSpiPins + { + HilPinId clk; + HilPinId ncs; + std::array io; + }; + + struct LowPowerPins + { + HilPinId wake; + HilPinId marker; + }; } diff --git a/validation/firmware/CMakeLists.txt b/validation/firmware/CMakeLists.txt index a87a4d12..f852db57 100644 --- a/validation/firmware/CMakeLists.txt +++ b/validation/firmware/CMakeLists.txt @@ -26,9 +26,15 @@ target_sources(hal_st.validation_firmware PRIVATE BoardInfoStm.cpp BoardInfoStm.hpp BoardTypes.hpp + ChannelPins.hpp Console.cpp Console.hpp + HsemMaster.cpp + HsemMaster.hpp Main.cpp + Owners.hpp + Payload.cpp + Payload.hpp PeripheralClocks.cpp PeripheralClocks.hpp PinFactoryStm.cpp @@ -37,8 +43,11 @@ target_sources(hal_st.validation_firmware PRIVATE PwmFactory.hpp QeiFactory.cpp QeiFactory.hpp + ResourceAllocation.cpp + ResourceAllocation.hpp SpiFactory.cpp SpiFactory.hpp + Stopwatch.hpp TimerAllocation.cpp TimerAllocation.hpp UartFactory.cpp diff --git a/validation/firmware/ChannelPins.hpp b/validation/firmware/ChannelPins.hpp new file mode 100644 index 00000000..41708c54 --- /dev/null +++ b/validation/firmware/ChannelPins.hpp @@ -0,0 +1,102 @@ +#pragma once + +#include "hal_st/stm32fxxx/GpioStm.hpp" +#include "infra/util/MemoryRange.hpp" +#include "infra/util/ReallyAssert.hpp" +#include +#include +#include +#include +#include + +namespace validation +{ + // TimerPwmWithChannels and LpTimerPwmWithChannels index one contiguous MemoryRange by channel, so an + // unused channel needs a DummyPinStm in its slot rather than a separate object + template + class ChannelPins + { + static_assert(sizeof(hal::DummyPinStm) == sizeof(hal::GpioPinStm) && alignof(hal::DummyPinStm) == alignof(hal::GpioPinStm)); + + public: + ChannelPins() = default; + ChannelPins(const ChannelPins& other) = delete; + ChannelPins& operator=(const ChannelPins& other) = delete; + ~ChannelPins(); + + void Emplace(std::size_t position, hal::Port port, uint8_t index); + void EmplaceUnused(std::size_t position); + infra::MemoryRange Range(std::size_t count); + + private: + enum class Kind : uint8_t + { + none, + pin, + unused, + }; + + void* Slot(std::size_t position); + void Destroy(std::size_t position); + + private: + alignas(hal::GpioPinStm) std::array storage; + std::array kinds{}; + }; + + //// Implementation //// + + template + ChannelPins::~ChannelPins() + { + for (std::size_t position = 0; position != N; ++position) + Destroy(position); + } + + template + void ChannelPins::Emplace(std::size_t position, hal::Port port, uint8_t index) + { + auto slot = Slot(position); + Destroy(position); + new (slot) hal::GpioPinStm(port, index); + kinds[position] = Kind::pin; + } + + template + void ChannelPins::EmplaceUnused(std::size_t position) + { + auto slot = Slot(position); + Destroy(position); + new (slot) hal::DummyPinStm(); + kinds[position] = Kind::unused; + } + + template + infra::MemoryRange ChannelPins::Range(std::size_t count) + { + really_assert(count <= N); + for (std::size_t position = 0; position != count; ++position) + really_assert(kinds[position] != Kind::none); + + auto first = std::launder(reinterpret_cast(storage.data())); + return infra::MemoryRange(first, first + count); + } + + template + void* ChannelPins::Slot(std::size_t position) + { + really_assert(position < N); + return storage.data() + position * sizeof(hal::GpioPinStm); + } + + template + void ChannelPins::Destroy(std::size_t position) + { + if (kinds[position] == Kind::pin) + std::destroy_at(std::launder(static_cast(Slot(position)))); + else if (kinds[position] == Kind::unused) + std::destroy_at(std::launder(static_cast(Slot(position)))); + + kinds[position] = Kind::none; + } +} diff --git a/validation/firmware/HsemMaster.cpp b/validation/firmware/HsemMaster.cpp new file mode 100644 index 00000000..b8c49410 --- /dev/null +++ b/validation/firmware/HsemMaster.cpp @@ -0,0 +1,25 @@ +#include "validation/firmware/HsemMaster.hpp" + +#if defined(STM32WB) + +#include "infra/util/StaticStorage.hpp" + +namespace validation +{ + hal::SynchronousHardwareSemaphoreMasterStm& HsemMaster() + { + // Never destroyed: the destructor gates the HSEM clock, which FlashCoordinatedWithWirelessStack and HAL_HSEM_* need + static infra::StaticStorage storage; + static bool constructed = false; + + if (!constructed) + { + storage.Construct(); + constructed = true; + } + + return *storage; + } +} + +#endif diff --git a/validation/firmware/HsemMaster.hpp b/validation/firmware/HsemMaster.hpp new file mode 100644 index 00000000..6923dc65 --- /dev/null +++ b/validation/firmware/HsemMaster.hpp @@ -0,0 +1,12 @@ +#pragma once + +#if defined(STM32WB) + +#include "hal_st/synchronous_stm32fxxx/SynchronousHardwareSemaphoreStm.hpp" + +namespace validation +{ + hal::SynchronousHardwareSemaphoreMasterStm& HsemMaster(); +} + +#endif diff --git a/validation/firmware/Main.cpp b/validation/firmware/Main.cpp index da467065..08c824df 100644 --- a/validation/firmware/Main.cpp +++ b/validation/firmware/Main.cpp @@ -19,6 +19,7 @@ #include "validation/firmware/PinFactoryStm.hpp" #include "validation/firmware/PwmFactory.hpp" #include "validation/firmware/QeiFactory.hpp" +#include "validation/firmware/ResourceAllocation.hpp" #include "validation/firmware/SpiFactory.hpp" #include "validation/firmware/TimerAllocation.hpp" #include "validation/firmware/UartFactory.hpp" @@ -43,6 +44,7 @@ int main() static services::HilPinNamingDefault naming{ validation::board::portLetters, validation::board::maximumPinIndex, infra::MakeRange(validation::board::aliases) }; static services::HilContext context{ console.response, pins, naming, console.terminal }; static validation::TimerAllocation timers; + static validation::ResourceAllocation resources; static hal::cortex::Reset reset; static services::HilSystemCommands system{ context, boardInfo, reset }; diff --git a/validation/firmware/Owners.hpp b/validation/firmware/Owners.hpp new file mode 100644 index 00000000..e2cbbf10 --- /dev/null +++ b/validation/firmware/Owners.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "services/hil/HilPinPool.hpp" + +namespace validation::owners +{ + // 17 is the watchdog warning pin (WatchDogFactory.cpp); EMIL owns the ids below extension + inline constexpr services::HilOwner i2c = 16; + inline constexpr services::HilOwner i2cTarget = 18; + inline constexpr services::HilOwner eeprom = 19; + inline constexpr services::HilOwner spiSlave = 20; + inline constexpr services::HilOwner timer = 21; + inline constexpr services::HilOwner timerPwm = 22; + inline constexpr services::HilOwner lpTimer = 23; + inline constexpr services::HilOwner lpTimerPwm = 24; + inline constexpr services::HilOwner quadSpi = 25; + inline constexpr services::HilOwner syncGpio = 26; + inline constexpr services::HilOwner analogInput = 27; + inline constexpr services::HilOwner lowPower = 28; + inline constexpr services::HilOwner dma = 29; + inline constexpr services::HilOwner scaffold = 30; + + static_assert(i2c == services::HilOwners::extension && scaffold < services::HilOwners::last); +} diff --git a/validation/firmware/Payload.cpp b/validation/firmware/Payload.cpp new file mode 100644 index 00000000..880ad572 --- /dev/null +++ b/validation/firmware/Payload.cpp @@ -0,0 +1,120 @@ +#include "validation/firmware/Payload.hpp" +#include "infra/util/Crc.hpp" +#include "infra/util/Endian.hpp" +#include +#include + +namespace validation +{ + namespace + { + using services::HilStatus; + + constexpr std::array, 2> outputChoices{ { + { "hex", Output::hex }, + { "crc", Output::crc }, + } }; + + uint8_t NextPrbs(uint32_t& state) + { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + return static_cast(state); + } + } + + HilStatus ParsePayload(const services::HilArguments& arguments, std::size_t positional, infra::ByteRange storage, infra::ByteRange& payload) + { + auto text = arguments.Positional(positional); + auto generated = arguments.Has("len"); + auto patterned = arguments.Has("pattern") || arguments.Has("seed"); + payload = infra::Head(storage, 0); + + if (text != "-") + { + if (generated || patterned) + return HilStatus::usage; + + std::size_t size = 0; + auto status = services::HilArguments::ParseHex(text, storage, size); + if (status == HilStatus::done) + payload = infra::Head(storage, size); + + return status; + } + + if (!generated) + return patterned ? HilStatus::usage : HilStatus::done; + + auto status = HilStatus::done; + auto pattern = Pattern::inc; + uint32_t seed = 0; + uint32_t length = 0; + arguments.Select("pattern", pattern, patternChoices, status); + arguments.Number("seed", seed, 0, std::numeric_limits::max(), status); + arguments.Number("len", length, 1, static_cast(storage.size()), status); + + if (status != HilStatus::done) + return status; + + payload = infra::Head(storage, length); + Generate(payload, pattern, seed); + return HilStatus::done; + } + + void Generate(infra::ByteRange data, Pattern pattern, uint32_t seed) + { + switch (pattern) + { + case Pattern::inc: + for (std::size_t i = 0; i != data.size(); ++i) + data[i] = static_cast(seed + i); + break; + case Pattern::constant: + std::fill(data.begin(), data.end(), static_cast(seed)); + break; + case Pattern::prbs: + { + uint32_t state = seed != 0 ? seed : 1; + for (auto& byte : data) + byte = NextPrbs(state); + break; + } + } + } + + uint32_t Crc32(infra::ConstByteRange data) + { + infra::Crc32 crc; + crc.Update(data); + return crc.Result(); + } + + HilStatus ParseOutput(const services::HilArguments& arguments, Output& output) + { + auto status = HilStatus::done; + output = Output::hex; + arguments.Select("out", output, outputChoices, status); + return status; + } + + HilStatus CheckOutput(std::size_t size, Output output) + { + return output == Output::hex && size > maximumHexOutput ? HilStatus::range : HilStatus::done; + } + + void PrintData(services::HilResponse::Line& line, infra::ConstByteRange data, Output output) + { + if (output == Output::hex) + { + line << " data="; + line.Hex(data); + return; + } + + infra::BigEndian crc{ Crc32(data) }; + line << " len=" << static_cast(data.size()) << " crc="; + line.Hex(infra::MakeByteRange(crc)); + } +} diff --git a/validation/firmware/Payload.hpp b/validation/firmware/Payload.hpp new file mode 100644 index 00000000..ae056b15 --- /dev/null +++ b/validation/firmware/Payload.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include "infra/util/ByteRange.hpp" +#include "services/hil/HilArguments.hpp" +#include "services/hil/HilResponse.hpp" +#include "services/hil/HilStatus.hpp" +#include +#include +#include + +namespace validation +{ + enum class Pattern : uint8_t + { + inc, + constant, + prbs, + }; + + enum class Output : uint8_t + { + hex, + crc, + }; + + inline constexpr std::array payloadKeys{ { "len", "pattern", "seed" } }; + inline constexpr std::array, 3> patternChoices{ { + { "inc", Pattern::inc }, + { "const", Pattern::constant }, + { "prbs", Pattern::prbs }, + } }; + inline constexpr std::size_t maximumHexOutput = 128; + + services::HilStatus ParsePayload(const services::HilArguments& arguments, std::size_t positional, infra::ByteRange storage, infra::ByteRange& payload); + void Generate(infra::ByteRange data, Pattern pattern, uint32_t seed); + uint32_t Crc32(infra::ConstByteRange data); + + services::HilStatus ParseOutput(const services::HilArguments& arguments, Output& output); + services::HilStatus CheckOutput(std::size_t size, Output output); + void PrintData(services::HilResponse::Line& line, infra::ConstByteRange data, Output output); +} diff --git a/validation/firmware/PeripheralClocks.cpp b/validation/firmware/PeripheralClocks.cpp index 057abce5..142aea64 100644 --- a/validation/firmware/PeripheralClocks.cpp +++ b/validation/firmware/PeripheralClocks.cpp @@ -39,4 +39,58 @@ namespace validation return hclk / peripheralClock == 1 ? peripheralClock : peripheralClock * 2; } + + bool I2cExists(uint8_t index) + { + return index >= 1 && index <= hal::peripheralI2c.size() && hal::peripheralI2c[index - 1] != nullptr; + } + + uint32_t I2cKernelClock(uint8_t index) + { + switch (index) + { + case 1: + return HAL_RCCEx_GetPeriphCLKFreq(RCC_PERIPHCLK_I2C1); + case 3: + return HAL_RCCEx_GetPeriphCLKFreq(RCC_PERIPHCLK_I2C3); + default: + return 0; + } + } + + bool SpiExists(uint8_t index) + { + return index >= 1 && index <= hal::peripheralSpi.size() && hal::peripheralSpi[index - 1] != nullptr; + } + + bool SpiLimited(uint8_t index) + { +#if defined(IS_SPI_LIMITED_INSTANCE) + return SpiExists(index) && IS_SPI_LIMITED_INSTANCE(hal::peripheralSpi[index - 1]); +#else + return false; +#endif + } + + bool LpTimerExists(uint8_t index) + { +#if defined(HAS_PERIPHERAL_LPTIMER) + return index >= 1 && index <= hal::peripheralLpTimer.size() && hal::peripheralLpTimer[index - 1] != nullptr; +#else + return false; +#endif + } + + uint32_t LpTimerClock(uint8_t index) + { + switch (index) + { + case 1: + return HAL_RCCEx_GetPeriphCLKFreq(RCC_PERIPHCLK_LPTIM1); + case 2: + return HAL_RCCEx_GetPeriphCLKFreq(RCC_PERIPHCLK_LPTIM2); + default: + return 0; + } + } } diff --git a/validation/firmware/PeripheralClocks.hpp b/validation/firmware/PeripheralClocks.hpp index 3c94adb9..26bc700b 100644 --- a/validation/firmware/PeripheralClocks.hpp +++ b/validation/firmware/PeripheralClocks.hpp @@ -6,4 +6,13 @@ namespace validation { bool TimerExists(uint8_t timer); uint32_t TimerClock(uint8_t timer); + + bool I2cExists(uint8_t index); + uint32_t I2cKernelClock(uint8_t index); + + bool SpiExists(uint8_t index); + bool SpiLimited(uint8_t index); + + bool LpTimerExists(uint8_t index); + uint32_t LpTimerClock(uint8_t index); } diff --git a/validation/firmware/ResourceAllocation.cpp b/validation/firmware/ResourceAllocation.cpp new file mode 100644 index 00000000..d58fd338 --- /dev/null +++ b/validation/firmware/ResourceAllocation.cpp @@ -0,0 +1,32 @@ +#include "validation/firmware/ResourceAllocation.hpp" +#include "infra/util/ReallyAssert.hpp" + +namespace validation +{ + services::HilStatus ResourceAllocation::Claim(Resource resource, uint8_t index, services::HilOwner owner) + { + auto& slot = Slot(resource, index); + + if (slot && *slot != owner) + return services::HilStatus::busy; + + slot = owner; + return services::HilStatus::done; + } + + void ResourceAllocation::Release(Resource resource, uint8_t index, services::HilOwner owner) + { + auto& slot = Slot(resource, index); + + if (slot == owner) + slot = std::nullopt; + } + + std::optional& ResourceAllocation::Slot(Resource resource, uint8_t index) + { + auto row = static_cast(resource); + really_assert(row < owners.size() && index < indices); + + return owners[row][index]; + } +} diff --git a/validation/firmware/ResourceAllocation.hpp b/validation/firmware/ResourceAllocation.hpp new file mode 100644 index 00000000..3d08d8e2 --- /dev/null +++ b/validation/firmware/ResourceAllocation.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "services/hil/HilPinPool.hpp" +#include "services/hil/HilStatus.hpp" +#include +#include +#include +#include + +namespace validation +{ + enum class Resource : uint8_t + { + lpTimer, + spi, + i2c, + adc, + dma1, + dma2, + hsem, + }; + + // Several command groups reach the same peripheral instance or DMA channel; this keeps them from sharing one + class ResourceAllocation + { + public: + static constexpr std::size_t indices = 16; + + services::HilStatus Claim(Resource resource, uint8_t index, services::HilOwner owner); + void Release(Resource resource, uint8_t index, services::HilOwner owner); + + private: + std::optional& Slot(Resource resource, uint8_t index); + + private: + std::array, indices>, static_cast(Resource::hsem) + 1> owners; + }; +} diff --git a/validation/firmware/Stopwatch.hpp b/validation/firmware/Stopwatch.hpp new file mode 100644 index 00000000..2cf689d4 --- /dev/null +++ b/validation/firmware/Stopwatch.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include "hal/cortex_m/DataWatchpointAndTrace.hpp" +#include +#include DEVICE_HEADER + +namespace validation +{ + // CYCCNT stops in Sleep (the core clock is gated), so a sleep window is timed with the scaffold timer instead + class Stopwatch + { + public: + Stopwatch(); + + void Start(); + uint32_t ElapsedUs() const; + + private: + hal::cortex::DataWatchpointAndTrace dwt; + uint32_t start = 0; + }; + + //// Implementation //// + + inline Stopwatch::Stopwatch() + { + // DataWatchpointAndTrace::Start() zeroes the one shared CYCCNT, which would corrupt another running stopwatch + static bool counting = false; + + if (!counting) + { + dwt.Start(); + counting = true; + } + } + + inline void Stopwatch::Start() + { + start = dwt.Cycles(); + } + + inline uint32_t Stopwatch::ElapsedUs() const + { + return static_cast(static_cast(dwt.Cycles() - start) * 1'000'000 / SystemCoreClock); + } +} diff --git a/validation/firmware/TimerAllocation.cpp b/validation/firmware/TimerAllocation.cpp index c88289a9..00697b28 100644 --- a/validation/firmware/TimerAllocation.cpp +++ b/validation/firmware/TimerAllocation.cpp @@ -21,4 +21,11 @@ namespace validation if (owners[timer] == owner) owners[timer] = std::nullopt; } + + std::optional TimerAllocation::Owner(uint8_t timer) const + { + really_assert(timer < owners.size()); + + return owners[timer]; + } } diff --git a/validation/firmware/TimerAllocation.hpp b/validation/firmware/TimerAllocation.hpp index 7025fb46..954dde71 100644 --- a/validation/firmware/TimerAllocation.hpp +++ b/validation/firmware/TimerAllocation.hpp @@ -14,6 +14,7 @@ namespace validation public: services::HilStatus Claim(uint8_t timer, services::HilOwner owner); void Release(uint8_t timer, services::HilOwner owner); + std::optional Owner(uint8_t timer) const; private: std::array, 32> owners; diff --git a/validation/firmware/boards/stm32wb55/BoardProfile.hpp b/validation/firmware/boards/stm32wb55/BoardProfile.hpp index 394481ce..5ee8ea1f 100644 --- a/validation/firmware/boards/stm32wb55/BoardProfile.hpp +++ b/validation/firmware/boards/stm32wb55/BoardProfile.hpp @@ -40,6 +40,12 @@ namespace validation::board inline constexpr std::optional defaultUart = UartPins{ 1, true, Pin(Port::A, 2), Pin(Port::A, 3) }; inline constexpr QeiPins defaultQei{ 2, Pin(Port::A, 15), Pin(Port::A, 1), Pin(Port::C, 6) }; + inline constexpr std::array i2cPins{ { + { 1, Pin(Port::B, 8), Pin(Port::B, 9) }, + { 3, Pin(Port::C, 0), Pin(Port::C, 1) }, + } }; + inline constexpr QuadSpiPins qspiPins{ Pin(Port::A, 3), Pin(Port::A, 2), { { Pin(Port::B, 9), Pin(Port::B, 8), Pin(Port::A, 7), Pin(Port::A, 6) } } }; + inline constexpr LowPowerPins lowPowerDefaults{ Pin(Port::C, 6), Pin(Port::B, 0) }; inline constexpr auto aliases = std::to_array({ { "terminaltx", terminal.tx }, @@ -89,11 +95,37 @@ namespace validation::board { "sw1", Pin(Port::C, 4) }, { "sw2", Pin(Port::D, 0) }, { "sw3", Pin(Port::D, 1) }, + { "i2c1scl", i2cPins[0].scl }, + { "i2c1sda", i2cPins[0].sda }, + { "i2c3scl", i2cPins[1].scl }, + { "i2c3sda", i2cPins[1].sda }, + { "spi2clk", Pin(Port::B, 13) }, + { "spi2miso", Pin(Port::B, 14) }, + { "spi2mosi", Pin(Port::B, 15) }, + { "spi2nss", Pin(Port::B, 12) }, + { "spi1nss", Pin(Port::A, 4) }, + { "qspiclk", qspiPins.clk }, + { "qspincs", qspiPins.ncs }, + { "qspiio0", qspiPins.io[0] }, + { "qspiio1", qspiPins.io[1] }, + { "qspiio2", qspiPins.io[2] }, + { "qspiio3", qspiPins.io[3] }, + { "mco", Pin(Port::A, 8) }, }); inline constexpr uint8_t uartDmaChannel = 3; inline constexpr uint8_t spiDmaChannel = 5; inline constexpr uint8_t adcDmaChannel = 7; + inline constexpr DmaChannel adcDma{ 1, adcDmaChannel }; + inline constexpr DmaPair spiSlaveDma{ { 2, 1 }, { 2, 2 } }; + inline constexpr DmaChannel qspiDma{ 2, 3 }; + inline constexpr uint8_t qspiDmaRequest = DMA_REQUEST_QUADSPI; + inline constexpr DmaChannel dmaGroupDma{ 2, 4 }; + inline constexpr uint8_t dmaGroupDmaRequest = DMA_REQUEST_TIM2_UP; + + inline constexpr uint8_t scaffoldTimer = 17; + inline constexpr uint32_t flashScratchFirstPage = 64; + inline constexpr uint32_t flashScratchEndPage = 128; constexpr std::optional UartDma(uint8_t index, bool lpuart) { diff --git a/validation/firmware/boards/stm32wba55/BoardProfile.hpp b/validation/firmware/boards/stm32wba55/BoardProfile.hpp index ff60ee63..1633877b 100644 --- a/validation/firmware/boards/stm32wba55/BoardProfile.hpp +++ b/validation/firmware/boards/stm32wba55/BoardProfile.hpp @@ -19,14 +19,17 @@ namespace validation::board inline constexpr uint8_t maximumPinIndex = 15; static_assert(static_cast(Port::H) == 3, "portLetters must follow hal::Port"); - inline constexpr std::array bondedPins{ { 0xffef, 0xffff, 0xe000, 0x0008 } }; + // The generated pinout table comes from the WBA52 XML; only these are bonded out on the UFQFPN48 (PA3, PB10, PB11 + // and PB13 are SMPS and VDD11 pads) + inline constexpr std::array bondedPins{ { 0xffe7, 0xd3ff, 0xe000, 0x0008 } }; inline constexpr UartPins terminal{ 1, false, Pin(Port::B, 12), Pin(Port::A, 8) }; inline constexpr uint32_t terminalBaudRate = 921600; inline constexpr DmaRequests terminalDma{ GPDMA1_REQUEST_USART1_TX, GPDMA1_REQUEST_USART1_RX }; - // Red LD3: the blue LD1 is the SPI1 clock pin, and the green LD2 needs a solder bridge on some board revisions - inline constexpr HilPinId debugLed = Pin(Port::B, 8); + // Green LD2: PB8 (red LD3) is the only SPI3 MOSI and the only bonded TIM16 CH1N pin, and the blue LD1 is the SPI1 + // clock pin. LD2 is not connected on a stock board (SB28 open), so the heartbeat stays dark unless SB28 is closed + inline constexpr HilPinId debugLed = Pin(Port::A, 9); inline constexpr std::array reservedPins{ { terminal.tx, terminal.rx, @@ -40,6 +43,11 @@ namespace validation::board inline constexpr std::optional defaultUart = UartPins{ 1, true, Pin(Port::B, 5), Pin(Port::A, 10) }; inline constexpr QeiPins defaultQei{ 1, Pin(Port::A, 11), Pin(Port::A, 12), Pin(Port::A, 15) }; + inline constexpr std::array i2cPins{ { + { 1, Pin(Port::B, 2), Pin(Port::B, 1) }, + { 3, Pin(Port::A, 6), Pin(Port::A, 7) }, + } }; + inline constexpr LowPowerPins lowPowerDefaults{ Pin(Port::B, 14), Pin(Port::A, 2) }; inline constexpr auto aliases = std::to_array({ { "terminaltx", terminal.tx }, @@ -94,11 +102,36 @@ namespace validation::board { "sw1", Pin(Port::C, 13) }, { "sw2", Pin(Port::B, 6) }, { "sw3", Pin(Port::B, 7) }, + { "i2c1scl", i2cPins[0].scl }, + { "i2c1sda", i2cPins[0].sda }, + { "i2c3scl", i2cPins[1].scl }, + { "i2c3sda", i2cPins[1].sda }, + { "spi3clk", Pin(Port::A, 0) }, + { "spi3miso", Pin(Port::B, 9) }, + { "spi3mosi", Pin(Port::B, 8) }, + { "spi3nss", Pin(Port::A, 5) }, + { "spi1nss", Pin(Port::A, 12) }, + { "lptim1ch2", Pin(Port::A, 15) }, + { "lptim2ch1", Pin(Port::A, 11) }, + { "lptim2ch2", Pin(Port::A, 1) }, + { "lptim1in1", Pin(Port::A, 0) }, + { "lptim1in2", Pin(Port::B, 3) }, + { "lptim2in1", Pin(Port::B, 9) }, + { "lptim2in2", Pin(Port::B, 0) }, + { "tim16ch1n", Pin(Port::B, 8) }, }); inline constexpr uint8_t uartDmaChannel = 3; inline constexpr uint8_t spiDmaChannel = 5; inline constexpr uint8_t adcDmaChannel = 7; + inline constexpr DmaChannel adcDma{ 1, adcDmaChannel }; + inline constexpr DmaPair spiSlaveDma{ { 1, 8 }, { 1, 7 } }; + inline constexpr DmaChannel dmaGroupDma{ 1, 8 }; + inline constexpr uint8_t dmaGroupDmaRequest = GPDMA1_REQUEST_TIM2_UP; + + inline constexpr uint8_t scaffoldTimer = 17; + inline constexpr uint32_t flashScratchFirstPage = 64; + inline constexpr uint32_t flashScratchEndPage = 128; constexpr std::optional UartDma(uint8_t index, bool lpuart) { diff --git a/validation/host/boards/nucleo_wb55rg.yaml b/validation/host/boards/nucleo_wb55rg.yaml index 1ba007d2..18730cd0 100644 --- a/validation/host/boards/nucleo_wb55rg.yaml +++ b/validation/host/boards/nucleo_wb55rg.yaml @@ -79,6 +79,22 @@ pins: sw1: PC4 sw2: PD0 sw3: PD1 + i2c1scl: PB8 + i2c1sda: PB9 + i2c3scl: PC0 + i2c3sda: PC1 + spi2clk: PB13 + spi2miso: PB14 + spi2mosi: PB15 + spi2nss: PB12 + spi1nss: PA4 + qspiclk: PA3 + qspincs: PA2 + qspiio0: PB9 + qspiio1: PB8 + qspiio2: PA7 + qspiio3: PA6 + mco: PA8 ad3: # V+/V- stay off (null): the Nucleo is powered from the ST-LINK USB. @@ -657,9 +673,12 @@ tests: - can.open 1 - can.send 1 0x123 01 - can.close 1 - - eeprom.write 0 00 - - eeprom.read 0 1 - - eeprom.erase - eth.open - eth.status - eth.close + - lptpwm.open 1 pins=PA15 + - lptpwm.duty 1 1 50 + - lptpwm.pulse 1 1 10 100 + - lptpwm.start 1 + - lptpwm.stop 1 + - lptpwm.close 1 diff --git a/validation/host/boards/nucleo_wba55cg.yaml b/validation/host/boards/nucleo_wba55cg.yaml index 9f2149fa..66f38c86 100644 --- a/validation/host/boards/nucleo_wba55cg.yaml +++ b/validation/host/boards/nucleo_wba55cg.yaml @@ -85,6 +85,23 @@ pins: sw1: PC13 sw2: PB6 sw3: PB7 + i2c1scl: PB2 + i2c1sda: PB1 + i2c3scl: PA6 + i2c3sda: PA7 + spi3clk: PA0 + spi3miso: PB9 + spi3mosi: PB8 + spi3nss: PA5 + spi1nss: PA12 + lptim1ch2: PA15 + lptim2ch1: PA11 + lptim2ch2: PA1 + lptim1in1: PA0 + lptim1in2: PB3 + lptim2in1: PB9 + lptim2in2: PB0 + tim16ch1n: PB8 ad3: # V+/V- stay off (null): the Nucleo is powered from the ST-LINK USB. @@ -197,8 +214,9 @@ tests: boot_timeout: 5.0 # USART1 is the terminal: `uart.open 1` answers busy. reserved_uarts: [1] - # Blinking debug LED (red LD3), reserved by the firmware. - debug_led: PB8 + # Blinking debug LED (green LD2), reserved by the firmware; dark unless + # SB28 is closed (open on a stock board). + debug_led: PA9 # SWD, LSE crystal and BOOT0: reserved like the terminal pins. reserved_pins: [PA13, PA14, PC14, PC15, PH3] # Instances the MCU lacks: ERR range (0 too, the peripherals start at 1). @@ -212,12 +230,12 @@ tests: - pwm.open 4 - qei.open 4 - wdt.start 1 timeout=100 - # Features the MCU lacks: ERR unsupported (no LPTIM encoder on the WBA55). + # Features the MCU lacks: ERR unsupported. unsupported_instances: - - qei.open 1 lp=1 - qei.open 16 # A pin the package does not bond out (or a port it lacks): ERR pin. - unbonded_pins: [PA4, PC0, PD0] + # PA3, PB10, PB11 and PB13 are SMPS and VDD11 pads on the UFQFPN48. + unbonded_pins: [PA4, PC0, PD0, PA3, PB10, PB11, PB13] # Names that are no pin: ERR pin. invalid_pins: [PA16, PA08, PD0, LED0, nosuchalias] delays_ms: [10, 200] @@ -231,7 +249,7 @@ tests: input_pulls: [none, up, down] # RAM holds `limit` GPIO pins; `limit_pins` names one more. limit: 8 - limit_pins: [gpio0, gpio1, gpio2, led0, led1, PA11, PB0, PA12, PB2] + limit_pins: [gpio0, gpio1, gpio2, led0, PB8, PA11, PB0, PA12, PB2] irq: edge: [rising, falling, both] handler: [immediate, dispatched] @@ -566,9 +584,19 @@ tests: - can.open 1 - can.send 1 0x123 01 - can.close 1 - - eeprom.write 0 00 - - eeprom.read 0 1 - - eeprom.erase - eth.open - eth.status - eth.close + - hsem.take 0 procid=1 + - hsem.release 0 procid=1 + - hsem.status 0 + - hsem.lock 0 + - hsem.mine 0 + - qspi.open 1 + - qspi.cmd 1 instr=0x9f rx=3 + - qspi.poll 1 match=0x01 mask=0x01 + - qspi.xfer 1 9f + - qspi.close 1 + - flash.stack stopped + - clock.mco sysclk + - clock.hsi48 1 diff --git a/validation/host/hal_st_validation/config.py b/validation/host/hal_st_validation/config.py index 3fa2b0ca..3733b7a2 100644 --- a/validation/host/hal_st_validation/config.py +++ b/validation/host/hal_st_validation/config.py @@ -17,6 +17,7 @@ BOARDS_DIR = Path(__file__).resolve().parent.parent / "boards" ChannelKind = Literal["dio", "wavegen", "scope"] _KINDS: tuple[ChannelKind, ...] = ("dio", "wavegen", "scope") +_OPTION_KEYS = frozenset({"description", "jumpered", "loads", "pullups", "excludes"}) _MISSING = object() @@ -53,43 +54,105 @@ def describe(self) -> str: return " ".join(parts) +@dataclass(frozen=True) +class WiringOption: + """Optional wiring a set offers (`--with `): `jumpered` ties each key pin to the listed pins (the key is the + end the wiring self-check drives), `loads` are pins the wiring loads, `pullups` pins it pulls up, and `excludes` + names options that cannot be fitted at the same time.""" + + tag: str + description: str = "" + jumpered: Mapping[str, tuple[str, ...]] = field(default_factory=dict) + loads: tuple[str, ...] = () + pullups: tuple[str, ...] = () + excludes: frozenset[str] = frozenset() + + def merged(self, other: WiringOption) -> WiringOption: + """The option as two selected sets offer it together.""" + jumpered = {pin: tuple(dict.fromkeys((*self.jumpered.get(pin, ()), *others))) for pin, others in other.jumpered.items()} + return WiringOption( + tag=self.tag, + description=self.description or other.description, + jumpered={**self.jumpered, **jumpered}, + loads=tuple(dict.fromkeys((*self.loads, *other.loads))), + pullups=tuple(dict.fromkeys((*self.pullups, *other.pullups))), + excludes=self.excludes | other.excludes, + ) + + @dataclass(frozen=True) class WiringSet: name: str description: str connections: tuple[Connection, ...] - options: Mapping[str, str] = field(default_factory=dict) + options: Mapping[str, WiringOption] = field(default_factory=dict) jumpers: tuple[str, ...] = () @dataclass(frozen=True) class Wiring: - """The merged active wiring sets, restricted to the optional connections that are enabled.""" + """The merged active wiring sets, restricted to the optional connections that are enabled. + + `options` are the options the selected sets offer, `enabled` the tags of `--with`; `option_jumpers` holds the + `jumpered` pins of the enabled options. A channel reaches an option-jumpered pin only for lookups that pass + the tag in `allowed` (tests marked `uses_option`/`requires_option`), and pins an enabled option loads are + reported by `loaded()` so such lookups can be refused for other tests.""" sets: tuple[str, ...] connections: tuple[Connection, ...] enabled: frozenset[str] jumpers: tuple[str, ...] = () - - def channel(self, kind: ChannelKind, pin: str | None = None, role: str | None = None) -> int | None: - for connection in self.connections: - if connection.kind != kind: - continue - if role is not None and connection.role != role: + options: Mapping[str, WiringOption] = field(default_factory=dict) + option_jumpers: Mapping[str, Mapping[str, tuple[str, ...]]] = field(default_factory=dict) + + def connection( + self, kind: ChannelKind, pin: str | None = None, role: str | None = None, allowed: Iterable[str] = () + ) -> Connection | None: + """The first connection of `kind` (and `role`) on `pin`: its own pins first, then the pins the jumpers of the + enabled options in `allowed` tie to them.""" + candidates = [c for c in self.connections if c.kind == kind and (role is None or c.role == role)] + if pin is None: + return candidates[0] if candidates else None + for connection in candidates: + if pin in connection.pins: + return connection + reach = self.jumpered_to(pin, allowed) + return next((connection for connection in candidates if reach & set(connection.pins)), None) + + def channel(self, kind: ChannelKind, pin: str | None = None, role: str | None = None, allowed: Iterable[str] = ()) -> int | None: + connection = self.connection(kind, pin, role, allowed) + return None if connection is None else connection.channel + + def dio(self, pin: str | None = None, role: str | None = None, allowed: Iterable[str] = ()) -> int | None: + return self.channel("dio", pin, role, allowed) + + def wavegen(self, pin: str | None = None, role: str | None = None, allowed: Iterable[str] = ()) -> int | None: + return self.channel("wavegen", pin, role, allowed) + + def scope(self, pin: str | None = None, role: str | None = None, allowed: Iterable[str] = ()) -> int | None: + return self.channel("scope", pin, role, allowed) + + def jumpered_to(self, pin: str, allowed: Iterable[str] = ()) -> set[str]: + """Pins the jumpers of the enabled options in `allowed` tie to `pin`.""" + tags = set(allowed) + reach: set[str] = set() + for tag, jumpers in self.option_jumpers.items(): + if tag not in tags: continue - if pin is not None and pin not in connection.pins: - continue - return connection.channel - return None - - def dio(self, pin: str | None = None, role: str | None = None) -> int | None: - return self.channel("dio", pin, role) + for key, others in jumpers.items(): + net = {key, *others} + if pin in net: + reach |= net - {pin} + return reach - def wavegen(self, pin: str | None = None, role: str | None = None) -> int | None: - return self.channel("wavegen", pin, role) + def loaded(self, pin: str) -> tuple[str, ...]: + """The enabled options that load `pin`.""" + return tuple(tag for tag, option in self.options.items() if tag in self.enabled and pin in option.loads) - def scope(self, pin: str | None = None, role: str | None = None) -> int | None: - return self.channel("scope", pin, role) + def blocking(self, pin: str, allowed: Iterable[str] = ()) -> tuple[str, ...]: + """The enabled options that load `pin` and are not in `allowed`: a test outside them must leave it alone.""" + tags = set(allowed) + return tuple(tag for tag in self.loaded(pin) if tag not in tags) def has(self, tag: str) -> bool: return tag in self.enabled @@ -98,6 +161,12 @@ def describe(self) -> str: lines = [f"wiring sets: {', '.join(self.sets) or '(none)'}"] lines += [f" {connection.describe()}" for connection in self.connections] lines += [f" jumper: {jumper}" for jumper in self.jumpers] + for tag in sorted(self.enabled): + option = self.options[tag] + parts = [f"{key}-{'+'.join(others)}" for key, others in option.jumpered.items()] + if option.loads: + parts.append(f"loads {', '.join(option.loads)}") + lines.append(f" --with {tag}: {'; '.join(parts) or option.description or 'enabled'}") return "\n".join(lines) @@ -167,6 +236,8 @@ class BoardConfig: wiring_sets: dict[str, WiringSet] tests: dict[str, Any] path: Path | None = None + # `--board-extra` files merged over the board file, in order. + extras: tuple[Path, ...] = () firmware_name: str | None = None # Kernel clocks in Hz (`sysclk`, `pclk1`, `pclk2`, `timer`, `spi.`, `uart.`). clocks: dict[str, Any] = field(default_factory=dict) @@ -237,9 +308,13 @@ def apply_overrides(self, overrides: Iterable[str]) -> None: self.set_param(path.strip(), yaml.safe_load(text)) def wiring(self, names: Iterable[str], enabled: Iterable[str] = ()) -> Wiring: + """The wiring of the sets `names` with the options `enabled`; `ConfigError` for an unknown set, for sets + that wire one AD3 channel differently, for an enabled option no selected set offers (or enabled options + without a set) and for enabled options that exclude each other.""" tags = frozenset(enabled) selected: list[Connection] = [] jumpers: list[str] = [] + options: dict[str, WiringOption] = {} used: dict[tuple[str, int], str] = {} names = tuple(name for name in names if name) for name in names: @@ -248,6 +323,8 @@ def wiring(self, names: Iterable[str], enabled: Iterable[str] = ()) -> Wiring: raise ConfigError(f"{self.name}: unknown wiring set {name!r} (known: {known})") wiring_set = self.wiring_sets[name] jumpers += [jumper for jumper in wiring_set.jumpers if jumper not in jumpers] + for tag, option in wiring_set.options.items(): + options[tag] = options[tag].merged(option) if tag in options else option for connection in wiring_set.connections: if connection.requires and connection.requires not in tags: continue @@ -261,7 +338,21 @@ def wiring(self, names: Iterable[str], enabled: Iterable[str] = ()) -> Wiring: continue used[key] = name selected.append(connection) - return Wiring(names, tuple(selected), tags, tuple(jumpers)) + self._check_options(names, options, tags) + option_jumpers = {tag: options[tag].jumpered for tag in sorted(tags) if options[tag].jumpered} + return Wiring(names, tuple(selected), tags, tuple(jumpers), options, option_jumpers) + + def _check_options(self, names: tuple[str, ...], options: Mapping[str, WiringOption], tags: frozenset[str]) -> None: + if tags and not names: + raise ConfigError(f"{self.name}: --with {', '.join(sorted(tags))} needs the --wiring-set that offers it") + missing = sorted(tags - set(options)) + if missing: + offered = ", ".join(sorted(options)) or "none" + raise ConfigError(f"{self.name}: wiring set(s) {', '.join(names)} offer no --with {', '.join(missing)} (offered: {offered})") + for tag in sorted(tags): + clash = sorted(options[tag].excludes & tags) + if clash: + raise ConfigError(f"{self.name}: --with {tag} and --with {', '.join(clash)} exclude each other: fit one of them") def _connection(kind: ChannelKind, channel: Any, spec: Any, pins: Mapping[str, str]) -> Connection: @@ -284,21 +375,63 @@ def _connection(kind: ChannelKind, channel: Any, spec: Any, pins: Mapping[str, s ) +def _pin_list(where: str, value: Any, pins: Mapping[str, str]) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, str) or not isinstance(value, (list, tuple)): + raise ConfigError(f"{where} must be a list of pins") + return tuple(normalize_pin(str(pin), pins, strict=True) for pin in value) + + +def _option(set_name: str, tag: str, spec: Any, pins: Mapping[str, str]) -> WiringOption: + """An option is a description (the wiring is only described) or a mapping of `_OPTION_KEYS`.""" + if spec is None or isinstance(spec, str): + return WiringOption(tag, str(spec or "")) + where = f"{set_name}: option {tag}" + if not isinstance(spec, Mapping): + raise ConfigError(f"{where} must be a description or a mapping") + unknown = sorted(str(key) for key in set(spec) - _OPTION_KEYS) + if unknown: + raise ConfigError(f"{where}: unknown keys {', '.join(unknown)} (known: {', '.join(sorted(_OPTION_KEYS))})") + jumpered_raw = spec.get("jumpered") or {} + if not isinstance(jumpered_raw, Mapping): + raise ConfigError(f"{where}: jumpered must map a pin to the pins it is tied to") + jumpered = { + normalize_pin(str(key), pins, strict=True): _pin_list(f"{where}: jumpered.{key}", others, pins) + for key, others in jumpered_raw.items() + } + excludes = spec.get("excludes") or [] + if isinstance(excludes, str) or not isinstance(excludes, (list, tuple)): + raise ConfigError(f"{where}: excludes must be a list of option tags") + return WiringOption( + tag=tag, + description=str(spec.get("description", "")), + jumpered=jumpered, + loads=_pin_list(f"{where}: loads", spec.get("loads"), pins), + pullups=_pin_list(f"{where}: pullups", spec.get("pullups"), pins), + excludes=frozenset(str(other) for other in excludes), + ) + + def _wiring_set(name: str, raw: Mapping[str, Any], pins: Mapping[str, str]) -> WiringSet: connections: list[Connection] = [] for kind in _KINDS: for channel, spec in (raw.get(kind) or {}).items(): connections.append(_connection(kind, channel, spec, pins)) + options = {str(tag): _option(name, str(tag), spec, pins) for tag, spec in (raw.get("options") or {}).items()} for connection in connections: if connection.kind == "dio" and not 0 <= connection.channel <= 15: raise ConfigError(f"{name}: DIO{connection.channel} does not exist on the AD3") if connection.kind != "dio" and connection.channel not in (1, 2): raise ConfigError(f"{name}: {connection.kind} channel must be 1 or 2") + if connection.requires is not None and connection.requires not in options: + what = f"{connection.kind}{connection.channel}" + raise ConfigError(f"{name}: {what} requires option {connection.requires!r}, which the set does not offer") return WiringSet( name=name, description=str(raw.get("description", "")), connections=tuple(connections), - options=dict(raw.get("options") or {}), + options=options, jumpers=tuple(raw.get("jumpers") or ()), ) @@ -331,7 +464,7 @@ def _known_gaps(raw: Any) -> tuple[KnownGap, ...]: return tuple(gaps) -def parse_board(raw: Mapping[str, Any], path: Path | None = None) -> BoardConfig: +def parse_board(raw: Mapping[str, Any], path: Path | None = None, extras: Iterable[Path] = ()) -> BoardConfig: try: pins = {str(alias).lower(): normalize_pin(str(pin)) for alias, pin in (raw.get("pins") or {}).items()} unknown = sorted(alias for alias in pins if not is_alias(alias)) @@ -354,6 +487,7 @@ def parse_board(raw: Mapping[str, Any], path: Path | None = None) -> BoardConfig wiring_sets={name: _wiring_set(name, spec or {}, pins) for name, spec in (raw.get("wiring_sets") or {}).items()}, tests=copy.deepcopy(dict(raw.get("tests") or {})), path=path, + extras=tuple(extras), firmware_name=raw.get("firmware_name"), clocks=_clocks(raw.get("clocks") or {}), known_gaps=_known_gaps(raw.get("known_gaps") or []), @@ -373,11 +507,56 @@ def board_path(name_or_path: str | Path, boards_dir: Path = BOARDS_DIR) -> Path: raise ConfigError(f"no board {name_or_path!r} (known: {known})") -def load_board(name_or_path: str | Path, boards_dir: Path = BOARDS_DIR) -> BoardConfig: +def merge_board(base: Any, extra: Any) -> Any: + """`extra` deep-merged over `base`: mappings merge key by key, lists append, a scalar or a change of type + replaces, and a key ending in `!` replaces the value of the key without it (`limit_pins!: [...]`).""" + if isinstance(base, Mapping) and isinstance(extra, Mapping): + result = dict(base) + for key, value in extra.items(): + if isinstance(key, str) and key.endswith("!"): + result[key[:-1]] = _without_bangs(value) + elif key in result: + result[key] = merge_board(result[key], value) + else: + result[key] = _without_bangs(value) + return result + if isinstance(base, list) and isinstance(extra, list): + return base + _without_bangs(extra) + return _without_bangs(extra) + + +def _without_bangs(value: Any) -> Any: + """A copy of `value` whose `key!` mapping keys are plain keys (nothing to replace inside a new value).""" + if isinstance(value, Mapping): + return {(key[:-1] if isinstance(key, str) and key.endswith("!") else key): _without_bangs(item) for key, item in value.items()} + if isinstance(value, list): + return [_without_bangs(item) for item in value] + return copy.deepcopy(value) + + +def _read_yaml(path: Path) -> Any: + try: + with path.open(encoding="utf-8") as stream: + return yaml.safe_load(stream) + except (OSError, yaml.YAMLError) as error: + raise ConfigError(f"{path}: {error}") from error + + +def load_board(name_or_path: str | Path, extras: Iterable[str | Path] = (), boards_dir: Path = BOARDS_DIR) -> BoardConfig: + """The board file, with the `extras` (`--board-extra` files) deep-merged over it in order (`merge_board`).""" path = board_path(name_or_path, boards_dir) - with path.open(encoding="utf-8") as stream: - raw = yaml.safe_load(stream) - return parse_board(raw, path) + raw = _read_yaml(path) + extra_paths = tuple(Path(extra) for extra in extras) + for extra_path in extra_paths: + if not extra_path.is_file(): + raise ConfigError(f"no board extra file {extra_path}") + extra = _read_yaml(extra_path) + if extra is None: + continue + if not isinstance(extra, Mapping): + raise ConfigError(f"{extra_path}: a board extra file must be a mapping") + raw = merge_board(raw, extra) + return parse_board(raw, path, extra_paths) def available_boards(boards_dir: Path = BOARDS_DIR) -> list[str]: diff --git a/validation/host/hal_st_validation/fake_firmware.py b/validation/host/hal_st_validation/fake_firmware.py index 6a9f86f6..cb3cd380 100644 --- a/validation/host/hal_st_validation/fake_firmware.py +++ b/validation/host/hal_st_validation/fake_firmware.py @@ -5,10 +5,15 @@ order (`usage`, `range`, `pin`, `unsupported` before `busy`, `notopen`), and models what needs no hardware: the board profiles (ports, bonded pins, reserved pins, instances, the pin functions of the generated pinout tables, analog pins), pin ownership, the one-instance-per-group limits, timers shared between PWM, encoder and -timer-triggered ADC, EXTI line ownership, ADC trigger timing and WWDG warnings and resets over a (fake) clock. +timer-triggered ADC, the peripherals and DMA channels groups share (`ResourceAllocation`), EXTI line ownership, +ADC trigger timing and WWDG warnings and resets over a (fake) clock. It models the protocol, not the driver gaps of the board files' `known_gaps`. Measured signals (DIO levels, analog codes, UART/SPI peers, encoder counts) are not emulated; tests preset `gpio_levels`, `adc_codes`, `uart_rx` and `spi_miso` instead. + +The groups of `fakes/*.py` (`FakeGroup` subclasses) are found at construction and answer `.` +before the groups modelled here; they share the pin claims, timer owners and resources through the methods +listed in `fakes/base.py`. """ from __future__ import annotations @@ -25,8 +30,14 @@ from ad3_waveforms_bench.protocol import format_hex from . import expect +from .fakes import FakeGroup +from .fakes import discover as discover_groups +from .fakes.base import UINT32_MAX, FakeError, _choice, _fail, _flag, _hex, _number, _shape, _tokens +from .fakes.base import _Error as _Error + +__all__ = ["FakeFirmware", "FakeSerial", "OPEN_LIMITS", "RESOURCES", "UNSUPPORTED_COMMANDS", "WB55_PINS", "WBA55_PINS"] -__all__ = ["FakeFirmware", "FakeSerial", "UNSUPPORTED_COMMANDS", "WB55_PINS", "WBA55_PINS"] +Owner = tuple[str, str] # The alias tables of PROTOCOL.md, in the order `board.pins` prints them. WB55_PINS: dict[str, str] = { @@ -77,6 +88,22 @@ "sw1": "PC4", "sw2": "PD0", "sw3": "PD1", + "i2c1scl": "PB8", + "i2c1sda": "PB9", + "i2c3scl": "PC0", + "i2c3sda": "PC1", + "spi2clk": "PB13", + "spi2miso": "PB14", + "spi2mosi": "PB15", + "spi2nss": "PB12", + "spi1nss": "PA4", + "qspiclk": "PA3", + "qspincs": "PA2", + "qspiio0": "PB9", + "qspiio1": "PB8", + "qspiio2": "PA7", + "qspiio3": "PA6", + "mco": "PA8", } WBA55_PINS: dict[str, str] = { @@ -132,9 +159,26 @@ "sw1": "PC13", "sw2": "PB6", "sw3": "PB7", + "i2c1scl": "PB2", + "i2c1sda": "PB1", + "i2c3scl": "PA6", + "i2c3sda": "PA7", + "spi3clk": "PA0", + "spi3miso": "PB9", + "spi3mosi": "PB8", + "spi3nss": "PA5", + "spi1nss": "PA12", + "lptim1ch2": "PA15", + "lptim2ch1": "PA11", + "lptim2ch2": "PA1", + "lptim1in1": "PA0", + "lptim1in2": "PB3", + "lptim2in1": "PB9", + "lptim2in2": "PB0", + "tim16ch1n": "PB8", } -UNSUPPORTED_COMMANDS = ( +_UNSUPPORTED_EVERYWHERE = ( "comp.open", "comp.read", "comp.irq", @@ -143,14 +187,44 @@ "can.open", "can.send", "can.close", - "eeprom.write", - "eeprom.read", - "eeprom.erase", "eth.open", "eth.status", "eth.close", ) +# `HilUnsupportedCommands` of validation/firmware/UnsupportedGroups.cpp: groups hal-st has no driver for on the MCU. +UNSUPPORTED_COMMANDS: dict[str, tuple[str, ...]] = { + "stm32wb55": ( + *_UNSUPPORTED_EVERYWHERE, + "lptpwm.open", + "lptpwm.duty", + "lptpwm.pulse", + "lptpwm.start", + "lptpwm.stop", + "lptpwm.close", + ), + "stm32wba55": ( + *_UNSUPPORTED_EVERYWHERE, + "hsem.take", + "hsem.release", + "hsem.status", + "hsem.lock", + "hsem.mine", + "qspi.open", + "qspi.cmd", + "qspi.poll", + "qspi.xfer", + "qspi.close", + "flash.stack", + "clock.mco", + "clock.hsi48", + ), +} + +# `ResourceAllocation`: one owner per peripheral or shared DMA channel across groups. +RESOURCES = ("lpTimer", "spi", "i2c", "adc", "dma1", "dma2", "hsem") +_RESOURCE_INDEX_LIMIT = 16 + Table = dict[int, tuple[str, ...]] @@ -159,8 +233,9 @@ def _table(**instances: str) -> Table: return {int(name[1:]): tuple(pins.split()) for name, pins in instances.items()} -# Pin functions of the generated pinout tables (hal_st/stm32fxxx/PinoutTableDefault), one-based instances. -# The WB55 table also lists pins of larger packages; `bonded` filters them like the firmware does. +# Pin functions of the generated pinout tables (`PinoutTableDefault.cpp` of build/stm32wb55 and build/stm32wba55), +# one-based instances; QUADSPI uses instance 0, as `QuadSpiStm` claims its pins. The tables also list pins of +# larger packages; `bonded` filters them like the firmware does. _WB55_FUNCTIONS: dict[str, Table] = { "uartTx": _table(i1="PA9 PB6"), "uartRx": _table(i1="PA10 PB7"), @@ -173,6 +248,9 @@ def _table(**instances: str) -> Table: "spiClock": _table(i1="PA1 PA5 PB3", i2="PA9 PB10 PB13 PD1 PD3"), "spiMiso": _table(i1="PA6 PA11 PB4", i2="PB14 PC2 PD3"), "spiMosi": _table(i1="PA7 PA12 PB5", i2="PB15 PC1 PC3 PD4"), + "spiSlaveSelect": _table(i1="PA4 PA15 PB2", i2="PB9 PB12 PD0"), + "i2cScl": _table(i1="PA9 PB6 PB8", i3="PA7 PB10 PB13 PC0"), + "i2cSda": _table(i1="PA10 PB7 PB9", i3="PB4 PB11 PB14 PC1"), "timerChannel1": _table(i1="PA8 PD14", i2="PA0 PA5 PA15", i16="PA6 PB8 PE0", i17="PA7 PB9 PE1"), "timerChannel2": _table(i1="PA9 PD15", i2="PA1 PB3"), "timerChannel3": _table(i1="PA10", i2="PA2 PB10"), @@ -181,8 +259,16 @@ def _table(**instances: str) -> Table: "timerChannel2N": _table(i1="PB8 PB14"), "timerChannel3N": _table(i1="PB9 PB15"), "timerBreak": _table(i1="PA6 PB7 PB12 PC9", i16="PB5", i17="PA10 PB4"), + "lpTimerChannel1": {}, + "lpTimerChannel2": {}, "lpTimerInput1": _table(i1="PB5 PC0", i2="PB1 PC0 PD12"), "lpTimerInput2": _table(i1="PB7 PC2"), + "quadSpiClock": _table(i0="PA3 PB10"), + "quadSpiSlaveSelect": _table(i0="PA2 PB11 PD3"), + "quadSpiData0": _table(i0="PB9 PD4"), + "quadSpiData1": _table(i0="PB8 PD5"), + "quadSpiData2": _table(i0="PA7 PD6"), + "quadSpiData3": _table(i0="PA6 PD7"), } _WBA55_FUNCTIONS: dict[str, Table] = { @@ -197,6 +283,9 @@ def _table(**instances: str) -> Table: "spiClock": _table(i1="PB4", i3="PA0"), "spiMiso": _table(i1="PB3", i3="PB9"), "spiMosi": _table(i1="PA15", i3="PB8"), + "spiSlaveSelect": _table(i1="PA12", i3="PA5"), + "i2cScl": _table(i1="PA15 PB2", i3="PA6 PB2"), + "i2cSda": _table(i1="PB1 PB3", i3="PA7 PB1"), "timerChannel1": _table(i1="PA11 PB8", i2="PA5 PB6 PB12", i3="PA2 PA10 PB5", i16="PA2 PB9", i17="PA1 PB4"), "timerChannel2": _table(i1="PA12", i2="PA8", i3="PA1 PA9"), "timerChannel3": _table(i1="PB4", i2="PA7", i3="PA0 PB14"), @@ -205,8 +294,10 @@ def _table(**instances: str) -> Table: "timerChannel2N": _table(i1="PA0 PB1"), "timerChannel3N": _table(i1="PB0 PB9"), "timerBreak": _table(i1="PA2", i16="PB10 PB15", i17="PA15"), - "lpTimerInput1": {}, - "lpTimerInput2": {}, + "lpTimerChannel1": _table(i1="PB11", i2="PA11"), + "lpTimerChannel2": _table(i1="PA8 PA15", i2="PA1"), + "lpTimerInput1": _table(i1="PA0", i2="PB9"), + "lpTimerInput2": _table(i1="PB3", i2="PB0 PB4"), } @@ -224,9 +315,17 @@ class _Family: lpuarts: frozenset[int] # (lp, index) of the UARTs with DMA requests (`board::UartDma`). uart_dma: frozenset[tuple[bool, int]] + # `SynchronousUartStmSendOnly` has LPUART constructors on STM32WB only. + lpuart_send_only: bool spis: frozenset[int] + # `IS_SPI_LIMITED_INSTANCE`: 8- and 16-bit frames only. + spi_limited: frozenset[int] + i2cs: frozenset[int] + qspi: frozenset[int] timers: frozenset[int] lptims: frozenset[int] + # `IS_LPTIM_ENCODER_INTERFACE_INSTANCE` + lptim_encoders: frozenset[int] adc: int default_lpuart: tuple[str, str] default_qei: tuple[int, str, str, str] @@ -237,6 +336,10 @@ class _Family: def sampling(self) -> tuple[str, ...]: return expect.ADC_SAMPLING_TIMES[self.family] + @property + def unsupported(self) -> tuple[str, ...]: + return UNSUPPORTED_COMMANDS[self.family] + _FAMILIES: dict[str, _Family] = { "stm32wb55": _Family( @@ -251,9 +354,14 @@ def sampling(self) -> tuple[str, ...]: usarts=frozenset({1}), lpuarts=frozenset({1}), uart_dma=frozenset({(False, 1), (True, 1)}), + lpuart_send_only=True, spis=frozenset({1, 2}), + spi_limited=frozenset(), + i2cs=frozenset({1, 3}), + qspi=frozenset({1}), timers=frozenset({1, 2, 16, 17}), - lptims=frozenset({1}), + lptims=frozenset({1, 2}), + lptim_encoders=frozenset({1}), adc=1, default_lpuart=("PA2", "PA3"), default_qei=(2, "PA15", "PA1", "PC6"), @@ -263,21 +371,28 @@ def sampling(self) -> tuple[str, ...]: **{"PC0": 1, "PC1": 2, "PC2": 3, "PC3": 4, "PC4": 13, "PC5": 14}, }, ), + # PA3, PB10, PB11 and PB13 are SMPS and core supply pins on the UFQFPN48; hal-st builds the WBA55 from the + # WBA52 description, whose tables still list them. "stm32wba55": _Family( family="stm32wba55", board="NUCLEO-WBA55CG", sysclk=100_000_000, ports="ABCH", - bonded={"A": 0xFFEF, "B": 0xFFFF, "C": 0xE000, "H": 0x0008}, + bonded={"A": 0xFFE7, "B": 0xD3FF, "C": 0xE000, "H": 0x0008}, pins=WBA55_PINS, - debug_led="PB8", + debug_led="PA9", reserved=("PA13", "PA14", "PC14", "PC15", "PH3"), usarts=frozenset({1, 2}), lpuarts=frozenset({1}), uart_dma=frozenset({(False, 1), (False, 2), (True, 1)}), + lpuart_send_only=False, spis=frozenset({1, 3}), + spi_limited=frozenset({3}), + i2cs=frozenset({1, 3}), + qspi=frozenset(), timers=frozenset({1, 2, 3, 16, 17}), - lptims=frozenset(), + lptims=frozenset({1, 2}), + lptim_encoders=frozenset({1, 2}), adc=4, default_lpuart=("PB5", "PA10"), default_qei=(1, "PA11", "PA12", "PA15"), @@ -293,22 +408,46 @@ def sampling(self) -> tuple[str, ...]: # `hal::peripheralTimer` holds TIM1 .. TIM17. _TIMER_TABLE_SIZE = 17 _ADC_KEY_MAX = 0xFFFF -_OPEN_LIMITS = {"pwm": 1, "uart": 1, "spi": 1, "adc": 1, "qei": 1, "gpio": 8} +# Instances a group holds at once (PROTOCOL.md: one per group, eight GPIO pins); groups not listed hold one. +OPEN_LIMITS = { + "pwm": 1, + "uart": 1, + "spi": 1, + "adc": 1, + "qei": 1, + "gpio": 8, + "i2c": 1, + "i2cs": 1, + "eeprom": 1, + "spis": 1, + "tim": 1, + "tpwm": 1, + "lptim": 1, + "lptpwm": 1, + "qspi": 1, +} _PWM_CHANNELS_MAX = 4 +_PWM_MODES = ("edge", "edgedown", "center", "centerup", "centerboth") +_PWM_TRIGGER_OUTPUTS = ("reset", "enable", "update", "oc1", "oc1ref", "oc2ref", "oc3ref", "oc4ref") +_PWM_BREAK_FILTER_MAX = 15 _UART_RECEIVE_CAPACITY = 256 _UART_TRANSMIT_CAPACITY = 112 _SPI_CAPACITY = 64 +_SPI_BITS = (4, 16) +_SPI_LIMITED_BITS = (8, 16) _DELAY_MAX_MS = 600_000 _PULSES_MAX = 1_000_000 _PULSE_PERIOD_MAX_MS = 60_000 _RECEIVE_TIMEOUT_MAX_MS = 10_000 _ADC_MEASURE_TIMEOUT = 1.0 +_ADC_DMA = ("dma1", 7) _QEI_RESOLUTION_16BIT = 65536 _QEI_VELOCITY_MAX_US = 1_000_000 +_QEI_CAPTURES = ("a", "b", "ab") +_QEI_LP_CAPTURES = ("ab", "rise", "fall") _LPTIM_FILTERS = (0, 2, 4, 8) # `HilPinNamingDefault`: a port letter and up to three digits without leading zero. _PIN_RE = re.compile(r"^[Pp]([A-Za-z])(0|[1-9][0-9]{0,2})$") -_UINT32_MAX = 0xFFFFFFFF class FakeSerial(_FakeSerial): @@ -333,78 +472,6 @@ def _poll(self) -> None: poll() -class _Error(Exception): - def __init__(self, reason: str) -> None: - super().__init__(reason) - self.reason = reason - - -def _fail(reason: str) -> None: - raise _Error(reason) - - -def _parse_uint(text: str) -> int: - """`HilArguments::ParseNumber`: decimal or `0x` hex, unsigned 32 bits.""" - base, digits = 10, "0123456789" - if len(text) > 2 and text[0] == "0" and text[1] in "xX": - base, digits, text = 16, "0123456789abcdefABCDEF", text[2:] - if not text or any(char not in digits for char in text): - _fail("usage") - value = int(text, base) - if value > _UINT32_MAX: - _fail("usage") - return value - - -def _number(text: str | None, low: int = 0, high: int = _UINT32_MAX) -> int: - if text is None: - _fail("usage") - assert text is not None - value = _parse_uint(text) - if not low <= value <= high: - _fail("range") - return value - - -def _flag(options: Mapping[str, str], key: str, default: bool = False) -> bool: - if key not in options: - return default - return _number(options[key], 0, 1) == 1 - - -def _choice(options: Mapping[str, str], key: str, choices: Iterable[str], default: str) -> str: - value = options.get(key, default) - if value not in tuple(choices): - _fail("usage") - return value - - -def _shape(args: list[str], options: Mapping[str, str], low: int, high: int, keys: Iterable[str] = ()) -> None: - """`HilArguments::Shape`: positional count and known keys, else `ERR usage`.""" - allowed = tuple(keys) - if not low <= len(args) <= high or any(key not in allowed for key in options): - _fail("usage") - - -def _tokens(text: str) -> list[str]: - """`infra::Tokenizer` on `,`: empty entries are skipped.""" - return [token for token in text.split(",") if token] - - -def _hex(text: str, capacity: int) -> bytes: - """`HilArguments::ParseHex`: `-` is empty, odd or non-hex is `usage`, beyond `capacity` is `range`.""" - if text == "-": - return b"" - if not text or len(text) % 2: - _fail("usage") - if len(text) // 2 > capacity: - _fail("range") - try: - return bytes.fromhex(text) - except ValueError: - raise _Error("usage") from None - - def _duty(text: str) -> float: """`HilArguments::ParseDutyCycle`: decimal percent 0-100 with up to 4 decimals.""" integer, dot, fraction = text.partition(".") @@ -416,6 +483,11 @@ def _duty(text: str) -> float: return value +def _pwm_alignment(mode: str) -> expect.PwmMode: + """`center`, `centerup` and `centerboth` count up and down; `edge` and `edgedown` one way.""" + return "center" if mode.startswith("center") else "edge" + + @dataclass class _PwmOutput: """One entry of `pwm.open`: channel 0 until it is inferred from the pin.""" @@ -460,10 +532,12 @@ def __post_init__(self) -> None: self.sysclk = self.sysclk or spec.sysclk if self.pins is None: self.pins = dict(spec.pins) - self.claims: dict[str, tuple[tuple[str, str], bool]] = {} - self.timer_owners: dict[int, tuple[str, str]] = {} + self.claims: dict[str, tuple[Owner, bool]] = {} + self.timer_owners: dict[int, Owner] = {} + self.resources: dict[tuple[str, int], Owner] = {} self.exti: dict[int, str] = {} self.watchdog: dict[str, Any] | None = None + self.groups: dict[str, FakeGroup] = {cls.prefix: cls(self) for cls in discover_groups()} super().__post_init__() @property @@ -479,8 +553,11 @@ def boot(self) -> None: self.opened.clear() self.claims = {} self.timer_owners = {} + self.resources = {} self.exti = {} self.watchdog = None + for group in self.groups.values(): + group.boot() super().boot() def event(self, line: str) -> None: @@ -490,20 +567,28 @@ def event(self, line: str) -> None: def boot_message(self) -> str: return f"EVT boot board={self.board} family={self.family} sysclk={self.sysclk} reset={self.reset_cause}" + def group(self, prefix: str) -> FakeGroup | None: + return self.groups.get(prefix) + def lookup(self, name: str) -> Handler | None: handler = self.handlers.get(name) if handler is not None: return handler - if name in UNSUPPORTED_COMMANDS: + if name in self.spec.unsupported: return lambda device, args, options: "ERR unsupported" - method = getattr(self, "_cmd_" + name.replace(".", "_"), None) + prefix, _, verb = name.partition(".") + group = self.groups.get(prefix) + method = group.handler(verb) if group is not None and verb else None + if method is None: + method = getattr(self, "_cmd_" + name.replace(".", "_"), None) if method is None: return None + command = method def run(device: FakeTerminalDevice, args: list[str], options: dict[str, str]) -> str | list[str] | None: try: - return method(args, options) - except _Error as error: + return command(args, options) + except FakeError as error: return f"ERR {error.reason}" return run @@ -540,18 +625,19 @@ def supports(self, function: str, instance: int, pin: str | None) -> bool: def supports_analog(self, pin: str) -> bool: return self.bonded(pin) and pin in self.spec.analog - def _check_function(self, function: str, instance: int, pin: str | None) -> None: + def check_function(self, function: str, instance: int, pin: str | None) -> None: + """`ERR pin` unless a given pin offers `function` of `instance`.""" if pin is not None and not self.supports(function, instance, pin): _fail("pin") - def _first_function_pin(self, function: str, instance: int) -> str | None: + def first_function_pin(self, function: str, instance: int) -> str | None: """`FindFunctionPin`: the first bonded pin of the table that is not reserved.""" for pin in self.spec.functions.get(function, {}).get(instance, ()): if self.bonded(pin) and pin not in self.reserved(): return pin return None - def _check_pins(self, owner: tuple[str, str], pins: Iterable[str | None], analog: bool = False) -> None: + def check_pins(self, owner: Owner, pins: Iterable[str | None], analog: bool = False) -> None: """`HilPinPool::Claim`: `pin` for a pin the package lacks, `busy` for a reserved or held pin (analog users share a pin).""" seen: set[str] = set() @@ -569,19 +655,37 @@ def _check_pins(self, owner: tuple[str, str], pins: Iterable[str | None], analog _fail("busy") seen.add(pin) - def _claim(self, owner: tuple[str, str], pins: Iterable[str | None], analog: bool = False) -> None: + def claim(self, owner: Owner, pins: Iterable[str | None], analog: bool = False) -> None: for pin in pins: if pin is not None: self.claims.setdefault(pin, (owner, analog)) - def _release(self, owner: tuple[str, str]) -> None: + def release(self, owner: Owner) -> None: + """Everything `owner` holds: pins, timers and resources.""" self.claims = {pin: holder for pin, holder in self.claims.items() if holder[0] != owner} self.timer_owners = {timer: holder for timer, holder in self.timer_owners.items() if holder != owner} + self.release_resources(owner) + + def check_resources(self, owner: Owner, resources: Iterable[tuple[str, int]]) -> None: + """`ResourceAllocation::Claim` without claiming: `busy` when another owner holds one of them.""" + for kind, index in resources: + if kind not in RESOURCES or not 0 <= index < _RESOURCE_INDEX_LIMIT: + raise ValueError(f"no resource {kind} {index}") + if self.resources.get((kind, index), owner) != owner: + _fail("busy") + + def claim_resource(self, kind: str, index: int, owner: Owner) -> None: + """`ResourceAllocation::Claim`: `busy` when another owner holds it; the same owner may claim it again.""" + self.check_resources(owner, [(kind, index)]) + self.resources[(kind, index)] = owner + + def release_resources(self, owner: Owner) -> None: + self.resources = {key: holder for key, holder in self.resources.items() if holder != owner} - def _timer_exists(self, timer: int) -> bool: + def timer_exists(self, timer: int) -> bool: return timer in self.spec.timers - def _open( + def open_instance( self, group: str, key: str, @@ -589,37 +693,47 @@ def _open( pins: Iterable[str | None], analog: bool = False, timer: int | None = None, + resources: Iterable[tuple[str, int]] = (), ) -> None: """Claims an instance after its arguments were validated: everything here answers `ERR busy`.""" owner = (group, key) pins = list(pins) - if owner in self.opened or sum(1 for opened_group, _ in self.opened if opened_group == group) >= _OPEN_LIMITS[group]: + resources = list(resources) + held = sum(1 for opened_group, _ in self.opened if opened_group == group) + if owner in self.opened or held >= OPEN_LIMITS.get(group, 1): _fail("busy") if timer is not None and self.timer_owners.get(timer, owner) != owner: _fail("busy") - self._check_pins(owner, pins, analog) - self._claim(owner, pins, analog) + self.check_resources(owner, resources) + self.check_pins(owner, pins, analog) + self.claim(owner, pins, analog) if timer is not None: self.timer_owners[timer] = owner + for kind, index in resources: + self.resources[(kind, index)] = owner self.opened[owner] = state - def _index(self, group: str, text: str) -> int: - """`HilSingleInstance::Parse`: the instance number of the command.""" - return _number(text, 0, _INSTANCES[group] - 1) - - def _find(self, group: str, text: str) -> tuple[str, dict[str, Any]]: - key = str(self._index(group, text)) + def find_instance(self, group: str, key: str) -> dict[str, Any]: + """The state of an open instance, else `ERR notopen`.""" state = self.opened.get((group, key)) if state is None: _fail("notopen") assert state is not None - return key, state + return state - def _close(self, group: str, key: str) -> str: + def close_instance(self, group: str, key: str) -> str: del self.opened[(group, key)] - self._release((group, key)) + self.release((group, key)) return "OK" + def _index(self, group: str, text: str) -> int: + """`HilSingleInstance::Parse`: the instance number of the command.""" + return _number(text, 0, _INSTANCES[group] - 1) + + def _find(self, group: str, text: str) -> tuple[str, dict[str, Any]]: + key = str(self._index(group, text)) + return key, self.find_instance(group, key) + # general def _cmd_ping(self, args: list[str], options: dict[str, str]) -> str: @@ -661,7 +775,7 @@ def _gpio_free(self, pin: str) -> None: if self.exti.get(line) == pin: del self.exti[line] del self.opened[("gpio", pin)] - self._release(("gpio", pin)) + self.release(("gpio", pin)) def _cmd_gpio_cfg(self, args: list[str], options: dict[str, str]) -> str: _shape(args, options, 2, 2, ("pull", "drive")) @@ -676,7 +790,7 @@ def _cmd_gpio_cfg(self, args: list[str], options: dict[str, str]) -> str: _fail("usage") if ("gpio", pin) in self.opened: self._gpio_free(pin) - self._open("gpio", pin, {"mode": mode, "pull": pull, "drive": drive, "irq": "off"}, [pin]) + self.open_instance("gpio", pin, {"mode": mode, "pull": pull, "drive": drive, "irq": "off"}, [pin]) if mode == "out": self.gpio_levels[pin] = 0 elif mode == "od": @@ -803,43 +917,51 @@ def _pwm_resolve(self, timer: int, outputs: list[_PwmOutput]) -> None: ): _fail("unsupported") if output.pin is None and output.npin is None: - output.pin = self._first_function_pin(f"timerChannel{channel}", timer) + output.pin = self.first_function_pin(f"timerChannel{channel}", timer) if output.pin is None: _fail("pin") - self._check_function(f"timerChannel{channel}", timer, output.pin) - self._check_function(f"timerChannel{channel}N", timer, output.npin) + self.check_function(f"timerChannel{channel}", timer, output.pin) + self.check_function(f"timerChannel{channel}N", timer, output.npin) def _cmd_pwm_open(self, args: list[str], options: dict[str, str]) -> str: keys = ("channels", "pins", "freq", "mode", "prescaler", "dead", "inv", "invn", "idle", "idlen", "brk", "brkpol", "brkauto", "sync") - _shape(args, options, 1, 1, keys) + _shape(args, options, 1, 1, (*keys, "preload", "brkfilter", "trgo")) timer = self._index("pwm", args[0]) - if not self._timer_exists(timer): + if not self.timer_exists(timer): _fail("range") freq = _number(options.get("freq", "10000"), 1) - mode = _choice(options, "mode", ("edge", "center"), "edge") + mode = _choice(options, "mode", _PWM_MODES, "edge") + trgo = _choice(options, "trgo", _PWM_TRIGGER_OUTPUTS, "reset") if "trgo" in options else None prescaler = _number(options.get("prescaler", "0"), 0, expect.PWM_PRESCALER_MAX) dead = None if options.get("dead", "off") == "off" else _number(options["dead"], 0, expect.PWM_DEAD_MAX_NS) flags = {key: _flag(options, key) for key in ("inv", "invn", "idle", "idlen", "brkauto", "sync")} + preload = _flag(options, "preload", True) _choice(options, "brkpol", ("low", "high"), "high") + brkfilter = _number(options["brkfilter"], 0, _PWM_BREAK_FILTER_MAX) if "brkfilter" in options else None brk = self.pin(options.get("brk")) + if brkfilter is not None and brk is None: + _fail("usage") outputs = self._pwm_outputs(options) - if mode == "center" and not expect.timer_has_center_mode(timer): + # PwmStm asserts a counter mode select instance for every alignment but edgeAligned (PwmStm.cpp:224). + if mode != "edge" and not expect.timer_has_center_mode(timer): _fail("unsupported") complementary = any(output.npin is not None for output in outputs) needs_break = complementary or dead is not None or flags["idle"] or flags["idlen"] or brk is not None if needs_break and not expect.timer_has_break(timer): _fail("unsupported") self._pwm_resolve(timer, outputs) - self._check_function("timerBreak", timer, brk) + self.check_function("timerBreak", timer, brk) pwmclk = expect.pwm_clock(self.kernel_clock, prescaler) counter_max = expect.timer_counter_max(timer) - if not expect.pwm_fits(pwmclk, freq, mode, counter_max): + alignment = _pwm_alignment(mode) + if not expect.pwm_fits(pwmclk, freq, alignment, counter_max): _fail("range") channels = [(output.channel, output.pin, output.npin) for output in outputs] pins = [pin for _, first, second in channels for pin in (first, second)] + [brk] state = {"channels": channels, "freq": freq, "mode": mode, "pwmclk": pwmclk, "counter_max": counter_max, "running": False} - state.update(dead=dead, brk=brk, sync=flags["sync"], duties=None) - self._open("pwm", str(timer), state, pins, timer=timer) + state.update(alignment=alignment, dead=dead, brk=brk, brkfilter=brkfilter, sync=flags["sync"], duties=None) + state.update(preload=preload, trgo=trgo) + self.open_instance("pwm", str(timer), state, pins, timer=timer) return f"OK pwmclk={pwmclk}" def _cmd_pwm_duty(self, args: list[str], options: dict[str, str]) -> str: @@ -856,7 +978,7 @@ def _cmd_pwm_freq(self, args: list[str], options: dict[str, str]) -> str: _shape(args, options, 2, 2) _, state = self._find("pwm", args[0]) freq = _number(args[1], 1) - if not expect.pwm_fits(state["pwmclk"], freq, state["mode"], state["counter_max"]): + if not expect.pwm_fits(state["pwmclk"], freq, state["alignment"], state["counter_max"]): _fail("range") state["freq"] = freq return "OK" @@ -870,12 +992,12 @@ def _cmd_pwm_stop(self, args: list[str], options: dict[str, str]) -> str: def _cmd_pwm_close(self, args: list[str], options: dict[str, str]) -> str: _shape(args, options, 1, 1) key, _ = self._find("pwm", args[0]) - return self._close("pwm", key) + return self.close_instance("pwm", key) # uart (`UartFactory.cpp` `Evaluate`) def _cmd_uart_open(self, args: list[str], options: dict[str, str]) -> str: - keys = ("lp", "tx", "rx", "rts", "cts", "baud", "parity", "flow", "swap", "dma", "duplex", "sync") + keys = ("lp", "tx", "rx", "rts", "cts", "baud", "parity", "flow", "swap", "dma", "duplex", "sync", "sendonly") _shape(args, options, 1, 1, keys) index = self._index("uart", args[0]) lp = _flag(options, "lp") @@ -883,14 +1005,20 @@ def _cmd_uart_open(self, args: list[str], options: dict[str, str]) -> str: baud = _number(options.get("baud", "115200"), expect.UART_BAUD_MIN, expect.UART_BAUD_MAX) parity = _choice(options, "parity", ("none", "even", "odd"), "none") flow = _choice(options, "flow", ("none", "rts", "cts", "rtscts"), "none") - swap, dma, duplex, sync = (_flag(options, key) for key in ("swap", "dma", "duplex", "sync")) + swap, dma, duplex, sync, sendonly = (_flag(options, key) for key in ("swap", "dma", "duplex", "sync", "sendonly")) if index not in (self.spec.lpuarts if lp else self.spec.usarts): _fail("range") - if dma + duplex + sync > 1: + if dma + duplex + sync + sendonly > 1: _fail("usage") if (flow in ("rts", "rtscts")) != (rts is not None) or (flow in ("cts", "rtscts")) != (cts is not None): _fail("usage") - if (lp and (duplex or sync)) or (sync and (parity != "none" or swap)) or (not sync and flow not in ("none", "rtscts")): + synchronous = sync or sendonly + if (lp and (duplex or sync)) or (synchronous and (parity != "none" or swap)): + _fail("unsupported") + # `SynchronousUartStmSendOnly` takes an RTS pin but no CTS pin, and LPUARTs on STM32WB only. + if sendonly and (flow not in ("none", "rts") or (lp and not self.spec.lpuart_send_only)): + _fail("unsupported") + if not synchronous and flow not in ("none", "rtscts"): _fail("unsupported") if (dma or duplex) and (lp, index) not in self.spec.uart_dma: _fail("unsupported") @@ -900,15 +1028,16 @@ def _cmd_uart_open(self, args: list[str], options: dict[str, str]) -> str: defaults = self._terminal_pins() if terminal else self.spec.default_lpuart if lp and index == _DEFAULT_LPUART else None if defaults is not None and tx is None and rx is None and rts is None and cts is None: tx, rx = defaults - if tx is None or rx is None: + if tx is None or (rx is None and not sendonly): _fail("usage") prefix = "lpuart" if lp else "uart" for function, pin in (("Tx", tx), ("Rx", rx), ("Rts", rts), ("Cts", cts)): - self._check_function(prefix + function, index, pin) + self.check_function(prefix + function, index, pin) if terminal: _fail("busy") state = {"lp": lp, "baud": baud, "parity": parity, "flow": flow, "swap": swap, "dma": dma, "duplex": duplex, "sync": sync} - self._open("uart", str(index), state, [tx, rx, rts, cts]) + state["sendonly"] = sendonly + self.open_instance("uart", str(index), state, [tx, rx, rts, cts]) self.uart_rx[index] = bytearray() return "OK" @@ -926,10 +1055,12 @@ def _cmd_uart_send(self, args: list[str], options: dict[str, str]) -> str: def _cmd_uart_recv(self, args: list[str], options: dict[str, str]) -> str: _shape(args, options, 1, 1, ("timeout", "len")) - key, _ = self._find("uart", args[0]) + key, state = self._find("uart", args[0]) _number(options.get("timeout", "1000"), 0, _RECEIVE_TIMEOUT_MAX_MS) if "len" in options: _number(options["len"], 1, _UART_RECEIVE_CAPACITY) + if state["sendonly"]: + return "OK data=-" buffer = self.uart_rx.setdefault(int(key), bytearray()) data = bytes(buffer[:_UART_RECEIVE_CAPACITY]) del buffer[: len(data)] @@ -938,31 +1069,36 @@ def _cmd_uart_recv(self, args: list[str], options: dict[str, str]) -> str: def _cmd_uart_close(self, args: list[str], options: dict[str, str]) -> str: _shape(args, options, 1, 1) key, _ = self._find("uart", args[0]) - return self._close("uart", key) + return self.close_instance("uart", key) # spi (`SpiFactory.cpp` `Evaluate`) def _cmd_spi_open(self, args: list[str], options: dict[str, str]) -> str: - _shape(args, options, 1, 1, ("clk", "mosi", "miso", "cs", "baud", "mode", "dma", "sync")) + _shape(args, options, 1, 1, ("clk", "mosi", "miso", "cs", "baud", "mode", "dma", "sync", "bits", "lsb", "nss")) index = self._index("spi", args[0]) if index not in self.spec.spis: _fail("range") - clk, mosi, miso, cs = (self.pin(options.get(key)) for key in ("clk", "mosi", "miso", "cs")) + clk, mosi, miso, cs, nss = (self.pin(options.get(key)) for key in ("clk", "mosi", "miso", "cs", "nss")) baud = _number(options.get("baud", "1000000"), 1) mode = _number(options.get("mode", "0"), 0, 3) - dma, sync = _flag(options, "dma"), _flag(options, "sync") + bits = _number(options.get("bits", "8"), *_SPI_BITS) + dma, sync, lsb = _flag(options, "dma"), _flag(options, "sync"), _flag(options, "lsb") if clk is None or mosi is None or miso is None: _fail("usage") - if dma and sync: + if (dma and sync) or (nss is not None and cs is not None): _fail("usage") if not expect.spi_baud_fits(self.kernel_clock, baud): _fail("range") - for function, pin in (("spiClock", clk), ("spiMosi", mosi), ("spiMiso", miso)): - self._check_function(function, index, pin) + for function, pin in (("spiClock", clk), ("spiMosi", mosi), ("spiMiso", miso), ("spiSlaveSelect", nss)): + self.check_function(function, index, pin) if cs is not None and not self.bonded(cs): _fail("pin") + # Only `SpiMasterStmDma` has a data size configurator; a limited instance takes 8- and 16-bit frames only. + if (bits != 8 and not dma) or (index in self.spec.spi_limited and bits not in _SPI_LIMITED_BITS): + _fail("unsupported") state = {"baud": baud, "clock": expect.spi_clock(self.kernel_clock, baud), "mode": mode, "dma": dma, "sync": sync, "cs": cs} - self._open("spi", str(index), state, [clk, mosi, miso, cs]) + state.update(bits=bits, lsb=lsb, nss=nss) + self.open_instance("spi", str(index), state, [clk, mosi, miso, cs, nss], resources=[("spi", index)]) return "OK" def _cmd_spi_xfer(self, args: list[str], options: dict[str, str]) -> str: @@ -978,7 +1114,7 @@ def _cmd_spi_xfer(self, args: list[str], options: dict[str, str]) -> str: def _cmd_spi_close(self, args: list[str], options: dict[str, str]) -> str: _shape(args, options, 1, 1) key, _ = self._find("spi", args[0]) - return self._close("spi", key) + return self.close_instance("spi", key) # adc (`AdcFactory.cpp` `ParseKey` and `Parse`) @@ -997,13 +1133,16 @@ def _adc_find(self, args: list[str]) -> tuple[str, dict[str, Any]]: return key, state def _cmd_adc_open(self, args: list[str], options: dict[str, str]) -> str: - _shape(args, options, 1, 1, ("pins", "sampling", "timer", "rate")) + _shape(args, options, 1, 1, ("pins", "sampling", "timer", "rate", "trgo")) key = self._adc_key(args) _choice(options, "sampling", self.spec.sampling, expect.ADC_DEFAULT_SAMPLING[self.family]) timer = _number(options["timer"], 0, _TIMER_TABLE_SIZE) if "timer" in options else None rate = _number(options.get("rate", str(expect.ADC_DEFAULT_RATE)), 1, expect.ADC_RATE_MAX) + trgo = _number(options["trgo"], 0, _TIMER_TABLE_SIZE) if "trgo" in options else None if "rate" in options and timer is None: _fail("usage") + if trgo is not None and (timer is not None or "rate" in options): + _fail("usage") if "pins" not in options: _fail("usage") entries = options["pins"].split(",") @@ -1016,12 +1155,23 @@ def _cmd_adc_open(self, args: list[str], options: dict[str, str]) -> str: if not self.supports_analog(pin): _fail("pin") pins.append(pin) - if timer is not None and not self._timer_exists(timer): + if timer is not None and not self.timer_exists(timer): _fail("range") if timer is not None and timer not in expect.ADC_TRIGGER_TIMERS: _fail("unsupported") - state = {"pins": pins, "timer": timer, "rate": rate} - self._open("adc", key, state, pins, analog=True, timer=timer) + if trgo is not None: + if not self.timer_exists(trgo): + _fail("range") + # The sequence runs on the TRGO of a timer the pwm group drives; the adc group does not own it. + holder = self.timer_owners.get(trgo) + if trgo not in expect.ADC_TRIGGER_TIMERS or holder is None: + _fail("unsupported") + assert holder is not None + if holder[0] != "pwm": + _fail("busy") + state = {"pins": pins, "timer": timer, "rate": rate, "trgo": trgo} + resources = [("adc", self.spec.adc), _ADC_DMA] + self.open_instance("adc", key, state, pins, analog=True, timer=timer, resources=resources) return "OK" def _cmd_adc_measure(self, args: list[str], options: dict[str, str]) -> str: @@ -1030,8 +1180,9 @@ def _cmd_adc_measure(self, args: list[str], options: dict[str, str]) -> str: runs = _number(options.get("n", "1"), 1, expect.ADC_MAX_VALUES) if runs * len(state["pins"]) > expect.ADC_MAX_VALUES: _fail("range") - if state["timer"] is not None: - duration = expect.adc_measure_time(runs, state["rate"]) + rate = state["rate"] if state["trgo"] is None else self._trigger_rate(state["trgo"]) + if state["timer"] is not None or state["trgo"] is not None: + duration = math.inf if rate is None else expect.adc_measure_time(runs, rate) if duration > _ADC_MEASURE_TIMEOUT: self.sleep(_ADC_MEASURE_TIMEOUT) return "ERR timeout" @@ -1042,10 +1193,15 @@ def _cmd_adc_measure(self, args: list[str], options: dict[str, str]) -> str: self.sleep(expect.terminal_line_time(reply, self.terminal_baud)) return reply + def _trigger_rate(self, timer: int) -> int | None: + """Runs per second of `adc.open trgo=`: one per period of the pwm group's timer, none while it is stopped.""" + state = self.opened.get(("pwm", str(timer))) + return state["freq"] if state is not None and state["running"] else None + def _cmd_adc_close(self, args: list[str], options: dict[str, str]) -> str: _shape(args, options, 1, 1) key, _ = self._adc_find(args) - return self._close("adc", key) + return self.close_instance("adc", key) # qei (`QeiFactory.cpp` `Evaluate`) @@ -1056,22 +1212,23 @@ def _cmd_qei_open(self, args: list[str], options: dict[str, str]) -> str: lp = _flag(options, "lp") if lp and not self.spec.lptims: _fail("unsupported") - if lp and timer not in self.spec.lptims: + if lp and timer not in self.spec.lptim_encoders: _fail("range") - if not lp and not self._timer_exists(timer): + if not lp and not self.timer_exists(timer): _fail("range") if not lp and timer not in expect.ENCODER_TIMERS: _fail("unsupported") - if lp and any(key in options for key in ("cap", "offset", "invb")): + # The LPTIM encoder counts both inputs (x4) or one edge of both (x2); it has no offset and one polarity. + if lp and (any(key in options for key in ("offset", "invb")) or options.get("cap") in ("a", "b")): _fail("unsupported") - maximum = _UINT32_MAX if not lp and timer in expect.TIMERS_32BIT else _QEI_RESOLUTION_16BIT + maximum = UINT32_MAX if not lp and timer in expect.TIMERS_32BIT else _QEI_RESOLUTION_16BIT res = _number(options.get("res", "4096"), 2, maximum) offset = _number(options.get("offset", "0")) if offset >= res: _fail("range") _flag(options, "inva") _flag(options, "invb") - _choice(options, "cap", ("a", "b", "ab"), "ab") + cap = _choice(options, "cap", _QEI_LP_CAPTURES if lp else _QEI_CAPTURES, "ab") filter_samples = _number(options.get("filter", "0"), 0, 15) if options.get("vel") != "off": _number(options.get("vel", "1000"), 1, _QEI_VELOCITY_MAX_US) @@ -1088,8 +1245,9 @@ def _cmd_qei_open(self, args: list[str], options: dict[str, str]) -> str: _fail("pin") if idx is not None and not self.bonded(idx): _fail("pin") - state = {"lp": lp, "res": res, "pos": offset, "dir": "fwd", "speed": 0, "idx": idx} - self._open("qei", str(timer), state, [a, b, idx], timer=None if lp else timer) + state = {"lp": lp, "res": res, "pos": offset, "dir": "fwd", "speed": 0, "idx": idx, "cap": cap} + resources = [("lpTimer", timer)] if lp else [] + self.open_instance("qei", str(timer), state, [a, b, idx], timer=None if lp else timer, resources=resources) return "OK" def _cmd_qei_read(self, args: list[str], options: dict[str, str]) -> str: @@ -1107,7 +1265,7 @@ def _cmd_qei_index(self, args: list[str], options: dict[str, str]) -> str: def _cmd_qei_close(self, args: list[str], options: dict[str, str]) -> str: _shape(args, options, 1, 1) key, _ = self._find("qei", args[0]) - return self._close("qei", key) + return self.close_instance("qei", key) # watchdog (`HilWatchDogCommands::Parse` around `WatchDogFactory.cpp` `Prepare`) @@ -1132,8 +1290,8 @@ def _cmd_wdt_start(self, args: list[str], options: dict[str, str]) -> str: if self.watchdog is not None: _fail("busy") owner = ("wdt", str(index)) - self._check_pins(owner, [pin]) - self._claim(owner, [pin]) + self.check_pins(owner, [pin]) + self.claim(owner, [pin]) if pin is not None: self.gpio_levels[pin] = 0 period = expect.wwdg_period(self.pclk1, prescaler) @@ -1165,6 +1323,12 @@ def _cmd_wdt_feed(self, args: list[str], options: dict[str, str]) -> str: return "OK" def poll(self) -> None: + """Time-driven output of the groups, then of the watchdog.""" + for group in self.groups.values(): + group.poll() + self._poll_watchdog() + + def _poll_watchdog(self) -> None: """Early warnings every period; `feed=auto` refreshes on each, otherwise the WWDG resets the board one counter tick after a warning that was not answered with `wdt.feed`. A tick shorter than the transmission of the `EVT wdt` line cuts the line: the host gets the part sent before the reset.""" diff --git a/validation/host/hal_st_validation/fakes/__init__.py b/validation/host/hal_st_validation/fakes/__init__.py new file mode 100644 index 00000000..00e242e0 --- /dev/null +++ b/validation/host/hal_st_validation/fakes/__init__.py @@ -0,0 +1,28 @@ +"""Command groups of the fake firmware, one module per area (`fakes/.py`); `discover()` finds them.""" + +from __future__ import annotations + +import importlib +import inspect +import pkgutil + +from .base import FakeGroup + +__all__ = ["FakeGroup", "discover"] + + +def discover() -> tuple[type[FakeGroup], ...]: + """Every `FakeGroup` subclass with a `prefix` defined in a module of this package, sorted by prefix; two groups + with one prefix are an error.""" + found: dict[str, type[FakeGroup]] = {} + for info in pkgutil.iter_modules(__path__): + if info.name == "base": + continue + module = importlib.import_module(f"{__name__}.{info.name}") + for _, cls in inspect.getmembers(module, inspect.isclass): + if cls.__module__ != module.__name__ or not issubclass(cls, FakeGroup) or not cls.prefix: + continue + other = found.setdefault(cls.prefix, cls) + if other is not cls: + raise ValueError(f"fake groups {other.__qualname__} and {cls.__qualname__} both serve {cls.prefix!r}") + return tuple(found[prefix] for prefix in sorted(found)) diff --git a/validation/host/hal_st_validation/fakes/base.py b/validation/host/hal_st_validation/fakes/base.py new file mode 100644 index 00000000..0ca48e70 --- /dev/null +++ b/validation/host/hal_st_validation/fakes/base.py @@ -0,0 +1,130 @@ +"""Building blocks of the fake firmware's command groups: the argument helpers that mirror EMIL's `HilArguments` +(`ERR usage` / `ERR range` as `FakeError`) and `FakeGroup`, the base of the groups in `fakes/*.py`. + +A group sees the firmware through `self.fw` (a `FakeFirmware`): `spec` (the board profile), `pin()`, `bonded()`, +`supports()`, `check_function()`, `first_function_pin()`, `supports_analog()`, `reserved()`, `timer_exists()`, +`check_pins()`/`claim()`/`release()` (the `HilPinPool`), `timer_owners` (`TimerAllocation`), +`check_resources()`/`claim_resource()`/`release_resources()` (`ResourceAllocation`: lpTimer, spi, i2c, adc, dma1, +dma2, hsem), `open_instance()`/`find_instance()`/`close_instance()` (one instance per group, with its pins, timer +and resources), `group()`, `event()`, `clock()` and `sleep()`. Owners are `(prefix, key)` tuples, as the +firmware's groups use one owner id each. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping +from typing import TYPE_CHECKING, ClassVar + +if TYPE_CHECKING: + from ..fake_firmware import FakeFirmware + +__all__ = ["Command", "FakeError", "FakeGroup", "_Error", "_choice", "_fail", "_flag", "_hex", "_number", "_shape", "_tokens"] + +UINT32_MAX = 0xFFFFFFFF + +Command = Callable[[list[str], dict[str, str]], str | list[str] | None] + + +class FakeError(Exception): + """Answered as `ERR `.""" + + def __init__(self, reason: str) -> None: + super().__init__(reason) + self.reason = reason + + +_Error = FakeError + + +def _fail(reason: str) -> None: + raise FakeError(reason) + + +def _parse_uint(text: str) -> int: + """`HilArguments::ParseNumber`: decimal or `0x` hex, unsigned 32 bits.""" + base, digits = 10, "0123456789" + if len(text) > 2 and text[0] == "0" and text[1] in "xX": + base, digits, text = 16, "0123456789abcdefABCDEF", text[2:] + if not text or any(char not in digits for char in text): + _fail("usage") + value = int(text, base) + if value > UINT32_MAX: + _fail("usage") + return value + + +def _number(text: str | None, low: int = 0, high: int = UINT32_MAX) -> int: + if text is None: + _fail("usage") + assert text is not None + value = _parse_uint(text) + if not low <= value <= high: + _fail("range") + return value + + +def _flag(options: Mapping[str, str], key: str, default: bool = False) -> bool: + if key not in options: + return default + return _number(options[key], 0, 1) == 1 + + +def _choice(options: Mapping[str, str], key: str, choices: Iterable[str], default: str) -> str: + value = options.get(key, default) + if value not in tuple(choices): + _fail("usage") + return value + + +def _shape(args: list[str], options: Mapping[str, str], low: int, high: int, keys: Iterable[str] = ()) -> None: + """`HilArguments::Shape`: positional count and known keys, else `ERR usage`.""" + allowed = tuple(keys) + if not low <= len(args) <= high or any(key not in allowed for key in options): + _fail("usage") + + +def _tokens(text: str) -> list[str]: + """`infra::Tokenizer` on `,`: empty entries are skipped.""" + return [token for token in text.split(",") if token] + + +def _hex(text: str, capacity: int) -> bytes: + """`HilArguments::ParseHex`: `-` is empty, odd or non-hex is `usage`, beyond `capacity` is `range`.""" + if text == "-": + return b"" + if not text or len(text) % 2: + _fail("usage") + if len(text) // 2 > capacity: + _fail("range") + try: + return bytes.fromhex(text) + except ValueError: + raise FakeError("usage") from None + + +class FakeGroup: + """One command group of the fake firmware, discovered in `fakes/*.py` (every subclass with a `prefix`). + + `. args key=value` runs `cmd_(args, options)`, which returns the final line(s) (or None + for a command without one) and raises `FakeError` for `ERR `. Check arguments in the firmware's order + (`usage`, `range`, `pin`, `unsupported`, then `busy`). `boot()` runs on every boot (state is lost), `poll()` + whenever the host reads or writes (time-driven output).""" + + prefix: ClassVar[str] = "" + + def __init__(self, fw: FakeFirmware) -> None: + self.fw = fw + + @property + def firmware(self) -> FakeFirmware: + return self.fw + + def boot(self) -> None: + """The firmware rebooted: the pins, timers and resources the group held are already released.""" + + def poll(self) -> None: + """Called whenever the host reads or writes; print `EVT` lines with `self.fw.event()`.""" + + def handler(self, verb: str) -> Command | None: + method = getattr(self, "cmd_" + verb, None) + return method if callable(method) else None diff --git a/validation/host/hal_st_validation/firmware.py b/validation/host/hal_st_validation/firmware.py index 3de213db..41461813 100644 --- a/validation/host/hal_st_validation/firmware.py +++ b/validation/host/hal_st_validation/firmware.py @@ -11,6 +11,8 @@ from ad3_waveforms_bench.protocol import Event, Response, format_command, format_decimal from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal, PendingCommand, TerminalError +from .groups import discover as discover_groups +from .groups.base import Group from .protocol import normalize_pin, parse_pin_map Pin = str @@ -18,6 +20,8 @@ Edge = Literal["rising", "falling", "both", "off"] Drive = Literal["low", "medium", "fast", "high"] PwmOutput = Pin | None | tuple[Pin | None, Pin | None] +PwmMode = Literal["edge", "edgedown", "center", "centerup", "centerboth"] +TriggerOutput = Literal["reset", "enable", "update", "oc1", "oc1ref", "oc2ref", "oc3ref", "oc4ref"] @dataclass(frozen=True) @@ -71,20 +75,7 @@ def quiesce(terminal: FirmwareTerminal, quiet: float = 0.1, limit: float = 3.0) return False -class _Group: - prefix = "" - - def __init__(self, firmware: Firmware) -> None: - self._fw = firmware - - def _cmd(self, name: str, *args: object, cmd_timeout: float | None = None, **options: object) -> Response: - return self._fw.command(f"{self.prefix}.{name}", *args, cmd_timeout=cmd_timeout, **options) - - def _pin(self, pin: Pin | None) -> str | None: - return None if pin is None else self._fw.pin(pin) - - def _pins(self, pins: Sequence[Pin] | None) -> list[str] | None: - return None if pins is None else [self._fw.pin(pin) for pin in pins] +_Group = Group class System(_Group): @@ -155,7 +146,7 @@ def open( channels: Sequence[int] | None = None, pins: Sequence[PwmOutput] | None = None, freq: int | None = None, - mode: Literal["edge", "center"] | None = None, + mode: PwmMode | None = None, prescaler: int | None = None, dead: int | Literal["off"] | None = None, inv: bool | None = None, @@ -166,9 +157,13 @@ def open( brkpol: Literal["low", "high"] | None = None, brkauto: bool | None = None, sync: bool | None = None, + preload: bool | None = None, + brkfilter: int | None = None, + trgo: TriggerOutput | None = None, ) -> int: """Returns `pwmclk`. A `pins` entry is the channel output, `(output, complementary)` or None (`-`); a None - inside the tuple leaves that position unused (`-:PA7` drives only CH1N).""" + inside the tuple leaves that position unused (`-:PA7` drives only CH1N). `brkfilter` (0..15) needs `brk`; + `trgo` selects the trigger output an `adc.open trgo=` sequence runs on.""" response = self._cmd( "open", timer, @@ -186,6 +181,9 @@ def open( brkpol=brkpol, brkauto=brkauto, sync=sync, + preload=preload, + brkfilter=brkfilter, + trgo=trgo, ) self._fw.track(("pwm", timer), "pwm.close", timer) return response.as_int("pwmclk") @@ -234,8 +232,10 @@ def open( dma: bool | None = None, duplex: bool | None = None, sync: bool | None = None, + sendonly: bool | None = None, ) -> None: - """`lp=True` selects LPUART; the group keeps one instance, addressed by `index` afterwards.""" + """`lp=True` selects LPUART; the group keeps one instance, addressed by `index` afterwards. + `sendonly=True` opens a transmit-only synchronous UART: `rx` is optional and `recv` returns no data.""" self._cmd( "open", index, @@ -251,6 +251,7 @@ def open( dma=dma, duplex=duplex, sync=sync, + sendonly=sendonly, ) self._fw.track(("uart", index), "uart.close", index) @@ -282,7 +283,12 @@ def open( mode: int | None = None, dma: bool | None = None, sync: bool | None = None, + bits: int | None = None, + lsb: bool | None = None, + nss: Pin | None = None, ) -> None: + """`bits` (4..16, `dma=True` only) is the frame size, `lsb=True` sends the least significant bit first and + `nss` muxes the hardware slave select (exclusive with the GPIO chip select `cs`).""" self._cmd( "open", index, @@ -294,6 +300,9 @@ def open( mode=mode, dma=dma, sync=sync, + bits=bits, + lsb=lsb, + nss=self._pin(nss), ) self._fw.track(("spi", index), "spi.close", index) @@ -315,9 +324,11 @@ def open( sampling: str | float | None = None, timer: int | None = None, rate: int | None = None, + trgo: int | None = None, ) -> None: - """`sampling` in ADC clock cycles (`2.5`, `"640.5"`); `timer` triggers the runs at `rate` per second.""" - self._cmd("open", adc, pins=self._pins(pins), sampling=sampling, timer=timer, rate=rate) + """`sampling` in ADC clock cycles (`2.5`, `"640.5"`); `timer` triggers the runs at `rate` per second, or + `trgo` on the trigger output of a timer the pwm group drives.""" + self._cmd("open", adc, pins=self._pins(pins), sampling=sampling, timer=timer, rate=rate, trgo=trgo) self._fw.track(("adc", adc), "adc.close", adc) def measure(self, adc: int, n: int | None = None, cmd_timeout: float | None = None) -> list[int]: @@ -342,11 +353,11 @@ def open( offset: int | None = None, inva: bool | None = None, invb: bool | None = None, - cap: Literal["a", "b", "ab"] | None = None, + cap: Literal["a", "b", "ab", "rise", "fall"] | None = None, filter: int | None = None, vel: int | Literal["off"] | None = None, ) -> None: - """`lp=True` selects LPTIM; `vel` is the speed sampling period in µs.""" + """`lp=True` selects LPTIM (`cap` ab, rise or fall); `vel` is the speed sampling period in µs.""" self._cmd( "open", timer, @@ -404,6 +415,7 @@ class Firmware: Open instances are tracked so `close_all()` can restore a clean state between tests. Pins are sent as `P` after resolving aliases with `aliases` (when given). + The groups of `groups/*.py` are attached under the names of their `GROUPS` (`fw.i2c`, ...). """ def __init__(self, terminal: FirmwareTerminal, aliases: Mapping[str, str] | None = None) -> None: @@ -418,6 +430,10 @@ def __init__(self, terminal: FirmwareTerminal, aliases: Mapping[str, str] | None self.adc = Adc(self) self.qei = Qei(self) self.wdt = Watchdog(self) + for name, cls in discover_groups().items(): + if hasattr(self, name): + raise ValueError(f"group {name!r} ({cls.__qualname__}) clashes with Firmware.{name}") + setattr(self, name, cls(self)) def command(self, name: str, *args: object, cmd_timeout: float | None = None, **options: object) -> Response: return self.terminal.command(format_command(name, *args, **options), timeout=cmd_timeout) diff --git a/validation/host/hal_st_validation/groups/__init__.py b/validation/host/hal_st_validation/groups/__init__.py new file mode 100644 index 00000000..b55d2c38 --- /dev/null +++ b/validation/host/hal_st_validation/groups/__init__.py @@ -0,0 +1,29 @@ +"""Command group wrappers, one module per area (`groups/.py`), each exporting +`GROUPS: dict[str, type[Group]]` (attribute name on `Firmware` -> class); `discover()` collects them.""" + +from __future__ import annotations + +import importlib +import pkgutil + +from .base import Group + +__all__ = ["Group", "discover"] + + +def discover() -> dict[str, type[Group]]: + """The `GROUPS` of every module of this package; one name exported twice is an error.""" + found: dict[str, type[Group]] = {} + owners: dict[str, str] = {} + for info in sorted(pkgutil.iter_modules(__path__), key=lambda info: info.name): + if info.name == "base": + continue + module = importlib.import_module(f"{__name__}.{info.name}") + for name, cls in getattr(module, "GROUPS", {}).items(): + if not (isinstance(cls, type) and issubclass(cls, Group)): + raise TypeError(f"{module.__name__}.GROUPS[{name!r}] is not a Group") + if name in found: + raise ValueError(f"group {name!r} is exported by {owners[name]} and {module.__name__}") + found[name] = cls + owners[name] = module.__name__ + return found diff --git a/validation/host/hal_st_validation/groups/base.py b/validation/host/hal_st_validation/groups/base.py new file mode 100644 index 00000000..e472df6c --- /dev/null +++ b/validation/host/hal_st_validation/groups/base.py @@ -0,0 +1,37 @@ +"""`Group`: the base of the typed command wrappers (`fw..(...)`).""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from ad3_waveforms_bench.protocol import Response, format_command +from ad3_waveforms_bench.terminal import PendingCommand + +if TYPE_CHECKING: + from ..firmware import Firmware + +Pin = str + + +class Group: + """Commands `.`; keyword arguments map 1:1 to protocol options (None leaves one out). Pins are + sent as `P` after resolving the firmware's aliases.""" + + prefix = "" + + def __init__(self, firmware: Firmware) -> None: + self._fw = firmware + + def _cmd(self, name: str, *args: object, cmd_timeout: float | None = None, **options: object) -> Response: + return self._fw.command(f"{self.prefix}.{name}", *args, cmd_timeout=cmd_timeout, **options) + + def begin(self, name: str, *args: object, cmd_timeout: float | None = None, **options: object) -> PendingCommand: + """Send `.` without waiting for its final line (`settle()` finishes it whatever happens).""" + return self._fw.terminal.begin(format_command(f"{self.prefix}.{name}", *args, **options), timeout=cmd_timeout) + + def _pin(self, pin: Pin | None) -> str | None: + return None if pin is None else self._fw.pin(pin) + + def _pins(self, pins: Sequence[Pin] | None) -> list[str] | None: + return None if pins is None else [self._fw.pin(pin) for pin in pins] diff --git a/validation/host/hal_st_validation/patterns.py b/validation/host/hal_st_validation/patterns.py new file mode 100644 index 00000000..a3e305ac --- /dev/null +++ b/validation/host/hal_st_validation/patterns.py @@ -0,0 +1,45 @@ +"""Host twin of the firmware-generated payloads (`len=`, `pattern=`, `seed=` of validation/PROTOCOL.md; firmware +`validation/firmware/Payload.cpp`) and of their `crc=` replies. + +`inc`: byte i = (seed + i) & 0xFF; `const`: seed & 0xFF; `prbs`: xorshift32 (x ^= x << 13; x ^= x >> 17; +x ^= x << 5, on 32 bits) from state `seed or 1`, one step per byte, the low byte of the state. +""" + +from __future__ import annotations + +import zlib +from typing import Literal + +Pattern = Literal["inc", "const", "prbs"] +PATTERNS: tuple[Pattern, ...] = ("inc", "const", "prbs") +SEED_MAX = 0xFFFFFFFF +_MASK32 = 0xFFFFFFFF + +# `infra::Crc32` of the firmware (reflected, polynomial 0xEDB88320, initial and final xor 0xFFFFFFFF). +crc32 = zlib.crc32 + + +def generate(length: int, pattern: Pattern = "inc", seed: int = 0) -> bytes: + if length < 0: + raise ValueError(f"length must not be negative: {length}") + if not 0 <= seed <= SEED_MAX: + raise ValueError(f"seed must fit 32 bits: {seed}") + if pattern == "inc": + return bytes((seed + index) & 0xFF for index in range(length)) + if pattern == "const": + return bytes([seed & 0xFF]) * length + if pattern == "prbs": + state = seed or 1 + result = bytearray(length) + for index in range(length): + state ^= (state << 13) & _MASK32 + state ^= state >> 17 + state ^= (state << 5) & _MASK32 + result[index] = state & 0xFF + return bytes(result) + raise ValueError(f"unknown pattern {pattern!r}") + + +def crc_text(data: bytes) -> str: + """The `crc=` field of a reply: CRC-32 as 8 lower-case hex digits.""" + return f"{crc32(data):08x}" diff --git a/validation/host/hal_st_validation/protocol.py b/validation/host/hal_st_validation/protocol.py index c18878eb..aa5ce571 100644 --- a/validation/host/hal_st_validation/protocol.py +++ b/validation/host/hal_st_validation/protocol.py @@ -22,13 +22,16 @@ r"tim(1[0-7]|[1-9])ch[1-3]n", r"tim(1[0-7]|[1-9])bkin", r"qei(1[0-7]|[1-9])(a|b|idx)", - r"lptim[12]in[12]", - r"spi[1-3](clk|miso|mosi|cs)", + r"lptim[12](in|ch)[12]", + r"spi[1-3](clk|miso|mosi|cs|nss)", + r"i2c[1-3](scl|sda)", + r"qspi(clk|ncs|io[0-3])", r"usart[1-3](tx|rx|rts|cts)", r"lpuart1(tx|rx|rts|cts)", r"led[0-7]", r"gpio(1[0-5]|[0-9])", r"sw[1-3]", + r"mco", ) _ALIAS_RE = re.compile("^(?:" + "|".join(_ALIAS_PATTERNS) + ")$") @@ -41,13 +44,16 @@ def _generic_aliases() -> frozenset[str]: names |= {f"tim{timer}ch{channel}n" for channel in range(1, 4)} names |= {f"tim{timer}bkin"} names |= {f"qei{timer}{signal}" for signal in ("a", "b", "idx")} - names |= {f"lptim{index}in{line}" for index in (1, 2) for line in (1, 2)} - names |= {f"spi{index}{signal}" for index in range(1, 4) for signal in ("clk", "miso", "mosi", "cs")} + names |= {f"lptim{index}{kind}{line}" for index in (1, 2) for kind in ("in", "ch") for line in (1, 2)} + names |= {f"spi{index}{signal}" for index in range(1, 4) for signal in ("clk", "miso", "mosi", "cs", "nss")} + names |= {f"i2c{index}{signal}" for index in range(1, 4) for signal in ("scl", "sda")} + names |= {f"qspi{signal}" for signal in ("clk", "ncs", "io0", "io1", "io2", "io3")} names |= {f"usart{index}{signal}" for index in range(1, 4) for signal in ("tx", "rx", "rts", "cts")} names |= {f"lpuart1{signal}" for signal in ("tx", "rx", "rts", "cts")} names |= {f"led{index}" for index in range(8)} names |= {f"gpio{index}" for index in range(16)} names |= {f"sw{index}" for index in range(1, 4)} + names |= {"mco"} assert all(_ALIAS_RE.match(name) for name in names) return frozenset(names) diff --git a/validation/host/pyproject.toml b/validation/host/pyproject.toml index b09bed68..46502c23 100644 --- a/validation/host/pyproject.toml +++ b/validation/host/pyproject.toml @@ -40,7 +40,10 @@ markers = [ "matrix(path): one parameter dimension per key of the tests. mapping of the board YAML", "constraint(valid): keep a parameter combination only when valid(argname -> value) is true", "family(name): only run on boards of this family (stm32wb55 or stm32wba55)", - "requires_option(tag): only run when enabled with --with ", + "requires_option(tag): only run when enabled with --with (implies uses_option(tag))", + "uses_option(tag): the test handles the wiring of --with : it may resolve the pins that option loads or jumpers", + "conflicts_option(tag): skip when enabled with --with ", + "wiring_options(argname, enabled=True): one case per wiring option of the selected sets (enabled, or left out with enabled=False)", "resets_board: the test resets the board on purpose", "slow: takes more than a few seconds", ] diff --git a/validation/host/tests/conftest.py b/validation/host/tests/conftest.py index b9dbebba..cfd8a56b 100644 --- a/validation/host/tests/conftest.py +++ b/validation/host/tests/conftest.py @@ -7,6 +7,11 @@ `--ad3-serial`, `--no-ad3`, `--fake`, the `ad3` marker and the `ad3` fixture come from the `ad3_waveforms_bench` pytest plugin; `ad3_settings` below feeds it the board file's AD3 section. +`--board-extra` files are deep-merged over the board file (`config.merge_board`). `--with` options must be +offered by a `--wiring-set` and must not exclude each other, else the run stops with a usage error. A pin an +enabled option loads (`loads:`) or ties to another pin (`jumpered:`) is only resolved by `need` for tests marked +`uses_option()` or `requires_option()`; the others skip ("pin X loaded by --with T"). + The board file's `known_gaps` mark the HIL tests that run into a driver gap: a gap that aborts or hangs the firmware skips its tests (run them with `--run-known-gaps`), any other is an expected failure (xfail, not strict). They describe the firmware, so `--fake` ignores them. @@ -14,8 +19,9 @@ from __future__ import annotations +import contextlib import os -from collections.abc import Iterator +from collections.abc import Iterable, Iterator from pathlib import Path from typing import Any @@ -23,17 +29,26 @@ from ad3_waveforms_bench.pytest_plugin import Ad3Settings from ad3_waveforms_bench.terminal import FirmwareTerminal, TerminalError -from hal_st_validation.config import BoardConfig, ConfigError, Wiring, known_gap_outcome, load_board +from hal_st_validation.config import BoardConfig, ChannelKind, ConfigError, Connection, Wiring, known_gap_outcome, load_board from hal_st_validation.firmware import Firmware, quiesce from hal_st_validation.pairwise import DEPTHS, combinations HIL_DIR = Path(__file__).parent / "hil" _BOARD_KEY = pytest.StashKey[BoardConfig]() +_WIRING_KEY = pytest.StashKey[Wiring]() +_OPTION_MARKERS = ("uses_option", "requires_option") def pytest_addoption(parser: pytest.Parser) -> None: group = parser.getgroup("hal-st validation") group.addoption("--board", default=os.environ.get("HAL_ST_BOARD", "nucleo_wb55rg"), help="board YAML name or path") + group.addoption( + "--board-extra", + dest="board_extras", + action="append", + default=[path for path in os.environ.get("HAL_ST_BOARD_EXTRA", "").split(os.pathsep) if path], + help="YAML deep-merged over the board file (repeatable; or HAL_ST_BOARD_EXTRA, a list separated by os.pathsep)", + ) group.addoption("--port", default=os.environ.get("HAL_ST_PORT"), help="firmware terminal serial port; HIL tests skip without it") group.addoption("--baud", type=int, default=None, help="terminal baud rate (default from the board YAML)") group.addoption( @@ -60,12 +75,25 @@ def pytest_addoption(parser: pytest.Parser) -> None: def board_config(config: pytest.Config) -> BoardConfig: if _BOARD_KEY not in config.stash: - board = load_board(config.getoption("--board")) + board = load_board(config.getoption("--board"), config.getoption("board_extras")) board.apply_overrides(config.getoption("overrides")) config.stash[_BOARD_KEY] = board return config.stash[_BOARD_KEY] +def wiring_config(config: pytest.Config) -> Wiring: + """`--wiring-set` with the `--with` options, validated once (`BoardConfig.wiring`).""" + if _WIRING_KEY not in config.stash: + names = [name.strip() for name in config.getoption("--wiring-set").split(",") if name.strip()] + config.stash[_WIRING_KEY] = board_config(config).wiring(names, config.getoption("with_tags")) + return config.stash[_WIRING_KEY] + + +def option_tags(node: pytest.Item) -> frozenset[str]: + """The options a test handles: its `uses_option` and `requires_option` markers.""" + return frozenset(str(marker.args[0]) for name in _OPTION_MARKERS for marker in node.iter_markers(name)) + + def _ids(value: Any) -> str: if isinstance(value, dict): if "name" in value: @@ -125,13 +153,34 @@ def _dimensions(metafunc: pytest.Metafunc, board: BoardConfig) -> tuple[list[_Di return dimensions, None +def _parametrize_options(metafunc: pytest.Metafunc, marker: pytest.Mark) -> None: + """`@pytest.mark.wiring_options("tag")` runs the test once per option the selected wiring sets offer: the + enabled ones (default), or with `enabled=False` the ones left out; each case is marked `uses_option(tag)`.""" + argname = marker.args[0] + enabled = marker.kwargs.get("enabled", True) + try: + wiring = wiring_config(metafunc.config) + except ConfigError as error: + raise pytest.UsageError(str(error)) from error + tags = [tag for tag in wiring.options if (tag in wiring.enabled) == enabled] + if not tags: + reason = "no option enabled with --with" if enabled else "every offered option is enabled" + metafunc.parametrize(argname, [pytest.param(None, marks=pytest.mark.skip(reason=reason))]) + return + metafunc.parametrize(argname, [pytest.param(tag, id=tag, marks=pytest.mark.uses_option(tag)) for tag in tags]) + + def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: """`@pytest.mark.board_params("freq", "pwm.frequencies")` adds one dimension from the board YAML (or from `values=[...]`); for several argnames each item is a mapping (picked by name) or a sequence (positional). `@pytest.mark.matrix("pwm.waveform")` adds one dimension per key of a YAML mapping, named like the argnames. `@pytest.mark.constraint(valid=predicate)` keeps a combination only when `predicate(values)` is true; it receives a possibly partial `argname -> value` mapping and must return False only for impossible assignments. + `@pytest.mark.wiring_options(argname)` parametrizes over wiring options instead (`_parametrize_options`). """ + options = metafunc.definition.get_closest_marker("wiring_options") + if options is not None: + _parametrize_options(metafunc, options) try: board = board_config(metafunc.config) dimensions, skip = _dimensions(metafunc, board) @@ -204,6 +253,11 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item board: BoardConfig | None = board_config(config) except ConfigError: board = None + if board is not None: + try: + wiring_config(config) + except ConfigError as error: + raise pytest.UsageError(str(error)) from error family = None if board is None else board.family for item in items: if HIL_DIR in Path(str(item.path)).parents: @@ -214,9 +268,12 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item marker = item.get_closest_marker("family") if marker and family and marker.args[0] != family: item.add_marker(pytest.mark.skip(reason=f"only for {marker.args[0]}")) - marker = item.get_closest_marker("requires_option") - if marker and marker.args[0] not in tags: - item.add_marker(pytest.mark.skip(reason=f"enable with --with {marker.args[0]}")) + for marker in item.iter_markers("requires_option"): + if marker.args[0] not in tags: + item.add_marker(pytest.mark.skip(reason=f"enable with --with {marker.args[0]}")) + for marker in item.iter_markers("conflicts_option"): + if marker.args[0] in tags: + item.add_marker(pytest.mark.skip(reason=f"disconnect --with {marker.args[0]}")) @pytest.fixture(scope="session") @@ -225,50 +282,79 @@ def board_cfg(pytestconfig: pytest.Config) -> BoardConfig: @pytest.fixture(scope="session") -def wiring(pytestconfig: pytest.Config, board_cfg: BoardConfig) -> Wiring: - names = [name.strip() for name in pytestconfig.getoption("--wiring-set").split(",") if name.strip()] - return board_cfg.wiring(names, pytestconfig.getoption("with_tags")) +def wiring(pytestconfig: pytest.Config) -> Wiring: + return wiring_config(pytestconfig) class Need: - """Wiring lookups that skip the test when the active wiring lacks the connection.""" + """Wiring lookups that skip the test when the active wiring lacks the connection, or when the pin is loaded by + an enabled option the test does not handle (`allowed`: the tags of its `uses_option`/`requires_option` + markers). Pins an enabled option ties to a channel by a jumper resolve only for the tags in `allowed`.""" - def __init__(self, wiring: Wiring, board: BoardConfig) -> None: + def __init__(self, wiring: Wiring, board: BoardConfig, allowed: Iterable[str] = ()) -> None: self.wiring = wiring self.board = board + self.allowed = frozenset(allowed) - def _found(self, value: int | None, what: str) -> int: - if value is None: - pytest.skip(f"{what} is not wired in wiring set(s) {', '.join(self.wiring.sets) or '(none)'}") - return value + def _blocked(self, pins: Iterable[str]) -> str | None: + for pin in pins: + tags = self.wiring.blocking(pin, self.allowed) + if tags: + return f'pin {pin} loaded by --with {tags[0]} (mark the test uses_option("{tags[0]}") if it handles that wiring)' + return None - def dio(self, pin: str | None = None, role: str | None = None) -> int: + def _lookup(self, kind: ChannelKind, pin: str | None, role: str | None) -> tuple[Connection | None, str | None]: + """The connection and, when the test must leave its pins alone, why.""" resolved = None if pin is None else self.board.resolve_pin(pin) - return self._found(self.wiring.dio(resolved, role), f"DIO for {pin or role}") + connection = self.wiring.connection(kind, resolved, role, self.allowed) + if connection is None: + return None, None + pins = ([resolved] if resolved is not None else []) + list(connection.pins) + return connection, self._blocked(pins) + + def _required(self, kind: ChannelKind, pin: str | None, role: str | None, what: str) -> int: + connection, blocked = self._lookup(kind, pin, role) + if connection is None: + pytest.skip(f"{what} for {pin or role} is not wired in wiring set(s) {', '.join(self.wiring.sets) or '(none)'}") + if blocked is not None: + pytest.skip(blocked) + assert connection is not None + return connection.channel + + def _optional(self, kind: ChannelKind, pin: str | None, role: str | None) -> int | None: + connection, blocked = self._lookup(kind, pin, role) + return None if connection is None or blocked is not None else connection.channel + + def dio(self, pin: str | None = None, role: str | None = None) -> int: + return self._required("dio", pin, role, "DIO") def wavegen(self, pin: str | None = None, role: str | None = None) -> int: - resolved = None if pin is None else self.board.resolve_pin(pin) - return self._found(self.wiring.wavegen(resolved, role), f"wavegen for {pin or role}") + return self._required("wavegen", pin, role, "wavegen") def scope(self, pin: str | None = None, role: str | None = None) -> int: - resolved = None if pin is None else self.board.resolve_pin(pin) - return self._found(self.wiring.scope(resolved, role), f"scope for {pin or role}") + return self._required("scope", pin, role, "scope") def optional_scope(self, pin: str) -> int | None: - return self.wiring.scope(self.board.resolve_pin(pin)) + return self._optional("scope", pin, None) def optional_dio(self, pin: str | None = None, role: str | None = None) -> int | None: - resolved = None if pin is None else self.board.resolve_pin(pin) - return self.wiring.dio(resolved, role) + return self._optional("dio", pin, role) + + def unloaded(self, *pins: str) -> None: + """Skip when an enabled option the test does not handle loads one of `pins` (for pins used without an AD3 + channel).""" + blocked = self._blocked(self.board.resolve_pin(pin) for pin in pins) + if blocked is not None: + pytest.skip(blocked) def tag(self, tag: str) -> None: if not self.wiring.has(tag): pytest.skip(f"enable with --with {tag}") -@pytest.fixture(scope="session") -def need(wiring: Wiring, board_cfg: BoardConfig) -> Need: - return Need(wiring, board_cfg) +@pytest.fixture +def need(request: pytest.FixtureRequest, wiring: Wiring, board_cfg: BoardConfig) -> Need: + return Need(wiring, board_cfg, option_tags(request.node)) @pytest.fixture(scope="session") @@ -334,6 +420,25 @@ def ad3_settings(board_cfg: BoardConfig) -> Ad3Settings: ) +def release_ad3(ad3: Any) -> None: + """Outputs off (`reset_outputs()`), then the weak pulls off and the I2C and SPI protocol engines reset, which + `reset_outputs()` leaves as they are. A device or runtime without them (or the `--fake` API) is fine.""" + ad3.reset_outputs() + with contextlib.suppress(AttributeError, RuntimeError): + ad3.dio.pull() + for name in ("FDwfDigitalI2cReset", "FDwfDigitalSpiReset"): + with contextlib.suppress(AttributeError, RuntimeError): + getattr(ad3.api, name)(ad3.handle) + + +@pytest.fixture +def ad3_released(request: pytest.FixtureRequest) -> None: + """The AD3 outputs and pulls off before the test, so the pins see only the board and its wiring; without + `--no-ad3` this needs the AD3 (the test skips when none is found).""" + if not request.config.getoption("--no-ad3"): + release_ad3(request.getfixturevalue("ad3")) + + @pytest.fixture(autouse=True) def _hil_isolation(request: pytest.FixtureRequest) -> Iterator[None]: """Clear stale events before a HIL test; afterwards close what it opened and reset the AD3 outputs.""" @@ -347,7 +452,7 @@ def _hil_isolation(request: pytest.FixtureRequest) -> Iterator[None]: boots = firmware.terminal.drain_events("boot") failures = firmware.close_all() if "ad3" in request.fixturenames: - request.getfixturevalue("ad3").reset_outputs() + release_ad3(request.getfixturevalue("ad3")) if boots and request.node.get_closest_marker("resets_board") is None: pytest.fail(f"unexpected reset during the test: {boots[-1].raw}") if failures: diff --git a/validation/host/tests/hil/test_qei.py b/validation/host/tests/hil/test_qei.py index d42f19ac..8bd0515d 100644 --- a/validation/host/tests/hil/test_qei.py +++ b/validation/host/tests/hil/test_qei.py @@ -209,7 +209,7 @@ def test_lptim_errors(fw, qei, instance): cases = [ ({**pins, "filter": 3}, "range"), ({**pins, "res": instance["max_res"] + 1}, "range"), - ({**pins, "cap": "ab"}, "unsupported"), + ({**pins, "cap": "a"}, "unsupported"), ({**pins, "offset": 0}, "unsupported"), ({**pins, "invb": 0}, "unsupported"), ({"lp": True}, "usage"), diff --git a/validation/host/tests/hil/test_system.py b/validation/host/tests/hil/test_system.py index 19601b33..b36c7ac0 100644 --- a/validation/host/tests/hil/test_system.py +++ b/validation/host/tests/hil/test_system.py @@ -43,9 +43,10 @@ def test_aliases_are_generic(board_cfg): def test_every_alias_is_accepted_as_pin(fw, board_cfg): - """Each alias names its pin in commands; the terminal pins stay reserved.""" + """Each alias names its pin in commands; the terminal pins and the debug LED stay reserved.""" + reserved = {*board_cfg.terminal.pins, board_cfg.resolve_pin(board_cfg.param("system.debug_led"))} for alias, pin in board_cfg.pins.items(): - if pin in board_cfg.terminal.pins: + if pin in reserved: assert reason_of(fw, f"gpio.cfg {alias} in") == "busy", alias continue fw.command("gpio.cfg", alias, "in") diff --git a/validation/host/tests/hil/test_wiring.py b/validation/host/tests/hil/test_wiring.py new file mode 100644 index 00000000..6b9f2fbd --- /dev/null +++ b/validation/host/tests/hil/test_wiring.py @@ -0,0 +1,89 @@ +"""Self-check of the bench wiring with GPIO commands only: run it first after wiring the board +(`pytest tests/hil/test_wiring.py --port ... --wiring-set [--with ...]`). + +With the AD3 outputs and pulls off, the MCU drives the key pin of each `jumpered` entry of an enabled option and +reads the listed pins against the opposite MCU pull (continuity), reads the `pullups` pins against the MCU +pull-down (external pull-ups on a live 3V3 rail), looks for the jumpers of offered options that are not enabled +(fitted wiring the run does not know about), and checks that the pins of `tests.wiring.undriven` follow both MCU +pulls (nothing else, such as an ST-LINK line through a solder bridge, drives them). Skipped with `--fake`: the +fake firmware has no wiring. +""" + +from __future__ import annotations + +import pytest + +from hal_st_validation.config import Wiring +from hal_st_validation.firmware import Firmware + + +@pytest.fixture(autouse=True) +def _physical_wiring(request: pytest.FixtureRequest) -> None: + if request.config.getoption("--fake"): + pytest.skip("checks the physical wiring: nothing to check with --fake") + request.getfixturevalue("ad3_released") + + +def level(fw: Firmware, pin: str, pull: str) -> int: + fw.gpio.cfg(pin, "in", pull=pull) + try: + return fw.gpio.get(pin) + finally: + fw.gpio.release(pin) + + +def follows(fw: Firmware, key: str, pin: str) -> bool: + """`pin` reads 1 against its pull-down while `key` drives high, and 0 against its pull-up while `key` drives low.""" + fw.gpio.cfg(key, "out") + try: + results = [] + for driven, pull in ((1, "down"), (0, "up")): + fw.gpio.set(key, driven) + results.append(level(fw, pin, pull) == driven) + return all(results) + finally: + fw.gpio.release(key) + + +def jumper_pairs(wiring: Wiring, tag: str) -> list[tuple[str, str]]: + return [(key, pin) for key, pins in wiring.options[tag].jumpered.items() for pin in pins] + + +@pytest.mark.wiring_options("tag") +def test_option_continuity(fw, wiring, tag): + pairs = jumper_pairs(wiring, tag) + if not pairs: + pytest.skip(f"--with {tag} declares no jumpered pins") + broken = [f"{key}-{pin}" for key, pin in pairs if not follows(fw, key, pin)] + assert not broken, f"--with {tag}: no continuity on {', '.join(broken)}: check those jumpers" + + +@pytest.mark.wiring_options("tag") +def test_option_pullups(fw, wiring, tag): + pullups = wiring.options[tag].pullups + if not pullups: + pytest.skip(f"--with {tag} declares no pull-ups") + low = [pin for pin in pullups if level(fw, pin, "down") != 1] + assert not low, f"--with {tag}: {', '.join(low)} read 0 against the MCU pull-down: no pull-up to a live 3V3 rail" + + +@pytest.mark.wiring_options("tag", enabled=False) +def test_undeclared_options(fw, wiring, tag): + pairs = jumper_pairs(wiring, tag) + if not pairs: + pytest.skip(f"--with {tag} declares no jumpered pins to look for") + fitted = [f"{key}-{pin}" for key, pin in pairs if follows(fw, key, pin)] + assert not fitted, f"the wiring of --with {tag} is fitted ({', '.join(fitted)}): pass --with {tag} or remove the wiring" + + +@pytest.mark.board_params("pin", "wiring.undriven") +def test_no_foreign_drivers(fw, wiring, board_cfg, pin): + resolved = board_cfg.resolve_pin(pin) + loads = wiring.loaded(resolved) + if loads: + pytest.skip(f"pin {resolved} loaded by --with {loads[0]}") + levels = (level(fw, resolved, "up"), level(fw, resolved, "down")) + assert levels == (1, 0), ( + f"{resolved} reads {levels[0]} against the MCU pull-up and {levels[1]} against the pull-down: something drives it " + "(a solder bridge to an ST-LINK line, a jumper or an AD3 output)" + ) diff --git a/validation/host/tests/unit/test_config.py b/validation/host/tests/unit/test_config.py index 99c43d41..1a32b30b 100644 --- a/validation/host/tests/unit/test_config.py +++ b/validation/host/tests/unit/test_config.py @@ -208,11 +208,11 @@ def test_matrices_load(name): @pytest.mark.parametrize("name", BOARDS) def test_unsupported_commands_cover_the_protocol(name): - """`tests.unsupported.commands` names every command PROTOCOL.md lists as unavailable.""" + """`tests.unsupported.commands` names every command PROTOCOL.md lists as unavailable on the board's MCU.""" from hal_st_validation.fake_firmware import UNSUPPORTED_COMMANDS board = load_board(name) - assert {line.split()[0] for line in board.param("unsupported.commands")} == set(UNSUPPORTED_COMMANDS) + assert {line.split()[0] for line in board.param("unsupported.commands")} == set(UNSUPPORTED_COMMANDS[board.family]) @pytest.mark.parametrize("name", BOARDS) @@ -295,7 +295,9 @@ def test_optional_connections_and_roles(): "board": "x", "family": "stm32wb55", "pins": {"gpio0": "PC6"}, - "wiring_sets": {"s": {"dio": {0: "gpio0", 1: {"pin": "PB0", "role": "probe", "requires": "extra"}}}}, + "wiring_sets": { + "s": {"options": {"extra": "a probe"}, "dio": {0: "gpio0", 1: {"pin": "PB0", "role": "probe", "requires": "extra"}}} + }, } board = parse_board(raw) assert board.wiring(["s"]).dio(role="probe") is None @@ -318,7 +320,7 @@ def test_params_and_overrides(): assert board.param("new.value") == 1.5 with pytest.raises(ConfigError): board.apply_overrides(["novalue"]) - assert board.aliases_of("PA8") == ["tim1ch1", "qei1a"] + assert board.aliases_of("PA8")[:2] == ["tim1ch1", "qei1a"], "in table order (PA8 is also mco)" assert load_board("nucleo_wb55rg").matrix("pwm.waveform")["freq"] != [20000], "overrides stay in one BoardConfig" diff --git a/validation/host/tests/unit/test_config_options.py b/validation/host/tests/unit/test_config_options.py new file mode 100644 index 00000000..3181aac3 --- /dev/null +++ b/validation/host/tests/unit/test_config_options.py @@ -0,0 +1,221 @@ +"""Wiring options (`--with`) as mappings, their validation and `--board-extra` merging; inline boards only.""" + +import textwrap + +import pytest +import yaml + +from hal_st_validation.config import ConfigError, WiringOption, load_board, merge_board, parse_board + +BOARD = """ +board: x +family: stm32wb55 +pins: + spi1mosi: PA7 + spi1miso: PA6 +wiring_sets: + one: + options: + loopback: + description: jumper PA7 to PA6 + jumpered: {spi1mosi: [spi1miso]} + loads: [PA6, PA7] + excludes: [spiloop] + spiloop: + jumpered: {PA5: [PB13], PA6: [PB14]} + loads: [PA5, PA6, PB13, PB14] + excludes: [loopback] + i2c: + jumpered: {PB8: [PC0], PB9: [PC1]} + loads: [PB8, PB9, PC0, PC1] + pullups: [PB8, PB9, PC0, PC1] + plain: a described option + dio: + 0: PA5 + 1: spi1mosi + 3: PB8 + 5: PB9 + 7: spi1miso + scope: + 1: {pin: PB8, requires: i2c} + two: + options: + loopback: jumper D11 to D12 + extra: + jumpered: {PB0: [PB1]} + dio: + 0: PA5 + 2: PB0 +""" + + +def board(text=BOARD): + return parse_board(yaml.safe_load(text)) + + +def test_options_parse_as_mappings_or_descriptions(): + options = board().wiring_sets["one"].options + assert options["loopback"] == WiringOption( + "loopback", "jumper PA7 to PA6", {"PA7": ("PA6",)}, ("PA6", "PA7"), (), frozenset({"spiloop"}) + ), "aliases resolve to pins" + assert options["i2c"].pullups == ("PB8", "PB9", "PC0", "PC1") + assert options["plain"] == WiringOption("plain", "a described option") + assert board().wiring_sets["two"].options["loopback"].description == "jumper D11 to D12" + + +def test_enabled_options_keep_their_jumpers_apart(): + wiring = board().wiring(["one"], ["i2c"]) + assert set(wiring.options) == {"loopback", "spiloop", "i2c", "plain"} + assert wiring.option_jumpers == {"i2c": {"PB8": ("PC0",), "PB9": ("PC1",)}} + assert all(connection.pins in (("PA5",), ("PA7",), ("PB8",), ("PB9",), ("PA6",)) for connection in wiring.connections) + assert wiring.scope("PB8") == 1, "the connection that requires i2c is enabled" + assert board().wiring(["one"]).scope("PB8") is None + + +def test_loaded_pins_are_those_of_enabled_options(): + wiring = board().wiring(["one"], ["i2c", "plain"]) + assert wiring.loaded("PC0") == ("i2c",) + assert wiring.loaded("PA6") == (), "loopback is offered, not enabled" + assert wiring.blocking("PC0") == ("i2c",) + assert wiring.blocking("PC0", {"i2c"}) == () + assert board().wiring(["one"], ["loopback"]).loaded("PA7") == ("loopback",) + + +def test_jumpered_pins_resolve_only_for_the_tag(): + wiring = board().wiring(["one"], ["i2c", "spiloop"]) + assert wiring.dio("PC0") is None + assert wiring.dio("PC0", allowed={"spiloop"}) is None + assert wiring.dio("PC0", allowed={"i2c"}) == 3 + assert wiring.dio("PC1", allowed=["i2c"]) == 5 + assert wiring.dio("PB14", allowed={"spiloop"}) == 7 + assert wiring.dio("PA6", allowed={"spiloop"}) == 7, "a pin's own channel comes first" + assert wiring.jumpered_to("PB13", {"spiloop"}) == {"PA5"} + assert board().wiring(["one"]).dio("PC0", allowed={"i2c"}) is None, "only enabled options tie pins" + + +def test_options_of_several_sets_merge(): + text = BOARD.replace( + " two:\n options:\n loopback: jumper D11 to D12\n", " two:\n options:\n loopback: {loads: [PB0]}\n" + ) + raw = yaml.safe_load(text) + raw["wiring_sets"]["two"]["dio"] = {0: "PA5"} + wiring = parse_board(raw).wiring(["one", "two"], ["loopback"]) + assert wiring.options["loopback"].loads == ("PA6", "PA7", "PB0") + assert wiring.options["loopback"].description == "jumper PA7 to PA6" + assert "--with loopback: PA7-PA6; loads PA6, PA7, PB0" in wiring.describe() + + +@pytest.mark.parametrize( + ("names", "enabled", "message"), + [ + (["one"], ["nosuch"], "offer no --with nosuch"), + (["two"], ["i2c"], "offered: extra, loopback"), + ([], ["i2c"], "needs the --wiring-set"), + (["one"], ["loopback", "spiloop"], "exclude each other"), + (["nosuch"], [], "unknown wiring set"), + ], +) +def test_invalid_tag_selections(names, enabled, message): + with pytest.raises(ConfigError, match=message): + board().wiring(names, enabled) + + +def test_valid_tag_selections(): + assert board().wiring([], []).enabled == frozenset() + assert board().wiring(["one"], ["i2c", "spiloop", "plain"]).has("spiloop") + assert board().wiring(["two"], ["extra"]).dio("PB1", allowed={"extra"}) == 2 + + +@pytest.mark.parametrize( + "option", + [ + "{jumpered: [PA5]}", + "{jumpered: {PA5: PB13}}", + "{jumpered: {PA5: [PZ1]}}", + "{loads: PA5}", + "{pullups: [nosuchalias]}", + "{excludes: spiloop}", + "{wires: [PA5]}", + "[PA5]", + ], +) +def test_invalid_options(option): + raw = yaml.safe_load(BOARD) + raw["wiring_sets"]["two"]["options"]["extra"] = yaml.safe_load(option) + with pytest.raises(ConfigError): + parse_board(raw) + + +def test_requires_names_an_offered_option(): + raw = yaml.safe_load(BOARD) + raw["wiring_sets"]["two"]["dio"][2] = {"pin": "PB0", "requires": "i2c"} + with pytest.raises(ConfigError, match="requires option 'i2c'"): + parse_board(raw) + + +def test_merge_board(): + base = {"a": {"b": [1, 2], "c": 1, "d": {"e": 1}}, "f": "x", "g": [1]} + extra = {"a": {"b": [3], "c": 2, "d": "flat", "h": {"i!": [9]}}, "f": {"now": "mapping"}, "g!": [5], "j": None} + assert merge_board(base, extra) == { + "a": {"b": [1, 2, 3], "c": 2, "d": "flat", "h": {"i": [9]}}, + "f": {"now": "mapping"}, + "g": [5], + "j": None, + } + assert base == {"a": {"b": [1, 2], "c": 1, "d": {"e": 1}}, "f": "x", "g": [1]}, "the base is not changed" + assert merge_board({"x": {"y": [1]}}, {"x": {"y!": [2]}}) == {"x": {"y": [2]}} + assert merge_board({1: "a"}, {1: "b", 2: "c"}) == {1: "b", 2: "c"}, "YAML integer keys (DIO numbers) merge too" + + +def write(path, text): + path.write_text(textwrap.dedent(text), encoding="utf-8") + return path + + +def test_load_board_with_extras(tmp_path): + base = write(tmp_path / "x.yaml", BOARD) + first = write( + tmp_path / "first.yaml", + """ + wiring_sets: + one: + options: + plain: + loads: [PB0] + tests: + gpio: + loop_pins: [PA5] + limit_pins: [PA8] + """, + ) + second = write( + tmp_path / "second.yaml", + """ + tests: + gpio: + loop_pins: [PB0] + limit_pins!: [PB8] + """, + ) + empty = write(tmp_path / "empty.yaml", "") + loaded = load_board(base, [first, str(second), empty]) + assert loaded.extras == (first, second, empty) + assert loaded.param("gpio.loop_pins") == ["PA5", "PB0"] + assert loaded.param("gpio.limit_pins") == ["PB8"] + assert loaded.wiring(["one"], ["plain"]).loaded("PB0") == ("plain",), "a description became a mapping" + assert load_board(base).wiring_sets["one"].options["plain"].loads == () + with pytest.raises(ConfigError, match="no board extra file"): + load_board(base, [tmp_path / "missing.yaml"]) + with pytest.raises(ConfigError, match="must be a mapping"): + load_board(base, [write(tmp_path / "list.yaml", "- a\n")]) + with pytest.raises(ConfigError): + load_board(base, [write(tmp_path / "bad.yaml", "a: [\n")]) + + +@pytest.mark.parametrize("name", ["nucleo_wb55rg", "nucleo_wba55cg"]) +def test_board_files_offer_their_options(name): + """Each option of the board files parses; every option a set offers can be enabled on its own.""" + loaded = load_board(name) + for set_name, wiring_set in loaded.wiring_sets.items(): + for tag in wiring_set.options: + assert loaded.wiring([set_name], [tag]).has(tag) diff --git a/validation/host/tests/unit/test_fake_firmware.py b/validation/host/tests/unit/test_fake_firmware.py index 2472fe24..1b2e88a7 100644 --- a/validation/host/tests/unit/test_fake_firmware.py +++ b/validation/host/tests/unit/test_fake_firmware.py @@ -125,13 +125,27 @@ def test_sync_clears_partial_input(): assert firmware.received[-1] == "ping" -@pytest.mark.parametrize("command", UNSUPPORTED_COMMANDS) -def test_unsupported_groups(command): - terminal, _ = make_terminal() +@pytest.mark.parametrize(("family", "command"), [(family, command) for family, names in UNSUPPORTED_COMMANDS.items() for command in names]) +def test_unsupported_groups(family, command): + terminal, _ = make_terminal(family=family) assert reason(terminal, command) == "unsupported" assert reason(terminal, f"{command} 0 key=1") == "unsupported" +def test_unsupported_names_follow_the_mcu(): + """HSEM, QUADSPI, the wireless-stack flash steps, MCO and HSI48 exist on the WB55 only, LPTIM PWM on the WBA55 + only; EMIL's EEPROM group is served on both.""" + assert {"hsem.take", "qspi.open", "flash.stack", "clock.mco", "clock.hsi48"} <= set(UNSUPPORTED_COMMANDS["stm32wba55"]) + assert {"lptpwm.open", "lptpwm.close"} <= set(UNSUPPORTED_COMMANDS["stm32wb55"]) + common = set(UNSUPPORTED_COMMANDS["stm32wb55"]) & set(UNSUPPORTED_COMMANDS["stm32wba55"]) + assert {name.split(".")[0] for name in common} == {"comp", "can", "eth"} + assert not any(name.startswith("eeprom.") for names in UNSUPPORTED_COMMANDS.values() for name in names) + terminal, _ = make_terminal() + assert reason(terminal, "hsem.take 0 procid=1") != "unsupported" + terminal, _ = wba() + assert reason(terminal, "lptpwm.close 1") != "unsupported" + + # board profiles against the board files @@ -156,10 +170,11 @@ def test_board_file_system_lines(family): @pytest.mark.parametrize("family", sorted(BOARDS)) def test_every_alias_is_a_pin(family): + """Every alias names a pin; the reserved ones (terminal, debug LED) answer busy.""" board = load_board(BOARDS[family]) - terminal, _ = make_terminal(family=family) + terminal, firmware = make_terminal(family=family) for alias, pin in board.pins.items(): - expected = "busy" if pin in board.terminal.pins else "ok" + expected = "busy" if pin in firmware.reserved() else "ok" assert reason(terminal, f"gpio.cfg {alias} in") == expected, alias if expected == "ok": assert reason(terminal, f"gpio.get {pin}") == "ok" @@ -618,7 +633,7 @@ def test_spi_wba55_instances_and_clock(): assert reason(terminal, "spi.open 1 clk=PB4 mosi=PA15 miso=PB3 baud=3000000") == "ok" assert firmware.opened[("spi", "1")]["clock"] == 1_562_500 terminal.command("spi.close 1") - assert reason(terminal, "spi.open 3 clk=PA0 mosi=PB8 miso=PB9") == "busy", "PB8 is the debug LED" + assert reason(terminal, "spi.open 3 clk=PA0 mosi=PB8 miso=PB9") == "ok", "PB8 is free since the debug LED moved to PA9" def test_spi_transfers(): @@ -780,7 +795,13 @@ def test_adc_pins_are_shared_only_with_analog_users(): ("qei.open 1 lp=1 a=PC0 b=PC2 invb=1", "unsupported"), ("qei.open 1 lp=1 a=PC0 b=PC2 offset=0", "unsupported"), ("qei.open 1 lp=1 a=PC0 b=PC2 invb=0", "unsupported"), - ("qei.open 1 lp=1 a=PC0 b=PC2 cap=ab", "unsupported"), + ("qei.open 1 lp=1 a=PC0 b=PC2 cap=ab", "ok"), + ("qei.open 1 lp=1 a=PC0 b=PC2 cap=rise", "ok"), + ("qei.open 1 lp=1 a=PC0 b=PC2 cap=fall", "ok"), + ("qei.open 1 lp=1 a=PC0 b=PC2 cap=b", "unsupported"), + ("qei.open 1 lp=1 a=PC0 b=PC2 cap=x", "usage"), + ("qei.open 2 cap=rise", "usage"), + ("qei.open 2 lp=1 a=PB1 b=PC2", "range"), ("qei.open 0 lp=1 a=PC0 b=PC2", "range"), ("qei.open 1 lp=1 a=PC0 b=PC2 inva=1 filter=8", "ok"), ("qei.open 1 lp=1 a=PC0 b=PC2 filter=3", "range"), @@ -800,9 +821,15 @@ def test_qei_open_validation_wb55(line, expected): ("qei.open 3 a=PA10 b=PA1", "ok"), ("qei.open 3", "usage"), ("qei.open 2 a=PA5 b=PA8", "busy"), - ("qei.open 1 lp=1 a=PA0 b=PB3", "unsupported"), - ("qei.open 0 lp=1", "unsupported"), - ("qei.open 17 lp=1 filter=3", "unsupported"), + ("qei.open 1 lp=1 a=PA0 b=PB3", "ok"), + ("qei.open 2 lp=1 a=PB9 b=PB0", "ok"), + ("qei.open 2 lp=1 a=lptim2in1 b=lptim2in2 cap=fall filter=8", "ok"), + ("qei.open 1 lp=1 a=PA0 b=PB3 cap=a", "unsupported"), + ("qei.open 1 lp=1 a=PB9 b=PB0", "pin"), + ("qei.open 1 lp=1", "usage"), + ("qei.open 0 lp=1", "range"), + ("qei.open 3 lp=1", "range"), + ("qei.open 17 lp=1 filter=3", "range"), ("qei.open 4", "range"), ], ) @@ -979,3 +1006,285 @@ def test_argument_errors_come_before_busy(): ] for line, expected in cases: assert reason(terminal, line) == expected, line + + +# board profiles of the coverage extensions + + +def test_wba55_bonding_and_debug_led(): + """PA3, PA4, PB10, PB11 and PB13 are not bonded on the UFQFPN48; the debug LED is the green LD2 on PA9.""" + terminal, firmware = wba() + for pin in ("PA3", "PA4", "PB10", "PB11", "PB13"): + assert reason(terminal, f"gpio.cfg {pin} in") == "pin", pin + assert reason(terminal, "gpio.cfg PA9 in") == "busy" + assert reason(terminal, "gpio.cfg led1 in") == "busy", "led1 names the debug LED pin" + assert reason(terminal, "gpio.cfg PB8 in") == "ok" + assert firmware.spec.debug_led == "PA9" + + +@pytest.mark.parametrize( + ("family", "function", "instance", "pin"), + [ + ("stm32wb55", "i2cScl", 1, "PB8"), + ("stm32wb55", "i2cSda", 1, "PB9"), + ("stm32wb55", "i2cScl", 3, "PC0"), + ("stm32wb55", "i2cSda", 3, "PC1"), + ("stm32wb55", "i2cScl", 3, "PA7"), + ("stm32wb55", "i2cSda", 3, "PB4"), + ("stm32wb55", "spiSlaveSelect", 1, "PA4"), + ("stm32wb55", "spiSlaveSelect", 2, "PB12"), + ("stm32wb55", "quadSpiClock", 0, "PA3"), + ("stm32wb55", "quadSpiSlaveSelect", 0, "PA2"), + ("stm32wb55", "quadSpiData0", 0, "PB9"), + ("stm32wb55", "quadSpiData3", 0, "PA6"), + ("stm32wb55", "lpTimerInput1", 2, "PB1"), + ("stm32wba55", "i2cScl", 1, "PB2"), + ("stm32wba55", "i2cSda", 1, "PB1"), + ("stm32wba55", "i2cScl", 1, "PA15"), + ("stm32wba55", "i2cSda", 1, "PB3"), + ("stm32wba55", "i2cScl", 3, "PA6"), + ("stm32wba55", "i2cSda", 3, "PA7"), + ("stm32wba55", "spiSlaveSelect", 3, "PA5"), + ("stm32wba55", "lpTimerChannel2", 1, "PA15"), + ("stm32wba55", "lpTimerChannel1", 2, "PA11"), + ("stm32wba55", "lpTimerChannel2", 2, "PA1"), + ("stm32wba55", "lpTimerInput1", 1, "PA0"), + ("stm32wba55", "lpTimerInput2", 2, "PB0"), + ("stm32wba55", "timerChannel1N", 16, "PB8"), + ], +) +def test_pin_functions_of_the_new_groups(family, function, instance, pin): + _, firmware = make_terminal(family=family) + assert firmware.supports(function, instance, pin) + + +def test_unbonded_table_pins_offer_nothing(): + _, firmware = wba() + assert not firmware.supports("lpTimerChannel1", 1, "PB11"), "LPTIM1 CH1 is on PB11 only, which is not bonded" + assert not firmware.supports("timerChannel1N", 16, "PA3") + assert firmware.first_function_pin("lpuartTx", 1) == "PA2" + + +@pytest.mark.parametrize(("family", "pins"), [("stm32wb55", WB55_PINS), ("stm32wba55", WBA55_PINS)]) +def test_new_aliases_resolve(family, pins): + terminal, _ = make_terminal(family=family) + for alias in ("i2c1scl", "i2c1sda", "i2c3scl", "i2c3sda", "spi1nss"): + assert reason(terminal, f"gpio.cfg {alias} in") == "ok", alias + assert reason(terminal, f"gpio.release {pins[alias]}") == "ok" + + +# spi extensions (D.4) + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 bits=8", "ok"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 bits=12", "unsupported"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 bits=12 dma=1", "ok"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 bits=4 dma=1", "ok"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 bits=16 dma=1", "ok"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 bits=16 sync=1", "unsupported"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 bits=3 dma=1", "range"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 bits=17 dma=1", "range"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 bits=wide", "usage"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 lsb=1", "ok"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 lsb=2", "range"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 nss=PA4", "ok"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 nss=spi1nss", "ok"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 nss=PA15", "ok"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 nss=PA4 cs=PB0", "usage"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 nss=PB12", "pin"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 nss=PB12 bits=12", "pin"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 nss=PA16", "pin"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 nss=PA5", "pin"), + ("spi.open 2 clk=spi2clk mosi=spi2mosi miso=spi2miso nss=spi2nss bits=16 dma=1 lsb=1", "ok"), + ], +) +def test_spi_open_extensions_wb55(line, expected): + terminal, _ = make_terminal() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("spi.open 3 clk=PA0 mosi=PB8 miso=PB9 bits=16 dma=1", "ok"), + ("spi.open 3 clk=PA0 mosi=PB8 miso=PB9 bits=8 dma=1", "ok"), + ("spi.open 3 clk=PA0 mosi=PB8 miso=PB9 bits=12 dma=1", "unsupported"), + ("spi.open 3 clk=PA0 mosi=PB8 miso=PB9 bits=4 dma=1", "unsupported"), + ("spi.open 3 clk=spi3clk mosi=spi3mosi miso=spi3miso nss=spi3nss", "ok"), + ("spi.open 3 clk=PA0 mosi=PB8 miso=PB9 nss=PA12", "pin"), + ("spi.open 1 clk=PB4 mosi=PA15 miso=PB3 bits=12 dma=1", "ok"), + ("spi.open 1 clk=PB4 mosi=PA15 miso=PB3 nss=PA12 lsb=1", "ok"), + ], +) +def test_spi_open_extensions_wba55(line, expected): + terminal, _ = wba() + assert reason(terminal, line) == expected + + +def test_spi_holds_its_peripheral(): + terminal, firmware = make_terminal() + terminal.command("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 nss=PA4 bits=16 dma=1 lsb=1") + state = firmware.opened[("spi", "1")] + assert (state["bits"], state["lsb"], state["nss"]) == (16, True, "PA4") + assert firmware.resources == {("spi", 1): ("spi", "1")} + assert reason(terminal, "gpio.cfg spi1nss in") == "busy" + terminal.command("spi.close 1") + assert firmware.resources == {} + terminal.command("gpio.cfg PA15 in") + assert reason(terminal, "spi.open 1 clk=PA5 mosi=PA7 miso=PA6 nss=PA15") == "busy" + + +# pwm extensions (D.10) + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("pwm.open 1 channels=1 mode=edgedown", "ok"), + ("pwm.open 1 channels=1 mode=centerup freq=1000", "ok"), + ("pwm.open 1 channels=1 mode=centerboth freq=1000", "ok"), + ("pwm.open 1 channels=1 mode=centerup freq=488", "range"), + ("pwm.open 1 channels=1 mode=edgedown freq=976", "range"), + ("pwm.open 1 channels=1 mode=diagonal", "usage"), + ("pwm.open 16 channels=1 mode=edgedown", "unsupported"), + ("pwm.open 17 channels=1 mode=centerup", "unsupported"), + ("pwm.open 16 channels=1 mode=centerboth", "unsupported"), + ("pwm.open 1 channels=1 preload=0", "ok"), + ("pwm.open 1 channels=1 preload=2", "range"), + ("pwm.open 1 channels=1 brkfilter=3", "usage"), + ("pwm.open 1 channels=1 brkfilter=16", "range"), + ("pwm.open 1 channels=1 brk=PB12 brkfilter=15", "ok"), + ("pwm.open 1 channels=1 brk=PB12 brkfilter=0", "ok"), + ("pwm.open 1 channels=1 brk=PB12 brkfilter=16", "range"), + ("pwm.open 2 channels=1 brk=PB12 brkfilter=2", "unsupported"), + ("pwm.open 1 channels=1 trgo=update", "ok"), + ("pwm.open 1 channels=1 trgo=oc4ref", "ok"), + ("pwm.open 2 channels=1 trgo=oc1", "ok"), + ("pwm.open 1 channels=1 trgo=never", "usage"), + ], +) +def test_pwm_open_extensions(line, expected): + terminal, _ = make_terminal() + assert reason(terminal, line) == expected + + +def test_pwm_open_extensions_keep_their_settings(): + terminal, firmware = make_terminal() + terminal.command("pwm.open 1 channels=1 mode=centerup preload=0 trgo=update brk=PB12 brkfilter=4 freq=1000") + state = firmware.opened[("pwm", "1")] + assert (state["mode"], state["alignment"], state["preload"], state["trgo"], state["brkfilter"]) == ( + "centerup", + "center", + False, + "update", + 4, + ) + assert reason(terminal, "pwm.freq 1 488") == "range", "centre aligned: 2 * ARR ticks per period" + + +# adc trgo (D.10) + + +def test_adc_trgo_runs_on_a_pwm_timer(): + terminal, firmware, clock = timed_terminal() + assert reason(terminal, "adc.open 1 pins=PC3 trgo=2") == "unsupported", "nobody drives TIM2" + assert reason(terminal, "adc.open 1 pins=PC3 trgo=3") == "range" + assert reason(terminal, "adc.open 1 pins=PC3 trgo=16") == "unsupported" + assert reason(terminal, "adc.open 1 pins=PC3 trgo=2 timer=2") == "usage" + assert reason(terminal, "adc.open 1 pins=PC3 trgo=2 rate=100") == "usage" + assert reason(terminal, "adc.open 1 pins=PC3 trgo=x") == "usage" + terminal.command("pwm.open 2 channels=1 freq=100 trgo=update") + assert reason(terminal, "adc.open 1 pins=PC3 trgo=2") == "ok" + assert firmware.timer_owners[2] == ("pwm", "2"), "the adc group does not take the timer" + firmware.adc_codes["PC3"] = 7 + start = clock.now + assert reason(terminal, "adc.measure 1 n=10") == "timeout", "the timer does not run before pwm.duty" + assert clock.now - start == pytest.approx(1.0) + terminal.command("pwm.duty 2 50") + start = clock.now + assert terminal.command("adc.measure 1 n=10").as_ints("samples") == [7] * 10 + reply = "OK samples=" + ",".join(["7"] * 10) + assert clock.now - start == pytest.approx(10 / 100 + expect.terminal_line_time(reply, 921600)) + + +def test_adc_trgo_on_a_timer_another_group_holds_is_busy(): + terminal, _ = make_terminal() + terminal.command("qei.open 1 a=PA8 b=PA9") + assert reason(terminal, "adc.open 1 pins=PC3 trgo=1") == "busy" + assert reason(terminal, "adc.open 1 pins=PB0 trgo=1") == "pin", "argument errors come first" + + +def test_adc_holds_its_peripheral_and_dma_channel(): + terminal, firmware = wba() + terminal.command("adc.open 4 pins=PA7") + assert firmware.resources == {("adc", 4): ("adc", "4"), ("dma1", 7): ("adc", "4")} + terminal.command("adc.close 4") + assert firmware.resources == {} + + +# uart send-only (D.13) + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("uart.open 1 lp=1 sendonly=1", "ok"), + ("uart.open 1 lp=1 tx=PA2 sendonly=1", "ok"), + ("uart.open 1 lp=1 tx=PA2", "usage"), + ("uart.open 1 lp=1 rx=PA3 sendonly=1", "usage"), + ("uart.open 1 lp=1 sendonly=2", "range"), + ("uart.open 1 lp=1 sendonly=1 dma=1", "usage"), + ("uart.open 1 lp=1 sendonly=1 sync=1", "usage"), + ("uart.open 1 lp=1 sendonly=1 duplex=1", "usage"), + ("uart.open 1 lp=1 tx=PA2 sendonly=1 flow=rts rts=PB12", "ok"), + ("uart.open 1 lp=1 tx=PA2 sendonly=1 flow=cts cts=PA6", "unsupported"), + ("uart.open 1 lp=1 tx=PA2 sendonly=1 flow=rtscts rts=PB12 cts=PA6", "unsupported"), + ("uart.open 1 lp=1 tx=PA2 sendonly=1 parity=even", "unsupported"), + ("uart.open 1 lp=1 tx=PA2 sendonly=1 swap=1", "unsupported"), + ("uart.open 1 lp=1 tx=PA3 sendonly=1", "pin"), + ("uart.open 1 sendonly=1", "busy"), + ], +) +def test_uart_sendonly_wb55(line, expected): + terminal, _ = make_terminal() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("uart.open 2 tx=PB0 sendonly=1", "ok"), + ("uart.open 2 tx=usart2tx rts=usart2rts flow=rts sendonly=1", "ok"), + ("uart.open 1 lp=1 tx=PB5 sendonly=1", "unsupported"), + ("uart.open 2 tx=PA11 sendonly=1", "pin"), + ], +) +def test_uart_sendonly_wba55(line, expected): + terminal, _ = wba() + assert reason(terminal, line) == expected + + +def test_uart_sendonly_receives_nothing(): + terminal, firmware = make_terminal() + terminal.command("uart.open 1 lp=1 tx=PA2 sendonly=1") + assert reason(terminal, "gpio.cfg PA3 in") == "ok", "no RX pin is claimed" + firmware.uart_rx[1] += b"\x55" + assert terminal.command("uart.send 1 55").ok + assert terminal.command("uart.recv 1").raw == "OK data=-" + assert reason(terminal, "uart.recv 1 len=0") == "range", "the options are still checked" + + +# encoder on LPTIM (D.9) + + +def test_lp_encoder_holds_its_lptim(): + terminal, firmware = wba() + terminal.command("qei.open 2 lp=1 a=lptim2in1 b=lptim2in2 cap=rise") + assert firmware.resources == {("lpTimer", 2): ("qei", "2")} + assert firmware.opened[("qei", "2")]["cap"] == "rise" + assert reason(terminal, "pwm.open 2 channels=1") == "ok", "LPTIM2 is not TIM2" + terminal.command("qei.close 2") + assert firmware.resources == {} diff --git a/validation/host/tests/unit/test_fake_groups.py b/validation/host/tests/unit/test_fake_groups.py new file mode 100644 index 00000000..7ff787a7 --- /dev/null +++ b/validation/host/tests/unit/test_fake_groups.py @@ -0,0 +1,216 @@ +"""Discovery of the area modules (`fakes/*.py` `FakeGroup`s, `groups/*.py` `GROUPS`) with dummy modules placed in a +temporary directory on the packages' paths, dispatch through the fake firmware and the state the groups share.""" + +import sys + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal + +from hal_st_validation import fakes, groups +from hal_st_validation.fake_firmware import FakeFirmware, FakeSerial +from hal_st_validation.fakes.base import FakeError, FakeGroup +from hal_st_validation.firmware import Firmware + +FAKE_MODULE = """ +from hal_st_validation.fakes.base import FakeGroup, _number, _shape + + +class Dummy(FakeGroup): + prefix = "dummy" + + def boot(self): + self.boots = getattr(self, "boots", 0) + 1 + self.polls = 0 + + def poll(self): + self.polls += 1 + + def cmd_open(self, args, options): + _shape(args, options, 1, 1, ("pin", "spi", "timer")) + index = _number(args[0], 1, 3) + pin = self.fw.pin(options.get("pin")) + resources = [("spi", _number(options["spi"], 1, 3))] if "spi" in options else [] + timer = _number(options["timer"], 1, 17) if "timer" in options else None + self.fw.open_instance(self.prefix, str(index), {"pin": pin}, [pin], timer=timer, resources=resources) + return "OK" + + def cmd_close(self, args, options): + _shape(args, options, 1, 1) + self.fw.find_instance(self.prefix, args[0]) + return self.fw.close_instance(self.prefix, args[0]) + + def cmd_event(self, args, options): + self.fw.event("EVT dummy index=1") + return ["EVT dummy line=2", "OK"] + + +class Qspi(FakeGroup): + prefix = "qspi" + + def cmd_close(self, args, options): + return "OK" + + +class Helper: + prefix = "helper" +""" + +GROUP_MODULE = """ +from hal_st_validation.groups.base import Group + + +class Dummy(Group): + prefix = "dummy" + + def open(self, index, pin=None): + self._cmd("open", index, pin=self._pin(pin)) + self._fw.track(("dummy", index), "dummy.close", index) + + +GROUPS = {"dummy": Dummy} +""" + +MODULES = ("hal_st_validation.fakes.dummy_area", "hal_st_validation.groups.dummy_area", "hal_st_validation.groups.clash_area") + + +@pytest.fixture +def area(tmp_path, monkeypatch): + """Writes `dummy_area.py` modules on the paths of `fakes` and `groups`; returns the group directory for more.""" + fake_dir, group_dir = tmp_path / "fakes", tmp_path / "groups" + fake_dir.mkdir() + group_dir.mkdir() + (fake_dir / "dummy_area.py").write_text(FAKE_MODULE, encoding="utf-8") + (group_dir / "dummy_area.py").write_text(GROUP_MODULE, encoding="utf-8") + monkeypatch.setattr(fakes, "__path__", [*fakes.__path__, str(fake_dir)]) + monkeypatch.setattr(groups, "__path__", [*groups.__path__, str(group_dir)]) + yield group_dir + for name in MODULES: + sys.modules.pop(name, None) + + +def make(family="stm32wb55"): + firmware = FakeFirmware(family=family, style="line") + return FirmwareTerminal(serial=FakeSerial(firmware), timeout=0.5), firmware + + +def reason(terminal, line): + response = terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +def test_discovery(area): + found = {cls.prefix: cls for cls in fakes.discover()} + assert {"dummy", "qspi"} <= set(found) + assert "helper" not in found, "only FakeGroup subclasses count" + assert issubclass(found["dummy"], FakeGroup) + assert "dummy" in groups.discover() + + +def test_without_area_modules_nothing_is_added(): + assert all(cls.__module__.startswith("hal_st_validation.fakes.") for cls in fakes.discover()) + terminal, firmware = make() + assert "dummy" not in firmware.groups + assert reason(terminal, "dummy.open 1") == "usage", "an unknown command" + + +def test_dispatch_and_errors(area): + terminal, firmware = make() + assert isinstance(firmware.group("dummy"), FakeGroup) + assert reason(terminal, "dummy.open 1 pin=PA5") == "ok" + assert firmware.opened[("dummy", "1")] == {"pin": "PA5"} + assert reason(terminal, "dummy.open 4") == "range" + assert reason(terminal, "dummy.open 1 nosuchkey=1") == "usage" + assert reason(terminal, "dummy.nosuch 1") == "usage", "a verb without cmd_" + assert reason(terminal, "dummy.open 2") == "busy", "one instance per group" + assert reason(terminal, "dummy.close 2") == "notopen" + assert reason(terminal, "dummy.close 1") == "ok" + assert firmware.claims == {} + + +def test_claims_are_shared_with_the_built_in_groups(area): + terminal, firmware = make() + terminal.command("dummy.open 1 pin=spi1clk spi=1 timer=2") + assert reason(terminal, "spi.open 1 clk=PA5 mosi=PA7 miso=PA6") == "busy", "PA5 is held" + assert reason(terminal, "pwm.open 2 channels=1") == "busy", "TIM2 is held" + terminal.command("dummy.close 1") + terminal.command("dummy.open 1 spi=1") + assert reason(terminal, "spi.open 1 clk=PA5 mosi=PA7 miso=PA6") == "busy", "SPI1 is held" + assert reason(terminal, "spi.open 2 clk=PB13 mosi=PB15 miso=PB14") == "ok" + terminal.command("dummy.close 1") + assert reason(terminal, "dummy.open 1 spi=2") == "busy", "the spi group holds SPI2" + assert firmware.resources == {("spi", 2): ("spi", "2")} + + +def test_resources(area): + _, firmware = make() + firmware.claim_resource("dma1", 7, ("dummy", "1")) + firmware.claim_resource("dma1", 7, ("dummy", "1")) + with pytest.raises(FakeError) as error: + firmware.claim_resource("dma1", 7, ("adc", "1")) + assert error.value.reason == "busy" + with pytest.raises(ValueError): + firmware.claim_resource("dma3", 1, ("dummy", "1")) + with pytest.raises(ValueError): + firmware.claim_resource("dma1", 16, ("dummy", "1")) + firmware.release(("dummy", "1")) + assert firmware.resources == {} + + +def test_boot_poll_and_events(area): + terminal, firmware = make() + group = firmware.group("dummy") + assert group.boots == 1 + terminal.command("dummy.open 1 pin=PA5 spi=1") + terminal.pump(0.01) + assert group.polls > 0, "reads and writes poll the groups" + terminal.send_nowait("reset") + terminal.wait_boot(0.5) + assert group.boots == 2 + assert firmware.opened == {} and firmware.resources == {} and firmware.claims == {} + assert terminal.command("dummy.event").ok + assert [event["line"] for event in terminal.drain_events("dummy") if "line" in event.values] == ["2"] + + +def test_unsupported_names_come_first(area): + terminal, _ = make("stm32wba55") + assert reason(terminal, "qspi.close 1") == "unsupported" + terminal, _ = make("stm32wb55") + assert reason(terminal, "qspi.close 1") == "ok" + + +def test_firmware_attaches_the_groups(area): + fake = FakeFirmware() + fw = Firmware(FirmwareTerminal(serial=FakeSerial(fake), timeout=0.5), fake.pins) + fw.dummy.open(1, pin="spi1clk") + assert fake.received[-1] == "dummy.open 1 pin=PA5" + assert fw.open_instances == [("dummy", 1)] + assert fw.close_all() == [] + assert fake.received[-1] == "dummy.close 1" + with pytest.raises(FirmwareError): + fw.dummy._cmd("close", 1) + + +def test_group_name_clashes(area): + (area / "clash_area.py").write_text(GROUP_MODULE.replace('{"dummy": Dummy}', '{"gpio": Dummy}'), encoding="utf-8") + with pytest.raises(ValueError, match="clashes with Firmware.gpio"): + Firmware(FirmwareTerminal(serial=FakeSerial(FakeFirmware()), timeout=0.5)) + (area / "clash_area.py").write_text(GROUP_MODULE, encoding="utf-8") + sys.modules.pop("hal_st_validation.groups.clash_area", None) + with pytest.raises(ValueError, match="exported by"): + groups.discover() + (area / "clash_area.py").write_text('GROUPS = {"other": object}\n', encoding="utf-8") + sys.modules.pop("hal_st_validation.groups.clash_area", None) + with pytest.raises(TypeError): + groups.discover() + + +def test_fake_group_prefix_clash(tmp_path, monkeypatch): + (tmp_path / "one_area.py").write_text(FAKE_MODULE, encoding="utf-8") + (tmp_path / "two_area.py").write_text(FAKE_MODULE, encoding="utf-8") + monkeypatch.setattr(fakes, "__path__", [*fakes.__path__, str(tmp_path)]) + try: + with pytest.raises(ValueError, match="both serve 'dummy'"): + fakes.discover() + finally: + for name in ("hal_st_validation.fakes.one_area", "hal_st_validation.fakes.two_area"): + sys.modules.pop(name, None) diff --git a/validation/host/tests/unit/test_firmware.py b/validation/host/tests/unit/test_firmware.py index c404ca01..0d757e8e 100644 --- a/validation/host/tests/unit/test_firmware.py +++ b/validation/host/tests/unit/test_firmware.py @@ -2,7 +2,7 @@ from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS, FakeFirmware, FakeSerial -from hal_st_validation.firmware import Firmware +from hal_st_validation.firmware import Firmware, settle @pytest.fixture @@ -84,6 +84,11 @@ def test_gpio_resolves_aliases_and_tracks_release(fw, fake): ({"channels": [1], "dead": "off", "sync": True}, "pwm.open 1 channels=1 dead=off sync=1"), ({"channels": [1], "inv": True, "invn": False, "idle": True, "idlen": False}, "pwm.open 1 channels=1 inv=1 invn=0 idle=1 idlen=0"), ({"channels": [1], "brk": "tim1bkin", "brkpol": "low", "brkauto": True}, "pwm.open 1 channels=1 brk=PB12 brkpol=low brkauto=1"), + ( + {"channels": [1], "mode": "centerup", "preload": False, "trgo": "update"}, + "pwm.open 1 channels=1 mode=centerup preload=0 trgo=update", + ), + ({"channels": [1], "brk": "tim1bkin", "brkfilter": 7}, "pwm.open 1 channels=1 brk=PB12 brkfilter=7"), ], ) def test_pwm_open_formatting(fw, fake, kwargs, line): @@ -202,3 +207,36 @@ def test_unsupported_groups_through_raw_commands(fw): with pytest.raises(FirmwareError) as error: fw.command("can.open", 1, bitrate=500000) assert error.value.reason == "unsupported" + + +def test_spi_extension_formatting(fw, fake): + fw.spi.open(1, clk="spi1clk", mosi="spi1mosi", miso="spi1miso", nss="spi1nss", dma=True, bits=12, lsb=True) + assert last(fake) == "spi.open 1 clk=PA5 mosi=PA7 miso=PA6 dma=1 bits=12 lsb=1 nss=PA4" + fw.spi.close(1) + fw.spi.open(2, clk="spi2clk", mosi="spi2mosi", miso="spi2miso", lsb=False) + assert last(fake) == "spi.open 2 clk=PB13 mosi=PB15 miso=PB14 lsb=0" + + +def test_adc_trgo_formatting(fw, fake): + fw.pwm.open(2, channels=[1], trgo="update") + fw.adc.open(1, pins=["ain4"], trgo=2) + assert last(fake) == "adc.open 1 pins=PC3 trgo=2" + assert fw.open_instances == [("pwm", 2), ("adc", 1)] + + +def test_uart_sendonly_formatting(fw, fake): + fw.uart.open(1, lp=True, tx="lpuart1tx", sendonly=True) + assert last(fake) == "uart.open 1 lp=1 tx=PA2 sendonly=1" + assert fw.uart.recv(1) == b"" + + +def test_qei_capture_on_edges_formatting(fw, fake): + fw.qei.open(1, lp=True, a="lptim1in1", b="lptim1in2", cap="rise") + assert last(fake) == "qei.open 1 lp=1 a=PC0 b=PC2 cap=rise" + + +def test_begin_sends_without_waiting(fw, fake): + pending = fw.gpio.begin("cfg", "led0", "out", drive="fast") + assert pending.wait().ok + assert last(fake) == "gpio.cfg led0 out drive=fast" + assert settle(fw.gpio.begin("get", "PB0")).as_int("value") == 0 diff --git a/validation/host/tests/unit/test_load_gate.py b/validation/host/tests/unit/test_load_gate.py new file mode 100644 index 00000000..5ac74813 --- /dev/null +++ b/validation/host/tests/unit/test_load_gate.py @@ -0,0 +1,174 @@ +"""The load gate of `need` (tests/conftest.py), run in pytester sessions with the real conftest and an inline board: +a pin an enabled option loads resolves only for tests marked `uses_option`/`requires_option` of that option, and +a pin an option jumpers to a channel resolves only for them; `--with` is validated against the wiring sets.""" + +from pathlib import Path + +import pytest + +pytest_plugins = ["pytester"] + +CONFTEST = Path(__file__).resolve().parents[1] / "conftest.py" + +BOARD = """ +board: gate +family: stm32wb55 +pins: + spi1miso: PA6 +wiring_sets: + one: + options: + loopback: + loads: [PA6, PA7] + excludes: [spiloop] + spiloop: + jumpered: {PA5: [PB13]} + loads: [PA5, PB13] + excludes: [loopback] + i2c: + jumpered: {PB8: [PC0]} + loads: [PB8, PC0] + dio: + 0: PA5 + 1: PA7 + 3: PB8 + 7: spi1miso + scope: + 1: PA6 + two: + dio: + 0: PA5 +""" + +TESTS = """ +import pytest + + +def test_unmarked_loaded(need): + need.dio("PA6") + + +def test_unmarked_loaded_by_alias(need): + need.dio("spi1miso") + + +@pytest.mark.uses_option("loopback") +def test_marked_loaded(need): + assert need.dio("PA6") == 7 + assert need.scope("PA6") == 1 + + +def test_unloaded(need): + assert need.dio("PA5") == 0 + + +@pytest.mark.uses_option("i2c") +def test_jumpered_with_tag(need): + assert need.dio("PC0") == 3 + + +def test_jumpered_without_tag(need): + need.dio("PC0") + + +def test_optional_lookups_of_loaded_pins(need): + assert need.optional_dio("PA6") is None + assert need.optional_scope("PA6") is None + assert need.optional_dio("PA5") == 0 + + +@pytest.mark.requires_option("loopback") +def test_requires_implies_uses(need): + assert need.dio("PA7") == 1 + + +def test_unloaded_skips_explicitly(need): + need.unloaded("PA5", "spi1miso") + + +@pytest.mark.conflicts_option("loopback") +def test_conflicts(): + pass +""" + +INI = """ +[pytest] +markers = + uses_option(tag): x + requires_option(tag): x + conflicts_option(tag): x +""" + + +@pytest.fixture +def session(pytester): + pytester.makeconftest(CONFTEST.read_text(encoding="utf-8")) + pytester.makeini(INI) + board = pytester.makefile(".yaml", gate=BOARD) + pytester.makepyfile(test_gate=TESTS) + + def run(*options): + return pytester.runpytest_inprocess("--board", str(board), "-p", "no:cacheprovider", "-rs", *options) + + return run + + +def outcomes(result): + reports = result.reprec.getreports("pytest_runtest_logreport") + found = {} + for report in reports: + name = report.nodeid.split("::")[-1] + if report.when == "call" or report.outcome != "passed": + found[name] = (report.outcome, report.longrepr[2] if report.skipped else "") + return found + + +def test_gate_with_options_enabled(session): + result = session("--wiring-set", "one", "--with", "loopback", "--with", "i2c") + found = outcomes(result) + loaded = 'pin PA6 loaded by --with loopback (mark the test uses_option("loopback") if it handles that wiring)' + assert found["test_unmarked_loaded"] == ("skipped", f"Skipped: {loaded}") + assert found["test_unmarked_loaded_by_alias"] == ("skipped", f"Skipped: {loaded}") + assert found["test_marked_loaded"][0] == "passed" + assert found["test_unloaded"][0] == "passed" + assert found["test_jumpered_with_tag"][0] == "passed" + assert found["test_jumpered_without_tag"][0] == "skipped" + assert "DIO for PC0 is not wired" in found["test_jumpered_without_tag"][1] + assert found["test_optional_lookups_of_loaded_pins"][0] == "passed" + assert found["test_requires_implies_uses"][0] == "passed" + assert found["test_unloaded_skips_explicitly"] == ("skipped", f"Skipped: {loaded}") + assert found["test_conflicts"] == ("skipped", "Skipped: disconnect --with loopback") + + +def test_gate_without_options(session): + result = session("--wiring-set", "one") + found = outcomes(result) + assert found["test_unmarked_loaded"][0] == "passed" + assert found["test_marked_loaded"][0] == "passed" + assert found["test_jumpered_with_tag"][0] == "skipped", "PC0 is only reached through the i2c jumpers" + assert found["test_unloaded_skips_explicitly"][0] == "passed" + assert found["test_requires_implies_uses"] == ("skipped", "Skipped: enable with --with loopback") + assert found["test_conflicts"][0] == "passed" + + +@pytest.mark.parametrize( + ("options", "message"), + [ + (["--with", "loopback"], "needs the --wiring-set"), + (["--wiring-set", "two", "--with", "loopback"], "offer no --with loopback"), + (["--wiring-set", "one", "--with", "loopback", "--with", "spiloop"], "exclude each other"), + (["--wiring-set", "nosuch"], "unknown wiring set"), + ], +) +def test_invalid_wiring_is_a_usage_error(session, options, message): + result = session(*options) + assert result.ret == pytest.ExitCode.USAGE_ERROR + assert message in "\n".join(result.errlines) + + +def test_board_extra_from_the_environment(session, pytester, monkeypatch): + extra = pytester.makefile(".yaml", extra="wiring_sets:\n two:\n options:\n loopback: {loads: [PA5]}\n") + monkeypatch.setenv("HAL_ST_BOARD_EXTRA", str(extra)) + pytester.makeconftest(CONFTEST.read_text(encoding="utf-8")) + found = outcomes(session("--wiring-set", "two", "--with", "loopback")) + assert found["test_unloaded"][0] == "skipped", "the extra file made PA5 a loaded pin" diff --git a/validation/host/tests/unit/test_patterns.py b/validation/host/tests/unit/test_patterns.py new file mode 100644 index 00000000..d2a0cd53 --- /dev/null +++ b/validation/host/tests/unit/test_patterns.py @@ -0,0 +1,42 @@ +import pytest + +from hal_st_validation.patterns import PATTERNS, crc32, crc_text, generate + + +def test_reference_vectors(): + """The C.8 vectors the firmware's `Payload.cpp` is checked against.""" + assert generate(4, "inc", 0xFE) == bytes.fromhex("feff0001") + assert generate(8, "prbs", 1) == bytes.fromhex("2101c54fd1d01ab2") + assert crc_text(generate(256)) == "29058c73" + assert crc_text(generate(1024, "prbs", 1)) == "696138f0" + + +def test_patterns(): + assert generate(3, "const", 0x1A5) == b"\xa5\xa5\xa5" + assert generate(300)[255:258] == b"\xff\x00\x01" + assert generate(0, "prbs") == b"" + assert generate(8, "prbs", 0) == generate(8, "prbs", 1), "seed 0 starts the generator at 1" + assert generate(16, "prbs", 0xFFFFFFFF) != generate(16, "prbs", 1) + assert generate(64, "prbs", 7)[:10] == generate(10, "prbs", 7), "a prefix does not depend on the length" + assert set(PATTERNS) == {"inc", "const", "prbs"} + + +def test_prbs_is_xorshift32(): + state, expected = 0x12345678, bytearray() + for _ in range(32): + state ^= (state << 13) & 0xFFFFFFFF + state ^= state >> 17 + state ^= (state << 5) & 0xFFFFFFFF + expected.append(state & 0xFF) + assert generate(32, "prbs", 0x12345678) == bytes(expected) + + +def test_crc_is_the_firmware_crc32(): + assert crc32(b"123456789") == 0xCBF43926, "the CRC-32 check value" + assert crc_text(b"") == "00000000" + + +@pytest.mark.parametrize(("length", "pattern", "seed"), [(-1, "inc", 0), (1, "inc", -1), (1, "inc", 1 << 32), (1, "noise", 0)]) +def test_invalid_arguments(length, pattern, seed): + with pytest.raises(ValueError): + generate(length, pattern, seed) diff --git a/validation/host/tests/unit/test_protocol.py b/validation/host/tests/unit/test_protocol.py index b0ac4d06..c9534068 100644 --- a/validation/host/tests/unit/test_protocol.py +++ b/validation/host/tests/unit/test_protocol.py @@ -63,6 +63,18 @@ def test_pin_parts(): "gpio15", "sw1", "sw3", + "i2c1scl", + "i2c3sda", + "spi2nss", + "spi1nss", + "qspiclk", + "qspincs", + "qspiio0", + "qspiio3", + "lptim1ch2", + "lptim2ch1", + "tim16ch1n", + "mco", ], ) def test_generic_aliases(alias): @@ -97,6 +109,15 @@ def test_generic_aliases(alias): "can0rx", "phasea", "button1", + "i2c0scl", + "i2c4sda", + "i2c1sck", + "spi4nss", + "qspiio4", + "qspics", + "lptim1ch3", + "lptim3ch1", + "mco1", ], ) def test_not_aliases(name): @@ -104,8 +125,9 @@ def test_not_aliases(name): def test_alias_scheme_size(): - """terminal 2, ain 20, timers 17 * (4 + 3 + 1), qei 17 * 3, lptim 4, spi 12, usart 12, lpuart 4, led 8, gpio 16, sw 3.""" - assert len(PIN_ALIASES) == 2 + 20 + 17 * 8 + 17 * 3 + 4 + 12 + 12 + 4 + 8 + 16 + 3 + """terminal 2, ain 20, timers 17 * (4 + 3 + 1), qei 17 * 3, lptim 2 * (2 inputs + 2 channels), spi 3 * 5, i2c 3 * 2, + qspi 6, usart 12, lpuart 4, led 8, gpio 16, sw 3, mco 1.""" + assert len(PIN_ALIASES) == 2 + 20 + 17 * 8 + 17 * 3 + 8 + 15 + 6 + 6 + 12 + 4 + 8 + 16 + 3 + 1 @pytest.mark.parametrize("table", [WB55_PINS, WBA55_PINS]) From 40b6977c23383b12ff21895af4ffaea705cca591 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:22:13 +0000 Subject: [PATCH 38/57] docs(validation): document the I2C, SPI loop and bundle2 wiring options The --with options of the board files become mappings: the pins each option ties together with a jumper (the key is the end the wiring self-check drives), the pins it loads and pulls up, and the options it excludes. The NUCLEO-WB55RG bundle1 gains i2c (I2C1 PB8/PB9 to I2C3 PC0/PC1 with 4.7 kOhm pull-ups and a 24Cxx EEPROM on a breadboard fed from CN6-4/CN6-6) and spiloop (SPI1 to SPI2, PB12 through a breadboard row); the loopback jumper declares PA6/PA7 and excludes spiloop. The NUCLEO-WBA55CG wiring names a source for every header position: Zephyr's Arduino labels, the STM32CubeWBA example readmes for the morpho and Arduino positions, modm-data's transcription of UM3301 Table 8 and the STM32-Sidewalk-SDK README for 3V3/GND (the comment named UM3390, which is not this board's manual). AD3 leads move to the morpho pins so the Arduino sockets stay free for jumpers. A new bundle2 moves DIO8, DIO9, DIO11 and DIO14 to PA7, PA6, PB8 and PA0 for SPI3, TIM2 CH3/CH4, TIM16 CH1N and the LPTIM1 encoder, and offers i2c (I2C1 PB2/PB1 to I2C3 PA6/PA7, scopes in the bus rows for the rise time) and spiloop (SPI1 to SPI3). The board notes list the solder bridges that can tie ST-LINK lines to PA0, PB9, PA10, PB5 and PB15 (tests.wiring.undriven), the LD3 load on PB8 and the green LD2 on PA9, the debug LED, dark unless SB28 is closed; the E.4 aliases, PA3/PB10/PB11/PB13 as unbonded pins and the GPIO limit list follow the firmware. validation/README.md gets the physical rules, the parts list, the bench bring-up (pre-power check, then tests/hil/test_wiring.py), per-option wiring tables with sources, which tests skip and how, and the known gaps that need a decision. PROTOCOL.md gets the new aliases, instance numbers, the WBA55 debug LED, the timer and peripheral sharing rules, the instance limits, the line-length rule with the generated payload convention and the per-MCU unsupported command names; EEPROM is no longer unsupported. test_config.py follows the WBA55 bundle2 and checks the options each set offers. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- validation/PROTOCOL.md | 98 ++++--- validation/README.md | 301 ++++++++++++++++----- validation/host/boards/nucleo_wb55rg.yaml | 140 +++++++--- validation/host/boards/nucleo_wba55cg.yaml | 301 +++++++++++++++++++-- validation/host/tests/unit/test_config.py | 36 ++- 5 files changed, 717 insertions(+), 159 deletions(-) diff --git a/validation/PROTOCOL.md b/validation/PROTOCOL.md index 99435e0f..f634c38a 100644 --- a/validation/PROTOCOL.md +++ b/validation/PROTOCOL.md @@ -11,34 +11,46 @@ The terminal is a `hal::UartStmDuplexDma` on USART1, the ST-LINK virtual COM por Aliases name the pins by peripheral function: -| Alias | NUCLEO-WB55RG | NUCLEO-WBA55CG | Function | -|-----------------------------------------------|-------------------------|-------------------------|------------------------------------------------------| -| `terminaltx` `terminalrx` | PB6 PB7 | PB12 PA8 | terminal USART1 TX / RX | -| `ain1` `ain2` `ain3` `ain4` `ain5` `ain6` | PC0 PC1 PC2 PC3 PA0 PA1 | - | ADC1 inputs IN1-IN6 | -| `ain2` `ain3` `ain4` `ain7` `ain8` `ain9` | - | PA7 PA6 PA5 PA2 PA1 PA0 | ADC4 inputs | -| `ain10` | - | PB9 | ADC4 input IN10 | -| `tim1ch1` `tim1ch2` `tim1ch3` `tim1ch4` | PA8 PA9 PA10 PA11 | PA11 PA12 PB4 PB3 | TIM1 channels 1-4 | -| `tim1ch1n` `tim1ch2n` `tim1ch3n` | PA7 PB8 PB9 | PB2 PB1 PB0 | TIM1 complementary channels 1N-3N | -| `tim1bkin` | PB12 | PA2 | TIM1 break input | -| `tim2ch1` `tim2ch2` `tim2ch3` `tim2ch4` | PA15 PA1 PA2 PA3 | - | TIM2 channels 1-4 (32-bit counter) | -| `tim2ch1` `tim2ch3` `tim2ch4` | - | PA5 PA7 PA6 | TIM2 channels 1, 3, 4 (CH2 is the terminal RX pin) | -| `tim3ch1` `tim3ch2` `tim3ch3` `tim3ch4` | - | PA10 PA1 PB14 PB9 | TIM3 channels 1-4 | -| `tim16ch1` `tim17ch1` | PA6 PB9 | PB9 PA1 | TIM16 / TIM17 channel 1 | -| `tim17ch1n` | - | PB3 | TIM17 complementary channel 1N | -| `qei1a` `qei1b` | PA8 PA9 | PA11 PA12 | TIM1 encoder phase A / B | -| `qei1idx` | - | PA15 | index input of the default encoder | -| `qei2a` `qei2b` `qei2idx` | PA15 PA1 PC6 | - | TIM2 encoder phase A / B, index input | -| `qei3a` `qei3b` | - | PA10 PA1 | TIM3 encoder phase A / B | -| `lptim1in1` `lptim1in2` | PC0 PC2 | - | LPTIM1 encoder inputs | -| `spi1clk` `spi1miso` `spi1mosi` `spi1cs` | PA5 PA6 PA7 PA4 | PB4 PB3 PA15 PA12 | SPI1 (Arduino D13/D12/D11/D10) | -| `lpuart1tx` `lpuart1rx` | PA2 PA3 | PB5 PA10 | LPUART1 TX / RX (Arduino D1/D0) | -| `lpuart1rts` `lpuart1cts` | PB12 PA6 | PB9 PB15 | LPUART1 RTS / CTS | -| `usart2tx` `usart2rx` `usart2rts` `usart2cts` | - | PB0 PA11 PB1 PB2 | USART2 | -| `led0` `led1` | PB0 PB1 | PB4 PA9 | user LEDs (green LD2, red LD3 / blue LD1, green LD2) | -| `gpio0` | PC6 | PB14 | general-purpose test pin, watchdog warning toggle | -| `gpio1` `gpio2` | PC10 PC12 | PA5 PA0 | general-purpose test pins | -| `gpio3` `gpio4` | PC13 PE4 | - | general-purpose test pins | -| `sw1` `sw2` `sw3` | PC4 PD0 PD1 | PC13 PB6 PB7 | user buttons (input only, pulled up) | +| Alias | NUCLEO-WB55RG | NUCLEO-WBA55CG | Function | +|-----------------------------------------------|-------------------------|-------------------------|------------------------------------------------------------------| +| `terminaltx` `terminalrx` | PB6 PB7 | PB12 PA8 | terminal USART1 TX / RX | +| `ain1` `ain2` `ain3` `ain4` `ain5` `ain6` | PC0 PC1 PC2 PC3 PA0 PA1 | - | ADC1 inputs IN1-IN6 | +| `ain2` `ain3` `ain4` `ain7` `ain8` `ain9` | - | PA7 PA6 PA5 PA2 PA1 PA0 | ADC4 inputs | +| `ain10` | - | PB9 | ADC4 input IN10 | +| `tim1ch1` `tim1ch2` `tim1ch3` `tim1ch4` | PA8 PA9 PA10 PA11 | PA11 PA12 PB4 PB3 | TIM1 channels 1-4 | +| `tim1ch1n` `tim1ch2n` `tim1ch3n` | PA7 PB8 PB9 | PB2 PB1 PB0 | TIM1 complementary channels 1N-3N | +| `tim1bkin` | PB12 | PA2 | TIM1 break input | +| `tim2ch1` `tim2ch2` `tim2ch3` `tim2ch4` | PA15 PA1 PA2 PA3 | - | TIM2 channels 1-4 (32-bit counter) | +| `tim2ch1` `tim2ch3` `tim2ch4` | - | PA5 PA7 PA6 | TIM2 channels 1, 3, 4 (CH2 is the terminal RX pin) | +| `tim3ch1` `tim3ch2` `tim3ch3` `tim3ch4` | - | PA10 PA1 PB14 PB9 | TIM3 channels 1-4 | +| `tim16ch1` `tim17ch1` | PA6 PB9 | PB9 PA1 | TIM16 / TIM17 channel 1 | +| `tim16ch1n` | - | PB8 | TIM16 complementary channel 1N (red LD3 on the pin) | +| `tim17ch1n` | - | PB3 | TIM17 complementary channel 1N | +| `qei1a` `qei1b` | PA8 PA9 | PA11 PA12 | TIM1 encoder phase A / B | +| `qei1idx` | - | PA15 | index input of the default encoder | +| `qei2a` `qei2b` `qei2idx` | PA15 PA1 PC6 | - | TIM2 encoder phase A / B, index input | +| `qei3a` `qei3b` | - | PA10 PA1 | TIM3 encoder phase A / B | +| `lptim1in1` `lptim1in2` | PC0 PC2 | PA0 PB3 | LPTIM1 encoder inputs | +| `lptim2in1` `lptim2in2` | - | PB9 PB0 | LPTIM2 encoder inputs | +| `lptim1ch2` | - | PA15 | LPTIM1 channel 2 (CH1, PB11, is not bonded out) | +| `lptim2ch1` `lptim2ch2` | - | PA11 PA1 | LPTIM2 channels 1-2 | +| `spi1clk` `spi1miso` `spi1mosi` `spi1cs` | PA5 PA6 PA7 PA4 | PB4 PB3 PA15 PA12 | SPI1 (Arduino D13/D12/D11/D10) | +| `spi1nss` | PA4 | PA12 | SPI1 hardware NSS (the pin of `spi1cs`) | +| `spi2clk` `spi2miso` `spi2mosi` `spi2nss` | PB13 PB14 PB15 PB12 | - | SPI2 | +| `spi3clk` `spi3miso` `spi3mosi` `spi3nss` | - | PA0 PB9 PB8 PA5 | SPI3 (8- and 16-bit frames only) | +| `i2c1scl` `i2c1sda` | PB8 PB9 | PB2 PB1 | I2C1 SCL / SDA (Arduino D15/D14) | +| `i2c3scl` `i2c3sda` | PC0 PC1 | PA6 PA7 | I2C3 SCL / SDA | +| `qspiclk` `qspincs` | PA3 PA2 | - | QUADSPI clock / chip select | +| `qspiio0` `qspiio1` `qspiio2` `qspiio3` | PB9 PB8 PA7 PA6 | - | QUADSPI IO0-IO3 | +| `lpuart1tx` `lpuart1rx` | PA2 PA3 | PB5 PA10 | LPUART1 TX / RX (Arduino D1/D0) | +| `lpuart1rts` `lpuart1cts` | PB12 PA6 | PB9 PB15 | LPUART1 RTS / CTS | +| `usart2tx` `usart2rx` `usart2rts` `usart2cts` | - | PB0 PA11 PB1 PB2 | USART2 | +| `mco` | PA8 | - | clock output MCO (`clock.mco`) | +| `led0` `led1` | PB0 PB1 | PB4 PA9 | user LEDs (green LD2, red LD3 / blue LD1, green LD2 = debug LED) | +| `gpio0` | PC6 | PB14 | general-purpose test pin, watchdog warning toggle | +| `gpio1` `gpio2` | PC10 PC12 | PA5 PA0 | general-purpose test pins | +| `gpio3` `gpio4` | PC13 PE4 | - | general-purpose test pins | +| `sw1` `sw2` `sw3` | PC4 PD0 PD1 | PC13 PB6 PB7 | user buttons (input only, pulled up) | - Wherever a pin is expected, an alias from this table may be used instead of `P`; several aliases may name the same pin, and no alias carries a default pull. - Per-instance default pins apply only when a command gets no pins at all: LPUART1 `lpuart1tx`/`lpuart1rx`; the default encoder (WB55 TIM2 `qei2a`/`qei2b`/`qei2idx`, WBA55 TIM1 `qei1a`/`qei1b`/`qei1idx`). Other UART and encoder instances need their pins; SPI and the ADC always do, PWM needs `channels` or `pins`. @@ -50,14 +62,22 @@ Aliases name the pins by peripheral function: The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix, number, hex and list syntax, open/close semantics) is specified in EMIL's [hardware-in-the-loop terminal documentation](https://github.com/embedded-pro/embedded-infra-lib/blob/main/docs/Hil.md). hal-st adds: - After reset the firmware prints `EVT boot board= family= sysclk= reset=` once; `` is `iwdg`, `wwdg`, `sw`, `lpwr`, `obl`, `bor`, `pin` or `unknown` (the RCC reset flags in that priority order; a reset through NRST also sets `pin`, so `pin` is checked last). -- Pins are written as `P`, for example `PA15`, `PB3`, `PH3`: ports A-E and H on STM32WB55 and A-C and H on STM32WBA55, index 0-15; a pin the package does not bond out returns `ERR pin`. -- Instance numbers are the STM32 peripheral numbers: USART 1-2 and LPUART 1 (selected with `lp=1`), SPI 1-3, TIM 1-17 (PWM and encoder) and LPTIM 1 (encoder, `lp=1`), ADC 1 (WB55) or 4 (WBA55), watchdog 0 (the WWDG). A number the running MCU lacks, including 0 where the peripherals start at 1, returns `ERR range`. -- The terminal UART and its pins, the debug LED (WB55 PB5, the blue LD1; WBA55 PB8, the red LD3; blinking while the firmware runs), the SWD pins PA13/PA14, the LSE crystal pins PC14/PC15 and BOOT0 (PH3) - are reserved and cannot be opened (`ERR busy`); any other pin, aliased or not, can be reconfigured freely. A pin held by another open instance returns `ERR busy`; a pin the hal-st pinout table does - not offer for the requested function and instance returns `ERR pin`. -- A timer serves one group at a time: a timer held by an open PWM, encoder or timer-triggered ADC returns `ERR busy` to the other groups. -- RAM limits how many instances are open at the same time: 1 PWM timer, 1 UART besides the terminal, 1 SPI, 1 ADC, 1 encoder, 1 watchdog and 8 GPIO pins; one more returns `ERR busy`. +- Pins are written as `P`, for example `PA15`, `PB3`, `PH3`: ports A-E and H on STM32WB55 and A-C and H on STM32WBA55, index 0-15; a pin the package does not bond out returns `ERR pin` (on the STM32WBA55 UFQFPN48 also PA3, PB10, PB11 and PB13, its SMPS and VDD11 pads). +- Instance numbers are the STM32 peripheral numbers: USART 1-2 and LPUART 1 (selected with `lp=1`), I2C 1-3, SPI 1-3, TIM 1-17 (PWM, encoder, timer and timer PWM), LPTIM 1-2 (low-power timer, LPTIM PWM, and the encoder with `lp=1`), QUADSPI 1, ADC 1 (WB55) or 4 (WBA55), watchdog 0 (the WWDG). + A number the running MCU lacks, including 0 where the peripherals start at 1, returns `ERR range`: both MCUs have I2C1 and I2C3 (not I2C2) and LPTIM1 and LPTIM2; the encoder with `lp=1` takes LPTIM1 on the WB55 and LPTIM1 or LPTIM2 on the WBA55; QUADSPI exists on the WB55 only (the `qspi` commands are unsupported on the WBA55, see below). +- The terminal UART and its pins, the debug LED, the SWD pins PA13/PA14, the LSE crystal pins PC14/PC15 and BOOT0 (PH3) are reserved and cannot be opened (`ERR busy`); any other pin, aliased or not, can be reconfigured freely. + The debug LED blinks while the firmware runs: WB55 PB5, the blue LD1; WBA55 PA9, the green LD2 (alias `led1`), which is not connected on a stock NUCLEO-WBA55CG (SB28 open), so it stays dark unless SB28 is closed. + A pin held by another open instance returns `ERR busy`; a pin the hal-st pinout table does not offer for the requested function and instance returns `ERR pin`. +- A timer serves one group at a time: a timer held by an open PWM, encoder, timer-triggered ADC, timer (`tim`) or timer PWM (`tpwm`), by a running `ain.burst` or `dma.wave` (both TIM2), or by the scaffold timer TIM17 while `lpm.enter` or `hsem.lock hold=` uses it, returns `ERR busy` to the other groups. +- One peripheral instance serves one group at a time: an I2C instance is shared by `i2c`, `i2cs` and `eeprom`, an SPI instance by `spi` and `spis`, the ADC by `adc` and `ain`, an LPTIM by `qei` (`lp=1`), `lptim` and `lptpwm`, and HSEM semaphore 0 by `flash ... variant=coord` and `hsem.lock` (WB55). + So are the DMA channels some groups share: WB55 DMA1 channel 7 (`adc`, `ain.burst`); WBA55 GPDMA1 channel 7 (`adc`, `ain.burst`, `spis` receive) and channel 8 (`spis` transmit, `dma.wave`). An instance or channel another group holds returns `ERR busy`. +- RAM limits how many instances are open at the same time: 1 PWM timer, 1 UART besides the terminal, 1 SPI master, 1 SPI slave, 1 I2C master, 1 I2C target, 1 attached EEPROM, 1 ADC, 1 encoder, 1 timer (`tim`), 1 timer PWM (`tpwm`), 1 LPTIM, 1 LPTIM PWM, 1 QUADSPI, 1 watchdog and 8 GPIO pins; one more returns `ERR busy`. - Argument errors (`usage`, `range`, `pin`, `unsupported`) are reported before `ERR busy`. +- Line length: a command line holds at most 255 characters (`terminal.max_command_length` in the board files). Commands that move more data than fits take a payload the firmware generates and can answer with a CRC instead of the data: + - in place of a hex payload, `-` with `len=` (1 up to the command's capacity, `ERR range` above) `[pattern=inc|const|prbs]` (default `inc`) `[seed=<0..0xFFFFFFFF>]` (default 0); `len` with a hex payload, or `pattern`/`seed` without `len`, returns `ERR usage` + - `inc`: byte i is (seed + i) & 0xFF; `const`: every byte is seed & 0xFF; `prbs`: per byte, a 32-bit xorshift `x ^= x << 13; x ^= x >> 17; x ^= x << 5` then `x & 0xFF`, starting from `seed` (1 when `seed` is 0). `inc` with seed 0xFE gives `fe ff 00 01`, `prbs` with seed 1 gives `21 01 c5 4f d1 d0 1a b2` + - `out=hex|crc` (default `hex`) where a command returns data: `hex` returns at most 128 bytes (`ERR range` above); `crc` returns `len= crc=<8 lower-case hex digits>`, the CRC-32 of zlib (`zlib.crc32`; `inc` seed 0 with 256 bytes gives `29058c73`) + - durations in replies are `us=` microseconds ## General @@ -159,4 +179,10 @@ The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix ## Not available on these boards -hal-st has no comparator, CAN, EEPROM or Ethernet driver for STM32WB55/STM32WBA55, so `comp.open`, `comp.read`, `comp.irq`, `comp.count`, `comp.close`, `can.open`, `can.send`, `can.close`, `eeprom.write`, `eeprom.read`, `eeprom.erase`, `eth.open`, `eth.status` and `eth.close` return `ERR unsupported`. +hal-st has no comparator, CAN or Ethernet driver for STM32WB55/STM32WBA55, so `comp.open`, `comp.read`, `comp.irq`, `comp.count`, `comp.close`, `can.open`, `can.send`, `can.close`, `eth.open`, `eth.status` and `eth.close` return `ERR unsupported`. +The groups one MCU lacks return `ERR unsupported` on that MCU: + +- STM32WB55: `lptpwm.open`, `lptpwm.duty`, `lptpwm.pulse`, `lptpwm.start`, `lptpwm.stop`, `lptpwm.close` (`hal::LpTimerPwmStm` is not built for STM32WB). +- STM32WBA55: `hsem.take`, `hsem.release`, `hsem.status`, `hsem.lock`, `hsem.mine` (no hardware semaphore), `qspi.open`, `qspi.cmd`, `qspi.poll`, `qspi.xfer`, `qspi.close` (no QUADSPI), `flash.stack` (no wireless coprocessor to coordinate the flash with), `clock.mco` (the only MCO pin is the terminal RX) and `clock.hsi48` (no HSI48). + +The `eeprom` commands are served by an external 24Cxx EEPROM on the I2C bus (see the EEPROM section). diff --git a/validation/README.md b/validation/README.md index c9f77c84..a580bfe4 100644 --- a/validation/README.md +++ b/validation/README.md @@ -7,7 +7,8 @@ The structure follows hal-ti's `validation/`; the firmware runs on EMIL's hardwa - `firmware/` - C++ firmware on hal-st and EMIL. It exposes the hal-st peripherals through a line-based terminal; the command set is specified in [PROTOCOL.md](PROTOCOL.md). - `host/` - Python package `hal_st_validation` and a pytest suite that talks to the firmware terminal over a serial port and to the AD3 through the WaveForms SDK. - The generic bench code (AD3 wrapper over the WaveForms SDK, signal analysis, `OK`/`ERR`/`EVT` terminal client, console, pytest plugin and fakes) lives in the separate [ad3-waveforms-bench](https://github.com/embedded-pro/ad3-waveforms-bench) repository; `hal_st_validation` only adds what is specific to hal-st. -- hal-st has no comparator, CAN, EEPROM or Ethernet driver for these MCUs, so those commands answer `ERR unsupported`; I2C, flash, RNG and AES have no command group in EMIL's terminal yet and are not validated here. +- hal-st has no comparator, CAN or Ethernet driver for these MCUs, so those commands answer `ERR unsupported`, as do the command groups one MCU lacks (PROTOCOL.md, "Not available on these boards"). + Peripherals EMIL's terminal has no command group for (I2C, timers, LPTIM, QUADSPI, flash, RNG, AES, PKA and others) get command groups of the validation firmware; the `eeprom` commands are EMIL's, served by an external 24Cxx EEPROM on the I2C bus. ## Why C++ and Python @@ -30,14 +31,22 @@ The structure follows hal-ti's `validation/`; the firmware runs on EMIL's hardwa - The Nucleo is powered from its ST-LINK USB port. The AD3 V+/V- supplies stay off unless `ad3.vplus`/`ad3.vminus` are set in the board file. - Wavegen outputs are refused outside `ad3.analog_limits` (0..3.3 V by default) so a wrong parameter cannot overdrive an analog input. - Run the tests with nothing else connected to the pins of the selected wiring sets: the tests drive them directly. -- Each board has fixed wiring bundles (tables below); wire one, run its tests, then switch: +- Each board has fixed wiring bundles (tables in [Wiring sets](#wiring-sets)); wire one, run its tests, then switch: - `bundle1` wires all 16 DIOs and W1/W2 on two ADC inputs, and runs nearly the whole suite. - - `bundle2` (NUCLEO-WB55RG only) moves DIO9/DIO10 to the LPTIM1 inputs PC0/PC2 for the LPTIM encoder tests; W2 and scope 2 are unplugged because PC2 is then driven by a DIO. + - `bundle2` of the NUCLEO-WB55RG moves DIO9/DIO10 to the LPTIM1 inputs PC0/PC2 for the LPTIM encoder tests; W2 and scope 2 are unplugged because PC2 is then driven by a DIO. + - `bundle2` of the NUCLEO-WBA55CG moves DIO8, DIO9, DIO11 and DIO14 to PA7, PA6, PB8 and PA0 for SPI3, TIM2 CH3/CH4, TIM16 CH1N and the LPTIM1 encoder, and carries the I2C and SPI loop options; W1, W2 and both scopes are unplugged (with `--with i2c` the scopes measure the I2C rise time). +- Optional wiring is fitted on top of a bundle and enabled with `--with ` (see [Optional wiring](#optional-wiring)): + - `loopback` - an SPI1 MOSI-MISO jumper (both boards, both bundles); + - `i2c` - I2C1 to I2C3 with external pull-ups and a 24Cxx EEPROM on a breadboard (NUCLEO-WB55RG `bundle1`, NUCLEO-WBA55CG `bundle2`); + - `spiloop` - SPI1 to SPI2 (NUCLEO-WB55RG `bundle1`) or SPI1 to SPI3 (NUCLEO-WBA55CG `bundle2`); not together with `loopback`. +- Fit an option only together with its `--with ` and remove it otherwise: its wiring loads pins that other tests drive. Start every new setup with the [bench bring-up](#bench-bring-up), which checks the wiring before the suite runs. - Each pin serves several tests: the TIM1 outputs are also SPI and encoder pins, the LPUART1 CTS pin is also the SPI1 MISO, and so on. The firmware frees every pin between tests, so a pin can change role from one test to the next. -- Tests whose connections are missing from the selected sets are skipped with the reason. +- Tests whose connections are missing from the selected sets are skipped with the reason, and so are tests on pins an enabled option loads ([Which tests skip, and how](#which-tests-skip-and-how)). - The terminal is USART1 on the ST-LINK virtual COM port (`/dev/ttyACM0`, `COMx`): PB6/PB7 on the NUCLEO-WB55RG, PB12/PA8 on the NUCLEO-WBA55CG, at 921600 baud. If the ST-LINK of a board cannot keep up with 921600 baud, change `terminalBaudRate` in `firmware/boards//BoardProfile.hpp` and `terminal.baud` in the board file together. -- The NUCLEO-WB55RG positions come from its user manual, UM2435 Rev 2 (board MB1355C: Table 10, Table 11, Fig. 8 and Fig. 24), cross-checked with the STM32CubeWB example readmes. A later board revision (MB1355D, user manual UM2819) may differ: check the solder bridges named in the table (SB1, SB5, SB8, SB11, SB14, SB15, SB41) before wiring. +- The NUCLEO-WB55RG positions come from its user manual, UM2435 Rev 2 (board MB1355C: Table 10, Table 11, Fig. 8 and Fig. 24), cross-checked with the STM32CubeWB example readmes. A later board revision (MB1355D, user manual UM2819) may differ: check the solder bridges named in the table (SB1, SB5, SB8, SB9, SB11, SB12, SB14, SB15, SB41) before wiring. +- The NUCLEO-WBA55CG user manual, UM3301, could not be consulted: its positions come from the STM32CubeWBA example readmes, Zephyr's Arduino connector map and modm-data's transcription of UM3301 Table 8, and its 3V3 and GND pins from a single source (the STM32-Sidewalk-SDK README). + Each position names its source; check the silkscreen and do the pre-power check of the bring-up before fitting the I2C option. - NUCLEO-WB55RG: the firmware runs on the Cortex-M4 alone and never starts the wireless coprocessor; the linker script keeps it below the flash and SRAM the wireless stack uses. ## Build and flash the firmware @@ -58,7 +67,7 @@ The artifacts are `build//validation/firmware/RelWithDebInfo/hal_st.vali STM32_Programmer_CLI -c port=SWD -w build/stm32wb55/validation/firmware/RelWithDebInfo/hal_st.validation_firmware.hex -v -rst ``` -After reset the firmware prints `EVT boot board=... family=... sysclk=... reset=...`, and the debug LED (WB55 blue LD1, WBA55 red LD3) blinks. +After reset the firmware prints `EVT boot board=... family=... sysclk=... reset=...`, and the debug LED blinks: the blue LD1 on the NUCLEO-WB55RG, the green LD2 on the NUCLEO-WBA55CG, which is not connected on a stock board (SB28 open) and stays dark unless SB28 is closed. ## Install the host package @@ -124,7 +133,7 @@ Tests that need no AD3 (system, argument errors, limits, instance and timer shar Optional wiring loads pins: a test that resolves an AD3 channel (`need.dio`, `need.wavegen`, `need.scope`) for a pin an enabled option loads skips ("pin X loaded by --with T") unless it is marked `@pytest.mark.uses_option("T")` or `@pytest.mark.requires_option("T")`. Pins an option ties to a channel with a jumper resolve only for such tests. `requires_option` skips the test without the option, `conflicts_option` skips it with the option. Tests that reset the board on purpose (watchdog, UART swap) are marked `resets_board`; any other unexpected `EVT boot` fails the test that caused it. -Every instance a test opened is closed afterwards and the AD3 outputs are released, so tests are independent (the firmware keeps at most one PWM timer, UART, SPI, ADC, encoder and watchdog open at a time). +Every instance a test opened is closed afterwards and the AD3 outputs are released, so tests are independent (the firmware keeps at most one instance of each group open at a time, see the Framing section of PROTOCOL.md). Use `-k`, `-m "not slow"` and `--junitxml report.xml` as usual. ## Known driver gaps @@ -143,6 +152,18 @@ test: PH3 (BOOT0) is the only port H pin on both boards and is reserved. Fix the driver, then remove its entry from both board files so the tests guard the fix. +### Known gaps needing a decision + +These behaviours are not fixed here because a fix changes an API or behaviour other users rely on; the tests assert the current behaviour or mark it as a known gap until a decision is made. + +- `SpiMasterStm`, `SpiMasterStmDma`, `SynchronousSpiMasterStm` mux `slaveSelect` to NSS but initialise `SPI_NSS_SOFT` and keep SPE on from construction (`SpiMasterStm.cpp:24,56`, `SpiMasterStmDma.cpp:38`, `SynchronousSpiMasterStm.cpp:12`): hardware NSS framing needs SPE toggled per transfer in three drivers; `test_spi_ext.py::test_hardware_nss*` is a known gap. +- `SpiDataSizeConfiguratorStm` (`SetDataSize`) changes only the peripheral DMA width (`SpiMasterStmDma.cpp:165-166`): a 9- to 16-bit frame carries one zero-extended byte; the tests assert that model (`spiwords.spi_frames`). +- `QuadSpiStm` waits with `HAL_MAX_DELAY` (`QuadSpiStm.cpp:52,73,97`): a `PollStatus` that never matches hangs the firmware; `test_qspi.py::test_poll_timeout[variant=poll]` is a hanging known gap. +- `AnalogToDigitalPinImplStm` and `AnalogToDigitalInternalTemperatureStm` ignore `numberOfSamples` (`AnalogToDigitalPinStm.cpp:90-165`) and always take one sample; the tests assert one. +- The default sampling time of `AnalogToDigitalInternalTemperatureStm` (`AnalogToDigitalPinStm.hpp:21-27`) is below the temperature sensor's minimum; the tests pass a long `sampling=` and check only that the default delivers a code. +- `TransmitDmaBridgeChannel` and `ReceiveDmaBridgeChannel` (`DmaStm.cpp:1176-1203`) never start on DMA v1 (WB55), the transmit bridge copies destination to source on GPDMA, and the memory side keeps byte width and increment; there is no in-tree user and no command, so they stay untested. +- `LpTimerPwmStm` writes CCR = ARR x duty / 100 with the output polarity high, which may give a duty of 100 - d on the LPTIM; the tests assert d, and if the bench measures 100 - d the test becomes a known gap. + ## Windows host and Docker (bridge mode) Use this when the build and the tests run in the hal-st devcontainer (Docker) but the AD3, the Nucleo's virtual COM port and its ST-LINK are plugged into a Windows PC. Docker cannot reach those USB devices, so Windows shares them over TCP and the container uses them through `host.docker.internal` (the devcontainer maps that name to the host): @@ -209,69 +230,221 @@ pytest validation/host/tests/hil --board nucleo_wb55rg --wiring-set bundle1 --de ## Wiring sets -The tables follow the `wiring_sets` of the board files (keep both in step); the notes list every function a pin serves. Scope inputs are single ended: connect the `-` input of each used scope channel to GND. +The tables follow the `wiring_sets` of the board files (keep both in step); the notes list every function a pin serves, and every header position names its source. Scope inputs are single ended: connect the `-` input of each used scope channel to GND. + +### Physical rules + +- One dupont housing per header pin. AD3 leads (female) sit on male morpho pins; jumper ends go to the free female Arduino sockets (male-ended wire) or to free morpho pins (female-ended wire). +- Where the only header position of a pin already carries an AD3 lead (NUCLEO-WB55RG PB12 at CN10-16, NUCLEO-WBA55CG PB8 at CN4-38), the net goes through a breadboard row: one male-female wire from the header pin to the row, the AD3 lead on a male pin in that row, and the jumper into the same row. +- The I2C option is built on a breadboard: an SCL row, an SDA row, a 3V3 rail and a GND rail. Each rail is fed by one wire from one named header pin; the pull-ups, the EEPROM VCC and GND and (NUCLEO-WBA55CG) the AD3 ground share the rail, never a header pin. +- Fit option wiring only together with its `--with `, and remove it otherwise. `test_wiring.py` fails when it finds the jumpers of an option that is not enabled ("pass --with or remove the wiring"). + +### Parts for the I2C option + +- EEPROM: Microchip 24LC256 or AT24C256 (DIP-8, 3.3 V, 32 KiB, 64-byte pages, 16-bit word address, 400 kHz, 5 ms write cycle). Pins 1-3 (A0-A2) to the GND rail (address 0x50), 4 (VSS) to the GND rail, 5 (SDA) to the SDA row, 6 (SCL) to the SCL row, 7 (WP) to the GND rail (writes enabled), 8 (VCC) to the 3V3 rail; 100 nF from VCC to GND at the part. +- Two 4.7 kOhm resistors (SCL row to the 3V3 rail, SDA row to the 3V3 rail), and two 2.2 kOhm as a fallback. +- A half-size breadboard, 8 male-male and 8 male-female dupont wires, a strip of 2.54 mm male pin headers (to put AD3 leads and scope probes into breadboard rows) and a multimeter. +- Rise time: a NUCLEO-WB55RG bus line (one AD3 DIO, two MCU pins, the EEPROM and the breadboard, about 50 pF) rises in about 0.85 x 4.7 kOhm x 50 pF = 200 ns, under the 300 ns of Fast mode. The NUCLEO-WBA55CG `bundle2` lines carry two AD3 DIOs and a scope probe each (80-100 pF, 320-400 ns): `test_rise_time` measures them; when it fails, fit the 2.2 kOhm resistors (150-190 ns). +- The board file's `tests.eeprom` holds `addr`, `size`, `page`, `abytes`, `wcycle_ms` and `erase_size`, so another part is a YAML change (a 24C02: `size: 256`, `page: 8`, `abytes: 1`). + +### Bench bring-up + +1. With the board unplugged, wire the bundle and the options you will enable; keep the terminal, SWD and BOOT0 pins free. +2. For `--with i2c`, build the breadboard without the EEPROM, plug the board in and measure 3.3 V between the 3V3 and GND rails (multimeter, or AD3 scope 1 with its leads on the rails). Unplug, insert the EEPROM, plug in again. +3. Flash the firmware and run the wiring self-check first, with the sets and options of the run that follows: + + ```bash + pytest validation/host/tests/hil/test_wiring.py --board nucleo_wb55rg --port /dev/ttyACM0 --wiring-set bundle1 --with i2c --with spiloop + pytest validation/host/tests/hil/test_wiring.py --board nucleo_wba55cg --port /dev/ttyACM0 --wiring-set bundle2 --with i2c + ``` + + With the AD3 outputs and pulls off it uses GPIO commands only: each jumper of an enabled option conducts both ways, its pull-ups sit on a live 3V3 rail, the jumpers of offered options that are not enabled are absent, and the pins of `tests.wiring.undriven` follow both MCU pulls (NUCLEO-WBA55CG: no solder bridge connects an ST-LINK line to PA0, PB9, PA10, PB5 or PB15). +4. Run the suite with the same `--wiring-set` and `--with`, `--depth quick` first: + + ```bash + pytest validation/host/tests/hil --board nucleo_wb55rg --port /dev/ttyACM0 --wiring-set bundle1 --with i2c --with spiloop --depth quick + pytest validation/host/tests/hil --board nucleo_wba55cg --port /dev/ttyACM0 --wiring-set bundle2 --with i2c --with spiloop --depth quick + ``` ### NUCLEO-WB55RG wiring -`CN7-n`/`CN10-n` are the male ST morpho pins and the easiest to reach with AD3 flywires; `Dn`/`An` are the same nets on the female Arduino sockets. "via SBn" names a solder bridge that is closed on a stock board. PB3 (SWO) is on no header of a stock board, so TIM2 CH2 and the TIM2 encoder B use PA1. - -| Wiring set | AD3 | Pin | Nucleo header | Note | -|------------|-----------------|-------------------|----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------| -| both | GND | GND | CN10-9 and CN10-20 | common ground, at least two leads | -| `bundle1` | DIO0 | PA8 (tim1ch1) | CN10-25 (D6) | TIM1 CH1, encoder TIM1 A, GPIO | -| `bundle1` | DIO1 | PA7 (tim1ch1n) | CN10-15 via SB1 (D11) | TIM1 CH1N, SPI1 MOSI, GPIO | -| `bundle1` | DIO2 | PA9 (tim1ch2) | CN10-19 via SB8 (D9) | TIM1 CH2, encoder TIM1 B, GPIO | -| `bundle1` | DIO3 | PB8 (tim1ch2n) | CN10-3 (D15) | TIM1 CH2N, GPIO | -| `bundle1` | DIO4 | PA10 (tim1ch3) | CN10-31 via SB11 (D3) | TIM1 CH3, TIM17 break, GPIO | -| `bundle1` | DIO5 | PB9 (tim1ch3n) | CN10-5 (D14) | TIM1 CH3N, TIM17 CH1, GPIO; CN10-6 next to it is the terminal RX (PB7) | -| `bundle1` | DIO6 | PA5 (spi1clk) | CN10-11 (D13) | SPI1 SCK, GPIO | -| `bundle1` | DIO7 | PA6 (spi1miso) | CN10-13 (D12) | SPI1 MISO, LPUART1 CTS, TIM16 CH1, EXTI line 6 partner of PC6, GPIO | -| `bundle1` | DIO8 | PA4 (spi1cs) | CN10-17 via SB5 (D10 via SB41) | SPI1 chip select, GPIO | -| `bundle1` | DIO9 | PA15 (qei2a) | CN10-27 (D5) | TIM2 CH1, encoder TIM2 A, GPIO | -| `bundle1` | DIO10 | PA1 (qei2b) | CN7-32 via SB14 (A2) | TIM2 CH2, encoder TIM2 B, GPIO | -| `bundle1` | DIO11 | PC6 (gpio0) | CN10-33 (D2) | GPIO/EXTI, encoder TIM1 and TIM2 index, watchdog warning toggle; CN10-34 next to it is the terminal TX (PB6) | -| `bundle1` | DIO12 | PA2 (lpuart1tx) | CN10-35 via SB15 (D1) | LPUART1 TX, TIM2 CH3, GPIO | -| `bundle1` | DIO13 | PA3 (lpuart1rx) | CN10-37 (D0) | LPUART1 RX, TIM2 CH4, GPIO | -| `bundle1` | DIO14 | PB12 (lpuart1rts) | CN10-16 | LPUART1 RTS, TIM1 break, GPIO | -| `bundle1` | DIO15 | PB0 (led0) | CN10-22 | green LED2 output, GPIO; LED2 and its 680 ohm resistor load the pin | -| `bundle1` | W1 | PC3 (ain4) | CN7-36 (A4) | ADC1 IN4 | -| `bundle1` | W2 | PC2 (ain3) | CN7-38 (A5) | ADC1 IN3 | -| `bundle1` | Scope 1+ | PC3 (ain4) | A4 (CN8-5) | same net as W1 | -| `bundle1` | Scope 2+ | PC2 (ain3) | A5 (CN8-6) | same net as W2 | -| `bundle1` | Scope 1-, 2- | GND | CN7-19, CN7-20 (or CN7-8) | not CN7-22: Fig. 24 calls it GND, the schematic leaves it unconnected | -| `bundle1` | - | - | CN10-15 to CN10-13 | `--with loopback`: jumper PA7 (SPI1 MOSI) to PA6 (SPI1 MISO), or D11 to D12; the SPI tests then leave DIO7 an input and check the read-back | -| `bundle1` | - | - | - | D10 and CN10-17 carry PA4 with the default solder bridges (SB41 and SB5 closed, SB42 and SB6 open; PB10 otherwise) | -| `bundle1` | - | - | CN10-6, CN10-34, CN7-7, CN7-13, CN7-15 | keep free: terminal RX/TX to the ST-LINK (PB7, PB6), BOOT0 (PH3) and SWD (PA13, PA14) | -| `bundle2` | everything else | as `bundle1` | | including GND, the loopback option and the D10 note | -| `bundle2` | DIO9 | PC0 (lptim1in1) | CN7-28 (A0) | LPTIM1 IN1, encoder LPTIM1 A, ADC1 IN1 | -| `bundle2` | DIO10 | PC2 (lptim1in2) | CN7-38 (A5) | LPTIM1 IN2, encoder LPTIM1 B, ADC1 IN3; W2 and scope 2 unplugged | +`CN7-n`/`CN10-n` are the male ST morpho pins and the easiest to reach with AD3 flywires; `Dn`/`An` are the same nets on the female Arduino sockets. +"via SBn" names a solder bridge that is closed on a stock board. PB3 (SWO) is on no header of a stock board, so TIM2 CH2 and the TIM2 encoder B use PA1. Positions are from UM2435 Rev 2: Table 10 (Arduino), Table 11 (morpho), Fig. 8 (solder bridges) and Fig. 24. + +| Wiring set | AD3 | Pin | Nucleo header | Note | +|------------|-----------------|-------------------|----------------------------------------|--------------------------------------------------------------------------------------------------------------------| +| both | GND | GND | CN10-9 and CN10-20 | common ground, at least two leads | +| `bundle1` | DIO0 | PA8 (tim1ch1) | CN10-25 (D6) | TIM1 CH1, encoder TIM1 A, MCO, GPIO | +| `bundle1` | DIO1 | PA7 (tim1ch1n) | CN10-15 via SB1 (D11) | TIM1 CH1N, SPI1 MOSI, I2C3 SCL, QUADSPI IO2, GPIO | +| `bundle1` | DIO2 | PA9 (tim1ch2) | CN10-19 via SB8 (D9) | TIM1 CH2, encoder TIM1 B, I2C1 SCL, GPIO | +| `bundle1` | DIO3 | PB8 (tim1ch2n) | CN10-3 (D15) | TIM1 CH2N, I2C1 SCL, QUADSPI IO1, GPIO | +| `bundle1` | DIO4 | PA10 (tim1ch3) | CN10-31 via SB11 (D3) | TIM1 CH3, TIM17 break, I2C1 SDA, GPIO | +| `bundle1` | DIO5 | PB9 (tim1ch3n) | CN10-5 (D14) | TIM1 CH3N, TIM17 CH1, I2C1 SDA, QUADSPI IO0, GPIO; CN10-6 next to it is the terminal RX (PB7) | +| `bundle1` | DIO6 | PA5 (spi1clk) | CN10-11 (D13) | SPI1 SCK, GPIO | +| `bundle1` | DIO7 | PA6 (spi1miso) | CN10-13 (D12) | SPI1 MISO, LPUART1 CTS, TIM16 CH1, QUADSPI IO3, EXTI line 6 partner of PC6, GPIO | +| `bundle1` | DIO8 | PA4 (spi1cs) | CN10-17 via SB5 (D10 via SB41) | SPI1 chip select and NSS, GPIO | +| `bundle1` | DIO9 | PA15 (qei2a) | CN10-27 (D5) | TIM2 CH1, encoder TIM2 A, GPIO | +| `bundle1` | DIO10 | PA1 (qei2b) | CN7-32 via SB14 (A2) | TIM2 CH2, encoder TIM2 B, GPIO | +| `bundle1` | DIO11 | PC6 (gpio0) | CN10-33 (D2) | GPIO/EXTI, encoder TIM1 and TIM2 index, watchdog warning toggle; CN10-34 next to it is the terminal TX (PB6) | +| `bundle1` | DIO12 | PA2 (lpuart1tx) | CN10-35 via SB15 (D1) | LPUART1 TX, TIM2 CH3, QUADSPI NCS, GPIO | +| `bundle1` | DIO13 | PA3 (lpuart1rx) | CN10-37 (D0) | LPUART1 RX, TIM2 CH4, QUADSPI CLK, GPIO | +| `bundle1` | DIO14 | PB12 (lpuart1rts) | CN10-16 | LPUART1 RTS, TIM1 break, SPI2 NSS, GPIO; with `--with spiloop` the lead sits in the PB12 breadboard row | +| `bundle1` | DIO15 | PB0 (led0) | CN10-22 | green LED2 output, GPIO; LED2 and its 680 ohm resistor load the pin | +| `bundle1` | W1 | PC3 (ain4) | CN7-36 (A4) | ADC1 IN4 | +| `bundle1` | W2 | PC2 (ain3) | CN7-38 (A5) | ADC1 IN3 | +| `bundle1` | Scope 1+ | PC3 (ain4) | A4 (CN8-5) | same net as W1 | +| `bundle1` | Scope 2+ | PC2 (ain3) | A5 (CN8-6) | same net as W2 | +| `bundle1` | Scope 1-, 2- | GND | CN7-19, CN7-20 (or CN7-8) | not CN7-22: Fig. 24 calls it GND, the schematic leaves it unconnected | +| `bundle1` | - | - | - | D10 and CN10-17 carry PA4 with the default solder bridges (SB41 and SB5 closed, SB42 and SB6 open; PB10 otherwise) | +| `bundle1` | - | - | CN10-6, CN10-34, CN7-7, CN7-13, CN7-15 | keep free: terminal RX/TX to the ST-LINK (PB7, PB6), BOOT0 (PH3) and SWD (PA13, PA14) | +| `bundle2` | everything else | as `bundle1` | | including GND, the loopback option and the D10 note | +| `bundle2` | DIO9 | PC0 (lptim1in1) | CN7-28 (A0) | LPTIM1 IN1, encoder LPTIM1 A, ADC1 IN1 | +| `bundle2` | DIO10 | PC2 (lptim1in2) | CN7-38 (A5) | LPTIM1 IN2, encoder LPTIM1 B, ADC1 IN3; W2 and scope 2 unplugged | ### NUCLEO-WBA55CG wiring -| Wiring set | AD3 | Pin | Note | -|------------|----------|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `bundle1` | DIO0 | PB4 (spi1clk) | SPI1 SCK, TIM1 CH3, blue LED (led0), GPIO (D13) | -| `bundle1` | DIO1 | PB3 (spi1miso) | SPI1 MISO, TIM1 CH4, TIM17 CH1N, GPIO (D12) | -| `bundle1` | DIO2 | PA15 (spi1mosi) | SPI1 MOSI, encoder TIM1 index, TIM17 break, GPIO (D11) | -| `bundle1` | DIO3 | PA12 (spi1cs) | SPI1 chip select, TIM1 CH2, encoder TIM1 B, GPIO (D10) | -| `bundle1` | DIO4 | PA11 (tim1ch1) | TIM1 CH1, encoder TIM1 A, USART2 RX, GPIO (D4) | -| `bundle1` | DIO5 | PB2 (tim1ch1n) | TIM1 CH1N, USART2 CTS, GPIO (D15) | -| `bundle1` | DIO6 | PB1 (tim1ch2n) | TIM1 CH2N, USART2 RTS, GPIO (D14) | -| `bundle1` | DIO7 | PB0 (tim1ch3n) | TIM1 CH3N, USART2 TX, GPIO (D6) | -| `bundle1` | DIO8 | PB5 (lpuart1tx) | LPUART1 TX, GPIO (D1) | -| `bundle1` | DIO9 | PA10 (lpuart1rx) | LPUART1 RX, TIM3 CH1, encoder TIM3 A, GPIO (D0) | -| `bundle1` | DIO10 | PB9 (lpuart1rts) | LPUART1 RTS, TIM3 CH4, TIM16 CH1, GPIO (D7) | -| `bundle1` | DIO11 | PB15 (lpuart1cts) | LPUART1 CTS, TIM16 break, GPIO (D8) | -| `bundle1` | DIO12 | PA1 (tim3ch2) | TIM3 CH2, encoder TIM3 B, TIM17 CH1, GPIO (A3) | -| `bundle1` | DIO13 | PB14 (gpio0) | GPIO/EXTI, TIM3 CH3, encoder TIM3 index, watchdog warning toggle (D5) | -| `bundle1` | DIO14 | PA2 (tim1bkin) | TIM1 break input, GPIO (A2) | -| `bundle1` | DIO15 | PA5 (tim2ch1) | TIM2 CH1, GPIO (A4) | -| `bundle1` | W1 | PA7 (ain2) | ADC4 IN2 (A0) | -| `bundle1` | W2 | PA6 (ain3) | ADC4 IN3 (A1) | -| `bundle1` | Scope 1+ | PA7 (ain2) | | -| `bundle1` | Scope 2+ | PA6 (ain3) | | -| `bundle1` | - | - | `--with loopback`: jumper PA15 (SPI1 MOSI, D11) to PB3 (SPI1 MISO, D12); the SPI tests then leave DIO1 an input and check the read-back | -| `bundle1` | - | - | PB4 (D13) also drives the blue LED LD1, which loads the SPI1 clock line | +The board manual, UM3301, could not be consulted, so every position names its source: + +- Arduino labels (Dn, An) are from Zephyr's `boards/st/nucleo_wba55cg/arduino_r3_connector.dtsi`. +- Morpho positions (CN3, CN4) and Arduino socket positions (CN6, CN7, CN8) are from the STM32CubeWBA v1.10.0 example readmes of `Projects/NUCLEO-WBA55CG`; "readmes (n)" is the number of readmes that give the position. +- CN4-35 and CN4-37 are from modm-data's transcription of UM3301 Table 8; 3V3 (CN5-4) and GND (CN5-6) are from the STM32-Sidewalk-SDK README (`NUCLEO-WBAxx`: `VDD 3V3 | CN5, pin 4`, `GND | CN5, pin 6`), a single source. +- The A1 and A4 sockets are named by their label only; DIO11 and DIO13 have no sourced morpho position and use a male pin in their Arduino socket. Every other AD3 lead sits on a morpho pin, so the Arduino sockets stay free for the option jumpers. + +Check before wiring (modm-data, UM3301 Table 8): on MB1801, SB7/SB8 connect PA10/PB5 to the ST-LINK VCP2; SB25 with MB1803 SB8 on and SB9 off routes STLINK_RTS to PA0 (with MB1803 SB32 on and SB31 off to PB15); SB23/SB25 route STLINK_CTS to PB9. +None is the stock setting; `test_wiring.py::test_no_foreign_drivers` checks that PA0, PB9, PA10, PB5 and PB15 follow the MCU pulls with the AD3 released. + +Board loads: PB4 drives the blue LD1, which loads the SPI1 clock line. PB8 carries the red LD3 (active low, Zephyr `nucleo_wba55cg.dts`): the LED and its resistor pull PB8 towards 3V3, so PB8 stays out of the pull-level and idle assertions and the `bundle2` TIM16 entry has no break input. +PA9 is the green LD2, the firmware's debug LED, which stays dark unless SB28 is closed (open on a stock board). + +| Wiring set | AD3 | Pin | Nucleo header | Source | Note | +|------------|-----------------|------------------------------|-----------------------------------|---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| both | GND | GND | CN5-6 | Sidewalk SDK README | common ground; CN5-6 is the only sourced GND pin, so scope 1-/2- share it through a breadboard GND rail fed from CN5-6 (with `--with i2c`, the rail of the option) | +| `bundle1` | DIO0 | PB4 (spi1clk) | CN4-6 (D13) | readmes (15) | SPI1 SCK, TIM1 CH3, blue LED LD1 (led0), GPIO | +| `bundle1` | DIO1 | PB3 (spi1miso) | CN4-10 (D12) | readmes (13) | SPI1 MISO, I2C1 SDA, TIM1 CH4, TIM17 CH1N, GPIO | +| `bundle1` | DIO2 | PA15 (spi1mosi) | CN4-15 (D11) | readmes (12) | SPI1 MOSI, I2C1 SCL, LPTIM1 CH2, encoder TIM1 index, TIM17 break, GPIO | +| `bundle1` | DIO3 | PA12 (spi1cs) | CN4-17 (D10) | readmes (10) | SPI1 chip select and NSS, TIM1 CH2, encoder TIM1 B, GPIO | +| `bundle1` | DIO4 | PA11 (tim1ch1) | CN4-24 (D4) | readmes (7) | TIM1 CH1, encoder TIM1 A, USART2 RX, LPTIM2 CH1, GPIO | +| `bundle1` | DIO5 | PB2 (tim1ch1n) | CN4-3 (D15) | readmes (31) | TIM1 CH1N, USART2 CTS, I2C1 SCL, I2C3 SCL, GPIO | +| `bundle1` | DIO6 | PB1 (tim1ch2n) | CN4-5 (D14) | readmes (31) | TIM1 CH2N, USART2 RTS, I2C1 SDA, I2C3 SDA, GPIO | +| `bundle1` | DIO7 | PB0 (tim1ch3n) | CN4-25 (D6) | readmes (1) | TIM1 CH3N, USART2 TX, encoder LPTIM2 B (LPTIM2 IN2), GPIO | +| `bundle1` | DIO8 | PB5 (lpuart1tx) | CN4-35 (D1) | modm-data, UM3301 Table 8 | LPUART1 TX, GPIO | +| `bundle1` | DIO9 | PA10 (lpuart1rx) | CN4-37 (D0) | modm-data, UM3301 Table 8 | LPUART1 RX, TIM3 CH1, encoder TIM3 A, GPIO | +| `bundle1` | DIO10 | PB9 (lpuart1rts) | CN4-23 (D7) | readmes (4) | LPUART1 RTS, TIM3 CH4, TIM16 CH1, encoder LPTIM2 A (LPTIM2 IN1), GPIO | +| `bundle1` | DIO11 | PB15 (lpuart1cts) | male pin in the D8 socket | Zephyr label | LPUART1 CTS, TIM16 break, GPIO | +| `bundle1` | DIO12 | PA1 (tim3ch2) | CN3-34 (A3) | readmes (1) | TIM3 CH2, encoder TIM3 B, TIM17 CH1, LPTIM2 CH2, GPIO | +| `bundle1` | DIO13 | PB14 (gpio0) | male pin in the D5 socket | Zephyr label | GPIO/EXTI, TIM3 CH3, encoder TIM3 index, watchdog warning toggle | +| `bundle1` | DIO14 | PA2 (tim1bkin) | CN3-32 (A2) | readmes (8) | TIM1 break input, GPIO | +| `bundle1` | DIO15 | PA5 (tim2ch1) | CN3-36 (A4) | readmes (7) | TIM2 CH1, GPIO | +| `bundle1` | W1 | PA7 (ain2) | CN3-28 (A0) | readmes (23) | ADC4 IN2 | +| `bundle1` | W2 | PA6 (ain3) | CN3-30 (A1) | readmes (10) | ADC4 IN3 | +| `bundle1` | Scope 1+ | PA7 (ain2) | male pin in the A0 socket (CN7-1) | readmes (12) | same net as W1 | +| `bundle1` | Scope 2+ | PA6 (ain3) | male pin in the A1 socket | Zephyr label | same net as W2 | +| `bundle1` | Scope 1-, 2- | GND | GND rail fed from CN5-6 | Sidewalk SDK README | see GND | +| `bundle2` | everything else | as `bundle1` | | | including GND and the loopback option; W1, W2 and the scopes of `bundle1` unplugged. DIO1 (PB3) is also the encoder LPTIM1 B (LPTIM1 IN2), DIO10 (PB9) the SPI3 MISO and DIO15 (PA5) the SPI3 NSS | +| `bundle2` | DIO8 | PA7 (i2c3sda) | CN3-28 (A0) | readmes (23) | I2C3 SDA, TIM2 CH3, GPIO | +| `bundle2` | DIO9 | PA6 (i2c3scl) | CN3-30 (A1) | readmes (10) | I2C3 SCL, TIM2 CH4, GPIO | +| `bundle2` | DIO11 | PB8 (spi3mosi) | CN4-38 | readmes (12) | SPI3 MOSI, TIM16 CH1N, GPIO; the red LD3 loads the pin; with `--with spiloop` the lead sits in the PB8 breadboard row | +| `bundle2` | DIO14 | PA0 (spi3clk) | CN3-38 (A5) | readmes (3) | SPI3 SCK, encoder LPTIM1 A (LPTIM1 IN1), GPIO | +| `bundle2` | Scope 1+, 2+ | PB2 (i2c1scl), PB1 (i2c1sda) | male pins in the SCL and SDA rows | - | only with `--with i2c` (rise time); 1- and 2- on the GND rail | + +### Optional wiring + +The options a set offers are in its `options` (board file); `--with ` enables one, and a tag no selected set offers, or two options that exclude each other, stop the run with a usage error. +The table lists the pins each option loads (tests on them skip unless they use the option) and pulls up; the tables after it give, per net, the AD3 lead already on it, where the jumper or breadboard wire goes, and the source of each position. + +| Board | Option | Sets | Loads | Pull-ups | Excludes | +|----------------|------------|----------------------|------------------------------------------|--------------------|------------| +| NUCLEO-WB55RG | `loopback` | `bundle1`, `bundle2` | PA6, PA7 | - | `spiloop` | +| NUCLEO-WB55RG | `i2c` | `bundle1` | PB8, PB9, PC0, PC1 | PB8, PB9, PC0, PC1 | - | +| NUCLEO-WB55RG | `spiloop` | `bundle1` | PA4-PA7, PB12-PB15 | - | `loopback` | +| NUCLEO-WBA55CG | `loopback` | `bundle1`, `bundle2` | PA15, PB3 | - | `spiloop` | +| NUCLEO-WBA55CG | `i2c` | `bundle2` | PB2, PB1, PA6, PA7 | PB2, PB1, PA6, PA7 | - | +| NUCLEO-WBA55CG | `spiloop` | `bundle2` | PB4, PA0, PB3, PB9, PA15, PB8, PA12, PA5 | - | `loopback` | + +#### NUCLEO-WB55RG `--with loopback` (`bundle1`, `bundle2`) + +One male-male wire from D11 to D12; the SPI tests then leave DIO7 an input and check the read-back. + +| Net | AD3 lead | Jumper or breadboard end | Source | +|-----------------|-----------------|--------------------------|-----------------| +| PA7 (SPI1 MOSI) | DIO1 at CN10-15 | D11 socket (CN5-4) | UM2435 Table 10 | +| PA6 (SPI1 MISO) | DIO7 at CN10-13 | D12 socket (CN5-5) | UM2435 Table 10 | + +#### NUCLEO-WB55RG `--with i2c` (`bundle1`) + +I2C1 to I2C3 with the EEPROM, ST's one-board pairing PB8-PC0 and PB9-PC1 (STM32CubeWB `Projects/P-NUCLEO-WB55.Nucleo/Examples_LL/I2C/I2C_OneBoard_Communication_IT/readme.txt`). `bundle2` cannot take it: its DIO9 sits on PC0. + +| Net | AD3 lead | Jumper or breadboard end | Source | +|-------------------------|----------------------------|----------------------------------------------------------------|--------------------------------------------------------------------------------------------| +| SCL row: PB8 (I2C1 SCL) | DIO3 at CN10-3 (unchanged) | D15 socket (CN5-10) to the SCL row | UM2435 Table 10; CN10-3 Table 11 | +| SCL row: PC0 (I2C3 SCL) | - | A0 socket (CN8-1) to the SCL row | UM2435 Table 10 | +| SDA row: PB9 (I2C1 SDA) | DIO5 at CN10-5 (unchanged) | D14 socket (CN5-9) to the SDA row | UM2435 Table 10; CN10-5 Table 11 | +| SDA row: PC1 (I2C3 SDA) | - | A1 socket (CN8-2) to the SDA row | UM2435 Table 10 | +| 3V3 rail | - | CN6-4 (3V3, female) to the rail | UM2435 Table 10; 7.5.2 "3V3 on CN6 pin 4 or CN7 pin 16 can be used as power supply output" | +| GND rail | - | CN6-6 (GND, female) to the rail; CN6-7 spare | UM2435 Table 10 (CN6 pins 6 and 7) | +| Pull-ups | - | 4.7 kOhm from the SCL row and from the SDA row to the 3V3 rail | [parts](#parts-for-the-i2c-option) | +| EEPROM | - | as in the parts list | [parts](#parts-for-the-i2c-option) | + +The AD3 observes SCL on DIO3 and SDA on DIO5. Both scope channels stay on the ADC inputs, so the rise time is measured on the NUCLEO-WBA55CG, whose bus has the same pull-up network. +Without the option the standalone I2C tests run on `bundle1` with the MCU pull-ups (`pull=up`): I2C1 on PB8/PB9 (DIO3/DIO5) and on PA9/PA10 (DIO2/DIO4), and I2C3 with SCL on PA7 (DIO1) and SDA on PB4 (CN10-4, UM2435 Table 11; no DIO, so SCL timing and the address NACK only). + +#### NUCLEO-WB55RG `--with spiloop` (`bundle1`) + +SPI1 to SPI2, ST's one-board pairing (STM32CubeWB `Projects/P-NUCLEO-WB55.Nucleo/Examples_LL/SPI/SPI_OneBoard_HalfDuplex_IT_Init/readme.txt`). SPI2 is observed through DIO6, DIO7, DIO1 and DIO14. + +| Net | AD3 lead | Jumper or breadboard end | Source | +|-------------------|--------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------|--------------------------------------| +| SCK: PA5 to PB13 | DIO6 at CN10-11 | D13 socket (CN5-6) to CN10-30 (PB13, via SB12) | UM2435 Table 10; Table 11 and Fig. 8 | +| MISO: PA6 to PB14 | DIO7 at CN10-13 | D12 socket (CN5-5) to CN10-28 (PB14) | UM2435 Table 10; Table 11 | +| MOSI: PA7 to PB15 | DIO1 at CN10-15 | D11 socket (CN5-4) to CN10-26 (PB15, via SB9) | UM2435 Table 10; Table 11 and Fig. 8 | +| NSS: PA4 to PB12 | DIO8 at CN10-17; DIO14 moved from CN10-16 to a male pin in the row | D10 socket (CN5-3, PA4 via SB41) to a breadboard row; CN10-16 (PB12, its only position) to the same row with a male-female wire | UM2435 Table 10; Table 11 and Fig. 8 | + +#### NUCLEO-WBA55CG `--with loopback` (`bundle1`, `bundle2`) + +One male-male wire from D11 to D12; the SPI tests then leave DIO1 an input and check the read-back. + +| Net | AD3 lead | Jumper or breadboard end | Source | +|------------------|----------------|--------------------------|----------------------------| +| PA15 (SPI1 MOSI) | DIO2 at CN4-15 | D11 socket (CN6-4) | Zephyr label; readmes (14) | +| PB3 (SPI1 MISO) | DIO1 at CN4-10 | D12 socket (CN6-5) | Zephyr label; readmes (11) | + +#### NUCLEO-WBA55CG `--with i2c` (`bundle2`) + +I2C1 (PB2/PB1) to I2C3 (PA6/PA7) with the EEPROM, ST's pairing (STM32CubeWBA `Projects/NUCLEO-WBA55CG/Examples_MIX/I2C/I2C_OneBoard_ComSlave7_10bits_IT/README.md`). `bundle1` cannot take it: its W1/W2 sit on PA7/PA6. + +| Net | AD3 lead | Jumper or breadboard end | Source | +|-------------------------|-------------------------------------------------------------------------------------|------------------------------------|-----------------------------------------------| +| SCL row: PB2 (I2C1 SCL) | DIO5 at CN4-3 | D15 socket (CN6-10) to the SCL row | Zephyr label; readmes (2) | +| SCL row: PA6 (I2C3 SCL) | DIO9 at CN3-30 | A1 socket to the SCL row | Zephyr label only | +| SDA row: PB1 (I2C1 SDA) | DIO6 at CN4-5 | D14 socket (CN6-9) to the SDA row | Zephyr label; readmes (2) | +| SDA row: PA7 (I2C3 SDA) | DIO8 at CN3-28 | A0 socket (CN7-1) to the SDA row | Zephyr label; readmes (12) | +| 3V3 rail | - | CN5-4 to the rail | Sidewalk SDK README | +| GND rail | AD3 GND moves from CN5-6 to the rail | CN5-6 to the rail | Sidewalk SDK README | +| Scopes (rise time) | scope 1+ on a male pin in the SCL row, 2+ in the SDA row, 1- and 2- on the GND rail | - | board file scope entries with `requires: i2c` | +| Pull-ups, EEPROM | - | as on the NUCLEO-WB55RG | [parts](#parts-for-the-i2c-option) | + +Without the option the standalone I2C tests run on `bundle1` with the MCU pull-ups (`pull=up`): I2C1 and I2C3 on PB2/PB1 (DIO5/DIO6) and I2C1 on PA15/PB3 (DIO2/DIO1). + +#### NUCLEO-WBA55CG `--with spiloop` (`bundle2`) + +SPI1 to SPI3; SPI3 is observed through DIO14, DIO10, DIO11 and DIO15. The PB8 end is the one the self-check drives: its LD3 load would spoil a pull-level read. + +| Net | AD3 leads | Jumper or breadboard end | Source | +|-------------------|------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------|----------------------------------------------------| +| SCK: PB4 to PA0 | DIO0 at CN4-6; DIO14 at CN3-38 | D13 socket (CN6-6) to the A5 socket (CN7-6) | Zephyr labels; readmes (12) and (9) | +| MISO: PB3 to PB9 | DIO1 at CN4-10; DIO10 at CN4-23 | D12 socket (CN6-5) to the D7 socket (CN8-8) | Zephyr labels; readmes (11) and (8) | +| MOSI: PA15 to PB8 | DIO2 at CN4-15; DIO11 moved from CN4-38 to a male pin in the row | D11 socket (CN6-4) to a breadboard row; CN4-38 (PB8, its only position) to the same row with a male-female wire | Zephyr label; readmes (14) and (12) | +| NSS: PA12 to PA5 | DIO3 at CN4-17; DIO15 at CN3-36 | D10 socket (CN6-3) to the A4 socket | Zephyr labels; CN6-3 readmes (1); A4 by label only | + +### Which tests skip, and how + +- Load gate: a test that resolves an AD3 channel (`need.dio`, `need.wavegen`, `need.scope`, `optional_dio`, `optional_scope`) for a pin an enabled option loads skips with "pin X loaded by --with T", unless it is marked `uses_option("T")` or `requires_option("T")`. Pins an option ties to a channel with a jumper resolve only for such tests. +- Without `--with i2c`: the I2C target, EEPROM, Fast-mode, rise-time, data-NACK, RELOAD, clock-stretch, bus-error and general-call tests skip (`requires_option("i2c")`); default timing, 100 kHz, the address NACK and arbitration loss run on `bundle1` with `pull=up`. +- With `--with i2c`: on the NUCLEO-WB55RG the PWM tests of TIM1 CH2N (PB8), TIM1 CH3N and TIM17 CH1 (PB9), the GPIO loop tests on PB8/PB9 and every QUADSPI test that uses IO0/IO1 skip; on the NUCLEO-WBA55CG `bundle2` the tests of TIM1 CH1N/CH2N and USART2 CTS/RTS (PB2/PB1) and of TIM2 CH3/CH4 (PA7/PA6) skip. +- Without `--with spiloop`: the SPI2 (NUCLEO-WB55RG) instance tests and the SPI1 to SPI2/SPI3 loops skip. With it, tests on its loaded pins skip unless they use the option, and `--with loopback` cannot be enabled as well. +- With `--with loopback`: tests on PA6/PA7 (NUCLEO-WB55RG) or PA15/PB3 (NUCLEO-WBA55CG) skip unless marked `uses_option("loopback")`: QUADSPI, the SPI slave cases with the AD3 as master, PWM TIM1 CH1N and TIM16 CH1 (NUCLEO-WB55RG), TIM17 break and the encoder index (NUCLEO-WBA55CG). +- NUCLEO-WBA55CG `bundle2`: the ADC, LPUART1 and TIM1/TIM16 break tests skip (no DIO, wavegen or scope on their pins). +- `--with` a tag that no selected wiring set offers, or without `--wiring-set`: the run stops with a usage error. ## What is tested diff --git a/validation/host/boards/nucleo_wb55rg.yaml b/validation/host/boards/nucleo_wb55rg.yaml index 18730cd0..b657d5c0 100644 --- a/validation/host/boards/nucleo_wb55rg.yaml +++ b/validation/host/boards/nucleo_wb55rg.yaml @@ -110,6 +110,14 @@ ad3: # stock board. CN10-6 (PB7) and CN10-34 (PB6) next to DIO5 and DIO11 are # the terminal to the ST-LINK: keep them free. PB3 (SWO) is on no header # of a stock board, so TIM2 CH2 / encoder B use PA1. +# Options (--with ) are fitted on top of a bundle, and only together +# with their tag: `jumpered` ties each key pin (the end test_wiring.py +# drives) to the listed pins, `loads` are the pins the wiring loads, +# `pullups` the pins it pulls up to 3V3, `excludes` the options that cannot +# be fitted at the same time. The I2C option sits on a breadboard +# (validation/README.md, "Optional wiring") with its rails fed from CN6-4 +# (3V3, UM2435 Table 10 and 7.5.2) and CN6-6 (GND, UM2435 Table 10, CN6 +# pins 6 and 7). bundle2 offers only the loopback: its DIO9 sits on PC0. wiring_sets: bundle1: description: >- @@ -117,10 +125,58 @@ wiring_sets: SPI1, LPUART1 with flow control, the TIM1/TIM2 encoders, GPIO, EXTI, LED, watchdog toggle and ADC1; W1/W2 on two ADC inputs options: - loopback: >- - jumper CN10-15 (PA7, SPI1 MOSI) to CN10-13 (PA6, SPI1 MISO), or D11 - to D12; the SPI tests then leave DIO7 an input and check the - read-back + loopback: + description: >- + jumper the D11 socket (CN5-4, PA7, SPI1 MOSI) to the D12 socket + (CN5-5, PA6, SPI1 MISO), UM2435 Table 10; CN10-15 and CN10-13 + carry the DIO1 and DIO7 leads. The SPI tests then leave DIO7 an + input and check the read-back. Fit the jumper only with --with + loopback; not together with --with spiloop (both use D11/D12) + jumpered: + PA7: [PA6] + loads: [PA6, PA7] + excludes: [spiloop] + i2c: + description: >- + I2C1 (PB8 SCL, PB9 SDA) to I2C3 (PC0 SCL, PC1 SDA) with a 24Cxx + EEPROM, ST's one-board pairing (STM32CubeWB + Examples_LL/I2C/I2C_OneBoard_Communication_IT readme), built on a + breadboard: D15 socket (CN5-10, PB8) and A0 socket (CN8-1, PC0) + to the SCL row; D14 socket (CN5-9, PB9) and A1 socket (CN8-2, PC1) + to the SDA row (UM2435 Table 10); CN6-4 (3V3, UM2435 Table 10 and + 7.5.2) to the 3V3 rail; CN6-6 (GND, UM2435 Table 10 CN6 pins 6 + and 7; CN6-7 spare) to the GND rail. 4.7 kOhm from the SCL row + and from the SDA row to the 3V3 rail. EEPROM (24LC256/AT24C256, + DIP-8): pins 1-3 (A0-A2, address 0x50), 4 (VSS) and 7 (WP) to the + GND rail, 5 to the SDA row, 6 to the SCL row, 8 (VCC) to the 3V3 + rail, 100 nF from VCC to GND at the part. The AD3 leads stay: + DIO3 (CN10-3) observes SCL, DIO5 (CN10-5) SDA. Measure 3.3 V + between the rails before inserting the EEPROM + jumpered: + PB8: [PC0] + PB9: [PC1] + loads: [PB8, PB9, PC0, PC1] + pullups: [PB8, PB9, PC0, PC1] + spiloop: + description: >- + SPI1 (PA5 SCK, PA6 MISO, PA7 MOSI, PA4 NSS) to SPI2 (PB13, PB14, + PB15, PB12), ST's one-board pairing (STM32CubeWB + Examples_LL/SPI/SPI_OneBoard_HalfDuplex_IT_Init readme): D13 + socket (CN5-6, PA5) to CN10-30 (PB13, via SB12); D12 socket + (CN5-5, PA6) to CN10-28 (PB14); D11 socket (CN5-4, PA7) to CN10-26 + (PB15, via SB9); D10 socket (CN5-3, PA4 via SB41) to a breadboard + row, CN10-16 (PB12, its only position) to the same row with an + M-F wire, and the DIO14 lead moved from CN10-16 to a male pin in + that row (UM2435 Tables 10 and 11, Fig. 8). SPI2 is observed + through DIO6, DIO7, DIO1 and DIO14. Not together with --with + loopback (both use D11/D12) + jumpered: + PA5: [PB13] + PA6: [PB14] + PA7: [PB15] + PA4: [PB12] + loads: [PA4, PA5, PA6, PA7, PB12, PB13, PB14, PB15] + excludes: [loopback] jumpers: - >- AD3 GND to CN10-9 and CN10-20; scope 1- and 2- to CN7-19 and CN7-20 @@ -132,27 +188,31 @@ wiring_sets: 0: pin: tim1ch1 note: >- - TIM1 CH1, encoder TIM1 A, GPIO (CN10-25; D6 = CN9-7) + TIM1 CH1, encoder TIM1 A, MCO, GPIO (CN10-25; D6 = CN9-7) 1: pin: tim1ch1n note: >- - TIM1 CH1N, SPI1 MOSI, GPIO (CN10-15 via SB1; D11 = CN5-4) + TIM1 CH1N, SPI1 MOSI, I2C3 SCL, QUADSPI IO2, GPIO (CN10-15 via + SB1; D11 = CN5-4) 2: pin: tim1ch2 note: >- - TIM1 CH2, encoder TIM1 B, GPIO (CN10-19 via SB8; D9 = CN5-2) + TIM1 CH2, encoder TIM1 B, I2C1 SCL, GPIO (CN10-19 via SB8; D9 = + CN5-2) 3: pin: tim1ch2n note: >- - TIM1 CH2N, GPIO (CN10-3; D15 = CN5-10) + TIM1 CH2N, I2C1 SCL, QUADSPI IO1, GPIO (CN10-3; D15 = CN5-10) 4: pin: tim1ch3 note: >- - TIM1 CH3, TIM17 break, GPIO (CN10-31 via SB11; D3 = CN9-4) + TIM1 CH3, TIM17 break, I2C1 SDA, GPIO (CN10-31 via SB11; D3 = + CN9-4) 5: pin: tim1ch3n note: >- - TIM1 CH3N, TIM17 CH1, GPIO (CN10-5; D14 = CN5-9) + TIM1 CH3N, TIM17 CH1, I2C1 SDA, QUADSPI IO0, GPIO (CN10-5; D14 = + CN5-9) 6: pin: spi1clk note: >- @@ -160,12 +220,13 @@ wiring_sets: 7: pin: spi1miso note: >- - SPI1 MISO, LPUART1 CTS, TIM16 CH1, EXTI line 6 partner of PC6, - GPIO (CN10-13; D12 = CN5-5) + SPI1 MISO, LPUART1 CTS, TIM16 CH1, QUADSPI IO3, EXTI line 6 + partner of PC6, GPIO (CN10-13; D12 = CN5-5) 8: pin: spi1cs note: >- - SPI1 chip select, GPIO (CN10-17 via SB5; D10 = CN5-3 via SB41) + SPI1 chip select and NSS, GPIO (CN10-17 via SB5; D10 = CN5-3 via + SB41) 9: pin: qei2a note: >- @@ -182,15 +243,17 @@ wiring_sets: 12: pin: lpuart1tx note: >- - LPUART1 TX, TIM2 CH3, GPIO (CN10-35 via SB15; D1 = CN9-2) + LPUART1 TX, TIM2 CH3, QUADSPI NCS, GPIO (CN10-35 via SB15; D1 = + CN9-2) 13: pin: lpuart1rx note: >- - LPUART1 RX, TIM2 CH4, GPIO (CN10-37; D0 = CN9-1) + LPUART1 RX, TIM2 CH4, QUADSPI CLK, GPIO (CN10-37; D0 = CN9-1) 14: pin: lpuart1rts note: >- - LPUART1 RTS, TIM1 break, GPIO (CN10-16) + LPUART1 RTS, TIM1 break, SPI2 NSS, GPIO (CN10-16; with --with + spiloop the lead sits in the PB12 breadboard row) 15: pin: led0 note: >- @@ -212,10 +275,17 @@ wiring_sets: LPTIM encoder tests and tests that need nothing on DIO9/DIO10/W2 run with it options: - loopback: >- - jumper CN10-15 (PA7, SPI1 MOSI) to CN10-13 (PA6, SPI1 MISO), or D11 - to D12; the SPI tests then leave DIO7 an input and check the - read-back + loopback: + description: >- + jumper the D11 socket (CN5-4, PA7, SPI1 MOSI) to the D12 socket + (CN5-5, PA6, SPI1 MISO), UM2435 Table 10; CN10-15 and CN10-13 + carry the DIO1 and DIO7 leads. The SPI tests then leave DIO7 an + input and check the read-back. Fit the jumper only with --with + loopback + jumpered: + PA7: [PA6] + loads: [PA6, PA7] + excludes: [spiloop] jumpers: - >- AD3 GND to CN10-9 and CN10-20; scope 1- and 2- to CN7-19 and CN7-20 @@ -227,27 +297,31 @@ wiring_sets: 0: pin: tim1ch1 note: >- - TIM1 CH1, encoder TIM1 A, GPIO (CN10-25; D6 = CN9-7) + TIM1 CH1, encoder TIM1 A, MCO, GPIO (CN10-25; D6 = CN9-7) 1: pin: tim1ch1n note: >- - TIM1 CH1N, SPI1 MOSI, GPIO (CN10-15 via SB1; D11 = CN5-4) + TIM1 CH1N, SPI1 MOSI, I2C3 SCL, QUADSPI IO2, GPIO (CN10-15 via + SB1; D11 = CN5-4) 2: pin: tim1ch2 note: >- - TIM1 CH2, encoder TIM1 B, GPIO (CN10-19 via SB8; D9 = CN5-2) + TIM1 CH2, encoder TIM1 B, I2C1 SCL, GPIO (CN10-19 via SB8; D9 = + CN5-2) 3: pin: tim1ch2n note: >- - TIM1 CH2N, GPIO (CN10-3; D15 = CN5-10) + TIM1 CH2N, I2C1 SCL, QUADSPI IO1, GPIO (CN10-3; D15 = CN5-10) 4: pin: tim1ch3 note: >- - TIM1 CH3, TIM17 break, GPIO (CN10-31 via SB11; D3 = CN9-4) + TIM1 CH3, TIM17 break, I2C1 SDA, GPIO (CN10-31 via SB11; D3 = + CN9-4) 5: pin: tim1ch3n note: >- - TIM1 CH3N, TIM17 CH1, GPIO (CN10-5; D14 = CN5-9) + TIM1 CH3N, TIM17 CH1, I2C1 SDA, QUADSPI IO0, GPIO (CN10-5; D14 = + CN5-9) 6: pin: spi1clk note: >- @@ -255,12 +329,13 @@ wiring_sets: 7: pin: spi1miso note: >- - SPI1 MISO, LPUART1 CTS, TIM16 CH1, EXTI line 6 partner of PC6, - GPIO (CN10-13; D12 = CN5-5) + SPI1 MISO, LPUART1 CTS, TIM16 CH1, QUADSPI IO3, EXTI line 6 + partner of PC6, GPIO (CN10-13; D12 = CN5-5) 8: pin: spi1cs note: >- - SPI1 chip select, GPIO (CN10-17 via SB5; D10 = CN5-3 via SB41) + SPI1 chip select and NSS, GPIO (CN10-17 via SB5; D10 = CN5-3 via + SB41) 9: pin: lptim1in1 note: >- @@ -277,15 +352,16 @@ wiring_sets: 12: pin: lpuart1tx note: >- - LPUART1 TX, TIM2 CH3, GPIO (CN10-35 via SB15; D1 = CN9-2) + LPUART1 TX, TIM2 CH3, QUADSPI NCS, GPIO (CN10-35 via SB15; D1 = + CN9-2) 13: pin: lpuart1rx note: >- - LPUART1 RX, TIM2 CH4, GPIO (CN10-37; D0 = CN9-1) + LPUART1 RX, TIM2 CH4, QUADSPI CLK, GPIO (CN10-37; D0 = CN9-1) 14: pin: lpuart1rts note: >- - LPUART1 RTS, TIM1 break, GPIO (CN10-16) + LPUART1 RTS, TIM1 break, SPI2 NSS, GPIO (CN10-16) 15: pin: led0 note: >- diff --git a/validation/host/boards/nucleo_wba55cg.yaml b/validation/host/boards/nucleo_wba55cg.yaml index 66f38c86..2eeed0ea 100644 --- a/validation/host/boards/nucleo_wba55cg.yaml +++ b/validation/host/boards/nucleo_wba55cg.yaml @@ -110,9 +110,19 @@ ad3: # Wavegen levels are refused outside this range (3.3 V I/O). analog_limits: [0.0, 3.3] -# One fixed bundle covers every test. Header positions follow the Nucleo-64 -# Arduino mapping (check against UM3390). Always connect AD3 GND to a Nucleo -# GND pin. +# Wiring sets: wire one, run its tests, then switch. The board manual is +# UM3301, which could not be fetched, so every position names its source: +# Arduino labels (Dn, An) from Zephyr's nucleo_wba55cg +# arduino_r3_connector.dtsi; CN3/CN4 morpho and CN6-CN8 Arduino positions +# from the STM32CubeWBA v1.10.0 example readmes ("readmes xN": N readmes +# agree); CN4-35/CN4-37 from modm-data's transcription of UM3301 Table 8; +# 3V3 CN5-4 and GND CN5-6 from the STM32-Sidewalk-SDK README (a single +# source: do the pre-power check of validation/README.md). AD3 leads sit +# on male morpho pins where a sourced position exists; DIO11/DIO13 use a +# male pin in their Arduino socket, and the A1/A4 sockets are named by +# their label only. Options as on the NUCLEO-WB55RG (`jumpered` key pin = +# the end test_wiring.py drives); bundle1 offers only the loopback (its +# W1/W2 sit on the I2C3 pins), bundle2 also the I2C and SPI loops. wiring_sets: bundle1: description: >- @@ -121,74 +131,308 @@ wiring_sets: TIM1/TIM3 encoders, GPIO, EXTI, LED, watchdog toggle and ADC4; W1/W2 on two ADC inputs options: - loopback: >- - jumper PA15 (SPI1 MOSI, D11) to PB3 (SPI1 MISO, D12); the SPI tests - then leave DIO1 an input and check the read-back + loopback: + description: >- + jumper the D11 socket (CN6-4, PA15, SPI1 MOSI) to the D12 socket + (CN6-5, PB3, SPI1 MISO): labels from Zephyr, positions from the + CubeWBA readmes (x14, x11); CN4-15 and CN4-10 carry the DIO2 and + DIO1 leads. The SPI tests then leave DIO1 an input and check the + read-back. Fit the jumper only with --with loopback + jumpered: + PA15: [PB3] + loads: [PA15, PB3] + excludes: [spiloop] jumpers: + - >- + AD3 GND to CN5-6 (Sidewalk SDK README: GND on CN5 pin 6, the only + sourced GND pin). Scope 1- and 2- need GND as well: feed a + breadboard GND rail from CN5-6 and put the AD3 GND, 1- and 2- leads + on male pins in that rail + - >- + check before wiring (modm-data, UM3301 Table 8): MB1801 SB7/SB8 + connect PA10/PB5 to the ST-LINK VCP2; MB1801 SB25 with MB1803 SB8 + on and SB9 off routes STLINK_RTS to PA0 (with MB1803 SB32 on and + SB31 off to PB15); SB23/SB25 route STLINK_CTS to PB9. None is the + stock setting; test_wiring.py::test_no_foreign_drivers checks that + PA0, PB9, PA10, PB5 and PB15 follow the MCU pulls - >- PB4 (D13) also drives the blue LED LD1, which loads the SPI1 clock line + - >- + PB8 carries the red LED LD3 (active low, Zephyr board file): the LED + and its resistor pull PB8 towards 3V3, so no pull-level or idle + assertion uses PB8. PA9 is the green LD2, the firmware's debug LED, + dark unless SB28 is closed (open on a stock board) dio: 0: pin: spi1clk - note: SPI1 SCK, TIM1 CH3, blue LED (led0), GPIO (D13) + note: >- + SPI1 SCK, TIM1 CH3, blue LED LD1 (led0), GPIO (CN4-6, readmes x15; + D13) 1: pin: spi1miso - note: SPI1 MISO, TIM1 CH4, TIM17 CH1N, GPIO (D12) + note: >- + SPI1 MISO, I2C1 SDA, TIM1 CH4, TIM17 CH1N, GPIO (CN4-10, readmes + x13; D12) 2: pin: spi1mosi - note: SPI1 MOSI, encoder TIM1 index, TIM17 break, GPIO (D11) + note: >- + SPI1 MOSI, I2C1 SCL, LPTIM1 CH2, encoder TIM1 index, TIM17 break, + GPIO (CN4-15, readmes x12; D11) 3: pin: spi1cs - note: SPI1 chip select, TIM1 CH2, encoder TIM1 B, GPIO (D10) + note: >- + SPI1 chip select and NSS, TIM1 CH2, encoder TIM1 B, GPIO (CN4-17, + readmes x10; D10) 4: pin: tim1ch1 - note: TIM1 CH1, encoder TIM1 A, USART2 RX, GPIO (D4) + note: >- + TIM1 CH1, encoder TIM1 A, USART2 RX, LPTIM2 CH1, GPIO (CN4-24, + readmes x7; D4) 5: pin: tim1ch1n - note: TIM1 CH1N, USART2 CTS, GPIO (D15) + note: >- + TIM1 CH1N, USART2 CTS, I2C1 SCL, I2C3 SCL, GPIO (CN4-3, readmes + x31; D15) 6: pin: tim1ch2n - note: TIM1 CH2N, USART2 RTS, GPIO (D14) + note: >- + TIM1 CH2N, USART2 RTS, I2C1 SDA, I2C3 SDA, GPIO (CN4-5, readmes + x31; D14) 7: pin: tim1ch3n - note: TIM1 CH3N, USART2 TX, GPIO (D6) + note: >- + TIM1 CH3N, USART2 TX, encoder LPTIM2 B (LPTIM2 IN2), GPIO (CN4-25, + readme x1; D6) 8: pin: lpuart1tx - note: LPUART1 TX, GPIO (D1) + note: >- + LPUART1 TX, GPIO (CN4-35, modm-data UM3301 Table 8; D1) 9: pin: lpuart1rx - note: LPUART1 RX, TIM3 CH1, encoder TIM3 A, GPIO (D0) + note: >- + LPUART1 RX, TIM3 CH1, encoder TIM3 A, GPIO (CN4-37, modm-data + UM3301 Table 8; D0) 10: pin: lpuart1rts - note: LPUART1 RTS, TIM3 CH4, TIM16 CH1, GPIO (D7) + note: >- + LPUART1 RTS, TIM3 CH4, TIM16 CH1, encoder LPTIM2 A (LPTIM2 IN1), + GPIO (CN4-23, readmes x4; D7) 11: pin: lpuart1cts - note: LPUART1 CTS, TIM16 break, GPIO (D8) + note: >- + LPUART1 CTS, TIM16 break, GPIO (male pin in the D8 socket: no + sourced morpho position) 12: pin: tim3ch2 - note: TIM3 CH2, encoder TIM3 B, TIM17 CH1, GPIO (A3) + note: >- + TIM3 CH2, encoder TIM3 B, TIM17 CH1, LPTIM2 CH2, GPIO (CN3-34, + readme x1; A3) 13: pin: gpio0 note: >- GPIO/EXTI, TIM3 CH3, encoder TIM3 index, watchdog warning toggle - (D5) + (male pin in the D5 socket: no sourced morpho position) 14: pin: tim1bkin - note: TIM1 break input, GPIO (A2) + note: >- + TIM1 break input, GPIO (CN3-32, readmes x8; A2) 15: pin: tim2ch1 - note: TIM2 CH1, GPIO (A4) + note: >- + TIM2 CH1, GPIO (CN3-36, readmes x7; A4) wavegen: 1: pin: ain2 - note: ADC4 IN2 (A0) + note: ADC4 IN2 (CN3-28, readmes x23; A0) + 2: + pin: ain3 + note: ADC4 IN3 (CN3-30, readmes x10; A1) + scope: + 1: + pin: ain2 + note: >- + same net as W1: male pin in the A0 socket (CN7-1, readmes x12), + 1- on the GND rail 2: pin: ain3 - note: ADC4 IN3 (A1) + note: >- + same net as W2: male pin in the A1 socket (label only), 2- on the + GND rail + bundle2: + description: >- + bundle1 with DIO8 on PA7, DIO9 on PA6, DIO11 on PB8 and DIO14 on PA0; + W1, W2 and both scopes unplugged (with --with i2c the scopes sit in + the I2C rows). SPI3 master and slave, PWM TIM2 CH3/CH4 and TIM16 + CH1N, timer PWM TIM2 with four channels, the LPTIM1 encoder (PA0, + PB3), the I2C and SPI loop options, and everything of bundle1 that + needs none of PB5, PA10, PB15, PA2, W1 and W2. The LPUART1 tests, the + TIM1/TIM16 break tests and the ADC tests skip + options: + loopback: + description: >- + jumper the D11 socket (CN6-4, PA15, SPI1 MOSI) to the D12 socket + (CN6-5, PB3, SPI1 MISO): labels from Zephyr, positions from the + CubeWBA readmes (x14, x11); CN4-15 and CN4-10 carry the DIO2 and + DIO1 leads. The SPI tests then leave DIO1 an input and check the + read-back. Fit the jumper only with --with loopback + jumpered: + PA15: [PB3] + loads: [PA15, PB3] + excludes: [spiloop] + i2c: + description: >- + I2C1 (PB2 SCL, PB1 SDA) to I2C3 (PA6 SCL, PA7 SDA) with a 24Cxx + EEPROM, ST's pairing (STM32CubeWBA + Examples_MIX/I2C/I2C_OneBoard_ComSlave7_10bits_IT README), built on + a breadboard: D15 socket (CN6-10, PB2) and A1 socket (PA6, label + only) to the SCL row; D14 socket (CN6-9, PB1) and A0 socket (CN7-1, + PA7) to the SDA row (labels from Zephyr, positions from the CubeWBA + readmes); CN5-4 (3V3) to the 3V3 rail and CN5-6 (GND) to the GND + rail (Sidewalk SDK README); the AD3 GND lead moves from CN5-6 to + the GND rail. 4.7 kOhm from the SCL row and from the SDA row to + the 3V3 rail (2.2 kOhm when test_rise_time fails). EEPROM wired as + on the NUCLEO-WB55RG (validation/README.md). Scope 1+ on a male pin + in the SCL row, 2+ in the SDA row, 1- and 2- on the GND rail. + DIO5 (CN4-3) and DIO9 (CN3-30) observe SCL, DIO6 (CN4-5) and DIO8 + (CN3-28) SDA. Measure 3.3 V between the rails before inserting the + EEPROM + jumpered: + PB2: [PA6] + PB1: [PA7] + loads: [PB2, PB1, PA6, PA7] + pullups: [PB2, PB1, PA6, PA7] + spiloop: + description: >- + SPI1 (PB4 SCK, PB3 MISO, PA15 MOSI, PA12 NSS) to SPI3 (PA0, PB9, + PB8, PA5): D13 socket (CN6-6, PB4) to the A5 socket (CN7-6, PA0); + D12 socket (CN6-5, PB3) to the D7 socket (CN8-8, PB9); D11 socket + (CN6-4, PA15) to a breadboard row, CN4-38 (PB8, its only position) + to the same row with an M-F wire, and the DIO11 lead moved from + CN4-38 to a male pin in that row; D10 socket (CN6-3, PA12) to the + A4 socket (PA5, label only). Labels from Zephyr, positions from + the CubeWBA readmes. SPI3 is observed through DIO14, DIO10, DIO11 + and DIO15. Not together with --with loopback (both use D11/D12) + jumpered: + PB8: [PA15] + PA0: [PB4] + PB9: [PB3] + PA5: [PA12] + loads: [PB4, PA0, PB3, PB9, PA15, PB8, PA12, PA5] + excludes: [loopback] + jumpers: + - >- + AD3 GND to CN5-6 (Sidewalk SDK README: GND on CN5 pin 6, the only + sourced GND pin); with --with i2c the lead moves to the breadboard + GND rail fed from CN5-6 + - >- + check before wiring (modm-data, UM3301 Table 8): MB1801 SB7/SB8 + connect PA10/PB5 to the ST-LINK VCP2; MB1801 SB25 with MB1803 SB8 + on and SB9 off routes STLINK_RTS to PA0 (with MB1803 SB32 on and + SB31 off to PB15); SB23/SB25 route STLINK_CTS to PB9. None is the + stock setting; test_wiring.py::test_no_foreign_drivers checks that + PA0, PB9, PA10, PB5 and PB15 follow the MCU pulls + - >- + PB4 (D13) also drives the blue LED LD1, which loads the SPI1 clock + line + - >- + PB8 (DIO11) carries the red LED LD3 (active low, Zephyr board + file): the LED and its resistor pull PB8 towards 3V3, so no + pull-level or idle assertion uses PB8, and the TIM16 entry of this + set has no break input. PA9 is the green LD2, the firmware's debug + LED, dark unless SB28 is closed (open on a stock board) + dio: + 0: + pin: spi1clk + note: >- + SPI1 SCK, TIM1 CH3, blue LED LD1 (led0), GPIO (CN4-6, readmes x15; + D13) + 1: + pin: spi1miso + note: >- + SPI1 MISO, I2C1 SDA, TIM1 CH4, TIM17 CH1N, encoder LPTIM1 B + (LPTIM1 IN2), GPIO (CN4-10, readmes x13; D12) + 2: + pin: spi1mosi + note: >- + SPI1 MOSI, I2C1 SCL, LPTIM1 CH2, encoder TIM1 index, TIM17 break, + GPIO (CN4-15, readmes x12; D11) + 3: + pin: spi1cs + note: >- + SPI1 chip select and NSS, TIM1 CH2, encoder TIM1 B, GPIO (CN4-17, + readmes x10; D10) + 4: + pin: tim1ch1 + note: >- + TIM1 CH1, encoder TIM1 A, USART2 RX, LPTIM2 CH1, GPIO (CN4-24, + readmes x7; D4) + 5: + pin: tim1ch1n + note: >- + TIM1 CH1N, USART2 CTS, I2C1 SCL, I2C3 SCL, GPIO (CN4-3, readmes + x31; D15) + 6: + pin: tim1ch2n + note: >- + TIM1 CH2N, USART2 RTS, I2C1 SDA, I2C3 SDA, GPIO (CN4-5, readmes + x31; D14) + 7: + pin: tim1ch3n + note: >- + TIM1 CH3N, USART2 TX, encoder LPTIM2 B (LPTIM2 IN2), GPIO (CN4-25, + readme x1; D6) + 8: + pin: i2c3sda + note: >- + I2C3 SDA, TIM2 CH3, GPIO (CN3-28, readmes x23; A0) + 9: + pin: i2c3scl + note: >- + I2C3 SCL, TIM2 CH4, GPIO (CN3-30, readmes x10; A1) + 10: + pin: lpuart1rts + note: >- + SPI3 MISO, LPUART1 RTS, TIM3 CH4, TIM16 CH1, encoder LPTIM2 A + (LPTIM2 IN1), GPIO (CN4-23, readmes x4; D7) + 11: + pin: spi3mosi + note: >- + SPI3 MOSI, TIM16 CH1N, GPIO; red LED LD3 (CN4-38, readmes x12; no + Arduino socket; with --with spiloop the lead sits in the PB8 + breadboard row) + 12: + pin: tim3ch2 + note: >- + TIM3 CH2, encoder TIM3 B, TIM17 CH1, LPTIM2 CH2, GPIO (CN3-34, + readme x1; A3) + 13: + pin: gpio0 + note: >- + GPIO/EXTI, TIM3 CH3, encoder TIM3 index, watchdog warning toggle + (male pin in the D5 socket: no sourced morpho position) + 14: + pin: spi3clk + note: >- + SPI3 SCK, encoder LPTIM1 A (LPTIM1 IN1), GPIO (CN3-38, readmes x3; + A5) + 15: + pin: tim2ch1 + note: >- + TIM2 CH1, SPI3 NSS, GPIO (CN3-36, readmes x7; A4) scope: - 1: ain2 - 2: ain3 + 1: + pin: i2c1scl + requires: i2c + note: >- + male pin in the SCL breadboard row (PB2 and PA6), 1- on the GND + rail + 2: + pin: i2c1sda + requires: i2c + note: >- + male pin in the SDA breadboard row (PB1 and PA7), 2- on the GND + rail # Driver gaps the HIL tests run into (tests/conftest.py): `tests` match the # test ids relative to tests/hil (`*` and `?` are wildcards, brackets are @@ -239,6 +483,11 @@ tests: # Names that are no pin: ERR pin. invalid_pins: [PA16, PA08, PD0, LED0, nosuchalias] delays_ms: [10, 200] + wiring: + # Pins nothing on the board may drive: ST-LINK lines reach them only + # through solder bridges that are open on a stock board + # (test_wiring.py::test_no_foreign_drivers). + undriven: [PA0, PB9, PA10, PB5, PB15] gpio: # bundle1 pins used as plain GPIO, on ports A and B. loop_pins: [gpio0, PA11, PB0, PA12, PA5] diff --git a/validation/host/tests/unit/test_config.py b/validation/host/tests/unit/test_config.py index 1a32b30b..d03a2031 100644 --- a/validation/host/tests/unit/test_config.py +++ b/validation/host/tests/unit/test_config.py @@ -79,7 +79,41 @@ def test_bundles_wire_each_dio_once(name): def test_bundle_sets(): assert set(load_board("nucleo_wb55rg").wiring_sets) == {"bundle1", "bundle2"} - assert set(load_board("nucleo_wba55cg").wiring_sets) == {"bundle1"} + assert set(load_board("nucleo_wba55cg").wiring_sets) == {"bundle1", "bundle2"} + + +@pytest.mark.parametrize( + ("name", "offered"), + [ + ("nucleo_wb55rg", {"bundle1": {"loopback", "i2c", "spiloop"}, "bundle2": {"loopback"}}), + ("nucleo_wba55cg", {"bundle1": {"loopback"}, "bundle2": {"loopback", "i2c", "spiloop"}}), + ], +) +def test_bundle_options(name, offered): + """The options each set offers; loopback and spiloop share pins, so they exclude each other.""" + board = load_board(name) + assert {bundle: set(wiring_set.options) for bundle, wiring_set in board.wiring_sets.items()} == offered + for bundle, tags in offered.items(): + if {"loopback", "spiloop"} <= tags: + with pytest.raises(ConfigError): + board.wiring([bundle], ["loopback", "spiloop"]) + for tag in tags: + option = board.wiring([bundle], [tag]).options[tag] + assert option.loads and set(option.jumpered) <= set(option.loads), (bundle, tag) + assert set(option.pullups) <= set(option.loads), (bundle, tag) + + +def test_wba55_bundle2_moves_four_dios(): + board = load_board("nucleo_wba55cg") + bundle1, bundle2 = board.wiring(["bundle1"]), board.wiring(["bundle2"]) + assert [bundle1.dio(pin) for pin in ("PB5", "PA10", "PB15", "PA2")] == [8, 9, 11, 14] + assert [bundle2.dio(pin) for pin in ("PA7", "PA6", "PB8", "PA0")] == [8, 9, 11, 14] + assert bundle2.wavegen("PA7") is None and bundle2.scope("PB2") is None + with_i2c = board.wiring(["bundle2"], ["i2c"]) + assert with_i2c.scope("PB2") == 1 and with_i2c.scope("PA6", allowed=["i2c"]) == 1 + assert with_i2c.loaded("PA6") == ("i2c",) and with_i2c.scope("PA6") is None + with pytest.raises(ConfigError): + board.wiring(["bundle1", "bundle2"]) def test_wb55_bundle2_moves_the_encoder_inputs(): From 2856e2872e65ac2f715acf182e656f49bb858da9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:25:55 +0000 Subject: [PATCH 39/57] feat(validation): validate I2cStm with an I2C target scaffold and an external EEPROM Add the i2c group (hal::I2cStm through I2cStmForHil, whose DeviceNotFound, BusError and ArbitrationLost hooks print EVT i2c), the i2cs group (an LL I2C target on the other instance: register file or sink, per-byte ACK control with slave byte control, clock stretching and a misplaced STOP on request) and eeprom.attach/eeprom.detach, which serve EMIL's eeprom group through I2cEepromStm, a hal::Eeprom over hal::I2cMaster with page writes, ACK polling and random reads. I2cTiming computes TIMINGR from the kernel clock and the bus frequency, never faster than nominal; the host twin expected_timing reproduces it bit for bit (PROTOCOL.md table). The i2c, i2cs and eeprom groups exclude each other per instance through ResourceAllocation; eeprom.write/read/erase leave the unsupported list. test_i2c.py runs the standalone cases with the MCU pull-ups (default and computed timing, address NACK and recovery, zero-length probe, arbitration loss held and triggered) and, with --with i2c, the loop against the target (Fast mode, rise time, data NACK, NBYTES/RELOAD lengths, repeated START, continued sessions, stretching, bus error, general call, held bus, both directions). test_eeprom.py covers EMIL's eeprom group on a 24Cxx and the 1-byte word address against the target. The fake firmware models the three groups on one bus with the target and a 24LC256. test_unsupported.py and its README bullet no longer name EEPROM. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- validation/PROTOCOL.md | 67 ++ validation/README.md | 10 +- validation/firmware/CMakeLists.txt | 10 + validation/firmware/EepromGroup.cpp | 209 +++++++ validation/firmware/EepromGroup.hpp | 62 ++ validation/firmware/I2cEepromStm.cpp | 213 +++++++ validation/firmware/I2cEepromStm.hpp | 79 +++ validation/firmware/I2cGroup.cpp | 436 +++++++++++++ validation/firmware/I2cGroup.hpp | 134 ++++ validation/firmware/I2cTarget.cpp | 571 +++++++++++++++++ validation/firmware/I2cTarget.hpp | 160 +++++ validation/firmware/I2cTiming.cpp | 109 ++++ validation/firmware/I2cTiming.hpp | 12 + validation/firmware/Main.cpp | 11 + validation/firmware/UnsupportedGroups.cpp | 5 +- validation/host/boards/nucleo_wb55rg.yaml | 59 ++ validation/host/boards/nucleo_wba55cg.yaml | 51 ++ .../host/hal_st_validation/fakes/i2c.py | 550 +++++++++++++++++ .../host/hal_st_validation/groups/i2c.py | 307 ++++++++++ validation/host/hal_st_validation/i2c.py | 409 +++++++++++++ validation/host/tests/hil/test_eeprom.py | 219 +++++++ validation/host/tests/hil/test_i2c.py | 573 ++++++++++++++++++ validation/host/tests/hil/test_unsupported.py | 3 +- validation/host/tests/unit/test_fake_i2c.py | 320 ++++++++++ validation/host/tests/unit/test_i2c_decode.py | 205 +++++++ validation/host/tests/unit/test_i2c_timing.py | 122 ++++ 26 files changed, 4900 insertions(+), 6 deletions(-) create mode 100644 validation/firmware/EepromGroup.cpp create mode 100644 validation/firmware/EepromGroup.hpp create mode 100644 validation/firmware/I2cEepromStm.cpp create mode 100644 validation/firmware/I2cEepromStm.hpp create mode 100644 validation/firmware/I2cGroup.cpp create mode 100644 validation/firmware/I2cGroup.hpp create mode 100644 validation/firmware/I2cTarget.cpp create mode 100644 validation/firmware/I2cTarget.hpp create mode 100644 validation/firmware/I2cTiming.cpp create mode 100644 validation/firmware/I2cTiming.hpp create mode 100644 validation/host/hal_st_validation/fakes/i2c.py create mode 100644 validation/host/hal_st_validation/groups/i2c.py create mode 100644 validation/host/hal_st_validation/i2c.py create mode 100644 validation/host/tests/hil/test_eeprom.py create mode 100644 validation/host/tests/hil/test_i2c.py create mode 100644 validation/host/tests/unit/test_fake_i2c.py create mode 100644 validation/host/tests/unit/test_i2c_decode.py create mode 100644 validation/host/tests/unit/test_i2c_timing.py diff --git a/validation/PROTOCOL.md b/validation/PROTOCOL.md index f634c38a..76751f53 100644 --- a/validation/PROTOCOL.md +++ b/validation/PROTOCOL.md @@ -177,6 +177,73 @@ The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix - `wdt.feed ` → `OK` - Early warning: `EVT wdt index=0 warning=`. After a watchdog reset the next `EVT boot` reports `reset=wwdg`. +## I2C master (`hal::I2cStm`) + +- `i2c.open scl= sda= [freq=] [timing=] [pull=none|up]` → `OK timing=<0x........> kernel=` + - checks in order: `scl` or `sda` missing, or `freq` together with `timing` → `ERR usage`; an index other than 1 and 3, `freq` outside 20000-400000 → `ERR range`; a pin without the `i2cScl`/`i2cSda` function of that instance → `ERR pin`; the instance held by `i2c`, `i2cs` or `eeprom.attach`, or a pin held → `ERR busy` + - neither `freq` nor `timing`: the driver's default `Config` (TIMINGR 0x70B03D3D on STM32WB/WBA: Standard mode at 63.8 kHz with a 64 MHz kernel clock, 99.2 kHz at 100 MHz); `freq` takes the TIMINGR below; `timing` is written as given (`HAL_I2C_Init` drops the reserved bits 24-27) + - `timing` in the reply is TIMINGR read back from the peripheral, `kernel` the I2C kernel clock + - `pull=up` turns on the MCU pull-ups of both pins after the driver configured them (validation scaffolding; the pinout table configures the I2C pins open drain without pull): enough for Standard mode on a short bus without resistors; Fast mode needs external pull-ups. Default `none` +- `freq` → TIMINGR (`I2cTiming`, validation/firmware/I2cTiming.cpp), in integer picoseconds (`tclk = 10^12 / kernel`, `target = 10^12 / freq`, both rounded down) + - Standard mode up to 100 kHz, Fast mode above: tLOW > 4700/1300 ns, tHIGH >= 4000/600 ns, rise 640/250 ns, fall 20/100 ns, tSU;DAT 250/100 ns, tVD;DAT 3450/900 ns; analog filter 50-260 ns, digital filter off + - per prescaler 0-15: the first SCLDEL with `(SCLDEL + 1) × tpresc >= rise + tSU;DAT` and the first SDADEL with `fall - 50 ns - 3 × tclk <= (SDADEL × (PRESC + 1) + 1) × tclk <= tVD;DAT - rise - 260 ns - 4 × tclk` (both bounds at least 0) + - then the minimal SCLL/SCLH meeting tLOW/tHIGH, with `sync = 50 ns + 2 × tclk` added to each phase, and the fewest `SCLL + SCLH + 2` clock units with `2 × sync + fall + units × tpresc >= target`, the extra units going to SCLL first + - SCLL and SCLH stay within 255; the prescaler with the shortest such period wins (ties: the smaller prescaler). With a zero rise time the bus never runs faster than `freq`; a slow rise makes it slower + - examples: + + | kernel | 20 kHz | 50 kHz | 100 kHz | 400 kHz | + |---------|------------|------------|------------|------------| + | 16 MHz | 0x20408186 | 0x00e097a2 | 0x00e04752 | 0x00500916 | + | 32 MHz | 0x80305659 | 0x2090656c | 0x10e04853 | 0x00b0162e | + | 64 MHz | 0x90509ca1 | 0x80604348 | 0x40b03943 | 0x10b0172f | + | 100 MHz | 0xd060aeb4 | 0x70b0777f | 0x50e04b57 | 0x20b11931 | + +- `i2c.write [next=stop|restart|continue] [len=] [pattern=] [seed=]` → `OK sent= result=` + - `addr` 0-0x7f, 0 being the general call; the data is hex or generated with `len` (1-1024, `pattern=inc|const|prbs` `seed=`, see "Line length" in General); `-` without `len` writes no data (an address probe, NBYTES=0) + - `next` maps to `hal::Action`: `stop` (default), `restart` (no STOP, the next transfer starts with a repeated START), `continue` (the next transfer continues the same transaction without START or address) + - `sent` is `numberOfBytesSent`, the acknowledged data bytes; `result` maps `Result::complete`, `partialComplete` (`nack`: the address or a data byte was not acknowledged) and `busError` (`buserror`: bus error or arbitration lost) + - `ERR timeout` after 1000 ms without completion; the group then answers `ERR busy` until `i2c.close` +- `i2c.read [next=stop|restart|continue] [out=hex|crc]` → `OK result=complete data=` or, with `out=crc`, `OK result=complete len= crc=`; `OK result=nack` or `OK result=buserror` without data + - `len` 1-1024, above 128 only with `out=crc`; `next` and the timeout as for `i2c.write` +- `i2c.close ` → `OK`; disables the interrupts and destroys the driver one event-loop turn later, so completions already queued run first; a held bus (`next=continue`) is released +- `EVT i2c index= hook=notfound|buserror|arblost` when the driver calls `DeviceNotFound`, `BusError` or `ArbitrationLost`, printed before the final line of the transfer + +## I2C target (validation scaffolding, LL on the other instance) + +Not a hal-st driver (hal-st has no I2C slave): the other end of the bus for the `i2c` tests, written with the LL I2C functions on the EV/ER interrupts of its instance (`hal::cortex::ImmediateInterruptHandler`). Writes use slave byte control: every received byte stops SCL before its acknowledge, so the target decides ACK or NACK per byte; reads load one byte per transmit request. + +- `i2cs.open scl= sda= [addr=<0x08-0x77>] [mode=regs|sink] [timing=]` → `OK addr=<0x..>` + - checks as for `i2c.open`; the instance must not be held by `i2c` or `eeprom.attach` (`ERR busy`) + - default `addr=0x42` (0x50-0x57 stay free for an EEPROM) + - `mode=regs` (default): a 256-byte register file; the first byte of a write sets the pointer, the following bytes are stored at pointer++ (wrapping at 256), reads return the registers from the pointer on; `mode=sink`: written bytes only update the counters and the CRC, reads return the pattern of `i2cs.cfg`, from its start in every read + - `timing` only sets the data setup and hold times (SCLDEL, SDADEL); default the 400 kHz `I2cTiming` value for the kernel clock +- `i2cs.cfg [nack=] [addrnack=0|1] [stretch=] [stretchat=] [fault=none|stop] [faultat=] [pattern=inc|const|prbs] [seed=]` → `OK` + - replaces the whole behaviour: options left out take their defaults `nack=0 addrnack=0 stretch=0 stretchat=0 fault=none faultat=1 pattern=inc seed=0` + - `nack=k` NACKs data byte k (1-based) of every write (0 = never); the NACKed byte is not counted or stored + - `addrnack=1` turns the own address off: the address is NACKed + - `stretch` (0-10000 us) holds SCL low at byte `stretchat`: 0 is the address phase (both directions), k >= 1 a written byte before its acknowledge, or a read byte before it is loaded; for read bytes after the first the byte in flight shortens the visible gap. The target busy-waits in its interrupt, which blocks the terminal meanwhile + - `fault=stop faultat=k` (k >= 1) applies to the next read once: when byte k is requested, the target drives SDA and SCL low as GPIO open-drain outputs, releases SCL, waits up to 1 ms for SCL to read high and releases SDA: a STOP in the middle of a byte, which the master reports as a bus error. The target then resets (PE off and on, own address kept) and restores the pins. Run it at 100 kHz +- `i2cs.status [clear=0|1]` → `OK rx= tx= crc= writes= reads= stops= nacked= errors= last=` + - since the open or the last `clear=1` (which clears after printing): received and transmitted data bytes, the CRC-32 of the received bytes, addressed writes and reads, STOP conditions, NACKed data bytes, bus errors, arbitration losses and overruns seen by the target, and the first 32 data bytes of the last write +- `i2cs.regs ` → `OK`; preloads the register file (offset + length at most 256) +- `i2cs.dump ` → `OK data=`; `len` 1-128, offset + length at most 256 +- `i2cs.close ` → `OK` + +## EEPROM (`services::HilEepromCommands` over the `I2cEepromStm` adapter) + +- `eeprom.attach scl= sda= [addr=<0x08-0x77>] [size=] [page=] [abytes=1|2] [freq=] [wcycle=]` → `OK` + - defaults for a 24LC256/AT24C256: `addr=0x50 size=32768 page=64 abytes=2 freq=400000 wcycle=10` + - `size` 1-65536, `page` 8-256 and a power of two, `abytes` the word address bytes (with `abytes=1` at most 256 bytes), `wcycle` 0-20 ms, `freq` as for `i2c.open` (`ERR range` outside); checks in the order of `i2c.open` + - the adapter builds its own `hal::I2cStm`, whose hooks only count (no `EVT i2c`); `ERR busy` when attached already or when the instance or a pin is held +- `eeprom.detach` → `OK`; `ERR busy` while an adapter transfer is in flight, `ERR notopen` when nothing is attached; after an error (below) it first completes the pending `eeprom.*` command (which prints nothing if it timed out already) and then detaches, so the group works again +- `eeprom.write
` → `OK`, `eeprom.read
` → `OK data=` (`len` 1-128), `eeprom.erase` → `OK`: EMIL's group (`HilEepromCommands`), `ERR range` beyond `size`, `ERR timeout` after 5 s. Without an attached EEPROM the size is 0: writes and reads answer `ERR range`, `eeprom.erase` `OK` at once +- the adapter: + - writes page by page: each page is one transfer of the word address and the data, ending with a STOP + - after each page it polls: it repeats only the word address (which starts no write cycle) from each completion until the chip acknowledges, at most `wcycle` ms; `wcycle=0` does not poll + - reads send the word address, then read after a repeated START + - `eeprom.erase` writes 0xFF pages over `size` (about 6.7 ms per 64-byte page at 400 kHz: attach with a `size` that fits EMIL's 5 s) + - a NACK or bus error outside the polling prints `EVT eeprom error= address=` and leaves the command pending (EMIL answers `ERR timeout`); `eeprom.detach` recovers + ## Not available on these boards hal-st has no comparator, CAN or Ethernet driver for STM32WB55/STM32WBA55, so `comp.open`, `comp.read`, `comp.irq`, `comp.count`, `comp.close`, `can.open`, `can.send`, `can.close`, `eth.open`, `eth.status` and `eth.close` return `ERR unsupported`. diff --git a/validation/README.md b/validation/README.md index a580bfe4..dd8b75a9 100644 --- a/validation/README.md +++ b/validation/README.md @@ -463,7 +463,13 @@ SPI1 to SPI3; SPI3 is observed through DIO14, DIO10, DIO11 and DIO15. The PB8 en - `test_adc.py` - wavegen DC levels against raw 12-bit codes (checked against the scope when it is wired), sequences of up to 8 conversions, every sampling time, timer-triggered rates and the measure timeout, timer sharing with PWM and the encoder, unsupported trigger timers, argument errors and limits, and ADC pins against GPIO. - `test_qei.py` - position counts for frequency x cycles x direction x decoding x phase inversion, physically inverted phases restored by `inva`/`invb`, offset and rollover, speed, the index input through `qei.index`, the LPTIM1 encoder on the NUCLEO-WB55RG, default pins, the resolution limits, argument errors, one encoder at a time and timer sharing. - `test_watchdog.py` - the window watchdog with timeouts across its range x automatic or manual feeding (warnings, no reset while fed, `reset=wwdg` otherwise), the early-warning period measured on the `pin=` toggle, manual feeding, a single watchdog at a time, the timeout limits and argument errors. -- `test_unsupported.py` - every comparator, CAN, EEPROM and Ethernet command answers `ERR unsupported`. +- `test_i2c.py` - `I2cStm` on both instances. + Without wiring, each instance on its own pins with the MCU pull-ups (`pull=up`): the default and computed TIMINGR (reply and bus timing against the Standard mode minima), address NACK and its recovery in both directions, the zero-length probe, arbitration loss with SDA held before `i2c.open` and pulled low by the AD3 inside the second address bit, and argument errors. + With `--with i2c` (the master against the `i2cs` LL target on the other instance, 4.7 kOhm pull-ups): Standard and Fast mode timing and the rise time, data NACK at every position, the address NACK after the other direction, writes and reads of 1-1024 bytes across the NBYTES/RELOAD boundaries, repeated START, continued sessions in both directions, + clock stretching, a bus error from a misplaced STOP, the general call, close while the bus is held and both instance directions. +- `test_eeprom.py` - EMIL's `eeprom.*` over the `I2cEepromStm` adapter on the external 24Cxx (`--with i2c`): the address probe, attach/detach, writes inside and across pages, reads across pages, write-read-write-read, erase, data across a reset, the size limit, + ACK polling on the logic analyzer, recovery from an absent chip, detach while busy, and 1-byte word addresses against the `i2cs` register file. +- `test_unsupported.py` - every comparator, CAN and Ethernet command, and the commands of the groups the running MCU lacks (PROTOCOL.md, "Not available on these boards"), answer `ERR unsupported`. ## Customising @@ -500,6 +506,7 @@ In `hal_st_validation` (hal-st specific): - `groups/` - `Group` (`groups/base.py`: `_cmd`, `begin` for commands whose final line comes later, pin resolution) and one module per area exporting `GROUPS = {"name": GroupClass}`. - `fakes/` - `FakeGroup` and the argument helpers (`fakes/base.py`); one module per area with the fake firmware's model of its command groups, found automatically. - `patterns.py` - the payloads the firmware generates (`len=`, `pattern=inc|const|prbs`, `seed=`) and their CRC-32 (`crc=`). +- `i2c.py` - I2C bus helpers: decoding SCL/SDA captures (`i2c_decode`), SCL timing and rise times, `expected_timing` (the host twin of the firmware's TIMINGR computation), and AD3 captures and SDA pulses started by a START condition; `groups/i2c.py` - the `fw.i2c`, `fw.i2cs` and `fw.eeprom` wrappers; `fakes/i2c.py` - their fake groups with one shared bus, the target and a 24LC256. - `protocol.py` - the hal-st part of the protocol: error reasons, `P` pins (ports A-K, index 0-15) and the generic alias names (`normalize_pin`, `parse_pin_map`). - `config.py` - board file loading, wiring-set merging, parameter matrices, overrides and known gaps; `expect.py` - expected STM32 values (PWM quantisation and range, SPI prescaler, UART baud-rate register limits, WWDG prescaler and period, ADC codes, encoder counts). - `pairwise.py` - the full product and the deterministic pairwise generator behind `--depth`. @@ -512,3 +519,4 @@ In the firmware (`firmware/`): - `PinFactoryStm` builds `hal::GpioPinStm`s over the generated pinout tables (bonded pins only, analog sharing, one EXTI line per port); `BoardInfoStm` reports the board, clock and reset cause; `TimerAllocation` keeps PWM, encoder and timer-triggered ADC off each other's timer. - `ResourceAllocation` keeps the groups that share an I2C, SPI, ADC or LPTIM instance, a DMA channel or an HSEM semaphore off each other, with the owner ids of `Owners.hpp`; `Payload` generates the `len=`/`pattern=` payloads and the `out=crc` CRCs; `ChannelPins`, `Stopwatch` (microseconds from the cycle counter) and `HsemMaster` (the one HSEM master of the STM32WB55) serve the newer groups. - One factory per command group (`UartFactory`, `SpiFactory`, `AdcFactory`, `PwmFactory`, `QeiFactory` with the `qei.index` command, `WatchDogFactory`) parses the hal-st options and builds the driver; `UnsupportedGroups` answers the rest. +- `I2cGroup` (`i2c.*` over `I2cStmForHil`, an `I2cStm` whose hooks report `EVT i2c`), `I2cTiming` (TIMINGR from kernel clock and bus frequency), `I2cTarget` (`i2cs.*`, the LL I2C target scaffold), `I2cEepromStm` (a `hal::Eeprom` over `hal::I2cMaster` with page writes and ACK polling) and `EepromGroup` (`eeprom.attach`/`eeprom.detach`, the factory of EMIL's `eeprom` group). diff --git a/validation/firmware/CMakeLists.txt b/validation/firmware/CMakeLists.txt index f852db57..0c415e10 100644 --- a/validation/firmware/CMakeLists.txt +++ b/validation/firmware/CMakeLists.txt @@ -29,8 +29,18 @@ target_sources(hal_st.validation_firmware PRIVATE ChannelPins.hpp Console.cpp Console.hpp + EepromGroup.cpp + EepromGroup.hpp HsemMaster.cpp HsemMaster.hpp + I2cEepromStm.cpp + I2cEepromStm.hpp + I2cGroup.cpp + I2cGroup.hpp + I2cTarget.cpp + I2cTarget.hpp + I2cTiming.cpp + I2cTiming.hpp Main.cpp Owners.hpp Payload.cpp diff --git a/validation/firmware/EepromGroup.cpp b/validation/firmware/EepromGroup.cpp new file mode 100644 index 00000000..49298814 --- /dev/null +++ b/validation/firmware/EepromGroup.cpp @@ -0,0 +1,209 @@ +#include "validation/firmware/EepromGroup.hpp" +#include "infra/event/EventDispatcher.hpp" +#include "validation/firmware/I2cTiming.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include + +namespace validation +{ + namespace + { + using services::HilStatus; + + constexpr uint32_t instances = 4; + constexpr uint32_t minimumAddress = 0x08; + constexpr uint32_t maximumAddress = 0x77; + constexpr uint32_t maximumSize = 65536; + constexpr uint32_t oneByteAddressSpace = 256; + constexpr uint32_t minimumPage = 8; + constexpr uint32_t maximumWriteCycleMs = 20; + constexpr uint32_t defaultBus = 400000; + + constexpr std::array attachKeys{ { "scl", "sda", "addr", "size", "page", "abytes", "freq", "wcycle" } }; + + bool PowerOfTwo(uint32_t value) + { + return value != 0 && (value & (value - 1)) == 0; + } + } + + uint32_t EepromGroup::DetachedEeprom::Size() const + { + return 0; + } + + void EepromGroup::DetachedEeprom::WriteBuffer(infra::ConstByteRange, uint32_t, infra::Function onDone) + { + onDone(); + } + + void EepromGroup::DetachedEeprom::ReadBuffer(infra::ByteRange, uint32_t, infra::Function onDone) + { + onDone(); + } + + void EepromGroup::DetachedEeprom::Erase(infra::Function onDone) + { + onDone(); + } + + EepromGroup::EepromGroup(services::HilContext& context, const services::HilPinNaming& naming, ResourceAllocation& resources) + : services::TerminalCommands(context.terminal) + , context(context) + , naming(naming) + , resources(resources) + , pins(context.pins, owners::eeprom) + , commands{ { + services::HilBind("eeprom.attach", " scl= sda= [addr=] [size=] [page=] [abytes=1|2] [freq=] [wcycle=]", *this, context.response), + services::HilBind("eeprom.detach", "", *this, context.response), + } } + {} + + infra::MemoryRange EepromGroup::Commands() + { + return infra::MakeRange(commands); + } + + hal::Eeprom& EepromGroup::Instance() + { + if (adapter && !detaching) + return *adapter; + + return detached; + } + + HilStatus EepromGroup::Attach(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, infra::MakeRange(attachKeys))) + return HilStatus::usage; + + uint8_t requested = 0; + Request request; + HilStatus status = Evaluate(arguments, requested, request); + if (status != HilStatus::done) + return status; + + if (adapter) + return HilStatus::busy; + + I2cBusPins claimed; + status = ClaimI2cBus(requested, request.bus, pins, resources, claimed); + if (status != HilStatus::done) + { + pins.Release(); + return status; + } + + hal::I2cStm::Config config; + config.timing = request.timing; + + index = requested; + auto& master = driver.emplace(requested, PinOrDummy(claimed.scl), PinOrDummy(claimed.sda), config, nullptr); + adapter.emplace(master, request.config, [this](I2cEepromError error, uint32_t address) + { + Error(error, address); + }); + + context.response.Ok(); + return HilStatus::done; + } + + HilStatus EepromGroup::Detach(const services::HilArguments& arguments) + { + if (!arguments.Shape(0, 0, {})) + return HilStatus::usage; + + if (!adapter) + return HilStatus::notOpen; + + if (detaching || adapter->Busy()) + return HilStatus::busy; + + if (adapter->Failed()) + adapter->Recover(); + + detaching = true; + driver->DisableInterrupts(); + + // I2cStm schedules its completions and its dispatched error handler with its own address: let those already + // queued run against a live driver + infra::EventDispatcher::Instance().Schedule([this]() + { + Destroy(); + context.response.Ok(); + }); + + return HilStatus::done; + } + + HilStatus EepromGroup::Evaluate(const services::HilArguments& arguments, uint8_t& index, Request& request) const + { + uint32_t requested = 0; + HilStatus status = HilStatus::done; + arguments.NumberAt(0, requested, 0, instances - 1, status); + if (status != HilStatus::done) + return status; + + if (!arguments.Has("scl") || !arguments.Has("sda")) + return HilStatus::usage; + + uint32_t address = request.config.address; + uint32_t size = request.config.size; + uint32_t page = request.config.page; + uint32_t addressBytes = request.config.addressBytes; + uint32_t writeCycleMs = request.config.writeCycleMs; + uint32_t frequency = defaultBus; + constexpr auto any = std::numeric_limits::max(); + arguments.Number("addr", address, 0, any, status); + arguments.Number("size", size, 0, any, status); + arguments.Number("page", page, 0, any, status); + arguments.Number("abytes", addressBytes, 0, any, status); + arguments.Number("wcycle", writeCycleMs, 0, any, status); + arguments.Number("freq", frequency, 0, any, status); + if (status != HilStatus::done) + return status; + + index = static_cast(requested); + if (!I2cExists(index) || address < minimumAddress || address > maximumAddress || size == 0 || size > maximumSize) + return HilStatus::range; + + if (page < minimumPage || page > I2cEepromStm::maximumPage || !PowerOfTwo(page) || addressBytes < 1 || addressBytes > I2cEepromStm::maximumAddressBytes || writeCycleMs > maximumWriteCycleMs) + return HilStatus::range; + + if (addressBytes == 1 && size > oneByteAddressSpace) + return HilStatus::range; + + auto timing = I2cTiming(I2cKernelClock(index), frequency); + if (!timing) + return HilStatus::range; + + request.timing = *timing; + request.config.address = static_cast(address); + request.config.size = size; + request.config.page = page; + request.config.addressBytes = static_cast(addressBytes); + request.config.writeCycleMs = writeCycleMs; + + status = ParseI2cBus(arguments, naming, request.bus); + if (status != HilStatus::done) + return status; + + return CheckI2cBus(index, request.bus); + } + + void EepromGroup::Error(I2cEepromError error, uint32_t address) + { + context.response.Event("eeprom") << " error=" << (error == I2cEepromError::busError ? "buserror" : "nack") << " address=" << address; + } + + void EepromGroup::Destroy() + { + adapter.reset(); + driver.reset(); + resources.Release(Resource::i2c, index, owners::eeprom); + pins.Release(); + detaching = false; + } +} diff --git a/validation/firmware/EepromGroup.hpp b/validation/firmware/EepromGroup.hpp new file mode 100644 index 00000000..30f5cdb3 --- /dev/null +++ b/validation/firmware/EepromGroup.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include "hal/interfaces/Eeprom.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilEepromCommands.hpp" +#include "services/util/Terminal.hpp" +#include "validation/firmware/I2cEepromStm.hpp" +#include "validation/firmware/I2cGroup.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include +#include +#include + +namespace validation +{ + class EepromGroup + : public services::TerminalCommands + , public services::HilEepromFactory + { + public: + EepromGroup(services::HilContext& context, const services::HilPinNaming& naming, ResourceAllocation& resources); + + infra::MemoryRange Commands() override; + hal::Eeprom& Instance() override; + + private: + class DetachedEeprom + : public hal::Eeprom + { + public: + uint32_t Size() const override; + void WriteBuffer(infra::ConstByteRange buffer, uint32_t address, infra::Function onDone) override; + void ReadBuffer(infra::ByteRange buffer, uint32_t address, infra::Function onDone) override; + void Erase(infra::Function onDone) override; + }; + + struct Request + { + I2cBus bus; + I2cEepromStm::Config config; + uint32_t timing = 0; + }; + + services::HilStatus Attach(const services::HilArguments& arguments); + services::HilStatus Detach(const services::HilArguments& arguments); + services::HilStatus Evaluate(const services::HilArguments& arguments, uint8_t& index, Request& request) const; + void Error(I2cEepromError error, uint32_t address); + void Destroy(); + + private: + services::HilContext& context; + const services::HilPinNaming& naming; + ResourceAllocation& resources; + services::HilPinOwner pins; + std::optional driver; + std::optional adapter; + DetachedEeprom detached; + uint8_t index = 0; + bool detaching = false; + std::array commands; + }; +} diff --git a/validation/firmware/I2cEepromStm.cpp b/validation/firmware/I2cEepromStm.cpp new file mode 100644 index 00000000..297db4e7 --- /dev/null +++ b/validation/firmware/I2cEepromStm.cpp @@ -0,0 +1,213 @@ +#include "validation/firmware/I2cEepromStm.hpp" +#include +#include + +namespace validation +{ + I2cEepromStm::I2cEepromStm(hal::I2cMaster& master, const Config& config, const infra::Function& onError) + : master(master) + , config(config) + , onError(onError) + {} + + uint32_t I2cEepromStm::Size() const + { + return config.size; + } + + void I2cEepromStm::WriteBuffer(infra::ConstByteRange buffer, uint32_t address, infra::Function onDone) + { + source = buffer; + erasing = false; + StartWrite(address, static_cast(buffer.size()), onDone); + } + + void I2cEepromStm::ReadBuffer(infra::ByteRange buffer, uint32_t address, infra::Function onDone) + { + this->onDone = onDone; + this->address = address; + destination = buffer; + + if (buffer.empty()) + { + Done(); + return; + } + + const auto size = PrepareAddress(address); + transferring = true; + master.SendData(hal::I2cAddress(config.address), Frame(size), hal::Action::repeatedStart, [this](hal::Result result, uint32_t) + { + AddressSent(result); + }); + } + + void I2cEepromStm::Erase(infra::Function onDone) + { + source = infra::ConstByteRange(); + erasing = true; + StartWrite(0, config.size, onDone); + } + + bool I2cEepromStm::Busy() const + { + return transferring; + } + + bool I2cEepromStm::Failed() const + { + return failed; + } + + void I2cEepromStm::Recover() + { + failed = false; + + if (onDone != nullptr) + onDone(); + } + + void I2cEepromStm::StartWrite(uint32_t address, uint32_t size, infra::Function onDone) + { + this->onDone = onDone; + this->address = address; + remaining = size; + + if (remaining == 0) + Done(); + else + WritePage(); + } + + void I2cEepromStm::WritePage() + { + chunk = std::min(remaining, config.page - address % config.page); + + const auto header = PrepareAddress(address); + if (erasing) + std::fill_n(frame.begin() + header, chunk, 0xff); + else + std::copy_n(source.begin(), chunk, frame.begin() + header); + + transferring = true; + master.SendData(hal::I2cAddress(config.address), Frame(header + chunk), hal::Action::stop, [this](hal::Result result, uint32_t) + { + PageWritten(result); + }); + } + + void I2cEepromStm::PageWritten(hal::Result result) + { + transferring = false; + + if (result != hal::Result::complete) + Fail(result); + else if (config.writeCycleMs == 0) + NextPage(); + else + { + expired = false; + deadline.Start(std::chrono::milliseconds(config.writeCycleMs), [this]() + { + expired = true; + }); + Poll(); + } + } + + void I2cEepromStm::Poll() + { + // Only the word address: a 24Cxx acknowledges it once its write cycle ends, and it starts no new write + transferring = true; + master.SendData(hal::I2cAddress(config.address), Frame(config.addressBytes), hal::Action::stop, [this](hal::Result result, uint32_t) + { + Polled(result); + }); + } + + void I2cEepromStm::Polled(hal::Result result) + { + transferring = false; + + if (result == hal::Result::partialComplete && !expired) + { + Poll(); + return; + } + + deadline.Cancel(); + + if (result == hal::Result::complete) + NextPage(); + else + Fail(result); + } + + void I2cEepromStm::NextPage() + { + address += chunk; + remaining -= chunk; + + if (!erasing) + source = infra::DiscardHead(source, chunk); + + if (remaining == 0) + Done(); + else + WritePage(); + } + + void I2cEepromStm::AddressSent(hal::Result result) + { + if (result != hal::Result::complete) + { + transferring = false; + Fail(result); + return; + } + + master.ReceiveData(hal::I2cAddress(config.address), destination, hal::Action::stop, [this](hal::Result result) + { + DataReceived(result); + }); + } + + void I2cEepromStm::DataReceived(hal::Result result) + { + transferring = false; + + if (result == hal::Result::complete) + Done(); + else + Fail(result); + } + + void I2cEepromStm::Fail(hal::Result result) + { + failed = true; + onError(result == hal::Result::busError ? I2cEepromError::busError : I2cEepromError::nack, address); + } + + void I2cEepromStm::Done() + { + onDone(); + } + + uint32_t I2cEepromStm::PrepareAddress(uint32_t address) + { + if (config.addressBytes == 1) + { + frame[0] = static_cast(address); + return 1; + } + + frame[0] = static_cast(address >> 8); + frame[1] = static_cast(address); + return 2; + } + + infra::ConstByteRange I2cEepromStm::Frame(uint32_t size) const + { + return infra::Head(infra::MakeRange(frame), size); + } +} diff --git a/validation/firmware/I2cEepromStm.hpp b/validation/firmware/I2cEepromStm.hpp new file mode 100644 index 00000000..f62a42bc --- /dev/null +++ b/validation/firmware/I2cEepromStm.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include "hal/interfaces/Eeprom.hpp" +#include "hal/interfaces/I2c.hpp" +#include "infra/timer/Timer.hpp" +#include "infra/util/AutoResetFunction.hpp" +#include "infra/util/Function.hpp" +#include +#include + +namespace validation +{ + enum class I2cEepromError : uint8_t + { + nack, + busError, + }; + + class I2cEepromStm + : public hal::Eeprom + { + public: + static constexpr uint32_t maximumPage = 256; + static constexpr uint32_t maximumAddressBytes = 2; + + struct Config + { + uint8_t address = 0x50; + uint32_t size = 32768; + uint32_t page = 64; + uint8_t addressBytes = 2; + uint32_t writeCycleMs = 10; + }; + + I2cEepromStm(hal::I2cMaster& master, const Config& config, const infra::Function& onError); + + uint32_t Size() const override; + void WriteBuffer(infra::ConstByteRange buffer, uint32_t address, infra::Function onDone) override; + void ReadBuffer(infra::ByteRange buffer, uint32_t address, infra::Function onDone) override; + void Erase(infra::Function onDone) override; + + bool Busy() const; + bool Failed() const; + void Recover(); + + private: + void StartWrite(uint32_t address, uint32_t size, infra::Function onDone); + void WritePage(); + void PageWritten(hal::Result result); + void Poll(); + void Polled(hal::Result result); + void NextPage(); + void AddressSent(hal::Result result); + void DataReceived(hal::Result result); + void Fail(hal::Result result); + void Done(); + uint32_t PrepareAddress(uint32_t address); + infra::ConstByteRange Frame(uint32_t size) const; + + private: + hal::I2cMaster& master; + Config config; + infra::Function onError; + infra::AutoResetFunction onDone; + + infra::ConstByteRange source; + infra::ByteRange destination; + bool erasing = false; + uint32_t address = 0; + uint32_t remaining = 0; + uint32_t chunk = 0; + + bool transferring = false; + bool failed = false; + bool expired = false; + infra::TimerSingleShot deadline; + std::array frame{}; + }; +} diff --git a/validation/firmware/I2cGroup.cpp b/validation/firmware/I2cGroup.cpp new file mode 100644 index 00000000..41868c92 --- /dev/null +++ b/validation/firmware/I2cGroup.cpp @@ -0,0 +1,436 @@ +#include "validation/firmware/I2cGroup.hpp" +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include "infra/event/EventDispatcher.hpp" +#include "infra/util/Endian.hpp" +#include "validation/firmware/I2cTiming.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include + +#if defined(STM32WB) +#include "stm32wbxx_ll_gpio.h" +#include "stm32wbxx_ll_i2c.h" +#elif defined(STM32WBA) +#include "stm32wbaxx_ll_gpio.h" +#include "stm32wbaxx_ll_i2c.h" +#endif + +namespace validation +{ + namespace + { + using services::HilChoice; + using services::HilStatus; + + constexpr uint8_t instances = 4; + constexpr uint32_t maximumAddress = 0x7f; + constexpr infra::Duration transferTimeout = std::chrono::milliseconds(1000); + + constexpr std::array openKeys{ { "scl", "sda", "freq", "timing", "pull" } }; + constexpr std::array writeKeys{ { "next", "len", "pattern", "seed" } }; + constexpr std::array readKeys{ { "next", "out" } }; + + constexpr std::array, 3> nextChoices{ { + { "stop", hal::Action::stop }, + { "restart", hal::Action::repeatedStart }, + { "continue", hal::Action::continueSession }, + } }; + + constexpr std::array, 2> pullChoices{ { + { "none", false }, + { "up", true }, + } }; + + const char* ToString(hal::Result result) + { + switch (result) + { + case hal::Result::complete: + return "complete"; + case hal::Result::partialComplete: + return "nack"; + default: + return "buserror"; + } + } + + const char* ToString(I2cHook hook) + { + switch (hook) + { + case I2cHook::notFound: + return "notfound"; + case I2cHook::busError: + return "buserror"; + default: + return "arblost"; + } + } + } + + GPIO_TypeDef* GpioRegisters(HilPinId pin) + { + const std::array ports{ +#if defined(GPIOA) + GPIOA, +#endif +#if defined(GPIOB) + GPIOB, +#endif +#if defined(GPIOC) + GPIOC, +#endif +#if defined(GPIOD) + GPIOD, +#endif +#if defined(GPIOE) + GPIOE, +#endif +#if defined(GPIOH) + GPIOH, +#endif + }; + + return ports[pin.port]; + } + + uint32_t GpioMask(HilPinId pin) + { + return uint32_t{ 1 } << pin.index; + } + + void PrintHex32(services::HilResponse::Line& line, uint32_t value) + { + infra::BigEndian bigEndian{ value }; + line.Hex(infra::MakeByteRange(bigEndian)); + } + + HilStatus ParseI2cBus(const services::HilArguments& arguments, const services::HilPinNaming& naming, I2cBus& bus) + { + HilStatus status = HilStatus::done; + arguments.Pin("scl", naming, bus.scl, status); + arguments.Pin("sda", naming, bus.sda, status); + return status; + } + + HilStatus CheckI2cBus(uint8_t index, const I2cBus& bus) + { + if (!SupportsFunction(*bus.scl, hal::PinConfigTypeStm::i2cScl, index) || !SupportsFunction(*bus.sda, hal::PinConfigTypeStm::i2cSda, index)) + return HilStatus::pin; + + return HilStatus::done; + } + + HilStatus ClaimI2cBus(uint8_t index, const I2cBus& bus, services::HilPinOwner& pins, ResourceAllocation& resources, I2cBusPins& claimed) + { + HilStatus status = resources.Claim(Resource::i2c, index, pins.Id()); + if (status != HilStatus::done) + return status; + + status = pins.ClaimFunction(*bus.scl, Function(hal::PinConfigTypeStm::i2cScl), index, claimed.scl); + if (status == HilStatus::done) + status = pins.ClaimFunction(*bus.sda, Function(hal::PinConfigTypeStm::i2cSda), index, claimed.sda); + if (status != HilStatus::done) + resources.Release(Resource::i2c, index, pins.Id()); + + return status; + } + + I2cStmForHil::I2cStmForHil(uint8_t oneBasedIndex, hal::GpioPinStm& scl, hal::GpioPinStm& sda, const Config& config, const infra::Function& onHook) + : hal::I2cStm(oneBasedIndex, scl, sda, config) + , peripheral(hal::peripheralI2c[oneBasedIndex - 1]) + , onHook(onHook) + {} + + uint32_t I2cStmForHil::Timing() const + { + return (LL_I2C_GetTimingPrescaler(peripheral) << I2C_TIMINGR_PRESC_Pos) | (LL_I2C_GetDataSetupTime(peripheral) << I2C_TIMINGR_SCLDEL_Pos) | (LL_I2C_GetDataHoldTime(peripheral) << I2C_TIMINGR_SDADEL_Pos) | (LL_I2C_GetClockHighPeriod(peripheral) << I2C_TIMINGR_SCLH_Pos) | (LL_I2C_GetClockLowPeriod(peripheral) << I2C_TIMINGR_SCLL_Pos); + } + + uint32_t I2cStmForHil::Count(I2cHook hook) const + { + return counts[static_cast(hook)]; + } + + void I2cStmForHil::DisableInterrupts() + { + LL_I2C_DisableIT_TX(peripheral); + LL_I2C_DisableIT_RX(peripheral); + LL_I2C_DisableIT_TC(peripheral); + LL_I2C_DisableIT_NACK(peripheral); + LL_I2C_DisableIT_ERR(peripheral); + } + + void I2cStmForHil::DeviceNotFound() + { + Report(I2cHook::notFound); + } + + void I2cStmForHil::BusError() + { + Report(I2cHook::busError); + } + + void I2cStmForHil::ArbitrationLost() + { + Report(I2cHook::arbitrationLost); + } + + void I2cStmForHil::Report(I2cHook hook) + { + ++counts[static_cast(hook)]; + + if (onHook != nullptr) + onHook(hook); + } + + I2cFactoryStm::I2cFactoryStm(const services::HilPinNaming& naming, ResourceAllocation& resources) + : naming(naming) + , resources(resources) + {} + + uint8_t I2cFactoryStm::Instances() const + { + return instances; + } + + infra::MemoryRange I2cFactoryStm::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus I2cFactoryStm::Prepare(uint8_t index, const services::HilArguments& arguments) + { + Request request; + return Evaluate(index, arguments, request); + } + + void I2cFactoryStm::Close(uint8_t, const infra::Function& onClosed) + { + this->onClosed = onClosed; + + if (driver) + driver->DisableInterrupts(); + + // I2cStm schedules its completions and its dispatched error handler with its own address: let those already + // queued run against a live driver + infra::EventDispatcher::Instance().Schedule([this]() + { + Destroy(); + }); + } + + HilStatus I2cFactoryStm::Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins, const infra::Function& onHook) + { + Request request; + HilStatus status = Evaluate(index, arguments, request); + if (status != HilStatus::done) + return status; + + I2cBusPins claimed; + status = ClaimI2cBus(index, request.bus, pins, resources, claimed); + if (status != HilStatus::done) + return status; + + hal::I2cStm::Config config; + if (request.timing) + config.timing = *request.timing; + + this->index = index; + driver.emplace(index, PinOrDummy(claimed.scl), PinOrDummy(claimed.sda), config, onHook); + + // The pinout table configures the I2C pins open drain without pull; this lets Standard mode run without external resistors + if (request.pullUp) + for (auto pin : { *request.bus.scl, *request.bus.sda }) + LL_GPIO_SetPinPull(GpioRegisters(pin), GpioMask(pin), LL_GPIO_PULL_UP); + + return HilStatus::done; + } + + hal::I2cMaster& I2cFactoryStm::Master() + { + return *driver; + } + + uint32_t I2cFactoryStm::Timing() const + { + return driver->Timing(); + } + + uint32_t I2cFactoryStm::KernelClock() const + { + return I2cKernelClock(index); + } + + HilStatus I2cFactoryStm::Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const + { + if (!arguments.Has("scl") || !arguments.Has("sda") || (arguments.Has("freq") && arguments.Has("timing"))) + return HilStatus::usage; + + uint32_t frequency = 0; + uint32_t timing = 0; + HilStatus status = HilStatus::done; + arguments.Select("pull", request.pullUp, pullChoices, status); + arguments.Number("timing", timing, 0, std::numeric_limits::max(), status); + arguments.Number("freq", frequency, 0, std::numeric_limits::max(), status); + if (status != HilStatus::done) + return status; + + if (!I2cExists(index)) + return HilStatus::range; + + if (arguments.Has("timing")) + request.timing = timing; + else if (arguments.Has("freq")) + { + request.timing = I2cTiming(I2cKernelClock(index), frequency); + if (!request.timing) + return HilStatus::range; + } + + status = ParseI2cBus(arguments, naming, request.bus); + if (status != HilStatus::done) + return status; + + return CheckI2cBus(index, request.bus); + } + + void I2cFactoryStm::Destroy() + { + driver.reset(); + resources.Release(Resource::i2c, index, owners::i2c); + onClosed(); + } + + I2cCommands::I2cCommands(services::HilContext& context, I2cFactoryStm& factory) + : services::HilSingleInstanceGroup(context, factory, owners::i2c) + , factory(factory) + , pending(context.response) + , commands{ { + OpenCommand("i2c.open", " scl= sda= [freq=|timing=] [pull=none|up]"), + services::HilBind("i2c.write", " [next=] [len=] [pattern=] [seed=]", *this, context.response), + services::HilBind("i2c.read", " [next=] [out=hex|crc]", *this, context.response), + CloseCommand("i2c.close", ""), + } } + {} + + infra::MemoryRange I2cCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus I2cCommands::OpenInstance(uint8_t index, const services::HilArguments& arguments) + { + return factory.Open(index, arguments, Pins(), [this](I2cHook hook) + { + Hook(hook); + }); + } + + void I2cCommands::Opened(services::HilResponse::Line& line) const + { + line << " timing=0x"; + PrintHex32(line, factory.Timing()); + line << " kernel=" << factory.KernelClock(); + } + + void I2cCommands::CloseInstance() + { + pending.Cancel(); + } + + HilStatus I2cCommands::Write(const services::HilArguments& arguments) + { + if (!arguments.Shape(3, 3, infra::MakeRange(writeKeys))) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done) + return status; + + uint32_t address = 0; + hal::Action action = hal::Action::stop; + arguments.NumberAt(1, address, 0, maximumAddress, status); + arguments.Select("next", action, nextChoices, status); + if (status != HilStatus::done) + return status; + + infra::ByteRange payload; + status = ParsePayload(arguments, 2, infra::MakeRange(buffer), payload); + if (status != HilStatus::done) + return status; + + if (pending.Busy()) + return HilStatus::busy; + + const auto operation = pending.Start(transferTimeout); + factory.Master().SendData(hal::I2cAddress(static_cast(address)), payload, action, [this, operation](hal::Result result, uint32_t sent) + { + Sent(operation, result, sent); + }); + + return HilStatus::done; + } + + HilStatus I2cCommands::Read(const services::HilArguments& arguments) + { + if (!arguments.Shape(3, 3, infra::MakeRange(readKeys))) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done) + return status; + + uint32_t address = 0; + uint32_t length = 0; + hal::Action action = hal::Action::stop; + Output requested = Output::hex; + arguments.NumberAt(1, address, 0, maximumAddress, status); + arguments.NumberAt(2, length, 1, static_cast(buffer.size()), status); + arguments.Select("next", action, nextChoices, status); + if (status == HilStatus::done) + status = ParseOutput(arguments, requested); + if (status == HilStatus::done) + status = CheckOutput(length, requested); + if (status != HilStatus::done) + return status; + + if (pending.Busy()) + return HilStatus::busy; + + output = requested; + received = infra::Head(infra::MakeRange(buffer), length); + const auto operation = pending.Start(transferTimeout); + factory.Master().ReceiveData(hal::I2cAddress(static_cast(address)), received, action, [this, operation](hal::Result result) + { + Received(operation, result); + }); + + return HilStatus::done; + } + + void I2cCommands::Hook(I2cHook hook) + { + if (Instance().Occupied()) + Context().response.Event("i2c") << " index=" << static_cast(Instance().Index()) << " hook=" << ToString(hook); + } + + void I2cCommands::Sent(uint32_t operation, hal::Result result, uint32_t sent) + { + if (pending.Complete(operation)) + Context().response.Ok() << " sent=" << sent << " result=" << ToString(result); + } + + void I2cCommands::Received(uint32_t operation, hal::Result result) + { + if (!pending.Complete(operation)) + return; + + auto line = Context().response.Ok(); + line << " result=" << ToString(result); + if (result == hal::Result::complete) + PrintData(line, received, output); + } +} diff --git a/validation/firmware/I2cGroup.hpp b/validation/firmware/I2cGroup.hpp new file mode 100644 index 00000000..a0d80211 --- /dev/null +++ b/validation/firmware/I2cGroup.hpp @@ -0,0 +1,134 @@ +#pragma once + +#include "hal_st/stm32fxxx/I2cStm.hpp" +#include "infra/util/AutoResetFunction.hpp" +#include "infra/util/Function.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilPendingOperation.hpp" +#include "services/hil/commands/HilSingleInstanceGroup.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/Payload.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include +#include +#include +#include DEVICE_HEADER + +namespace validation +{ + enum class I2cHook : uint8_t + { + notFound, + busError, + arbitrationLost, + }; + + struct I2cBus + { + std::optional scl; + std::optional sda; + }; + + struct I2cBusPins + { + hal::GpioPin* scl = nullptr; + hal::GpioPin* sda = nullptr; + }; + + GPIO_TypeDef* GpioRegisters(HilPinId pin); + uint32_t GpioMask(HilPinId pin); + void PrintHex32(services::HilResponse::Line& line, uint32_t value); + + services::HilStatus ParseI2cBus(const services::HilArguments& arguments, const services::HilPinNaming& naming, I2cBus& bus); + services::HilStatus CheckI2cBus(uint8_t index, const I2cBus& bus); + services::HilStatus ClaimI2cBus(uint8_t index, const I2cBus& bus, services::HilPinOwner& pins, ResourceAllocation& resources, I2cBusPins& claimed); + + class I2cStmForHil + : public hal::I2cStm + { + public: + I2cStmForHil(uint8_t oneBasedIndex, hal::GpioPinStm& scl, hal::GpioPinStm& sda, const Config& config, const infra::Function& onHook); + + uint32_t Timing() const; + uint32_t Count(I2cHook hook) const; + void DisableInterrupts(); + + protected: + void DeviceNotFound() override; + void BusError() override; + void ArbitrationLost() override; + + private: + void Report(I2cHook hook); + + private: + I2C_TypeDef* peripheral; + infra::Function onHook; + std::array counts{}; + }; + + class I2cFactoryStm + : public services::HilInstanceFactory + { + public: + I2cFactoryStm(const services::HilPinNaming& naming, ResourceAllocation& resources); + + uint8_t Instances() const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + void Close(uint8_t index, const infra::Function& onClosed) override; + + services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins, const infra::Function& onHook); + hal::I2cMaster& Master(); + uint32_t Timing() const; + uint32_t KernelClock() const; + + private: + struct Request + { + I2cBus bus; + std::optional timing; + bool pullUp = false; + }; + + services::HilStatus Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const; + void Destroy(); + + private: + const services::HilPinNaming& naming; + ResourceAllocation& resources; + std::optional driver; + uint8_t index = 0; + infra::AutoResetFunction onClosed; + }; + + class I2cCommands + : public services::HilSingleInstanceGroup + { + public: + I2cCommands(services::HilContext& context, I2cFactoryStm& factory); + + infra::MemoryRange Commands() override; + + protected: + services::HilStatus OpenInstance(uint8_t index, const services::HilArguments& arguments) override; + void Opened(services::HilResponse::Line& line) const override; + void CloseInstance() override; + + private: + services::HilStatus Write(const services::HilArguments& arguments); + services::HilStatus Read(const services::HilArguments& arguments); + + void Hook(I2cHook hook); + void Sent(uint32_t operation, hal::Result result, uint32_t sent); + void Received(uint32_t operation, hal::Result result); + + private: + I2cFactoryStm& factory; + services::HilPendingOperation pending; + infra::ByteRange received; + Output output = Output::hex; + std::array buffer{}; + std::array commands; + }; +} diff --git a/validation/firmware/I2cTarget.cpp b/validation/firmware/I2cTarget.cpp new file mode 100644 index 00000000..64b7300f --- /dev/null +++ b/validation/firmware/I2cTarget.cpp @@ -0,0 +1,571 @@ +#include "validation/firmware/I2cTarget.hpp" +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include "validation/firmware/I2cTiming.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include + +#if defined(STM32WB) +#include "stm32wbxx_ll_gpio.h" +#include "stm32wbxx_ll_i2c.h" +#elif defined(STM32WBA) +#include "stm32wbaxx_ll_gpio.h" +#include "stm32wbaxx_ll_i2c.h" +#endif + +namespace validation +{ + namespace + { + using services::HilChoice; + using services::HilStatus; + + constexpr uint8_t instances = 4; + constexpr uint32_t minimumAddress = 0x08; + constexpr uint32_t maximumAddress = 0x77; + constexpr uint32_t defaultBus = 400000; + constexpr uint32_t maximumStretchUs = 10000; + constexpr uint32_t maximumPosition = 0xffff; + constexpr uint32_t faultTimeoutUs = 1000; + constexpr uint32_t maximumDump = 128; + + constexpr std::array openKeys{ { "scl", "sda", "addr", "mode", "timing" } }; + constexpr std::array configureKeys{ { "nack", "addrnack", "stretch", "stretchat", "fault", "faultat", "pattern", "seed" } }; + + constexpr std::array, 2> modeChoices{ { + { "regs", false }, + { "sink", true }, + } }; + + constexpr std::array, 2> faultChoices{ { + { "none", false }, + { "stop", true }, + } }; + } + + I2cTarget::I2cTarget(uint8_t oneBasedIndex, hal::GpioPinStm& scl, hal::GpioPinStm& sda, HilPinId sclId, HilPinId sdaId, const Config& config) + : instance(oneBasedIndex - 1) + , peripheral(hal::peripheralI2c[instance]) + , sclId(sclId) + , sdaId(sdaId) + , scl(scl, hal::PinConfigTypeStm::i2cScl, oneBasedIndex) + , sda(sda, hal::PinConfigTypeStm::i2cSda, oneBasedIndex) + , sink(config.sink) + , eventHandler(hal::peripheralI2cEvIrq[instance], [this]() + { + EventInterrupt(); + }) + , errorHandler(hal::peripheralI2cErIrq[instance], [this]() + { + ErrorInterrupt(); + }) + { + hal::EnableClockI2c(instance); + + handle.Instance = peripheral; + handle.Init.Timing = config.timing; + handle.Init.OwnAddress1 = static_cast(config.address) << 1; + handle.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; + handle.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE; + handle.Init.OwnAddress2 = 0; + handle.Init.OwnAddress2Masks = I2C_OA2_NOMASK; + handle.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE; + handle.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE; + HAL_I2C_Init(&handle); + + LL_I2C_Disable(peripheral); + LL_I2C_EnableSlaveByteControl(peripheral); + LL_I2C_Enable(peripheral); + + LL_I2C_EnableIT_ADDR(peripheral); + LL_I2C_EnableIT_TC(peripheral); + LL_I2C_EnableIT_NACK(peripheral); + LL_I2C_EnableIT_STOP(peripheral); + LL_I2C_EnableIT_ERR(peripheral); + } + + I2cTarget::~I2cTarget() + { + LL_I2C_DisableIT_ADDR(peripheral); + LL_I2C_DisableIT_TC(peripheral); + LL_I2C_DisableIT_NACK(peripheral); + LL_I2C_DisableIT_STOP(peripheral); + LL_I2C_DisableIT_ERR(peripheral); + LL_I2C_DisableIT_TX(peripheral); + + HAL_I2C_DeInit(&handle); + hal::DisableClockI2c(instance); + } + + void I2cTarget::Configure(const I2cTargetSettings& settings) + { + this->settings = settings; + faultArmed = settings.fault; + + if (settings.addressNack) + LL_I2C_DisableOwnAddress1(peripheral); + else + LL_I2C_EnableOwnAddress1(peripheral); + } + + void I2cTarget::PrintStatus(services::HilResponse::Line& line) const + { + line << " rx=" << received.load() << " tx=" << transmitted.load() << " crc="; + PrintHex32(line, crc.Result()); + line << " writes=" << writes.load() << " reads=" << reads.load() << " stops=" << stops.load() << " nacked=" << nacked.load() << " errors=" << errors.load() << " last="; + line.Hex(infra::Head(infra::MakeRange(last), std::min(lastSize.load(), last.size()))); + } + + void I2cTarget::ClearStatus() + { + received = 0; + transmitted = 0; + writes = 0; + reads = 0; + stops = 0; + nacked = 0; + errors = 0; + crc.Reset(); + lastSize = 0; + } + + void I2cTarget::Preload(std::size_t offset, infra::ConstByteRange data) + { + std::copy(data.begin(), data.end(), registers.begin() + offset); + } + + infra::ConstByteRange I2cTarget::Registers(std::size_t offset, std::size_t size) const + { + return infra::Head(infra::DiscardHead(infra::MakeRange(registers), offset), size); + } + + void I2cTarget::EventInterrupt() + { + if (LL_I2C_IsActiveFlag_NACK(peripheral)) + MasterNacked(); + + if (LL_I2C_IsActiveFlag_STOP(peripheral)) + Stopped(); + + if (LL_I2C_IsActiveFlag_ADDR(peripheral)) + Addressed(); + + if (LL_I2C_IsActiveFlag_TCR(peripheral)) + ByteReceived(); + + if (LL_I2C_IsEnabledIT_TX(peripheral) && LL_I2C_IsActiveFlag_TXIS(peripheral)) + LoadByte(); + } + + void I2cTarget::ErrorInterrupt() + { + if (LL_I2C_IsActiveFlag_BERR(peripheral)) + { + LL_I2C_ClearFlag_BERR(peripheral); + ++errors; + } + + if (LL_I2C_IsActiveFlag_ARLO(peripheral)) + { + LL_I2C_ClearFlag_ARLO(peripheral); + ++errors; + } + + if (LL_I2C_IsActiveFlag_OVR(peripheral)) + { + LL_I2C_ClearFlag_OVR(peripheral); + ++errors; + } + } + + void I2cTarget::Addressed() + { + position = 0; + + if (settings.stretchUs != 0 && settings.stretchAt == 0) + Hold(settings.stretchUs); + + if (LL_I2C_GetTransferDirection(peripheral) == LL_I2C_DIRECTION_WRITE) + { + ++writes; + lastSize = 0; + LL_I2C_DisableIT_TX(peripheral); + LL_I2C_EnableReloadMode(peripheral); + LL_I2C_SetTransferSize(peripheral, 1); + } + else + { + ++reads; + patternState = settings.seed; + LL_I2C_DisableReloadMode(peripheral); + LL_I2C_ClearFlag_TXE(peripheral); + LL_I2C_EnableIT_TX(peripheral); + } + + LL_I2C_ClearFlag_ADDR(peripheral); + } + + void I2cTarget::ByteReceived() + { + const uint8_t byte = LL_I2C_ReceiveData8(peripheral); + ++position; + + if (position == settings.nack) + { + ++nacked; + LL_I2C_AcknowledgeNextData(peripheral, LL_I2C_NACK); + } + else + { + ++received; + crc.Update(byte); + + const auto size = lastSize.load(); + if (size < last.size()) + { + last[size] = byte; + lastSize = size + 1; + } + + if (!sink && position == 1) + pointer = byte; + else if (!sink) + registers[pointer++] = byte; + } + + if (settings.stretchUs != 0 && settings.stretchAt == position) + Hold(settings.stretchUs); + + LL_I2C_SetTransferSize(peripheral, 1); + } + + void I2cTarget::LoadByte() + { + ++position; + + if (faultArmed && position == settings.faultAt) + { + Fault(); + return; + } + + if (settings.stretchUs != 0 && settings.stretchAt == position) + Hold(settings.stretchUs); + + LL_I2C_TransmitData8(peripheral, sink ? NextPattern() : registers[pointer++]); + ++transmitted; + } + + void I2cTarget::MasterNacked() + { + const bool unsent = !LL_I2C_IsActiveFlag_TXE(peripheral); + + LL_I2C_ClearFlag_NACK(peripheral); + LL_I2C_ClearFlag_TXE(peripheral); + LL_I2C_DisableIT_TX(peripheral); + + // The byte loaded for the next TXIS never left TXDR + if (unsent) + { + --transmitted; + if (!sink) + --pointer; + } + } + + void I2cTarget::Stopped() + { + LL_I2C_ClearFlag_STOP(peripheral); + LL_I2C_DisableIT_TX(peripheral); + LL_I2C_ClearFlag_TXE(peripheral); + ++stops; + } + + void I2cTarget::Fault() + { + auto* sclPort = GpioRegisters(sclId); + auto* sdaPort = GpioRegisters(sdaId); + const auto sclMask = GpioMask(sclId); + const auto sdaMask = GpioMask(sdaId); + + faultArmed = false; + + LL_GPIO_ResetOutputPin(sdaPort, sdaMask); + LL_GPIO_ResetOutputPin(sclPort, sclMask); + LL_GPIO_SetPinMode(sdaPort, sdaMask, LL_GPIO_MODE_OUTPUT); + LL_GPIO_SetPinMode(sclPort, sclMask, LL_GPIO_MODE_OUTPUT); + + // SDA must rise while SCL is high to form the misplaced STOP; the master may still hold SCL low + LL_GPIO_SetOutputPin(sclPort, sclMask); + stopwatch.Start(); + while (!LL_GPIO_IsInputPinSet(sclPort, sclMask) && stopwatch.ElapsedUs() < faultTimeoutUs) + { + } + LL_GPIO_SetOutputPin(sdaPort, sdaMask); + + LL_I2C_Disable(peripheral); + while (LL_I2C_IsEnabled(peripheral)) + { + } + LL_I2C_ClearFlag_ADDR(peripheral); + LL_I2C_ClearFlag_NACK(peripheral); + LL_I2C_ClearFlag_STOP(peripheral); + LL_I2C_ClearFlag_BERR(peripheral); + LL_I2C_ClearFlag_ARLO(peripheral); + LL_I2C_ClearFlag_OVR(peripheral); + LL_I2C_DisableIT_TX(peripheral); + LL_I2C_Enable(peripheral); + + LL_GPIO_SetPinMode(sclPort, sclMask, LL_GPIO_MODE_ALTERNATE); + LL_GPIO_SetPinMode(sdaPort, sdaMask, LL_GPIO_MODE_ALTERNATE); + } + + void I2cTarget::Hold(uint32_t us) + { + stopwatch.Start(); + while (stopwatch.ElapsedUs() < us) + { + } + } + + uint8_t I2cTarget::NextPattern() + { + switch (settings.pattern) + { + case Pattern::inc: + return static_cast(settings.seed + position - 1); + case Pattern::constant: + return static_cast(settings.seed); + default: + if (patternState == 0) + patternState = 1; + patternState ^= patternState << 13; + patternState ^= patternState >> 17; + patternState ^= patternState << 5; + return static_cast(patternState); + } + } + + I2cTargetFactory::I2cTargetFactory(const services::HilPinNaming& naming, ResourceAllocation& resources) + : naming(naming) + , resources(resources) + {} + + uint8_t I2cTargetFactory::Instances() const + { + return instances; + } + + infra::MemoryRange I2cTargetFactory::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus I2cTargetFactory::Prepare(uint8_t index, const services::HilArguments& arguments) + { + Request request; + return Evaluate(index, arguments, request); + } + + void I2cTargetFactory::Close(uint8_t, const infra::Function& onClosed) + { + target.reset(); + resources.Release(Resource::i2c, index, owners::i2cTarget); + onClosed(); + } + + HilStatus I2cTargetFactory::Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) + { + Request request; + HilStatus status = Evaluate(index, arguments, request); + if (status != HilStatus::done) + return status; + + I2cBusPins claimed; + status = ClaimI2cBus(index, request.bus, pins, resources, claimed); + if (status != HilStatus::done) + return status; + + this->index = index; + address = request.config.address; + target.emplace(index, PinOrDummy(claimed.scl), PinOrDummy(claimed.sda), *request.bus.scl, *request.bus.sda, request.config); + return HilStatus::done; + } + + I2cTarget& I2cTargetFactory::Target() + { + return *target; + } + + uint8_t I2cTargetFactory::Address() const + { + return address; + } + + HilStatus I2cTargetFactory::Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const + { + if (!arguments.Has("scl") || !arguments.Has("sda")) + return HilStatus::usage; + + uint32_t address = request.config.address; + uint32_t timing = 0; + HilStatus status = HilStatus::done; + arguments.Select("mode", request.config.sink, modeChoices, status); + arguments.Number("timing", timing, 0, std::numeric_limits::max(), status); + arguments.Number("addr", address, minimumAddress, maximumAddress, status); + if (status != HilStatus::done) + return status; + + if (!I2cExists(index)) + return HilStatus::range; + + request.config.address = static_cast(address); + if (arguments.Has("timing")) + request.config.timing = timing; + else + { + auto standard = I2cTiming(I2cKernelClock(index), defaultBus); + if (!standard) + return HilStatus::range; + + request.config.timing = *standard; + } + + status = ParseI2cBus(arguments, naming, request.bus); + if (status != HilStatus::done) + return status; + + return CheckI2cBus(index, request.bus); + } + + I2cTargetCommands::I2cTargetCommands(services::HilContext& context, I2cTargetFactory& factory) + : services::HilSingleInstanceGroup(context, factory, owners::i2cTarget) + , factory(factory) + , commands{ { + OpenCommand("i2cs.open", " scl= sda= [addr=] [mode=regs|sink] [timing=]"), + services::HilBind("i2cs.cfg", " [nack=] [addrnack=] [stretch=] [stretchat=] [fault=none|stop] [faultat=] [pattern=] [seed=]", *this, context.response), + services::HilBind("i2cs.status", " [clear=0|1]", *this, context.response), + services::HilBind("i2cs.regs", " ", *this, context.response), + services::HilBind("i2cs.dump", " ", *this, context.response), + CloseCommand("i2cs.close", ""), + } } + {} + + infra::MemoryRange I2cTargetCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus I2cTargetCommands::OpenInstance(uint8_t index, const services::HilArguments& arguments) + { + return factory.Open(index, arguments, Pins()); + } + + void I2cTargetCommands::Opened(services::HilResponse::Line& line) const + { + const uint8_t address = factory.Address(); + line << " addr=0x"; + line.Hex(infra::MakeByteRange(address)); + } + + void I2cTargetCommands::CloseInstance() + {} + + HilStatus I2cTargetCommands::Configure(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, infra::MakeRange(configureKeys))) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done) + return status; + + I2cTargetSettings settings; + arguments.Number("nack", settings.nack, 0, maximumPosition, status); + arguments.Flag("addrnack", settings.addressNack, status); + arguments.Number("stretch", settings.stretchUs, 0, maximumStretchUs, status); + arguments.Number("stretchat", settings.stretchAt, 0, maximumPosition, status); + arguments.Select("fault", settings.fault, faultChoices, status); + arguments.Number("faultat", settings.faultAt, 1, maximumPosition, status); + arguments.Select("pattern", settings.pattern, patternChoices, status); + arguments.Number("seed", settings.seed, 0, std::numeric_limits::max(), status); + if (status != HilStatus::done) + return status; + + factory.Target().Configure(settings); + Context().response.Ok(); + return HilStatus::done; + } + + HilStatus I2cTargetCommands::Status(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, { "clear" })) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + bool clear = false; + arguments.Flag("clear", clear, status); + if (status != HilStatus::done) + return status; + + { + auto line = Context().response.Ok(); + factory.Target().PrintStatus(line); + } + + if (clear) + factory.Target().ClearStatus(); + + return HilStatus::done; + } + + HilStatus I2cTargetCommands::Preload(const services::HilArguments& arguments) + { + if (!arguments.Shape(3, 3, {})) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + uint32_t offset = 0; + arguments.NumberAt(1, offset, 0, I2cTarget::registerCount - 1, status); + if (status != HilStatus::done) + return status; + + std::array data; + std::size_t size = 0; + status = services::HilArguments::ParseHex(arguments.Positional(2), infra::MakeRange(data), size); + if (status != HilStatus::done) + return status; + + if (size == 0) + return HilStatus::usage; + + if (offset + size > I2cTarget::registerCount) + return HilStatus::range; + + factory.Target().Preload(offset, infra::Head(infra::MakeRange(data), size)); + Context().response.Ok(); + return HilStatus::done; + } + + HilStatus I2cTargetCommands::Dump(const services::HilArguments& arguments) + { + if (!arguments.Shape(3, 3, {})) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + uint32_t offset = 0; + uint32_t length = 0; + arguments.NumberAt(1, offset, 0, I2cTarget::registerCount - 1, status); + arguments.NumberAt(2, length, 1, maximumDump, status); + if (status != HilStatus::done) + return status; + + if (offset + length > I2cTarget::registerCount) + return HilStatus::range; + + (Context().response.Ok() << " data=").Hex(factory.Target().Registers(offset, length)); + return HilStatus::done; + } +} diff --git a/validation/firmware/I2cTarget.hpp b/validation/firmware/I2cTarget.hpp new file mode 100644 index 00000000..db92ddcc --- /dev/null +++ b/validation/firmware/I2cTarget.hpp @@ -0,0 +1,160 @@ +#pragma once + +#include "hal/cortex_m/InterruptCortex.hpp" +#include "hal_st/stm32fxxx/GpioStm.hpp" +#include "infra/util/AutoResetFunction.hpp" +#include "infra/util/Crc.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilSingleInstanceGroup.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/I2cGroup.hpp" +#include "validation/firmware/Payload.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include "validation/firmware/Stopwatch.hpp" +#include +#include +#include +#include +#include DEVICE_HEADER + +namespace validation +{ + struct I2cTargetSettings + { + uint32_t nack = 0; + bool addressNack = false; + uint32_t stretchUs = 0; + uint32_t stretchAt = 0; + bool fault = false; + uint32_t faultAt = 1; + Pattern pattern = Pattern::inc; + uint32_t seed = 0; + }; + + // Validation scaffolding: hal-st has no I2C slave driver, so the other end of the bus is driven with LL + class I2cTarget + { + public: + static constexpr std::size_t registerCount = 256; + + struct Config + { + uint8_t address; + bool sink; + uint32_t timing; + }; + + I2cTarget(uint8_t oneBasedIndex, hal::GpioPinStm& scl, hal::GpioPinStm& sda, HilPinId sclId, HilPinId sdaId, const Config& config); + I2cTarget(const I2cTarget& other) = delete; + I2cTarget& operator=(const I2cTarget& other) = delete; + ~I2cTarget(); + + void Configure(const I2cTargetSettings& settings); + void PrintStatus(services::HilResponse::Line& line) const; + void ClearStatus(); + void Preload(std::size_t offset, infra::ConstByteRange data); + infra::ConstByteRange Registers(std::size_t offset, std::size_t size) const; + + private: + void EventInterrupt(); + void ErrorInterrupt(); + + void Addressed(); + void ByteReceived(); + void LoadByte(); + void MasterNacked(); + void Stopped(); + void Fault(); + void Hold(uint32_t us); + uint8_t NextPattern(); + + private: + uint8_t instance; + I2C_TypeDef* peripheral; + HilPinId sclId; + HilPinId sdaId; + hal::PeripheralPinStm scl; + hal::PeripheralPinStm sda; + I2C_HandleTypeDef handle{}; + bool sink; + + I2cTargetSettings settings; + bool faultArmed = false; + Stopwatch stopwatch; + + std::array registers{}; + uint8_t pointer = 0; + uint32_t position = 0; + uint32_t patternState = 0; + + std::atomic received{ 0 }; + std::atomic transmitted{ 0 }; + std::atomic writes{ 0 }; + std::atomic reads{ 0 }; + std::atomic stops{ 0 }; + std::atomic nacked{ 0 }; + std::atomic errors{ 0 }; + infra::Crc32 crc; + std::array last{}; + std::atomic lastSize{ 0 }; + + hal::cortex::ImmediateInterruptHandler eventHandler; + hal::cortex::ImmediateInterruptHandler errorHandler; + }; + + class I2cTargetFactory + : public services::HilInstanceFactory + { + public: + I2cTargetFactory(const services::HilPinNaming& naming, ResourceAllocation& resources); + + uint8_t Instances() const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + void Close(uint8_t index, const infra::Function& onClosed) override; + + services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins); + I2cTarget& Target(); + uint8_t Address() const; + + private: + struct Request + { + I2cBus bus; + I2cTarget::Config config{ 0x42, false, 0 }; + }; + + services::HilStatus Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const; + + private: + const services::HilPinNaming& naming; + ResourceAllocation& resources; + std::optional target; + uint8_t index = 0; + uint8_t address = 0; + }; + + class I2cTargetCommands + : public services::HilSingleInstanceGroup + { + public: + I2cTargetCommands(services::HilContext& context, I2cTargetFactory& factory); + + infra::MemoryRange Commands() override; + + protected: + services::HilStatus OpenInstance(uint8_t index, const services::HilArguments& arguments) override; + void Opened(services::HilResponse::Line& line) const override; + void CloseInstance() override; + + private: + services::HilStatus Configure(const services::HilArguments& arguments); + services::HilStatus Status(const services::HilArguments& arguments); + services::HilStatus Preload(const services::HilArguments& arguments); + services::HilStatus Dump(const services::HilArguments& arguments); + + private: + I2cTargetFactory& factory; + std::array commands; + }; +} diff --git a/validation/firmware/I2cTiming.cpp b/validation/firmware/I2cTiming.cpp new file mode 100644 index 00000000..ba8f162e --- /dev/null +++ b/validation/firmware/I2cTiming.cpp @@ -0,0 +1,109 @@ +#include "validation/firmware/I2cTiming.hpp" +#include + +namespace validation +{ + namespace + { + struct Mode + { + int64_t hddatMin; + int64_t vddatMax; + int64_t sudatMin; + int64_t lsclMin; + int64_t hsclMin; + int64_t rise; + int64_t fall; + }; + + struct Candidate + { + int64_t period; + uint32_t prescaler; + uint32_t scldel; + uint32_t sdadel; + uint32_t sclh; + uint32_t scll; + }; + + constexpr int64_t picosecondsPerSecond = 1'000'000'000'000; + constexpr Mode standardMode{ 0, 3'450'000, 250'000, 4'700'000, 4'000'000, 640'000, 20'000 }; + constexpr Mode fastMode{ 0, 900'000, 100'000, 1'300'000, 600'000, 250'000, 100'000 }; + constexpr int64_t analogFilterMin = 50'000; + constexpr int64_t analogFilterMax = 260'000; + constexpr uint32_t fieldMax = 15; + constexpr int64_t periodMax = 255; + constexpr uint32_t standardModeMaximumBus = 100'000; + + int64_t FloorDivide(int64_t numerator, int64_t denominator) + { + const auto quotient = numerator / denominator; + return quotient * denominator > numerator ? quotient - 1 : quotient; + } + + int64_t CeilDivide(int64_t numerator, int64_t denominator) + { + return -FloorDivide(-numerator, denominator); + } + + std::optional Evaluate(const Mode& mode, int64_t clock, int64_t target, uint32_t prescaler) + { + const int64_t prescaled = (prescaler + 1) * clock; + const int64_t sdadelMin = std::max(mode.fall + mode.hddatMin - analogFilterMin - 3 * clock, 0); + const int64_t sdadelMax = std::max(mode.vddatMax - mode.rise - analogFilterMax - 4 * clock, 0); + const int64_t scldelMin = mode.rise + mode.sudatMin; + + uint32_t scldel = 0; + while (scldel <= fieldMax && (scldel + 1) * prescaled < scldelMin) + ++scldel; + if (scldel > fieldMax) + return std::nullopt; + + uint32_t sdadel = 0; + for (; sdadel <= fieldMax; ++sdadel) + { + const int64_t delay = (sdadel * (prescaler + 1) + 1) * clock; + if (delay >= sdadelMin && delay <= sdadelMax) + break; + } + if (sdadel > fieldMax) + return std::nullopt; + + const int64_t sync = analogFilterMin + 2 * clock; + const int64_t scllMin = std::max(0, FloorDivide(mode.lsclMin - sync, prescaled)); + const int64_t sclhMin = std::max(0, CeilDivide(mode.hsclMin - sync, prescaled) - 1); + const int64_t fixed = 2 * sync + mode.fall; + const int64_t units = std::max(scllMin + sclhMin + 2, CeilDivide(target - fixed, prescaled)); + const int64_t extra = units - (scllMin + sclhMin + 2); + const int64_t scll = scllMin + (extra + 1) / 2; + const int64_t sclh = sclhMin + extra / 2; + if (scll > periodMax || sclh > periodMax) + return std::nullopt; + + return Candidate{ fixed + units * prescaled, prescaler, scldel, sdadel, static_cast(sclh), static_cast(scll) }; + } + } + + std::optional I2cTiming(uint32_t kernelHz, uint32_t busHz) + { + if (kernelHz == 0 || busHz < i2cMinimumBus || busHz > i2cMaximumBus) + return std::nullopt; + + const Mode& mode = busHz <= standardModeMaximumBus ? standardMode : fastMode; + const int64_t clock = picosecondsPerSecond / kernelHz; + const int64_t target = picosecondsPerSecond / busHz; + + std::optional best; + for (uint32_t prescaler = 0; prescaler <= fieldMax; ++prescaler) + { + auto candidate = Evaluate(mode, clock, target, prescaler); + if (candidate && (!best || candidate->period < best->period)) + best = candidate; + } + + if (!best) + return std::nullopt; + + return (best->prescaler << 28) | (best->scldel << 20) | (best->sdadel << 16) | (best->sclh << 8) | best->scll; + } +} diff --git a/validation/firmware/I2cTiming.hpp b/validation/firmware/I2cTiming.hpp new file mode 100644 index 00000000..b448dde1 --- /dev/null +++ b/validation/firmware/I2cTiming.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include +#include + +namespace validation +{ + inline constexpr uint32_t i2cMinimumBus = 20000; + inline constexpr uint32_t i2cMaximumBus = 400000; + + std::optional I2cTiming(uint32_t kernelHz, uint32_t busHz); +} diff --git a/validation/firmware/Main.cpp b/validation/firmware/Main.cpp index 08c824df..8092ddf8 100644 --- a/validation/firmware/Main.cpp +++ b/validation/firmware/Main.cpp @@ -6,6 +6,7 @@ #include "services/hil/HilPinPool.hpp" #include "services/hil/HilSystemCommands.hpp" #include "services/hil/commands/HilAdcCommands.hpp" +#include "services/hil/commands/HilEepromCommands.hpp" #include "services/hil/commands/HilGpioCommands.hpp" #include "services/hil/commands/HilPwmCommands.hpp" #include "services/hil/commands/HilQeiCommands.hpp" @@ -16,6 +17,9 @@ #include "validation/firmware/AdcFactory.hpp" #include "validation/firmware/BoardInfoStm.hpp" #include "validation/firmware/Console.hpp" +#include "validation/firmware/EepromGroup.hpp" +#include "validation/firmware/I2cGroup.hpp" +#include "validation/firmware/I2cTarget.hpp" #include "validation/firmware/PinFactoryStm.hpp" #include "validation/firmware/PwmFactory.hpp" #include "validation/firmware/QeiFactory.hpp" @@ -69,6 +73,13 @@ int main() static validation::WatchDogFactoryStm watchDogFactory{ naming, pins }; static services::HilWatchDogCommands watchDog{ context, watchDogFactory }; + static validation::I2cFactoryStm i2cFactory{ naming, resources }; + static validation::I2cCommands i2c{ context, i2cFactory }; + static validation::I2cTargetFactory i2cTargetFactory{ naming, resources }; + static validation::I2cTargetCommands i2cTarget{ context, i2cTargetFactory }; + static validation::EepromGroup eepromGroup{ context, naming, resources }; + static services::HilEepromCommands::WithCapacity<128> eeprom{ context, eepromGroup }; + validation::CreateUnsupportedGroups(context); system.PrintBoot(); diff --git a/validation/firmware/UnsupportedGroups.cpp b/validation/firmware/UnsupportedGroups.cpp index bafc86b2..e0e479a1 100644 --- a/validation/firmware/UnsupportedGroups.cpp +++ b/validation/firmware/UnsupportedGroups.cpp @@ -6,7 +6,7 @@ namespace validation { namespace { - constexpr std::array commandNames{ { + constexpr std::array commandNames{ { "comp.open", "comp.read", "comp.irq", @@ -15,9 +15,6 @@ namespace validation "can.open", "can.send", "can.close", - "eeprom.write", - "eeprom.read", - "eeprom.erase", "eth.open", "eth.status", "eth.close", diff --git a/validation/host/boards/nucleo_wb55rg.yaml b/validation/host/boards/nucleo_wb55rg.yaml index b657d5c0..8b96c010 100644 --- a/validation/host/boards/nucleo_wb55rg.yaml +++ b/validation/host/boards/nucleo_wb55rg.yaml @@ -739,6 +739,65 @@ tests: feed_seconds: 2.0 observe_periods: 3 period_tolerance: 0.03 + i2c: + # Standalone cases on bundle1 with the MCU pull-ups (pull=up), each + # instance on its own pins; `observed` names the lines with a DIO + # (PB8 DIO3, PB9 DIO5, PA9 DIO2, PA10 DIO4, PA7 DIO1; PB4 has none). + instances: + - name: i2c1 + index: 1 + scl: PB8 + sda: PB9 + observed: [scl, sda] + - name: i2c1alt + index: 1 + scl: PA9 + sda: PA10 + observed: [scl, sda] + - name: i2c3 + index: 3 + scl: PA7 + sda: PB4 + observed: [scl] + # --with i2c: I2C3 master on PC0/PC1, the i2cs target on I2C1 PB8/PB9 + # (jumpered PB8-PC0, PB9-PC1; DIO3 sees SCL, DIO5 SDA), 4.7 kOhm + # pull-ups and the 24LC256 at 0x50 on the same rows. + loop: + master: {index: 3, scl: PC0, sda: PC1} + target: {index: 1, scl: PB8, sda: PB9, addr: 0x42} + freqs: + standalone: [100000] + option: [100000, 400000] + # Nobody answers these: an address NACK (absent_addr) and the + # arbitration-loss address (all ones, so the injected 0 always loses). + absent_addr: 0x10 + arbitration_addr: 0x7f + payload_lengths: [1, 2, 255, 256, 257, 300, 1024] + # nack=k for k in nack_positions and nack_length - 1, nack_length. + nack_positions: [1, 2, 5] + nack_length: 8 + stretch_us: [100, 2000] + # Measured SCL frequency / nominal: I2cTiming never runs faster than + # nominal; a slow rise lowers it. + tolerance: [0.8, 1.01] + # 30-70 % rise time limit per mode (needs a scope on the bus rows; the + # WB55 scopes stay on the ADC inputs, so test_rise_time skips here). + rise_ns: {sm: 1000, fm: 300} + # D.2: the target's misplaced STOP needs tHIGH >= 4 us. + fault_freq: 100000 + eeprom: + # The adapter on the loop master's instance and pins; the i2cs target + # sits on the other instance for the 1-byte-address case. + index: 3 + scl: PC0 + sda: PC1 + addr: 0x50 + size: 32768 + page: 64 + abytes: 2 + wcycle_ms: 10 + erase_size: 8192 + freq: 400000 unsupported: commands: - comp.open 1 diff --git a/validation/host/boards/nucleo_wba55cg.yaml b/validation/host/boards/nucleo_wba55cg.yaml index 2eeed0ea..dff25cc6 100644 --- a/validation/host/boards/nucleo_wba55cg.yaml +++ b/validation/host/boards/nucleo_wba55cg.yaml @@ -823,6 +823,57 @@ tests: feed_seconds: 2.0 observe_periods: 3 period_tolerance: 0.03 + i2c: + # Standalone cases on bundle1 (or bundle2) with the MCU pull-ups + # (pull=up): PB2 DIO5, PB1 DIO6, PA15 DIO2, PB3 DIO1. I2C3 reaches + # PB2/PB1 through AF6. + instances: + - name: i2c1 + index: 1 + scl: PB2 + sda: PB1 + observed: [scl, sda] + - name: i2c3 + index: 3 + scl: PB2 + sda: PB1 + observed: [scl, sda] + - name: i2c1alt + index: 1 + scl: PA15 + sda: PB3 + observed: [scl, sda] + # bundle2 --with i2c: I2C3 master on PA6/PA7 (DIO9/DIO8), the i2cs + # target on I2C1 PB2/PB1 (DIO5/DIO6; jumpered PB2-PA6, PB1-PA7), scope + # 1/2 on the SCL/SDA rows, 4.7 kOhm pull-ups and the 24LC256 at 0x50. + loop: + master: {index: 3, scl: PA6, sda: PA7} + target: {index: 1, scl: PB2, sda: PB1, addr: 0x42} + freqs: + standalone: [100000] + option: [100000, 400000] + absent_addr: 0x10 + arbitration_addr: 0x7f + payload_lengths: [1, 2, 255, 256, 257, 300, 1024] + nack_positions: [1, 2, 5] + nack_length: 8 + stretch_us: [100, 2000] + tolerance: [0.8, 1.01] + # 30-70 % rise time on the bundle2 scopes; with two DIOs and a probe + # per row a 4.7 kOhm pull-up may miss Fast mode: use 2.2 kOhm then. + rise_ns: {sm: 1000, fm: 300} + fault_freq: 100000 + eeprom: + index: 3 + scl: PA6 + sda: PA7 + addr: 0x50 + size: 32768 + page: 64 + abytes: 2 + wcycle_ms: 10 + erase_size: 8192 + freq: 400000 unsupported: commands: - comp.open 1 diff --git a/validation/host/hal_st_validation/fakes/i2c.py b/validation/host/hal_st_validation/fakes/i2c.py new file mode 100644 index 00000000..6b5cce0d --- /dev/null +++ b/validation/host/hal_st_validation/fakes/i2c.py @@ -0,0 +1,550 @@ +"""Fake `i2c`, `i2cs` and `eeprom` groups: the argument checks of validation/firmware/I2cGroup.cpp, I2cTarget.cpp, +EepromGroup.cpp and EMIL's `HilEepromCommands` in their order, the pins and `ResourceAllocation` i2c instances they +hold, and one bus that every open master, target and attached EEPROM adapter share (the fake does not know the +wiring), with two devices: the `i2cs` target while it is open, and a 24LC256 at 0x50 that keeps its contents across +resets. General calls (address 0) are not acknowledged. Arbitration loss and clock stretching are accepted and not +modelled (they need the AD3); `i2cs.cfg fault=stop` makes the next read answer `buserror`. +""" + +from __future__ import annotations + +import zlib +from dataclasses import dataclass, field +from typing import Any + +from ..groups.i2c import ( + DEFAULT_TIMING, + EEPROM_BUFFER, + I2C_BUFFER, + I2C_HEX_MAX, + TARGET_DUMP_MAX, + TARGET_LAST_MAX, + TARGET_REGISTERS, + TIMING_MASK, +) +from ..i2c import BUS_MAX, BUS_MIN, expected_timing +from ..patterns import PATTERNS, SEED_MAX, crc_text, generate +from .base import UINT32_MAX, FakeGroup, _choice, _fail, _hex, _number, _shape + +_INSTANCES = 4 +_ADDRESS_MAX = 0x7F +_TARGET_ADDRESS_MIN = 0x08 +_TARGET_ADDRESS_MAX = 0x77 +_TARGET_ADDRESS = 0x42 +_STRETCH_MAX = 10_000 +_POSITION_MAX = 0xFFFF +_TARGET_BUS = 400_000 +_EEPROM_SIZE_MAX = 65536 +_EEPROM_PAGE_MIN = 8 +_EEPROM_PAGE_MAX = 256 +_EEPROM_WRITE_CYCLE_MAX = 20 +_ONE_BYTE_SPACE = 256 +# A page write keeps the chip busy for this many address phases (the adapter's ACK polling sees them NACKed) or +# until the host's next command, whichever comes first. +_WRITE_CYCLE_POLLS = 2 +_POLLS_MAX = 1000 + +_OPEN_KEYS = ("scl", "sda", "freq", "timing", "pull") +_WRITE_KEYS = ("next", "len", "pattern", "seed") +_READ_KEYS = ("next", "out") +_TARGET_KEYS = ("scl", "sda", "addr", "mode", "timing") +_CONFIGURE_KEYS = ("nack", "addrnack", "stretch", "stretchat", "fault", "faultat", "pattern", "seed") +_ATTACH_KEYS = ("scl", "sda", "addr", "size", "page", "abytes", "freq", "wcycle") +_NEXT = ("stop", "restart", "continue") + + +@dataclass +class _Chip: + """24LC256: 32 KiB, 64-byte pages, a 16-bit word address. Data bytes of a write land inside the page of the + address (wrapping at its end) once the STOP arrives, which starts the write cycle; reads continue from the + address counter and wrap at the end of the memory.""" + + address: int = 0x50 + size: int = 32768 + page: int = 64 + memory: bytearray = field(default_factory=lambda: bytearray(b"\xff" * 32768)) + pointer: int = 0 + cycle: int = 0 + received: bytearray = field(default_factory=bytearray) + + def acknowledge(self, read: bool) -> bool: + if self.cycle: + self.cycle -= 1 + return False + self.received = bytearray() + return True + + def write(self, data: bytes) -> int: + for byte in data: + self.received.append(byte) + if len(self.received) == 2: + self.pointer = (self.received[0] << 8 | self.received[1]) % self.size + return len(data) + + def read(self, length: int) -> bytes | None: + data = bytes(self.memory[(self.pointer + offset) % self.size] for offset in range(length)) + self.pointer = (self.pointer + length) % self.size + return data + + def stop(self) -> None: + data = self.received[2:] + self.received = bytearray() + if not data: + return + base = self.pointer - self.pointer % self.page + for offset, byte in enumerate(data): + self.memory[base + (self.pointer - base + offset) % self.page] = byte + self.pointer = base + (self.pointer - base + len(data)) % self.page + self.cycle = _WRITE_CYCLE_POLLS + + +@dataclass +class _Target: + """The `i2cs` scaffold: a 256-byte register file (the first byte of a write sets the pointer) or a sink that + reads back the C.8 pattern; the counters of `i2cs.status`.""" + + address: int + sink: bool + registers: bytearray = field(default_factory=lambda: bytearray(TARGET_REGISTERS)) + pointer: int = 0 + position: int = 0 + nack: int = 0 + addrnack: bool = False + fault: bool = False + faultat: int = 1 + pattern: str = "inc" + seed: int = 0 + rx: int = 0 + tx: int = 0 + crc: int = 0 + writes: int = 0 + reads: int = 0 + stops: int = 0 + nacked: int = 0 + errors: int = 0 + last: bytearray = field(default_factory=bytearray) + + def acknowledge(self, read: bool) -> bool: + if self.addrnack: + return False + self.position = 0 + if read: + self.reads += 1 + else: + self.writes += 1 + self.last = bytearray() + return True + + def write(self, data: bytes) -> int: + for index, byte in enumerate(data): + self.position += 1 + if self.position == self.nack: + self.nacked += 1 + return index + self.rx += 1 + self.crc = zlib.crc32(bytes([byte]), self.crc) + if len(self.last) < TARGET_LAST_MAX: + self.last.append(byte) + if self.sink: + continue + if self.position == 1: + self.pointer = byte + else: + self.registers[self.pointer] = byte + self.pointer = (self.pointer + 1) % TARGET_REGISTERS + return len(data) + + def read(self, length: int) -> bytes | None: + if self.fault and self.faultat <= length: + self.fault = False + return None + self.tx += length + if self.sink: + return generate(length, self.pattern, self.seed) + data = bytes(self.registers[(self.pointer + offset) % TARGET_REGISTERS] for offset in range(length)) + self.pointer = (self.pointer + length) % TARGET_REGISTERS + return data + + def stop(self) -> None: + self.stops += 1 + + def status(self) -> str: + return ( + f"OK rx={self.rx} tx={self.tx} crc={self.crc:08x} writes={self.writes} reads={self.reads} stops={self.stops}" + f" nacked={self.nacked} errors={self.errors} last={self.last.hex()}" + ) + + def clear(self) -> None: + self.rx = self.tx = self.crc = self.writes = self.reads = self.stops = self.nacked = self.errors = 0 + self.last = bytearray() + + +Device = _Chip | _Target + + +def _device(fw: Any, address: int) -> Device | None: + """The device that answers `address` on the shared bus: the open target first, then the EEPROM chip.""" + target = fw.group("i2cs") + if isinstance(target, FakeI2cTarget) and target.device is not None and target.device.address == address: + return target.device + eeprom = fw.group("eeprom") + if isinstance(eeprom, FakeEeprom) and eeprom.chip.address == address: + return eeprom.chip + return None + + +def _write(fw: Any, address: int, data: bytes, stop: bool = True) -> tuple[int, str, bool]: + """A master write with its address phase: (acknowledged data bytes, `complete`|`nack`, address acknowledged). + A NACK ends the transfer with a STOP.""" + device = _device(fw, address) + if device is None or not device.acknowledge(False): + return 0, "nack", False + sent = device.write(data) + if sent < len(data): + device.stop() + return sent, "nack", True + if stop: + device.stop() + return sent, "complete", True + + +def _read(fw: Any, address: int, length: int, stop: bool = True) -> tuple[bytes | None, str]: + device = _device(fw, address) + if device is None or not device.acknowledge(True): + return None, "nack" + data = device.read(length) + if data is None: + return None, "buserror" + if stop: + device.stop() + return data, "complete" + + +def _payload(args: list[str], options: dict[str, str], position: int, capacity: int) -> bytes: + """`ParsePayload` (C.8): hex, or `-` with `len`/`pattern`/`seed`; `-` alone is empty.""" + text = args[position] + generated = "len" in options + patterned = "pattern" in options or "seed" in options + if text != "-": + if generated or patterned: + _fail("usage") + return _hex(text, capacity) + if not generated: + if patterned: + _fail("usage") + return b"" + pattern = _choice(options, "pattern", PATTERNS, "inc") + seed = _number(options.get("seed", "0"), 0, SEED_MAX) + length = _number(options["len"], 1, capacity) + return generate(length, pattern, seed) + + +def _pins(fw: Any, options: dict[str, str], index: int) -> tuple[str, str]: + scl = fw.pin(options["scl"]) + sda = fw.pin(options["sda"]) + fw.check_function("i2cScl", index, scl) + fw.check_function("i2cSda", index, sda) + return scl, sda + + +class FakeI2c(FakeGroup): + prefix = "i2c" + + def cmd_open(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, _OPEN_KEYS) + index = _number(args[0], 0, _INSTANCES - 1) + if "scl" not in options or "sda" not in options or ("freq" in options and "timing" in options): + _fail("usage") + pull = _choice(options, "pull", ("none", "up"), "none") + raw = _number(options.get("timing", "0"), 0, UINT32_MAX) + frequency = _number(options.get("freq", "0"), 0, UINT32_MAX) + if index not in self.fw.spec.i2cs: + _fail("range") + kernel = self.fw.kernel_clock + timing = DEFAULT_TIMING + if "timing" in options: + timing = raw & TIMING_MASK + elif "freq" in options: + computed = expected_timing(kernel, frequency) + if computed is None: + _fail("range") + assert computed is not None + timing = computed + scl, sda = _pins(self.fw, options, index) + state = {"index": index, "pull": pull, "continuing": None} + self.fw.open_instance(self.prefix, str(index), state, [scl, sda], resources=[("i2c", index)]) + return f"OK timing=0x{timing:08x} kernel={kernel}" + + def _find(self, args: list[str], options: dict[str, str], keys: tuple[str, ...]) -> dict[str, Any]: + _shape(args, options, 3, 3, keys) + return self.fw.find_instance(self.prefix, str(_number(args[0], 0, _INSTANCES - 1))) + + def _hook(self, state: dict[str, Any], hook: str) -> None: + self.fw.event(f"EVT i2c index={state['index']} hook={hook}") + + def _continued(self, state: dict[str, Any], read: bool) -> Device | None: + """The device of a transfer that ended with `next=continue` in the same direction: no new address phase.""" + continuing, state["continuing"] = state["continuing"], None + if continuing is None or continuing[1] != read: + return None + return _device(self.fw, continuing[0]) + + def cmd_write(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options, _WRITE_KEYS) + address = _number(args[1], 0, _ADDRESS_MAX) + action = _choice(options, "next", _NEXT, "stop") + data = _payload(args, options, 2, I2C_BUFFER) + device = self._continued(state, False) + if device is not None: + sent = device.write(data) + result = "complete" if sent == len(data) else "nack" + if result == "nack" or action == "stop": + device.stop() + else: + sent, result, found = _write(self.fw, address, data, stop=action == "stop") + if not found: + self._hook(state, "notfound") + if result == "complete" and action == "continue": + state["continuing"] = (address, False) + return f"OK sent={sent} result={result}" + + def cmd_read(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options, _READ_KEYS) + address = _number(args[1], 0, _ADDRESS_MAX) + length = _number(args[2], 1, I2C_BUFFER) + action = _choice(options, "next", _NEXT, "stop") + output = _choice(options, "out", ("hex", "crc"), "hex") + if output == "hex" and length > I2C_HEX_MAX: + _fail("range") + device = self._continued(state, True) + if device is not None: + data = device.read(length) + result = "complete" if data is not None else "buserror" + if data is not None and action == "stop": + device.stop() + else: + data, result = _read(self.fw, address, length, stop=action == "stop") + if result == "nack": + self._hook(state, "notfound") + elif result == "buserror": + self._hook(state, "buserror") + if data is not None and action == "continue": + state["continuing"] = (address, True) + if data is None: + return f"OK result={result}" + if output == "crc": + return f"OK result={result} len={length} crc={crc_text(data)}" + return f"OK result={result} data={data.hex()}" + + def cmd_close(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + key = str(_number(args[0], 0, _INSTANCES - 1)) + self.fw.find_instance(self.prefix, key) + return self.fw.close_instance(self.prefix, key) + + +class FakeI2cTarget(FakeGroup): + prefix = "i2cs" + device: _Target | None = None + + def boot(self) -> None: + self.device = None + + def cmd_open(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, _TARGET_KEYS) + index = _number(args[0], 0, _INSTANCES - 1) + if "scl" not in options or "sda" not in options: + _fail("usage") + mode = _choice(options, "mode", ("regs", "sink"), "regs") + _number(options.get("timing", "0"), 0, UINT32_MAX) + address = _number(options.get("addr", str(_TARGET_ADDRESS)), _TARGET_ADDRESS_MIN, _TARGET_ADDRESS_MAX) + if index not in self.fw.spec.i2cs or expected_timing(self.fw.kernel_clock, _TARGET_BUS) is None: + _fail("range") + scl, sda = _pins(self.fw, options, index) + self.fw.open_instance(self.prefix, str(index), {"index": index}, [scl, sda], resources=[("i2c", index)]) + self.device = _Target(address, mode == "sink") + return f"OK addr=0x{address:02x}" + + def _find(self, args: list[str], options: dict[str, str], low: int, high: int, keys: tuple[str, ...] = ()) -> _Target: + _shape(args, options, low, high, keys) + self.fw.find_instance(self.prefix, str(_number(args[0], 0, _INSTANCES - 1))) + assert self.device is not None + return self.device + + def cmd_cfg(self, args: list[str], options: dict[str, str]) -> str: + device = self._find(args, options, 1, 1, _CONFIGURE_KEYS) + nack = _number(options.get("nack", "0"), 0, _POSITION_MAX) + addrnack = _number(options.get("addrnack", "0"), 0, 1) == 1 + _number(options.get("stretch", "0"), 0, _STRETCH_MAX) + _number(options.get("stretchat", "0"), 0, _POSITION_MAX) + fault = _choice(options, "fault", ("none", "stop"), "none") == "stop" + faultat = _number(options.get("faultat", "1"), 1, _POSITION_MAX) + pattern = _choice(options, "pattern", PATTERNS, "inc") + seed = _number(options.get("seed", "0"), 0, SEED_MAX) + device.nack, device.addrnack, device.fault, device.faultat = nack, addrnack, fault, faultat + device.pattern, device.seed = pattern, seed + return "OK" + + def cmd_status(self, args: list[str], options: dict[str, str]) -> str: + device = self._find(args, options, 1, 1, ("clear",)) + clear = _number(options.get("clear", "0"), 0, 1) == 1 + line = device.status() + if clear: + device.clear() + return line + + def cmd_regs(self, args: list[str], options: dict[str, str]) -> str: + device = self._find(args, options, 3, 3) + offset = _number(args[1], 0, TARGET_REGISTERS - 1) + data = _hex(args[2], TARGET_DUMP_MAX) + if not data: + _fail("usage") + if offset + len(data) > TARGET_REGISTERS: + _fail("range") + device.registers[offset : offset + len(data)] = data + return "OK" + + def cmd_dump(self, args: list[str], options: dict[str, str]) -> str: + device = self._find(args, options, 3, 3) + offset = _number(args[1], 0, TARGET_REGISTERS - 1) + length = _number(args[2], 1, TARGET_DUMP_MAX) + if offset + length > TARGET_REGISTERS: + _fail("range") + return f"OK data={device.registers[offset : offset + length].hex()}" + + def cmd_close(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + key = str(_number(args[0], 0, _INSTANCES - 1)) + self.fw.find_instance(self.prefix, key) + self.device = None + return self.fw.close_instance(self.prefix, key) + + +@dataclass +class _Adapter: + """`I2cEepromStm`: page writes with ACK polling, random reads with a repeated START.""" + + address: int + size: int + page: int + abytes: int + wcycle: int + failed: bool = False + + def word(self, address: int) -> bytes: + return address.to_bytes(self.abytes, "big") if self.abytes == 2 else bytes([address & 0xFF]) + + +class FakeEeprom(FakeGroup): + """`eeprom.attach`/`eeprom.detach` and EMIL's `eeprom.write`/`eeprom.read`/`eeprom.erase` over the adapter. An + error prints `EVT eeprom error=... address=...` and answers `ERR timeout` at once (the firmware after 5 s); the + group stays busy until `eeprom.detach`.""" + + prefix = "eeprom" + adapter: _Adapter | None = None + + def __init__(self, fw: Any) -> None: + super().__init__(fw) + self.chip = _Chip() + + def boot(self) -> None: + self.adapter = None + self.chip.cycle = 0 + self.chip.received = bytearray() + + def poll(self) -> None: + self.chip.cycle = 0 + + def cmd_attach(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, _ATTACH_KEYS) + index = _number(args[0], 0, _INSTANCES - 1) + if "scl" not in options or "sda" not in options: + _fail("usage") + address, size, page, abytes, wcycle, frequency = ( + _number(options.get(key, default), 0, UINT32_MAX) + for key, default in (("addr", "0x50"), ("size", "32768"), ("page", "64"), ("abytes", "2"), ("wcycle", "10"), ("freq", "400000")) + ) + if index not in self.fw.spec.i2cs or not _TARGET_ADDRESS_MIN <= address <= _TARGET_ADDRESS_MAX or not 1 <= size <= _EEPROM_SIZE_MAX: + _fail("range") + if not _EEPROM_PAGE_MIN <= page <= _EEPROM_PAGE_MAX or page & (page - 1) or abytes not in (1, 2): + _fail("range") + if wcycle > _EEPROM_WRITE_CYCLE_MAX: + _fail("range") + if (abytes == 1 and size > _ONE_BYTE_SPACE) or not BUS_MIN <= frequency <= BUS_MAX: + _fail("range") + if expected_timing(self.fw.kernel_clock, frequency) is None: + _fail("range") + scl, sda = _pins(self.fw, options, index) + self.fw.open_instance(self.prefix, "attached", {"index": index}, [scl, sda], resources=[("i2c", index)]) + self.adapter = _Adapter(address, size, page, abytes, wcycle) + return "OK" + + def cmd_detach(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 0, 0) + self.fw.find_instance(self.prefix, "attached") + self.adapter = None + return self.fw.close_instance(self.prefix, "attached") + + def _size(self) -> int: + return 0 if self.adapter is None else self.adapter.size + + def _check_busy(self) -> None: + if self.adapter is not None and self.adapter.failed: + _fail("busy") + + def _fail(self, adapter: _Adapter, result: str, address: int) -> str: + adapter.failed = True + self.fw.event(f"EVT eeprom error={result} address={address}") + return "ERR timeout" + + def cmd_write(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 2) + address = _number(args[0], 0, self._size()) + data = _hex(args[1], EEPROM_BUFFER) + if not data: + _fail("usage") + if len(data) > self._size() - address: + _fail("range") + self._check_busy() + assert self.adapter is not None + return self._write(self.adapter, address, data) + + def _write(self, adapter: _Adapter, address: int, data: bytes) -> str: + while data: + chunk = min(len(data), adapter.page - address % adapter.page) + _, result, _ = _write(self.fw, adapter.address, adapter.word(address) + data[:chunk]) + if result != "complete": + return self._fail(adapter, result, address) + if adapter.wcycle: + for _ in range(_POLLS_MAX): + if _write(self.fw, adapter.address, adapter.word(address))[1] == "complete": + break + else: + return self._fail(adapter, "nack", address) + address += chunk + data = data[chunk:] + return "OK" + + def cmd_read(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 2) + address = _number(args[0], 0, self._size()) + length = _number(args[1], 1, EEPROM_BUFFER) + if length > self._size() - address: + _fail("range") + self._check_busy() + adapter = self.adapter + assert adapter is not None + _, result, _ = _write(self.fw, adapter.address, adapter.word(address), stop=False) + if result != "complete": + return self._fail(adapter, result, address) + data, result = _read(self.fw, adapter.address, length) + if data is None: + return self._fail(adapter, result, address) + return f"OK data={data.hex()}" + + def cmd_erase(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 0, 0) + self._check_busy() + if self.adapter is None: + return "OK" + return self._write(self.adapter, 0, b"\xff" * self.adapter.size) diff --git a/validation/host/hal_st_validation/groups/i2c.py b/validation/host/hal_st_validation/groups/i2c.py new file mode 100644 index 00000000..38f66907 --- /dev/null +++ b/validation/host/hal_st_validation/groups/i2c.py @@ -0,0 +1,307 @@ +"""I2C groups of validation/PROTOCOL.md: `i2c` (master, `hal::I2cStm`), `i2cs` (the LL target scaffold on another +instance) and `eeprom` (`eeprom.attach`/`eeprom.detach` of validation/firmware/EepromGroup.cpp around EMIL's +`eeprom.write`/`eeprom.read`/`eeprom.erase` over the `I2cEepromStm` adapter). + +Sources: validation/firmware/I2cGroup.cpp (`i2c.*` answers within 1000 ms or `ERR timeout`; `EVT i2c index= +hook=notfound|buserror|arblost` precedes the final line of the transfer), I2cTarget.cpp, EepromGroup.cpp and +I2cEepromStm.cpp; EMIL `HilEepromCommands.cpp` (128-byte buffer, 5 s timeout). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from ad3_waveforms_bench.protocol import Event + +from ..patterns import Pattern +from .base import Group, Pin + +__all__ = [ + "DEFAULT_TIMING", + "EEPROM_BUFFER", + "EEPROM_TIMEOUT_S", + "GROUPS", + "I2C_BUFFER", + "I2C_HEX_MAX", + "I2C_TIMEOUT_S", + "TARGET_DUMP_MAX", + "TARGET_LAST_MAX", + "TARGET_REGISTERS", + "TIMING_MASK", + "Eeprom", + "I2c", + "I2cOpen", + "I2cRead", + "I2cTarget", + "I2cWrite", + "Next", + "TargetStatus", +] + +Next = Literal["stop", "restart", "continue"] + +# `hal::I2cStm::Config::timing` on STM32WB/WBA (B.1f): Standard mode for every kernel clock up to 100 MHz. +DEFAULT_TIMING = 0x70B03D3D +# `HAL_I2C_Init` drops TIMINGR bits 24-27 (reserved); `timing=` replies the register as written. +TIMING_MASK = 0xF0FFFFFF +I2C_BUFFER = 1024 +I2C_HEX_MAX = 128 +I2C_TIMEOUT_S = 1.0 +TARGET_REGISTERS = 256 +TARGET_DUMP_MAX = 128 +TARGET_LAST_MAX = 32 +EEPROM_BUFFER = 128 +EEPROM_TIMEOUT_S = 5.0 + + +@dataclass(frozen=True) +class I2cOpen: + timing: int + kernel: int + + +@dataclass(frozen=True) +class I2cWrite: + """`sent` is `numberOfBytesSent` of `hal::I2cMaster::SendData`: the acknowledged data bytes; `result` is + `complete`, `nack` or `buserror`.""" + + sent: int + result: str + + +@dataclass(frozen=True) +class I2cRead: + """`data` (hex output) or `length` and `crc` (`out=crc`) when `result` is complete, else only `result`.""" + + result: str + data: bytes | None = None + length: int | None = None + crc: str | None = None + + +@dataclass(frozen=True) +class TargetStatus: + rx: int + tx: int + crc: str + writes: int + reads: int + stops: int + nacked: int + errors: int + last: bytes + + +class I2c(Group): + prefix = "i2c" + + def _timeout(self, cmd_timeout: float | None) -> float: + return cmd_timeout or self._fw.terminal.timeout + I2C_TIMEOUT_S + + def open( + self, + index: int, + scl: Pin, + sda: Pin, + freq: int | None = None, + timing: int | None = None, + pull: Literal["none", "up"] | None = None, + ) -> I2cOpen: + response = self._cmd( + "open", + index, + scl=self._pin(scl), + sda=self._pin(sda), + freq=freq, + timing=None if timing is None else f"0x{timing:08x}", + pull=pull, + ) + self._fw.track((self.prefix, index), f"{self.prefix}.close", index) + return I2cOpen(response.as_int("timing"), response.as_int("kernel")) + + def write( + self, + index: int, + address: int, + data: bytes = b"", + next: Next | None = None, + len: int | None = None, + pattern: Pattern | None = None, + seed: int | None = None, + cmd_timeout: float | None = None, + ) -> I2cWrite: + """`data` empty and no `len` is a zero-length write (an address probe).""" + response = self._cmd( + "write", + index, + f"0x{address:02x}", + data, + next=next, + len=len, + pattern=pattern, + seed=seed, + cmd_timeout=self._timeout(cmd_timeout), + ) + return I2cWrite(response.as_int("sent"), response["result"]) + + def read( + self, + index: int, + address: int, + length: int, + next: Next | None = None, + out: Literal["hex", "crc"] | None = None, + cmd_timeout: float | None = None, + ) -> I2cRead: + response = self._cmd("read", index, f"0x{address:02x}", length, next=next, out=out, cmd_timeout=self._timeout(cmd_timeout)) + result = response["result"] + if "crc" in response: + return I2cRead(result, length=response.as_int("len"), crc=response["crc"]) + if "data" in response: + return I2cRead(result, data=response.as_bytes("data")) + return I2cRead(result) + + def close(self, index: int) -> None: + self._cmd("close", index) + self._fw.untrack((self.prefix, index)) + + def events(self, index: int) -> list[Event]: + """The `EVT i2c` lines of `index` received so far, removed from the terminal's queue.""" + return [event for event in self._fw.terminal.drain_events("i2c") if event.as_int("index") == index] + + def hooks(self, index: int) -> list[str]: + """The driver hooks (`notfound`, `buserror`, `arblost`) reported for `index` since the last call.""" + return [event["hook"] for event in self.events(index)] + + +class I2cTarget(Group): + prefix = "i2cs" + + def open( + self, + index: int, + scl: Pin, + sda: Pin, + addr: int | None = None, + mode: Literal["regs", "sink"] | None = None, + timing: int | None = None, + ) -> int: + """Returns the own address.""" + response = self._cmd( + "open", + index, + scl=self._pin(scl), + sda=self._pin(sda), + addr=None if addr is None else f"0x{addr:02x}", + mode=mode, + timing=None if timing is None else f"0x{timing:08x}", + ) + self._fw.track((self.prefix, index), f"{self.prefix}.close", index) + return response.as_int("addr") + + def cfg( + self, + index: int, + nack: int | None = None, + addrnack: bool | None = None, + stretch: int | None = None, + stretchat: int | None = None, + fault: Literal["none", "stop"] | None = None, + faultat: int | None = None, + pattern: Pattern | None = None, + seed: int | None = None, + ) -> None: + """Replaces the whole behaviour: options left out take their defaults (`nack=0 addrnack=0 stretch=0 + stretchat=0 fault=none faultat=1 pattern=inc seed=0`).""" + self._cmd( + "cfg", + index, + nack=nack, + addrnack=addrnack, + stretch=stretch, + stretchat=stretchat, + fault=fault, + faultat=faultat, + pattern=pattern, + seed=seed, + ) + + def status(self, index: int, clear: bool | None = None) -> TargetStatus: + response = self._cmd("status", index, clear=clear) + return TargetStatus( + rx=response.as_int("rx"), + tx=response.as_int("tx"), + crc=response["crc"], + writes=response.as_int("writes"), + reads=response.as_int("reads"), + stops=response.as_int("stops"), + nacked=response.as_int("nacked"), + errors=response.as_int("errors"), + last=response.as_bytes("last"), + ) + + def regs(self, index: int, offset: int, data: bytes) -> None: + self._cmd("regs", index, offset, data) + + def dump(self, index: int, offset: int, length: int) -> bytes: + return self._cmd("dump", index, offset, length).as_bytes("data") + + def close(self, index: int) -> None: + self._cmd("close", index) + self._fw.untrack((self.prefix, index)) + + +class Eeprom(Group): + prefix = "eeprom" + + def _timeout(self, cmd_timeout: float | None) -> float: + return cmd_timeout or self._fw.terminal.timeout + EEPROM_TIMEOUT_S + + def attach( + self, + index: int, + scl: Pin, + sda: Pin, + addr: int | None = None, + size: int | None = None, + page: int | None = None, + abytes: Literal[1, 2] | None = None, + freq: int | None = None, + wcycle: int | None = None, + ) -> None: + self._cmd( + "attach", + index, + scl=self._pin(scl), + sda=self._pin(sda), + addr=None if addr is None else f"0x{addr:02x}", + size=size, + page=page, + abytes=abytes, + freq=freq, + wcycle=wcycle, + ) + self._fw.track((self.prefix,), f"{self.prefix}.detach") + + def detach(self, cmd_timeout: float | None = None) -> None: + self._cmd("detach", cmd_timeout=cmd_timeout) + self._fw.untrack((self.prefix,)) + + def write(self, address: int, data: bytes, cmd_timeout: float | None = None) -> None: + """EMIL `eeprom.write`: at most `EEPROM_BUFFER` bytes, written page by page with ACK polling.""" + self._cmd("write", address, data, cmd_timeout=self._timeout(cmd_timeout)) + + def read(self, address: int, length: int, cmd_timeout: float | None = None) -> bytes: + return self._cmd("read", address, length, cmd_timeout=self._timeout(cmd_timeout)).as_bytes("data") + + def erase(self, cmd_timeout: float | None = None) -> None: + """Every byte of the attached `size` to 0xFF, a page at a time.""" + self._cmd("erase", cmd_timeout=self._timeout(cmd_timeout)) + + def errors(self) -> list[Event]: + """The `EVT eeprom error= address=` lines received so far.""" + return self._fw.terminal.drain_events("eeprom") + + +GROUPS: dict[str, type[Group]] = {"i2c": I2c, "i2cs": I2cTarget, "eeprom": Eeprom} diff --git a/validation/host/hal_st_validation/i2c.py b/validation/host/hal_st_validation/i2c.py new file mode 100644 index 00000000..491d7944 --- /dev/null +++ b/validation/host/hal_st_validation/i2c.py @@ -0,0 +1,409 @@ +"""I2C bus helpers for the `i2c`, `i2cs` and `eeprom` groups of validation/PROTOCOL.md: decoding a logic-analyzer +capture of SCL and SDA, the TIMINGR arithmetic of validation/firmware/I2cTiming.cpp (`expected_timing`, bit for bit), +and AD3 helpers that start on a START condition (a logic-analyzer capture, or an SDA pulse for arbitration loss). + +The AD3 helpers pass every argument as a ctypes value, like `ad3_waveforms_bench.instruments.ad3`, so they also work +through `--ad3-remote`. +""" + +from __future__ import annotations + +import time +from collections.abc import Sequence +from ctypes import byref, c_double, c_int, c_ubyte, c_uint +from dataclasses import dataclass, field +from statistics import median +from typing import TYPE_CHECKING, Any, NamedTuple + +from ad3_waveforms_bench.instruments.ad3 import PendingCapture + +if TYPE_CHECKING: + from ad3_waveforms_bench import AnalogDiscovery3 + +__all__ = [ + "BUS_MAX", + "BUS_MIN", + "FAST_MODE", + "STANDARD_MODE", + "I2cByte", + "I2cTransfer", + "ModeLimits", + "PendingScope", + "SclTiming", + "arbitration_window", + "arm_on_start", + "arm_scope", + "bit_times", + "expected_timing", + "i2c_decode", + "inject_on_start", + "mode_limits", + "rise_times", + "scl_timing", + "timing_fields", +] + +BUS_MIN = 20_000 +BUS_MAX = 400_000 +_PICOSECONDS = 10**12 +_FIELD_MAX = 15 +_PERIOD_MAX = 255 +_ANALOG_FILTER_MIN = 50_000 +_ANALOG_FILTER_MAX = 260_000 + + +@dataclass(frozen=True) +class ModeLimits: + """I2C specification limits of one speed mode, in picoseconds (`rise`/`fall` are the characterisation edges + the TIMINGR computation assumes).""" + + hddat_min: int + vddat_max: int + sudat_min: int + lscl_min: int + hscl_min: int + rise: int + fall: int + + @property + def tlow_min(self) -> float: + return self.lscl_min / _PICOSECONDS + + @property + def thigh_min(self) -> float: + return self.hscl_min / _PICOSECONDS + + +STANDARD_MODE = ModeLimits(0, 3_450_000, 250_000, 4_700_000, 4_000_000, 640_000, 20_000) +FAST_MODE = ModeLimits(0, 900_000, 100_000, 1_300_000, 600_000, 250_000, 100_000) + + +def mode_limits(bus_hz: int) -> ModeLimits: + """Standard mode up to 100 kHz, Fast mode above.""" + return STANDARD_MODE if bus_hz <= 100_000 else FAST_MODE + + +def _ceil_div(numerator: int, denominator: int) -> int: + return -(-numerator // denominator) + + +def expected_timing(kernel_hz: int, bus_hz: int) -> int | None: + """TIMINGR for an I2C kernel clock and bus frequency, as `I2cTiming` computes it; None outside 20..400 kHz. + + Integer picoseconds: the first SCLDEL/SDADEL pair per prescaler, the minimal SCLL/SCLH, then the fewest clock + units whose period with a zero rise time is not shorter than 1/bus (extra units go to SCLL first); the shortest + period wins, ties to the smaller prescaler.""" + if kernel_hz <= 0 or not BUS_MIN <= bus_hz <= BUS_MAX: + return None + mode = mode_limits(bus_hz) + clock = _PICOSECONDS // kernel_hz + target = _PICOSECONDS // bus_hz + sdadel_min = max(mode.fall + mode.hddat_min - _ANALOG_FILTER_MIN - 3 * clock, 0) + sdadel_max = max(mode.vddat_max - mode.rise - _ANALOG_FILTER_MAX - 4 * clock, 0) + scldel_min = mode.rise + mode.sudat_min + best: tuple[int, int, int, int, int, int] | None = None + for prescaler in range(_FIELD_MAX + 1): + prescaled = (prescaler + 1) * clock + scldel = next((value for value in range(_FIELD_MAX + 1) if (value + 1) * prescaled >= scldel_min), None) + if scldel is None: + continue + sdadel = next( + (value for value in range(_FIELD_MAX + 1) if sdadel_min <= (value * (prescaler + 1) + 1) * clock <= sdadel_max), + None, + ) + if sdadel is None: + continue + sync = _ANALOG_FILTER_MIN + 2 * clock + scll_min = max(0, (mode.lscl_min - sync) // prescaled) + sclh_min = max(0, _ceil_div(mode.hscl_min - sync, prescaled) - 1) + fixed = 2 * sync + mode.fall + units = max(scll_min + sclh_min + 2, _ceil_div(target - fixed, prescaled)) + extra = units - (scll_min + sclh_min + 2) + scll = scll_min + (extra + 1) // 2 + sclh = sclh_min + extra // 2 + if scll > _PERIOD_MAX or sclh > _PERIOD_MAX: + continue + period = fixed + units * prescaled + if best is None or period < best[0]: + best = (period, prescaler, scldel, sdadel, sclh, scll) + if best is None: + return None + _, prescaler, scldel, sdadel, sclh, scll = best + return prescaler << 28 | scldel << 20 | sdadel << 16 | sclh << 8 | scll + + +def timing_fields(timingr: int) -> dict[str, int]: + """The fields of a TIMINGR value: `presc`, `scldel`, `sdadel`, `sclh`, `scll`.""" + return { + "presc": timingr >> 28 & 0xF, + "scldel": timingr >> 20 & 0xF, + "sdadel": timingr >> 16 & 0xF, + "sclh": timingr >> 8 & 0xFF, + "scll": timingr & 0xFF, + } + + +def bit_times(timingr: int, kernel_hz: int) -> tuple[float, float]: + """(tLOW, tHIGH) in seconds of a master running `timingr` at `kernel_hz` on a bus with zero rise time: each is + (SCLx + 1) prescaled clocks plus the analog filter and the two-clock synchronisation (ST's timing model).""" + fields = timing_fields(timingr) + clock = 1 / kernel_hz + prescaled = (fields["presc"] + 1) * clock + sync = _ANALOG_FILTER_MIN / _PICOSECONDS + 2 * clock + return sync + (fields["scll"] + 1) * prescaled, sync + (fields["sclh"] + 1) * prescaled + + +def arbitration_window(timingr: int, kernel_hz: int) -> tuple[float, float]: + """(delay, width) in seconds of an SDA pulse that starts in the middle of the SCL low phase of the second address + bit after a START and lasts one SCL period, so it covers that bit's high phase: the START hold is one tHIGH, then + bit 1, then half of bit 2's tLOW.""" + low, high = bit_times(timingr, kernel_hz) + return 2 * high + 1.5 * low, low + high + + +@dataclass(frozen=True) +class I2cByte: + value: int + ack: bool + + +@dataclass +class I2cTransfer: + """One address phase and its data bytes, from a START (`restart` when no STOP preceded it) to the next START or + STOP; `start` and `end` are sample indices (the START, and the STOP or the last sampled bit).""" + + restart: bool + address: int + read: bool + address_ack: bool + start: int = 0 + end: int = 0 + data: list[I2cByte] = field(default_factory=list) + stop: bool = False + + @property + def payload(self) -> bytes: + return bytes(byte.value for byte in self.data) + + +def i2c_decode(scl: Sequence[int], sda: Sequence[int]) -> list[I2cTransfer]: + """START/repeated START: SDA falls while SCL stays high; STOP: SDA rises while SCL stays high; a bit is the SDA + level at the first sample of each SCL high phase, nine bits per byte with the acknowledge last (0 = ACK).""" + transfers: list[I2cTransfer] = [] + bits: list[int] = [] + current: I2cTransfer | None = None + in_frame = False + restart = False + started = 0 + for index in range(1, min(len(scl), len(sda))): + if scl[index] and scl[index - 1] and sda[index] != sda[index - 1]: + if current is not None: + current.end = index + current.stop = bool(sda[index]) + transfers.append(current) + if not sda[index]: + restart = in_frame + started = index + in_frame = True + else: + in_frame = False + current, bits = None, [] + continue + if in_frame and scl[index] and not scl[index - 1]: + bits.append(1 if sda[index] else 0) + if len(bits) == 9: + value = int("".join(map(str, bits[:8])), 2) + ack = bits[8] == 0 + if current is None: + current = I2cTransfer(restart, value >> 1, bool(value & 1), ack, start=started) + else: + current.data.append(I2cByte(value, ack)) + current.end = index + bits = [] + if current is not None: + transfers.append(current) + return transfers + + +class SclTiming(NamedTuple): + """`freq` from the median SCL period (rising edge to rising edge); `tlow_min`/`thigh_min` the shortest complete + low and high phases, in seconds.""" + + freq: float + tlow_min: float + thigh_min: float + + +def scl_timing(scl: Sequence[int], rate: float) -> SclTiming: + """SCL clock figures of a capture sampled at `rate`; only phases bounded by edges on both sides count, so the + idle level before the first and after the last edge is ignored.""" + edges = [index for index in range(1, len(scl)) if scl[index] != scl[index - 1]] + lows = [(edges[i + 1] - edges[i]) / rate for i in range(len(edges) - 1) if not scl[edges[i]]] + highs = [(edges[i + 1] - edges[i]) / rate for i in range(len(edges) - 1) if scl[edges[i]]] + rising = [index for index in edges if scl[index]] + periods = [(rising[i + 1] - rising[i]) / rate for i in range(len(rising) - 1)] + if not periods or not lows or not highs: + raise ValueError("fewer than two SCL clock periods in the capture") + return SclTiming(1 / median(periods), min(lows), min(highs)) + + +def _wait(condition: Any, timeout: float, what: str) -> None: + deadline = time.monotonic() + timeout + while not condition(): + if time.monotonic() > deadline: + raise TimeoutError(f"{what} timed out after {timeout} s") + time.sleep(0.001) + + +def _detect_start(ad3: AnalogDiscovery3, scl_dio: int, sda_dio: int) -> None: + """The DigitalIn trigger detector on a START: SDA falling while SCL is high.""" + ad3.api.FDwfDigitalInTriggerSet(ad3.handle, c_uint(0), c_uint(1 << scl_dio), c_uint(0), c_uint(1 << sda_dio)) + + +def arm_on_start( + ad3: AnalogDiscovery3, scl_dio: int, sda_dio: int, rate: float, samples: int, pretrigger: float = 0.1, arm_timeout: float = 2.0 +) -> PendingCapture: + """`LogicAnalyzer.arm` with the trigger on a START condition instead of one edge; returns once armed, so a + transfer started afterwards cannot be missed. `wait()` the result for the capture.""" + analyzer = ad3.logic + buffer = analyzer.buffer_size + if samples > buffer: + raise ValueError(f"{samples} samples exceed the logic analyzer buffer ({buffer})") + api, handle, constants = ad3.api, ad3.handle, ad3.api.constants + divider = max(1, round(analyzer.clock_hz / rate)) + before = int(samples * pretrigger) + api.FDwfDigitalInReset(handle) + api.FDwfDigitalInAcquisitionModeSet(handle, constants.acqmodeSingle) + api.FDwfDigitalInDividerSet(handle, c_uint(divider)) + api.FDwfDigitalInSampleFormatSet(handle, c_int(16)) + api.FDwfDigitalInBufferSizeSet(handle, c_int(samples)) + api.FDwfDigitalInTriggerPositionSet(handle, c_uint(samples - before)) + api.FDwfDigitalInTriggerSourceSet(handle, constants.trigsrcDetectorDigitalIn) + _detect_start(ad3, scl_dio, sda_dio) + api.FDwfDigitalInConfigure(handle, c_int(1), c_int(1)) + waiting = {int(getattr(state, "value", state)) for state in (constants.DwfStateConfig, constants.DwfStatePrefill)} + + def armed() -> bool: + state = c_ubyte() + api.FDwfDigitalInStatus(handle, c_int(0), byref(state)) + return int(state.value) not in waiting + + _wait(armed, arm_timeout, "logic analyzer arm") + return PendingCapture(analyzer, analyzer.clock_hz / divider, samples, before) + + +def inject_on_start(ad3: AnalogDiscovery3, scl_dio: int, sda_dio: int, delay_s: float, width_s: float) -> None: + """Arm one open-drain low pulse on SDA, `width_s` long, `delay_s` after the next START (the DigitalIn trigger + detector starts the pattern generator): a master sending a 1 during the pulse loses arbitration. SDA is released + (high impedance) before and after the pulse; `ad3.pattern.stop()` disarms it.""" + if delay_s < 0 or width_s <= 0: + raise ValueError("delay must not be negative and width must be positive") + api, handle, constants = ad3.api, ad3.handle, ad3.api.constants + clock = ad3.pattern.clock_hz + low, high = c_uint(), c_uint() + api.FDwfDigitalOutCounterInfo(handle, c_int(sda_dio), byref(low), byref(high)) + counter_max = int(high.value) or 0x7FFF + divider = max(1, -(-round(width_s * clock) // counter_max)) + ticks = max(1, round(width_s * clock / divider)) + api.FDwfDigitalOutReset(handle) + _detect_start(ad3, scl_dio, sda_dio) + api.FDwfDigitalInConfigure(handle, c_int(1), c_int(0)) + api.FDwfDigitalOutEnableSet(handle, c_int(sda_dio), c_int(1)) + api.FDwfDigitalOutOutputSet(handle, c_int(sda_dio), constants.DwfDigitalOutOutputOpenDrain) + api.FDwfDigitalOutTypeSet(handle, c_int(sda_dio), constants.DwfDigitalOutTypePulse) + api.FDwfDigitalOutIdleSet(handle, c_int(sda_dio), constants.DwfDigitalOutIdleZet) + api.FDwfDigitalOutDividerSet(handle, c_int(sda_dio), c_uint(divider)) + api.FDwfDigitalOutCounterSet(handle, c_int(sda_dio), c_uint(ticks), c_uint(ticks)) + api.FDwfDigitalOutCounterInitSet(handle, c_int(sda_dio), c_int(0), c_uint(ticks)) + api.FDwfDigitalOutTriggerSourceSet(handle, constants.trigsrcDetectorDigitalIn) + api.FDwfDigitalOutWaitSet(handle, c_double(delay_s)) + api.FDwfDigitalOutRunSet(handle, c_double(width_s)) + api.FDwfDigitalOutRepeatSet(handle, c_uint(1)) + api.FDwfDigitalOutConfigure(handle, c_int(1)) + + +def rise_times(samples: Sequence[float], rate: float, low: float, high: float) -> list[float]: + """The `low` to `high` crossing times (seconds) of every rising edge of an analog capture, interpolated + linearly between samples; an edge still rising at the end of the capture is left out.""" + + def crossing(index: int, level: float) -> float: + before, after = samples[index - 1], samples[index] + return index - 1 + (level - before) / (after - before) + + times: list[float] = [] + start: float | None = None + for index in range(1, len(samples)): + if samples[index - 1] < low <= samples[index]: + start = crossing(index, low) + if start is not None and samples[index - 1] < high <= samples[index]: + times.append((crossing(index, high) - start) / rate) + start = None + elif samples[index] < low: + start = None + return times + + +@dataclass +class PendingScope: + """An armed scope acquisition (`arm_scope`); `wait()` returns the samples (volts) per scope channel.""" + + ad3: AnalogDiscovery3 + channels: tuple[int, ...] + samples: int + + def wait(self, timeout: float = 5.0) -> dict[int, list[float]]: + api, handle, constants = self.ad3.api, self.ad3.handle, self.ad3.api.constants + done = int(getattr(constants.DwfStateDone, "value", constants.DwfStateDone)) + + def finished() -> bool: + state = c_ubyte() + api.FDwfAnalogInStatus(handle, c_int(1), byref(state)) + return int(state.value) == done + + _wait(finished, timeout, "scope acquisition") + result: dict[int, list[float]] = {} + for channel in self.channels: + buffer = (c_double * self.samples)() + api.FDwfAnalogInStatusData(handle, c_int(channel - 1), byref(buffer), c_int(self.samples)) + result[channel] = list(buffer) + return result + + +def arm_scope( + ad3: AnalogDiscovery3, + channels: Sequence[int], + rate: float, + samples: int, + trigger: int, + level: float, + range_v: float = 10.0, + arm_timeout: float = 2.0, +) -> PendingScope: + """A single scope acquisition of `channels` (1/2) that starts on the next rising edge of channel `trigger` + through `level` volts, with the trigger at 10 % of the buffer and no auto trigger; returns once armed.""" + api, handle, constants = ad3.api, ad3.handle, ad3.api.constants + for index in (0, 1): + api.FDwfAnalogInChannelEnableSet(handle, c_int(index), c_int(int(index + 1 in channels))) + for channel in channels: + api.FDwfAnalogInChannelRangeSet(handle, c_int(channel - 1), c_double(range_v)) + api.FDwfAnalogInChannelOffsetSet(handle, c_int(channel - 1), c_double(0.0)) + api.FDwfAnalogInAcquisitionModeSet(handle, constants.acqmodeSingle) + api.FDwfAnalogInFrequencySet(handle, c_double(rate)) + api.FDwfAnalogInBufferSizeSet(handle, c_int(samples)) + api.FDwfAnalogInTriggerAutoTimeoutSet(handle, c_double(0.0)) + api.FDwfAnalogInTriggerSourceSet(handle, constants.trigsrcDetectorAnalogIn) + api.FDwfAnalogInTriggerTypeSet(handle, getattr(constants, "trigtypeEdge", c_int(0))) + api.FDwfAnalogInTriggerChannelSet(handle, c_int(trigger - 1)) + api.FDwfAnalogInTriggerLevelSet(handle, c_double(level)) + api.FDwfAnalogInTriggerConditionSet(handle, constants.DwfTriggerSlopeRise) + api.FDwfAnalogInTriggerPositionSet(handle, c_double(0.4 * samples / rate)) + api.FDwfAnalogInConfigure(handle, c_int(1), c_int(1)) + waiting = {int(getattr(state, "value", state)) for state in (constants.DwfStateConfig, constants.DwfStatePrefill)} + + def armed() -> bool: + state = c_ubyte() + api.FDwfAnalogInStatus(handle, c_int(0), byref(state)) + return int(state.value) not in waiting + + _wait(armed, arm_timeout, "scope arm") + return PendingScope(ad3, tuple(channels), samples) diff --git a/validation/host/tests/hil/test_eeprom.py b/validation/host/tests/hil/test_eeprom.py new file mode 100644 index 00000000..b885dc57 --- /dev/null +++ b/validation/host/tests/hil/test_eeprom.py @@ -0,0 +1,219 @@ +"""EMIL's `eeprom.*` group over the `I2cEepromStm` adapter (`hal::Eeprom` on `hal::I2cStm`), against the external +24Cxx of `--with i2c` (`tests.eeprom`: a 24LC256 at 0x50 by default) and, for the 1-byte word address, against the +`i2cs` register target on the other instance. + +The adapter splits writes at page boundaries and polls the chip's address after each page (B.1 address-NACK path) +until it acknowledges or `wcycle` ms pass; reads set the word address and read after a repeated START. An error +outside polling prints `EVT eeprom error=... address=...` and leaves EMIL's command pending (`ERR timeout` after +5 s); `eeprom.detach` recovers. Data written by one test is overwritten by the next, each at its own address range. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.firmware import quiesce, settle +from hal_st_validation.groups.i2c import EEPROM_BUFFER, EEPROM_TIMEOUT_S +from hal_st_validation.i2c import arm_on_start, i2c_decode +from hal_st_validation.patterns import generate + +pytestmark = pytest.mark.requires_option("i2c") + +READ_CHUNK = 128 +# `eeprom.write
` must fit the 255-character command line. +WRITE_MAX = (255 - len("eeprom.write 65535 ")) // 2 + + +@pytest.fixture +def eeprom_cfg(need, board_cfg) -> dict[str, Any]: + cfg = board_cfg.param("eeprom") + need.unloaded(cfg["scl"], cfg["sda"]) + return cfg + + +def attach(fw, cfg, **options) -> None: + settings = {key: cfg[key] for key in ("addr", "size", "page", "abytes", "freq")} + settings["wcycle"] = cfg["wcycle_ms"] + settings.update(options) + fw.eeprom.attach(cfg["index"], cfg["scl"], cfg["sda"], **settings) + + +def read_all(fw, address: int, length: int) -> bytes: + data = b"" + while len(data) < length: + chunk = min(READ_CHUNK, length - len(data)) + data += fw.eeprom.read(address + len(data), chunk) + return data + + +@pytest.fixture +def bench(request) -> None: + """The case measures the physical bus, which the fake does not model.""" + if request.config.getoption("--fake"): + pytest.skip("measures the physical bus: nothing to measure with --fake") + + +def expect_error(reason, call, *args, **options): + with pytest.raises(FirmwareError) as error: + call(*args, **options) + assert error.value.reason == reason, (args, options) + + +def test_eeprom_present(fw, eeprom_cfg): + """A zero-length write (address probe) to the chip is acknowledged.""" + fw.i2c.open(eeprom_cfg["index"], eeprom_cfg["scl"], eeprom_cfg["sda"]) + assert fw.i2c.write(eeprom_cfg["index"], eeprom_cfg["addr"]).result == "complete" + + +def test_attach_detach(fw, eeprom_cfg): + attach(fw, eeprom_cfg) + expect_error("busy", fw.eeprom.attach, eeprom_cfg["index"], eeprom_cfg["scl"], eeprom_cfg["sda"]) + expect_error("busy", fw.i2c.open, eeprom_cfg["index"], eeprom_cfg["scl"], eeprom_cfg["sda"]) + fw.eeprom.detach() + expect_error("notopen", fw.eeprom.detach) + expect_error("range", fw.eeprom.read, 0, 1) + fw.eeprom.erase() + attach(fw, eeprom_cfg) + + +def test_write_read_inside_a_page(fw, eeprom_cfg): + attach(fw, eeprom_cfg) + address = 2 * eeprom_cfg["page"] + 3 + data = generate(eeprom_cfg["page"] // 2, "prbs", 11) + fw.eeprom.write(address, data) + assert fw.eeprom.read(address, len(data)) == data + + +def test_write_across_a_page_boundary(fw, eeprom_cfg): + """The adapter splits the write at the page boundary, so nothing wraps inside a page.""" + attach(fw, eeprom_cfg) + page = eeprom_cfg["page"] + address = 4 * page - 5 + data = generate(min(WRITE_MAX, page + 10), "prbs", 12) + fw.eeprom.write(address, data) + assert fw.eeprom.read(address, len(data)) == data + assert fw.eeprom.read(4 * page, 5) == data[5:10] + + +def test_reads_across_pages(fw, eeprom_cfg): + attach(fw, eeprom_cfg) + address = 6 * eeprom_cfg["page"] + data = generate(EEPROM_BUFFER, "inc", 0x30) + half = EEPROM_BUFFER // 2 + fw.eeprom.write(address, data[:half]) + fw.eeprom.write(address + half, data[half:]) + assert fw.eeprom.read(address, EEPROM_BUFFER) == data + assert fw.eeprom.read(address + 1, EEPROM_BUFFER - 1) == data[1:] + + +def test_write_read_write_read(fw, eeprom_cfg): + """B.1h: ACK polling after a write that follows a read (stale counters used to abort or report 4294967295).""" + attach(fw, eeprom_cfg) + address = 10 * eeprom_cfg["page"] + for seed in (1, 2): + data = generate(16, "prbs", seed) + fw.eeprom.write(address, data) + assert fw.eeprom.read(address, 16) == data + + +@pytest.mark.slow +def test_erase(fw, eeprom_cfg): + """`eeprom.erase` writes 0xFF over `erase_size` (attached with that size: EMIL's 5 s limit covers it).""" + size = eeprom_cfg["erase_size"] + attach(fw, eeprom_cfg, size=size) + fw.eeprom.write(size - 8, b"\x00" * 8) + fw.eeprom.erase() + assert read_all(fw, 0, size) == b"\xff" * size + + +@pytest.mark.resets_board +def test_data_survives_reset(fw, eeprom_cfg): + attach(fw, eeprom_cfg) + address = 12 * eeprom_cfg["page"] + data = generate(8, "prbs", 99) + fw.eeprom.write(address, data) + fw.system.reset() + fw.forget_open() + attach(fw, eeprom_cfg) + assert fw.eeprom.read(address, 8) == data + + +def test_range_at_size(fw, eeprom_cfg): + size = eeprom_cfg["size"] + attach(fw, eeprom_cfg) + expect_error("range", fw.eeprom.write, size - 1, b"\x00\x00") + expect_error("range", fw.eeprom.read, size, 1) + expect_error("range", fw.eeprom.read, size - 1, 2) + fw.eeprom.write(size - 1, b"\xa5") + assert fw.eeprom.read(size - 1, 1) == b"\xa5" + + +@pytest.mark.ad3 +def test_ack_polling_on_la(fw, ad3, need, bench, eeprom_cfg): + """At 100 kHz from the START of the page write: the control byte (0xA0 for a chip at 0x50), the word address and + the data, then the first poll (the address bytes again) NACKed during the write cycle.""" + scl, sda = need.dio(eeprom_cfg["scl"]), need.dio(eeprom_cfg["sda"]) + attach(fw, eeprom_cfg, freq=100_000) + address = 14 * eeprom_cfg["page"] + data = generate(4, "prbs", 3) + rate = min(ad3.logic.clock_hz, 2e6) + samples = min(ad3.logic.buffer_size, int(rate * 2e-3)) + capture = arm_on_start(ad3, scl, sda, rate, samples, pretrigger=0.01) + fw.eeprom.write(address, data) + transfers = i2c_decode(*capture.wait(timeout=2.0).channels(scl, sda)) + assert len(transfers) >= 2, "no poll after the page write" + write, poll = transfers[0], transfers[1] + chip = eeprom_cfg["addr"] + assert (write.address, write.read, write.address_ack) == (chip, False, True) + assert write.payload == address.to_bytes(eeprom_cfg["abytes"], "big") + data + assert write.stop + assert (poll.address, poll.address_ack) == (chip, False) + + +@pytest.mark.slow +def test_error_then_detach_recovers(fw, eeprom_cfg): + """No device at 0x57: the first page write is NACKed (`EVT eeprom error=nack address=0`), EMIL answers + `ERR timeout` and stays busy; `eeprom.detach` completes the operation and a new attach works.""" + attach(fw, eeprom_cfg, addr=0x57) + fw.eeprom.errors() + expect_error("timeout", fw.eeprom.write, 0, b"\x01") + errors = fw.eeprom.errors() + assert [(event["error"], event.as_int("address")) for event in errors] == [("nack", 0)] + expect_error("busy", fw.eeprom.read, 0, 1) + fw.eeprom.detach() + attach(fw, eeprom_cfg) + fw.eeprom.write(16 * eeprom_cfg["page"], b"\x01") + + +def test_detach_while_busy(fw, request, eeprom_cfg): + """`eeprom.detach` while the adapter is in a transfer answers `ERR busy` (the longest write the command line + holds spans two pages, two write cycles).""" + if request.config.getoption("--fake"): + pytest.skip("the fake completes every write at once") + attach(fw, eeprom_cfg) + address = 18 * eeprom_cfg["page"] + pending = fw.eeprom.begin("write", address, generate(WRITE_MAX), cmd_timeout=fw.terminal.timeout + EEPROM_TIMEOUT_S) + fw.terminal.send_nowait("eeprom.detach") + # The detach answers first; the write's own OK follows once its pages are written and is dropped by quiesce. + reply = settle(pending) + quiesce(fw.terminal, quiet=0.2) + assert reply is not None and reply.reason == "busy", reply + assert fw.eeprom.read(address, 8) == generate(8) + + +def test_one_byte_addressing_on_target(fw, board_cfg, eeprom_cfg, need): + """`abytes=1` and 16-byte pages against the `i2cs` register file at 0x42 on the other instance: the word address + is the register pointer, so the data lands in the target's registers.""" + target = board_cfg.param("i2c.loop.target") + if target["index"] == eeprom_cfg["index"]: + target = board_cfg.param("i2c.loop.master") + need.unloaded(target["scl"], target["sda"]) + fw.i2cs.open(target["index"], target["scl"], target["sda"], addr=0x42) + attach(fw, eeprom_cfg, addr=0x42, size=256, page=16, abytes=1, wcycle=0) + data = generate(40, "prbs", 21) + fw.eeprom.write(10, data) + assert fw.eeprom.read(10, len(data)) == data + assert fw.i2cs.dump(target["index"], 10, len(data)) == data diff --git a/validation/host/tests/hil/test_i2c.py b/validation/host/tests/hil/test_i2c.py new file mode 100644 index 00000000..61f0c728 --- /dev/null +++ b/validation/host/tests/hil/test_i2c.py @@ -0,0 +1,573 @@ +"""I2C master (`hal::I2cStm`) through the `i2c` group, against the `i2cs` LL target scaffold on the other instance. + +Standalone cases (`tests.i2c.instances`, bundle1, no option): each instance on its own pins with the MCU pull-ups +(`pull=up`), enough for Standard mode on a short bus: the default and computed TIMINGR, address NACK and its +recovery, the zero-length probe, arbitration loss (the AD3 holds SDA low before `i2c.open`, or pulls it low inside +the second address bit after a START) and the argument errors. Nobody answers on these buses, so every transfer +ends in an address NACK. + +`--with i2c` cases (`tests.i2c.loop`): the master and the target on the jumpered rows with 4.7 kOhm pull-ups and the +24LC256 at 0x50: Fast mode, rise time (where a scope sits on the rows), data NACK at every position, the address +NACK after the other direction (B.1h), RELOAD lengths, repeated START, continued sessions in both directions (B.1d), +clock stretching, a bus error from the target's misplaced STOP (B.1e), the general call against an idle I2cStm +(B.1i), close while the bus is held, and both instance directions. + +The module is marked `uses_option("i2c")`: the standalone cases also run with the option fitted (they then share the +rows with the pull-ups and the EEPROM, which answers neither 0x10 nor 0x7f). Checks of the physical bus (levels, +timing, rise time) skip under `--fake`; the fake answers the protocol. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.groups.i2c import DEFAULT_TIMING, I2C_HEX_MAX +from hal_st_validation.i2c import ( + arbitration_window, + arm_on_start, + arm_scope, + expected_timing, + i2c_decode, + inject_on_start, + mode_limits, + rise_times, + scl_timing, +) +from hal_st_validation.patterns import crc_text, generate + +pytestmark = pytest.mark.uses_option("i2c") + +# Scope levels of the 30-70 % rise time on a 3.3 V bus. +VDD = 3.3 +WRITE_READ_LENGTHS = [1, 2, 64, 128] +# (direction, byte position) of the target's clock stretch: the address phase, a written byte, a read byte. +STRETCH_POSITIONS = [("write", 0), ("write", 2), ("read", 0), ("read", 1)] + + +@pytest.fixture +def i2c_cfg(board_cfg) -> dict[str, Any]: + return board_cfg.param("i2c") + + +@pytest.fixture +def bench(request) -> None: + """The case measures the physical bus, which the fake does not model.""" + if request.config.getoption("--fake"): + pytest.skip("measures the physical bus: nothing to measure with --fake") + + +@pytest.fixture +def loop(need, i2c_cfg) -> dict[str, dict[str, Any]]: + """`tests.i2c.loop`, after the gate check of its four pins.""" + loop = i2c_cfg["loop"] + need.unloaded(loop["master"]["scl"], loop["master"]["sda"], loop["target"]["scl"], loop["target"]["sda"]) + return loop + + +def expect_error(reason, call, *args, **options): + with pytest.raises(FirmwareError) as error: + call(*args, **options) + assert error.value.reason == reason, (args, options) + + +def reason(fw, line): + response = fw.terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +def open_standalone(fw, need, instance, **options): + need.unloaded(instance["scl"], instance["sda"]) + return fw.i2c.open(instance["index"], instance["scl"], instance["sda"], pull="up", **options) + + +def bus_dios(need, instance) -> tuple[int, int | None]: + """SCL's DIO (required) and SDA's when `observed` lists it.""" + scl = need.dio(instance["scl"]) + sda = need.dio(instance["sda"]) if "sda" in instance.get("observed", ["scl", "sda"]) else None + return scl, sda + + +def arm_bus(ad3, scl: int, sda: int | None, freq: int, seconds: float): + """The logic analyzer at 100 samples per SCL period (or what the buffer holds for `seconds`), started on a START + when SDA is observed, else on the first SCL falling edge.""" + rate = min(ad3.logic.clock_hz, 100 * freq, ad3.logic.buffer_size / seconds) + samples = int(rate * seconds) + if sda is None: + return ad3.logic.arm(rate, samples, trigger=(scl, "falling"), pretrigger=0.02) + return arm_on_start(ad3, scl, sda, rate, samples, pretrigger=0.02) + + +def check_scl(capture, scl: int, freq: int, tolerance) -> None: + """Frequency within `tolerance` of nominal, and the Standard/Fast mode tLOW/tHIGH minima (one sample of slack).""" + timing = scl_timing(capture.channel(scl), capture.rate) + limits = mode_limits(freq) + slack = 1 / capture.rate + assert tolerance[0] <= timing.freq / freq <= tolerance[1], f"{timing.freq:.0f} Hz for {freq} Hz" + assert timing.tlow_min + slack >= limits.tlow_min, f"tLOW {timing.tlow_min * 1e9:.0f} ns" + assert timing.thigh_min + slack >= limits.thigh_min, f"tHIGH {timing.thigh_min * 1e9:.0f} ns" + + +def open_loop(fw, loop, freq: int | None = None, mode: str = "regs") -> tuple[int, int, int]: + """Master and target of `tests.i2c.loop`: returns (master index, target index, target address).""" + master, target = loop["master"], loop["target"] + fw.i2c.open(master["index"], master["scl"], master["sda"], freq=freq) + address = fw.i2cs.open(target["index"], target["scl"], target["sda"], addr=target["addr"], mode=mode) + return master["index"], target["index"], address + + +# standalone, bundle1, pull=up + + +@pytest.mark.board_params("instance", "i2c.instances") +def test_open_replies_timing(fw, need, i2c_cfg, instance): + """Without `freq` the driver's default `Config` (0x70B03D3D on STM32WB/WBA, B.1f); with `freq` the `I2cTiming` + value for the reported kernel clock; `timing=` is written as given.""" + opened = open_standalone(fw, need, instance) + assert opened.timing == DEFAULT_TIMING + fw.i2c.close(instance["index"]) + for freq in sorted({*i2c_cfg["freqs"]["standalone"], *i2c_cfg["freqs"]["option"]}): + opened = open_standalone(fw, need, instance, freq=freq) + assert opened.timing == expected_timing(opened.kernel, freq) + fw.i2c.close(instance["index"]) + assert open_standalone(fw, need, instance, timing=0x10B0172F).timing == 0x10B0172F + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "i2c.instances") +def test_default_timing_in_spec(fw, ad3, need, bench, i2c_cfg, instance): + """B.1f: the default TIMINGR runs Standard mode on the bus: tLOW >= 4.7 us, tHIGH >= 4.0 us, f <= 100 kHz.""" + scl, sda = bus_dios(need, instance) + open_standalone(fw, need, instance) + capture = arm_bus(ad3, scl, sda, 100_000, 400e-6) + assert fw.i2c.write(instance["index"], i2c_cfg["absent_addr"], b"\x00").result == "nack" + check_scl(capture.wait(timeout=2.0), scl, 100_000, (0.0, 1.01)) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "i2c.instances") +@pytest.mark.board_params("freq", "i2c.freqs.standalone") +def test_timing(fw, ad3, need, bench, i2c_cfg, instance, freq): + scl, sda = bus_dios(need, instance) + open_standalone(fw, need, instance, freq=freq) + capture = arm_bus(ad3, scl, sda, freq, 30 / freq) + fw.i2c.write(instance["index"], i2c_cfg["absent_addr"], b"\x00") + check_scl(capture.wait(timeout=2.0), scl, freq, i2c_cfg["tolerance"]) + + +@pytest.mark.board_params("instance", "i2c.instances") +@pytest.mark.board_params("direction", values=["write", "read"]) +def test_address_nack_recovers(fw, need, i2c_cfg, instance, direction): + """B.1a/b: an address NACK answers `nack` (`sent=0`) after `EVT i2c hook=notfound`, and the same command behaves + the same again: no stale NACKF, no callback left armed.""" + index, absent = instance["index"], i2c_cfg["absent_addr"] + open_standalone(fw, need, instance) + for _ in range(2): + if direction == "write": + reply = fw.i2c.write(index, absent, b"\x01\x02") + assert (reply.sent, reply.result) == (0, "nack") + else: + assert fw.i2c.read(index, absent, 2).result == "nack" + assert fw.i2c.hooks(index) == ["notfound"] + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "i2c.instances") +def test_address_nack_on_bus(fw, ad3, need, bench, i2c_cfg, instance): + """The address and the R/W bit on the bus, NACKed, followed by a STOP.""" + scl = need.dio(instance["scl"]) + sda = need.dio(instance["sda"]) + open_standalone(fw, need, instance, freq=100_000) + for read in (False, True): + capture = arm_bus(ad3, scl, sda, 100_000, 400e-6) + if read: + fw.i2c.read(instance["index"], i2c_cfg["absent_addr"], 1) + else: + fw.i2c.write(instance["index"], i2c_cfg["absent_addr"], b"\x55") + samples = capture.wait(timeout=2.0) + transfers = i2c_decode(samples.channel(scl), samples.channel(sda)) + assert transfers, "no transfer decoded" + first = transfers[0] + assert (first.address, first.read, first.address_ack, first.data, first.stop) == (i2c_cfg["absent_addr"], read, False, [], True) + + +@pytest.mark.board_params("instance", "i2c.instances") +def test_zero_length_probe(fw, need, i2c_cfg, instance): + """`-` without `len` writes no data (NBYTES=0, R2): an address probe that answers `nack` here.""" + open_standalone(fw, need, instance) + reply = fw.i2c.write(instance["index"], i2c_cfg["absent_addr"]) + assert (reply.sent, reply.result) == (0, "nack") + assert fw.i2c.hooks(instance["index"]) == ["notfound"] + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "i2c.instances") +@pytest.mark.board_params("direction", values=["write", "read"]) +def test_arbitration_lost_held(fw, ad3, need, bench, i2c_cfg, instance, direction): + """B.1e: SDA held low by the AD3 before `i2c.open` (no START is latched, BUSY stays 0): the first address 1 bit + loses arbitration (`buserror`, `EVT hook=arblost`); once SDA is released the same instance works (no device: + `nack`).""" + sda = need.dio(instance["sda"]) + index, address = instance["index"], i2c_cfg["arbitration_addr"] + ad3.dio.drive(sda, 0) + try: + open_standalone(fw, need, instance, freq=100_000) + if direction == "write": + reply = fw.i2c.write(index, address, b"\x00") + assert reply.result == "buserror" + else: + assert fw.i2c.read(index, address, 1).result == "buserror" + assert "arblost" in fw.i2c.hooks(index) + finally: + ad3.dio.release(sda) + assert fw.i2c.write(index, address, b"\x00").result == "nack" + assert fw.i2c.hooks(index) == ["notfound"] + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "i2c.instances") +def test_arbitration_lost_triggered(fw, ad3, need, bench, i2c_cfg, instance): + """B.1e: an open instance writes to 0x7f while the AD3 pulls SDA low for one SCL period inside the second + address bit (started by the START detector): `buserror` and `EVT hook=arblost`, then a good transfer.""" + scl, sda = need.dio(instance["scl"]), need.dio(instance["sda"]) + index, address = instance["index"], i2c_cfg["arbitration_addr"] + opened = open_standalone(fw, need, instance, freq=100_000) + delay, width = arbitration_window(opened.timing, opened.kernel) + try: + inject_on_start(ad3, scl, sda, delay, width) + assert fw.i2c.write(index, address, b"\x00").result == "buserror" + assert "arblost" in fw.i2c.hooks(index) + finally: + ad3.pattern.stop() + assert fw.i2c.write(index, address, b"\x00").result == "nack" + + +def test_errors(fw, need, i2c_cfg): + instance = i2c_cfg["instances"][0] + index, scl, sda = instance["index"], fw.pin(instance["scl"]), fw.pin(instance["sda"]) + need.unloaded(scl, sda) + cases = [ + ("i2c.open", "usage"), + (f"i2c.open {index} sda={sda}", "usage"), + (f"i2c.open {index} scl={scl}", "usage"), + (f"i2c.open {index} scl={scl} sda={sda} freq=100000 timing=0x10", "usage"), + (f"i2c.open {index} scl={scl} sda={sda} pull=down", "usage"), + (f"i2c.open {index} scl={scl} sda={sda} speed=1", "usage"), + (f"i2c.open 2 scl={scl} sda={sda}", "range"), + (f"i2c.open 4 scl={scl} sda={sda}", "range"), + (f"i2c.open {index} scl={scl} sda={sda} freq=19999", "range"), + (f"i2c.open {index} scl={scl} sda={sda} freq=400001", "range"), + (f"i2c.open {index} scl={sda} sda={scl}", "pin"), + (f"i2c.write {index} 0x10 00", "notopen"), + (f"i2c.read {index} 0x10 1", "notopen"), + (f"i2c.close {index}", "notopen"), + ] + for line, expected in cases: + assert reason(fw, line) == expected, line + fw.i2c.open(index, scl, sda, pull="up") + cases = [ + (f"i2c.open {index} scl={scl} sda={sda}", "busy"), + (f"i2cs.open {index} scl={scl} sda={sda}", "busy"), + (f"eeprom.attach {index} scl={scl} sda={sda}", "busy"), + (f"gpio.cfg {scl} in", "busy"), + (f"i2c.write {index} 0x80 00", "range"), + (f"i2c.write {index} 0x10 0", "usage"), + (f"i2c.write {index} 0x10 00 next=later", "usage"), + (f"i2c.write {index} 0x10 00 len=1", "usage"), + (f"i2c.write {index} 0x10 - len=1025", "range"), + (f"i2c.read {index} 0x10 0", "range"), + (f"i2c.read {index} 0x10 {I2C_HEX_MAX + 1}", "range"), + (f"i2c.read {index} 0x10 1025 out=crc", "range"), + (f"i2c.read {index} 0x10 1 out=bin", "usage"), + ] + for line, expected in cases: + assert reason(fw, line) == expected, line + fw.i2c.close(index) + fw.i2cs.open(index, scl, sda) + expect_error("busy", fw.i2c.open, index, scl, sda) + + +# --with i2c: master and target on the jumpered rows + + +@pytest.mark.requires_option("i2c") +def test_option_bus_pullups(fw, bench, ad3_released, loop): + """The external pull-ups win against the MCU pull-down on every row pin.""" + for end in ("master", "target"): + for line in ("scl", "sda"): + pin = loop[end][line] + fw.gpio.cfg(pin, "in", pull="down") + try: + assert fw.gpio.get(pin) == 1, pin + finally: + fw.gpio.release(pin) + + +@pytest.mark.board_params("instance", "i2c.instances") +@pytest.mark.conflicts_option("i2c") +def test_no_onboard_pullups(fw, need, bench, ad3_released, instance): + """Without the option nothing on the board pulls the I2C pins up (documents WBA55 PB1/PB2): the MCU pull-down + wins, which is why the standalone cases need `pull=up`.""" + for line in ("scl", "sda"): + pin = instance[line] + need.unloaded(pin) + fw.gpio.cfg(pin, "in", pull="down") + try: + assert fw.gpio.get(pin) == 0, pin + finally: + fw.gpio.release(pin) + + +@pytest.mark.ad3 +@pytest.mark.requires_option("i2c") +@pytest.mark.board_params("freq", "i2c.freqs.option") +def test_option_timing(fw, ad3, need, bench, i2c_cfg, loop, freq): + """Standard and Fast mode with the external pull-ups: frequency within tolerance and the tLOW/tHIGH minima.""" + master, _, address = open_loop(fw, loop, freq=freq) + scl, sda = need.dio(loop["master"]["scl"]), need.dio(loop["master"]["sda"]) + capture = arm_bus(ad3, scl, sda, freq, 60 / freq) + assert fw.i2c.write(master, address, generate(4)).result == "complete" + check_scl(capture.wait(timeout=2.0), scl, freq, i2c_cfg["tolerance"]) + + +@pytest.mark.ad3 +@pytest.mark.requires_option("i2c") +@pytest.mark.board_params("freq", "i2c.freqs.option") +def test_rise_time(fw, ad3, need, bench, i2c_cfg, loop, freq): + """30-70 % rise time of SCL and SDA on the rows (needs a scope on the bus: WBA55 bundle2; the WB55 scopes stay on + the ADC inputs, A.3), within `rise_ns` of the mode.""" + scopes = {line: need.optional_scope(loop["master"][line]) for line in ("scl", "sda")} + if None in scopes.values(): + pytest.skip("no scope on the I2C rows in this wiring set") + limit = i2c_cfg["rise_ns"]["sm" if freq <= 100_000 else "fm"] * 1e-9 + master, _, address = open_loop(fw, loop, freq=freq) + rate = 100e6 + pending = arm_scope(ad3, list(scopes.values()), rate, 8192, scopes["scl"], VDD / 2) + fw.i2c.write(master, address, len=64, pattern="prbs", seed=1) + samples = pending.wait(timeout=2.0) + for line, channel in scopes.items(): + rises = rise_times(samples[channel], rate, 0.3 * VDD, 0.7 * VDD) + assert rises, f"no rising edge on {line}" + assert max(rises) <= limit, f"{line}: {max(rises) * 1e9:.0f} ns rise (use 2.2 kOhm pull-ups)" + + +@pytest.mark.requires_option("i2c") +def test_target_answers(fw, loop): + master, target, address = open_loop(fw, loop) + assert fw.i2c.write(master, address).result == "complete" + assert fw.i2c.hooks(master) == [] + assert fw.i2cs.status(target).writes == 1 + + +@pytest.mark.requires_option("i2c") +@pytest.mark.board_params("position", "i2c.nack_positions") +def test_data_nack(fw, i2c_cfg, loop, position): + _data_nack(fw, loop, i2c_cfg["nack_length"], position) + + +@pytest.mark.requires_option("i2c") +@pytest.mark.board_params("from_end", values=[1, 0]) +def test_data_nack_at_the_end(fw, i2c_cfg, loop, from_end): + """B.1c: a NACK of the next-to-last and of the last byte (the empty `onReceived` used to abort).""" + length = i2c_cfg["nack_length"] + _data_nack(fw, loop, length, length - from_end) + + +def _data_nack(fw, loop, length: int, position: int) -> None: + """B.1a/c: the target NACKs byte `position`: `sent` counts the acknowledged bytes and no address NACK is + reported.""" + master, target, address = open_loop(fw, loop, mode="sink") + fw.i2cs.cfg(target, nack=position) + reply = fw.i2c.write(master, address, len=length, pattern="prbs", seed=position) + assert (reply.sent, reply.result) == (position - 1, "nack") + assert "notfound" not in fw.i2c.hooks(master) + status = fw.i2cs.status(target) + assert (status.rx, status.nacked) == (position - 1, 1) + fw.i2cs.cfg(target) + assert fw.i2c.write(master, address, len=length).result == "complete" + + +@pytest.mark.requires_option("i2c") +def test_address_nack_after_other_direction(fw, i2c_cfg, loop): + """B.1h: a read, then a write to an absent address (`sent=0`, `EVT notfound`); a write, then a read from an + absent address (`nack`, `EVT notfound`).""" + master, _, address = open_loop(fw, loop) + absent = i2c_cfg["absent_addr"] + assert fw.i2c.read(master, address, 4).result == "complete" + reply = fw.i2c.write(master, absent, b"\x01") + assert (reply.sent, reply.result) == (0, "nack") + assert fw.i2c.hooks(master) == ["notfound"] + assert fw.i2c.write(master, address, b"\x00\x01").result == "complete" + assert fw.i2c.read(master, absent, 4).result == "nack" + assert fw.i2c.hooks(master) == ["notfound"] + + +@pytest.mark.requires_option("i2c") +@pytest.mark.board_params("length", values=WRITE_READ_LENGTHS) +def test_write_read_regs(fw, loop, length): + """Register file: the first written byte sets the pointer; a write and a restart read return the data. The write + is the `inc` pattern from 0: pointer 0, then the bytes 1 to `length`.""" + master, target, address = open_loop(fw, loop) + data = generate(length + 1)[1:] + assert fw.i2c.write(master, address, len=length + 1).sent == length + 1 + assert fw.i2c.write(master, address, b"\x00", next="restart").result == "complete" + assert fw.i2c.read(master, address, length).data == data + assert fw.i2cs.dump(target, 0, length) == data + + +@pytest.mark.requires_option("i2c") +@pytest.mark.board_params("length", "i2c.payload_lengths") +def test_reload_lengths(fw, loop, length): + """NBYTES/RELOAD: writes and reads of every length, checked by CRC at both ends.""" + master, target, address = open_loop(fw, loop, mode="sink") + fw.i2cs.cfg(target, pattern="prbs", seed=length) + reply = fw.i2c.write(master, address, len=length, pattern="prbs", seed=7) + assert (reply.sent, reply.result) == (length, "complete") + status = fw.i2cs.status(target) + assert (status.rx, status.crc) == (length, crc_text(generate(length, "prbs", 7))) + read = fw.i2c.read(master, address, length, out="crc") + assert (read.result, read.length, read.crc) == ("complete", length, crc_text(generate(length, "prbs", length))) + + +@pytest.mark.requires_option("i2c") +def test_repeated_start(fw, loop): + """`next=restart`: write the register, read it back after a repeated START, one STOP for both.""" + master, target, address = open_loop(fw, loop) + fw.i2cs.regs(target, 0x20, b"\xde\xad\xbe\xef") + fw.i2c.write(master, address, b"\x20", next="restart") + assert fw.i2c.read(master, address, 4).data == b"\xde\xad\xbe\xef" + status = fw.i2cs.status(target) + assert (status.writes, status.reads, status.stops) == (1, 1, 1) + + +@pytest.mark.ad3 +@pytest.mark.requires_option("i2c") +def test_repeated_start_on_bus(fw, ad3, need, bench, loop): + master, target, address = open_loop(fw, loop, freq=100_000) + scl, sda = need.dio(loop["master"]["scl"]), need.dio(loop["master"]["sda"]) + fw.i2cs.regs(target, 0x20, b"\x5a") + capture = arm_bus(ad3, scl, sda, 100_000, 600e-6) + fw.i2c.write(master, address, b"\x20", next="restart") + fw.i2c.read(master, address, 1) + samples = capture.wait(timeout=2.0) + write, read = i2c_decode(samples.channel(scl), samples.channel(sda))[:2] + assert (write.read, write.payload, write.stop) == (False, b"\x20", False) + assert (read.restart, read.read, read.payload, read.stop) == (True, True, b"\x5a", True) + + +@pytest.mark.requires_option("i2c") +def test_continue_session(fw, loop): + """`next=continue`: the next write continues the same transaction without a START or an address.""" + master, target, address = open_loop(fw, loop) + assert fw.i2c.write(master, address, b"\x30\x01\x02", next="continue").result == "complete" + assert fw.i2c.write(master, address, b"\x03\x04").result == "complete" + status = fw.i2cs.status(target) + assert (status.writes, status.stops, status.last) == (1, 1, b"\x30\x01\x02\x03\x04") + assert fw.i2cs.dump(target, 0x30, 4) == b"\x01\x02\x03\x04" + + +@pytest.mark.requires_option("i2c") +def test_receive_continue_session(fw, loop): + """B.1d: a read with `next=continue` completes (RELOAD set) and the next read continues the same transfer.""" + master, target, address = open_loop(fw, loop) + fw.i2cs.regs(target, 0x40, bytes(range(8))) + fw.i2c.write(master, address, b"\x40", next="restart") + first = fw.i2c.read(master, address, 3, next="continue") + second = fw.i2c.read(master, address, 5) + assert (first.result, second.result) == ("complete", "complete") + assert (first.data or b"") + (second.data or b"") == bytes(range(8)) + status = fw.i2cs.status(target) + assert (status.reads, status.stops) == (1, 1) + + +def _stretched_transfer(fw, loop, direction: str, position: int, us: int) -> tuple[int, int, int]: + master, target, address = open_loop(fw, loop, freq=100_000) + fw.i2cs.regs(target, 0, generate(8)) + fw.i2cs.cfg(target, stretch=us, stretchat=position) + if direction == "write": + assert fw.i2c.write(master, address, bytes([0x80]) + generate(4)).result == "complete" + else: + fw.i2c.write(master, address, b"\x00", next="restart") + assert fw.i2c.read(master, address, 4).data == generate(4) + return master, target, address + + +@pytest.mark.requires_option("i2c") +@pytest.mark.board_params("us", "i2c.stretch_us") +@pytest.mark.board_params("direction,position", values=STRETCH_POSITIONS) +def test_clock_stretch_completes(fw, loop, us, direction, position): + """The target holds SCL low for `us` at the address phase (position 0) or at a byte: the transfer completes.""" + _stretched_transfer(fw, loop, direction, position, us) + + +@pytest.mark.ad3 +@pytest.mark.requires_option("i2c") +@pytest.mark.board_params("us", "i2c.stretch_us") +@pytest.mark.board_params("direction,position", values=STRETCH_POSITIONS) +def test_clock_stretch_on_bus(fw, ad3, need, bench, loop, us, direction, position): + """The longest SCL low phase of the transfer is at least the stretch (5 % slack for the Stopwatch).""" + scl = need.dio(loop["master"]["scl"]) + seconds = us * 1e-6 + 2e-3 + rate = min(ad3.logic.clock_hz, ad3.logic.buffer_size / seconds, 10e6) + capture = ad3.logic.arm(rate, int(rate * seconds), trigger=(scl, "falling"), pretrigger=0.01) + _stretched_transfer(fw, loop, direction, position, us) + trace = capture.wait(timeout=2.0).channel(scl) + edges = [index for index in range(1, len(trace)) if trace[index] != trace[index - 1]] + lows = [(edges[i + 1] - edges[i]) / rate for i in range(len(edges) - 1) if not trace[edges[i]]] + assert lows and max(lows) >= 0.95 * us * 1e-6, f"longest SCL low {max(lows, default=0) * 1e6:.0f} us" + + +@pytest.mark.requires_option("i2c") +def test_bus_error(fw, i2c_cfg, loop): + """B.1e: the target turns byte 2 of a read into a misplaced STOP: `buserror` after `EVT hook=buserror`, then + a good read on the same open instance.""" + master, target, address = open_loop(fw, loop, freq=i2c_cfg["fault_freq"], mode="sink") + fw.i2cs.cfg(target, fault="stop", faultat=2) + assert fw.i2c.read(master, address, 8).result == "buserror" + assert "buserror" in fw.i2c.hooks(master) + assert fw.i2c.read(master, address, 8).data == generate(8) + + +@pytest.mark.requires_option("i2c") +def test_general_call_does_not_hang(fw, loop): + """B.1i: an idle I2cStm (the EEPROM adapter's, own address disabled) on the bus does not answer the general call, + so the write is NACKed and the bus stays usable.""" + master, target = loop["master"], loop["target"] + fw.eeprom.attach(target["index"], target["scl"], target["sda"]) + fw.i2c.open(master["index"], master["scl"], master["sda"]) + assert fw.i2c.write(master["index"], 0x00, b"\x00").result == "nack" + assert fw.i2c.write(master["index"], 0x50).result == "complete" + + +@pytest.mark.requires_option("i2c") +def test_close_releases_held_bus(fw, request, loop): + """`next=continue` leaves SCL held low by the master; `i2c.close` releases both lines (regression check, A.3) + and a new open transfers normally.""" + master, _, address = open_loop(fw, loop) + assert fw.i2c.write(master, address, b"\x00\x01", next="continue").result == "complete" + fw.i2c.close(master) + if not request.config.getoption("--fake"): + for line in ("scl", "sda"): + pin = loop["master"][line] + fw.gpio.cfg(pin, "in") + try: + assert fw.gpio.get(pin) == 1, pin + finally: + fw.gpio.release(pin) + fw.i2c.open(master, loop["master"]["scl"], loop["master"]["sda"]) + assert fw.i2c.write(master, address, b"\x00\x02").result == "complete" + + +@pytest.mark.requires_option("i2c") +def test_both_instances(fw, loop): + """Each instance as master against the other as target.""" + for master, target in ((loop["master"], loop["target"]), (loop["target"], loop["master"])): + fw.i2c.open(master["index"], master["scl"], master["sda"]) + address = fw.i2cs.open(target["index"], target["scl"], target["sda"], addr=loop["target"]["addr"]) + assert fw.i2c.write(master["index"], address, b"\x10\xa5").result == "complete" + fw.i2c.write(master["index"], address, b"\x10", next="restart") + assert fw.i2c.read(master["index"], address, 1).data == b"\xa5" + fw.i2c.close(master["index"]) + fw.i2cs.close(target["index"]) diff --git a/validation/host/tests/hil/test_unsupported.py b/validation/host/tests/hil/test_unsupported.py index c93d0842..553574d5 100644 --- a/validation/host/tests/hil/test_unsupported.py +++ b/validation/host/tests/hil/test_unsupported.py @@ -1,4 +1,5 @@ -"""Groups hal-st has no driver for on these boards (comparator, CAN, EEPROM, Ethernet) answer `ERR unsupported`.""" +"""Groups hal-st has no driver for on these boards (comparator, CAN, Ethernet) and the groups the running MCU lacks +answer `ERR unsupported`.""" import pytest from ad3_waveforms_bench.terminal import FirmwareError diff --git a/validation/host/tests/unit/test_fake_i2c.py b/validation/host/tests/unit/test_fake_i2c.py new file mode 100644 index 00000000..dba248d4 --- /dev/null +++ b/validation/host/tests/unit/test_fake_i2c.py @@ -0,0 +1,320 @@ +"""The fake `i2c`, `i2cs` and `eeprom` groups (argument order and reasons of validation/firmware/I2cGroup.cpp, +I2cTarget.cpp, EepromGroup.cpp and EMIL's HilEepromCommands; PROTOCOL.md D.1-D.3), their bus model and the +`groups.i2c` wrappers.""" + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal + +from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS, FakeFirmware, FakeSerial +from hal_st_validation.firmware import Firmware +from hal_st_validation.groups.i2c import DEFAULT_TIMING +from hal_st_validation.i2c import expected_timing +from hal_st_validation.patterns import crc_text, generate + + +def make(family="stm32wb55"): + fake = FakeFirmware(family=family) + terminal = FirmwareTerminal(serial=FakeSerial(fake), timeout=0.5) + pins = WB55_PINS if family == "stm32wb55" else WBA55_PINS + return terminal, fake, Firmware(terminal, pins) + + +def reason(terminal, line): + response = terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("i2c.open", "usage"), + ("i2c.open 1 2 scl=PB8 sda=PB9", "usage"), + ("i2c.open 1 scl=PB8 sda=PB9 speed=1", "usage"), + ("i2c.open x scl=PB8 sda=PB9", "usage"), + ("i2c.open 4 scl=PB8 sda=PB9", "range"), + ("i2c.open 1 sda=PB9", "usage"), + ("i2c.open 1 scl=PB8", "usage"), + ("i2c.open 1 scl=PB8 sda=PB9 freq=100000 timing=0x10", "usage"), + ("i2c.open 9 scl=PB8", "range"), + ("i2c.open 1 scl=PB8 sda=PB9 pull=down", "usage"), + ("i2c.open 2 scl=PB8 sda=PB9 pull=down", "usage"), + ("i2c.open 1 scl=PB8 sda=PB9 freq=x", "usage"), + ("i2c.open 2 scl=PB8 sda=PB9", "range"), + ("i2c.open 0 scl=PB8 sda=PB9", "range"), + ("i2c.open 1 scl=PB8 sda=PB9 freq=19999", "range"), + ("i2c.open 1 scl=PB8 sda=PB9 freq=400001", "range"), + ("i2c.open 2 scl=nope sda=PB9", "range"), + ("i2c.open 1 scl=nope sda=PB9", "pin"), + ("i2c.open 1 scl=PB9 sda=PB8", "pin"), + ("i2c.open 1 scl=PC0 sda=PC1", "pin"), + ("i2c.open 1 scl=PB6 sda=PB7", "busy"), + ("i2c.open 1 scl=PB8 sda=PB9", "ok"), + ("i2c.open 3 scl=i2c3scl sda=i2c3sda pull=up freq=400000", "ok"), + ("i2c.open 3 scl=PA7 sda=PB4 timing=0x10b0172f", "ok"), + ], +) +def test_open_reasons(line, expected): + terminal, *_ = make() + assert reason(terminal, line) == expected + + +def test_open_replies_timing_and_kernel(): + terminal, _, fw = make() + opened = fw.i2c.open(1, "i2c1scl", "i2c1sda") + assert (opened.timing, opened.kernel) == (DEFAULT_TIMING, 64_000_000) + assert fw.open_instances == [("i2c", 1)] + fw.i2c.close(1) + assert fw.i2c.open(1, "PB8", "PB9", freq=100_000).timing == expected_timing(64_000_000, 100_000) == 0x40B03943 + fw.i2c.close(1) + assert fw.i2c.open(1, "PB8", "PB9", timing=0x1FFFFFFF).timing == 0x10FFFFFF + assert reason(terminal, "i2c.open 3 scl=PC0 sda=PC1") == "busy" + + +def test_wba55_instances(): + terminal, _, fw = make("stm32wba55") + assert fw.i2c.open(3, "PB2", "PB1", freq=400_000).timing == 0x20B11931 + assert reason(terminal, "i2c.open 3 scl=PA6 sda=PA7") == "busy" + fw.i2c.close(3) + assert reason(terminal, "i2c.open 1 scl=PA15 sda=PB3") == "ok" + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("i2c.write 1 0x10", "usage"), + ("i2c.write 1 0x10 00 extra", "usage"), + ("i2c.write 1 0x10 00 stop=1", "usage"), + ("i2c.write 4 0x10 00", "range"), + ("i2c.write 3 0x10 00", "notopen"), + ("i2c.write 1 0x80 00", "range"), + ("i2c.write 1 x 00", "usage"), + ("i2c.write 1 0x10 00 next=later", "usage"), + ("i2c.write 1 0x10 0", "usage"), + ("i2c.write 1 0x10 00 len=2", "usage"), + ("i2c.write 1 0x10 - pattern=inc", "usage"), + ("i2c.write 1 0x10 - len=0", "range"), + ("i2c.write 1 0x10 - len=1025", "range"), + ("i2c.write 1 0x10 - len=4 pattern=noise", "usage"), + ("i2c.read 1 0x10", "usage"), + ("i2c.read 1 0x10 0", "range"), + ("i2c.read 1 0x10 1025", "range"), + ("i2c.read 1 0x10 129", "range"), + ("i2c.read 1 0x10 4 out=bin", "usage"), + ("i2c.read 1 0x10 4 len=4", "usage"), + ("i2c.read 3 0x10 4", "notopen"), + ("i2c.close 3", "notopen"), + ("i2c.close 4", "range"), + ], +) +def test_transfer_reasons(line, expected): + terminal, _, fw = make() + fw.i2c.open(1, "PB8", "PB9") + assert reason(terminal, line) == expected + + +def test_absent_device_nacks_with_the_hook(): + _, _, fw = make() + fw.i2c.open(1, "PB8", "PB9") + write = fw.i2c.write(1, 0x10, b"\x01\x02") + assert (write.sent, write.result) == (0, "nack") + assert fw.i2c.write(1, 0x10, b"").result == "nack" + read = fw.i2c.read(1, 0x10, 4) + assert (read.result, read.data) == ("nack", None) + assert fw.i2c.hooks(1) == ["notfound"] * 3 + assert fw.i2c.write(1, 0x00, b"\x06").result == "nack" + assert fw.i2c.write(1, 0x50, b"").result == "complete" + + +def test_target_registers_and_counters(): + terminal, _, fw = make() + fw.i2c.open(3, "PC0", "PC1") + assert fw.i2cs.open(1, "PB8", "PB9") == 0x42 + assert fw.i2c.write(3, 0x42, bytes([0x10, 1, 2, 3])).sent == 4 + assert fw.i2c.write(3, 0x42, b"\x10", next="restart").result == "complete" + assert fw.i2c.read(3, 0x42, 3).data == b"\x01\x02\x03" + assert fw.i2cs.dump(1, 0x10, 4) == b"\x01\x02\x03\x00" + status = fw.i2cs.status(1, clear=True) + assert (status.rx, status.tx, status.writes, status.reads, status.stops) == (5, 3, 2, 1, 2) + assert status.crc == crc_text(bytes([0x10, 1, 2, 3, 0x10])) + assert status.last == b"\x10" + assert fw.i2cs.status(1).rx == 0 + fw.i2cs.regs(1, 0xFE, b"\xaa\xbb") + assert fw.i2c.write(3, 0x42, b"\xfe").sent == 1 + assert fw.i2c.read(3, 0x42, 3).data == b"\xaa\xbb\x00" + assert fw.i2c.hooks(3) == [] + assert reason(terminal, "i2cs.regs 1 0xff 0102") == "range" + assert reason(terminal, "i2cs.dump 1 0xff 2") == "range" + + +def test_target_nack_sink_and_fault(): + _, _, fw = make("stm32wba55") + fw.i2c.open(3, "PA6", "PA7") + fw.i2cs.open(1, "PB2", "PB1", mode="sink") + fw.i2cs.cfg(1, nack=3) + write = fw.i2c.write(3, 0x42, len=8, pattern="prbs", seed=3) + assert (write.sent, write.result) == (2, "nack") + assert fw.i2cs.status(1).nacked == 1 + assert fw.i2c.hooks(3) == [] + fw.i2cs.cfg(1, pattern="prbs", seed=1) + assert fw.i2c.read(3, 0x42, 300, out="crc").crc == crc_text(generate(300, "prbs", 1)) + fw.i2cs.cfg(1, fault="stop", faultat=2) + assert fw.i2c.read(3, 0x42, 8).result == "buserror" + assert fw.i2c.hooks(3) == ["buserror"] + assert fw.i2c.read(3, 0x42, 2).data == generate(2) + fw.i2cs.cfg(1, addrnack=True) + assert fw.i2c.read(3, 0x42, 2).result == "nack" + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("i2cs.open 1", "usage"), + ("i2cs.open 1 scl=PB8", "usage"), + ("i2cs.open 1 scl=PB8 sda=PB9 mode=echo", "usage"), + ("i2cs.open 1 scl=PB8 sda=PB9 addr=0x07", "range"), + ("i2cs.open 1 scl=PB8 sda=PB9 addr=0x78", "range"), + ("i2cs.open 2 scl=PB8 sda=PB9", "range"), + ("i2cs.open 1 scl=PC0 sda=PC1", "pin"), + ("i2cs.open 1 scl=PB8 sda=PB9 addr=0x08 mode=sink timing=0x00500916", "ok"), + ], +) +def test_target_open_reasons(line, expected): + terminal, *_ = make() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("i2cs.cfg 1 nack=x", "usage"), + ("i2cs.cfg 1 stretch=10001", "range"), + ("i2cs.cfg 1 faultat=0", "range"), + ("i2cs.cfg 1 fault=start", "usage"), + ("i2cs.cfg 1 addrnack=2", "range"), + ("i2cs.cfg 1 pattern=noise", "usage"), + ("i2cs.cfg 1 speed=1", "usage"), + ("i2cs.cfg 3", "notopen"), + ("i2cs.status 1 clear=2", "range"), + ("i2cs.regs 1 0 -", "usage"), + ("i2cs.regs 1 256 00", "range"), + ("i2cs.dump 1 0 0", "range"), + ("i2cs.dump 1 0 129", "range"), + ("i2cs.cfg 1 nack=2 stretch=100 stretchat=0 fault=stop faultat=3 pattern=const seed=7", "ok"), + ], +) +def test_target_command_reasons(line, expected): + terminal, _, fw = make() + fw.i2cs.open(1, "PB8", "PB9") + assert reason(terminal, line) == expected + + +def test_one_owner_per_instance(): + """`Resource::i2c` keeps i2c, i2cs and eeprom off each other's instance; pins stay busy too.""" + terminal, _, fw = make() + fw.i2cs.open(1, "PB8", "PB9") + assert reason(terminal, "i2c.open 1 scl=PA9 sda=PA10") == "busy" + assert reason(terminal, "eeprom.attach 1 scl=PA9 sda=PA10") == "busy" + assert reason(terminal, "gpio.cfg PB8 in") == "busy" + fw.eeprom.attach(3, "PC0", "PC1") + assert reason(terminal, "i2c.open 3 scl=PA7 sda=PB4") == "busy" + assert reason(terminal, "eeprom.attach 3 scl=PC0 sda=PC1") == "busy" + fw.close_all() + assert reason(terminal, "i2c.open 1 scl=PB8 sda=PB9") == "ok" + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("eeprom.attach", "usage"), + ("eeprom.attach 3 sda=PC1", "usage"), + ("eeprom.attach 3 scl=PC0 sda=PC1 speed=1", "usage"), + ("eeprom.attach 4 scl=PC0 sda=PC1", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 size=x", "usage"), + ("eeprom.attach 2 scl=PC0 sda=PC1", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 addr=0x78", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 size=0", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 size=65537", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 page=4", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 page=48", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 page=512", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 abytes=3", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 abytes=1", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 wcycle=21", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 freq=500000", "range"), + ("eeprom.attach 3 scl=PB8 sda=PB9", "pin"), + ("eeprom.attach 3 scl=PC0 sda=PC1 addr=0x42 size=256 page=16 abytes=1 wcycle=0 freq=100000", "ok"), + ("eeprom.detach", "notopen"), + ("eeprom.detach 1", "usage"), + ("eeprom.write 0 0102", "range"), + ("eeprom.read 0 1", "range"), + ("eeprom.erase", "ok"), + ("eeprom.erase 1", "usage"), + ], +) +def test_eeprom_reasons(line, expected): + terminal, *_ = make() + assert reason(terminal, line) == expected + + +def test_eeprom_pages_and_erase(): + terminal, fake, fw = make() + fw.eeprom.attach(3, "PC0", "PC1", size=8192) + data = generate(100, "prbs", 5) + fw.eeprom.write(30, data) + assert fw.eeprom.read(30, 100) == data + assert reason(terminal, "eeprom.write 8190 010203") == "range" + assert reason(terminal, "eeprom.read 8192 1") == "range" + fw.eeprom.erase() + assert fw.eeprom.read(0, 128) == b"\xff" * 128 + fake.boot() + fw.forget_open() + fw.eeprom.attach(3, "PC0", "PC1") + fw.eeprom.write(20000, b"\x5a") + fake.boot() + fw.forget_open() + fw.eeprom.attach(3, "PC0", "PC1") + assert fw.eeprom.read(20000, 1) == b"\x5a" + + +def test_eeprom_page_wrap_on_direct_writes(): + """A write through `i2c.write` that crosses a page wraps to the start of that page, as on a 24LC256.""" + _, _, fw = make() + fw.i2c.open(3, "PC0", "PC1") + assert fw.i2c.write(3, 0x50, bytes([0x00, 0x3E, 1, 2, 3, 4])).sent == 6 + assert fw.i2c.write(3, 0x50, b"\x00\x3e", next="restart").sent == 2 + assert fw.i2c.read(3, 0x50, 2).data == b"\x01\x02" + assert fw.i2c.write(3, 0x50, b"\x00\x00", next="restart").sent == 2 + assert fw.i2c.read(3, 0x50, 2).data == b"\x03\x04" + + +def test_eeprom_error_then_detach(): + terminal, _, fw = make() + fw.eeprom.attach(3, "PC0", "PC1", addr=0x57) + with pytest.raises(FirmwareError) as error: + fw.eeprom.write(0, b"\x01") + assert error.value.reason == "timeout" + assert [event.raw for event in fw.eeprom.errors()] == ["EVT eeprom error=nack address=0"] + assert reason(terminal, "eeprom.read 0 1") == "busy" + fw.eeprom.detach() + fw.eeprom.attach(3, "PC0", "PC1") + fw.eeprom.write(0, b"\x01") + + +def test_one_byte_addressing_on_the_target(): + _, _, fw = make() + fw.i2cs.open(1, "PB8", "PB9") + fw.eeprom.attach(3, "PC0", "PC1", addr=0x42, size=256, page=16, abytes=1, wcycle=0) + data = generate(40, "inc", 0x80) + fw.eeprom.write(10, data) + assert fw.eeprom.read(10, 40) == data + assert fw.i2cs.dump(1, 10, 40) == data + + +def test_state_is_lost_on_reset(): + terminal, fake, fw = make() + fw.i2c.open(1, "PB8", "PB9") + fw.i2cs.open(3, "PC0", "PC1") + fake.boot() + fw.forget_open() + assert reason(terminal, "i2cs.status 3") == "notopen" + assert reason(terminal, "i2c.open 1 scl=PB8 sda=PB9") == "ok" diff --git a/validation/host/tests/unit/test_i2c_decode.py b/validation/host/tests/unit/test_i2c_decode.py new file mode 100644 index 00000000..ab61d26f --- /dev/null +++ b/validation/host/tests/unit/test_i2c_decode.py @@ -0,0 +1,205 @@ +"""`i2c.i2c_decode` and `i2c.scl_timing` on synthetic logic-analyzer captures: START, repeated START, STOP, NACK, +clock stretching, and the SCL figures the HIL tests compare with TIMINGR.""" + +import pytest +from ad3_waveforms_bench.instruments.fake import FakeDwfApi, fake_ad3 + +from hal_st_validation.i2c import I2cByte, arm_on_start, arm_scope, i2c_decode, inject_on_start, rise_times, scl_timing + + +class Bus: + """SCL/SDA samples: each bit is low/high/low thirds of `half` samples, with the data set while SCL is low.""" + + def __init__(self, half: int = 4) -> None: + self.half = half + self.scl = [1] * 8 + self.sda = [1] * 8 + + def put(self, scl: int, sda: int, count: int | None = None) -> None: + count = self.half if count is None else count + self.scl.extend([scl] * count) + self.sda.extend([sda] * count) + + def start(self) -> None: + self.put(1, 1) + self.put(1, 0) + self.put(0, 0) + + def restart(self) -> None: + self.put(0, 1) + self.start() + + def stop(self) -> None: + self.put(0, 0) + self.put(1, 0) + self.put(1, 1) + + def byte(self, value: int, ack: bool, stretch: int = 0) -> None: + for bit in [(value >> (7 - index)) & 1 for index in range(8)] + [0 if ack else 1]: + self.put(0, bit) + self.put(1, bit) + self.put(0, bit) + if stretch: + self.put(0, 0 if ack else 1, stretch) + + def idle(self) -> None: + self.put(1, 1) + + +def test_write_read_with_repeated_start(): + bus = Bus() + bus.start() + bus.byte(0x42 << 1, True) + bus.byte(0x10, True) + bus.restart() + bus.byte(0x42 << 1 | 1, True) + bus.byte(0xAB, True) + bus.byte(0xCD, False) + bus.stop() + bus.idle() + write, read = i2c_decode(bus.scl, bus.sda) + assert (write.restart, write.address, write.read, write.address_ack, write.stop) == (False, 0x42, False, True, False) + assert write.data == [I2cByte(0x10, True)] + assert (read.restart, read.address, read.read, read.stop) == (True, 0x42, True, True) + assert read.payload == b"\xab\xcd" + assert [byte.ack for byte in read.data] == [True, False] + assert write.start < write.end <= read.start < read.end + + +def test_address_nack_then_stop(): + bus = Bus() + bus.start() + bus.byte(0x50 << 1, False) + bus.stop() + bus.idle() + (transfer,) = i2c_decode(bus.scl, bus.sda) + assert (transfer.address, transfer.address_ack, transfer.data, transfer.stop) == (0x50, False, [], True) + + +def test_data_nack(): + bus = Bus() + bus.start() + bus.byte(0x42 << 1, True) + bus.byte(0x01, True) + bus.byte(0x02, False) + bus.stop() + (transfer,) = i2c_decode(bus.scl, bus.sda) + assert [(byte.value, byte.ack) for byte in transfer.data] == [(1, True), (2, False)] + + +def test_clock_stretch_keeps_the_bits(): + bus = Bus() + bus.start() + bus.byte(0x42 << 1, True, stretch=200) + bus.byte(0x5A, True, stretch=50) + bus.stop() + (transfer,) = i2c_decode(bus.scl, bus.sda) + assert transfer.payload == b"\x5a" + + +def test_two_transfers_and_no_restart_after_stop(): + bus = Bus() + for value in (0x11, 0x22): + bus.start() + bus.byte(0x42 << 1, True) + bus.byte(value, True) + bus.stop() + bus.idle() + transfers = i2c_decode(bus.scl, bus.sda) + assert [(t.restart, t.payload, t.stop) for t in transfers] == [(False, b"\x11", True), (False, b"\x22", True)] + + +def test_partial_address_is_dropped(): + bus = Bus() + bus.start() + bus.put(0, 1) + bus.put(1, 1) + bus.put(0, 1) + bus.stop() + assert i2c_decode(bus.scl, bus.sda) == [] + + +def test_unterminated_transfer_is_kept(): + bus = Bus() + bus.start() + bus.byte(0x42 << 1, True) + bus.byte(0x99, True) + (transfer,) = i2c_decode(bus.scl, bus.sda) + assert (transfer.payload, transfer.stop) == (b"\x99", False) + + +def test_scl_timing(): + """Each bit is one third low, one third high, one third low (half = 4 samples): 12-sample periods, 8-sample low + phases and 4-sample high phases; a stretch lengthens one low phase but not the median period.""" + bus = Bus(half=4) + bus.start() + bus.byte(0x42 << 1, True, stretch=100) + bus.byte(0x0F, True) + bus.stop() + bus.idle() + timing = scl_timing(bus.scl, rate=1_000_000) + assert timing.freq == pytest.approx(1e6 / 12) + assert timing.tlow_min == pytest.approx(8e-6) + assert timing.thigh_min == pytest.approx(4e-6) + + +def test_scl_timing_needs_clocks(): + with pytest.raises(ValueError): + scl_timing([1, 1, 0, 0, 1, 1], rate=1e6) + + +def test_rise_times(): + edge = [0.0] * 5 + [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.3] + [3.3] * 5 + [0.0] * 3 + [1.0, 2.0, 3.0] + assert rise_times(edge, rate=1e8, low=1.0, high=2.0) == [pytest.approx(20e-9), pytest.approx(10e-9)] + assert rise_times([0.0, 1.5, 0.0, 3.0], rate=1e8, low=1.0, high=2.0) == [pytest.approx(10e-9 / 3)] + assert rise_times([0.0, 1.5, 1.8], rate=1e8, low=1.0, high=2.0) == [] + + +def calls(api, name): + return api.calls_to(name) + + +def test_arm_on_start_triggers_on_sda_falling_with_scl_high(): + api = FakeDwfApi() + ad3 = fake_ad3(api) + ad3.open() + api.logic_samples = [0b1000, 0b0000] + pending = arm_on_start(ad3, scl_dio=3, sda_dio=5, rate=1e6, samples=1000, pretrigger=0.1) + assert calls(api, "FDwfDigitalInTriggerSet")[-1][1:] == (0, 1 << 3, 0, 1 << 5) + assert calls(api, "FDwfDigitalInTriggerSourceSet")[-1][1] == api.constants.trigsrcDetectorDigitalIn.value + assert calls(api, "FDwfDigitalInTriggerPositionSet")[-1][1] == 900 + capture = pending.wait(timeout=1.0) + assert (capture.rate, capture.trigger_index, len(capture.samples)) == (1e6, 100, 1000) + assert capture.channel(3)[:2] == [1, 0] + with pytest.raises(ValueError): + arm_on_start(ad3, 3, 5, rate=1e6, samples=10**6) + + +def test_inject_on_start_pulses_sda_open_drain_after_the_start(): + api = FakeDwfApi() + ad3 = fake_ad3(api) + ad3.open() + inject_on_start(ad3, scl_dio=3, sda_dio=5, delay_s=20e-6, width_s=10e-6) + assert calls(api, "FDwfDigitalInTriggerSet")[-1][1:] == (0, 1 << 3, 0, 1 << 5) + assert calls(api, "FDwfDigitalOutTriggerSourceSet")[-1][1] == api.constants.trigsrcDetectorDigitalIn.value + assert calls(api, "FDwfDigitalOutOutputSet")[-1][1:] == (5, api.constants.DwfDigitalOutOutputOpenDrain.value) + assert calls(api, "FDwfDigitalOutIdleSet")[-1][1:] == (5, api.constants.DwfDigitalOutIdleZet.value) + assert calls(api, "FDwfDigitalOutCounterInitSet")[-1][1:] == (5, 0, 1000) + assert calls(api, "FDwfDigitalOutWaitSet")[-1][1] == pytest.approx(20e-6) + assert calls(api, "FDwfDigitalOutRunSet")[-1][1] == pytest.approx(10e-6) + assert calls(api, "FDwfDigitalOutConfigure")[-1][1] == 1 + with pytest.raises(ValueError): + inject_on_start(ad3, 3, 5, delay_s=0.0, width_s=0.0) + + +def test_arm_scope_triggers_on_the_rising_edge(): + api = FakeDwfApi() + ad3 = fake_ad3(api) + ad3.open() + api.scope_levels = {0: 3.3, 1: 0.0} + pending = arm_scope(ad3, [1, 2], rate=1e8, samples=100, trigger=1, level=1.65) + assert calls(api, "FDwfAnalogInTriggerChannelSet")[-1][1] == 0 + assert calls(api, "FDwfAnalogInTriggerLevelSet")[-1][1] == pytest.approx(1.65) + assert calls(api, "FDwfAnalogInTriggerAutoTimeoutSet")[-1][1] == 0.0 + samples = pending.wait(timeout=1.0) + assert samples[1] == [3.3] * 100 and samples[2] == [0.0] * 100 diff --git a/validation/host/tests/unit/test_i2c_timing.py b/validation/host/tests/unit/test_i2c_timing.py new file mode 100644 index 00000000..82dc96d5 --- /dev/null +++ b/validation/host/tests/unit/test_i2c_timing.py @@ -0,0 +1,122 @@ +"""`i2c.expected_timing`, the host twin of validation/firmware/I2cTiming.cpp: the TIMINGR table of PROTOCOL.md (D.1) +and reference vectors of the design's `i2c_timing.py` `compute()`, copied as literals, plus the I2C limits every +result must meet with a zero rise time.""" + +import pytest + +from hal_st_validation.i2c import ( + BUS_MAX, + BUS_MIN, + FAST_MODE, + STANDARD_MODE, + arbitration_window, + bit_times, + expected_timing, + mode_limits, + timing_fields, +) + +PROTOCOL_TABLE = [ + (16_000_000, 20_000, 0x20408186), + (16_000_000, 50_000, 0x00E097A2), + (16_000_000, 100_000, 0x00E04752), + (16_000_000, 400_000, 0x00500916), + (32_000_000, 20_000, 0x80305659), + (32_000_000, 50_000, 0x2090656C), + (32_000_000, 100_000, 0x10E04853), + (32_000_000, 400_000, 0x00B0162E), + (64_000_000, 20_000, 0x90509CA1), + (64_000_000, 50_000, 0x80604348), + (64_000_000, 100_000, 0x40B03943), + (64_000_000, 400_000, 0x10B0172F), + (100_000_000, 20_000, 0xD060AEB4), + (100_000_000, 50_000, 0x70B0777F), + (100_000_000, 100_000, 0x50E04B57), + (100_000_000, 400_000, 0x20B11931), +] + +REFERENCE_GRID = [ + (16_000_000, 75_000, 0x00E0616D), + (16_000_000, 100_001, 0x00504552), + (16_000_000, 250_000, 0x00501522), + (24_000_000, 20_000, 0x2070C3C9), + (24_000_000, 75_000, 0x10A04952), + (24_000_000, 100_001, 0x00806A7C), + (24_000_000, 250_000, 0x00802234), + (24_000_000, 400_000, 0x00801022), + (48_000_000, 20_000, 0x70509297), + (48_000_000, 75_000, 0x30A04953), + (48_000_000, 100_001, 0x10806B7D), + (48_000_000, 250_000, 0x10802335), + (48_000_000, 400_000, 0x10801123), + (80_000_000, 20_000, 0x8070D9E0), + (80_000_000, 75_000, 0x40E0626F), + (80_000_000, 100_001, 0x10D0B3D1), + (80_000_000, 250_000, 0x10D03B59), + (80_000_000, 400_000, 0x10D01D3B), +] + + +@pytest.mark.parametrize(("kernel", "bus", "timing"), PROTOCOL_TABLE) +def test_protocol_table(kernel, bus, timing): + assert expected_timing(kernel, bus) == timing + + +@pytest.mark.parametrize(("kernel", "bus", "timing"), REFERENCE_GRID) +def test_reference_grid(kernel, bus, timing): + assert expected_timing(kernel, bus) == timing + + +@pytest.mark.parametrize("bus", [0, BUS_MIN - 1, BUS_MAX + 1, 1_000_000]) +def test_bus_outside_the_range(bus): + assert expected_timing(64_000_000, bus) is None + + +def test_no_kernel_clock(): + assert expected_timing(0, 100_000) is None + + +@pytest.mark.parametrize("kernel", [16_000_000, 32_000_000, 64_000_000, 100_000_000]) +@pytest.mark.parametrize("bus", [BUS_MIN, 33_333, 50_000, 99_999, 100_000, 100_001, 200_000, 333_333, BUS_MAX]) +def test_limits_with_zero_rise(kernel, bus): + """The `check(..., trise=0)` of the design: tLOW/tHIGH minima of the mode, SCLDEL covers rise + setup, SDADEL + stays below the valid-data limit, and the period with zero rise is never shorter than 1/bus.""" + timing = expected_timing(kernel, bus) + assert timing is not None + mode = mode_limits(bus) + fields = timing_fields(timing) + clock = 1e12 / kernel + prescaled = (fields["presc"] + 1) * clock + low, high = bit_times(timing, kernel) + assert low * 1e12 > mode.lscl_min + assert high * 1e12 >= mode.hscl_min - 1e-3 + assert (fields["scldel"] + 1) * prescaled >= mode.rise + mode.sudat_min + assert (fields["sdadel"] * (fields["presc"] + 1) + 1) * clock <= mode.vddat_max - mode.rise - 260_000 - 4 * clock + assert 1 / (low + high + mode.fall / 1e12) <= bus * 1.0001 + + +def test_mode_split(): + assert mode_limits(100_000) is STANDARD_MODE + assert mode_limits(100_001) is FAST_MODE + assert STANDARD_MODE.tlow_min == pytest.approx(4.7e-6) + assert FAST_MODE.thigh_min == pytest.approx(0.6e-6) + + +def test_timing_fields(): + assert timing_fields(0x10B0172F) == {"presc": 1, "scldel": 0xB, "sdadel": 0, "sclh": 0x17, "scll": 0x2F} + assert timing_fields(0x70B03D3D) == {"presc": 7, "scldel": 0xB, "sdadel": 0, "sclh": 0x3D, "scll": 0x3D} + + +def test_bit_times_of_the_default_timing(): + """0x70B03D3D at 64 MHz: 62 prescaled clocks of 125 ns each plus 81.25 ns of filter and sync per phase.""" + low, high = bit_times(0x70B03D3D, 64_000_000) + assert low == pytest.approx(62 * 125e-9 + 50e-9 + 2 / 64e6) + assert high == pytest.approx(low) + + +def test_arbitration_window_covers_the_second_address_bit(): + low, high = bit_times(0x40B03943, 64_000_000) + delay, width = arbitration_window(0x40B03943, 64_000_000) + second_high = (2 * high + 2 * low, 3 * high + 2 * low) + assert delay < second_high[0] and delay + width > second_high[1] + assert width == pytest.approx(low + high) From 7cabd2d2105b671058aa8e55063db8fe1d97f137 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:29:56 +0000 Subject: [PATCH 40/57] feat(validation): validate SpiSlaveStmDma, SPI data sizes and the second SPI instance Add the spis command group (PROTOCOL.md "SPI slave") and extend spi.open: - spis.open builds SpiSlaveStmDma on the board's slave DMA channels (WB55 DMA2 1/2, WBA55 GPDMA1 8/7, the latter shared with the ADC and dma.wave) and holds the SPI instance in ResourceAllocation, so spi and spis never share an instance. spis.arm hands one transfer to the driver (hex or a generated payload, full duplex, send or receive only); spis.result answers when it is done, or done=0 after its own wait timer (HilPendingOperation would answer ERR timeout); spis.cancel and spis.close stop the DMA with CancelTransmission before the driver goes, one event-loop turn later. - spi.open takes bits=4..16 (dma=1: a SpiDataSizeConfiguratorStm on SpiMasterStmDma for the whole open, removed before the driver; the WBA55 SPI3 limited instance takes 8 and 16 only), lsb=1 (Config::msbFirst) and nss= (the drivers' slaveSelect pin, exclusive with cs), and holds the SPI instance in ResourceAllocation. Host: spiwords.py decodes SPI words of any width (the bench decoder masks to 8 bits) and models the frames of SpiMasterStmDma: one frame per byte up to 8 bits, one per little-endian byte pair above. groups/spis.py and fakes/spis.py (with the SPI loop of the fake spi.xfer) follow the firmware. test_spi_slave.py runs the slave against the AD3 master (directions, lengths up to 1024, clock rates, unarmed clocks, over- and under-length transfers, result waiting and busy, cancel, re-open) and, with --with spiloop, against the board's own master on the other instance. test_spi_ext.py decodes lsb and every frame size per instance, the SPI3 size limit, an 8-bit transfer after a 16-bit one, and the hardware NSS, a known gap (the masters initialise SPI_NSS_SOFT). The board files add SPI2 (WB55, spiloop) and SPI3 (WBA55, bundle2) to the SPI master tests. test_parameters_reference_wired_pins accepts SPI instances wired in any set, through the jumpers of their option (WB55 SPI2 with spiloop, WBA55 SPI3 in bundle2). test_known_gaps.py checks the gap-free case with known_gaps!: [] now that the WB55 board file has a gap. The README's known-gap list cites SynchronousSpiMasterStm.cpp:23 for SPI_NSS_SOFT and describes the 16-bit frame packing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- validation/PROTOCOL.md | 27 +- validation/README.md | 12 +- validation/firmware/CMakeLists.txt | 2 + validation/firmware/Main.cpp | 6 +- validation/firmware/SpiFactory.cpp | 58 ++- validation/firmware/SpiFactory.hpp | 11 +- validation/firmware/SpiSlaveGroup.cpp | 331 +++++++++++++++++ validation/firmware/SpiSlaveGroup.hpp | 102 ++++++ validation/host/boards/nucleo_wb55rg.yaml | 99 +++++- validation/host/boards/nucleo_wba55cg.yaml | 96 +++++ .../host/hal_st_validation/fakes/spis.py | 168 +++++++++ .../host/hal_st_validation/groups/spis.py | 85 +++++ validation/host/hal_st_validation/spiwords.py | 112 ++++++ validation/host/tests/hil/test_spi.py | 51 ++- validation/host/tests/hil/test_spi_ext.py | 208 +++++++++++ validation/host/tests/hil/test_spi_slave.py | 272 ++++++++++++++ validation/host/tests/unit/test_config.py | 10 +- validation/host/tests/unit/test_fake_spis.py | 335 ++++++++++++++++++ validation/host/tests/unit/test_known_gaps.py | 4 +- validation/host/tests/unit/test_spiwords.py | 126 +++++++ 20 files changed, 2075 insertions(+), 40 deletions(-) create mode 100644 validation/firmware/SpiSlaveGroup.cpp create mode 100644 validation/firmware/SpiSlaveGroup.hpp create mode 100644 validation/host/hal_st_validation/fakes/spis.py create mode 100644 validation/host/hal_st_validation/groups/spis.py create mode 100644 validation/host/hal_st_validation/spiwords.py create mode 100644 validation/host/tests/hil/test_spi_ext.py create mode 100644 validation/host/tests/hil/test_spi_slave.py create mode 100644 validation/host/tests/unit/test_fake_spis.py create mode 100644 validation/host/tests/unit/test_spiwords.py diff --git a/validation/PROTOCOL.md b/validation/PROTOCOL.md index 76751f53..681c5e9d 100644 --- a/validation/PROTOCOL.md +++ b/validation/PROTOCOL.md @@ -133,15 +133,38 @@ The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix - `uart.recv [timeout=] [len=]` → `OK data=` with everything received since the last `uart.recv`, at most 256 bytes (waits up to `timeout`, default 1000, at most 10000, for `len` bytes when given, and returns what arrived even if fewer) - `uart.close ` → `OK` -## SPI master (`hal::SpiMasterStm`, `dma=1` selects `hal::SpiMasterStmDma`, `sync=1` selects `hal::SynchronousSpiMasterStm`) +## SPI master (`hal::SpiMasterStm`, `dma=1` selects `hal::SpiMasterStmDma`, `sync=1` selects `hal::SynchronousSpiMasterStm`, `bits` adds `hal::SpiDataSizeConfiguratorStm`) -- `spi.open clk= mosi= miso= [cs=] [baud=] [mode=0|1|2|3] [dma=0|1] [sync=0|1]` → `OK`; defaults `baud=1000000 mode=0` +- `spi.open clk= mosi= miso= [cs=] [nss=] [baud=] [mode=0|1|2|3] [dma=0|1] [sync=0|1] [bits=<4-16>] [lsb=0|1]` → `OK`; defaults `baud=1000000 mode=0 bits=8 lsb=0` - the SPI clock is the fastest `spiclk / 2^n` (n = 1-8) not above `baud`, where `spiclk` is the kernel clock of the instance; `baud` outside `spiclk/256 ... spiclk/2` returns `ERR range` - `cs` is a GPIO chip select (any free pin, driven by EMIL's `SpiMasterWithChipSelect` or `SynchronousSpiMasterWithChipSelect`): low during a transfer, released after it unless `continue=1` - `dma=1` with `sync=1` returns `ERR usage` + - `nss` is the hardware slave select of the driver (`slaveSelect`): the pin must offer the instance's NSS function (`ERR pin`) and excludes `cs` (`ERR usage`). The masters configure the NSS pin but initialise the SPI with software NSS, so the pin is never driven (a known gap) + - `lsb=1` sends and receives the least significant bit first + - `bits` other than 8 needs `dma=1` (`ERR unsupported`); every transfer then runs with frames of `bits` bits (`SpiDataSizeConfiguratorStm`, installed for the whole open) + - up to 8 bits each buffer byte is one frame (its low bits); from 9 bits on, each frame takes two buffer bytes, little endian, so `txHex` and `rx` need an even length. Received frames are right aligned, the bits above the frame zero + - SPI3 of the STM32WBA55 is a limited instance: `bits` 8 or 16 only (`ERR unsupported`) + - the instance is shared with `spis`: an instance the SPI slave holds returns `ERR busy` - `spi.xfer [rx=] [continue=0|1]` → `OK rx=`; with an empty `txHex` (`-`) it receives `rx` bytes; `rx` defaults to the length of `txHex`, the transfer lasts max(tx, `rx`) bytes with `txHex` zero-padded, and the first `rx` received bytes are returned (`rx=0` only transmits); at most 64 bytes - `spi.close ` → `OK` +## SPI slave (`hal::SpiSlaveStmDma`) + +- `spis.open clk= miso= mosi= nss=` → `OK`: all four pins are required (`ERR usage`), each must offer its SPI function of the instance (`ERR pin`); the slave runs mode 0, 8-bit frames, MSB first, with the hardware NSS input (low selects it) + - DMA: STM32WB55 DMA2 channels 1 (transmit) and 2 (receive); STM32WBA55 GPDMA1 channels 8 (transmit) and 7 (receive), shared with `adc`, `ain.burst` and `dma.wave`. The instance held by `spi`, a held channel or pin returns `ERR busy` +- `spis.arm [rx=] [len=] [pattern=inc|const|prbs] [seed=]` → `OK` once the transfer is handed to the driver (`SendAndReceive`) + - full duplex: `rx` left out or equal to the transmit length (another value returns `ERR usage`); send only: `rx=0`; receive only: `-` with `rx=`; nothing to send or receive returns `ERR usage`. Lengths 1-1024 (`ERR range`) + - `-` with `len=` sends a payload generated in firmware (see "Framing"); `pattern`/`seed` without `len`, or `len` with hex, return `ERR usage` + - a transfer already armed returns `ERR busy` + - while nothing is armed the slave is disabled: frames a master clocks then are dropped. Frames beyond the armed length are dropped as well; the transfer completes with the armed length + - receive only sends the DMA's dummy word on MISO (undefined content) +- `spis.result [wait=] [out=hex|crc]` → `OK done=1 rx=` (or `OK done=1 len= crc=` with `out=crc`) once the transfer is done; `OK done=0` at once when nothing is armed, or after `wait` ms (0-10000, default 1000) with the transfer still armed + - `out=hex` holds 128 bytes: a longer receive length returns `ERR range`; a send-only transfer answers `rx=` with no data + - the result stays readable until the next `spis.arm` or `spis.cancel` + - a second `spis.result` while one waits returns `ERR busy`; `spis.cancel` and `spis.close` answer a waiting `spis.result` with `OK done=0` first +- `spis.cancel ` → `OK cancelled=<0|1>`: stops the armed transfer (`CancelTransmission`); `cancelled=1` when it was still running +- `spis.close ` → `OK`: cancels the transfer, then releases the driver, its DMA channels and pins + ## ADC (`hal::AdcStm` with `hal::AdcDmaMultiChannelStmBase`) - `adc.open pins=[,...] [sampling=] [timer=] [rate=]` → `OK` diff --git a/validation/README.md b/validation/README.md index dd8b75a9..5a36781f 100644 --- a/validation/README.md +++ b/validation/README.md @@ -156,8 +156,9 @@ Fix the driver, then remove its entry from both board files so the tests guard t These behaviours are not fixed here because a fix changes an API or behaviour other users rely on; the tests assert the current behaviour or mark it as a known gap until a decision is made. -- `SpiMasterStm`, `SpiMasterStmDma`, `SynchronousSpiMasterStm` mux `slaveSelect` to NSS but initialise `SPI_NSS_SOFT` and keep SPE on from construction (`SpiMasterStm.cpp:24,56`, `SpiMasterStmDma.cpp:38`, `SynchronousSpiMasterStm.cpp:12`): hardware NSS framing needs SPE toggled per transfer in three drivers; `test_spi_ext.py::test_hardware_nss*` is a known gap. -- `SpiDataSizeConfiguratorStm` (`SetDataSize`) changes only the peripheral DMA width (`SpiMasterStmDma.cpp:165-166`): a 9- to 16-bit frame carries one zero-extended byte; the tests assert that model (`spiwords.spi_frames`). +- `SpiMasterStm`, `SpiMasterStmDma`, `SynchronousSpiMasterStm` mux `slaveSelect` to NSS but initialise `SPI_NSS_SOFT` and keep SPE on from construction (`SpiMasterStm.cpp:24,56`, `SpiMasterStmDma.cpp:38`, `SynchronousSpiMasterStm.cpp:23`): hardware NSS framing needs SPE toggled per transfer in three drivers; `test_spi_ext.py::test_hardware_nss*` is a known gap. +- `SpiDataSizeConfiguratorStm` with 9- to 16-bit frames makes `SpiMasterStmDma` move 16-bit DMA words (`SpiMasterStmDma.cpp:109-129`), so each frame takes two buffer bytes, little endian; the tests assert that model (`spiwords.spi_frames`) with even lengths. + An odd byte count has no defined result: the last byte is dropped on the STM32WB55, and the GPDMA of the STM32WBA55 gets a count that is not a multiple of its 16-bit data width. - `QuadSpiStm` waits with `HAL_MAX_DELAY` (`QuadSpiStm.cpp:52,73,97`): a `PollStatus` that never matches hangs the firmware; `test_qspi.py::test_poll_timeout[variant=poll]` is a hanging known gap. - `AnalogToDigitalPinImplStm` and `AnalogToDigitalInternalTemperatureStm` ignore `numberOfSamples` (`AnalogToDigitalPinStm.cpp:90-165`) and always take one sample; the tests assert one. - The default sampling time of `AnalogToDigitalInternalTemperatureStm` (`AnalogToDigitalPinStm.hpp:21-27`) is below the temperature sensor's minimum; the tests pass a long `sampling=` and check only that the default delivers a code. @@ -459,7 +460,10 @@ SPI1 to SPI3; SPI3 is observed through DIO14, DIO10, DIO11 and DIO15. The PB8 en - `test_uart.py` - every baud rate x parity x driver variant (interrupt, DMA, duplex DMA, synchronous) on LPUART1 and USART2, both directions against the AD3 UART, frames decoded from the firmware TX line and the bit rate measured on it, large payloads, full-duplex streaming, RTS/CTS flow control per variant, send timeout with CTS held, re-open with other settings, TX/RX swap, the baud limits of each instance, argument errors and default pins. -- `test_spi.py` - SPI modes 0-3 x baud x driver variant (interrupt, DMA, synchronous) x GPIO chip select, decoded from the logic analyzer (MOSI, MISO, clock polarity and rate, chip-select release), continued sessions, receive-only first transfers, the largest transfer, the baud limits and argument errors. +- `test_spi.py` - SPI modes 0-3 x baud x driver variant (interrupt, DMA, synchronous) x GPIO chip select, decoded from the logic analyzer (MOSI, MISO, clock polarity and rate, chip-select release), continued sessions, receive-only first transfers, the largest transfer, the baud limits and argument errors, on SPI1 and on SPI2 (NUCLEO-WB55RG, `--with spiloop`) or SPI3 (NUCLEO-WBA55CG, `bundle2`). +- `test_spi_ext.py` - the SPI master options: the bit order (`lsb=1`) on every driver, frame sizes of 4 to 16 bits (`bits=`, `SpiDataSizeConfiguratorStm` on the DMA driver) decoded word by word, the 8/16-bit limit of the WBA55 SPI3, an 8-bit transfer after a 16-bit one (DMA data width), the `nss`/`cs` checks, and the hardware slave select (a known gap). +- `test_spi_slave.py` - `SpiSlaveStmDma` clocked by the AD3 SPI master: full duplex, send-only and receive-only transfers of 1-1024 bytes at several clock rates (CRC beyond 128 bytes), clocks while nothing is armed, a master clocking more or less than armed, `spis.result` waiting and busy, cancel and re-open, argument errors and the instance shared with `spi`; + with `--with spiloop` the board's own SPI master (every driver) against the slave on the other instance, both ways. - `test_adc.py` - wavegen DC levels against raw 12-bit codes (checked against the scope when it is wired), sequences of up to 8 conversions, every sampling time, timer-triggered rates and the measure timeout, timer sharing with PWM and the encoder, unsupported trigger timers, argument errors and limits, and ADC pins against GPIO. - `test_qei.py` - position counts for frequency x cycles x direction x decoding x phase inversion, physically inverted phases restored by `inva`/`invb`, offset and rollover, speed, the index input through `qei.index`, the LPTIM1 encoder on the NUCLEO-WB55RG, default pins, the resolution limits, argument errors, one encoder at a time and timer sharing. - `test_watchdog.py` - the window watchdog with timeouts across its range x automatic or manual feeding (warnings, no reset while fed, `reset=wwdg` otherwise), the early-warning period measured on the `pin=` toggle, manual feeding, a single watchdog at a time, the timeout limits and argument errors. @@ -507,6 +511,7 @@ In `hal_st_validation` (hal-st specific): - `fakes/` - `FakeGroup` and the argument helpers (`fakes/base.py`); one module per area with the fake firmware's model of its command groups, found automatically. - `patterns.py` - the payloads the firmware generates (`len=`, `pattern=inc|const|prbs`, `seed=`) and their CRC-32 (`crc=`). - `i2c.py` - I2C bus helpers: decoding SCL/SDA captures (`i2c_decode`), SCL timing and rise times, `expected_timing` (the host twin of the firmware's TIMINGR computation), and AD3 captures and SDA pulses started by a START condition; `groups/i2c.py` - the `fw.i2c`, `fw.i2cs` and `fw.eeprom` wrappers; `fakes/i2c.py` - their fake groups with one shared bus, the target and a 24LC256. +- `spiwords.py` - SPI words of 4-16 bits: a logic-capture decoder without the bench decoder's 8-bit mask (`spi_decode_words`) and the frame model of `SpiMasterStmDma` with a data size configurator (`spi_frames`, `spi_bytes`); `groups/spis.py` - the `fw.spis` wrapper; `fakes/spis.py` - its fake group and the SPI loop of the fake `spi.xfer` (`FakeFirmware.spi_loop`). - `protocol.py` - the hal-st part of the protocol: error reasons, `P` pins (ports A-K, index 0-15) and the generic alias names (`normalize_pin`, `parse_pin_map`). - `config.py` - board file loading, wiring-set merging, parameter matrices, overrides and known gaps; `expect.py` - expected STM32 values (PWM quantisation and range, SPI prescaler, UART baud-rate register limits, WWDG prescaler and period, ADC codes, encoder counts). - `pairwise.py` - the full product and the deterministic pairwise generator behind `--depth`. @@ -520,3 +525,4 @@ In the firmware (`firmware/`): - `ResourceAllocation` keeps the groups that share an I2C, SPI, ADC or LPTIM instance, a DMA channel or an HSEM semaphore off each other, with the owner ids of `Owners.hpp`; `Payload` generates the `len=`/`pattern=` payloads and the `out=crc` CRCs; `ChannelPins`, `Stopwatch` (microseconds from the cycle counter) and `HsemMaster` (the one HSEM master of the STM32WB55) serve the newer groups. - One factory per command group (`UartFactory`, `SpiFactory`, `AdcFactory`, `PwmFactory`, `QeiFactory` with the `qei.index` command, `WatchDogFactory`) parses the hal-st options and builds the driver; `UnsupportedGroups` answers the rest. - `I2cGroup` (`i2c.*` over `I2cStmForHil`, an `I2cStm` whose hooks report `EVT i2c`), `I2cTiming` (TIMINGR from kernel clock and bus frequency), `I2cTarget` (`i2cs.*`, the LL I2C target scaffold), `I2cEepromStm` (a `hal::Eeprom` over `hal::I2cMaster` with page writes and ACK polling) and `EepromGroup` (`eeprom.attach`/`eeprom.detach`, the factory of EMIL's `eeprom` group). +- `SpiSlaveGroup` (`spis.*`: `SpiSlaveFactory` builds `SpiSlaveStmDma` on the board's slave DMA channels, `SpiSlaveCommands` arms one transfer at a time and answers `spis.result` from its own timer); `SpiFactory` also takes `bits`, `lsb` and `nss` and holds the SPI instance in `ResourceAllocation`. diff --git a/validation/firmware/CMakeLists.txt b/validation/firmware/CMakeLists.txt index 0c415e10..a7d6f679 100644 --- a/validation/firmware/CMakeLists.txt +++ b/validation/firmware/CMakeLists.txt @@ -57,6 +57,8 @@ target_sources(hal_st.validation_firmware PRIVATE ResourceAllocation.hpp SpiFactory.cpp SpiFactory.hpp + SpiSlaveGroup.cpp + SpiSlaveGroup.hpp Stopwatch.hpp TimerAllocation.cpp TimerAllocation.hpp diff --git a/validation/firmware/Main.cpp b/validation/firmware/Main.cpp index 8092ddf8..e576e21b 100644 --- a/validation/firmware/Main.cpp +++ b/validation/firmware/Main.cpp @@ -25,6 +25,7 @@ #include "validation/firmware/QeiFactory.hpp" #include "validation/firmware/ResourceAllocation.hpp" #include "validation/firmware/SpiFactory.hpp" +#include "validation/firmware/SpiSlaveGroup.hpp" #include "validation/firmware/TimerAllocation.hpp" #include "validation/firmware/UartFactory.hpp" #include "validation/firmware/UnsupportedGroups.hpp" @@ -60,7 +61,7 @@ int main() static validation::UartFactoryStm uartFactory{ naming, console.dma }; static services::HilUartCommands::WithCapacity<256, 112> uart{ context, uartFactory }; - static validation::SpiFactoryStm spiFactory{ naming, console.dma }; + static validation::SpiFactoryStm spiFactory{ naming, console.dma, resources }; static services::HilSpiCommands::WithCapacity<64> spi{ context, spiFactory }; static validation::AdcFactoryStm adcFactory{ naming, console.dma, timers }; @@ -80,6 +81,9 @@ int main() static validation::EepromGroup eepromGroup{ context, naming, resources }; static services::HilEepromCommands::WithCapacity<128> eeprom{ context, eepromGroup }; + static validation::SpiSlaveFactory spiSlaveFactory{ naming, console.dma, resources }; + static validation::SpiSlaveCommands spiSlave{ context, spiSlaveFactory }; + validation::CreateUnsupportedGroups(context); system.PrintBoot(); diff --git a/validation/firmware/SpiFactory.cpp b/validation/firmware/SpiFactory.cpp index 55645508..eca5bdf0 100644 --- a/validation/firmware/SpiFactory.cpp +++ b/validation/firmware/SpiFactory.cpp @@ -2,6 +2,7 @@ #include "BoardProfile.hpp" #include "generated/stm32fxxx/PeripheralTable.hpp" #include "infra/event/EventDispatcher.hpp" +#include "validation/firmware/PeripheralClocks.hpp" #include "validation/firmware/PinFactoryStm.hpp" #include #include @@ -17,7 +18,11 @@ namespace validation // Longer than the slowest 64-byte transfer (2.05 ms at 250 kHz on WB55); one still running by then never completes constexpr infra::Duration quiesceTimeout = std::chrono::milliseconds(10); - constexpr std::array openKeys{ { "clk", "mosi", "miso", "cs", "baud", "mode", "dma", "sync" } }; + constexpr std::array openKeys{ { "clk", "mosi", "miso", "cs", "baud", "mode", "dma", "sync", "bits", "lsb", "nss" } }; + + constexpr uint32_t minimumBits = 4; + constexpr uint32_t maximumBits = 16; + constexpr uint32_t defaultBits = 8; constexpr std::array baudRatePrescalers{ { SPI_BAUDRATEPRESCALER_2, @@ -30,11 +35,6 @@ namespace validation SPI_BAUDRATEPRESCALER_256, } }; - bool InstanceExists(uint8_t index) - { - return index >= 1 && index <= hal::peripheralSpi.size() && hal::peripheralSpi[index - 1] != nullptr; - } - std::optional BaudRatePrescaler(uint32_t kernelClock, uint32_t baud) { const auto rate = static_cast(baud); @@ -52,6 +52,14 @@ namespace validation { return SupportsFunction(clock, hal::PinConfigTypeStm::spiClock, index) && SupportsFunction(mosi, hal::PinConfigTypeStm::spiMosi, index) && SupportsFunction(miso, hal::PinConfigTypeStm::spiMiso, index); } + + bool DataSizeSupported(uint8_t index, uint32_t bits, bool dma) + { + if (bits == defaultBits) + return true; + + return dma && (!SpiLimited(index) || bits == 16); + } } template @@ -61,6 +69,7 @@ namespace validation config.polarityLow = (request.mode & 2) == 0; config.phase1st = (request.mode & 1) == 0; config.baudRatePrescaler = request.baudRatePrescaler; + config.msbFirst = !request.lsb; return config; } @@ -101,9 +110,10 @@ namespace validation return busy; } - SpiFactoryStm::SpiFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma) + SpiFactoryStm::SpiFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma, ResourceAllocation& resources) : naming(naming) , dma(dma) + , resources(resources) {} uint8_t SpiFactoryStm::Instances() const @@ -131,9 +141,12 @@ namespace validation ClaimedPins claimed; status = Claim(index, request, pins, claimed); + if (status == HilStatus::done) + status = resources.Claim(Resource::spi, index, services::HilOwners::spi); if (status != HilStatus::done) return status; + this->index = index; Construct(index, request, claimed, handle); return HilStatus::done; } @@ -169,6 +182,10 @@ namespace validation void SpiFactoryStm::Destroy() { + if (auto spi = std::get_if(&driver); spi != nullptr && dataSize) + spi->ResetCommunicationConfigurator(); + dataSize.reset(); + // The driver goes first: an end-of-transfer interrupt must not reach a destroyed chip-select wrapper driver.emplace(); tracked.reset(); @@ -176,12 +193,13 @@ namespace validation synchronousChipSelect.reset(); receiveStream.reset(); transmitStream.reset(); + resources.Release(Resource::spi, index, services::HilOwners::spi); onClosed(); } HilStatus SpiFactoryStm::Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const { - if (!InstanceExists(index)) + if (!SpiExists(index)) return HilStatus::range; const HilStatus status = Parse(arguments, request); @@ -191,7 +209,7 @@ namespace validation if (!request.clock || !request.mosi || !request.miso) return HilStatus::usage; - if (request.dma && request.synchronous) + if ((request.dma && request.synchronous) || (request.slaveSelect && request.chipSelect)) return HilStatus::usage; const auto prescaler = BaudRatePrescaler(board::SpiKernelClock(index), request.baud); @@ -203,12 +221,18 @@ namespace validation if (!PinsSupportFunctions(index, *request.clock, *request.mosi, *request.miso)) return HilStatus::pin; + if (request.slaveSelect && !SupportsFunction(*request.slaveSelect, hal::PinConfigTypeStm::spiSlaveSelect, index)) + return HilStatus::pin; + if (request.chipSelect && !IsBonded(*request.chipSelect)) return HilStatus::pin; if (request.dma && !board::SpiDma(index)) return HilStatus::unsupported; + if (!DataSizeSupported(index, request.bits, request.dma)) + return HilStatus::unsupported; + return HilStatus::done; } @@ -219,10 +243,13 @@ namespace validation arguments.Pin("mosi", naming, request.mosi, status); arguments.Pin("miso", naming, request.miso, status); arguments.Pin("cs", naming, request.chipSelect, status); + arguments.Pin("nss", naming, request.slaveSelect, status); arguments.Number("baud", request.baud, 1, std::numeric_limits::max(), status); arguments.Number("mode", request.mode, 0, 3, status); + arguments.Number("bits", request.bits, minimumBits, maximumBits, status); arguments.Flag("dma", request.dma, status); arguments.Flag("sync", request.synchronous, status); + arguments.Flag("lsb", request.lsb, status); return status; } @@ -235,6 +262,8 @@ namespace validation status = pins.ClaimFunction(request.miso, Function(hal::PinConfigTypeStm::spiMiso), index, claimed.miso); if (status == HilStatus::done && request.chipSelect) status = pins.Claim(*request.chipSelect, services::HilPinPool::Use::exclusive, claimed.chipSelect); + if (status == HilStatus::done && request.slaveSelect) + status = pins.ClaimFunction(request.slaveSelect, Function(hal::PinConfigTypeStm::spiSlaveSelect), index, claimed.slaveSelect); return status; } @@ -243,7 +272,7 @@ namespace validation { if (request.synchronous) { - auto& spi = driver.emplace(index, PinOrDummy(claimed.clock), PinOrDummy(claimed.miso), PinOrDummy(claimed.mosi), MakeConfig(request)); + auto& spi = driver.emplace(index, PinOrDummy(claimed.clock), PinOrDummy(claimed.miso), PinOrDummy(claimed.mosi), MakeConfig(request), PinOrDummy(claimed.slaveSelect)); if (claimed.chipSelect != nullptr) handle.synchronous = &synchronousChipSelect.emplace(spi, *claimed.chipSelect); @@ -267,12 +296,17 @@ namespace validation hal::SpiMaster& SpiFactoryStm::ConstructAsynchronous(uint8_t index, const Request& request, const ClaimedPins& claimed) { if (!request.dma) - return driver.emplace(index, PinOrDummy(claimed.clock), PinOrDummy(claimed.miso), PinOrDummy(claimed.mosi), MakeConfig(request)); + return driver.emplace(index, PinOrDummy(claimed.clock), PinOrDummy(claimed.miso), PinOrDummy(claimed.mosi), MakeConfig(request), PinOrDummy(claimed.slaveSelect)); const DmaRequests requests = *board::SpiDma(index); transmitStream.emplace(dma, hal::DmaChannelId(1, board::spiDmaChannel, requests.transmit)); receiveStream.emplace(dma, hal::DmaChannelId(1, static_cast(board::spiDmaChannel + 1), requests.receive)); - return driver.emplace(*transmitStream, *receiveStream, index, PinOrDummy(claimed.clock), PinOrDummy(claimed.miso), PinOrDummy(claimed.mosi), MakeConfig(request)); + auto& spi = driver.emplace(*transmitStream, *receiveStream, index, PinOrDummy(claimed.clock), PinOrDummy(claimed.miso), PinOrDummy(claimed.mosi), MakeConfig(request), PinOrDummy(claimed.slaveSelect)); + + if (request.bits != defaultBits) + spi.SetCommunicationConfigurator(dataSize.emplace(spi, request.bits)); + + return spi; } } diff --git a/validation/firmware/SpiFactory.hpp b/validation/firmware/SpiFactory.hpp index 33fa7a56..cd6f2245 100644 --- a/validation/firmware/SpiFactory.hpp +++ b/validation/firmware/SpiFactory.hpp @@ -1,6 +1,7 @@ #pragma once #include "hal_st/stm32fxxx/DmaStm.hpp" +#include "hal_st/stm32fxxx/SpiDataSizeConfiguratorStm.hpp" #include "hal_st/stm32fxxx/SpiMasterStm.hpp" #include "hal_st/stm32fxxx/SpiMasterStmDma.hpp" #include "hal_st/synchronous_stm32fxxx/SynchronousSpiMasterStm.hpp" @@ -10,6 +11,7 @@ #include "services/peripheral/SpiMasterWithChipSelect.hpp" #include "services/synchronous_peripheral/SynchronousSpiMasterWithChipSelect.hpp" #include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/ResourceAllocation.hpp" #include #include #include @@ -20,7 +22,7 @@ namespace validation : public services::HilSpiFactory { public: - SpiFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma); + SpiFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma, ResourceAllocation& resources); uint8_t Instances() const override; infra::MemoryRange OpenKeys() const override; @@ -35,10 +37,13 @@ namespace validation std::optional mosi; std::optional miso; std::optional chipSelect; + std::optional slaveSelect; uint32_t baud = 1000000; uint32_t mode = 0; + uint32_t bits = 8; bool dma = false; bool synchronous = false; + bool lsb = false; uint32_t baudRatePrescaler = SPI_BAUDRATEPRESCALER_2; }; @@ -48,6 +53,7 @@ namespace validation hal::GpioPin* mosi = nullptr; hal::GpioPin* miso = nullptr; hal::GpioPin* chipSelect = nullptr; + hal::GpioPin* slaveSelect = nullptr; }; class TrackedSpiMaster @@ -84,9 +90,12 @@ namespace validation private: const services::HilPinNaming& naming; hal::DmaStm& dma; + ResourceAllocation& resources; + uint8_t index = 0; std::optional transmitStream; std::optional receiveStream; std::variant driver; + std::optional dataSize; std::optional chipSelect; std::optional synchronousChipSelect; std::optional tracked; diff --git a/validation/firmware/SpiSlaveGroup.cpp b/validation/firmware/SpiSlaveGroup.cpp new file mode 100644 index 00000000..b859a6ee --- /dev/null +++ b/validation/firmware/SpiSlaveGroup.cpp @@ -0,0 +1,331 @@ +#include "validation/firmware/SpiSlaveGroup.hpp" +#include "BoardProfile.hpp" +#include "infra/event/EventDispatcher.hpp" +#include "infra/util/ReallyAssert.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include + +namespace validation +{ + namespace + { + using services::HilStatus; + + constexpr uint8_t instances = 4; + constexpr uint32_t defaultWaitMs = 1000; + constexpr uint32_t maximumWaitMs = 10000; + + constexpr std::array openKeys{ { "clk", "miso", "mosi", "nss" } }; + constexpr std::array armKeys{ { "rx", "len", "pattern", "seed" } }; + constexpr std::array resultKeys{ { "wait", "out" } }; + + bool PinsSupportFunctions(uint8_t index, HilPinId clock, HilPinId miso, HilPinId mosi, HilPinId slaveSelect) + { + return SupportsFunction(clock, hal::PinConfigTypeStm::spiClock, index) && SupportsFunction(miso, hal::PinConfigTypeStm::spiMiso, index) && SupportsFunction(mosi, hal::PinConfigTypeStm::spiMosi, index) && SupportsFunction(slaveSelect, hal::PinConfigTypeStm::spiSlaveSelect, index); + } + + Resource DmaResource(DmaChannel channel) + { + return channel.dma == 1 ? Resource::dma1 : Resource::dma2; + } + } + + SpiSlaveFactory::SpiSlaveFactory(const services::HilPinNaming& naming, hal::DmaStm& dma, ResourceAllocation& resources) + : naming(naming) + , dma(dma) + , resources(resources) + {} + + uint8_t SpiSlaveFactory::Instances() const + { + return instances; + } + + infra::MemoryRange SpiSlaveFactory::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus SpiSlaveFactory::Prepare(uint8_t index, const services::HilArguments& arguments) + { + Request request; + return Evaluate(index, arguments, request); + } + + void SpiSlaveFactory::Close(uint8_t, const infra::Function& onClosed) + { + this->onClosed = onClosed; + + // Stops both DMA channels: a channel left enabled keeps its count for the next open on STM32WB + slave->CancelTransmission(); + + // A DMA completion dispatched before the cancel still runs against the driver + infra::EventDispatcher::Instance().Schedule([this]() + { + Destroy(); + }); + } + + HilStatus SpiSlaveFactory::Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) + { + Request request; + ClaimedPins claimed; + + HilStatus status = Evaluate(index, arguments, request); + if (status == HilStatus::done) + status = Claim(index, request, pins, claimed); + if (status != HilStatus::done) + return status; + + const DmaRequests requests = *board::SpiDma(index); + transmitStream.emplace(dma, hal::DmaChannelId(board::spiSlaveDma.transmit.dma, board::spiSlaveDma.transmit.channel, requests.transmit)); + receiveStream.emplace(dma, hal::DmaChannelId(board::spiSlaveDma.receive.dma, board::spiSlaveDma.receive.channel, requests.receive)); + slave.emplace(*transmitStream, *receiveStream, index, PinOrDummy(claimed.clock), PinOrDummy(claimed.miso), PinOrDummy(claimed.mosi), PinOrDummy(claimed.slaveSelect)); + return HilStatus::done; + } + + hal::SpiSlave& SpiSlaveFactory::Slave() + { + really_assert(slave.has_value()); + return *slave; + } + + HilStatus SpiSlaveFactory::Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const + { + if (!SpiExists(index)) + return HilStatus::range; + + HilStatus status = HilStatus::done; + arguments.Pin("clk", naming, request.clock, status); + arguments.Pin("miso", naming, request.miso, status); + arguments.Pin("mosi", naming, request.mosi, status); + arguments.Pin("nss", naming, request.slaveSelect, status); + if (status != HilStatus::done) + return status; + + if (!request.clock || !request.miso || !request.mosi || !request.slaveSelect) + return HilStatus::usage; + + if (!PinsSupportFunctions(index, *request.clock, *request.miso, *request.mosi, *request.slaveSelect)) + return HilStatus::pin; + + if (!board::SpiDma(index)) + return HilStatus::unsupported; + + return HilStatus::done; + } + + HilStatus SpiSlaveFactory::Claim(uint8_t index, const Request& request, services::HilPinOwner& pins, ClaimedPins& claimed) + { + HilStatus status = pins.ClaimFunction(request.clock, Function(hal::PinConfigTypeStm::spiClock), index, claimed.clock); + if (status == HilStatus::done) + status = pins.ClaimFunction(request.miso, Function(hal::PinConfigTypeStm::spiMiso), index, claimed.miso); + if (status == HilStatus::done) + status = pins.ClaimFunction(request.mosi, Function(hal::PinConfigTypeStm::spiMosi), index, claimed.mosi); + if (status == HilStatus::done) + status = pins.ClaimFunction(request.slaveSelect, Function(hal::PinConfigTypeStm::spiSlaveSelect), index, claimed.slaveSelect); + if (status != HilStatus::done) + return status; + + this->index = index; + status = resources.Claim(Resource::spi, index, owners::spiSlave); + if (status == HilStatus::done) + status = ClaimChannel(board::spiSlaveDma.transmit); + if (status == HilStatus::done) + status = ClaimChannel(board::spiSlaveDma.receive); + if (status != HilStatus::done) + ReleaseResources(); + + return status; + } + + HilStatus SpiSlaveFactory::ClaimChannel(DmaChannel channel) + { + return resources.Claim(DmaResource(channel), channel.channel, owners::spiSlave); + } + + void SpiSlaveFactory::ReleaseResources() + { + resources.Release(Resource::spi, index, owners::spiSlave); + resources.Release(DmaResource(board::spiSlaveDma.transmit), board::spiSlaveDma.transmit.channel, owners::spiSlave); + resources.Release(DmaResource(board::spiSlaveDma.receive), board::spiSlaveDma.receive.channel, owners::spiSlave); + } + + void SpiSlaveFactory::Destroy() + { + slave.reset(); + receiveStream.reset(); + transmitStream.reset(); + ReleaseResources(); + onClosed(); + } + + SpiSlaveCommands::SpiSlaveCommands(services::HilContext& context, SpiSlaveFactory& factory) + : services::HilSingleInstanceGroup(context, factory, owners::spiSlave) + , factory(factory) + , commands{ { + OpenCommand("spis.open", " clk= miso= mosi= nss="), + services::HilBind("spis.arm", " [rx=] [len=] [pattern=] [seed=]", *this, context.response), + services::HilBind("spis.result", " [wait=] [out=hex|crc]", *this, context.response), + services::HilBind("spis.cancel", "", *this, context.response), + CloseCommand("spis.close", ""), + } } + {} + + infra::MemoryRange SpiSlaveCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus SpiSlaveCommands::OpenInstance(uint8_t index, const services::HilArguments& arguments) + { + armed = false; + done = false; + receiveSize = 0; + return factory.Open(index, arguments, Pins()); + } + + void SpiSlaveCommands::CloseInstance() + { + StopWaiting(); + armed = false; + done = false; + } + + HilStatus SpiSlaveCommands::Arm(const services::HilArguments& arguments) + { + if (!arguments.Shape(2, 2, infra::MakeRange(armKeys))) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done) + return status; + + infra::ByteRange send; + status = ParsePayload(arguments, 1, infra::MakeRange(transmitBuffer), send); + auto receive = static_cast(send.size()); + arguments.Number("rx", receive, 0, static_cast(capacity), status); + if (status != HilStatus::done) + return status; + + // SpiSlaveStmDma runs full duplex on equal lengths only + if ((send.empty() && receive == 0) || (!send.empty() && receive != 0 && receive != send.size())) + return HilStatus::usage; + + if (armed) + return HilStatus::busy; + + armed = true; + done = false; + receiveSize = receive; + std::fill(receiveBuffer.begin(), receiveBuffer.end(), 0); + + factory.Slave().SendAndReceive(send, infra::Head(infra::MakeRange(receiveBuffer), receiveSize), [this]() + { + TransferDone(); + }); + + Context().response.Ok(); + return HilStatus::done; + } + + HilStatus SpiSlaveCommands::Result(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, infra::MakeRange(resultKeys))) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done) + return status; + + auto requested = Output::hex; + uint32_t wait = defaultWaitMs; + status = ParseOutput(arguments, requested); + arguments.Number("wait", wait, 0, maximumWaitMs, status); + if (status == HilStatus::done) + status = CheckOutput(receiveSize, requested); + if (status != HilStatus::done) + return status; + + if (waiting) + return HilStatus::busy; + + output = requested; + + if (done) + Report(); + else if (!armed || wait == 0) + Context().response.Ok() << " done=0"; + else + { + // HilPendingOperation answers an expired wait with ERR timeout; a transfer still pending is a valid answer here + waiting = true; + waitTimer.Start(std::chrono::milliseconds(wait), [this]() + { + waiting = false; + Context().response.Ok() << " done=0"; + }); + } + + return HilStatus::done; + } + + HilStatus SpiSlaveCommands::Cancel(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done) + return status; + + StopWaiting(); + const bool cancelled = factory.Slave().CancelTransmission(); + armed = false; + done = false; + + Context().response.Ok() << " cancelled=" << static_cast(cancelled ? 1 : 0); + return HilStatus::done; + } + + void SpiSlaveCommands::TransferDone() + { + armed = false; + done = true; + + if (waiting) + { + waitTimer.Cancel(); + waiting = false; + Report(); + } + } + + void SpiSlaveCommands::Report() + { + const infra::ConstByteRange data = infra::Head(infra::MakeRange(receiveBuffer), receiveSize); + auto line = Context().response.Ok(); + line << " done=1"; + + if (output == Output::hex) + { + line << " rx="; + line.Hex(data); + } + else + PrintData(line, data, Output::crc); + } + + void SpiSlaveCommands::StopWaiting() + { + if (!waiting) + return; + + waitTimer.Cancel(); + waiting = false; + Context().response.Ok() << " done=0"; + } +} diff --git a/validation/firmware/SpiSlaveGroup.hpp b/validation/firmware/SpiSlaveGroup.hpp new file mode 100644 index 00000000..6f1519cd --- /dev/null +++ b/validation/firmware/SpiSlaveGroup.hpp @@ -0,0 +1,102 @@ +#pragma once + +#include "hal_st/stm32fxxx/DmaStm.hpp" +#include "hal_st/stm32fxxx/SpiSlaveStmDma.hpp" +#include "infra/timer/Timer.hpp" +#include "infra/util/AutoResetFunction.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilSingleInstanceGroup.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/Payload.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include +#include +#include +#include + +namespace validation +{ + class SpiSlaveFactory + : public services::HilInstanceFactory + { + public: + SpiSlaveFactory(const services::HilPinNaming& naming, hal::DmaStm& dma, ResourceAllocation& resources); + + uint8_t Instances() const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + void Close(uint8_t index, const infra::Function& onClosed) override; + + services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins); + hal::SpiSlave& Slave(); + + private: + struct Request + { + std::optional clock; + std::optional miso; + std::optional mosi; + std::optional slaveSelect; + }; + + struct ClaimedPins + { + hal::GpioPin* clock = nullptr; + hal::GpioPin* miso = nullptr; + hal::GpioPin* mosi = nullptr; + hal::GpioPin* slaveSelect = nullptr; + }; + + services::HilStatus Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const; + services::HilStatus Claim(uint8_t index, const Request& request, services::HilPinOwner& pins, ClaimedPins& claimed); + services::HilStatus ClaimChannel(DmaChannel channel); + void ReleaseResources(); + void Destroy(); + + private: + const services::HilPinNaming& naming; + hal::DmaStm& dma; + ResourceAllocation& resources; + uint8_t index = 0; + std::optional transmitStream; + std::optional receiveStream; + std::optional slave; + infra::AutoResetFunction onClosed; + }; + + class SpiSlaveCommands + : public services::HilSingleInstanceGroup + { + public: + static constexpr std::size_t capacity = 1024; + + SpiSlaveCommands(services::HilContext& context, SpiSlaveFactory& factory); + + infra::MemoryRange Commands() override; + + protected: + services::HilStatus OpenInstance(uint8_t index, const services::HilArguments& arguments) override; + void CloseInstance() override; + + private: + services::HilStatus Arm(const services::HilArguments& arguments); + services::HilStatus Result(const services::HilArguments& arguments); + services::HilStatus Cancel(const services::HilArguments& arguments); + + void TransferDone(); + void Report(); + void StopWaiting(); + + private: + SpiSlaveFactory& factory; + bool armed = false; + bool done = false; + bool waiting = false; + Output output = Output::hex; + std::size_t receiveSize = 0; + infra::TimerSingleShot waitTimer; + std::array transmitBuffer{}; + std::array receiveBuffer{}; + std::array commands; + }; +} diff --git a/validation/host/boards/nucleo_wb55rg.yaml b/validation/host/boards/nucleo_wb55rg.yaml index 8b96c010..e858c437 100644 --- a/validation/host/boards/nucleo_wb55rg.yaml +++ b/validation/host/boards/nucleo_wb55rg.yaml @@ -378,7 +378,14 @@ wiring_sets: # literal); `hangs` gaps abort or hang the firmware, so their tests are # skipped unless --run-known-gaps; the others are expected failures. --fake # ignores them. -known_gaps: [] +known_gaps: + - tests: + - test_spi_ext.py::test_hardware_nss* + reason: >- + hal_st/stm32fxxx/SpiMasterStm.cpp:24, SpiMasterStmDma.cpp:38 and + hal_st/synchronous_stm32fxxx/SynchronousSpiMasterStm.cpp:23 - the SPI + masters mux slaveSelect to NSS but initialise SPI_NSS_SOFT, so NSS is + never driven; hardware NSS framing needs a decision (DESIGN B.14) # Test parameters. A mapping used by `@pytest.mark.matrix` is one dimension # per key: `--depth full` runs the cartesian product, `--depth quick` a @@ -405,6 +412,8 @@ tests: - qei.open 3 - adc.open 4 - wdt.start 1 timeout=100 + - spis.open 0 + - spis.open 3 # Features the MCU lacks: ERR unsupported. unsupported_instances: - qei.open 16 @@ -595,6 +604,15 @@ tests: mosi: spi1mosi miso: spi1miso cs: spi1cs + # SPI2 reaches the AD3 only through the spiloop jumpers (`option`): + # its tests skip without --with spiloop. + - name: spi2 + index: 2 + clk: spi2clk + mosi: spi2mosi + miso: spi2miso + cs: spi2nss + option: spiloop variants: interrupt: dma: 0 @@ -798,6 +816,85 @@ tests: wcycle_ms: 10 erase_size: 8192 freq: 400000 + # hal::SpiSlaveStmDma (`spis.*`): the AD3 SPI master (mode 0, MSB first, + # its chip select on `nss`) clocks the instances; `option` as above. + spis: + instances: + - name: spi1 + index: 1 + clk: spi1clk + miso: spi1miso + mosi: spi1mosi + nss: spi1nss + - name: spi2 + index: 2 + clk: spi2clk + miso: spi2miso + mosi: spi2mosi + nss: spi2nss + option: spiloop + freqs: [100000, 1000000, 4000000] + # Above 128 bytes the result is compared by CRC (`out=crc`). + lengths: [1, 2, 64, 255, 1024] + # The board's own master against the slave of the other instance + # (requires --with spiloop); EMIL's spi.xfer holds 64 bytes. The + # master drives the slave's NSS with its GPIO chip select `cs`. + loop_lengths: [1, 2, 64] + loop_baud: 1000000 + loop_variants: [interrupt, dma, sync] + loops: + - name: spi1-to-spi2 + master: + index: 1 + clk: spi1clk + mosi: spi1mosi + miso: spi1miso + cs: spi1cs + slave: + index: 2 + clk: spi2clk + miso: spi2miso + mosi: spi2mosi + nss: spi2nss + - name: spi2-to-spi1 + master: + index: 2 + clk: spi2clk + mosi: spi2mosi + miso: spi2miso + cs: spi2nss + slave: + index: 1 + clk: spi1clk + miso: spi1miso + mosi: spi1mosi + nss: spi1nss + # spi.open lsb=, bits= (dma=1, SpiDataSizeConfiguratorStm) and nss=. Frames + # above 8 bits take two buffer bytes, little endian (spiwords.spi_frames), + # so `payload` has an even length. `bits` lists the sizes decoded on the + # logic analyzer per instance; `limited` instances take 8 and 16 only. + spi_ext: + instances: + - name: spi1 + index: 1 + clk: spi1clk + mosi: spi1mosi + miso: spi1miso + cs: spi1cs + nss: spi1nss + bits: [4, 8, 12, 16] + - name: spi2 + index: 2 + clk: spi2clk + mosi: spi2mosi + miso: spi2miso + cs: spi2nss + nss: spi2nss + bits: [4, 8, 12, 16] + option: spiloop + lsb: [0, 1] + baud: 1000000 + payload: "a53c0f8001fe7e42" unsupported: commands: - comp.open 1 diff --git a/validation/host/boards/nucleo_wba55cg.yaml b/validation/host/boards/nucleo_wba55cg.yaml index dff25cc6..78769a13 100644 --- a/validation/host/boards/nucleo_wba55cg.yaml +++ b/validation/host/boards/nucleo_wba55cg.yaml @@ -448,6 +448,13 @@ known_gaps: and CTS held off SendData waits for TXE without a timeout and blocks the event loop for good hangs: true + - tests: + - test_spi_ext.py::test_hardware_nss* + reason: >- + hal_st/stm32fxxx/SpiMasterStm.cpp:24, SpiMasterStmDma.cpp:38 and + hal_st/synchronous_stm32fxxx/SynchronousSpiMasterStm.cpp:23 - the SPI + masters mux slaveSelect to NSS but initialise SPI_NSS_SOFT, so NSS is + never driven; hardware NSS framing needs a decision (DESIGN B.14) # Test parameters. A mapping used by `@pytest.mark.matrix` is one dimension # per key: `--depth full` runs the cartesian product, `--depth quick` a @@ -474,6 +481,8 @@ tests: - pwm.open 4 - qei.open 4 - wdt.start 1 timeout=100 + - spis.open 0 + - spis.open 2 # Features the MCU lacks: ERR unsupported. unsupported_instances: - qei.open 16 @@ -686,6 +695,13 @@ tests: mosi: spi1mosi miso: spi1miso cs: spi1cs + # SPI3 is wired in bundle2 only (its tests skip in bundle1). + - name: spi3 + index: 3 + clk: spi3clk + mosi: spi3mosi + miso: spi3miso + cs: spi3nss variants: interrupt: dma: 0 @@ -874,6 +890,86 @@ tests: wcycle_ms: 10 erase_size: 8192 freq: 400000 + # hal::SpiSlaveStmDma (`spis.*`): the AD3 SPI master (mode 0, MSB first, + # its chip select on `nss`) clocks the instances; SPI3 in bundle2 only. + spis: + instances: + - name: spi1 + index: 1 + clk: spi1clk + miso: spi1miso + mosi: spi1mosi + nss: spi1nss + - name: spi3 + index: 3 + clk: spi3clk + miso: spi3miso + mosi: spi3mosi + nss: spi3nss + freqs: [100000, 1000000, 4000000] + # Above 128 bytes the result is compared by CRC (`out=crc`). + lengths: [1, 2, 64, 255, 1024] + # The board's own master against the slave of the other instance + # (requires bundle2 --with spiloop); EMIL's spi.xfer holds 64 bytes. The + # master drives the slave's NSS with its GPIO chip select `cs`. + loop_lengths: [1, 2, 64] + loop_baud: 1000000 + loop_variants: [interrupt, dma, sync] + loops: + - name: spi1-to-spi3 + master: + index: 1 + clk: spi1clk + mosi: spi1mosi + miso: spi1miso + cs: spi1cs + slave: + index: 3 + clk: spi3clk + miso: spi3miso + mosi: spi3mosi + nss: spi3nss + - name: spi3-to-spi1 + master: + index: 3 + clk: spi3clk + mosi: spi3mosi + miso: spi3miso + cs: spi3nss + slave: + index: 1 + clk: spi1clk + miso: spi1miso + mosi: spi1mosi + nss: spi1nss + # spi.open lsb=, bits= (dma=1, SpiDataSizeConfiguratorStm) and nss=. Frames + # above 8 bits take two buffer bytes, little endian (spiwords.spi_frames), + # so `payload` has an even length. `bits` lists the sizes decoded on the + # logic analyzer per instance; SPI3 is a limited instance + # (IS_SPI_LIMITED_INSTANCE): 8 and 16 bits only, other sizes answer + # ERR unsupported. + spi_ext: + instances: + - name: spi1 + index: 1 + clk: spi1clk + mosi: spi1mosi + miso: spi1miso + cs: spi1cs + nss: spi1nss + bits: [4, 8, 12, 16] + - name: spi3 + index: 3 + clk: spi3clk + mosi: spi3mosi + miso: spi3miso + cs: spi3nss + nss: spi3nss + bits: [8, 16] + limited: true + lsb: [0, 1] + baud: 1000000 + payload: "a53c0f8001fe7e42" unsupported: commands: - comp.open 1 diff --git a/validation/host/hal_st_validation/fakes/spis.py b/validation/host/hal_st_validation/fakes/spis.py new file mode 100644 index 00000000..73c477b2 --- /dev/null +++ b/validation/host/hal_st_validation/fakes/spis.py @@ -0,0 +1,168 @@ +"""Fake `spis` group: the argument checks of validation/firmware/SpiSlaveGroup.cpp in their order, the pins, the +`ResourceAllocation` spi instance and slave DMA channels it holds (WB55 DMA2 1/2, WBA55 GPDMA1 8/7, shared with +`dma.wave` and the ADC), and the arm/result state of `hal::SpiSlaveStmDma`. + +Nothing clocks the slave without the AD3, except the SPI loop: `FakeFirmware.spi_loop`, a mapping {master instance: +slave instance} (default: the two SPI instances of the MCU on each other, the `--with spiloop` wiring), lets +`spi.xfer` on an open master clock an open `spis` instance (`FakeSpiLoop` serves `spi.xfer` for that; the other +`spi.*` commands stay with `FakeFirmware`). Each byte the master clocks goes into the armed transfer (MOSI) and +takes the slave's next byte (MISO, zero for a receive-only transfer, whose DMA dummy the firmware does not +define); bytes beyond the armed length, or with no transfer armed, read `FakeFirmware.spi_miso`. The master's +`bits`, `lsb` and `mode` are not modelled. `spis.result` answers synchronously (a not yet done transfer sleeps +`wait`), so a second `spis.result` while one waits cannot happen here. +""" + +from __future__ import annotations + +from typing import Any + +from ..groups.spis import SPIS_BUFFER, SPIS_HEX_MAX, SPIS_WAIT_DEFAULT_MS, SPIS_WAIT_MAX_MS +from ..patterns import PATTERNS, SEED_MAX, crc_text, generate +from .base import FakeGroup, _choice, _fail, _flag, _hex, _number, _shape + +__all__ = ["FakeSpiLoop", "FakeSpiSlave"] + +_INSTANCES = 4 +_MASTER_CAPACITY = 64 +_OPEN_KEYS = ("clk", "miso", "mosi", "nss") +_ARM_KEYS = ("rx", "len", "pattern", "seed") +_RESULT_KEYS = ("wait", "out") +_FUNCTIONS = (("spiClock", "clk"), ("spiMiso", "miso"), ("spiMosi", "mosi"), ("spiSlaveSelect", "nss")) +# `board::spiSlaveDma`: (transmit, receive) as `ResourceAllocation` keys. +_SLAVE_DMA = {"stm32wb55": (("dma2", 1), ("dma2", 2)), "stm32wba55": (("dma1", 8), ("dma1", 7))} + + +def _payload(args: list[str], options: dict[str, str], position: int, capacity: int) -> bytes: + """`ParsePayload`: hex, or `-` with `len`/`pattern`/`seed`; `-` alone is empty.""" + text = args[position] + generated = "len" in options + patterned = "pattern" in options or "seed" in options + if text != "-": + if generated or patterned: + _fail("usage") + return _hex(text, capacity) + if not generated: + if patterned: + _fail("usage") + return b"" + pattern = _choice(options, "pattern", PATTERNS, "inc") + seed = _number(options.get("seed", "0"), 0, SEED_MAX) + length = _number(options["len"], 1, capacity) + return generate(length, pattern, seed) + + +class FakeSpiSlave(FakeGroup): + prefix = "spis" + + def _key(self, args: list[str]) -> str: + return str(_number(args[0], 0, _INSTANCES - 1)) + + def _find(self, args: list[str], options: dict[str, str], low: int, high: int, keys: tuple[str, ...] = ()) -> dict[str, Any]: + _shape(args, options, low, high, keys) + return self.fw.find_instance(self.prefix, self._key(args)) + + def cmd_open(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, _OPEN_KEYS) + index = _number(args[0], 0, _INSTANCES - 1) + if index not in self.fw.spec.spis: + _fail("range") + pins = {key: self.fw.pin(options.get(key)) for key in _OPEN_KEYS} + if any(pin is None for pin in pins.values()): + _fail("usage") + for function, key in _FUNCTIONS: + self.fw.check_function(function, index, pins[key]) + state = {"index": index, "armed": False, "done": False, "tx": b"", "rx": 0, "received": bytearray(), "clocked": 0} + resources = [("spi", index), *_SLAVE_DMA[self.fw.family]] + self.fw.open_instance(self.prefix, str(index), state, pins.values(), resources=resources) + return "OK" + + def cmd_arm(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options, 2, 2, _ARM_KEYS) + send = _payload(args, options, 1, SPIS_BUFFER) + receive = _number(options.get("rx", str(len(send))), 0, SPIS_BUFFER) + if (not send and receive == 0) or (send and receive not in (0, len(send))): + _fail("usage") + if state["armed"]: + _fail("busy") + state.update(armed=True, done=False, tx=send, rx=receive, received=bytearray(), clocked=0) + return "OK" + + def cmd_result(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options, 1, 1, _RESULT_KEYS) + output = _choice(options, "out", ("hex", "crc"), "hex") + wait = _number(options.get("wait", str(SPIS_WAIT_DEFAULT_MS)), 0, SPIS_WAIT_MAX_MS) + if output == "hex" and state["rx"] > SPIS_HEX_MAX: + _fail("range") + if state["done"]: + received = bytes(state["received"]) + if output == "crc": + return f"OK done=1 len={len(received)} crc={crc_text(received)}" + return f"OK done=1 rx={received.hex()}" + if state["armed"]: + self.fw.sleep(wait / 1000) + return "OK done=0" + + def cmd_cancel(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options, 1, 1) + cancelled = state["armed"] + state.update(armed=False, done=False) + return f"OK cancelled={int(cancelled)}" + + def cmd_close(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + key = self._key(args) + self.fw.find_instance(self.prefix, key) + return self.fw.close_instance(self.prefix, key) + + def clock(self, index: int, mosi: bytes) -> bytes | None: + """A master clocks `mosi` into the `spis` instance `index`: its MISO bytes, or None when it is not open.""" + state = self.fw.opened.get((self.prefix, str(index))) + if state is None: + return None + miso = bytearray() + for byte in mosi: + if not state["armed"]: + miso.append(self.fw.spi_miso) + continue + position = state["clocked"] + length = max(len(state["tx"]), state["rx"]) + miso.append(state["tx"][position] if state["tx"] else 0x00) + if position < state["rx"]: + state["received"].append(byte) + state["clocked"] = position + 1 + if state["clocked"] == length: + state.update(armed=False, done=True) + return bytes(miso) + + +class FakeSpiLoop(FakeGroup): + """`spi.xfer` of the SPI master with the loop to `spis`; the checks are those of EMIL's `HilSpiCommands`.""" + + prefix = "spi" + + def loop(self) -> dict[int, int]: + """`FakeFirmware.spi_loop`, or the two SPI instances of the MCU wired to each other.""" + loop = getattr(self.fw, "spi_loop", None) + if loop is not None: + return dict(loop) + first, second = sorted(self.fw.spec.spis) + return {first: second, second: first} + + def cmd_xfer(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 2, ("rx", "continue")) + index = _number(args[0], 0, _INSTANCES - 1) + self.fw.find_instance("spi", str(index)) + data = _hex(args[1], _MASTER_CAPACITY) + size = _number(options.get("rx", str(len(data))), 0, _MASTER_CAPACITY) + _flag(options, "continue") + length = max(len(data), size) + if length == 0: + _fail("usage") + target = self.loop().get(index) + slave = self.fw.group(FakeSpiSlave.prefix) + miso = None + if target is not None and isinstance(slave, FakeSpiSlave): + miso = slave.clock(target, data + bytes(length - len(data))) + if miso is None: + miso = bytes([self.fw.spi_miso]) * length + return f"OK rx={miso[:size].hex() or '-'}" diff --git a/validation/host/hal_st_validation/groups/spis.py b/validation/host/hal_st_validation/groups/spis.py new file mode 100644 index 00000000..ed72b133 --- /dev/null +++ b/validation/host/hal_st_validation/groups/spis.py @@ -0,0 +1,85 @@ +"""SPI slave group of validation/PROTOCOL.md: `spis` (`hal::SpiSlaveStmDma`, validation/firmware/SpiSlaveGroup.cpp). + +`spis.arm` hands the driver one transfer and answers at once; `spis.result` answers when that transfer is done, or +`done=0` after `wait` ms (default 1000) with the transfer still armed. A second `spis.result` while one waits +answers `ERR busy`; `spis.cancel` and `spis.close` answer a waiting `spis.result` with `done=0` first. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from ad3_waveforms_bench.protocol import Response +from ad3_waveforms_bench.terminal import PendingCommand + +from ..patterns import Pattern +from .base import Group, Pin + +__all__ = ["GROUPS", "SPIS_BUFFER", "SPIS_HEX_MAX", "SPIS_WAIT_DEFAULT_MS", "SPIS_WAIT_MAX_MS", "SpiSlave", "SpisResult"] + +SPIS_BUFFER = 1024 +SPIS_HEX_MAX = 128 +SPIS_WAIT_DEFAULT_MS = 1000 +SPIS_WAIT_MAX_MS = 10000 + +Output = Literal["hex", "crc"] + + +@dataclass(frozen=True) +class SpisResult: + """`done` with the received bytes (`rx`, hex output) or their `length` and `crc` (`out=crc`); not done, none.""" + + done: bool + rx: bytes | None = None + length: int | None = None + crc: str | None = None + + @classmethod + def parse(cls, response: Response) -> SpisResult: + if not response.as_bool("done"): + return cls(False) + if "crc" in response: + return cls(True, length=response.as_int("len"), crc=response["crc"]) + return cls(True, rx=response.as_bytes("rx")) + + +class SpiSlave(Group): + prefix = "spis" + + def _timeout(self, wait: int | None, cmd_timeout: float | None) -> float: + return cmd_timeout or self._fw.terminal.timeout + (SPIS_WAIT_DEFAULT_MS if wait is None else wait) / 1000 + + def open(self, index: int, clk: Pin, miso: Pin, mosi: Pin, nss: Pin) -> None: + self._cmd("open", index, clk=self._pin(clk), miso=self._pin(miso), mosi=self._pin(mosi), nss=self._pin(nss)) + self._fw.track((self.prefix, index), f"{self.prefix}.close", index) + + def arm( + self, + index: int, + tx: bytes = b"", + rx: int | None = None, + len: int | None = None, + pattern: Pattern | None = None, + seed: int | None = None, + ) -> None: + """Full duplex: `tx` (or `len=`) with `rx` left out or equal; send only: `rx=0`; receive only: no `tx`, `rx=n`.""" + self._cmd("arm", index, bytes(tx), rx=rx, len=len, pattern=pattern, seed=seed) + + def result(self, index: int, wait: int | None = None, out: Output | None = None, cmd_timeout: float | None = None) -> SpisResult: + return SpisResult.parse(self._cmd("result", index, wait=wait, out=out, cmd_timeout=self._timeout(wait, cmd_timeout))) + + def begin_result(self, index: int, wait: int | None = None, out: Output | None = None) -> PendingCommand: + """`spis.result` without waiting for its final line (`SpisResult.parse(settle(pending))`).""" + return self.begin("result", index, wait=wait, out=out, cmd_timeout=self._timeout(wait, None)) + + def cancel(self, index: int) -> bool: + """True when a transfer was still running.""" + return self._cmd("cancel", index).as_bool("cancelled") + + def close(self, index: int) -> None: + self._cmd("close", index) + self._fw.untrack((self.prefix, index)) + + +GROUPS = {"spis": SpiSlave} diff --git a/validation/host/hal_st_validation/spiwords.py b/validation/host/hal_st_validation/spiwords.py new file mode 100644 index 00000000..75c4954f --- /dev/null +++ b/validation/host/hal_st_validation/spiwords.py @@ -0,0 +1,112 @@ +"""SPI frames of 4 to 16 bits: a logic-capture decoder and the frame model of `hal::SpiMasterStmDma` with a +`hal::SpiDataSizeConfiguratorStm` (`spi.open ... dma=1 bits=`). + +`spi_decode_words` decodes like `ad3_waveforms_bench.analysis.spi_decode` but keeps whole words: the bench decoder +masks every word to 8 bits. + +The frame model (`spi_frames`, `spi_bytes`) follows the driver, not the API's byte view: with up to 8 bits every +buffer byte is one frame (its low `bits` bits); above 8 bits `SpiMasterStmDma::SendAndReceive` hands the DMA the +buffer as 16-bit words (SpiMasterStmDma.cpp:109-129, memory width `sizeof(uint16_t)`, DmaStm.hpp:461), so every +frame takes two buffer bytes, little endian, and a buffer needs an even length (the count is bytes / 2, +DmaStm.cpp:816; GPDMA takes the byte count, DmaStm.cpp:812). Received frames come back right aligned, the bits above +the frame zero. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, field + +from ad3_waveforms_bench.analysis import Bits, runs, spi_mode_bits + +__all__ = ["BITS_MAX", "BITS_MIN", "SpiWords", "spi_bytes", "spi_decode_words", "spi_frames", "spi_join_words"] + +BITS_MIN = 4 +BITS_MAX = 16 +_BYTE_BITS = 8 + + +@dataclass +class SpiWords: + """One chip-select-active region (or the whole capture): its words, as wide as `bits`.""" + + mosi: list[int] = field(default_factory=list) + miso: list[int] = field(default_factory=list) + clock_edges: int = 0 + + +def _check_bits(bits: int) -> None: + if not BITS_MIN <= bits <= BITS_MAX: + raise ValueError(f"frame size {bits} outside {BITS_MIN}..{BITS_MAX} bits") + + +def spi_decode_words( + clk: Bits, + mosi: Bits, + miso: Bits | None = None, + cs: Bits | None = None, + mode: int = 0, + bits: int = 8, + msb_first: bool = True, + cs_active_low: bool = True, +) -> list[SpiWords]: + """Decode `bits`-wide words from sampled lines. Each chip-select-active region is one entry, without `cs` the + whole capture is one; a partial word at the end of a region is dropped.""" + if bits < 1: + raise ValueError(f"word size {bits}") + cpol, cpha = spi_mode_bits(mode) + sample_on_rising = cpol == cpha + active = 0 if cs_active_low else 1 + regions = [(0, len(clk))] if cs is None else [(start, start + length) for level, start, length in runs(cs) if level == active] + result: list[SpiWords] = [] + for start, end in regions: + words = SpiWords() + word_mosi = word_miso = 0 + count = 0 + for i in range(max(start, 1), end): + if clk[i] == clk[i - 1] or bool(clk[i]) != sample_on_rising: + continue + words.clock_edges += 1 + bit_mosi = int(mosi[i]) + bit_miso = int(miso[i]) if miso is not None else 0 + if msb_first: + word_mosi = (word_mosi << 1) | bit_mosi + word_miso = (word_miso << 1) | bit_miso + else: + word_mosi |= bit_mosi << count + word_miso |= bit_miso << count + count += 1 + if count == bits: + words.mosi.append(word_mosi) + words.miso.append(word_miso) + word_mosi = word_miso = 0 + count = 0 + if words.clock_edges: + result.append(words) + return result + + +def spi_join_words(regions: Sequence[SpiWords]) -> tuple[list[int], list[int]]: + """The MOSI and MISO words of every region, in order.""" + return [word for region in regions for word in region.mosi], [word for region in regions for word in region.miso] + + +def spi_frames(data: bytes, bits: int) -> list[int]: + """The frames `SpiMasterStmDma` clocks for the buffer `data` with frames of `bits` bits.""" + _check_bits(bits) + mask = (1 << bits) - 1 + if bits <= _BYTE_BITS: + return [byte & mask for byte in data] + if len(data) % 2: + raise ValueError(f"frames of {bits} bits take two buffer bytes each: {len(data)} bytes") + return [int.from_bytes(data[i : i + 2], "little") & mask for i in range(0, len(data), 2)] + + +def spi_bytes(frames: Sequence[int], bits: int) -> bytes: + """The receive buffer `SpiMasterStmDma` fills with `frames` of `bits` bits (the inverse of `spi_frames` for + the bits a frame carries).""" + _check_bits(bits) + mask = (1 << bits) - 1 + if bits <= _BYTE_BITS: + return bytes(frame & mask for frame in frames) + return b"".join((frame & mask).to_bytes(2, "little") for frame in frames) diff --git a/validation/host/tests/hil/test_spi.py b/validation/host/tests/hil/test_spi.py index 314637b7..cbce3b56 100644 --- a/validation/host/tests/hil/test_spi.py +++ b/validation/host/tests/hil/test_spi.py @@ -1,10 +1,11 @@ """SPI master (`hal::SpiMasterStm`, `SpiMasterStmDma`, `SynchronousSpiMasterStm`), decoded from a logic-analyzer capture. -Wiring set `bundle1`: CLK, CS, MOSI and MISO of `tests.spi.instances` on DIOs. The AD3 SDK has no verified -SPI-slave mode, so the firmware master is observed with the logic analyzer: MISO is driven to a static level by -the AD3, or, with `--with loopback` and a MOSI-MISO jumper, only monitored (the firmware must then read back what -it sent). The clock is the fastest spiclk / 2^n not above `baud` (`expect.spi_clock`). +Wiring set `bundle1`: CLK, CS, MOSI and MISO of `tests.spi.instances` on DIOs (an instance with `option` is +reached only through that option's jumpers, e.g. WB55 SPI2 with `--with spiloop`). The firmware master is +observed with the logic analyzer: MISO is driven to a static level by the AD3, or, where the loopback jumper ties +the instance's MOSI to its MISO (`--with loopback`), only monitored (the firmware must then read back what it +sent). The clock is the fastest spiclk / 2^n not above `baud` (`expect.spi_clock`). """ from __future__ import annotations @@ -21,10 +22,19 @@ def spi_cfg(board_cfg): return board_cfg.param("spi") -def spi_dios(need, instance): +def spi_dios(need, wiring, instance): + option = instance.get("option") + if option and not wiring.has(option): + pytest.skip(f"{instance['name']} is wired with --with {option} only") return {key: need.dio(instance[key]) for key in ("clk", "cs", "mosi", "miso")} +def looped(wiring, board_cfg, instance): + """The loopback jumper ties this instance's MOSI to its MISO.""" + mosi, miso = (board_cfg.resolve_pin(instance[key]) for key in ("mosi", "miso")) + return miso in wiring.jumpered_to(mosi, {"loopback"}) + + def measured_clock(clk, rate, baud): """Clock frequency over each burst of edges (a gap of more than 1.5 periods separates bursts).""" edges = [edge.index for edge in analysis.edges(clk)] @@ -60,8 +70,8 @@ def expected_rx(payload, loopback, miso_level): return payload if loopback else bytes([0xFF if miso_level else 0x00] * len(payload)) -def prepare_miso(ad3, wiring, dios, miso_level): - loopback = wiring.has("loopback") +def prepare_miso(ad3, wiring, board_cfg, instance, dios, miso_level): + loopback = looped(wiring, board_cfg, instance) if loopback and miso_level: pytest.skip("MISO follows MOSI with the loopback jumper") if not loopback: @@ -70,12 +80,14 @@ def prepare_miso(ad3, wiring, dios, miso_level): @pytest.mark.ad3 +@pytest.mark.uses_option("loopback") +@pytest.mark.uses_option("spiloop") @pytest.mark.board_params("instance", "spi.instances") @pytest.mark.matrix("spi.transfer") @pytest.mark.board_params("miso_level", "spi.miso_levels") def test_transfer(fw, ad3, need, wiring, board_cfg, spi_cfg, instance, mode, baud, variant, cs, miso_level): - dios = spi_dios(need, instance) - loopback = prepare_miso(ad3, wiring, dios, miso_level) + dios = spi_dios(need, wiring, instance) + loopback = prepare_miso(ad3, wiring, board_cfg, instance, dios, miso_level) use_cs = cs == "gpio" open_spi(fw, spi_cfg, instance, variant, use_cs, baud=baud, mode=mode) clock = expect.spi_clock(board_cfg.clock("spi", instance["index"]), baud) @@ -106,11 +118,12 @@ def test_transfer(fw, ad3, need, wiring, board_cfg, spi_cfg, instance, mode, bau @pytest.mark.ad3 +@pytest.mark.uses_option("spiloop") @pytest.mark.board_params("instance", "spi.instances") @pytest.mark.matrix("spi.sessions") -def test_continued_session(fw, ad3, need, spi_cfg, instance, mode, variant): +def test_continued_session(fw, ad3, need, wiring, spi_cfg, instance, mode, variant): """`continue=1` keeps the chip select low for the next `spi.xfer`; the bytes of both arrive in one session.""" - dios = spi_dios(need, instance) + dios = spi_dios(need, wiring, instance) ad3.dio.drive(dios["miso"], 0) baud = spi_cfg["session_baud"] open_spi(fw, spi_cfg, instance, variant, baud=baud, mode=mode) @@ -129,22 +142,26 @@ def test_continued_session(fw, ad3, need, spi_cfg, instance, mode, variant): @pytest.mark.ad3 +@pytest.mark.uses_option("loopback") +@pytest.mark.uses_option("spiloop") @pytest.mark.board_params("instance", "spi.instances") @pytest.mark.matrix("spi.receive_only") -def test_receive_only_first(fw, ad3, need, wiring, spi_cfg, instance, variant): +def test_receive_only_first(fw, ad3, need, wiring, board_cfg, spi_cfg, instance, variant): """A receive-only transfer (`spi.xfer - rx=`) right after `spi.open` clocks out zeros and returns MISO.""" - dios = spi_dios(need, instance) - loopback = prepare_miso(ad3, wiring, dios, 0 if wiring.has("loopback") else 1) + dios = spi_dios(need, wiring, instance) + loopback = prepare_miso(ad3, wiring, board_cfg, instance, dios, 0 if looped(wiring, board_cfg, instance) else 1) open_spi(fw, spi_cfg, instance, variant, baud=1000000) assert fw.spi.xfer(instance["index"], b"", rx=4) == (b"\x00" * 4 if loopback else b"\xff" * 4) @pytest.mark.ad3 +@pytest.mark.uses_option("loopback") +@pytest.mark.uses_option("spiloop") @pytest.mark.board_params("instance", "spi.instances") @pytest.mark.matrix("spi.largest") -def test_largest_transfer(fw, ad3, need, wiring, spi_cfg, instance, variant): - dios = spi_dios(need, instance) - loopback = prepare_miso(ad3, wiring, dios, 0 if wiring.has("loopback") else 1) +def test_largest_transfer(fw, ad3, need, wiring, board_cfg, spi_cfg, instance, variant): + dios = spi_dios(need, wiring, instance) + loopback = prepare_miso(ad3, wiring, board_cfg, instance, dios, 0 if looped(wiring, board_cfg, instance) else 1) open_spi(fw, spi_cfg, instance, variant, baud=1000000) size = spi_cfg["max_transfer"] payload = bytes((i * 11 + 5) & 0xFF for i in range(size)) diff --git a/validation/host/tests/hil/test_spi_ext.py b/validation/host/tests/hil/test_spi_ext.py new file mode 100644 index 00000000..54374ef2 --- /dev/null +++ b/validation/host/tests/hil/test_spi_ext.py @@ -0,0 +1,208 @@ +"""SPI master extensions (PROTOCOL.md "SPI master"): the bit order (`lsb=1`), frame sizes (`bits=`, a +`hal::SpiDataSizeConfiguratorStm` on `hal::SpiMasterStmDma`), the DMA data width after a 16-bit transfer (B.4) and +the hardware slave select (`nss=`). + +The master is observed with the logic analyzer (`tests.spi_ext.instances`; an entry with `option` is reached only +through that option's jumpers). Words are decoded with `spiwords` at the frame size and compared with the driver's +frame model (`spiwords.spi_frames`: frames above 8 bits take two buffer bytes). MISO is a static AD3 level, or +follows MOSI where the loopback jumper ties them (`--with loopback`). `nss=` is a known gap (DESIGN B.14): the +masters initialise SPI_NSS_SOFT, so NSS is never driven and `test_hardware_nss` is an expected failure. +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import expect +from hal_st_validation.spiwords import BITS_MAX, BITS_MIN, spi_bytes, spi_decode_words, spi_frames, spi_join_words + +pytestmark = [pytest.mark.uses_option("loopback"), pytest.mark.uses_option("spiloop")] + +VARIANTS = ["interrupt", "dma", "sync"] +LIMITED_BITS = (8, 16) +DEFAULT_BITS = 8 + + +@pytest.fixture +def ext_cfg(board_cfg): + return board_cfg.param("spi_ext") + + +def skip_without_option(wiring, entry): + option = entry.get("option") + if option and not wiring.has(option): + pytest.skip(f"{entry['name']} is wired with --with {option} only") + + +def looped(wiring, board_cfg, instance): + """MISO tied to MOSI by the loopback jumper.""" + mosi, miso = (board_cfg.resolve_pin(instance[key]) for key in ("mosi", "miso")) + return miso in wiring.jumpered_to(mosi, {"loopback"}) + + +def prepare(ad3, need, wiring, board_cfg, instance, select="cs"): + """The DIOs of the instance; MISO driven high unless the loopback jumper drives it.""" + skip_without_option(wiring, instance) + dios = {key: need.dio(instance[key]) for key in ("clk", "mosi", "miso", select)} + loop = looped(wiring, board_cfg, instance) + if not loop: + ad3.dio.drive(dios["miso"], 1) + return dios, loop + + +def open_master(fw, board_cfg, ext_cfg, instance, variant="dma", select="cs", **options): + driver = board_cfg.param("spi.variants")[variant] + fw.spi.open( + instance["index"], + clk=instance["clk"], + mosi=instance["mosi"], + miso=instance["miso"], + baud=ext_cfg["baud"], + dma=driver["dma"], + sync=driver["sync"], + **{select: instance[select]}, + **options, + ) + return expect.spi_clock(board_cfg.clock("spi", instance["index"]), ext_cfg["baud"]) + + +def capture_transfer(fw, ad3, index, payload, clock, trigger): + """`spi.xfer` of `payload` under the logic analyzer: the reply and the capture.""" + duration = len(payload) * 8 / clock * 4 + 200e-6 + rate = min(ad3.logic.clock_hz, 20 * clock, ad3.logic.buffer_size / duration) + if rate < 4 * clock: + pytest.skip("the transfer does not fit the logic analyzer buffer") + pending = ad3.logic.arm(rate, int(duration * rate), trigger=trigger, pretrigger=0.05) + received = fw.spi.xfer(index, payload) + return received, pending.wait(timeout=2.0) + + +def listed_size(values): + """The frame size is one the instance lists (`bits`).""" + return "instance" not in values or "bits" not in values or values["bits"] in values["instance"]["bits"] + + +def decode(result, dios, bits=DEFAULT_BITS, msb_first=True): + lines = [result.channel(dios[key]) for key in ("clk", "mosi", "miso", "cs")] + return spi_join_words(spi_decode_words(*lines, mode=0, bits=bits, msb_first=msb_first)) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spi_ext.instances") +@pytest.mark.board_params("lsb", "spi_ext.lsb") +@pytest.mark.board_params("variant", values=VARIANTS) +def test_bit_order(fw, ad3, need, wiring, board_cfg, ext_cfg, instance, lsb, variant): + """`lsb=1` sends and receives the least significant bit first (`Config::msbFirst`), on every driver.""" + dios, loop = prepare(ad3, need, wiring, board_cfg, instance) + clock = open_master(fw, board_cfg, ext_cfg, instance, variant, lsb=bool(lsb)) + payload = bytes.fromhex(ext_cfg["payload"]) + received, result = capture_transfer(fw, ad3, instance["index"], payload, clock, (dios["cs"], "falling")) + wanted = payload if loop else b"\xff" * len(payload) + mosi, miso = decode(result, dios, msb_first=not lsb) + assert bytes(mosi) == payload + assert bytes(miso) == wanted + assert received == wanted + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spi_ext.instances") +@pytest.mark.board_params("bits", values=list(range(BITS_MIN, BITS_MAX + 1))) +@pytest.mark.constraint(valid=listed_size) +def test_frame_sizes(fw, ad3, need, wiring, board_cfg, ext_cfg, instance, bits): + """`bits=` clocks n-bit frames (`spiwords.spi_frames`); the received frames come back right aligned.""" + dios, loop = prepare(ad3, need, wiring, board_cfg, instance) + clock = open_master(fw, board_cfg, ext_cfg, instance, bits=bits) + payload = bytes.fromhex(ext_cfg["payload"]) + frames = spi_frames(payload, bits) + received, result = capture_transfer(fw, ad3, instance["index"], payload, clock, (dios["cs"], "falling")) + wanted = frames if loop else [(1 << bits) - 1] * len(frames) + mosi, miso = decode(result, dios, bits) + assert mosi == frames + assert miso == wanted + assert received == spi_bytes(wanted, bits) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spi_ext.instances") +def test_width_restored_after_16_bit(fw, ad3, need, wiring, board_cfg, ext_cfg, instance): + """An 8-bit transfer after a 16-bit one decodes exactly: the DMA data widths go back to bytes (B.4).""" + if 16 not in instance["bits"]: + pytest.skip(f"{instance['name']} has no 16-bit frames") + dios, loop = prepare(ad3, need, wiring, board_cfg, instance) + open_master(fw, board_cfg, ext_cfg, instance, bits=16) + fw.spi.xfer(instance["index"], b"\x34\x12\x78\x56") + fw.spi.close(instance["index"]) + clock = open_master(fw, board_cfg, ext_cfg, instance) + payload = bytes.fromhex(ext_cfg["payload"]) + received, result = capture_transfer(fw, ad3, instance["index"], payload, clock, (dios["cs"], "falling")) + wanted = payload if loop else b"\xff" * len(payload) + mosi, miso = decode(result, dios) + assert bytes(mosi) == payload + assert received == wanted + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spi_ext.instances") +@pytest.mark.board_params("variant", values=VARIANTS) +def test_hardware_nss(fw, ad3, need, wiring, board_cfg, ext_cfg, instance, variant): + """`nss=`: NSS low while the master clocks, high after the transfer. Known gap (B.14): the masters + initialise SPI_NSS_SOFT, so the pin (pulled up by the AD3 here) never goes low.""" + dios, _ = prepare(ad3, need, wiring, board_cfg, instance, select="nss") + ad3.dio.pull(up=[dios["nss"]]) + clock = open_master(fw, board_cfg, ext_cfg, instance, variant, select="nss") + payload = bytes.fromhex(ext_cfg["payload"]) + _, result = capture_transfer(fw, ad3, instance["index"], payload, clock, (dios["clk"], "either")) + nss = result.channel(dios["nss"]) + edges = analysis.edges(result.channel(dios["clk"])) + assert edges, "no clock captured" + assert all(nss[edge.index] == 0 for edge in edges), "NSS must be low while the master clocks" + assert nss[-1] == 1, "NSS must be released after the transfer" + + +@pytest.mark.board_params("instance", "spi_ext.instances") +def test_open_frame_sizes(fw, instance): + """`bits` 4..16 needs `dma=1` (8 is the default and needs nothing); a limited instance (WBA55 SPI3, + `IS_SPI_LIMITED_INSTANCE`) takes 8 and 16 only; anything else is `ERR unsupported`, outside 4..16 `ERR range`.""" + index = instance["index"] + pins = {key: instance[key] for key in ("clk", "mosi", "miso")} + for bits in range(BITS_MIN, BITS_MAX + 1): + accepted = not instance.get("limited") or bits in LIMITED_BITS + try: + fw.spi.open(index, **pins, dma=True, bits=bits) + except FirmwareError as error: + assert error.reason == "unsupported" and not accepted, f"bits={bits}: ERR {error.reason}" + continue + assert accepted, f"bits={bits} accepted on a limited instance" + fw.spi.close(index) + for options, reason in ( + ({"dma": True, "bits": BITS_MIN - 1}, "range"), + ({"dma": True, "bits": BITS_MAX + 1}, "range"), + ({"bits": 16}, "unsupported"), + ({"sync": True, "bits": 16}, "unsupported"), + ({"lsb": 2}, "range"), + ): + with pytest.raises(FirmwareError) as error: + fw.spi.open(index, **pins, **options) + assert error.value.reason == reason, options + fw.spi.open(index, **pins, bits=DEFAULT_BITS, lsb=True) + fw.spi.close(index) + + +@pytest.mark.board_params("instance", "spi_ext.instances") +def test_open_slave_select(fw, instance): + """`nss` must offer the instance's slave select (`ERR pin`) and excludes the GPIO chip select `cs` (`ERR usage`).""" + index = instance["index"] + pins = {key: instance[key] for key in ("clk", "mosi", "miso")} + for options, reason in ( + ({"nss": instance["nss"], "cs": instance["cs"]}, "usage"), + ({"nss": instance["clk"]}, "pin"), + ): + with pytest.raises(FirmwareError) as error: + fw.spi.open(index, **pins, **options) + assert error.value.reason == reason, options + fw.spi.open(index, **pins, nss=instance["nss"], dma=True) + with pytest.raises(FirmwareError) as error: + fw.gpio.cfg(instance["nss"], "out") + assert error.value.reason == "busy", "the open master holds its NSS pin" diff --git a/validation/host/tests/hil/test_spi_slave.py b/validation/host/tests/hil/test_spi_slave.py new file mode 100644 index 00000000..ef19882f --- /dev/null +++ b/validation/host/tests/hil/test_spi_slave.py @@ -0,0 +1,272 @@ +"""SPI slave (`hal::SpiSlaveStmDma`, PROTOCOL.md "SPI slave"), clocked by the AD3 SPI master and, with +`--with spiloop`, by the board's own SPI master on the other instance. + +The AD3 master runs mode 0, MSB first, with its chip select on the slave's NSS (`tests.spis.instances`; an entry +with `option` is reached only through that option's jumpers). Slave and master send different payloads, so each +side checks what the other received; results above 128 bytes are compared by CRC. Every case may run with the +spiloop jumpers fitted: they tie the pins to the other SPI instance, which stays unconfigured. +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.firmware import quiesce, settle +from hal_st_validation.groups.spis import SPIS_HEX_MAX, SpisResult +from hal_st_validation.patterns import crc_text, generate + +pytestmark = pytest.mark.uses_option("spiloop") + +DIRECTIONS = ["duplex", "send", "receive"] +WORD = 4 + + +@pytest.fixture +def spis_cfg(board_cfg): + return board_cfg.param("spis") + + +def skip_without_option(wiring, entry): + option = entry.get("option") + if option and not wiring.has(option): + pytest.skip(f"{entry['name']} is wired with --with {option} only") + + +def open_slave(fw, instance): + fw.spis.open(instance["index"], instance["clk"], instance["miso"], instance["mosi"], instance["nss"]) + return instance["index"] + + +def ad3_master(ad3, need, wiring, instance, freq): + """The AD3 SPI master on the slave's pins.""" + skip_without_option(wiring, instance) + dios = {key: need.dio(instance[key]) for key in ("clk", "miso", "mosi", "nss")} + ad3.spi.configure(dios["clk"], dios["mosi"], dios["miso"], dios["nss"], freq, mode=0) + + +def expect_received(fw, index, data): + if len(data) > SPIS_HEX_MAX: + assert fw.spis.result(index, out="crc") == SpisResult(True, length=len(data), crc=crc_text(data)) + else: + assert fw.spis.result(index) == SpisResult(True, rx=data) + + +def exchange(fw, ad3, index, slave_tx, master_tx): + """One full-duplex transfer of equal lengths, checked on both sides.""" + fw.spis.arm(index, slave_tx) + assert ad3.spi.transfer(master_tx) == slave_tx + expect_received(fw, index, master_tx) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spis.instances") +@pytest.mark.board_params("freq", "spis.freqs") +@pytest.mark.board_params("length", "spis.lengths") +@pytest.mark.board_params("direction", values=DIRECTIONS) +def test_ad3_master(fw, ad3, need, wiring, instance, freq, length, direction): + """Full duplex, send only (`rx=0`) and receive only (`-` with `rx=`); the slave payload is generated in + firmware (`len=`, `pattern=prbs`).""" + ad3_master(ad3, need, wiring, instance, freq) + index = open_slave(fw, instance) + slave_tx = generate(length, "prbs", length) + master_tx = generate(length, "inc", 0x40) + if direction == "receive": + fw.spis.arm(index, rx=length) + else: + fw.spis.arm(index, len=length, pattern="prbs", seed=length, rx=0 if direction == "send" else None) + master_rx = ad3.spi.transfer(master_tx) + if direction != "receive": + assert master_rx == slave_tx + expect_received(fw, index, b"" if direction == "send" else master_tx) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spis.instances") +def test_unarmed_clocks_then_transfer(fw, ad3, need, wiring, spis_cfg, instance): + """Frames clocked while nothing is armed do not lead the next transfer (B.2: SPE off between transfers, the + STM32WB RX FIFO drained before arming).""" + ad3_master(ad3, need, wiring, instance, spis_cfg["freqs"][0]) + index = open_slave(fw, instance) + exchange(fw, ad3, index, b"\x10\x11\x12\x13", b"\xa0\xa1\xa2\xa3") + ad3.spi.transfer(b"\xde\xad\xbe\xef") + exchange(fw, ad3, index, b"\x20\x21\x22\x23", b"\xb0\xb1\xb2\xb3") + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spis.instances") +def test_master_clocks_more_than_armed(fw, ad3, need, wiring, spis_cfg, instance): + """Armed for 4 bytes, clocked for 8: done with the first 4 (B.2); the next transfer is exact.""" + ad3_master(ad3, need, wiring, instance, spis_cfg["freqs"][0]) + index = open_slave(fw, instance) + slave_tx = b"\x31\x32\x33\x34" + master_tx = bytes(range(0xC0, 0xC0 + 2 * WORD)) + fw.spis.arm(index, slave_tx) + assert ad3.spi.transfer(master_tx)[:WORD] == slave_tx + expect_received(fw, index, master_tx[:WORD]) + exchange(fw, ad3, index, b"\x41\x42\x43\x44", b"\xd0\xd1\xd2\xd3") + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spis.instances") +def test_underrun(fw, ad3, need, wiring, spis_cfg, instance): + """Clocks with no transfer armed complete nothing (`done=0` at once); an armed transfer afterwards is exact.""" + ad3_master(ad3, need, wiring, instance, spis_cfg["freqs"][0]) + index = open_slave(fw, instance) + ad3.spi.transfer(b"\x01\x02\x03\x04") + assert fw.spis.result(index) == SpisResult(False) + exchange(fw, ad3, index, b"\x51\x52\x53\x54", b"\xe0\xe1\xe2\xe3") + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spis.instances") +def test_short_transfer_and_cancel(fw, ad3, need, wiring, spis_cfg, instance): + """Armed for 8 bytes, clocked for 4: `done=0` after the wait; `spis.cancel` stops it (`cancelled=1`) and the + next transfer is exact.""" + ad3_master(ad3, need, wiring, instance, spis_cfg["freqs"][0]) + index = open_slave(fw, instance) + slave_tx = bytes(range(0x60, 0x60 + 2 * WORD)) + fw.spis.arm(index, slave_tx) + assert ad3.spi.transfer(b"\x71\x72\x73\x74") == slave_tx[:WORD] + assert fw.spis.result(index, wait=100) == SpisResult(False) + assert fw.spis.cancel(index) is True + assert fw.spis.result(index) == SpisResult(False) + assert fw.spis.cancel(index) is False + exchange(fw, ad3, index, b"\x81\x82\x83\x84", b"\xf0\xf1\xf2\xf3") + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spis.instances") +def test_result_waits_for_the_transfer(fw, ad3, need, wiring, spis_cfg, instance): + """`spis.result` armed and not done answers when the transfer completes.""" + ad3_master(ad3, need, wiring, instance, spis_cfg["freqs"][0]) + index = open_slave(fw, instance) + fw.spis.arm(index, b"\x91\x92") + pending = fw.spis.begin_result(index, wait=5000) + try: + assert ad3.spi.transfer(b"\x93\x94") == b"\x91\x92" + finally: + response = settle(pending) + assert response is not None and SpisResult.parse(response) == SpisResult(True, rx=b"\x93\x94") + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spis.instances") +def test_second_result_while_waiting_is_busy(fw, ad3, need, wiring, spis_cfg, instance): + """A `spis.result` while another waits answers `ERR busy` at once; the waiting one still answers `done=1`.""" + ad3_master(ad3, need, wiring, instance, spis_cfg["freqs"][0]) + index = open_slave(fw, instance) + fw.spis.arm(index, b"\xa1\xa2") + pending = fw.spis.begin_result(index, wait=5000) + fw.terminal.send_nowait(f"spis.result {index}") + busy = settle(pending) + fw.terminal.history.clear() + ad3.spi.transfer(b"\xa3\xa4") + quiesce(fw.terminal, quiet=0.3) + assert busy is not None and busy.reason == "busy" + assert "OK done=1 rx=a3a4" in fw.terminal.history + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spis.instances") +def test_reopen_after_close(fw, ad3, need, wiring, spis_cfg, instance): + ad3_master(ad3, need, wiring, instance, spis_cfg["freqs"][0]) + index = open_slave(fw, instance) + exchange(fw, ad3, index, b"\xb1\xb2\xb3\xb4", b"\x0b\x1b\x2b\x3b") + fw.spis.close(index) + open_slave(fw, instance) + exchange(fw, ad3, index, b"\xc1\xc2\xc3\xc4", b"\x0c\x1c\x2c\x3c") + + +@pytest.mark.requires_option("spiloop") +@pytest.mark.board_params("loop", "spis.loops") +@pytest.mark.board_params("variant", "spis.loop_variants") +@pytest.mark.board_params("length", "spis.loop_lengths") +def test_loop(fw, board_cfg, spis_cfg, loop, variant, length): + """The board's master (`spi.*`, the variant's driver) against the slave on the other instance; the master's + GPIO chip select drives the slave's NSS through the jumpers.""" + master, slave = loop["master"], loop["slave"] + driver = board_cfg.param("spi.variants")[variant] + open_slave(fw, slave) + fw.spi.open( + master["index"], + clk=master["clk"], + mosi=master["mosi"], + miso=master["miso"], + cs=master["cs"], + baud=spis_cfg["loop_baud"], + dma=driver["dma"], + sync=driver["sync"], + ) + slave_tx = generate(length, "prbs", 7) + master_tx = generate(length, "inc", 0x80) + fw.spis.arm(slave["index"], slave_tx) + assert fw.spi.xfer(master["index"], master_tx) == slave_tx + assert fw.spis.result(slave["index"]) == SpisResult(True, rx=master_tx) + + +@pytest.mark.board_params("instance", "spis.instances") +def test_open_errors(fw, instance): + index = instance["index"] + pins = {key: instance[key] for key in ("clk", "miso", "mosi", "nss")} + cases = [ + ({key: pins[key] for key in ("clk", "miso", "mosi")}, "usage"), + ({**pins, "clk": pins["miso"], "miso": pins["clk"]}, "pin"), + ({**pins, "nss": pins["clk"]}, "pin"), + ] + for options, reason in cases: + with pytest.raises(FirmwareError) as error: + fw.command("spis.open", index, **{key: fw.pin(pin) for key, pin in options.items()}) + assert error.value.reason == reason, options + for line in (f"spis.open {index} cs=PA0", f"spis.open {index} extra"): + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == "usage", line + open_slave(fw, instance) + with pytest.raises(FirmwareError) as error: + open_slave(fw, instance) + assert error.value.reason == "busy" + with pytest.raises(FirmwareError) as error: + fw.spi.open(index, clk=pins["clk"], mosi=pins["mosi"], miso=pins["miso"]) + assert error.value.reason == "busy", "the SPI master and slave share the instance" + fw.spis.close(index) + fw.spi.open(index, clk=pins["clk"], mosi=pins["mosi"], miso=pins["miso"]) + fw.spi.close(index) + + +@pytest.mark.board_params("instance", "spis.instances") +def test_arm_and_result_errors(fw, instance): + index = instance["index"] + with pytest.raises(FirmwareError) as error: + fw.spis.arm(index, b"\x01") + assert error.value.reason == "notopen" + open_slave(fw, instance) + for line, reason in ( + (f"spis.arm {index} -", "usage"), + (f"spis.arm {index} - rx=0", "usage"), + (f"spis.arm {index} 0102 rx=1", "usage"), + (f"spis.arm {index} 0102 len=2", "usage"), + (f"spis.arm {index} - seed=1", "usage"), + (f"spis.arm {index} - len=1025", "range"), + (f"spis.arm {index} - rx=1025", "range"), + (f"spis.result {index} out=bin", "usage"), + (f"spis.result {index} wait=10001", "range"), + (f"spis.cancel {index} 1", "usage"), + ): + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line + assert fw.spis.result(index) == SpisResult(False), "nothing armed: done=0 at once" + fw.spis.arm(index, rx=SPIS_HEX_MAX + 1) + with pytest.raises(FirmwareError) as error: + fw.spis.result(index, wait=0) + assert error.value.reason == "range", "hex output holds 128 bytes" + assert fw.spis.result(index, wait=0, out="crc") == SpisResult(False) + with pytest.raises(FirmwareError) as error: + fw.spis.arm(index, b"\x01") + assert error.value.reason == "busy" + assert fw.spis.result(index, wait=50, out="crc") == SpisResult(False) + assert fw.spis.cancel(index) is True + assert fw.spis.cancel(index) is False + fw.spis.arm(index, b"\x01\x02", rx=0) + fw.spis.close(index) diff --git a/validation/host/tests/unit/test_config.py b/validation/host/tests/unit/test_config.py index d03a2031..f0fd2c84 100644 --- a/validation/host/tests/unit/test_config.py +++ b/validation/host/tests/unit/test_config.py @@ -129,12 +129,18 @@ def test_wb55_bundle2_moves_the_encoder_inputs(): @pytest.mark.parametrize("name", BOARDS) def test_parameters_reference_wired_pins(name): - """Pins in the test parameters are wired in bundle1 (the LPTIM encoder in bundle2).""" + """Pins in the test parameters are wired in bundle1 (the LPTIM encoder in bundle2); SPI instances are wired in some + set, through the jumpers of their `option` when they name one.""" board = load_board(name) def wired(pin, kind="dio", bundle="bundle1"): return board.wiring([bundle]).channel(kind, board.resolve_pin(pin)) is not None + def wired_in_a_set(pin, option=None): + tags = [option] if option else [] + sets = [set_name for set_name, wiring_set in board.wiring_sets.items() if option is None or option in wiring_set.options] + return any(board.wiring([bundle], tags).channel("dio", board.resolve_pin(pin), allowed=tags) is not None for bundle in sets) + for pin in board.param("gpio.loop_pins") + board.param("gpio.output_pins"): assert wired(pin), pin sharing = board.param("gpio.exti_sharing") @@ -149,7 +155,7 @@ def wired(pin, kind="dio", bundle="bundle1"): for instance in board.param("uart.instances"): assert all(wired(instance[key]) for key in ("tx", "rx", "rts", "cts")), instance for instance in board.param("spi.instances"): - assert all(wired(instance[key]) for key in ("clk", "cs", "mosi", "miso")), instance + assert all(wired_in_a_set(instance[key], instance.get("option")) for key in ("clk", "cs", "mosi", "miso")), instance for instance in board.param("qei.instances"): assert all(wired(instance[key]) for key in ("a", "b", "idx")), instance for instance in board.param("qei.lp_instances"): diff --git a/validation/host/tests/unit/test_fake_spis.py b/validation/host/tests/unit/test_fake_spis.py new file mode 100644 index 00000000..eae2fb45 --- /dev/null +++ b/validation/host/tests/unit/test_fake_spis.py @@ -0,0 +1,335 @@ +"""The fake `spis` group (argument order and reasons of validation/firmware/SpiSlaveGroup.cpp; PROTOCOL.md D.5), the +pins, SPI instance and DMA channels it holds, its arm/result state, the SPI loop through `spi.xfer` and the +`groups.spis` wrappers.""" + +import pytest +from ad3_waveforms_bench.terminal import FirmwareTerminal + +from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS, FakeFirmware, FakeSerial +from hal_st_validation.firmware import Firmware, settle +from hal_st_validation.groups.spis import SpisResult +from hal_st_validation.patterns import crc_text, generate + +# (family, slave instance, its pins, the other instance and its pins) per board +BOARDS = { + "stm32wb55": (2, ("PB13", "PB14", "PB15", "PB12"), 1, ("PA5", "PA6", "PA7", "PA4")), + "stm32wba55": (3, ("PA0", "PB9", "PB8", "PA5"), 1, ("PB4", "PB3", "PA15", "PA12")), +} +SLAVE_DMA = {"stm32wb55": [("dma2", 1), ("dma2", 2)], "stm32wba55": [("dma1", 8), ("dma1", 7)]} + + +class Clock: + def __init__(self) -> None: + self.now = 100.0 + + def __call__(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.now += seconds + + +def make(family="stm32wb55"): + clock = Clock() + fake = FakeFirmware(family=family, clock=clock, sleep=clock.sleep) + terminal = FirmwareTerminal(serial=FakeSerial(fake), timeout=0.5) + pins = WB55_PINS if family == "stm32wb55" else WBA55_PINS + return terminal, fake, clock, Firmware(terminal, pins) + + +def reason(terminal, line): + response = terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +def open_line(index, pins): + clk, miso, mosi, nss = pins + return f"spis.open {index} clk={clk} miso={miso} mosi={mosi} nss={nss}" + + +def open_slave(fw, family, which=0): + index, pins, other, other_pins = BOARDS[family] + if which: + index, pins = other, other_pins + fw.spis.open(index, *pins) + return index + + +def open_master(fw, family, which=1): + index, pins, other, other_pins = BOARDS[family] + if which: + index, pins = other, other_pins + clk, miso, mosi, _ = pins + fw.spi.open(index, clk=clk, mosi=mosi, miso=miso) + return index + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("spis.open", "usage"), + ("spis.open 2 3", "usage"), + ("spis.open 2 cs=PB12", "usage"), + ("spis.open x", "usage"), + ("spis.open 4", "range"), + ("spis.open 0 clk=PB13", "range"), + ("spis.open 3 clk=PB13", "range"), + ("spis.open 2 clk=PZ1 miso=PB14 mosi=PB15 nss=PB12", "pin"), + ("spis.open 2 clk=PB13 miso=PB14 mosi=PB15", "usage"), + ("spis.open 2 clk=PB14 miso=PB13 mosi=PB15 nss=PB12", "pin"), + ("spis.open 2 clk=PB13 miso=PB14 mosi=PB15 nss=PA4", "pin"), + ("spis.open 2 clk=PB13 miso=PB14 mosi=PB15 nss=PB12", "ok"), + ("spis.open 1 clk=spi1clk miso=spi1miso mosi=spi1mosi nss=spi1nss", "ok"), + ("spis.open 2 clk=spi2clk miso=spi2miso mosi=spi2mosi nss=spi2nss", "ok"), + ], +) +def test_open_reasons_wb55(line, expected): + terminal, *_ = make() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("spis.open 2 clk=PA0 miso=PB9 mosi=PB8 nss=PA5", "range"), + ("spis.open 3 clk=PA0 miso=PB9 mosi=PB8 nss=PA12", "pin"), + ("spis.open 3 clk=spi3clk miso=spi3miso mosi=spi3mosi nss=spi3nss", "ok"), + ("spis.open 1 clk=PB4 miso=PB3 mosi=PA15 nss=PA12", "ok"), + ], +) +def test_open_reasons_wba55(line, expected): + terminal, *_ = make("stm32wba55") + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize("family", list(BOARDS)) +def test_open_holds_pins_spi_instance_and_dma_channels(family): + terminal, fake, _, fw = make(family) + index, pins, other, other_pins = BOARDS[family] + fw.spis.open(index, *pins) + owner = ("spis", str(index)) + assert all(fake.resources[key] == owner for key in [("spi", index), *SLAVE_DMA[family]]) + assert fw.open_instances == [("spis", index)] + assert reason(terminal, open_line(other, other_pins)) == "busy" + clk, miso, mosi, _ = pins + assert reason(terminal, f"spi.open {index} clk={clk} mosi={mosi} miso={miso}") == "busy" + assert reason(terminal, f"gpio.cfg {pins[3]} out") == "busy" + fw.close_all() + assert fake.resources == {} + assert reason(terminal, f"spi.open {index} clk={clk} mosi={mosi} miso={miso}") == "ok" + assert reason(terminal, open_line(index, pins)) == "busy" + + +@pytest.mark.parametrize("family", list(BOARDS)) +def test_shared_dma_channel_is_busy(family): + terminal, fake, *_ = make(family) + index, pins, *_ = BOARDS[family] + kind, channel = SLAVE_DMA[family][1] + fake.claim_resource(kind, channel, ("adc", "x")) + assert reason(terminal, open_line(index, pins)) == "busy" + fake.release_resources(("adc", "x")) + assert reason(terminal, open_line(index, pins)) == "ok" + + +@pytest.mark.parametrize( + ("arguments", "expected"), + [ + ("", "usage"), + ("0102 3", "usage"), + ("0102 cs=1", "usage"), + ("-", "usage"), + ("- rx=0", "usage"), + ("0102 rx=3", "usage"), + ("0102 rx=1", "usage"), + ("012", "usage"), + ("0102 len=2", "usage"), + ("- pattern=inc", "usage"), + ("- len=2 pattern=walk", "usage"), + ("- len=0", "range"), + ("- len=1025", "range"), + ("- rx=1025", "range"), + ("0102", "ok"), + ("0102 rx=2", "ok"), + ("0102 rx=0", "ok"), + ("- rx=1024", "ok"), + ("- len=1024 pattern=prbs seed=7", "ok"), + ], +) +def test_arm_reasons(arguments, expected): + terminal, _, _, fw = make() + open_slave(fw, "stm32wb55") + assert reason(terminal, f"spis.arm 2 {arguments}".rstrip()) == expected + + +def test_arm_needs_the_open_instance_and_answers_busy_while_armed(): + terminal, _, _, fw = make() + assert reason(terminal, "spis.arm 2 01") == "notopen" + open_slave(fw, "stm32wb55") + assert reason(terminal, "spis.arm 1 01") == "notopen" + assert reason(terminal, "spis.arm 4 01") == "range" + fw.spis.arm(2, b"\x01") + assert reason(terminal, "spis.arm 2 02") == "busy" + assert reason(terminal, "spis.arm 2 0203 rx=1") == "usage" + + +@pytest.mark.parametrize( + ("arguments", "expected"), + [ + ("", "usage"), + ("2 3", "usage"), + ("2 rx=1", "usage"), + ("2 out=bin", "usage"), + ("2 wait=x", "usage"), + ("2 wait=10001", "range"), + ("1", "notopen"), + ("2 wait=0 out=crc", "ok"), + ], +) +def test_result_reasons(arguments, expected): + terminal, _, _, fw = make() + open_slave(fw, "stm32wb55") + assert reason(terminal, f"spis.result {arguments}".rstrip()) == expected + + +def test_result_waits_only_while_armed(): + _, _, clock, fw = make() + open_slave(fw, "stm32wb55") + start = clock.now + assert fw.spis.result(2) == SpisResult(False) + assert clock.now == start + fw.spis.arm(2, b"\x01\x02") + assert fw.spis.result(2, wait=250) == SpisResult(False) + assert clock.now == pytest.approx(start + 0.25) + assert fw.spis.result(2) == SpisResult(False) + assert clock.now == pytest.approx(start + 1.25) + assert fw.spis.result(2, wait=0) == SpisResult(False) + assert clock.now == pytest.approx(start + 1.25) + + +def test_hex_output_up_to_128_bytes(): + terminal, _, _, fw = make() + open_slave(fw, "stm32wb55") + fw.spis.arm(2, b"", rx=129) + assert reason(terminal, "spis.result 2 wait=0") == "range" + assert reason(terminal, "spis.result 2 wait=0 out=crc") == "ok" + fw.spis.cancel(2) + fw.spis.arm(2, b"", rx=128) + assert reason(terminal, "spis.result 2 wait=0") == "ok" + + +@pytest.mark.parametrize("family", list(BOARDS)) +def test_loop_full_duplex(family): + _, _, _, fw = make(family) + slave = open_slave(fw, family) + master = open_master(fw, family) + fw.spis.arm(slave, b"\x11\x22\x33\x44") + assert fw.spi.xfer(master, b"\xa1\xa2\xa3\xa4") == b"\x11\x22\x33\x44" + assert fw.spis.result(slave) == SpisResult(True, rx=b"\xa1\xa2\xa3\xa4") + assert fw.spis.result(slave, out="crc") == SpisResult(True, length=4, crc=crc_text(b"\xa1\xa2\xa3\xa4")) + assert fw.spis.cancel(slave) is False + + +def test_loop_in_the_other_direction_and_generated_payload(): + _, _, _, fw = make() + slave = open_slave(fw, "stm32wb55", which=1) + master = open_master(fw, "stm32wb55", which=0) + fw.spis.arm(slave, len=64, pattern="prbs", seed=3) + assert fw.spi.xfer(master, bytes(64)) == generate(64, "prbs", 3) + assert fw.spis.result(slave) == SpisResult(True, rx=bytes(64)) + + +def test_loop_send_only_and_receive_only(): + _, fake, _, fw = make() + slave = open_slave(fw, "stm32wb55") + master = open_master(fw, "stm32wb55") + fw.spis.arm(slave, b"\x5a\xa5", rx=0) + assert fw.spi.xfer(master, b"\x01\x02") == b"\x5a\xa5" + assert fw.spis.result(slave) == SpisResult(True, rx=b"") + fw.spis.arm(slave, rx=3) + assert fw.spi.xfer(master, b"\x07\x08\x09", rx=0) == b"" + assert fw.spis.result(slave) == SpisResult(True, rx=b"\x07\x08\x09") + fake.spi_miso = 0xFF + assert fw.spi.xfer(master, b"\x01") == b"\xff", "no transfer armed: MISO is the static level" + + +def test_master_clocks_more_or_less_than_armed(): + _, fake, _, fw = make() + fake.spi_miso = 0xEE + slave = open_slave(fw, "stm32wb55") + master = open_master(fw, "stm32wb55") + fw.spis.arm(slave, b"\x01\x02\x03\x04") + assert fw.spi.xfer(master, b"\x10\x20\x30\x40\x50\x60") == b"\x01\x02\x03\x04\xee\xee" + assert fw.spis.result(slave) == SpisResult(True, rx=b"\x10\x20\x30\x40") + fw.spis.arm(slave, b"\x0a\x0b\x0c\x0d") + assert fw.spi.xfer(master, b"\x11\x12") == b"\x0a\x0b" + assert fw.spis.result(slave, wait=0) == SpisResult(False) + assert fw.spi.xfer(master, b"\x13\x14") == b"\x0c\x0d" + assert fw.spis.result(slave) == SpisResult(True, rx=b"\x11\x12\x13\x14") + + +def test_cancel_and_close(): + terminal, _, _, fw = make() + assert reason(terminal, "spis.cancel 2") == "notopen" + slave = open_slave(fw, "stm32wb55") + master = open_master(fw, "stm32wb55") + assert reason(terminal, "spis.cancel 2 1") == "usage" + assert fw.spis.cancel(slave) is False + fw.spis.arm(slave, b"\x01\x02") + fw.spi.xfer(master, b"\x03") + assert fw.spis.cancel(slave) is True + assert fw.spis.result(slave) == SpisResult(False) + fw.spis.arm(slave, b"\x04") + assert fw.spi.xfer(master, b"\x05") == b"\x04" + fw.spis.close(slave) + assert fw.open_instances == [("spi", master)] + assert reason(terminal, "spis.close 2") == "notopen" + assert fw.spi.xfer(master, b"\x05") == b"\x00" + + +def test_spi_loop_mapping(): + _, fake, _, fw = make() + slave = open_slave(fw, "stm32wb55") + master = open_master(fw, "stm32wb55") + fw.spis.arm(slave, b"\x77") + fake.spi_loop = {} + assert fw.spi.xfer(master, b"\x01") == b"\x00" + fake.spi_loop = {master: slave} + assert fw.spi.xfer(master, b"\x01") == b"\x77" + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("spi.xfer 1", "usage"), + ("spi.xfer 1 00 cs=1", "usage"), + ("spi.xfer 5 00", "range"), + ("spi.xfer 2 00", "notopen"), + ("spi.xfer 1 0", "usage"), + ("spi.xfer 1 -", "usage"), + ("spi.xfer 1 " + "00" * 65, "range"), + ("spi.xfer 1 - rx=65", "range"), + ("spi.xfer 1 00 continue=2", "range"), + ("spi.xfer 1 00 continue=1", "ok"), + ], +) +def test_master_transfer_reasons_are_emil_s(line, expected): + terminal, _, _, fw = make() + open_master(fw, "stm32wb55", which=1) + assert reason(terminal, line) == expected + + +def test_pending_result_wrapper(): + _, _, _, fw = make() + slave = open_slave(fw, "stm32wb55") + fw.spis.arm(slave, b"\x01") + pending = fw.spis.begin_result(slave, wait=0) + assert SpisResult.parse(settle(pending)) == SpisResult(False) + + +def test_reset_forgets_the_slave(): + terminal, fake, _, fw = make() + open_slave(fw, "stm32wb55") + fake.boot() + fw.forget_open() + assert fake.resources == {} + assert reason(terminal, "spis.arm 2 01") == "notopen" diff --git a/validation/host/tests/unit/test_known_gaps.py b/validation/host/tests/unit/test_known_gaps.py index f383a9fe..60a52055 100644 --- a/validation/host/tests/unit/test_known_gaps.py +++ b/validation/host/tests/unit/test_known_gaps.py @@ -161,7 +161,9 @@ def test_run_known_gaps_turns_skips_into_expected_failures(tmp_path): def test_a_board_without_gaps_marks_nothing(tmp_path): - marks = collected_marks(tmp_path, "nucleo_wb55rg", "--port", "nosuchport") + no_gaps = tmp_path / "no_gaps.yaml" + no_gaps.write_text("known_gaps!: []\n", encoding="utf-8") + marks = collected_marks(tmp_path, "nucleo_wb55rg", "--port", "nosuchport", "--board-extra", str(no_gaps)) assert not any("known gap" in reason for found in marks.values() for _, reason in found) diff --git a/validation/host/tests/unit/test_spiwords.py b/validation/host/tests/unit/test_spiwords.py new file mode 100644 index 00000000..9edcbee9 --- /dev/null +++ b/validation/host/tests/unit/test_spiwords.py @@ -0,0 +1,126 @@ +"""`spiwords`: decoding words of 4..16 bits from sampled lines, and the frame model of `SpiMasterStmDma` with a +data size configurator (one frame per byte up to 8 bits, one per little-endian byte pair above).""" + +import pytest +from ad3_waveforms_bench import analysis + +from hal_st_validation.spiwords import BITS_MAX, BITS_MIN, spi_bytes, spi_decode_words, spi_frames, spi_join_words + + +def waveform(regions, bits, mode=0, msb_first=True, gap=3): + """Sampled clk, mosi, miso and cs for `regions` (lists of (mosi word, miso word)): four samples per bit, data + changing on the shift edge, chip select low around each region.""" + cpol, cpha = analysis.spi_mode_bits(mode) + idle, active = cpol, 1 - cpol + clk, mosi, miso, cs = [], [], [], [] + + def emit(count, clock, out, back, select): + clk.extend([clock] * count) + mosi.extend([out] * count) + miso.extend([back] * count) + cs.extend([select] * count) + + emit(gap, idle, 0, 0, 1) + for words in regions: + emit(gap, idle, 0, 0, 0) + for out_word, back_word in words: + order = range(bits - 1, -1, -1) if msb_first else range(bits) + for bit in order: + out, back = (out_word >> bit) & 1, (back_word >> bit) & 1 + first, second = (idle, active) if cpha == 0 else (active, idle) + emit(2, first, out, back, 0) + emit(2, second, out, back, 0) + emit(gap, idle, 0, 0, 0) + emit(gap, idle, 0, 0, 1) + return clk, mosi, miso, cs + + +@pytest.mark.parametrize("mode", [0, 1, 2, 3]) +@pytest.mark.parametrize("bits", [4, 7, 8, 9, 12, 16]) +@pytest.mark.parametrize("msb_first", [True, False]) +def test_decode_round_trip(mode, bits, msb_first): + mask = (1 << bits) - 1 + words = [(0xA5A5 & mask, 0x5A5A & mask), (mask, 0), (1, 1 << (bits - 1)), (0x1234 & mask, 0xFEDC & mask)] + clk, mosi, miso, cs = waveform([words], bits, mode, msb_first) + regions = spi_decode_words(clk, mosi, miso, cs, mode=mode, bits=bits, msb_first=msb_first) + assert len(regions) == 1 + assert regions[0].mosi == [out for out, _ in words] + assert regions[0].miso == [back for _, back in words] + assert regions[0].clock_edges == bits * len(words) + + +def test_words_are_not_masked_to_bytes(): + """The bench decoder keeps 8 bits of every word; `spi_decode_words` keeps all of them.""" + clk, mosi, miso, cs = waveform([[(0xABC, 0x123)]], 12) + assert spi_decode_words(clk, mosi, miso, cs, bits=12)[0].mosi == [0xABC] + assert analysis.spi_decode(clk, mosi, miso, cs, bits_per_word=12)[0].mosi == bytearray([0xBC]) + + +def test_regions_follow_the_chip_select_and_join(): + clk, mosi, miso, cs = waveform([[(1, 2), (3, 4)], [(5, 6)]], 16) + regions = spi_decode_words(clk, mosi, miso, cs, bits=16) + assert [region.mosi for region in regions] == [[1, 3], [5]] + assert spi_join_words(regions) == ([1, 3, 5], [2, 4, 6]) + + +def test_without_chip_select_the_capture_is_one_region_and_a_partial_word_is_dropped(): + clk, mosi, miso, _ = waveform([[(0x9, 0x6), (0xF, 0x0)]], 4) + regions = spi_decode_words(clk, mosi, miso, bits=8) + assert len(regions) == 1 + assert regions[0].mosi == [0x9F] + assert regions[0].clock_edges == 8 + assert spi_decode_words(clk, mosi, miso, bits=12)[0].mosi == [] + + +def test_miso_defaults_to_zero_without_a_line(): + clk, mosi, _, cs = waveform([[(0x81, 0xFF)]], 8) + assert spi_decode_words(clk, mosi, None, cs)[0].miso == [0] + + +def test_decode_rejects_an_empty_word(): + with pytest.raises(ValueError): + spi_decode_words([0, 1], [0, 0], bits=0) + + +@pytest.mark.parametrize( + ("data", "bits", "frames"), + [ + (b"\x12\xff\x00", 8, [0x12, 0xFF, 0x00]), + (b"\x12\xff\x00", 4, [0x2, 0xF, 0x0]), + (b"\xa5", 7, [0x25]), + (b"\x34\x12\xff\xff", 16, [0x1234, 0xFFFF]), + (b"\x34\x12\xff\xff", 12, [0x234, 0xFFF]), + (b"\xff\x01", 9, [0x1FF]), + (b"", 16, []), + ], +) +def test_frames(data, bits, frames): + assert spi_frames(data, bits) == frames + + +@pytest.mark.parametrize("bits", [9, 12, 16]) +def test_wide_frames_take_byte_pairs(bits): + with pytest.raises(ValueError): + spi_frames(b"\x01\x02\x03", bits) + + +@pytest.mark.parametrize("bits", [BITS_MIN - 1, BITS_MAX + 1]) +def test_frame_sizes_outside_the_driver_range(bits): + with pytest.raises(ValueError): + spi_frames(b"\x00\x00", bits) + with pytest.raises(ValueError): + spi_bytes([0], bits) + + +@pytest.mark.parametrize("bits", range(BITS_MIN, BITS_MAX + 1)) +def test_bytes_invert_frames(bits): + data = bytes((i * 37 + 11) & 0xFF for i in range(8)) + frames = spi_frames(data, bits) + assert spi_frames(spi_bytes(frames, bits), bits) == frames + assert len(spi_bytes(frames, bits)) == len(data) + + +def test_received_frames_are_right_aligned(): + assert spi_bytes([0xF, 0x3], 4) == b"\x0f\x03" + assert spi_bytes([0xFFF], 12) == b"\xff\x0f" + assert spi_bytes([0x1FFFF], 16) == b"\xff\xff" From ff44dcc315650d5f6d30ac4abfd388cbebf1a863 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:35:09 +0000 Subject: [PATCH 41/57] feat(validation): validate TimerStm, TimerPwmStm, the LPTIM drivers and the WBA55 LPTIM encoder Add the tim, tpwm, lptim and lptpwm command groups (PROTOCOL.md "Timer", "Timer PWM", "Low-power timer", "LPTIM PWM"): - tim builds FreeRunningTimerStm (irq=none, mode=up|down) or TimerWithInterruptStm (immediate or dispatched), toggles a marker pin per update and reports the counter register and the update count. - tpwm builds TimerPwmWithChannels<1..4> over ChannelPins, with DummyPinStm for an unused `-` channel; duty, pulse and start/stop of one channel or all. - lptim builds FreeRunningLowPowerTimerStm or LowPowerTimerWithInterruptStm with every prescaler and, on the WBA LPTIM, the repetition counter. - lptpwm builds LpTimerPwmWithChannels<1|2> (STM32WBA55; LpTimerPwmStm is not built for STM32WB, where the names answer ERR unsupported). Each group is a factory (argument checks in the protocol order, driver state) plus a HilSingleInstanceGroup. tim and tpwm hold their timer in TimerAllocation; lptim, lptpwm and the LPTIM encoder hold their LPTIM in ResourceAllocation. Groups whose driver schedules dispatched callbacks destroy it one event-loop turn after stopping it, and the PWM groups track which channels run because HAL_TIM_PWM_Start and HAL_LPTIM_PWM_Start refuse a running channel. The encoder takes lp=1 on both LPTIMs of the WBA55 once the LPTIM is in its peripheral table, and cap=rise|fall select the x2 LPTIM decode modes; cap=a|b stay unsupported with lp=1. Host: groups/timers.py (fw.tim, fw.tpwm, fw.lptim, fw.lptpwm and the expected update rates and duties), fakes/timers.py, test_timer.py, test_timer_pwm.py, test_lptim.py, test_lptim_pwm.py, the LPTIM capture modes and the WBA55 LPTIM encoders in test_qei.py, and the board YAML for them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- validation/PROTOCOL.md | 47 ++- validation/README.md | 12 +- validation/firmware/CMakeLists.txt | 8 + validation/firmware/LpTimerGroup.cpp | 209 ++++++++++ validation/firmware/LpTimerGroup.hpp | 72 ++++ validation/firmware/LpTimerPwmGroup.cpp | 194 ++++++++++ validation/firmware/LpTimerPwmGroup.hpp | 73 ++++ validation/firmware/Main.cpp | 19 +- validation/firmware/QeiFactory.cpp | 63 ++- validation/firmware/QeiFactory.hpp | 13 +- validation/firmware/TimerGroup.cpp | 303 +++++++++++++++ validation/firmware/TimerGroup.hpp | 146 +++++++ validation/firmware/TimerPwmGroup.cpp | 366 ++++++++++++++++++ validation/firmware/TimerPwmGroup.hpp | 213 ++++++++++ validation/firmware/UnsupportedGroups.cpp | 22 ++ validation/host/boards/nucleo_wb55rg.yaml | 119 ++++++ validation/host/boards/nucleo_wba55cg.yaml | 169 +++++++- .../host/hal_st_validation/fakes/timers.py | 251 ++++++++++++ .../host/hal_st_validation/groups/timers.py | 177 +++++++++ validation/host/tests/hil/test_lptim.py | 211 ++++++++++ validation/host/tests/hil/test_lptim_pwm.py | 145 +++++++ validation/host/tests/hil/test_qei.py | 37 +- validation/host/tests/hil/test_timer.py | 201 ++++++++++ validation/host/tests/hil/test_timer_pwm.py | 255 ++++++++++++ .../host/tests/unit/test_fake_timers.py | 274 +++++++++++++ 25 files changed, 3570 insertions(+), 29 deletions(-) create mode 100644 validation/firmware/LpTimerGroup.cpp create mode 100644 validation/firmware/LpTimerGroup.hpp create mode 100644 validation/firmware/LpTimerPwmGroup.cpp create mode 100644 validation/firmware/LpTimerPwmGroup.hpp create mode 100644 validation/firmware/TimerGroup.cpp create mode 100644 validation/firmware/TimerGroup.hpp create mode 100644 validation/firmware/TimerPwmGroup.cpp create mode 100644 validation/firmware/TimerPwmGroup.hpp create mode 100644 validation/host/hal_st_validation/fakes/timers.py create mode 100644 validation/host/hal_st_validation/groups/timers.py create mode 100644 validation/host/tests/hil/test_lptim.py create mode 100644 validation/host/tests/hil/test_lptim_pwm.py create mode 100644 validation/host/tests/hil/test_timer.py create mode 100644 validation/host/tests/hil/test_timer_pwm.py create mode 100644 validation/host/tests/unit/test_fake_timers.py diff --git a/validation/PROTOCOL.md b/validation/PROTOCOL.md index 681c5e9d..fe912568 100644 --- a/validation/PROTOCOL.md +++ b/validation/PROTOCOL.md @@ -177,18 +177,59 @@ The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix ## Quadrature encoder (`hal::SynchronousQuadratureEncoderStm`, `lp=1` selects `hal::SynchronousQuadratureEncoderLpTimStm`) -- `qei.open [lp=0|1] [a=] [b=] [idx=] [res=] [offset=] [inva=0|1] [invb=0|1] [cap=a|b|ab] [filter=<0-15>] [vel=|off]` → `OK` +- `qei.open [lp=0|1] [a=] [b=] [idx=] [res=] [offset=] [inva=0|1] [invb=0|1] [cap=a|b|ab|rise|fall] [filter=<0-15>] [vel=|off]` → `OK` - defaults `res=4096 offset=0 inva=0 invb=0 cap=ab filter=0 vel=1000`; without pins the default encoder takes its default pins, other instances need `a` and `b` - `a` and `b` are channels 1 and 2 of the timer; the timer must offer encoder mode (TIM1, TIM2, TIM3; `ERR unsupported` for TIM16/TIM17) - `res` is the count at which the counter wraps (2 to 65536, up to 4294967295 on TIM2), `offset` the starting count (below `res`, `ERR range` otherwise) - - `cap=ab` counts both edges of both phases, `cap=a` and `cap=b` both edges of one phase + - `cap=ab` counts both edges of both phases, `cap=a` and `cap=b` both edges of one phase; `cap=rise` and `cap=fall` need `lp=1` (`ERR usage` otherwise) - `vel` is the speed sampling period in µs (1-1000000), `off` leaves speed at 0 - `idx` is a plain input read by `qei.index`; it never changes the count - - `lp=1` (LPTIM1, WB55 only, `ERR unsupported` elsewhere) takes `a`/`b` on the LPTIM inputs 1/2 (`lptim1in1`/`lptim1in2`), `res` up to 65536, `inva=1` as the mirrored-mounting reversal and `filter` 0, 2, 4 or 8 (consecutive samples); `cap`, `offset` and `invb` return `ERR unsupported` + - `lp=1` selects the LPTIM encoder: LPTIM1 on STM32WB55 (LPTIM2 has no encoder interface, `ERR range`), LPTIM1 and LPTIM2 on STM32WBA55. It takes `a`/`b` on the LPTIM inputs 1/2 (`lptimin1`/`lptimin2`), `res` up to 65536, `inva=1` as the mirrored-mounting reversal and `filter` 0, 2, 4 or 8 (consecutive samples) + - with `lp=1`, `cap=ab` (default) counts both edges of both inputs, `cap=rise` and `cap=fall` only the rising or falling edges of both inputs (two counts per quadrature cycle); `cap=a`, `cap=b`, `offset` and `invb` return `ERR unsupported` + - the LPTIM of an `lp=1` encoder is held against `lptim` and `lptpwm` (`ERR busy`) - `qei.read ` → `OK pos= dir= speed= res=` (`speed` is in counts per second, `res` is the driver's `Resolution()`) - `qei.index ` → `OK idx=<0|1>`, the level of the index input; `ERR unsupported` when the encoder was opened without `idx` - `qei.close ` → `OK` +## Timer (`hal::FreeRunningTimerStm`, `irq=immediate|dispatched` selects `hal::TimerWithInterruptStm`) + +- `tim.open [prescaler=<0-65535>] [period=] [irq=immediate|dispatched|none] [mode=up|down] [pin=]` → `OK timclk=` + - defaults `prescaler=0 period=999 irq=dispatched mode=up`; `period` is the auto-reload, 1-65535 (1-4294967295 on TIM2); `timclk` is the timer kernel clock + - one update per `period + 1` ticks of `timclk / (prescaler + 1)` + - `irq=none` builds a `FreeRunningTimerStm` without interrupt; `immediate` and `dispatched` build a `TimerWithInterruptStm` whose update callback runs in the interrupt or from the event loop. Dispatched callbacks coalesce while one is queued: above about 1 kHz `irqs` counts fewer than the updates + - `pin` (any free bonded pin) is driven low and toggled by every update callback, so it runs at half the update rate; `pin` with `irq=none` returns `ERR usage` + - `mode=down` counts down from `period`; it needs `irq=none` and a timer with a counter mode select (TIM1, TIM2, TIM3), `ERR unsupported` otherwise +- `tim.start `, `tim.stop ` → `OK`; starting a running or stopping a stopped timer changes nothing +- `tim.count ` → `OK cnt= irqs=`: the counter register and the update callbacks since `tim.open` +- `tim.close ` → `OK` + +## Timer PWM (`hal::TimerPwmWithChannels`) + +- `tpwm.open pins=[,...] [prescaler=<0-65535>] [period=]` → `OK timclk=` + - N is the number of entries (1-4) and entry n is channel n; `-` leaves a channel unused (a `hal::DummyPinStm`: the channel is configured but drives no pin, and the pin stays free); at least one entry is a pin (`ERR usage`) + - defaults `prescaler=0 period=6399` (10 kHz at 64 MHz); the PWM frequency is `timclk / ((prescaler + 1) (period + 1))`, `period` 1-65535 (1-4294967295 on TIM2) + - a pin that is not channel n of the timer returns `ERR pin`; a channel the timer does not have (CH2-CH4 on TIM16/TIM17) `ERR unsupported` +- `tpwm.duty <0-100>` → `OK`: integer percent, compare value = `period` × duty / 100 (`PwmChannelGpio::SetDuty`, 32-bit arithmetic); the output is high while the counter is below the compare value, so 100 keeps one low counter tick per period +- `tpwm.pulse ` → `OK`: compare value `on` (0 to the counter maximum) and auto-reload `period` (1 to the counter maximum, `PwmChannelGpio::SetPulse`); the period applies to every channel +- `tpwm.start [ch=]`, `tpwm.stop [ch=]` → `OK`: one channel, or every channel through `TimerPwmBaseStm::Start`/`Stop`; starting a running channel changes nothing +- `tpwm.close ` → `OK`; every channel stops first + +## Low-power timer (`hal::FreeRunningLowPowerTimerStm`, `irq=immediate|dispatched` selects `hal::LowPowerTimerWithInterruptStm`) + +- `lptim.open [period=<1-65535>] [prescaler=1|2|4|8|16|32|64|128] [irq=immediate|dispatched|none] [rep=<0-255>] [pin=]` → `OK lptimclk=` + - LPTIM 1-2; defaults `period=999 prescaler=1 irq=dispatched rep=0`; `prescaler` is the clock divider, another number returns `ERR range` + - one update per `period + 1` ticks of `lptimclk / prescaler`, and with `rep` once every `rep + 1` periods; `lptimclk` is the LPTIM kernel clock (PCLK1 on STM32WB55; PCLK7 for LPTIM1 and PCLK1 for LPTIM2 on STM32WBA55) + - `rep` is the repetition counter of the STM32WBA LPTIM (`ERR unsupported` on STM32WB55, whose LPTIM has none) + - `irq` and `pin` as for `tim.open` +- `lptim.start `, `lptim.stop `, `lptim.count ` → `OK cnt= irqs=`, `lptim.close ` as for `tim` + +## LPTIM PWM (`hal::LpTimerPwmWithChannels`, NUCLEO-WBA55CG only) + +- `lptpwm.open pins=[,] [prescaler=1|2|4|8|16|32|64|128] [period=<1-65535>]` → `OK lptimclk=` + - N is the number of entries (1-2), `-` as for `tpwm`; defaults `prescaler=1 period=6399`; the PWM frequency is `lptimclk / prescaler / (period + 1)` + - LPTIM1 channel 1 (PB11) is not bonded out on the UFQFPN48, so LPTIM1 runs as `pins=-,` +- `lptpwm.duty`, `lptpwm.pulse`, `lptpwm.start`, `lptpwm.stop` and `lptpwm.close` as for `tpwm`; the LPTIM takes compare and period writes only while it is enabled, so set them after `lptpwm.start` + ## Watchdog (`hal::WatchDogStm`, the window watchdog) - `wdt.start timeout= [feed=auto|manual] [pin=]` → `OK` diff --git a/validation/README.md b/validation/README.md index 5a36781f..905fbb99 100644 --- a/validation/README.md +++ b/validation/README.md @@ -104,6 +104,7 @@ Hardware tests need `--port`; without it they are skipped. Select the board file pytest validation/host/tests/hil --board nucleo_wb55rg --port /dev/ttyACM0 --wiring-set bundle1 --depth quick pytest validation/host/tests/hil --board nucleo_wb55rg --port /dev/ttyACM0 --wiring-set bundle1 --depth full pytest validation/host/tests/hil/test_qei.py --board nucleo_wb55rg --port /dev/ttyACM0 --wiring-set bundle2 +pytest validation/host/tests/hil/test_qei.py validation/host/tests/hil/test_timer_pwm.py --board nucleo_wba55cg --port /dev/ttyACM0 --wiring-set bundle2 pytest validation/host/tests/hil --board nucleo_wba55cg --port /dev/ttyACM0 --wiring-set bundle1 pytest validation/host/tests/hil --board nucleo_wb55rg --port /dev/ttyACM0 --no-ad3 ``` @@ -465,7 +466,14 @@ SPI1 to SPI3; SPI3 is observed through DIO14, DIO10, DIO11 and DIO15. The PB8 en - `test_spi_slave.py` - `SpiSlaveStmDma` clocked by the AD3 SPI master: full duplex, send-only and receive-only transfers of 1-1024 bytes at several clock rates (CRC beyond 128 bytes), clocks while nothing is armed, a master clocking more or less than armed, `spis.result` waiting and busy, cancel and re-open, argument errors and the instance shared with `spi`; with `--with spiloop` the board's own SPI master (every driver) against the slave on the other instance, both ways. - `test_adc.py` - wavegen DC levels against raw 12-bit codes (checked against the scope when it is wired), sequences of up to 8 conversions, every sampling time, timer-triggered rates and the measure timeout, timer sharing with PWM and the encoder, unsupported trigger timers, argument errors and limits, and ADC pins against GPIO. -- `test_qei.py` - position counts for frequency x cycles x direction x decoding x phase inversion, physically inverted phases restored by `inva`/`invb`, offset and rollover, speed, the index input through `qei.index`, the LPTIM1 encoder on the NUCLEO-WB55RG, default pins, the resolution limits, argument errors, one encoder at a time and timer sharing. +- `test_qei.py` - position counts for frequency x cycles x direction x decoding x phase inversion, physically inverted phases restored by `inva`/`invb`, offset and rollover, speed, the index input through `qei.index`, default pins, the resolution limits, argument errors, one encoder at a time and timer sharing. + - The LPTIM encoders with `cap=ab|rise|fall`: NUCLEO-WB55RG LPTIM1 in bundle2; NUCLEO-WBA55CG LPTIM2 in bundle1 and LPTIM1 in bundle2. +- `test_timer.py` - for `FreeRunningTimerStm` and `TimerWithInterruptStm` on every timer: the update rate on the marker pin (gpio0) with immediate and dispatched callbacks (dispatched at most 1 kHz), interrupt counts against the elapsed time, the free-running counter up and down (`ERR unsupported` down on TIM16/TIM17). + - Also stop and restart, the marker pin claimed while open, argument errors, one timer at a time and the timer shared with pwm, tpwm, the encoder and the timer-triggered ADC. +- `test_timer_pwm.py` - for `TimerPwmWithChannels`: frequency and duty 0/50/100 % of every wired channel (100 % keeps one low counter tick), a duty per channel, `SetPulse` changing the period of every channel, an unused `-` channel whose pin stays free, starting and stopping one channel or all, argument errors and the timer shared with pwm and tim (NUCLEO-WBA55CG TIM2 with CH3/CH4 in bundle2). +- `test_lptim.py` - for `FreeRunningLowPowerTimerStm` and `LowPowerTimerWithInterruptStm` on LPTIM1 and LPTIM2: the update rate on the marker pin (gpio0) with immediate and dispatched callbacks, every prescaler and the WBA55 repetition counter. + - Also interrupt counts against the elapsed time, the free-running counter, stop, the kernel clock, argument errors and the LPTIM shared with the LPTIM encoder and `lptpwm`. +- `test_lptim_pwm.py` (NUCLEO-WBA55CG) - for `LpTimerPwmWithChannels`: frequency and duty of LPTIM1 CH2 and LPTIM2 CH1/CH2, `SetPulse`, stop and restart, argument errors and one LPTIM PWM at a time. It asserts the requested duty; a bench that measures 1 - duty is recorded as a known gap (DESIGN R14). - `test_watchdog.py` - the window watchdog with timeouts across its range x automatic or manual feeding (warnings, no reset while fed, `reset=wwdg` otherwise), the early-warning period measured on the `pin=` toggle, manual feeding, a single watchdog at a time, the timeout limits and argument errors. - `test_i2c.py` - `I2cStm` on both instances. Without wiring, each instance on its own pins with the MCU pull-ups (`pull=up`): the default and computed TIMINGR (reply and bus timing against the Standard mode minima), address NACK and its recovery in both directions, the zero-length probe, arbitration loss with SDA held before `i2c.open` and pulled low by the AD3 inside the second address bit, and argument errors. @@ -512,6 +520,7 @@ In `hal_st_validation` (hal-st specific): - `patterns.py` - the payloads the firmware generates (`len=`, `pattern=inc|const|prbs`, `seed=`) and their CRC-32 (`crc=`). - `i2c.py` - I2C bus helpers: decoding SCL/SDA captures (`i2c_decode`), SCL timing and rise times, `expected_timing` (the host twin of the firmware's TIMINGR computation), and AD3 captures and SDA pulses started by a START condition; `groups/i2c.py` - the `fw.i2c`, `fw.i2cs` and `fw.eeprom` wrappers; `fakes/i2c.py` - their fake groups with one shared bus, the target and a 24LC256. - `spiwords.py` - SPI words of 4-16 bits: a logic-capture decoder without the bench decoder's 8-bit mask (`spi_decode_words`) and the frame model of `SpiMasterStmDma` with a data size configurator (`spi_frames`, `spi_bytes`); `groups/spis.py` - the `fw.spis` wrapper; `fakes/spis.py` - its fake group and the SPI loop of the fake `spi.xfer` (`FakeFirmware.spi_loop`). +- `groups/timers.py` - `fw.tim`, `fw.tpwm`, `fw.lptim`, `fw.lptpwm` and their expected waveforms (`timer_update_rate`, `lptim_update_rate`, `pwm_duty_fraction`); `fakes/timers.py` is their fake firmware model. - `protocol.py` - the hal-st part of the protocol: error reasons, `P` pins (ports A-K, index 0-15) and the generic alias names (`normalize_pin`, `parse_pin_map`). - `config.py` - board file loading, wiring-set merging, parameter matrices, overrides and known gaps; `expect.py` - expected STM32 values (PWM quantisation and range, SPI prescaler, UART baud-rate register limits, WWDG prescaler and period, ADC codes, encoder counts). - `pairwise.py` - the full product and the deterministic pairwise generator behind `--depth`. @@ -526,3 +535,4 @@ In the firmware (`firmware/`): - One factory per command group (`UartFactory`, `SpiFactory`, `AdcFactory`, `PwmFactory`, `QeiFactory` with the `qei.index` command, `WatchDogFactory`) parses the hal-st options and builds the driver; `UnsupportedGroups` answers the rest. - `I2cGroup` (`i2c.*` over `I2cStmForHil`, an `I2cStm` whose hooks report `EVT i2c`), `I2cTiming` (TIMINGR from kernel clock and bus frequency), `I2cTarget` (`i2cs.*`, the LL I2C target scaffold), `I2cEepromStm` (a `hal::Eeprom` over `hal::I2cMaster` with page writes and ACK polling) and `EepromGroup` (`eeprom.attach`/`eeprom.detach`, the factory of EMIL's `eeprom` group). - `SpiSlaveGroup` (`spis.*`: `SpiSlaveFactory` builds `SpiSlaveStmDma` on the board's slave DMA channels, `SpiSlaveCommands` arms one transfer at a time and answers `spis.result` from its own timer); `SpiFactory` also takes `bits`, `lsb` and `nss` and holds the SPI instance in `ResourceAllocation`. +- `TimerGroup`, `TimerPwmGroup`, `LpTimerGroup` and `LpTimerPwmGroup` serve `tim.*`, `tpwm.*`, `lptim.*` and `lptpwm.*`, each a factory and a single-instance group; `QeiFactory` also builds the LPTIM encoder with `cap=rise|fall` and holds its LPTIM in `ResourceAllocation`. diff --git a/validation/firmware/CMakeLists.txt b/validation/firmware/CMakeLists.txt index a7d6f679..54ba178a 100644 --- a/validation/firmware/CMakeLists.txt +++ b/validation/firmware/CMakeLists.txt @@ -41,6 +41,10 @@ target_sources(hal_st.validation_firmware PRIVATE I2cTarget.hpp I2cTiming.cpp I2cTiming.hpp + LpTimerGroup.cpp + LpTimerGroup.hpp + LpTimerPwmGroup.cpp + LpTimerPwmGroup.hpp Main.cpp Owners.hpp Payload.cpp @@ -62,6 +66,10 @@ target_sources(hal_st.validation_firmware PRIVATE Stopwatch.hpp TimerAllocation.cpp TimerAllocation.hpp + TimerGroup.cpp + TimerGroup.hpp + TimerPwmGroup.cpp + TimerPwmGroup.hpp UartFactory.cpp UartFactory.hpp UnsupportedGroups.cpp diff --git a/validation/firmware/LpTimerGroup.cpp b/validation/firmware/LpTimerGroup.cpp new file mode 100644 index 00000000..f5c881e3 --- /dev/null +++ b/validation/firmware/LpTimerGroup.cpp @@ -0,0 +1,209 @@ +#include "validation/firmware/LpTimerGroup.hpp" +#include "infra/event/EventDispatcher.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include +#include DEVICE_HEADER + +#if defined(HAS_PERIPHERAL_LPTIMER) + +namespace validation +{ + namespace + { + using services::HilStatus; + + constexpr uint32_t maximumPeriod = 0xffff; + constexpr uint32_t maximumPrescaler = 128; + constexpr uint32_t maximumRepetition = 0xff; + + constexpr std::array openKeys{ { "period", "prescaler", "irq", "rep", "pin" } }; + + constexpr std::array prescalers{ { + LPTIM_PRESCALER_DIV1, + LPTIM_PRESCALER_DIV2, + LPTIM_PRESCALER_DIV4, + LPTIM_PRESCALER_DIV8, + LPTIM_PRESCALER_DIV16, + LPTIM_PRESCALER_DIV32, + LPTIM_PRESCALER_DIV64, + LPTIM_PRESCALER_DIV128, + } }; + + constexpr TimerCounterGroupNames names{ "lptim.open", "lptim.start", "lptim.stop", "lptim.count", "lptim.close", " lptimclk=" }; + } + + std::optional LpTimerPrescaler(uint32_t divider) + { + for (std::size_t shift = 0; shift != prescalers.size(); ++shift) + if (divider == (uint32_t{ 1 } << shift)) + return prescalers[shift]; + + return std::nullopt; + } + + LpTimerFactoryStm::LpTimerFactoryStm(const services::HilPinNaming& naming, ResourceAllocation& resources) + : naming(naming) + , resources(resources) + {} + + uint8_t LpTimerFactoryStm::Instances() const + { + return 3; + } + + infra::MemoryRange LpTimerFactoryStm::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus LpTimerFactoryStm::Prepare(uint8_t index, const services::HilArguments& arguments) + { + Request request; + return Evaluate(index, arguments, request); + } + + HilStatus LpTimerFactoryStm::Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) + { + Request request; + HilStatus status = Evaluate(index, arguments, request); + if (status != HilStatus::done) + return status; + + status = resources.Claim(Resource::lpTimer, index, owners::lpTimer); + if (status != HilStatus::done) + return status; + + status = marker.Claim(pins, request.pin); + if (status != HilStatus::done) + { + resources.Release(Resource::lpTimer, index, owners::lpTimer); + return status; + } + + Construct(index, request); + return HilStatus::done; + } + + void LpTimerFactoryStm::Close(uint8_t, const infra::Function& onClosed) + { + this->onClosed = onClosed; + Stop(); + + // A dispatched update may already be queued with a pointer to the driver + infra::EventDispatcher::Instance().Schedule([this]() + { + Destroy(); + }); + } + + uint32_t LpTimerFactoryStm::Clock() const + { + return clock; + } + + void LpTimerFactoryStm::Start() + { + if (running) + return; + + running = true; + if (auto freeRunning = std::get_if(&driver)) + freeRunning->Start(); + else if (auto withInterrupt = std::get_if(&driver)) + withInterrupt->Start([this]() + { + marker.Update(); + }, + irq == TimerIrq::immediate ? hal::InterruptType::immediate : hal::InterruptType::dispatched); + } + + void LpTimerFactoryStm::Stop() + { + if (!running) + return; + + running = false; + services::HilWithDriver(driver, [](auto& alternative) + { + alternative.Stop(); + }); + } + + uint32_t LpTimerFactoryStm::Counter() + { + uint32_t counter = 0; + services::HilWithDriver(driver, [&counter](auto& alternative) + { + counter = HAL_LPTIM_ReadCounter(&alternative.Handle()); + }); + + return counter; + } + + uint32_t LpTimerFactoryStm::Interrupts() const + { + return marker.Updates(); + } + + HilStatus LpTimerFactoryStm::Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const + { + if (!LpTimerExists(index)) + return HilStatus::range; + + HilStatus status = HilStatus::done; + arguments.Number("period", request.period, 1, maximumPeriod, status); + arguments.Number("prescaler", request.prescaler, 1, maximumPrescaler, status); + arguments.Number("rep", request.repetition, 0, maximumRepetition, status); + arguments.Select("irq", request.irq, timerIrqChoices, status); + arguments.Pin("pin", naming, request.pin, status); + if (status != HilStatus::done) + return status; + + if (!LpTimerPrescaler(request.prescaler)) + return HilStatus::range; + + if (request.pin && request.irq == TimerIrq::none) + return HilStatus::usage; + + if (request.pin && !IsBonded(*request.pin)) + return HilStatus::pin; + +#if defined(STM32WB) + if (arguments.Has("rep")) + return HilStatus::unsupported; +#endif + + return HilStatus::done; + } + + void LpTimerFactoryStm::Construct(uint8_t index, const Request& request) + { + this->index = index; + clock = LpTimerClock(index); + irq = request.irq; + running = false; + + const hal::LowPowerTimerBaseStm::Timing timing{ request.period, request.repetition, *LpTimerPrescaler(request.prescaler) }; + if (request.irq == TimerIrq::none) + driver.emplace(index, timing); + else + driver.emplace(index, timing); + } + + void LpTimerFactoryStm::Destroy() + { + driver.emplace(); + marker.Release(); + resources.Release(Resource::lpTimer, index, owners::lpTimer); + onClosed(); + } + + LpTimerGroup::LpTimerGroup(services::HilContext& context, LpTimerFactoryStm& factory) + : TimerCounterGroup(context, factory, owners::lpTimer, names) + {} +} + +#endif diff --git a/validation/firmware/LpTimerGroup.hpp b/validation/firmware/LpTimerGroup.hpp new file mode 100644 index 00000000..d421f909 --- /dev/null +++ b/validation/firmware/LpTimerGroup.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include "hal_st/stm32fxxx/LpTimerStm.hpp" +#include "infra/util/AutoResetFunction.hpp" +#include "services/hil/HilCommand.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include "validation/firmware/TimerGroup.hpp" +#include +#include +#include + +#if defined(HAS_PERIPHERAL_LPTIMER) + +namespace validation +{ + std::optional LpTimerPrescaler(uint32_t divider); + + class LpTimerFactoryStm + : public TimerCounterFactory + { + public: + LpTimerFactoryStm(const services::HilPinNaming& naming, ResourceAllocation& resources); + + uint8_t Instances() const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + void Close(uint8_t index, const infra::Function& onClosed) override; + + services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) override; + uint32_t Clock() const override; + void Start() override; + void Stop() override; + uint32_t Counter() override; + uint32_t Interrupts() const override; + + private: + struct Request + { + uint32_t period = 999; + uint32_t prescaler = 1; + uint32_t repetition = 0; + TimerIrq irq = TimerIrq::dispatched; + std::optional pin; + }; + + services::HilStatus Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const; + void Construct(uint8_t index, const Request& request); + void Destroy(); + + private: + const services::HilPinNaming& naming; + ResourceAllocation& resources; + std::variant driver; + TimerMarker marker; + uint8_t index = 0; + uint32_t clock = 0; + TimerIrq irq = TimerIrq::none; + bool running = false; + infra::AutoResetFunction onClosed; + }; + + class LpTimerGroup + : public TimerCounterGroup + { + public: + LpTimerGroup(services::HilContext& context, LpTimerFactoryStm& factory); + }; +} + +#endif diff --git a/validation/firmware/LpTimerPwmGroup.cpp b/validation/firmware/LpTimerPwmGroup.cpp new file mode 100644 index 00000000..1f8161a6 --- /dev/null +++ b/validation/firmware/LpTimerPwmGroup.cpp @@ -0,0 +1,194 @@ +#include "validation/firmware/LpTimerPwmGroup.hpp" +#include "validation/firmware/LpTimerGroup.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include DEVICE_HEADER + +#if defined(HAS_PERIPHERAL_LPTIMER) && !defined(STM32WB) + +namespace validation +{ + namespace + { + using services::HilStatus; + + constexpr uint32_t maximumCount = 0xffff; + constexpr uint32_t maximumPrescaler = 128; + + constexpr std::array openKeys{ { "pins", "prescaler", "period" } }; + + constexpr std::array channelFunctions{ { + hal::PinConfigTypeStm::lpTimerChannel1, + hal::PinConfigTypeStm::lpTimerChannel2, + } }; + + constexpr std::array lpTimerChannels{ { + LPTIM_CHANNEL_1, + LPTIM_CHANNEL_2, + } }; + + constexpr PwmChannelGroupNames names{ "lptpwm.open", "lptpwm.duty", "lptpwm.pulse", "lptpwm.start", "lptpwm.stop", "lptpwm.close", " lptimclk=" }; + } + + LpTimerPwmFactoryStm::LpTimerPwmFactoryStm(const services::HilPinNaming& naming, ResourceAllocation& resources) + : naming(naming) + , resources(resources) + {} + + uint8_t LpTimerPwmFactoryStm::Instances() const + { + return 3; + } + + infra::MemoryRange LpTimerPwmFactoryStm::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus LpTimerPwmFactoryStm::Prepare(uint8_t index, const services::HilArguments& arguments) + { + Request request; + return Evaluate(index, arguments, request); + } + + HilStatus LpTimerPwmFactoryStm::Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) + { + Request request; + HilStatus status = Evaluate(index, arguments, request); + if (status != HilStatus::done) + return status; + + status = resources.Claim(Resource::lpTimer, index, owners::lpTimerPwm); + if (status != HilStatus::done) + return status; + + status = Claim(index, request, pins); + if (status != HilStatus::done) + { + resources.Release(Resource::lpTimer, index, owners::lpTimerPwm); + return status; + } + + Construct(index, request); + return HilStatus::done; + } + + void LpTimerPwmFactoryStm::Close(uint8_t index, const infra::Function& onClosed) + { + run.Stop(std::nullopt); + driver.emplace(); + base = nullptr; + resources.Release(Resource::lpTimer, index, owners::lpTimerPwm); + onClosed(); + } + + uint32_t LpTimerPwmFactoryStm::Clock() const + { + return clock; + } + + std::size_t LpTimerPwmFactoryStm::Channels() const + { + return channels; + } + + uint32_t LpTimerPwmFactoryStm::MaximumCount() const + { + return maximumCount; + } + + void LpTimerPwmFactoryStm::SetDuty(uint8_t channel, uint8_t percent) + { + base->Channel(channel).SetDuty(percent); + } + + void LpTimerPwmFactoryStm::SetPulse(uint8_t channel, uint32_t pulseOn, uint32_t period) + { + base->Channel(channel).SetPulse(pulseOn, period); + } + + void LpTimerPwmFactoryStm::Start(std::optional channel) + { + run.Start(channel); + } + + void LpTimerPwmFactoryStm::Stop(std::optional channel) + { + run.Stop(channel); + } + + HilStatus LpTimerPwmFactoryStm::Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const + { + if (!LpTimerExists(index)) + return HilStatus::range; + + HilStatus status = HilStatus::done; + arguments.Number("prescaler", request.prescaler, 1, maximumPrescaler, status); + arguments.Number("period", request.period, 1, maximumCount, status); + if (status != HilStatus::done) + return status; + + if (!LpTimerPrescaler(request.prescaler)) + return HilStatus::range; + + status = ParseChannelPins(arguments, naming, infra::MakeRange(request.pins), request.channels); + if (status != HilStatus::done) + return status; + + for (std::size_t position = 0; position != request.channels; ++position) + if (request.pins[position] && !SupportsFunction(*request.pins[position], channelFunctions[position], index)) + return HilStatus::pin; + + for (std::size_t position = 0; position != request.channels; ++position) + if (!IS_LPTIM_CCX_INSTANCE(hal::peripheralLpTimer[index - 1], lpTimerChannels[position])) + return HilStatus::unsupported; + + return HilStatus::done; + } + + HilStatus LpTimerPwmFactoryStm::Claim(uint8_t index, const Request& request, services::HilPinOwner& pins) const + { + HilStatus status = HilStatus::done; + + for (std::size_t position = 0; position != request.channels && status == HilStatus::done; ++position) + { + hal::GpioPin* claimed = nullptr; + status = pins.ClaimFunction(request.pins[position], Function(channelFunctions[position]), index, claimed); + } + + return status; + } + + void LpTimerPwmFactoryStm::Construct(uint8_t index, const Request& request) + { + clock = LpTimerClock(index); + channels = request.channels; + + for (std::size_t position = 0; position != request.channels; ++position) + { + if (request.pins[position]) + channelPins.Emplace(position, PortOf(*request.pins[position]), request.pins[position]->index); + else + channelPins.EmplaceUnused(position); + } + + hal::LowPowerTimerBaseStm::Timing timing{}; + timing.period = request.period; + timing.prescaler = *LpTimerPrescaler(request.prescaler); + const auto pins = channelPins.Range(request.channels); + + if (request.channels == 1) + base = &driver.emplace>(index, timing, pins); + else + base = &driver.emplace>(index, timing, pins); + + run.Attach(*base, request.channels); + } + + LpTimerPwmGroup::LpTimerPwmGroup(services::HilContext& context, LpTimerPwmFactoryStm& factory) + : PwmChannelGroup(context, factory, owners::lpTimerPwm, names) + {} +} + +#endif diff --git a/validation/firmware/LpTimerPwmGroup.hpp b/validation/firmware/LpTimerPwmGroup.hpp new file mode 100644 index 00000000..87e52ae7 --- /dev/null +++ b/validation/firmware/LpTimerPwmGroup.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include "hal_st/stm32fxxx/LpTimerPwmStm.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/ChannelPins.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include "validation/firmware/TimerPwmGroup.hpp" +#include +#include +#include +#include +#include + +#if defined(HAS_PERIPHERAL_LPTIMER) && !defined(STM32WB) + +namespace validation +{ + class LpTimerPwmFactoryStm + : public PwmChannelFactory + { + public: + static constexpr std::size_t maximumChannels = 2; + + LpTimerPwmFactoryStm(const services::HilPinNaming& naming, ResourceAllocation& resources); + + uint8_t Instances() const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + void Close(uint8_t index, const infra::Function& onClosed) override; + + services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) override; + uint32_t Clock() const override; + std::size_t Channels() const override; + uint32_t MaximumCount() const override; + void SetDuty(uint8_t channel, uint8_t percent) override; + void SetPulse(uint8_t channel, uint32_t pulseOn, uint32_t period) override; + void Start(std::optional channel) override; + void Stop(std::optional channel) override; + + private: + struct Request + { + uint32_t prescaler = 1; + uint32_t period = 6399; + std::size_t channels = 0; + std::array, maximumChannels> pins; + }; + + services::HilStatus Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const; + services::HilStatus Claim(uint8_t index, const Request& request, services::HilPinOwner& pins) const; + void Construct(uint8_t index, const Request& request); + + private: + const services::HilPinNaming& naming; + ResourceAllocation& resources; + ChannelPins channelPins; + std::variant, hal::LpTimerPwmWithChannels<2>> driver; + hal::LpTimerPwmBaseStm* base = nullptr; + PwmChannelRun run; + uint32_t clock = 0; + std::size_t channels = 0; + }; + + class LpTimerPwmGroup + : public PwmChannelGroup + { + public: + LpTimerPwmGroup(services::HilContext& context, LpTimerPwmFactoryStm& factory); + }; +} + +#endif diff --git a/validation/firmware/Main.cpp b/validation/firmware/Main.cpp index e576e21b..a4dc0e74 100644 --- a/validation/firmware/Main.cpp +++ b/validation/firmware/Main.cpp @@ -20,6 +20,8 @@ #include "validation/firmware/EepromGroup.hpp" #include "validation/firmware/I2cGroup.hpp" #include "validation/firmware/I2cTarget.hpp" +#include "validation/firmware/LpTimerGroup.hpp" +#include "validation/firmware/LpTimerPwmGroup.hpp" #include "validation/firmware/PinFactoryStm.hpp" #include "validation/firmware/PwmFactory.hpp" #include "validation/firmware/QeiFactory.hpp" @@ -27,6 +29,8 @@ #include "validation/firmware/SpiFactory.hpp" #include "validation/firmware/SpiSlaveGroup.hpp" #include "validation/firmware/TimerAllocation.hpp" +#include "validation/firmware/TimerGroup.hpp" +#include "validation/firmware/TimerPwmGroup.hpp" #include "validation/firmware/UartFactory.hpp" #include "validation/firmware/UnsupportedGroups.hpp" #include "validation/firmware/WatchDogFactory.hpp" @@ -67,7 +71,7 @@ int main() static validation::AdcFactoryStm adcFactory{ naming, console.dma, timers }; static services::HilAdcCommands::WithCapacity adc{ context, adcFactory }; - static validation::QeiFactoryStm qeiFactory{ naming, timers }; + static validation::QeiFactoryStm qeiFactory{ naming, timers, resources }; static services::HilQeiCommands qei{ context, qeiFactory }; static validation::QeiExtensionCommands qeiExtension{ context, qeiFactory }; @@ -84,6 +88,19 @@ int main() static validation::SpiSlaveFactory spiSlaveFactory{ naming, console.dma, resources }; static validation::SpiSlaveCommands spiSlave{ context, spiSlaveFactory }; + static validation::TimerFactoryStm timerFactory{ naming, timers }; + static validation::TimerGroup timer{ context, timerFactory }; + static validation::TimerPwmFactoryStm timerPwmFactory{ naming, timers }; + static validation::TimerPwmGroup timerPwm{ context, timerPwmFactory }; +#if defined(HAS_PERIPHERAL_LPTIMER) + static validation::LpTimerFactoryStm lpTimerFactory{ naming, resources }; + static validation::LpTimerGroup lpTimer{ context, lpTimerFactory }; +#endif +#if defined(HAS_PERIPHERAL_LPTIMER) && !defined(STM32WB) + static validation::LpTimerPwmFactoryStm lpTimerPwmFactory{ naming, resources }; + static validation::LpTimerPwmGroup lpTimerPwm{ context, lpTimerPwmFactory }; +#endif + validation::CreateUnsupportedGroups(context); system.PrintBoot(); diff --git a/validation/firmware/QeiFactory.cpp b/validation/firmware/QeiFactory.cpp index a8552bf2..b04e6fa4 100644 --- a/validation/firmware/QeiFactory.cpp +++ b/validation/firmware/QeiFactory.cpp @@ -13,6 +13,7 @@ namespace validation namespace { using Config = hal::SynchronousQuadratureEncoderStm::Config; + using LowPowerDecode = QeiFactoryStm::LowPowerDecode; using services::HilChoice; using services::HilStatus; @@ -29,6 +30,12 @@ namespace validation { "ab", Config::DecodeMode::x4OnBothPhases }, } }; + constexpr std::array, 3> lowPowerDecodeModes{ { + { "ab", LowPowerDecode::bothEdges }, + { "rise", LowPowerDecode::risingEdges }, + { "fall", LowPowerDecode::fallingEdges }, + } }; + constexpr std::array timerInputs{ { hal::PinConfigTypeStm::timerChannel1, hal::PinConfigTypeStm::timerChannel2 } }; constexpr std::array lowPowerTimerInputs{ { hal::PinConfigTypeStm::lpTimerInput1, hal::PinConfigTypeStm::lpTimerInput2 } }; @@ -69,6 +76,11 @@ namespace validation return samples == 0 || samples == 2 || samples == 4 || samples == 8; } + bool IsSinglePhase(std::optional decodeMode) + { + return decodeMode && (*decodeMode == "a" || *decodeMode == "b"); + } + #if defined(HAS_PERIPHERAL_LPTIMER) hal::SynchronousQuadratureEncoderLpTimStm::Config::Filter LowPowerFilter(uint8_t samples) { @@ -87,10 +99,26 @@ namespace validation } } - hal::SynchronousQuadratureEncoderLpTimStm::Config LowPowerConfig(const Config& config) + hal::SynchronousQuadratureEncoderLpTimStm::Config::DecodeMode LowPowerDecodeMode(LowPowerDecode decode) + { + using DecodeMode = hal::SynchronousQuadratureEncoderLpTimStm::Config::DecodeMode; + + switch (decode) + { + case LowPowerDecode::risingEdges: + return DecodeMode::x2OnRisingEdges; + case LowPowerDecode::fallingEdges: + return DecodeMode::x2OnFallingEdges; + default: + return DecodeMode::x4OnBothEdges; + } + } + + hal::SynchronousQuadratureEncoderLpTimStm::Config LowPowerConfig(const Config& config, LowPowerDecode decode) { hal::SynchronousQuadratureEncoderLpTimStm::Config result; result.resolution = config.resolution; + result.decodeMode = LowPowerDecodeMode(decode); result.filter = LowPowerFilter(config.filter); result.reverseForMirroredMounting = config.invertPhaseA; result.speedSamplePeriod = config.speedSamplePeriod; @@ -108,9 +136,10 @@ namespace validation } } - QeiFactoryStm::QeiFactoryStm(const services::HilPinNaming& naming, TimerAllocation& timers) + QeiFactoryStm::QeiFactoryStm(const services::HilPinNaming& naming, TimerAllocation& timers, ResourceAllocation& resources) : naming(naming) , timers(timers) + , resources(resources) {} uint8_t QeiFactoryStm::Instances() const @@ -136,12 +165,9 @@ namespace validation if (status != HilStatus::done) return status; - if (!request.lowPower) - { - status = timers.Claim(index, services::HilOwners::qei); - if (status != HilStatus::done) - return status; - } + status = ClaimTimer(index, request.lowPower); + if (status != HilStatus::done) + return status; ClaimedPins claimed; status = Claim(index, request, pins, claimed); @@ -207,7 +233,7 @@ namespace validation HilStatus QeiFactoryStm::ParseSettings(uint8_t timer, const services::HilArguments& arguments, Request& request) const { - if (request.lowPower && (arguments.Has("cap") || arguments.Has("offset") || arguments.Has("invb"))) + if (request.lowPower && (arguments.Has("offset") || arguments.Has("invb") || IsSinglePhase(arguments.Key("cap")))) return HilStatus::unsupported; auto& config = request.config; @@ -221,7 +247,10 @@ namespace validation uint32_t filter = 0; arguments.Flag("inva", config.invertPhaseA, status); arguments.Flag("invb", config.invertPhaseB, status); - arguments.Select("cap", config.decodeMode, decodeModes, status); + if (request.lowPower) + arguments.Select("cap", request.lowPowerDecode, lowPowerDecodeModes, status); + else + arguments.Select("cap", config.decodeMode, decodeModes, status); arguments.Number("filter", filter, 0, maximumFilter, status); config.filter = static_cast(filter); @@ -289,15 +318,25 @@ namespace validation { #if defined(HAS_PERIPHERAL_LPTIMER) if (request.lowPower) - return WithIndex(driver.emplace(timer, PinOrDummy(claimed.a), PinOrDummy(claimed.b), PinOrDummy(claimed.index), LowPowerConfig(request.config)), claimed.index); + return WithIndex(driver.emplace(timer, PinOrDummy(claimed.a), PinOrDummy(claimed.b), PinOrDummy(claimed.index), LowPowerConfig(request.config, request.lowPowerDecode)), claimed.index); #endif return WithIndex(driver.emplace(timer, PinOrDummy(claimed.a), PinOrDummy(claimed.b), PinOrDummy(claimed.index), request.config), claimed.index); } + HilStatus QeiFactoryStm::ClaimTimer(uint8_t timer, bool lowPower) + { + if (lowPower) + return resources.Claim(Resource::lpTimer, timer, services::HilOwners::qei); + + return timers.Claim(timer, services::HilOwners::qei); + } + void QeiFactoryStm::ReleaseTimer(uint8_t timer, bool lowPower) { - if (!lowPower) + if (lowPower) + resources.Release(Resource::lpTimer, timer, services::HilOwners::qei); + else timers.Release(timer, services::HilOwners::qei); } diff --git a/validation/firmware/QeiFactory.hpp b/validation/firmware/QeiFactory.hpp index a4b8ad37..fc729ebe 100644 --- a/validation/firmware/QeiFactory.hpp +++ b/validation/firmware/QeiFactory.hpp @@ -6,6 +6,7 @@ #include "services/hil/commands/HilQeiCommands.hpp" #include "services/util/Terminal.hpp" #include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/ResourceAllocation.hpp" #include "validation/firmware/TimerAllocation.hpp" #include #include @@ -18,7 +19,14 @@ namespace validation : public services::HilQeiFactory { public: - QeiFactoryStm(const services::HilPinNaming& naming, TimerAllocation& timers); + enum class LowPowerDecode : uint8_t + { + bothEdges, + risingEdges, + fallingEdges, + }; + + QeiFactoryStm(const services::HilPinNaming& naming, TimerAllocation& timers, ResourceAllocation& resources); uint8_t Instances() const override; infra::MemoryRange OpenKeys() const override; @@ -38,6 +46,7 @@ namespace validation struct Request { bool lowPower = false; + LowPowerDecode lowPowerDecode = LowPowerDecode::bothEdges; std::optional a; std::optional b; std::optional index; @@ -63,11 +72,13 @@ namespace validation services::HilStatus ParsePins(uint8_t timer, const services::HilArguments& arguments, Request& request) const; services::HilStatus Claim(uint8_t timer, const Request& request, services::HilPinOwner& pins, ClaimedPins& claimed) const; hal::SynchronousQuadratureEncoder& Construct(uint8_t timer, const Request& request, const ClaimedPins& claimed); + services::HilStatus ClaimTimer(uint8_t timer, bool lowPower); void ReleaseTimer(uint8_t timer, bool lowPower); private: const services::HilPinNaming& naming; TimerAllocation& timers; + ResourceAllocation& resources; Driver driver; std::optional opened; }; diff --git a/validation/firmware/TimerGroup.cpp b/validation/firmware/TimerGroup.cpp new file mode 100644 index 00000000..01d4123a --- /dev/null +++ b/validation/firmware/TimerGroup.cpp @@ -0,0 +1,303 @@ +#include "validation/firmware/TimerGroup.hpp" +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include "infra/event/EventDispatcher.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + using CounterMode = hal::TimerBaseStm::CounterMode; + using services::HilChoice; + using services::HilStatus; + + constexpr uint32_t maximumPrescaler = 0xffff; + + constexpr std::array openKeys{ { "prescaler", "period", "irq", "mode", "pin" } }; + + constexpr std::array, 2> modeChoices{ { + { "up", CounterMode::up }, + { "down", CounterMode::down }, + } }; + + constexpr TimerCounterGroupNames names{ "tim.open", "tim.start", "tim.stop", "tim.count", "tim.close", " timclk=" }; + + TIM_TypeDef* Instance(uint8_t timer) + { + return hal::peripheralTimer[timer - 1]; + } + + uint32_t MaximumPeriod(uint8_t timer) + { + return IS_TIM_32B_COUNTER_INSTANCE(Instance(timer)) ? 0xffffffffu : 0xffffu; + } + } + + HilStatus TimerMarker::Claim(services::HilPinOwner& pins, const std::optional& id) + { + updates = 0; + pin = nullptr; + if (!id) + return HilStatus::done; + + hal::GpioPin* claimed = nullptr; + HilStatus status = pins.Claim(*id, services::HilPinPool::Use::exclusive, claimed); + if (status != HilStatus::done) + return status; + + claimed->Config(hal::PinConfigType::output, false); + pin = claimed; + return HilStatus::done; + } + + void TimerMarker::Update() + { + if (pin != nullptr) + pin->Set(!pin->GetOutputLatch()); + + updates.fetch_add(1); + } + + uint32_t TimerMarker::Updates() const + { + return updates.load(); + } + + void TimerMarker::Release() + { + if (pin != nullptr) + pin->ResetConfig(); + + pin = nullptr; + } + + TimerCounterGroup::TimerCounterGroup(services::HilContext& context, TimerCounterFactory& factory, services::HilOwner owner, const TimerCounterGroupNames& names) + : services::HilSingleInstanceGroup(context, factory, owner) + , factory(factory) + , clockKey(names.clock) + , commands{ { + OpenCommand(names.open, " [key=value]..."), + services::HilBind(names.start, "", *this, context.response), + services::HilBind(names.stop, "", *this, context.response), + services::HilBind(names.count, "", *this, context.response), + CloseCommand(names.close, ""), + } } + {} + + infra::MemoryRange TimerCounterGroup::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus TimerCounterGroup::OpenInstance(uint8_t index, const services::HilArguments& arguments) + { + return factory.Open(index, arguments, Pins()); + } + + void TimerCounterGroup::Opened(services::HilResponse::Line& line) const + { + line << clockKey << factory.Clock(); + } + + void TimerCounterGroup::CloseInstance() + {} + + HilStatus TimerCounterGroup::Start(const services::HilArguments& arguments) + { + HilStatus status = FindOpened(arguments); + if (status != HilStatus::done) + return status; + + factory.Start(); + Context().response.Ok(); + return HilStatus::done; + } + + HilStatus TimerCounterGroup::Stop(const services::HilArguments& arguments) + { + HilStatus status = FindOpened(arguments); + if (status != HilStatus::done) + return status; + + factory.Stop(); + Context().response.Ok(); + return HilStatus::done; + } + + HilStatus TimerCounterGroup::Count(const services::HilArguments& arguments) + { + HilStatus status = FindOpened(arguments); + if (status != HilStatus::done) + return status; + + Context().response.Ok() << " cnt=" << factory.Counter() << " irqs=" << factory.Interrupts(); + return HilStatus::done; + } + + HilStatus TimerCounterGroup::FindOpened(const services::HilArguments& arguments) const + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + return Instance().Find(arguments); + } + + TimerFactoryStm::TimerFactoryStm(const services::HilPinNaming& naming, TimerAllocation& timers) + : naming(naming) + , timers(timers) + {} + + uint8_t TimerFactoryStm::Instances() const + { + return 18; + } + + infra::MemoryRange TimerFactoryStm::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus TimerFactoryStm::Prepare(uint8_t index, const services::HilArguments& arguments) + { + Request request; + return Evaluate(index, arguments, request); + } + + HilStatus TimerFactoryStm::Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) + { + Request request; + HilStatus status = Evaluate(index, arguments, request); + if (status != HilStatus::done) + return status; + + status = timers.Claim(index, owners::timer); + if (status != HilStatus::done) + return status; + + status = marker.Claim(pins, request.pin); + if (status != HilStatus::done) + { + timers.Release(index, owners::timer); + return status; + } + + Construct(index, request); + return HilStatus::done; + } + + void TimerFactoryStm::Close(uint8_t, const infra::Function& onClosed) + { + this->onClosed = onClosed; + Stop(); + + // A dispatched update may already be queued with a pointer to the driver + infra::EventDispatcher::Instance().Schedule([this]() + { + Destroy(); + }); + } + + uint32_t TimerFactoryStm::Clock() const + { + return clock; + } + + void TimerFactoryStm::Start() + { + if (running) + return; + + running = true; + if (auto freeRunning = std::get_if(&driver)) + freeRunning->Start(); + else if (auto withInterrupt = std::get_if(&driver)) + withInterrupt->Start([this]() + { + marker.Update(); + }, + irq == TimerIrq::immediate ? hal::InterruptType::immediate : hal::InterruptType::dispatched); + } + + void TimerFactoryStm::Stop() + { + if (!running) + return; + + running = false; + services::HilWithDriver(driver, [](auto& alternative) + { + alternative.Stop(); + }); + } + + uint32_t TimerFactoryStm::Counter() + { + uint32_t counter = 0; + services::HilWithDriver(driver, [&counter](auto& alternative) + { + counter = __HAL_TIM_GET_COUNTER(&alternative.Handle()); + }); + + return counter; + } + + uint32_t TimerFactoryStm::Interrupts() const + { + return marker.Updates(); + } + + HilStatus TimerFactoryStm::Evaluate(uint8_t timer, const services::HilArguments& arguments, Request& request) const + { + if (!TimerExists(timer)) + return HilStatus::range; + + HilStatus status = HilStatus::done; + arguments.Number("prescaler", request.prescaler, 0, maximumPrescaler, status); + arguments.Number("period", request.period, 1, MaximumPeriod(timer), status); + arguments.Select("irq", request.irq, timerIrqChoices, status); + arguments.Select("mode", request.mode, modeChoices, status); + arguments.Pin("pin", naming, request.pin, status); + if (status != HilStatus::done) + return status; + + if (request.pin && request.irq == TimerIrq::none) + return HilStatus::usage; + + if (request.pin && !IsBonded(*request.pin)) + return HilStatus::pin; + + if (request.mode == CounterMode::down && (request.irq != TimerIrq::none || !IS_TIM_COUNTER_MODE_SELECT_INSTANCE(Instance(timer)))) + return HilStatus::unsupported; + + return HilStatus::done; + } + + void TimerFactoryStm::Construct(uint8_t timer, const Request& request) + { + this->timer = timer; + clock = TimerClock(timer); + irq = request.irq; + running = false; + + const hal::TimerBaseStm::Timing timing{ request.prescaler, request.period }; + if (request.irq == TimerIrq::none) + driver.emplace(timer, timing, hal::TimerBaseStm::Config{ request.mode, std::nullopt }); + else + driver.emplace(timer, timing); + } + + void TimerFactoryStm::Destroy() + { + driver.emplace(); + marker.Release(); + timers.Release(timer, owners::timer); + onClosed(); + } + + TimerGroup::TimerGroup(services::HilContext& context, TimerFactoryStm& factory) + : TimerCounterGroup(context, factory, owners::timer, names) + {} +} diff --git a/validation/firmware/TimerGroup.hpp b/validation/firmware/TimerGroup.hpp new file mode 100644 index 00000000..620cbdad --- /dev/null +++ b/validation/firmware/TimerGroup.hpp @@ -0,0 +1,146 @@ +#pragma once + +#include "hal_st/stm32fxxx/TimerStm.hpp" +#include "infra/util/AutoResetFunction.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilSingleInstanceGroup.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/TimerAllocation.hpp" +#include +#include +#include +#include +#include + +namespace validation +{ + enum class TimerIrq : uint8_t + { + none, + immediate, + dispatched, + }; + + inline constexpr std::array, 3> timerIrqChoices{ { + { "none", TimerIrq::none }, + { "immediate", TimerIrq::immediate }, + { "dispatched", TimerIrq::dispatched }, + } }; + + class TimerMarker + { + public: + services::HilStatus Claim(services::HilPinOwner& pins, const std::optional& id); + void Update(); + uint32_t Updates() const; + void Release(); + + private: + hal::GpioPin* pin = nullptr; + std::atomic updates{ 0 }; + }; + + class TimerCounterFactory + : public services::HilInstanceFactory + { + protected: + TimerCounterFactory() = default; + TimerCounterFactory(const TimerCounterFactory& other) = delete; + TimerCounterFactory& operator=(const TimerCounterFactory& other) = delete; + ~TimerCounterFactory() = default; + + public: + virtual services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) = 0; + virtual uint32_t Clock() const = 0; + virtual void Start() = 0; + virtual void Stop() = 0; + virtual uint32_t Counter() = 0; + virtual uint32_t Interrupts() const = 0; + }; + + struct TimerCounterGroupNames + { + const char* open; + const char* start; + const char* stop; + const char* count; + const char* close; + const char* clock; + }; + + class TimerCounterGroup + : public services::HilSingleInstanceGroup + { + public: + TimerCounterGroup(services::HilContext& context, TimerCounterFactory& factory, services::HilOwner owner, const TimerCounterGroupNames& names); + + infra::MemoryRange Commands() override; + + protected: + services::HilStatus OpenInstance(uint8_t index, const services::HilArguments& arguments) override; + void Opened(services::HilResponse::Line& line) const override; + void CloseInstance() override; + + private: + services::HilStatus Start(const services::HilArguments& arguments); + services::HilStatus Stop(const services::HilArguments& arguments); + services::HilStatus Count(const services::HilArguments& arguments); + services::HilStatus FindOpened(const services::HilArguments& arguments) const; + + private: + TimerCounterFactory& factory; + const char* clockKey; + std::array commands; + }; + + class TimerFactoryStm + : public TimerCounterFactory + { + public: + TimerFactoryStm(const services::HilPinNaming& naming, TimerAllocation& timers); + + uint8_t Instances() const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + void Close(uint8_t index, const infra::Function& onClosed) override; + + services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) override; + uint32_t Clock() const override; + void Start() override; + void Stop() override; + uint32_t Counter() override; + uint32_t Interrupts() const override; + + private: + struct Request + { + uint32_t prescaler = 0; + uint32_t period = 999; + TimerIrq irq = TimerIrq::dispatched; + hal::TimerBaseStm::CounterMode mode = hal::TimerBaseStm::CounterMode::up; + std::optional pin; + }; + + services::HilStatus Evaluate(uint8_t timer, const services::HilArguments& arguments, Request& request) const; + void Construct(uint8_t timer, const Request& request); + void Destroy(); + + private: + const services::HilPinNaming& naming; + TimerAllocation& timers; + std::variant driver; + TimerMarker marker; + uint8_t timer = 0; + uint32_t clock = 0; + TimerIrq irq = TimerIrq::none; + bool running = false; + infra::AutoResetFunction onClosed; + }; + + class TimerGroup + : public TimerCounterGroup + { + public: + TimerGroup(services::HilContext& context, TimerFactoryStm& factory); + }; +} diff --git a/validation/firmware/TimerPwmGroup.cpp b/validation/firmware/TimerPwmGroup.cpp new file mode 100644 index 00000000..d8250f5d --- /dev/null +++ b/validation/firmware/TimerPwmGroup.cpp @@ -0,0 +1,366 @@ +#include "validation/firmware/TimerPwmGroup.hpp" +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include "infra/util/Tokenizer.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + using services::HilStatus; + + constexpr uint32_t maximumPrescaler = 0xffff; + + constexpr std::array openKeys{ { "pins", "prescaler", "period" } }; + + constexpr std::array channelFunctions{ { + hal::PinConfigTypeStm::timerChannel1, + hal::PinConfigTypeStm::timerChannel2, + hal::PinConfigTypeStm::timerChannel3, + hal::PinConfigTypeStm::timerChannel4, + } }; + + constexpr std::array timerChannels{ { + TIM_CHANNEL_1, + TIM_CHANNEL_2, + TIM_CHANNEL_3, + TIM_CHANNEL_4, + } }; + + constexpr PwmChannelGroupNames names{ "tpwm.open", "tpwm.duty", "tpwm.pulse", "tpwm.start", "tpwm.stop", "tpwm.close", " timclk=" }; + + TIM_TypeDef* Instance(uint8_t timer) + { + return hal::peripheralTimer[timer - 1]; + } + + uint32_t MaximumPeriod(uint8_t timer) + { + return IS_TIM_32B_COUNTER_INSTANCE(Instance(timer)) ? 0xffffffffu : 0xffffu; + } + } + + HilStatus ParseChannelPins(const services::HilArguments& arguments, const services::HilPinNaming& naming, infra::MemoryRange> pins, std::size_t& count) + { + const auto text = arguments.Key("pins"); + if (!text) + return HilStatus::usage; + + infra::Tokenizer tokens(*text, ','); + count = tokens.Size(); + if (count == 0 || count > pins.size()) + return HilStatus::usage; + + bool used = false; + for (std::size_t position = 0; position != count; ++position) + { + const auto token = tokens.Token(position); + pins[position] = std::nullopt; + if (token == "-") + continue; + + pins[position] = services::HilArguments::ParsePin(token, naming); + if (!pins[position]) + return HilStatus::pin; + + used = true; + } + + return used ? HilStatus::done : HilStatus::usage; + } + + PwmChannelGroup::PwmChannelGroup(services::HilContext& context, PwmChannelFactory& factory, services::HilOwner owner, const PwmChannelGroupNames& names) + : services::HilSingleInstanceGroup(context, factory, owner) + , factory(factory) + , clockKey(names.clock) + , commands{ { + OpenCommand(names.open, " pins=[,...] [key=value]..."), + services::HilBind(names.duty, " ", *this, context.response), + services::HilBind(names.pulse, " ", *this, context.response), + services::HilBind(names.start, " [ch=]", *this, context.response), + services::HilBind(names.stop, " [ch=]", *this, context.response), + CloseCommand(names.close, ""), + } } + {} + + infra::MemoryRange PwmChannelGroup::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus PwmChannelGroup::OpenInstance(uint8_t index, const services::HilArguments& arguments) + { + return factory.Open(index, arguments, Pins()); + } + + void PwmChannelGroup::Opened(services::HilResponse::Line& line) const + { + line << clockKey << factory.Clock(); + } + + void PwmChannelGroup::CloseInstance() + {} + + HilStatus PwmChannelGroup::Duty(const services::HilArguments& arguments) + { + if (!arguments.Shape(3, 3, {})) + return HilStatus::usage; + + uint8_t channel = 0; + HilStatus status = FindChannel(arguments, 1, channel); + + uint32_t percent = 0; + arguments.NumberAt(2, percent, 0, 100, status); + if (status != HilStatus::done) + return status; + + factory.SetDuty(channel, static_cast(percent)); + Context().response.Ok(); + return HilStatus::done; + } + + HilStatus PwmChannelGroup::Pulse(const services::HilArguments& arguments) + { + if (!arguments.Shape(4, 4, {})) + return HilStatus::usage; + + uint8_t channel = 0; + HilStatus status = FindChannel(arguments, 1, channel); + + uint32_t pulseOn = 0; + uint32_t period = 0; + if (status == HilStatus::done) + { + arguments.NumberAt(2, pulseOn, 0, factory.MaximumCount(), status); + arguments.NumberAt(3, period, 1, factory.MaximumCount(), status); + } + + if (status != HilStatus::done) + return status; + + factory.SetPulse(channel, pulseOn, period); + Context().response.Ok(); + return HilStatus::done; + } + + HilStatus PwmChannelGroup::Start(const services::HilArguments& arguments) + { + std::optional channel; + HilStatus status = FindChannels(arguments, channel); + if (status != HilStatus::done) + return status; + + factory.Start(channel); + Context().response.Ok(); + return HilStatus::done; + } + + HilStatus PwmChannelGroup::Stop(const services::HilArguments& arguments) + { + std::optional channel; + HilStatus status = FindChannels(arguments, channel); + if (status != HilStatus::done) + return status; + + factory.Stop(channel); + Context().response.Ok(); + return HilStatus::done; + } + + HilStatus PwmChannelGroup::FindChannel(const services::HilArguments& arguments, std::size_t positional, uint8_t& channel) const + { + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done) + return status; + + uint32_t number = 0; + arguments.NumberAt(positional, number, 1, static_cast(factory.Channels()), status); + channel = static_cast(number); + return status; + } + + HilStatus PwmChannelGroup::FindChannels(const services::HilArguments& arguments, std::optional& channel) const + { + static constexpr std::array keys{ { "ch" } }; + if (!arguments.Shape(1, 1, infra::MakeRange(keys))) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done || !arguments.Has("ch")) + return status; + + uint32_t number = 0; + arguments.Number("ch", number, 1, static_cast(factory.Channels()), status); + channel = static_cast(number); + return status; + } + + TimerPwmFactoryStm::TimerPwmFactoryStm(const services::HilPinNaming& naming, TimerAllocation& timers) + : naming(naming) + , timers(timers) + {} + + uint8_t TimerPwmFactoryStm::Instances() const + { + return 18; + } + + infra::MemoryRange TimerPwmFactoryStm::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus TimerPwmFactoryStm::Prepare(uint8_t index, const services::HilArguments& arguments) + { + Request request; + return Evaluate(index, arguments, request); + } + + HilStatus TimerPwmFactoryStm::Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) + { + Request request; + HilStatus status = Evaluate(index, arguments, request); + if (status != HilStatus::done) + return status; + + status = timers.Claim(index, owners::timerPwm); + if (status != HilStatus::done) + return status; + + status = Claim(index, request, pins); + if (status != HilStatus::done) + { + timers.Release(index, owners::timerPwm); + return status; + } + + Construct(index, request); + return HilStatus::done; + } + + void TimerPwmFactoryStm::Close(uint8_t index, const infra::Function& onClosed) + { + run.Stop(std::nullopt); + driver.emplace(); + base = nullptr; + timers.Release(index, owners::timerPwm); + onClosed(); + } + + uint32_t TimerPwmFactoryStm::Clock() const + { + return clock; + } + + std::size_t TimerPwmFactoryStm::Channels() const + { + return channels; + } + + uint32_t TimerPwmFactoryStm::MaximumCount() const + { + return MaximumPeriod(timer); + } + + void TimerPwmFactoryStm::SetDuty(uint8_t channel, uint8_t percent) + { + base->Channel(channel).SetDuty(percent); + } + + void TimerPwmFactoryStm::SetPulse(uint8_t channel, uint32_t pulseOn, uint32_t period) + { + base->Channel(channel).SetPulse(pulseOn, period); + } + + void TimerPwmFactoryStm::Start(std::optional channel) + { + run.Start(channel); + } + + void TimerPwmFactoryStm::Stop(std::optional channel) + { + run.Stop(channel); + } + + HilStatus TimerPwmFactoryStm::Evaluate(uint8_t timer, const services::HilArguments& arguments, Request& request) const + { + if (!TimerExists(timer)) + return HilStatus::range; + + HilStatus status = HilStatus::done; + arguments.Number("prescaler", request.prescaler, 0, maximumPrescaler, status); + arguments.Number("period", request.period, 1, MaximumPeriod(timer), status); + if (status != HilStatus::done) + return status; + + status = ParseChannelPins(arguments, naming, infra::MakeRange(request.pins), request.channels); + if (status != HilStatus::done) + return status; + + for (std::size_t position = 0; position != request.channels; ++position) + if (request.pins[position] && !SupportsFunction(*request.pins[position], channelFunctions[position], timer)) + return HilStatus::pin; + + for (std::size_t position = 0; position != request.channels; ++position) + if (!IS_TIM_CCX_INSTANCE(Instance(timer), timerChannels[position])) + return HilStatus::unsupported; + + return HilStatus::done; + } + + HilStatus TimerPwmFactoryStm::Claim(uint8_t timer, const Request& request, services::HilPinOwner& pins) const + { + HilStatus status = HilStatus::done; + + for (std::size_t position = 0; position != request.channels && status == HilStatus::done; ++position) + { + hal::GpioPin* claimed = nullptr; + status = pins.ClaimFunction(request.pins[position], Function(channelFunctions[position]), timer, claimed); + } + + return status; + } + + void TimerPwmFactoryStm::Construct(uint8_t timer, const Request& request) + { + this->timer = timer; + clock = TimerClock(timer); + channels = request.channels; + + for (std::size_t position = 0; position != request.channels; ++position) + { + if (request.pins[position]) + channelPins.Emplace(position, PortOf(*request.pins[position]), request.pins[position]->index); + else + channelPins.EmplaceUnused(position); + } + + const hal::TimerBaseStm::Timing timing{ request.prescaler, request.period }; + const auto pins = channelPins.Range(request.channels); + + switch (request.channels) + { + case 1: + base = &driver.emplace>(timer, timing, pins); + break; + case 2: + base = &driver.emplace>(timer, timing, pins); + break; + case 3: + base = &driver.emplace>(timer, timing, pins); + break; + default: + base = &driver.emplace>(timer, timing, pins); + break; + } + + run.Attach(*base, request.channels); + } + + TimerPwmGroup::TimerPwmGroup(services::HilContext& context, TimerPwmFactoryStm& factory) + : PwmChannelGroup(context, factory, owners::timerPwm, names) + {} +} diff --git a/validation/firmware/TimerPwmGroup.hpp b/validation/firmware/TimerPwmGroup.hpp new file mode 100644 index 00000000..f23ebfcd --- /dev/null +++ b/validation/firmware/TimerPwmGroup.hpp @@ -0,0 +1,213 @@ +#pragma once + +#include "hal_st/stm32fxxx/TimerPwmStm.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilSingleInstanceGroup.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/ChannelPins.hpp" +#include "validation/firmware/TimerAllocation.hpp" +#include +#include +#include +#include +#include +#include + +namespace validation +{ + services::HilStatus ParseChannelPins(const services::HilArguments& arguments, const services::HilPinNaming& naming, infra::MemoryRange> pins, std::size_t& count); + + // HAL_TIM_PWM_Start and HAL_LPTIM_PWM_Start refuse a channel that already runs, and the drivers assert on that + template + class PwmChannelRun + { + public: + void Attach(Base& base, std::size_t channels); + void Start(std::optional channel); + void Stop(std::optional channel); + + private: + void StartChannel(std::size_t position); + + private: + Base* base = nullptr; + std::size_t channels = 0; + std::array running{}; + bool started = false; + }; + + class PwmChannelFactory + : public services::HilInstanceFactory + { + protected: + PwmChannelFactory() = default; + PwmChannelFactory(const PwmChannelFactory& other) = delete; + PwmChannelFactory& operator=(const PwmChannelFactory& other) = delete; + ~PwmChannelFactory() = default; + + public: + virtual services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) = 0; + virtual uint32_t Clock() const = 0; + virtual std::size_t Channels() const = 0; + virtual uint32_t MaximumCount() const = 0; + virtual void SetDuty(uint8_t channel, uint8_t percent) = 0; + virtual void SetPulse(uint8_t channel, uint32_t pulseOn, uint32_t period) = 0; + virtual void Start(std::optional channel) = 0; + virtual void Stop(std::optional channel) = 0; + }; + + struct PwmChannelGroupNames + { + const char* open; + const char* duty; + const char* pulse; + const char* start; + const char* stop; + const char* close; + const char* clock; + }; + + class PwmChannelGroup + : public services::HilSingleInstanceGroup + { + public: + PwmChannelGroup(services::HilContext& context, PwmChannelFactory& factory, services::HilOwner owner, const PwmChannelGroupNames& names); + + infra::MemoryRange Commands() override; + + protected: + services::HilStatus OpenInstance(uint8_t index, const services::HilArguments& arguments) override; + void Opened(services::HilResponse::Line& line) const override; + void CloseInstance() override; + + private: + services::HilStatus Duty(const services::HilArguments& arguments); + services::HilStatus Pulse(const services::HilArguments& arguments); + services::HilStatus Start(const services::HilArguments& arguments); + services::HilStatus Stop(const services::HilArguments& arguments); + services::HilStatus FindChannel(const services::HilArguments& arguments, std::size_t positional, uint8_t& channel) const; + services::HilStatus FindChannels(const services::HilArguments& arguments, std::optional& channel) const; + + private: + PwmChannelFactory& factory; + const char* clockKey; + std::array commands; + }; + + class TimerPwmFactoryStm + : public PwmChannelFactory + { + public: + static constexpr std::size_t maximumChannels = 4; + + TimerPwmFactoryStm(const services::HilPinNaming& naming, TimerAllocation& timers); + + uint8_t Instances() const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + void Close(uint8_t index, const infra::Function& onClosed) override; + + services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) override; + uint32_t Clock() const override; + std::size_t Channels() const override; + uint32_t MaximumCount() const override; + void SetDuty(uint8_t channel, uint8_t percent) override; + void SetPulse(uint8_t channel, uint32_t pulseOn, uint32_t period) override; + void Start(std::optional channel) override; + void Stop(std::optional channel) override; + + private: + struct Request + { + uint32_t prescaler = 0; + uint32_t period = 6399; + std::size_t channels = 0; + std::array, maximumChannels> pins; + }; + + services::HilStatus Evaluate(uint8_t timer, const services::HilArguments& arguments, Request& request) const; + services::HilStatus Claim(uint8_t timer, const Request& request, services::HilPinOwner& pins) const; + void Construct(uint8_t timer, const Request& request); + + private: + const services::HilPinNaming& naming; + TimerAllocation& timers; + ChannelPins channelPins; + std::variant, hal::TimerPwmWithChannels<2>, hal::TimerPwmWithChannels<3>, hal::TimerPwmWithChannels<4>> driver; + hal::TimerPwmBaseStm* base = nullptr; + PwmChannelRun run; + uint8_t timer = 0; + uint32_t clock = 0; + std::size_t channels = 0; + }; + + class TimerPwmGroup + : public PwmChannelGroup + { + public: + TimerPwmGroup(services::HilContext& context, TimerPwmFactoryStm& factory); + }; + + //// Implementation //// + + template + void PwmChannelRun::Attach(Base& base, std::size_t channels) + { + this->base = &base; + this->channels = channels; + running.fill(false); + started = false; + } + + template + void PwmChannelRun::Start(std::optional channel) + { + if (channel) + { + StartChannel(*channel - 1); + return; + } + + if (!started && std::none_of(running.begin(), running.begin() + channels, [](bool value) + { + return value; + })) + { + base->Start(); + started = true; + std::fill_n(running.begin(), channels, true); + return; + } + + for (std::size_t position = 0; position != channels; ++position) + StartChannel(position); + } + + template + void PwmChannelRun::Stop(std::optional channel) + { + if (!channel) + { + base->Stop(); + started = false; + running.fill(false); + return; + } + + if (running[*channel - 1]) + { + base->Channel(*channel).Stop(); + running[*channel - 1] = false; + } + } + + template + void PwmChannelRun::StartChannel(std::size_t position) + { + if (running[position]) + return; + + base->Channel(static_cast(position + 1)).Start(); + running[position] = true; + } +} diff --git a/validation/firmware/UnsupportedGroups.cpp b/validation/firmware/UnsupportedGroups.cpp index e0e479a1..5b3d56a1 100644 --- a/validation/firmware/UnsupportedGroups.cpp +++ b/validation/firmware/UnsupportedGroups.cpp @@ -6,6 +6,27 @@ namespace validation { namespace { +#if defined(STM32WB) + constexpr std::array commandNames{ { + "comp.open", + "comp.read", + "comp.irq", + "comp.count", + "comp.close", + "can.open", + "can.send", + "can.close", + "eth.open", + "eth.status", + "eth.close", + "lptpwm.open", + "lptpwm.duty", + "lptpwm.pulse", + "lptpwm.start", + "lptpwm.stop", + "lptpwm.close", + } }; +#elif defined(STM32WBA) constexpr std::array commandNames{ { "comp.open", "comp.read", @@ -19,6 +40,7 @@ namespace validation "eth.status", "eth.close", } }; +#endif } void CreateUnsupportedGroups(services::HilContext& context) diff --git a/validation/host/boards/nucleo_wb55rg.yaml b/validation/host/boards/nucleo_wb55rg.yaml index e858c437..df572def 100644 --- a/validation/host/boards/nucleo_wb55rg.yaml +++ b/validation/host/boards/nucleo_wb55rg.yaml @@ -20,6 +20,10 @@ clocks: uart: usart1: 64000000 lpuart1: 64000000 + # LPTIM1SEL/LPTIM2SEL reset value: PCLK1. + lptim: + 1: 64000000 + 2: 64000000 terminal: baud: 921600 @@ -724,12 +728,17 @@ tests: ab: 4 a: 2 b: 2 + # LPTIM capture modes: both edges of both inputs (x4) or the rising or + # falling edges only (x2). + rise: 2 + fall: 2 lp_position: freq: [100, 1000, 10000] cycles: [1, 10, 250] direction: [fwd, rev] inva: [0, 1] filter: [0, 2, 4, 8] + cap: [ab, rise, fall] velocity: freq: [1000, 10000] vel: [1000, 10000] @@ -895,6 +904,116 @@ tests: lsb: [0, 1] baud: 1000000 payload: "a53c0f8001fe7e42" + timer: + # bundle1: the marker (gpio0, DIO11) toggles once per update interrupt. + timers: + - name: tim1 + timer: 1 + - name: tim2 + timer: 2 + - name: tim16 + timer: 16 + - name: tim17 + timer: 17 + marker: gpio0 + # TIM numbers the MCU lacks: ERR range. + missing: [3, 15, 18] + # No counter mode select (up counting only): mode=down is ERR unsupported. + up_only: [16, 17] + # Update rate timclk / ((prescaler + 1) (period + 1)); `dispatched: + # false` marks rates above 1 kHz, where dispatched callbacks coalesce. + interrupt: + irq: [immediate, dispatched] + update: + - name: 100hz + prescaler: 639 + period: 999 + - name: 1khz + prescaler: 63 + period: 999 + - name: 20khz + prescaler: 0 + period: 3199 + dispatched: false + # Free running (irq=none): a 1 kHz counter that wraps after 65.5 s. + free_running: + prescaler: 63999 + period: 65535 + window_s: 0.5 + record_periods: 10 + tolerance: + frequency: 0.01 + count: 0.05 + latency_s: 0.02 + timer_pwm: + # Every channel pin of bundle1 in channel order (null = `-`); TIM1 CH4 + # (PA11) is on no header of a stock board, so TIM1 runs N = 3. + timers: + - name: tim1 + timer: 1 + pins: [tim1ch1, tim1ch2, tim1ch3] + - name: tim2 + timer: 2 + pins: [tim2ch1, tim2ch2, tim2ch3, tim2ch4] + - name: tim16 + timer: 16 + pins: [tim16ch1] + - name: tim17 + timer: 17 + pins: [tim17ch1] + # 1 MHz counter (64 MHz / 64), 10 kHz period: the low tick of 100 % + # lasts 1 us. + prescaler: 63 + period: 99 + duties: [0, 50, 100] + channel_duties: [20, 40, 60, 80] + # tpwm.pulse 1 + pulse: + ccr: 30 + period: 199 + # A pin without a channel function of any timer under test. + foreign_pin: gpio0 + record_periods: 20 + tolerance: + frequency: 0.01 + duty: 0.01 + lptim: + instances: + - name: lptim1 + index: 1 + - name: lptim2 + index: 2 + marker: gpio0 + missing: [0, 3] + # Update rate lptimclk / prescaler / (period + 1) at 64 MHz. + interrupt: + irq: [immediate, dispatched] + update: + - name: 100hz + prescaler: 128 + period: 4999 + - name: 1khz + prescaler: 64 + period: 999 + - name: 16khz + prescaler: 1 + period: 3999 + dispatched: false + # Every divider with this period, immediate interrupt (500 Hz .. 64 kHz). + prescalers: [1, 2, 4, 8, 16, 32, 64, 128] + prescaler_period: 999 + # The STM32WB LPTIM has no repetition counter: `rep` is ERR unsupported. + repetition: false + repetitions: [] + free_running: + prescaler: 128 + period: 65535 + window_s: 0.5 + record_periods: 10 + tolerance: + frequency: 0.01 + count: 0.05 + latency_s: 0.02 unsupported: commands: - comp.open 1 diff --git a/validation/host/boards/nucleo_wba55cg.yaml b/validation/host/boards/nucleo_wba55cg.yaml index 78769a13..c29f2777 100644 --- a/validation/host/boards/nucleo_wba55cg.yaml +++ b/validation/host/boards/nucleo_wba55cg.yaml @@ -21,6 +21,10 @@ clocks: usart1: 100000000 usart2: 100000000 lpuart1: 100000000 + # Reset kernel clock selection: LPTIM1 PCLK7, LPTIM2 PCLK1. + lptim: + 1: 100000000 + 2: 100000000 terminal: baud: 921600 @@ -790,8 +794,19 @@ tests: max_res: 65536 # Opens on its default pins without a and b. default_instance: 1 - # No LPTIM on the STM32WBA55. - lp_instances: [] + # LPTIM2 is wired in bundle1 (DIO10 PB9, DIO7 PB0), LPTIM1 in bundle2 + # (DIO14 PA0, DIO1 PB3). + lp_instances: + - name: lptim2 + index: 2 + a: lptim2in1 + b: lptim2in2 + max_res: 65536 + - name: lptim1 + index: 1 + a: lptim1in1 + b: lptim1in2 + max_res: 65536 lp_missing_instances: [] resolution: 4096 position: @@ -806,12 +821,17 @@ tests: ab: 4 a: 2 b: 2 + # LPTIM capture modes: both edges of both inputs (x4) or the rising or + # falling edges only (x2). + rise: 2 + fall: 2 lp_position: freq: [100, 1000, 10000] cycles: [1, 10, 250] direction: [fwd, rev] inva: [0, 1] filter: [0, 2, 4, 8] + cap: [ab, rise, fall] velocity: freq: [1000, 10000] vel: [1000, 10000] @@ -970,6 +990,151 @@ tests: lsb: [0, 1] baud: 1000000 payload: "a53c0f8001fe7e42" + timer: + # bundle1: the marker (gpio0, DIO13) toggles once per update interrupt. + timers: + - name: tim1 + timer: 1 + - name: tim2 + timer: 2 + - name: tim3 + timer: 3 + - name: tim16 + timer: 16 + - name: tim17 + timer: 17 + marker: gpio0 + # TIM numbers the MCU lacks: ERR range. + missing: [4, 15, 18] + # No counter mode select (up counting only): mode=down is ERR unsupported. + up_only: [16, 17] + # Update rate timclk / ((prescaler + 1) (period + 1)); `dispatched: + # false` marks rates above 1 kHz, where dispatched callbacks coalesce. + interrupt: + irq: [immediate, dispatched] + update: + - name: 100hz + prescaler: 999 + period: 999 + - name: 1khz + prescaler: 99 + period: 999 + - name: 20khz + prescaler: 0 + period: 4999 + dispatched: false + # Free running (irq=none): a 2 kHz counter that wraps after 32.8 s. + free_running: + prescaler: 49999 + period: 65535 + window_s: 0.5 + record_periods: 10 + tolerance: + frequency: 0.01 + count: 0.05 + latency_s: 0.02 + timer_pwm: + # Every channel pin in channel order (null = `-`). TIM2 CH2 is only on + # PA8, the terminal RX: bundle1 runs TIM2 with N = 1, bundle2 (TIM2 + # CH3/CH4 on DIO8/DIO9) with N = 4 and CH2 unused. + timers: + - name: tim1 + timer: 1 + pins: [tim1ch1, tim1ch2, tim1ch3, tim1ch4] + - name: tim2 + timer: 2 + pins: [tim2ch1] + - name: tim2_bundle2 + timer: 2 + pins: [tim2ch1, null, tim2ch3, tim2ch4] + - name: tim3 + timer: 3 + pins: [tim3ch1, tim3ch2, tim3ch3, tim3ch4] + - name: tim16 + timer: 16 + pins: [tim16ch1] + - name: tim17 + timer: 17 + pins: [tim17ch1] + # 1 MHz counter (100 MHz / 100), 10 kHz period: the low tick of 100 % + # lasts 1 us. + prescaler: 99 + period: 99 + duties: [0, 50, 100] + channel_duties: [20, 40, 60, 80] + # tpwm.pulse 1 + pulse: + ccr: 30 + period: 199 + # A pin without a channel function of any timer under test. + foreign_pin: PB2 + record_periods: 20 + tolerance: + frequency: 0.01 + duty: 0.01 + lptim: + instances: + - name: lptim1 + index: 1 + - name: lptim2 + index: 2 + marker: gpio0 + missing: [0, 3] + # Update rate lptimclk / prescaler / (period + 1) at 100 MHz. + interrupt: + irq: [immediate, dispatched] + update: + - name: 100hz + prescaler: 128 + period: 7811 + - name: 1khz + prescaler: 128 + period: 799 + - name: 20khz + prescaler: 1 + period: 4999 + dispatched: false + # Every divider with this period, immediate interrupt (781 Hz .. 100 kHz). + prescalers: [1, 2, 4, 8, 16, 32, 64, 128] + prescaler_period: 999 + # The WBA LPTIM repetition counter: one update every rep + 1 periods. + repetition: true + repetitions: [1, 3, 15] + repetition_timing: + prescaler: 128 + period: 799 + free_running: + prescaler: 128 + period: 65535 + window_s: 0.5 + record_periods: 10 + tolerance: + frequency: 0.01 + count: 0.05 + latency_s: 0.02 + lptim_pwm: + # bundle1: LPTIM1 CH2 on PA15 (DIO2; CH1 is PB11, not bonded) and LPTIM2 + # CH1/CH2 on PA11/PA1 (DIO4/DIO12). + instances: + - name: lptim1 + index: 1 + pins: [null, lptim1ch2] + - name: lptim2 + index: 2 + pins: [lptim2ch1, lptim2ch2] + # 25 MHz counter (100 MHz / 4), 25 kHz period. + prescaler: 4 + period: 999 + duties: [25, 50, 75] + pulse: + ccr: 300 + period: 1999 + # A bonded pin without an LPTIM channel function. + foreign_pin: PB2 + record_periods: 20 + tolerance: + frequency: 0.01 + duty: 0.01 unsupported: commands: - comp.open 1 diff --git a/validation/host/hal_st_validation/fakes/timers.py b/validation/host/hal_st_validation/fakes/timers.py new file mode 100644 index 00000000..3aa27611 --- /dev/null +++ b/validation/host/hal_st_validation/fakes/timers.py @@ -0,0 +1,251 @@ +"""Fake `tim`, `tpwm`, `lptim` and `lptpwm` groups: the argument checks of validation/firmware/TimerGroup.cpp, +TimerPwmGroup.cpp, LpTimerGroup.cpp and LpTimerPwmGroup.cpp in their order, the timers and LPTIMs they share with +`pwm`, `qei` and `adc` (`TimerAllocation`, `ResourceAllocation` lpTimer), and counters that follow the fake clock.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from .. import expect +from ..groups.timers import LPTIM_PERIOD_MAX, LPTIM_PRESCALERS, LPTIM_REPETITION_MAX, TIMER_PRESCALER_MAX, timer_period_max +from .base import FakeGroup, _choice, _fail, _number, _shape, _tokens + +_IRQS = ("none", "immediate", "dispatched") +_MODES = ("up", "down") +_TIMER_INSTANCES = 18 +_LPTIM_INSTANCES = 3 + + +class _Group(FakeGroup): + """One instance at a time (`HilSingleInstanceGroup`): `.open` claims, the other commands find it.""" + + instances: ClassVar[int] = 1 + + def _index(self, text: str) -> int: + return _number(text, 0, self.instances - 1) + + def _find(self, args: list[str], options: dict[str, str], keys: tuple[str, ...] = (), positional: int = 1) -> dict[str, Any]: + _shape(args, options, positional, positional, keys) + return self.fw.find_instance(self.prefix, str(self._index(args[0]))) + + def cmd_close(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + key = str(self._index(args[0])) + self.fw.find_instance(self.prefix, key) + return self.fw.close_instance(self.prefix, key) + + +class _Counter(_Group): + """`TimerCounterGroup`: start/stop/count over a counter that runs at `rate` ticks per second while started and + wraps after `period + 1` ticks; with an update interrupt every wrap counts in `irqs`.""" + + def _counter_state(self, rate: float, period: int, irq: str, down: bool = False) -> dict[str, Any]: + return {"rate": rate, "period": period, "irq": irq, "down": down, "ticks": 0.0, "since": None} + + def _ticks(self, state: dict[str, Any]) -> float: + running = 0.0 if state["since"] is None else (self.fw.clock() - state["since"]) * state["rate"] + return state["ticks"] + running + + def cmd_start(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options) + if state["since"] is None: + state["since"] = self.fw.clock() + return "OK" + + def cmd_stop(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options) + if state["since"] is not None: + state["ticks"] = self._ticks(state) + state["since"] = None + return "OK" + + def cmd_count(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options) + ticks = int(self._ticks(state)) + wrap = state["period"] + 1 + counter = (state["period"] - ticks % wrap) if state["down"] else ticks % wrap + irqs = 0 if state["irq"] == "none" else ticks // wrap + if state["pin"] is not None: + self.fw.gpio_levels[state["pin"]] = irqs % 2 + return f"OK cnt={counter} irqs={irqs}" + + def _marker(self, irq: str, pin: str | None) -> str | None: + """The update marker of `pin=`: none without an update interrupt (`usage`), a bonded pin (`pin`).""" + if pin is not None and irq == "none": + _fail("usage") + if pin is not None and not self.fw.bonded(pin): + _fail("pin") + return pin + + +class FakeTimer(_Counter): + prefix = "tim" + instances = _TIMER_INSTANCES + + def cmd_open(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("prescaler", "period", "irq", "mode", "pin")) + timer = self._index(args[0]) + if not self.fw.timer_exists(timer): + _fail("range") + prescaler = _number(options.get("prescaler", "0"), 0, TIMER_PRESCALER_MAX) + period = _number(options.get("period", "999"), 1, timer_period_max(timer)) + irq = _choice(options, "irq", _IRQS, "dispatched") + mode = _choice(options, "mode", _MODES, "up") + pin = self._marker(irq, self.fw.pin(options.get("pin"))) + if mode == "down" and (irq != "none" or not expect.timer_has_center_mode(timer)): + _fail("unsupported") + timclk = self.fw.kernel_clock + state = self._counter_state(timclk / (prescaler + 1), period, irq, mode == "down") + state.update(timer=timer, prescaler=prescaler, pin=pin, timclk=timclk) + self.fw.open_instance(self.prefix, str(timer), state, [pin], timer=timer) + return f"OK timclk={timclk}" + + +class FakeLpTimer(_Counter): + prefix = "lptim" + instances = _LPTIM_INSTANCES + + def cmd_open(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("period", "prescaler", "irq", "rep", "pin")) + index = self._index(args[0]) + if index not in self.fw.spec.lptims: + _fail("range") + period = _number(options.get("period", "999"), 1, LPTIM_PERIOD_MAX) + divider = _number(options.get("prescaler", "1"), 1, LPTIM_PRESCALERS[-1]) + repetition = _number(options.get("rep", "0"), 0, LPTIM_REPETITION_MAX) + irq = _choice(options, "irq", _IRQS, "dispatched") + pin = self.fw.pin(options.get("pin")) + if divider not in LPTIM_PRESCALERS: + _fail("range") + pin = self._marker(irq, pin) + # The STM32WB LPTIM has no repetition counter (LpTimerStm.cpp). + if "rep" in options and self.fw.family == "stm32wb55": + _fail("unsupported") + lptimclk = self.fw.kernel_clock + state = self._counter_state(lptimclk / divider / (repetition + 1), period, irq) + state.update(index=index, divider=divider, repetition=repetition, pin=pin, lptimclk=lptimclk) + self.fw.open_instance(self.prefix, str(index), state, [pin], resources=[("lpTimer", index)]) + return f"OK lptimclk={lptimclk}" + + +class _ChannelPwm(_Group): + """`PwmChannelGroup`: N channels in `pins` order (`-` unused), duty/pulse per channel, start/stop one or all.""" + + clock_key: ClassVar[str] = "" + functions: ClassVar[tuple[str, ...]] = () + + def _channel_pins(self, options: dict[str, str]) -> list[str | None]: + """`ParseChannelPins`: 1..N entries, `-` for an unused channel, at least one pin.""" + if "pins" not in options: + _fail("usage") + tokens = _tokens(options["pins"]) + if not 1 <= len(tokens) <= len(self.functions): + _fail("usage") + pins = [None if token == "-" else self.fw.pin(token) for token in tokens] + if all(pin is None for pin in pins): + _fail("usage") + return pins + + def _open(self, number: int, pins: list[str | None], counter_max: int, period: int, clock: int, **extra: Any) -> str: + """`Evaluate` after the numbers and `ParseChannelPins`: channel functions (`pin`), channels (`unsupported`).""" + for position, pin in enumerate(pins): + if pin is not None and not self.fw.supports(self.functions[position], number, pin): + _fail("pin") + if not all(self._has_channel(number, position + 1) for position in range(len(pins))): + _fail("unsupported") + state = {"pins": pins, "period": period, "counter_max": counter_max, "running": [False] * len(pins), **extra} + state["ccr"] = [0] * len(pins) + state[self.clock_key] = clock + self.fw.open_instance(self.prefix, str(number), state, pins, **self._claims(number)) + return f"OK {self.clock_key}={clock}" + + def _has_channel(self, index: int, channel: int) -> bool: + raise NotImplementedError + + def _claims(self, index: int) -> dict[str, Any]: + raise NotImplementedError + + def _channel(self, state: dict[str, Any], text: str) -> int: + return _number(text, 1, len(state["pins"])) + + def cmd_duty(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options, positional=3) + channel = self._channel(state, args[1]) + percent = _number(args[2], 0, 100) + state["ccr"][channel - 1] = ((state["period"] * percent) & 0xFFFFFFFF) // 100 + return "OK" + + def cmd_pulse(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options, positional=4) + channel = self._channel(state, args[1]) + on = _number(args[2], 0, state["counter_max"]) + state["period"] = _number(args[3], 1, state["counter_max"]) + state["ccr"][channel - 1] = on + return "OK" + + def _channels(self, args: list[str], options: dict[str, str]) -> tuple[dict[str, Any], list[int]]: + state = self._find(args, options, ("ch",)) + if "ch" in options: + return state, [self._channel(state, options["ch"]) - 1] + return state, list(range(len(state["pins"]))) + + def cmd_start(self, args: list[str], options: dict[str, str]) -> str: + state, positions = self._channels(args, options) + for position in positions: + state["running"][position] = True + return "OK" + + def cmd_stop(self, args: list[str], options: dict[str, str]) -> str: + state, positions = self._channels(args, options) + for position in positions: + state["running"][position] = False + return "OK" + + +class FakeTimerPwm(_ChannelPwm): + prefix = "tpwm" + instances = _TIMER_INSTANCES + clock_key = "timclk" + functions = ("timerChannel1", "timerChannel2", "timerChannel3", "timerChannel4") + + def cmd_open(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("pins", "prescaler", "period")) + timer = self._index(args[0]) + if not self.fw.timer_exists(timer): + _fail("range") + counter_max = timer_period_max(timer) + prescaler = _number(options.get("prescaler", "0"), 0, TIMER_PRESCALER_MAX) + period = _number(options.get("period", "6399"), 1, counter_max) + pins = self._channel_pins(options) + return self._open(timer, pins, counter_max, period, self.fw.kernel_clock, timer=timer, prescaler=prescaler) + + def _has_channel(self, index: int, channel: int) -> bool: + return expect.timer_has_channel(index, channel) + + def _claims(self, index: int) -> dict[str, Any]: + return {"timer": index} + + +class FakeLpTimerPwm(_ChannelPwm): + prefix = "lptpwm" + instances = _LPTIM_INSTANCES + clock_key = "lptimclk" + functions = ("lpTimerChannel1", "lpTimerChannel2") + + def cmd_open(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("pins", "prescaler", "period")) + index = self._index(args[0]) + if index not in self.fw.spec.lptims: + _fail("range") + divider = _number(options.get("prescaler", "1"), 1, LPTIM_PRESCALERS[-1]) + period = _number(options.get("period", "6399"), 1, LPTIM_PERIOD_MAX) + if divider not in LPTIM_PRESCALERS: + _fail("range") + pins = self._channel_pins(options) + return self._open(index, pins, LPTIM_PERIOD_MAX, period, self.fw.kernel_clock, index=index, divider=divider) + + def _has_channel(self, index: int, channel: int) -> bool: + return 1 <= channel <= 2 + + def _claims(self, index: int) -> dict[str, Any]: + return {"resources": [("lpTimer", index)]} diff --git a/validation/host/hal_st_validation/groups/timers.py b/validation/host/hal_st_validation/groups/timers.py new file mode 100644 index 00000000..01773808 --- /dev/null +++ b/validation/host/hal_st_validation/groups/timers.py @@ -0,0 +1,177 @@ +"""Timer groups of validation/PROTOCOL.md: `tim` (`hal::FreeRunningTimerStm`, `hal::TimerWithInterruptStm`), `tpwm` +(`hal::TimerPwmWithChannels`), `lptim` (`hal::FreeRunningLowPowerTimerStm`, `hal::LowPowerTimerWithInterruptStm`) +and `lptpwm` (`hal::LpTimerPwmWithChannels`, STM32WBA55 only), with the expected waveforms (pure functions). + +Sources: `TimerBaseStm` (hal_st/stm32fxxx/TimerStm.cpp: PSC = prescaler, ARR = period), `PwmChannelGpio::SetDuty` +(TimerPwmStm.cpp: CCR = ARR * duty / 100 in 32 bits, PWM mode 1, so the output is high while CNT < CCR), +`LowPowerTimerBaseStm` (LpTimerStm.cpp: prescaler divider, ARR = period, repetition counter on the WBA LPTIM only). +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Literal + +from .. import expect +from .base import Group, Pin + +__all__ = [ + "GROUPS", + "LPTIM_PERIOD_MAX", + "LPTIM_PRESCALERS", + "LPTIM_REPETITION_MAX", + "TIMER_DISPATCHED_MAX_HZ", + "TIMER_PRESCALER_MAX", + "LpTimer", + "LpTimerPwm", + "Timer", + "TimerCount", + "TimerPwm", + "lptim_update_rate", + "pwm_duty_fraction", + "pwm_high_ticks", + "timer_period_max", + "timer_update_rate", +] + +Irq = Literal["none", "immediate", "dispatched"] +ChannelPin = Pin | None + +TIMER_PRESCALER_MAX = 0xFFFF +LPTIM_PRESCALERS = (1, 2, 4, 8, 16, 32, 64, 128) +LPTIM_PERIOD_MAX = 0xFFFF +LPTIM_REPETITION_MAX = 0xFF +# Above this update rate `irq=dispatched` callbacks coalesce in the event loop (one queued callback at a time). +TIMER_DISPATCHED_MAX_HZ = 1000 + + +def timer_period_max(timer: int) -> int: + """`period` (ARR) of `tim.open`/`tpwm.open`: 16 bits, 32 bits on TIM2.""" + return expect.timer_counter_max(timer) + + +def timer_update_rate(timclk: int, prescaler: int, period: int) -> float: + """Update events per second of a TIM: one per `period + 1` counter ticks of `timclk / (prescaler + 1)`.""" + return timclk / (prescaler + 1) / (period + 1) + + +def lptim_update_rate(lptimclk: int, divider: int, period: int, repetition: int = 0) -> float: + """Update events per second of an LPTIM: one per `period + 1` ticks of `lptimclk / divider`, and on the WBA + LPTIM only every `repetition + 1` periods.""" + return lptimclk / divider / (period + 1) / (repetition + 1) + + +def pwm_high_ticks(period: int, percent: int) -> int: + """`SetDuty`: CCR = ARR * percent / 100 computed in 32 bits (it wraps above ARR 42949672 on TIM2).""" + return ((period * percent) & 0xFFFFFFFF) // 100 + + +def pwm_duty_fraction(period: int, percent: int) -> float: + """High fraction of a channel: high while the counter is below CCR, out of `period + 1` ticks; 100 % keeps one + low tick (CCR = ARR).""" + return min(pwm_high_ticks(period, percent), period + 1) / (period + 1) + + +@dataclass(frozen=True) +class TimerCount: + cnt: int + irqs: int + + +class _Counter(Group): + def start(self, index: int) -> None: + self._cmd("start", index) + + def stop(self, index: int) -> None: + self._cmd("stop", index) + + def count(self, index: int) -> TimerCount: + response = self._cmd("count", index) + return TimerCount(cnt=response.as_int("cnt"), irqs=response.as_int("irqs")) + + def close(self, index: int) -> None: + self._cmd("close", index) + self._fw.untrack((self.prefix, index)) + + def _track(self, index: int) -> None: + self._fw.track((self.prefix, index), f"{self.prefix}.close", index) + + +class Timer(_Counter): + prefix = "tim" + + def open( + self, + timer: int, + prescaler: int | None = None, + period: int | None = None, + irq: Irq | None = None, + mode: Literal["up", "down"] | None = None, + pin: Pin | None = None, + ) -> int: + """Returns `timclk`, the timer kernel clock. `pin` toggles on every update interrupt (not with `irq="none"`); + `mode="down"` needs `irq="none"`.""" + response = self._cmd("open", timer, prescaler=prescaler, period=period, irq=irq, mode=mode, pin=self._pin(pin)) + self._track(timer) + return response.as_int("timclk") + + +class LpTimer(_Counter): + prefix = "lptim" + + def open( + self, + index: int, + period: int | None = None, + prescaler: int | None = None, + irq: Irq | None = None, + rep: int | None = None, + pin: Pin | None = None, + ) -> int: + """Returns `lptimclk`, the LPTIM kernel clock. `prescaler` is the divider (1, 2, 4, ..., 128); `rep` needs the + WBA LPTIM.""" + response = self._cmd("open", index, period=period, prescaler=prescaler, irq=irq, rep=rep, pin=self._pin(pin)) + self._track(index) + return response.as_int("lptimclk") + + +class _ChannelPwm(Group): + clock_key = "" + + def open(self, index: int, pins: Sequence[ChannelPin], prescaler: int | None = None, period: int | None = None) -> int: + """`pins` in channel order, None for an unused channel (`-`); returns the kernel clock.""" + entries = ["-" if pin is None else self._fw.pin(pin) for pin in pins] + response = self._cmd("open", index, pins=entries, prescaler=prescaler, period=period) + self._fw.track((self.prefix, index), f"{self.prefix}.close", index) + return response.as_int(self.clock_key) + + def duty(self, index: int, channel: int, percent: int) -> None: + self._cmd("duty", index, channel, percent) + + def pulse(self, index: int, channel: int, on: int, period: int) -> None: + """`SetPulse`: CCR of `channel` = `on`, ARR = `period` for every channel.""" + self._cmd("pulse", index, channel, on, period) + + def start(self, index: int, ch: int | None = None) -> None: + self._cmd("start", index, ch=ch) + + def stop(self, index: int, ch: int | None = None) -> None: + self._cmd("stop", index, ch=ch) + + def close(self, index: int) -> None: + self._cmd("close", index) + self._fw.untrack((self.prefix, index)) + + +class TimerPwm(_ChannelPwm): + prefix = "tpwm" + clock_key = "timclk" + + +class LpTimerPwm(_ChannelPwm): + prefix = "lptpwm" + clock_key = "lptimclk" + + +GROUPS: dict[str, type[Group]] = {"tim": Timer, "tpwm": TimerPwm, "lptim": LpTimer, "lptpwm": LpTimerPwm} diff --git a/validation/host/tests/hil/test_lptim.py b/validation/host/tests/hil/test_lptim.py new file mode 100644 index 00000000..809e9b38 --- /dev/null +++ b/validation/host/tests/hil/test_lptim.py @@ -0,0 +1,211 @@ +"""Low-power timers (`hal::FreeRunningLowPowerTimerStm`, `hal::LowPowerTimerWithInterruptStm`) through the `lptim` +group: update rate on the marker pin for every prescaler, the WBA repetition counter, interrupt counts, the +free-running counter, stop, and the LPTIM shared with the LPTIM encoder (`qei lp=1`) and `lptpwm`. + +Wiring set `bundle1`: `tests.lptim.marker` (gpio0) on a DIO; the marker toggles once per update interrupt, so it runs +at half the update rate `lptimclk / prescaler / (period + 1) / (rep + 1)`, with the LPTIM kernel clock `lptimclk` of +the board file's `clocks.lptim` (`lptim.open` reports it, test_reports_the_kernel_clock). +""" + +from __future__ import annotations + +import time + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.groups.timers import LPTIM_PERIOD_MAX, LPTIM_REPETITION_MAX, lptim_update_rate + + +@pytest.fixture +def lptim_cfg(board_cfg): + return board_cfg.param("lptim") + + +def dispatchable(values): + """Dispatched callbacks coalesce above 1 kHz: those rates run with `irq=immediate` only.""" + update = values.get("update") + return values.get("irq") != "dispatched" or update is None or update.get("dispatched", True) + + +def expect_error(reason, call, *args, **options): + with pytest.raises(FirmwareError) as error: + call(*args, **options) + assert error.value.reason == reason, (args, options) + + +def marker_frequency(ad3, dio, cfg, marker): + periods = cfg["record_periods"] + capture = ad3.logic.record_for(periods / marker, trigger=(dio, "rising"), timeout=periods / marker + 2) + return capture.frequency(dio) + + +@pytest.mark.board_params("instance", "lptim.instances") +def test_reports_the_kernel_clock(fw, board_cfg, instance): + index = instance["index"] + assert fw.lptim.open(index, irq="none") == board_cfg.clock("lptim", index) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "lptim.instances") +@pytest.mark.matrix("lptim.interrupt") +@pytest.mark.constraint(valid=dispatchable) +def test_update_marker(fw, ad3, need, board_cfg, lptim_cfg, instance, irq, update): + """The marker toggles on every update interrupt: it runs at half the update rate.""" + dio = need.dio(lptim_cfg["marker"]) + index = instance["index"] + fw.lptim.open(index, prescaler=update["prescaler"], period=update["period"], irq=irq, pin=lptim_cfg["marker"]) + fw.lptim.start(index) + lptimclk = board_cfg.clock("lptim", index) + marker = lptim_update_rate(lptimclk, update["prescaler"], update["period"]) / 2 + assert marker_frequency(ad3, dio, lptim_cfg, marker) == pytest.approx(marker, rel=lptim_cfg["tolerance"]["frequency"]) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "lptim.instances") +@pytest.mark.board_params("prescaler", "lptim.prescalers") +def test_prescaler(fw, ad3, need, board_cfg, lptim_cfg, instance, prescaler): + """Every divider of the LPTIM clock (1 to 128) divides the update rate.""" + dio = need.dio(lptim_cfg["marker"]) + index, period = instance["index"], lptim_cfg["prescaler_period"] + fw.lptim.open(index, prescaler=prescaler, period=period, irq="immediate", pin=lptim_cfg["marker"]) + fw.lptim.start(index) + lptimclk = board_cfg.clock("lptim", index) + marker = lptim_update_rate(lptimclk, prescaler, period) / 2 + assert marker_frequency(ad3, dio, lptim_cfg, marker) == pytest.approx(marker, rel=lptim_cfg["tolerance"]["frequency"]) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "lptim.instances") +@pytest.mark.board_params("rep", "lptim.repetitions") +def test_repetition_counter(fw, ad3, need, board_cfg, lptim_cfg, instance, rep): + """`rep` (WBA LPTIM): one update every `rep + 1` periods.""" + dio = need.dio(lptim_cfg["marker"]) + index, timing = instance["index"], lptim_cfg["repetition_timing"] + marker_pin = lptim_cfg["marker"] + fw.lptim.open(index, prescaler=timing["prescaler"], period=timing["period"], rep=rep, irq="immediate", pin=marker_pin) + fw.lptim.start(index) + lptimclk = board_cfg.clock("lptim", index) + marker = lptim_update_rate(lptimclk, timing["prescaler"], timing["period"], rep) / 2 + assert marker_frequency(ad3, dio, lptim_cfg, marker) == pytest.approx(marker, rel=lptim_cfg["tolerance"]["frequency"]) + + +@pytest.mark.board_params("instance", "lptim.instances") +@pytest.mark.matrix("lptim.interrupt") +@pytest.mark.constraint(valid=dispatchable) +def test_interrupt_count(fw, lptim_cfg, instance, irq, update): + """`irqs` follows the update rate; dispatched callbacks at or below 1 kHz are not lost.""" + index = instance["index"] + lptimclk = fw.lptim.open(index, prescaler=update["prescaler"], period=update["period"], irq=irq) + rate = lptim_update_rate(lptimclk, update["prescaler"], update["period"]) + fw.lptim.start(index) + start = time.monotonic() + before = fw.lptim.count(index) + time.sleep(lptim_cfg["window_s"]) + after = fw.lptim.count(index) + elapsed = time.monotonic() - start + tolerance = lptim_cfg["tolerance"] + counted = after.irqs - before.irqs + assert counted >= rate * elapsed * (1 - tolerance["count"]) - rate * tolerance["latency_s"], (counted, rate * elapsed) + assert counted <= rate * elapsed * (1 + tolerance["count"]) + 1, (counted, rate * elapsed) + + +@pytest.mark.board_params("instance", "lptim.instances") +def test_free_running_counter_and_stop(fw, lptim_cfg, instance): + """`irq=none` counts without interrupts; `lptim.stop` freezes the counter; repeated start and stop are harmless.""" + index, free = instance["index"], lptim_cfg["free_running"] + fw.lptim.open(index, prescaler=free["prescaler"], period=free["period"], irq="none") + assert fw.lptim.count(index).irqs == 0 + fw.lptim.start(index) + fw.lptim.start(index) + readings = set() + for _ in range(5): + reading = fw.lptim.count(index) + assert reading.irqs == 0 and reading.cnt <= free["period"] + readings.add(reading.cnt) + assert len(readings) > 1, "the counter does not count" + fw.lptim.stop(index) + fw.lptim.stop(index) + stopped = fw.lptim.count(index) + time.sleep(lptim_cfg["window_s"]) + assert fw.lptim.count(index) == stopped + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "lptim.instances") +def test_stop_stops_the_marker(fw, ad3, need, lptim_cfg, instance): + dio = need.dio(lptim_cfg["marker"]) + index, update = instance["index"], lptim_cfg["interrupt"]["update"][1] + lptimclk = fw.lptim.open(index, prescaler=update["prescaler"], period=update["period"], irq="immediate", pin=lptim_cfg["marker"]) + fw.lptim.start(index) + fw.lptim.stop(index) + marker = lptim_update_rate(lptimclk, update["prescaler"], update["period"]) / 2 + capture = ad3.logic.record_for(lptim_cfg["record_periods"] / marker) + assert len(set(capture.channel(dio))) == 1, "the marker moved after lptim.stop" + + +def test_repetition_support(fw, lptim_cfg): + """`rep` 0..255 on the WBA LPTIM; the STM32WB LPTIM has no repetition counter (`ERR unsupported`).""" + index = lptim_cfg["instances"][0]["index"] + expect_error("range", fw.lptim.open, index, rep=LPTIM_REPETITION_MAX + 1) + if not lptim_cfg["repetition"]: + expect_error("unsupported", fw.lptim.open, index, rep=0) + return + fw.lptim.open(index, rep=LPTIM_REPETITION_MAX) + fw.lptim.close(index) + + +def test_open_errors(fw, board_cfg, lptim_cfg): + """Argument errors in the protocol order: usage, range, pin.""" + index, marker = lptim_cfg["instances"][0]["index"], lptim_cfg["marker"] + cases = [ + ({"irq": "nmi"}, "usage"), + ({"irq": "none", "pin": marker}, "usage"), + ({"period": 0}, "range"), + ({"period": LPTIM_PERIOD_MAX + 1}, "range"), + ({"prescaler": 0}, "range"), + ({"prescaler": 3}, "range"), + ({"prescaler": 256}, "range"), + ({"pin": board_cfg.param("system.unbonded_pins")[0]}, "pin"), + ] + for options, reason in cases: + expect_error(reason, fw.lptim.open, index, **options) + for missing in lptim_cfg["missing"]: + expect_error("range", fw.lptim.open, missing) + for command in ("start", "stop", "count", "close"): + expect_error("notopen", getattr(fw.lptim, command), index) + assert fw.terminal.command(f"lptim.open {index} mode=down", check=False).reason == "usage" + assert fw.terminal.command(f"lptim.open {index} pin=nosuchalias", check=False).reason == "pin" + + +def test_one_lptim_at_a_time(fw, lptim_cfg): + first, second = (instance["index"] for instance in lptim_cfg["instances"][:2]) + fw.lptim.open(first, irq="none") + expect_error("busy", fw.lptim.open, second, irq="none") + + +def test_marker_is_released_on_close(fw, need, lptim_cfg): + index, marker = lptim_cfg["instances"][0]["index"], lptim_cfg["marker"] + need.unloaded(marker) + fw.lptim.open(index, irq="immediate", pin=marker) + expect_error("busy", fw.gpio.cfg, marker, "in") + fw.lptim.close(index) + fw.gpio.cfg(marker, "in") + + +@pytest.mark.board_params("encoder", "qei.lp_instances") +def test_lptim_shared_with_the_encoder(fw, board_cfg, encoder): + """An LPTIM serves one group (`ResourceAllocation` lpTimer): the LPTIM encoder, `lptim` and `lptpwm`.""" + index = encoder["index"] + fw.qei.open(index, lp=True, a=encoder["a"], b=encoder["b"]) + expect_error("busy", fw.lptim.open, index, irq="none") + pwm = next((entry for entry in board_cfg.param("lptim_pwm.instances", []) if entry["index"] == index), None) + if pwm is not None: + expect_error("busy", fw.lptpwm.open, index, pins=pwm["pins"]) + fw.qei.close(index) + fw.lptim.open(index, irq="none") + expect_error("busy", fw.qei.open, index, lp=True, a=encoder["a"], b=encoder["b"]) + fw.lptim.close(index) + if pwm is not None: + fw.lptpwm.open(index, pins=pwm["pins"]) + expect_error("busy", fw.lptim.open, index, irq="none") diff --git a/validation/host/tests/hil/test_lptim_pwm.py b/validation/host/tests/hil/test_lptim_pwm.py new file mode 100644 index 00000000..29c69279 --- /dev/null +++ b/validation/host/tests/hil/test_lptim_pwm.py @@ -0,0 +1,145 @@ +"""LPTIM PWM (`hal::LpTimerPwmWithChannels`, `hal::LpPwmChannelGpio`, STM32WBA55 only: LpTimerPwmStm is not +built for STM32WB) through the `lptpwm` group: frequency and duty per channel, `SetPulse`, an unused channel and the +argument checks. + +Wiring set `bundle1`: LPTIM1 CH2 (PA15) and LPTIM2 CH1/CH2 (PA11/PA1) on DIOs. The tests assert the duty that +`lptpwm.duty` asks for (CCR = ARR * duty / 100, high while the counter is below CCR). If the bench measures 1 - duty +instead (the output polarity of the LPTIM IP, DESIGN R14), the measured value is in the assertion message: record +it as a known gap rather than changing the expectation. The LPTIM takes compare writes only while it is enabled, so +the tests set duties after `lptpwm.start`. +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.groups.timers import LPTIM_PERIOD_MAX, lptim_update_rate, pwm_duty_fraction + +pytestmark = pytest.mark.family("stm32wba55") + + +@pytest.fixture +def lptpwm_cfg(board_cfg): + return board_cfg.param("lptim_pwm") + + +def expect_error(reason, call, *args, **options): + with pytest.raises(FirmwareError) as error: + call(*args, **options) + assert error.value.reason == reason, (args, options) + + +def record(ad3, cfg, frequency): + periods = cfg["record_periods"] + return ad3.logic.record_for(periods / frequency, timeout=periods / frequency + 2) + + +def check_channel(capture, dio, frequency, fraction, cfg): + assert capture.frequency(dio) == pytest.approx(frequency, rel=cfg["tolerance"]["frequency"]), f"DIO{dio} frequency" + measured = capture.duty(dio) + quantisation = 2 * frequency / capture.rate + assert measured == pytest.approx(fraction, abs=cfg["tolerance"]["duty"] + quantisation), ( + f"DIO{dio}: high fraction {measured:.4f}, expected {fraction:.4f} (1 - duty would be {1 - fraction:.4f})" + ) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "lptim_pwm.instances") +@pytest.mark.board_params("duty", "lptim_pwm.duties") +def test_duty(fw, ad3, need, board_cfg, lptpwm_cfg, instance, duty): + """Frequency lptimclk / prescaler / (period + 1) and the duty of every wired channel.""" + dios = [None if pin is None else need.dio(pin) for pin in instance["pins"]] + index = instance["index"] + lptimclk = fw.lptpwm.open(index, pins=instance["pins"], prescaler=lptpwm_cfg["prescaler"], period=lptpwm_cfg["period"]) + assert lptimclk == board_cfg.clock("lptim", index) + fw.lptpwm.start(index) + for channel, dio in enumerate(dios, 1): + if dio is not None: + fw.lptpwm.duty(index, channel, duty) + frequency = lptim_update_rate(lptimclk, lptpwm_cfg["prescaler"], lptpwm_cfg["period"]) + capture = record(ad3, lptpwm_cfg, frequency) + for dio in dios: + if dio is not None: + check_channel(capture, dio, frequency, pwm_duty_fraction(lptpwm_cfg["period"], duty), lptpwm_cfg) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "lptim_pwm.instances") +def test_pulse(fw, ad3, need, lptpwm_cfg, instance): + """`SetPulse(on, period)`: the channel's compare value and the shared auto-reload.""" + channel = next(position for position, pin in enumerate(instance["pins"], 1) if pin is not None) + dio = need.dio(instance["pins"][channel - 1]) + index, pulse = instance["index"], lptpwm_cfg["pulse"] + lptimclk = fw.lptpwm.open(index, pins=instance["pins"], prescaler=lptpwm_cfg["prescaler"], period=lptpwm_cfg["period"]) + fw.lptpwm.start(index) + fw.lptpwm.pulse(index, channel, pulse["ccr"], pulse["period"]) + frequency = lptim_update_rate(lptimclk, lptpwm_cfg["prescaler"], pulse["period"]) + check_channel(record(ad3, lptpwm_cfg, frequency), dio, frequency, pulse["ccr"] / (pulse["period"] + 1), lptpwm_cfg) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "lptim_pwm.instances") +def test_stop(fw, ad3, need, lptpwm_cfg, instance): + """`lptpwm.stop` stops the outputs; start again resumes them; repeating either is harmless.""" + dios = [need.dio(pin) for pin in instance["pins"] if pin is not None] + index = instance["index"] + lptimclk = fw.lptpwm.open(index, pins=instance["pins"], prescaler=lptpwm_cfg["prescaler"], period=lptpwm_cfg["period"]) + fw.lptpwm.start(index) + fw.lptpwm.start(index) + for channel, pin in enumerate(instance["pins"], 1): + if pin is not None: + fw.lptpwm.duty(index, channel, 50) + fw.lptpwm.stop(index) + fw.lptpwm.stop(index) + frequency = lptim_update_rate(lptimclk, lptpwm_cfg["prescaler"], lptpwm_cfg["period"]) + capture = record(ad3, lptpwm_cfg, frequency) + stopped = lptpwm_cfg["record_periods"] // 4 + assert all(analysis.edge_count(capture.channel(dio), "rising") <= stopped for dio in dios), "an output runs after lptpwm.stop" + fw.lptpwm.start(index) + for channel, pin in enumerate(instance["pins"], 1): + if pin is not None: + fw.lptpwm.duty(index, channel, 50) + capture = record(ad3, lptpwm_cfg, frequency) + for dio in dios: + check_channel(capture, dio, frequency, pwm_duty_fraction(lptpwm_cfg["period"], 50), lptpwm_cfg) + + +def test_open_errors(fw, lptpwm_cfg, board_cfg): + """Argument errors in the protocol order: usage, range, pin.""" + instance = next(entry for entry in lptpwm_cfg["instances"] if entry["pins"][0] is not None) + index, pins = instance["index"], instance["pins"] + cases = [ + ({"pins": [None, None]}, "usage"), + ({"pins": [*pins, pins[0]]}, "usage"), + ({"pins": pins, "prescaler": 3}, "range"), + ({"pins": pins, "prescaler": 256}, "range"), + ({"pins": pins, "period": 0}, "range"), + ({"pins": pins, "period": LPTIM_PERIOD_MAX + 1}, "range"), + ({"pins": [lptpwm_cfg["foreign_pin"]]}, "pin"), + ({"pins": list(reversed(pins))}, "pin"), + ] + for options, reason in cases: + expect_error(reason, fw.lptpwm.open, index, **options) + for line, reason in ((f"lptpwm.open {index}", "usage"), (f"lptpwm.open {index} pins=nosuchalias", "pin")): + assert fw.terminal.command(line, check=False).reason == reason, line + for missing in board_cfg.param("lptim.missing"): + expect_error("range", fw.lptpwm.open, missing, pins=pins) + for command, args in (("duty", (1, 50)), ("pulse", (1, 1, 2)), ("start", ()), ("stop", ()), ("close", ())): + expect_error("notopen", getattr(fw.lptpwm, command), index, *args) + fw.lptpwm.open(index, pins=pins) + for command, args in (("duty", (3, 50)), ("duty", (1, 101)), ("pulse", (1, LPTIM_PERIOD_MAX + 1, 2)), ("pulse", (1, 1, 0))): + expect_error("range", getattr(fw.lptpwm, command), index, *args) + + +def test_one_lptim_pwm_at_a_time_and_pins_claimed(fw, need, lptpwm_cfg): + first, second = lptpwm_cfg["instances"][:2] + need.unloaded(*(pin for pin in first["pins"] if pin is not None)) + pin = next(pin for pin in first["pins"] if pin is not None) + fw.lptpwm.open(first["index"], pins=first["pins"]) + expect_error("busy", fw.lptpwm.open, second["index"], pins=second["pins"]) + expect_error("busy", fw.lptim.open, first["index"], irq="none") + expect_error("busy", fw.gpio.cfg, pin, "in") + fw.lptpwm.close(first["index"]) + fw.gpio.cfg(pin, "in") diff --git a/validation/host/tests/hil/test_qei.py b/validation/host/tests/hil/test_qei.py index 8bd0515d..7c6567a9 100644 --- a/validation/host/tests/hil/test_qei.py +++ b/validation/host/tests/hil/test_qei.py @@ -1,9 +1,10 @@ """Quadrature encoder (`hal::SynchronousQuadratureEncoderStm`, LPTIM `SynchronousQuadratureEncoderLpTimStm`) driven by the AD3 pattern generator. -Wiring set `bundle1`: A, B and index of each `tests.qei.instances` entry on DIOs; the LPTIM1 encoder of the -NUCLEO-WB55RG (`tests.qei.lp_instances`) is wired in `bundle2`. The pattern generator produces an exact number of -4-state cycles (A leads B for `fwd`). +Wiring set `bundle1`: A, B and index of each `tests.qei.instances` entry on DIOs. The LPTIM encoders +(`tests.qei.lp_instances`): NUCLEO-WB55RG LPTIM1 in `bundle2`; NUCLEO-WBA55CG LPTIM2 in `bundle1` and LPTIM1 in +`bundle2`. The pattern generator produces an exact number of 4-state cycles (A leads B for `fwd`); the LPTIM counts +both edges of both inputs (`cap=ab`) or the rising or falling edges only (`cap=rise|fall`, two counts per cycle). """ from __future__ import annotations @@ -46,6 +47,12 @@ def run_inverted(ad3, dios, frequency, cycles, invert_a, invert_b, direction="fw ad3.pattern.wait_done(timeout=cycles / frequency + 2) +def lp_counts(qei, cycles, cap, direction, inva): + """Signed count change of the LPTIM encoder: `counts_per_cycle[cap]` per cycle, reversed by `inva`.""" + sign = 1 if expect.qei_counts(1, "ab", direction, inva) > 0 else -1 + return qei["counts_per_cycle"][cap] * cycles * sign + + def check_counts(fw, qei, index, before, counts, resolution=None): resolution = resolution or qei["resolution"] after = fw.qei.read(index) @@ -125,17 +132,17 @@ def test_index_input(fw, ad3, need, qei, instance): @pytest.mark.ad3 -@pytest.mark.family("stm32wb55") @pytest.mark.board_params("instance", "qei.lp_instances") @pytest.mark.matrix("qei.lp_position") -def test_lptim_position(fw, ad3, need, qei, instance, freq, cycles, direction, inva, filter): - """LPTIM1 (`lp=1`) counts both edges of both inputs; `inva=1` reverses the direction (mirrored mounting).""" +def test_lptim_position(fw, ad3, need, qei, instance, freq, cycles, direction, inva, filter, cap): + """The LPTIM encoder (`lp=1`) counts both edges of both inputs (`ab`) or the rising or falling edges only; + `inva=1` reverses the direction (mirrored mounting).""" dios = instance_dios(need, instance, ("a", "b")) resolution = instance["max_res"] - fw.qei.open(instance["index"], lp=True, a=instance["a"], b=instance["b"], res=resolution, inva=inva, filter=filter) + fw.qei.open(instance["index"], lp=True, a=instance["a"], b=instance["b"], res=resolution, inva=inva, filter=filter, cap=cap) before = fw.qei.read(instance["index"]).pos run(ad3, dios, freq, cycles, direction) - check_counts(fw, qei, instance["index"], before, expect.qei_counts(cycles, "ab", direction, inva), resolution) + check_counts(fw, qei, instance["index"], before, lp_counts(qei, cycles, cap, direction, inva), resolution) def test_default_instance(fw, qei): @@ -183,6 +190,8 @@ def test_open_errors(fw, instance): pins = {"a": instance["a"], "b": instance["b"]} cases = [ ({**pins, "cap": "x"}, "usage"), + ({**pins, "cap": "rise"}, "usage"), + ({**pins, "cap": "fall"}, "usage"), ({**pins, "filter": 16}, "range"), ({**pins, "vel": 0}, "range"), ({**pins, "vel": 1000001}, "range"), @@ -202,14 +211,24 @@ def test_open_errors(fw, instance): assert error.value.reason == "unsupported", "opened without idx" -@pytest.mark.family("stm32wb55") +@pytest.mark.board_params("instance", "qei.lp_instances") +def test_lptim_capture_modes(fw, qei, instance): + """Every LPTIM capture mode opens and reads back the resolution.""" + for cap in qei["lp_position"]["cap"]: + fw.qei.open(instance["index"], lp=True, a=instance["a"], b=instance["b"], cap=cap) + assert fw.qei.read(instance["index"]).res == qei["resolution"], cap + fw.qei.close(instance["index"]) + + @pytest.mark.board_params("instance", "qei.lp_instances") def test_lptim_errors(fw, qei, instance): pins = {"lp": True, "a": instance["a"], "b": instance["b"]} cases = [ ({**pins, "filter": 3}, "range"), ({**pins, "res": instance["max_res"] + 1}, "range"), + ({**pins, "cap": "x"}, "usage"), ({**pins, "cap": "a"}, "unsupported"), + ({**pins, "cap": "b"}, "unsupported"), ({**pins, "offset": 0}, "unsupported"), ({**pins, "invb": 0}, "unsupported"), ({"lp": True}, "usage"), diff --git a/validation/host/tests/hil/test_timer.py b/validation/host/tests/hil/test_timer.py new file mode 100644 index 00000000..ed0bd42c --- /dev/null +++ b/validation/host/tests/hil/test_timer.py @@ -0,0 +1,201 @@ +"""Timers (`hal::FreeRunningTimerStm`, `hal::TimerWithInterruptStm`) through the `tim` group: update rate on the +marker pin, interrupt counts, the free-running counter up and down, stop, and the timer shared with pwm, tpwm, qei +and the timer-triggered ADC. + +Wiring set `bundle1`: `tests.timer.marker` (gpio0) on a DIO; the marker toggles once per update interrupt, so it runs +at half the update rate `timclk / ((prescaler + 1) (period + 1))`. +""" + +from __future__ import annotations + +import time + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import expect +from hal_st_validation.groups.timers import TIMER_PRESCALER_MAX, timer_period_max, timer_update_rate + + +@pytest.fixture +def timer_cfg(board_cfg): + return board_cfg.param("timer") + + +def dispatchable(values): + """Dispatched callbacks coalesce above 1 kHz: those rates run with `irq=immediate` only.""" + update = values.get("update") + return values.get("irq") != "dispatched" or update is None or update.get("dispatched", True) + + +def expect_error(reason, call, *args, **options): + with pytest.raises(FirmwareError) as error: + call(*args, **options) + assert error.value.reason == reason, (args, options) + + +def count_rate(fw, index, window): + """Counter and interrupt deltas over about `window` seconds, with the host time between the two readings.""" + start = time.monotonic() + before = fw.tim.count(index) + time.sleep(window) + after = fw.tim.count(index) + return after, before, time.monotonic() - start + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "timer.timers") +@pytest.mark.matrix("timer.interrupt") +@pytest.mark.constraint(valid=dispatchable) +def test_update_marker(fw, ad3, need, board_cfg, timer_cfg, timer, irq, update): + """The marker toggles on every update interrupt: it runs at half the update rate.""" + dio = need.dio(timer_cfg["marker"]) + index = timer["timer"] + timclk = fw.tim.open(index, prescaler=update["prescaler"], period=update["period"], irq=irq, pin=timer_cfg["marker"]) + assert timclk == board_cfg.clock("timer") + fw.tim.start(index) + marker = timer_update_rate(timclk, update["prescaler"], update["period"]) / 2 + periods = timer_cfg["record_periods"] + capture = ad3.logic.record_for(periods / marker, trigger=(dio, "rising"), timeout=periods / marker + 2) + assert capture.frequency(dio) == pytest.approx(marker, rel=timer_cfg["tolerance"]["frequency"]) + + +@pytest.mark.board_params("timer", "timer.timers") +@pytest.mark.matrix("timer.interrupt") +@pytest.mark.constraint(valid=dispatchable) +def test_interrupt_count(fw, timer_cfg, timer, irq, update): + """`irqs` follows the update rate; dispatched callbacks at or below 1 kHz are not lost.""" + index = timer["timer"] + timclk = fw.tim.open(index, prescaler=update["prescaler"], period=update["period"], irq=irq) + rate = timer_update_rate(timclk, update["prescaler"], update["period"]) + fw.tim.start(index) + after, before, elapsed = count_rate(fw, index, timer_cfg["window_s"]) + tolerance = timer_cfg["tolerance"] + expected = rate * elapsed + counted = after.irqs - before.irqs + assert counted >= expected * (1 - tolerance["count"]) - rate * tolerance["latency_s"], (counted, expected) + assert counted <= expected * (1 + tolerance["count"]) + 1, (counted, expected) + + +@pytest.mark.board_params("timer", "timer.timers") +@pytest.mark.board_params("mode", values=["up", "down"]) +def test_free_running_counter(fw, timer_cfg, timer, mode): + """`irq=none` counts at `timclk / (prescaler + 1)`, down from `period` with `mode=down`, without interrupts.""" + index = timer["timer"] + free = timer_cfg["free_running"] + if mode == "down" and index in timer_cfg["up_only"]: + expect_error("unsupported", fw.tim.open, index, prescaler=free["prescaler"], period=free["period"], irq="none", mode=mode) + return + timclk = fw.tim.open(index, prescaler=free["prescaler"], period=free["period"], irq="none", mode=mode) + fw.tim.start(index) + after, before, elapsed = count_rate(fw, index, timer_cfg["window_s"]) + delta = after.cnt - before.cnt if mode == "up" else before.cnt - after.cnt + tolerance = timer_cfg["tolerance"] + rate = timclk / (free["prescaler"] + 1) + assert 0 < delta <= rate * elapsed * (1 + tolerance["count"]) + 1, (delta, rate * elapsed) + assert delta >= rate * (elapsed - 2 * tolerance["latency_s"]) * (1 - tolerance["count"]), (delta, rate * elapsed) + assert (before.irqs, after.irqs) == (0, 0) + + +@pytest.mark.board_params("timer", "timer.timers") +def test_stop_holds_the_counter(fw, timer_cfg, timer): + """`tim.stop` freezes the counter and the interrupts; `tim.start` resumes; repeated start and stop are harmless.""" + index = timer["timer"] + update = timer_cfg["interrupt"]["update"][0] + fw.tim.open(index, prescaler=update["prescaler"], period=update["period"], irq="immediate") + fw.tim.start(index) + fw.tim.start(index) + time.sleep(timer_cfg["window_s"]) + fw.tim.stop(index) + fw.tim.stop(index) + stopped = fw.tim.count(index) + assert stopped.irqs > 0 + time.sleep(timer_cfg["window_s"]) + assert fw.tim.count(index) == stopped + fw.tim.start(index) + time.sleep(timer_cfg["window_s"]) + assert fw.tim.count(index).irqs > stopped.irqs + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "timer.timers") +def test_stop_stops_the_marker(fw, ad3, need, timer_cfg, timer): + dio = need.dio(timer_cfg["marker"]) + index = timer["timer"] + update = timer_cfg["interrupt"]["update"][1] + timclk = fw.tim.open(index, prescaler=update["prescaler"], period=update["period"], irq="immediate", pin=timer_cfg["marker"]) + fw.tim.start(index) + fw.tim.stop(index) + marker = timer_update_rate(timclk, update["prescaler"], update["period"]) / 2 + capture = ad3.logic.record_for(timer_cfg["record_periods"] / marker) + assert capture.frequency(dio) == 0.0 + assert len(set(capture.channel(dio))) == 1, "the marker moved after tim.stop" + + +@pytest.mark.board_params("timer", "timer.timers") +def test_marker_is_released_on_close(fw, need, timer_cfg, timer): + """The marker pin belongs to the timer while it is open and returns to the pool with `tim.close`.""" + index, marker = timer["timer"], timer_cfg["marker"] + need.unloaded(marker) + fw.tim.open(index, irq="immediate", pin=marker) + expect_error("busy", fw.gpio.cfg, marker, "in") + fw.tim.close(index) + expect_error("notopen", fw.tim.count, index) + fw.gpio.cfg(marker, "in") + expect_error("busy", fw.tim.open, index, irq="immediate", pin=marker) + + +def test_open_errors(fw, board_cfg, timer_cfg): + """Argument errors in the protocol order: usage, range, pin, unsupported.""" + first = timer_cfg["timers"][0]["timer"] + wide = next((entry["timer"] for entry in timer_cfg["timers"] if timer_period_max(entry["timer"]) > 0xFFFF), None) + narrow = next(entry["timer"] for entry in timer_cfg["timers"] if timer_period_max(entry["timer"]) == 0xFFFF) + marker = timer_cfg["marker"] + cases = [ + ((first,), {"irq": "nmi"}, "usage"), + ((first,), {"mode": "sideways"}, "usage"), + ((first,), {"irq": "none", "pin": marker}, "usage"), + ((first,), {"prescaler": TIMER_PRESCALER_MAX + 1}, "range"), + ((first,), {"period": 0}, "range"), + ((narrow,), {"period": 0x10000}, "range"), + ((first,), {"pin": board_cfg.param("system.unbonded_pins")[0]}, "pin"), + ((first,), {"mode": "down"}, "unsupported"), + ] + cases += [((timer,), {}, "range") for timer in timer_cfg["missing"]] + cases += [((timer,), {"irq": "none", "mode": "down"}, "unsupported") for timer in timer_cfg["up_only"]] + for args, options, reason in cases: + expect_error(reason, fw.tim.open, *args, **options) + if wide is not None: + assert fw.tim.open(wide, period=0xFFFFFFFF, irq="none") > 0 + fw.tim.close(wide) + for command in ("start", "stop", "count", "close"): + expect_error("notopen", getattr(fw.tim, command), first) + for line in ("tim.count", f"tim.count {first} {first}", f"tim.start {first} ch=1"): + assert fw.terminal.command(line, check=False).reason == "usage", line + assert fw.terminal.command(f"tim.open {first} pin=nosuchalias", check=False).reason == "pin" + + +def test_one_timer_at_a_time(fw, timer_cfg): + first, second = (entry["timer"] for entry in timer_cfg["timers"][:2]) + fw.tim.open(first, irq="none") + expect_error("busy", fw.tim.open, second, irq="none") + expect_error("busy", fw.tim.open, first, irq="none") + + +@pytest.mark.board_params("timer", "timer.timers") +def test_timer_shared_with_other_groups(fw, board_cfg, timer): + """A timer serves one group: while `tim` holds it, pwm, tpwm, the encoder and the ADC trigger get `ERR busy`, + and `tim` gets it while pwm holds the timer.""" + index = timer["timer"] + fw.tim.open(index, irq="none") + expect_error("busy", fw.pwm.open, index, channels=[1]) + pwm_pin = next(entry for entry in board_cfg.param("timer_pwm.timers") if entry["timer"] == index)["pins"][0] + expect_error("busy", fw.tpwm.open, index, pins=[pwm_pin]) + encoder = next((entry for entry in board_cfg.param("qei.instances") if entry["index"] == index), None) + if encoder is not None: + expect_error("busy", fw.qei.open, index, a=encoder["a"], b=encoder["b"]) + if index in expect.ADC_TRIGGER_TIMERS: + expect_error("busy", fw.adc.open, board_cfg.param("adc.adc"), pins=[board_cfg.param("adc.inputs")[0]], timer=index) + fw.tim.close(index) + fw.pwm.open(index, channels=[1]) + expect_error("busy", fw.tim.open, index, irq="none") diff --git a/validation/host/tests/hil/test_timer_pwm.py b/validation/host/tests/hil/test_timer_pwm.py new file mode 100644 index 00000000..13aea184 --- /dev/null +++ b/validation/host/tests/hil/test_timer_pwm.py @@ -0,0 +1,255 @@ +"""Timer PWM (`hal::TimerPwmWithChannels`, `hal::PwmChannelGpio`) through the `tpwm` group: frequency and duty of +every channel, per-channel duties, `SetPulse` rewriting the period of every channel, unused (`-`) channels, starting +and stopping one channel or all, and the argument checks. + +Wiring set `bundle1` (WBA55 TIM2 with CH3/CH4: `bundle2`): the channel pins of `tests.timer_pwm.timers` on DIOs. +`SetDuty` writes CCR = ARR * duty / 100 and the output is high while the counter is below CCR, so 100 % keeps one low +counter tick per period (`groups.timers.pwm_duty_fraction`); the YAML prescaler makes that tick 1 us long. +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import expect +from hal_st_validation.groups.timers import TIMER_PRESCALER_MAX, pwm_duty_fraction, timer_period_max, timer_update_rate + + +@pytest.fixture +def tpwm_cfg(board_cfg): + return board_cfg.param("timer_pwm") + + +def expect_error(reason, call, *args, **options): + with pytest.raises(FirmwareError) as error: + call(*args, **options) + assert error.value.reason == reason, (args, options) + + +def wired(need, pins): + """DIO per channel position (None for `-`); skips when a channel pin is not wired.""" + return [None if pin is None else need.dio(pin) for pin in pins] + + +def several_channels(values): + timer = values.get("timer") + return timer is None or sum(pin is not None for pin in timer["pins"]) >= 2 + + +def record(ad3, cfg, frequency): + periods = cfg["record_periods"] + return ad3.logic.record_for(periods / frequency, timeout=periods / frequency + 2) + + +def check_channel(capture, dio, frequency, fraction, cfg): + """`fraction` 0 is a static low output; anything else a waveform with that high fraction.""" + bits = capture.channel(dio) + if fraction == 0: + assert set(bits) == {0}, f"DIO{dio} moved at 0 %" + return + assert capture.frequency(dio) == pytest.approx(frequency, rel=cfg["tolerance"]["frequency"]), f"DIO{dio} frequency" + quantisation = 2 * frequency / capture.rate + assert capture.duty(dio) == pytest.approx(fraction, abs=cfg["tolerance"]["duty"] + quantisation), f"DIO{dio} duty" + + +def is_stopped(capture, dio, cfg): + """No PWM on `dio`: a channel whose output is disabled is not driven (high impedance), so a few edges of a floating + line are tolerated, far fewer than the `record_periods` of a running channel.""" + return analysis.edge_count(capture.channel(dio), "rising") <= cfg["record_periods"] // 4 + + +def open_tpwm(fw, cfg, timer, pins=None): + """Opens with the YAML prescaler and period; returns `timclk`.""" + return fw.tpwm.open(timer["timer"], pins=timer["pins"] if pins is None else pins, prescaler=cfg["prescaler"], period=cfg["period"]) + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "timer_pwm.timers") +@pytest.mark.board_params("duty", "timer_pwm.duties") +def test_duty(fw, ad3, need, board_cfg, tpwm_cfg, timer, duty): + """One duty on every channel: frequency timclk / ((prescaler + 1) (period + 1)), high fraction of `SetDuty`.""" + dios = wired(need, timer["pins"]) + timclk = open_tpwm(fw, tpwm_cfg, timer) + assert timclk == board_cfg.clock("timer") + for channel, dio in enumerate(dios, 1): + if dio is not None: + fw.tpwm.duty(timer["timer"], channel, duty) + fw.tpwm.start(timer["timer"]) + frequency = timer_update_rate(timclk, tpwm_cfg["prescaler"], tpwm_cfg["period"]) + capture = record(ad3, tpwm_cfg, frequency) + for dio in dios: + if dio is not None: + check_channel(capture, dio, frequency, pwm_duty_fraction(tpwm_cfg["period"], duty), tpwm_cfg) + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "timer_pwm.timers") +@pytest.mark.constraint(valid=several_channels) +def test_channel_duties(fw, ad3, need, tpwm_cfg, timer): + """Each channel keeps its own duty: the channel number is the position in `pins`.""" + dios = wired(need, timer["pins"]) + timclk = open_tpwm(fw, tpwm_cfg, timer) + duties = tpwm_cfg["channel_duties"] + for channel in range(1, len(dios) + 1): + fw.tpwm.duty(timer["timer"], channel, duties[channel - 1]) + fw.tpwm.start(timer["timer"]) + frequency = timer_update_rate(timclk, tpwm_cfg["prescaler"], tpwm_cfg["period"]) + capture = record(ad3, tpwm_cfg, frequency) + for channel, dio in enumerate(dios, 1): + if dio is not None: + check_channel(capture, dio, frequency, pwm_duty_fraction(tpwm_cfg["period"], duties[channel - 1]), tpwm_cfg) + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "timer_pwm.timers") +def test_pulse_sets_the_period_of_every_channel(fw, ad3, need, tpwm_cfg, timer): + """`SetPulse(on, period)` writes the channel's compare value and the shared auto-reload: every channel changes + frequency, the others keep their compare value.""" + dios = wired(need, timer["pins"]) + index, pulse = timer["timer"], tpwm_cfg["pulse"] + timclk = open_tpwm(fw, tpwm_cfg, timer) + for channel in range(1, len(dios) + 1): + fw.tpwm.duty(index, channel, 50) + fw.tpwm.start(index) + fw.tpwm.pulse(index, 1, pulse["ccr"], pulse["period"]) + frequency = timer_update_rate(timclk, tpwm_cfg["prescaler"], pulse["period"]) + capture = record(ad3, tpwm_cfg, frequency) + for channel, dio in enumerate(dios, 1): + if dio is not None: + ccr = pulse["ccr"] if channel == 1 else tpwm_cfg["period"] * 50 // 100 + check_channel(capture, dio, frequency, ccr / (pulse["period"] + 1), tpwm_cfg) + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "timer_pwm.timers") +@pytest.mark.constraint(valid=several_channels) +def test_unused_channel(fw, ad3, need, tpwm_cfg, timer): + """A `-` channel gets a `DummyPinStm`: its pin stays free for other groups, the other channels run unchanged.""" + first = next(position for position, pin in enumerate(timer["pins"]) if pin is not None) + pins = list(timer["pins"]) + free = pins[first] + pins[first] = None + dios = wired(need, pins) + timclk = open_tpwm(fw, tpwm_cfg, timer, pins) + for channel in range(1, len(pins) + 1): + fw.tpwm.duty(timer["timer"], channel, 50) + fw.tpwm.start(timer["timer"]) + fw.gpio.cfg(free, "in") + frequency = timer_update_rate(timclk, tpwm_cfg["prescaler"], tpwm_cfg["period"]) + capture = record(ad3, tpwm_cfg, frequency) + for dio in dios: + if dio is not None: + check_channel(capture, dio, frequency, pwm_duty_fraction(tpwm_cfg["period"], 50), tpwm_cfg) + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "timer_pwm.timers") +@pytest.mark.constraint(valid=several_channels) +def test_start_and_stop_one_channel(fw, ad3, need, tpwm_cfg, timer): + """`ch=` starts or stops one channel; without it every channel; repeating either is harmless.""" + positions = [position for position, pin in enumerate(timer["pins"]) if pin is not None][:2] + dios = wired(need, timer["pins"]) + first, second = (dios[position] for position in positions) + channel = positions[0] + 1 + index = timer["timer"] + timclk = open_tpwm(fw, tpwm_cfg, timer) + for number in range(1, len(dios) + 1): + fw.tpwm.duty(index, number, 50) + frequency = timer_update_rate(timclk, tpwm_cfg["prescaler"], tpwm_cfg["period"]) + fraction = pwm_duty_fraction(tpwm_cfg["period"], 50) + fw.tpwm.start(index, ch=channel) + fw.tpwm.start(index, ch=channel) + capture = record(ad3, tpwm_cfg, frequency) + check_channel(capture, first, frequency, fraction, tpwm_cfg) + assert is_stopped(capture, second, tpwm_cfg), "a channel that was not started runs" + fw.tpwm.start(index) + fw.tpwm.stop(index, ch=channel) + capture = record(ad3, tpwm_cfg, frequency) + assert is_stopped(capture, first, tpwm_cfg), "the stopped channel runs" + check_channel(capture, second, frequency, fraction, tpwm_cfg) + fw.tpwm.stop(index) + fw.tpwm.stop(index) + capture = record(ad3, tpwm_cfg, frequency) + assert is_stopped(capture, first, tpwm_cfg) and is_stopped(capture, second, tpwm_cfg) + fw.tpwm.start(index) + check_channel(record(ad3, tpwm_cfg, frequency), second, frequency, fraction, tpwm_cfg) + + +def test_open_errors(fw, tpwm_cfg, board_cfg): + """Argument errors in the protocol order: usage, range, pin, unsupported.""" + timer = next(entry for entry in tpwm_cfg["timers"] if entry["timer"] in expect.SINGLE_CHANNEL_TIMERS) + index, pin = timer["timer"], timer["pins"][0] + wide = max(tpwm_cfg["timers"], key=lambda entry: len(entry["pins"])) + cases = [ + ((index,), {"pins": [None]}, "usage"), + ((index,), {"pins": [pin] * 5}, "usage"), + ((index,), {"pins": [pin], "prescaler": TIMER_PRESCALER_MAX + 1}, "range"), + ((index,), {"pins": [pin], "period": 0}, "range"), + ((index,), {"pins": [pin], "period": timer_period_max(index) + 1}, "range"), + ((index,), {"pins": [tpwm_cfg["foreign_pin"]]}, "pin"), + ((index,), {"pins": [pin, None]}, "unsupported"), + ] + cases += [((missing,), {"pins": [pin]}, "range") for missing in board_cfg.param("timer.missing")] + for args, options, reason in cases: + expect_error(reason, fw.tpwm.open, *args, **options) + raw = {f"tpwm.open {index}": "usage", f"tpwm.open {index} pins=,": "usage", f"tpwm.open {index} pins=nosuchalias": "pin"} + for line, reason in raw.items(): + assert fw.terminal.command(line, check=False).reason == reason, line + if len(wide["pins"]) > 1: + reversed_pins = list(reversed(wide["pins"])) + if reversed_pins != wide["pins"] and reversed_pins[0] is not None: + expect_error("pin", fw.tpwm.open, wide["timer"], pins=reversed_pins) + for command, args in (("duty", (1, 50)), ("pulse", (1, 1, 2)), ("start", ()), ("stop", ()), ("close", ())): + expect_error("notopen", getattr(fw.tpwm, command), index, *args) + + +def test_channel_errors(fw, need, tpwm_cfg): + timer = max(tpwm_cfg["timers"], key=lambda entry: len(entry["pins"])) + need.unloaded(*(pin for pin in timer["pins"] if pin is not None)) + index, channels = timer["timer"], len(timer["pins"]) + maximum = timer_period_max(index) + fw.tpwm.open(index, pins=timer["pins"]) + cases = [ + ("duty", (0, 50), "range"), + ("duty", (channels + 1, 50), "range"), + ("duty", (1, 101), "range"), + ("pulse", (1, 0, 0), "range"), + ("pulse", (channels + 1, 1, 2), "range"), + ] + if maximum < 0xFFFFFFFF: + cases += [("pulse", (1, maximum + 1, 2), "range"), ("pulse", (1, 1, maximum + 1), "range")] + for command, args, reason in cases: + expect_error(reason, getattr(fw.tpwm, command), index, *args) + expect_error("range", fw.tpwm.start, index, ch=channels + 1) + expect_error("range", fw.tpwm.stop, index, ch=0) + for line in (f"tpwm.duty {index} 1", f"tpwm.start {index} ch=x", f"tpwm.stop {index} channel=1", f"tpwm.duty {index} 1 50 1"): + assert fw.terminal.command(line, check=False).reason == "usage", line + fw.tpwm.duty(index, channels, 100) + fw.tpwm.pulse(index, channels, maximum, maximum) + + +def test_timer_shared_with_pwm_and_tim(fw, need, tpwm_cfg): + """`tpwm` holds its timer against pwm and tim, and one timer at a time.""" + first, second = tpwm_cfg["timers"][:2] + need.unloaded(*(pin for pin in first["pins"] if pin is not None)) + fw.tpwm.open(first["timer"], pins=first["pins"]) + expect_error("busy", fw.pwm.open, first["timer"], channels=[1]) + expect_error("busy", fw.tim.open, first["timer"], irq="none") + expect_error("busy", fw.tpwm.open, second["timer"], pins=second["pins"]) + fw.tpwm.close(first["timer"]) + fw.tim.open(first["timer"], irq="none") + expect_error("busy", fw.tpwm.open, first["timer"], pins=first["pins"]) + + +def test_channel_pins_are_claimed(fw, need, tpwm_cfg): + """The channel pins belong to `tpwm` while it is open and return to the pool with `tpwm.close`.""" + timer = tpwm_cfg["timers"][0] + need.unloaded(*(pin for pin in timer["pins"] if pin is not None)) + pin = next(pin for pin in timer["pins"] if pin is not None) + fw.tpwm.open(timer["timer"], pins=timer["pins"]) + expect_error("busy", fw.gpio.cfg, pin, "in") + fw.tpwm.close(timer["timer"]) + fw.gpio.cfg(pin, "in") + expect_error("busy", fw.tpwm.open, timer["timer"], pins=timer["pins"]) diff --git a/validation/host/tests/unit/test_fake_timers.py b/validation/host/tests/unit/test_fake_timers.py new file mode 100644 index 00000000..d68302ec --- /dev/null +++ b/validation/host/tests/unit/test_fake_timers.py @@ -0,0 +1,274 @@ +"""The fake `tim`, `tpwm`, `lptim` and `lptpwm` groups (argument order and reasons of validation/firmware/TimerGroup.cpp, +TimerPwmGroup.cpp, LpTimerGroup.cpp and LpTimerPwmGroup.cpp; PROTOCOL.md D.6-D.9), the timers and LPTIMs they share +with the other groups, the counters over a fake clock, the `groups.timers` wrappers and expectations.""" + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal + +from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS, FakeFirmware, FakeSerial +from hal_st_validation.firmware import Firmware +from hal_st_validation.groups import timers + + +class Clock: + def __init__(self) -> None: + self.now = 100.0 + + def __call__(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.now += seconds + + +def make(family="stm32wb55"): + clock = Clock() + fake = FakeFirmware(family=family, clock=clock, sleep=clock.sleep) + terminal = FirmwareTerminal(serial=FakeSerial(fake), timeout=0.5) + pins = WB55_PINS if family == "stm32wb55" else WBA55_PINS + return terminal, fake, clock, Firmware(terminal, pins) + + +def reason(terminal, line): + response = terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("tim.open", "usage"), + ("tim.open 1 2", "usage"), + ("tim.open 1 speed=1", "usage"), + ("tim.open x", "usage"), + ("tim.open 18", "range"), + ("tim.open 3", "range"), + ("tim.open 1 irq=nmi", "usage"), + ("tim.open 1 mode=sideways", "usage"), + ("tim.open 1 prescaler=65536", "range"), + ("tim.open 1 period=0", "range"), + ("tim.open 1 period=65536", "range"), + ("tim.open 1 prescaler=65536 irq=nmi", "range"), + ("tim.open 1 irq=none pin=gpio0", "usage"), + ("tim.open 1 irq=none pin=PZ1", "pin"), + ("tim.open 1 pin=PC7", "pin"), + ("tim.open 1 mode=down", "unsupported"), + ("tim.open 16 irq=none mode=down", "unsupported"), + ("tim.open 1 irq=none mode=down", "ok"), + ("tim.open 2 period=0xFFFFFFFF irq=none", "ok"), + ("tim.open 17 pin=gpio0 irq=immediate", "ok"), + ("tim.open 1 pin=terminaltx", "busy"), + ], +) +def test_timer_open_reasons(line, expected): + terminal, *_ = make() + assert reason(terminal, line) == expected + + +def test_timer_open_reports_the_kernel_clock_and_tracks(): + terminal, fake, _, fw = make("stm32wba55") + assert fw.tim.open(3, irq="immediate", pin="gpio0") == 100_000_000 + assert fake.received[-1] == "tim.open 3 irq=immediate pin=PB14" + assert fw.open_instances == [("tim", 3)] + assert reason(terminal, "gpio.cfg gpio0 in") == "busy" + fw.close_all() + assert fake.received[-1] == "tim.close 3" + assert reason(terminal, "gpio.cfg gpio0 in") == "ok" + + +def test_timer_counts_over_the_clock(): + terminal, _, clock, fw = make() + fw.tim.open(1, prescaler=63, period=999, irq="dispatched") + assert fw.tim.count(1) == timers.TimerCount(0, 0) + fw.tim.start(1) + clock.sleep(0.5) + reading = fw.tim.count(1) + assert reading.irqs == 500 + fw.tim.stop(1) + clock.sleep(1) + assert fw.tim.count(1) == reading + fw.tim.start(1) + fw.tim.start(1) + clock.sleep(0.03125) + assert fw.tim.count(1) == timers.TimerCount(250, 531) + assert reason(terminal, "tim.count 2") == "notopen" + assert reason(terminal, "tim.count 1 ch=1") == "usage" + + +def test_free_running_counts_down_without_interrupts(): + _, _, clock, fw = make() + fw.tim.open(2, prescaler=63999, period=65535, irq="none", mode="down") + fw.tim.start(2) + clock.sleep(0.25) + assert fw.tim.count(2) == timers.TimerCount(65535 - 250, 0) + + +def test_timer_shared_with_pwm_qei_adc_and_tpwm(): + terminal, *_ = make() + terminal.command("tim.open 2 irq=none") + for line in ("pwm.open 2 channels=1", "qei.open 2", "adc.open 1 pins=ain1 timer=2", "tpwm.open 2 pins=tim2ch1", "tim.open 1"): + assert reason(terminal, line) == "busy", line + terminal.command("tim.close 2") + terminal.command("pwm.open 2 channels=1") + assert reason(terminal, "tim.open 2") == "busy" + assert reason(terminal, "tim.open 1") == "ok" + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("tpwm.open 1", "usage"), + ("tpwm.open 1 pins=-", "usage"), + ("tpwm.open 1 pins=-,-", "usage"), + ("tpwm.open 1 pins=,", "usage"), + ("tpwm.open 1 pins=PA8,PA9,PA10,PA11,PA8", "usage"), + ("tpwm.open 1 pins=PA8 prescaler=65536", "range"), + ("tpwm.open 1 pins=PA8 period=0", "range"), + ("tpwm.open 1 pins=PZ1 period=0", "range"), + ("tpwm.open 1 pins=PA8 period=65536", "range"), + ("tpwm.open 2 pins=PA15 period=0xFFFFFFFF", "ok"), + ("tpwm.open 1 pins=PZ1", "pin"), + ("tpwm.open 1 pins=PA9", "pin"), + ("tpwm.open 1 pins=-,PA8", "pin"), + ("tpwm.open 16 pins=PA6,-", "unsupported"), + ("tpwm.open 16 pins=PA6,PA7", "pin"), + ("tpwm.open 3 pins=PA8", "range"), + ("tpwm.open 1 pins=-,PA9,PA10", "ok"), + ("tpwm.open 1 pins=PA8,PA9,PA10,PA11", "ok"), + ], +) +def test_timer_pwm_open_reasons(line, expected): + terminal, *_ = make() + assert reason(terminal, line) == expected + + +def test_timer_pwm_commands(): + terminal, fake, _, fw = make() + assert fw.tpwm.open(1, pins=[None, "tim1ch2", "tim1ch3"], prescaler=63, period=99) == 64_000_000 + assert fake.received[-1] == "tpwm.open 1 pins=-,PA9,PA10 prescaler=63 period=99" + assert reason(terminal, "gpio.cfg PA8 in") == "ok", "a `-` channel claims no pin" + assert reason(terminal, "gpio.cfg PA9 in") == "busy" + fw.tpwm.duty(1, 2, 50) + fw.tpwm.pulse(1, 3, 10, 199) + fw.tpwm.start(1, ch=2) + fw.tpwm.stop(1) + assert fake.received[-4:] == ["tpwm.duty 1 2 50", "tpwm.pulse 1 3 10 199", "tpwm.start 1 ch=2", "tpwm.stop 1"] + state = fake.opened[("tpwm", "1")] + assert state["ccr"] == [0, 49, 10] and state["period"] == 199 + for line, expected in ( + ("tpwm.duty 1 0 50", "range"), + ("tpwm.duty 1 4 50", "range"), + ("tpwm.duty 1 1 101", "range"), + ("tpwm.duty 1 1", "usage"), + ("tpwm.pulse 1 1 65536 2", "range"), + ("tpwm.pulse 1 1 1 0", "range"), + ("tpwm.start 1 ch=4", "range"), + ("tpwm.start 1 ch=x", "usage"), + ("tpwm.stop 1 channel=1", "usage"), + ("tpwm.duty 2 1 50", "notopen"), + ("tpwm.open 2 pins=PA15", "busy"), + ("pwm.open 1 channels=1", "busy"), + ("tim.open 1", "busy"), + ): + assert reason(terminal, line) == expected, line + fw.tpwm.close(1) + assert reason(terminal, "tpwm.duty 1 1 50") == "notopen" + + +@pytest.mark.parametrize( + ("family", "line", "expected"), + [ + ("stm32wb55", "lptim.open 0", "range"), + ("stm32wb55", "lptim.open 3", "range"), + ("stm32wb55", "lptim.open 1 period=0", "range"), + ("stm32wb55", "lptim.open 1 period=65536", "range"), + ("stm32wb55", "lptim.open 1 prescaler=0", "range"), + ("stm32wb55", "lptim.open 1 prescaler=3", "range"), + ("stm32wb55", "lptim.open 1 prescaler=256", "range"), + ("stm32wb55", "lptim.open 1 prescaler=3 pin=PZ1", "pin"), + ("stm32wb55", "lptim.open 1 prescaler=3 irq=none pin=gpio0", "range"), + ("stm32wb55", "lptim.open 1 irq=nmi", "usage"), + ("stm32wb55", "lptim.open 1 irq=none pin=gpio0", "usage"), + ("stm32wb55", "lptim.open 1 pin=PC7", "pin"), + ("stm32wb55", "lptim.open 1 rep=256", "range"), + ("stm32wb55", "lptim.open 1 rep=0", "unsupported"), + ("stm32wb55", "lptim.open 2 prescaler=128 pin=gpio0", "ok"), + ("stm32wba55", "lptim.open 1 rep=255", "ok"), + ("stm32wba55", "lptim.open 2 prescaler=64 irq=immediate pin=gpio0", "ok"), + ], +) +def test_lptim_open_reasons(family, line, expected): + terminal, *_ = make(family) + assert reason(terminal, line) == expected + + +def test_lptim_counts_and_reports_its_clock(): + _, fake, clock, fw = make("stm32wba55") + assert fw.lptim.open(1, prescaler=128, period=799, rep=1, irq="immediate") == 100_000_000 + assert fake.received[-1] == "lptim.open 1 period=799 prescaler=128 irq=immediate rep=1" + fw.lptim.start(1) + clock.sleep(1.0) + assert fw.lptim.count(1).irqs == int(timers.lptim_update_rate(100_000_000, 128, 799, 1)) + + +def test_lptim_shared_with_the_encoder_and_lptpwm(): + terminal, *_ = make("stm32wba55") + terminal.command("qei.open 2 lp=1 a=lptim2in1 b=lptim2in2") + assert reason(terminal, "lptim.open 2") == "busy" + assert reason(terminal, "lptpwm.open 2 pins=PA11") == "busy" + assert reason(terminal, "lptim.open 1") == "ok", "LPTIM1 is free" + terminal.command("qei.close 2") + terminal.command("lptim.close 1") + terminal.command("lptpwm.open 2 pins=PA11,PA1") + assert reason(terminal, "qei.open 2 lp=1 a=lptim2in1 b=lptim2in2") == "busy" + assert reason(terminal, "lptim.open 2") == "busy" + assert reason(terminal, "tim.open 2") == "ok", "TIM2 is not LPTIM2" + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("lptpwm.open 1", "usage"), + ("lptpwm.open 1 pins=-,-", "usage"), + ("lptpwm.open 1 pins=-,PA15,PA15", "usage"), + ("lptpwm.open 3 pins=PA11", "range"), + ("lptpwm.open 1 pins=-,PA15 prescaler=3", "range"), + ("lptpwm.open 1 pins=-,PA15 period=65536", "range"), + ("lptpwm.open 1 pins=PB11", "pin"), + ("lptpwm.open 1 pins=PA15", "pin"), + ("lptpwm.open 2 pins=PA1,PA11", "pin"), + ("lptpwm.open 1 pins=-,PA8", "busy"), + ("lptpwm.open 1 pins=-,PA15", "ok"), + ("lptpwm.open 2 pins=PA11,PA1 prescaler=4 period=999", "ok"), + ], +) +def test_lptim_pwm_open_reasons(line, expected): + terminal, *_ = make("stm32wba55") + assert reason(terminal, line) == expected + + +def test_lptim_pwm_is_unsupported_on_wb55_only(): + terminal, *_ = make() + assert reason(terminal, "lptpwm.open 1 pins=PA15") == "unsupported" + terminal, _, _, fw = make("stm32wba55") + assert fw.lptpwm.open(2, pins=["lptim2ch1", None]) == 100_000_000 + with pytest.raises(FirmwareError) as error: + fw.lptpwm.duty(2, 3, 50) + assert error.value.reason == "range" + + +def test_groups_are_attached(): + *_, fw = make() + assert isinstance(fw.tim, timers.Timer) and isinstance(fw.tpwm, timers.TimerPwm) + assert isinstance(fw.lptim, timers.LpTimer) and isinstance(fw.lptpwm, timers.LpTimerPwm) + + +def test_expectations(): + assert timers.timer_update_rate(64_000_000, 63, 999) == 1000 + assert timers.lptim_update_rate(64_000_000, 64, 999) == 1000 + assert timers.lptim_update_rate(100_000_000, 128, 799, 3) == pytest.approx(244.140625) + assert timers.pwm_high_ticks(99, 50) == 49 + assert timers.pwm_duty_fraction(99, 100) == 0.99, "100 % keeps one low tick" + assert timers.pwm_duty_fraction(99, 0) == 0 + assert timers.pwm_high_ticks(0xFFFFFFFF, 50) == ((0xFFFFFFFF * 50) & 0xFFFFFFFF) // 100, "SetDuty wraps in 32 bits" + assert (timers.timer_period_max(2), timers.timer_period_max(16)) == (0xFFFFFFFF, 0xFFFF) From 7b628a290cb2f2589af7293e6e3fb278a609fc06 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:40:53 +0000 Subject: [PATCH 42/57] feat(validation): validate PWM options, ADC single/temperature/burst and the circular DMA channel pwm.open takes every PwmStm alignment (mode=edge|edgedown|center|centerup|centerboth), preload=0|1, brkfilter=0-15 (needs brk) and trgo=; modes other than edge and any trgo need a master timer with a counter mode select, so TIM16/TIM17 answer ERR unsupported instead of reaching the driver's assert or reserved CR2 bits. adc.open trgo= runs the DMA circular sequence on the trigger output of a timer the pwm group drives, without taking the timer: ERR busy when another group holds it, ERR unsupported when nobody does or the ADC cannot trigger from its TRGO (STM32WBA55 ADC4 reaches TIM1 through TRGO2 only). The adc group now holds the ADC and its DMA channel in ResourceAllocation. New stateless groups: - ain.read converts a pin (AnalogToDigitalPinImplStm) or the temperature sensor (AnalogToDigitalInternalTemperatureStm, __LL_ADC_CALC_TEMPERATURE at 3300 mV); - ain.burst builds one AdcTriggeredByTimerWithDma over a 256-sample buffer and calls Measure(n) once or twice, paced by TIM2, reporting the samples or their statistics and the duration of each measurement; - dma.wave drives a pin from a CircularTransmitDmaChannel writing 32-bit BSRR words on TIM2 update requests (DMA2 channel 4 on STM32WB55, GPDMA1 channel 8 on STM32WBA55), stopped before it is destroyed. Each claims the ADC, TIM2, DMA channels and pins it uses for the length of the command. Host: groups/analog.py (fw.ain, fw.dma and the TIM2 pacing expectations), fakes/analog.py with deferred replies, the fake's pwm/adc trgo rules, test_pwm_ext.py, test_ain.py (B.12: n samples in n/rate, twice on one driver), test_dma.py (32-bit transfers on both MCUs, B.4), and the STM32WBA55 bundle2 PWM outputs (TIM2 CH3/CH4, TIM16 CH1N) in the board file. test_parameters_reference_wired_pins accepts PWM timers wired in any set (the WBA55 bundle2 timers). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- validation/PROTOCOL.md | 40 ++- validation/README.md | 6 + validation/firmware/AdcFactory.cpp | 137 +++++++- validation/firmware/AdcFactory.hpp | 27 +- validation/firmware/AnalogInputGroup.cpp | 287 +++++++++++++++++ validation/firmware/AnalogInputGroup.hpp | 83 +++++ validation/firmware/CMakeLists.txt | 4 + validation/firmware/DmaGroup.cpp | 177 +++++++++++ validation/firmware/DmaGroup.hpp | 51 +++ validation/firmware/Main.cpp | 7 +- validation/firmware/PwmFactory.cpp | 61 +++- validation/firmware/PwmFactory.hpp | 5 +- validation/host/boards/nucleo_wb55rg.yaml | 95 ++++++ validation/host/boards/nucleo_wba55cg.yaml | 114 +++++++ .../host/hal_st_validation/fake_firmware.py | 7 +- .../host/hal_st_validation/fakes/analog.py | 179 +++++++++++ .../host/hal_st_validation/groups/analog.py | 157 ++++++++++ validation/host/tests/hil/test_ain.py | 195 ++++++++++++ validation/host/tests/hil/test_dma.py | 115 +++++++ validation/host/tests/hil/test_pwm_ext.py | 296 ++++++++++++++++++ validation/host/tests/unit/test_config.py | 10 +- .../host/tests/unit/test_fake_analog.py | 284 +++++++++++++++++ .../host/tests/unit/test_fake_firmware.py | 11 + 23 files changed, 2310 insertions(+), 38 deletions(-) create mode 100644 validation/firmware/AnalogInputGroup.cpp create mode 100644 validation/firmware/AnalogInputGroup.hpp create mode 100644 validation/firmware/DmaGroup.cpp create mode 100644 validation/firmware/DmaGroup.hpp create mode 100644 validation/host/hal_st_validation/fakes/analog.py create mode 100644 validation/host/hal_st_validation/groups/analog.py create mode 100644 validation/host/tests/hil/test_ain.py create mode 100644 validation/host/tests/hil/test_dma.py create mode 100644 validation/host/tests/hil/test_pwm_ext.py create mode 100644 validation/host/tests/unit/test_fake_analog.py diff --git a/validation/PROTOCOL.md b/validation/PROTOCOL.md index fe912568..ae3d5028 100644 --- a/validation/PROTOCOL.md +++ b/validation/PROTOCOL.md @@ -99,13 +99,17 @@ The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix ## PWM (`hal::PwmStm`, `sync=1` selects `hal::SynchronousPwmStm`) -- `pwm.open [channels=[,...]] [pins=[:][,...]] [freq=] [mode=edge|center] [prescaler=] [dead=|off] [inv=0|1] [invn=0|1] [idle=0|1] [idlen=0|1] [brk=] [brkpol=low|high] [brkauto=0|1] [sync=0|1]` → `OK pwmclk=` +- `pwm.open [channels=[,...]] [pins=[:][,...]] [freq=] [mode=edge|edgedown|center|centerup|centerboth] [prescaler=] [dead=|off] [inv=0|1] [invn=0|1] [idle=0|1] [idlen=0|1] [brk=] [brkpol=low|high] [brkauto=0|1] [sync=0|1] [preload=0|1] [brkfilter=<0-15>] [trgo=reset|enable|update|oc1|oc1ref|oc2ref|oc3ref|oc4ref]` → `OK pwmclk=` - `channels` or `pins` is required (`ERR usage`); 1 to 4 channels, each at most once (`ERR usage`), numbered 1-4 - a `pins` entry is the channel output, optionally followed by `:` and its complementary output; `-` leaves a position unused (`PA8:PA7` drives CH1 and CH1N, `PA8` or `PA8:-` CH1 only, `-:PA7` CH1N only); with `channels` the entries follow the channel order, without it each channel follows from its pin - `channels` without `pins` takes the first pin of the pinout table for each channel that is neither reserved nor missing from the package, and no complementary output - - defaults: `freq=10000 mode=edge prescaler=0 dead=off inv=0 invn=0 idle=0 idlen=0 brkpol=high brkauto=0 sync=0` + - defaults: `freq=10000 mode=edge prescaler=0 dead=off inv=0 invn=0 idle=0 idlen=0 brkpol=high brkauto=0 sync=0 preload=1`, no `brkfilter` and no `trgo` - `pwmclk` is the counter clock, the timer kernel clock divided by `prescaler + 1`; `prescaler` is 0-65535 - - `mode=center` is the centre-aligned counter (`ERR unsupported` on TIM16/TIM17) + - `mode` selects the counter (`hal::PwmStmBase::Alignment`): `edge` counts up; `edgedown` counts down and cannot reach 0 %: at duty 0 the output stays active for one counter tick per period, since PWM mode 1 is active while the counter is at or below the compare value; + `center`, `centerup` and `centerboth` count up and down and give the same waveform (they differ only in when the compare flags are set); every mode but `edge` needs a counter mode select, TIM1, TIM2 or (STM32WBA55) TIM3 (`ERR unsupported` on TIM16/TIM17) + - `preload=1` buffers period and compare values, so a `pwm.duty` or `pwm.freq` takes effect at the next update event; with `preload=0` it takes effect at once, and a write in the middle of a period cuts or stretches that period's pulse + - `brkfilter` is the break input filter (BDTR.BKF, 0 = none up to 15 = 8 samples at the timer kernel clock / 32, 256 kernel clocks; the reference manual lists every step); it needs `brk` (`ERR usage`) + - `trgo` selects the trigger output (TIMx_CR2.MMS) an `adc.open trgo=` sequence runs on: `reset`, `enable`, `update`, `oc1` (compare pulse of channel 1), `oc1ref`-`oc4ref` (the channel's reference signal); without `trgo` the trigger output is `reset`; it needs a master timer, TIM1, TIM2 or (STM32WBA55) TIM3 (`ERR unsupported` on TIM16/TIM17) - `dead` is the dead time inserted between a channel and its complementary output, at most 1000000 ns; it saturates at the largest dead time the DTG field encodes - `inv` and `invn` invert every channel output or complementary output, `idle` and `idlen` set their level while the outputs are disabled - `brk=` muxes the break input of the timer (`ERR pin` for another pin); `brkpol` is the active level and `brkauto=1` re-enables the outputs automatically after the break input releases @@ -115,7 +119,7 @@ The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix - `pwm.duty [duty2%] [duty3%] [duty4%]` → `OK`; one duty per opened channel in channel order, or a single duty for all of them, starts the outputs; duty accepts decimals (`12.5`, up to 4 digits), `0` and `100` - `pwm.freq ` → `OK` - `pwm.stop ` → `OK` -- A frequency whose period is under 2 counter ticks, or whose auto-reload does not fit the counter (16 bits, 32 bits on TIM2), returns `ERR range`, both in `pwm.open` and `pwm.freq`; with `ticks = pwmclk / hz` (rounded down) the auto-reload is `ticks - 1` edge aligned and `ticks / 2` centre aligned, where the counter runs up and down for a period of `2 x ARR` ticks +- A frequency whose period is under 2 counter ticks, or whose auto-reload does not fit the counter (16 bits, 32 bits on TIM2), returns `ERR range`, both in `pwm.open` and `pwm.freq`; with `ticks = pwmclk / hz` (rounded down) the auto-reload is `ticks - 1` for `edge` and `edgedown` and `ticks / 2` for the centre-aligned modes, where the counter runs up and down for a period of `2 x ARR` ticks - `pwm.close ` → `OK` ## UART (`hal::UartStm`, `dma=1` selects `hal::UartStmDma`, `duplex=1` selects `hal::UartStmDuplexDma`, `sync=1` selects `hal::SynchronousUartStm`) @@ -167,14 +171,40 @@ The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix ## ADC (`hal::AdcStm` with `hal::AdcDmaMultiChannelStmBase`) -- `adc.open pins=[,...] [sampling=] [timer=] [rate=]` → `OK` +- `adc.open pins=[,...] [sampling=] [timer=] [rate=] [trgo=]` → `OK` - `pins` is required (`ERR usage`), one conversion per pin in the given order, at most 8 (`ERR range`); a pin without an ADC channel returns `ERR pin` - `sampling` is the sampling time of every channel, in ADC clock cycles: WB55 `2.5`, `6.5`, `12.5`, `24.5`, `47.5`, `92.5`, `247.5`, `640.5` (default `2.5`); WBA55 `1.5`, `3.5`, `7.5`, `12.5`, `19.5`, `39.5`, `79.5`, `814.5` (default `3.5`); the value is handed to the driver's per-channel `samplingTime` - without `timer` each run converts the sequence once from a software trigger (DMA one-shot mode), and the firmware starts the next run from the event loop after the previous one completed - `timer=` triggers the conversions from the TRGO of timer `t` at `rate` runs per second (default 1000, 1-100000, `ERR range` outside) in DMA circular mode; the timers the driver can trigger from are TIM1 and TIM2 (`ERR unsupported` for others); `rate` without `timer` returns `ERR usage` + - `trgo=` converts the sequence once per trigger output of timer `t` while the `pwm` group drives it (open `pwm.open ... trgo=` first; `adc.open` never takes the timer), in DMA circular mode; nothing arrives before `pwm.duty` starts the timer + - `trgo` with `timer` or `rate` returns `ERR usage`, a timer the MCU lacks `ERR range` + - `ERR unsupported` when no group holds the timer, or when the ADC cannot trigger from its TRGO: TIM1 and TIM2 on STM32WB55, TIM2 only on STM32WBA55 (ADC4 reaches TIM1 through TRGO2, which PWM leaves at reset) + - `ERR busy` when another group (encoder, timer-triggered ADC, `ain.burst`, `dma.wave`, `tim`, `tpwm`) holds the timer + - while open the group holds the ADC and its DMA channel (channel 7 of DMA1 on STM32WB55, of GPDMA1 on STM32WBA55), which `ain` needs too (`ERR busy` both ways) - `adc.measure [n=]` → `OK samples=[,...]` (raw 12-bit codes); `n` is the number of sequence runs (default 1), each contributing one value per pin, at most 64 values; returns `ERR timeout` after 1000 ms - `adc.close ` → `OK` +## Analog input (`hal::AnalogToDigitalPinImplStm`, `hal::AnalogToDigitalInternalTemperatureStm`, `hal::AdcTriggeredByTimerWithDma`) + +Each command builds its drivers, holds the ADC (and for `ain.burst` TIM2 and the ADC's DMA channel, which `spis` receives on as well on STM32WBA55) until it answers and then releases them; while `adc` is open, another group holds one of them, or a command of this group still runs, it answers `ERR busy`. `` is the ADC of `adc.open` (1 on STM32WB55, 4 on STM32WBA55, `ERR range` otherwise). + +- `ain.read [sampling=]` → `OK code=`, or `OK code= mcelsius=` for `temp` + - one conversion of `pin` (an analog pin, `ERR pin` otherwise) or of the internal temperature sensor; `sampling` takes the values of `adc.open` (default the same); the STM32WBA55 driver samples every channel with its common sampling time (79.5 cycles) whatever `sampling` says + - `mcelsius` is `__LL_ADC_CALC_TEMPERATURE` with VDDA = 3300 mV and the factory calibration, in whole degrees (a multiple of 1000); the sensor needs a long sampling time (WB55 `640.5`, WBA55 `814.5`), the driver's default is shorter than its minimum + - `ERR timeout` after 1000 ms +- `ain.burst n=<1-256> rate=<10-100000> [repeat=1|2] [out=list|stats]` → `OK samples=,... us=` or `OK n= min= max= mean= us=` + - one `AdcTriggeredByTimerWithDma` over a 256-sample buffer converts `pin` on every TIM2 update at `rate` per second (TIM2 is the driver's timer); each command calls `Measure(n)` `repeat` times on that driver + - `n` and `rate` are required (`ERR usage`); `out=list` (default) lists the samples and allows `n` up to 64 (`ERR range` above), `out=stats` reports the number of samples, their minimum, maximum and mean (rounded); `us` is the time from `Measure(n)` to its result + - with `repeat=2` the first measurement is reported as `EVT ain index= run=1 ...` with the same fields, the second as the final line + - `ERR timeout` after `repeat` × (n / rate + 1 ms) + 1000 ms; the group stays busy until the measurement completes + +## DMA (`hal::CircularTransmitDmaChannel`) + +- `dma.wave rate=<1-1000000> pattern= [ms=<1-10000>]` → `OK` after `ms` (default 50) + - drives `pin` (an output) with `pattern`, 1 to 32 bytes, one bit per TIM2 update at `rate` per second, byte by byte and least significant bit first, repeated: TIM2 update requests make a `CircularTransmitDmaChannel` write a 32-bit set/reset word to the port's BSRR (32-bit memory and peripheral transfers) + - the channel is DMA2 channel 4 on STM32WB55 and GPDMA1 channel 8 on STM32WBA55; TIM2, the channel and the pin are held until the reply, so a group using any of them answers `ERR busy`, and so does `dma.wave` while they are in use + - `rate` and `pattern` are required (`ERR usage`); `pattern=-` or an odd number of hex digits is `ERR usage`, more than 32 bytes `ERR range` + ## Quadrature encoder (`hal::SynchronousQuadratureEncoderStm`, `lp=1` selects `hal::SynchronousQuadratureEncoderLpTimStm`) - `qei.open [lp=0|1] [a=] [b=] [idx=] [res=] [offset=] [inva=0|1] [invb=0|1] [cap=a|b|ab|rise|fall] [filter=<0-15>] [vel=|off]` → `OK` diff --git a/validation/README.md b/validation/README.md index 905fbb99..d7d243f4 100644 --- a/validation/README.md +++ b/validation/README.md @@ -458,6 +458,8 @@ SPI1 to SPI3; SPI3 is observed through DIO14, DIO10, DIO11 and DIO15. The PB8 en - 1-4 channels, complementary outputs and complementary-only outputs with dead time and inversion (shoot-through check), the dead-time limit; - idle levels, the break input with both polarities and automatic re-enable; - frequency changes and stop, the features each timer offers (`unsupported` elsewhere), argument errors, one timer at a time, and that a re-open forgets dead time and break. +- `test_pwm_ext.py` - the PWM options of D.10: frequency, duty and alignment of every counter mode (rising edges shared counting up, falling edges counting down, pulse centres centre aligned), the one-tick pulse `edgedown` keeps at 0 %, + compare preload (duty 80 % / 20 % written while the logic analyzer records: cut pulses only without preload), the break filter against AD3 pulses shorter and longer than it, `adc.open trgo=` paced by the PWM's trigger output (`update`, `oc1ref`), the timers that offer neither, and argument errors. - `test_uart.py` - every baud rate x parity x driver variant (interrupt, DMA, duplex DMA, synchronous) on LPUART1 and USART2, both directions against the AD3 UART, frames decoded from the firmware TX line and the bit rate measured on it, large payloads, full-duplex streaming, RTS/CTS flow control per variant, send timeout with CTS held, re-open with other settings, TX/RX swap, the baud limits of each instance, argument errors and default pins. @@ -466,6 +468,8 @@ SPI1 to SPI3; SPI3 is observed through DIO14, DIO10, DIO11 and DIO15. The PB8 en - `test_spi_slave.py` - `SpiSlaveStmDma` clocked by the AD3 SPI master: full duplex, send-only and receive-only transfers of 1-1024 bytes at several clock rates (CRC beyond 128 bytes), clocks while nothing is armed, a master clocking more or less than armed, `spis.result` waiting and busy, cancel and re-open, argument errors and the instance shared with `spi`; with `--with spiloop` the board's own SPI master (every driver) against the slave on the other instance, both ways. - `test_adc.py` - wavegen DC levels against raw 12-bit codes (checked against the scope when it is wired), sequences of up to 8 conversions, every sampling time, timer-triggered rates and the measure timeout, timer sharing with PWM and the encoder, unsupported trigger timers, argument errors and limits, and ADC pins against GPIO. +- `test_ain.py` - single conversions against wavegen levels and every listed sampling time, the internal temperature sensor in its room-temperature range, TIM2-paced bursts that give exactly n samples in n / rate (B.12), also twice on one driver, burst values and a wavegen ramp followed at its slope, the ADC, TIM2 and DMA channel shared with `adc` and `pwm`, argument errors. +- `test_dma.py` - `dma.wave`: 8- to 64-bit patterns at 1 kHz to 1 MHz decoded from the logic analyzer (32-bit DMA transfers to BSRR on both MCUs, B.4), TIM2 and the pin shared with `pwm` and `gpio`, argument errors. - `test_qei.py` - position counts for frequency x cycles x direction x decoding x phase inversion, physically inverted phases restored by `inva`/`invb`, offset and rollover, speed, the index input through `qei.index`, default pins, the resolution limits, argument errors, one encoder at a time and timer sharing. - The LPTIM encoders with `cap=ab|rise|fall`: NUCLEO-WB55RG LPTIM1 in bundle2; NUCLEO-WBA55CG LPTIM2 in bundle1 and LPTIM1 in bundle2. - `test_timer.py` - for `FreeRunningTimerStm` and `TimerWithInterruptStm` on every timer: the update rate on the marker pin (gpio0) with immediate and dispatched callbacks (dispatched at most 1 kHz), interrupt counts against the elapsed time, the free-running counter up and down (`ERR unsupported` down on TIM16/TIM17). @@ -521,6 +525,7 @@ In `hal_st_validation` (hal-st specific): - `i2c.py` - I2C bus helpers: decoding SCL/SDA captures (`i2c_decode`), SCL timing and rise times, `expected_timing` (the host twin of the firmware's TIMINGR computation), and AD3 captures and SDA pulses started by a START condition; `groups/i2c.py` - the `fw.i2c`, `fw.i2cs` and `fw.eeprom` wrappers; `fakes/i2c.py` - their fake groups with one shared bus, the target and a 24LC256. - `spiwords.py` - SPI words of 4-16 bits: a logic-capture decoder without the bench decoder's 8-bit mask (`spi_decode_words`) and the frame model of `SpiMasterStmDma` with a data size configurator (`spi_frames`, `spi_bytes`); `groups/spis.py` - the `fw.spis` wrapper; `fakes/spis.py` - its fake group and the SPI loop of the fake `spi.xfer` (`FakeFirmware.spi_loop`). - `groups/timers.py` - `fw.tim`, `fw.tpwm`, `fw.lptim`, `fw.lptpwm` and their expected waveforms (`timer_update_rate`, `lptim_update_rate`, `pwm_duty_fraction`); `fakes/timers.py` is their fake firmware model. +- `groups/analog.py` - `fw.ain` (`read`, `burst` with one `BurstRun` per measurement) and `fw.dma` (`wave`), with the expectations of the TIM2 pacing (`trigger_timing`, `trigger_rate`, `burst_seconds`) and the wave bit order (`wave_bits`); `fakes/analog.py` models both groups and their deferred replies. - `protocol.py` - the hal-st part of the protocol: error reasons, `P` pins (ports A-K, index 0-15) and the generic alias names (`normalize_pin`, `parse_pin_map`). - `config.py` - board file loading, wiring-set merging, parameter matrices, overrides and known gaps; `expect.py` - expected STM32 values (PWM quantisation and range, SPI prescaler, UART baud-rate register limits, WWDG prescaler and period, ADC codes, encoder counts). - `pairwise.py` - the full product and the deterministic pairwise generator behind `--depth`. @@ -533,6 +538,7 @@ In the firmware (`firmware/`): - `PinFactoryStm` builds `hal::GpioPinStm`s over the generated pinout tables (bonded pins only, analog sharing, one EXTI line per port); `BoardInfoStm` reports the board, clock and reset cause; `TimerAllocation` keeps PWM, encoder and timer-triggered ADC off each other's timer. - `ResourceAllocation` keeps the groups that share an I2C, SPI, ADC or LPTIM instance, a DMA channel or an HSEM semaphore off each other, with the owner ids of `Owners.hpp`; `Payload` generates the `len=`/`pattern=` payloads and the `out=crc` CRCs; `ChannelPins`, `Stopwatch` (microseconds from the cycle counter) and `HsemMaster` (the one HSEM master of the STM32WB55) serve the newer groups. - One factory per command group (`UartFactory`, `SpiFactory`, `AdcFactory`, `PwmFactory`, `QeiFactory` with the `qei.index` command, `WatchDogFactory`) parses the hal-st options and builds the driver; `UnsupportedGroups` answers the rest. + `AnalogInputGroup` (`ain.read`, `ain.burst`) and `DmaGroup` (`dma.wave`) build their drivers per command and claim the ADC, TIM2 and the DMA channels through `TimerAllocation` and `ResourceAllocation`. - `I2cGroup` (`i2c.*` over `I2cStmForHil`, an `I2cStm` whose hooks report `EVT i2c`), `I2cTiming` (TIMINGR from kernel clock and bus frequency), `I2cTarget` (`i2cs.*`, the LL I2C target scaffold), `I2cEepromStm` (a `hal::Eeprom` over `hal::I2cMaster` with page writes and ACK polling) and `EepromGroup` (`eeprom.attach`/`eeprom.detach`, the factory of EMIL's `eeprom` group). - `SpiSlaveGroup` (`spis.*`: `SpiSlaveFactory` builds `SpiSlaveStmDma` on the board's slave DMA channels, `SpiSlaveCommands` arms one transfer at a time and answers `spis.result` from its own timer); `SpiFactory` also takes `bits`, `lsb` and `nss` and holds the SPI instance in `ResourceAllocation`. - `TimerGroup`, `TimerPwmGroup`, `LpTimerGroup` and `LpTimerPwmGroup` serve `tim.*`, `tpwm.*`, `lptim.*` and `lptpwm.*`, each a factory and a single-instance group; `QeiFactory` also builds the LPTIM encoder with `cap=rise|fall` and holds its LPTIM in `ResourceAllocation`. diff --git a/validation/firmware/AdcFactory.cpp b/validation/firmware/AdcFactory.cpp index 46f221ba..9c47c11a 100644 --- a/validation/firmware/AdcFactory.cpp +++ b/validation/firmware/AdcFactory.cpp @@ -15,12 +15,14 @@ namespace validation { using services::HilStatus; - constexpr std::array openKeys{ { "pins", "sampling", "timer", "rate" } }; + constexpr std::array openKeys{ { "pins", "sampling", "timer", "rate", "trgo" } }; constexpr uint32_t defaultRate = 1000; constexpr uint32_t maximumRate = 100000; constexpr uint64_t minimumTicks = 2; + static_assert(board::adcDma.dma == 1); + // A Stop from the event loop must not interleave with the DMA interrupt, which disables the ADC itself in one-shot mode and stops a timer-triggered sequence from EMIL's last-run callback class InterruptsDisabled { @@ -53,19 +55,37 @@ namespace validation return a > b ? a - b : b - a; } - // The smallest prescaler that fits is at most 1 / maximumTicks off the best divider; searching every prescaler for that one blocks the event loop for tens of milliseconds - hal::TimerBaseStm::Timing TriggerTiming(uint8_t timer, uint32_t rate) + // PwmStm drives TRGO only, so a timer the ADC reaches through TRGO2 alone (TIM1 on the STM32WBA ADC4) cannot pace it + std::optional PwmTriggerSource(uint8_t timer) { - const uint64_t clock = TimerClock(timer); - const uint64_t maximumTicks = IS_TIM_32B_COUNTER_INSTANCE(hal::peripheralTimer[timer - 1]) ? uint64_t{ 1 } << 32 : uint64_t{ 1 } << 16; + switch (timer) + { +#if defined(ADC_EXTERNALTRIG_T1_TRGO) + case 1: + return ADC_EXTERNALTRIG_T1_TRGO; +#endif +#if defined(ADC_EXTERNALTRIG_T2_TRGO) + case 2: + return ADC_EXTERNALTRIG_T2_TRGO; +#endif + default: + return std::nullopt; + } + } + } + + // The smallest prescaler that fits is at most 1 / maximumTicks off the best divider; searching every prescaler for that one blocks the event loop for tens of milliseconds + hal::TimerBaseStm::Timing TriggerTiming(uint8_t timer, uint32_t rate) + { + const uint64_t clock = TimerClock(timer); + const uint64_t maximumTicks = IS_TIM_32B_COUNTER_INSTANCE(hal::peripheralTimer[timer - 1]) ? uint64_t{ 1 } << 32 : uint64_t{ 1 } << 16; - const uint64_t prescale = clock / (rate * (maximumTicks + 1)) + 1; - const uint64_t floorTicks = std::max(clock / (prescale * rate), minimumTicks); - const uint64_t ceilTicks = std::min(floorTicks + 1, maximumTicks); - const bool roundUp = Distance(clock, prescale * ceilTicks * rate) * floorTicks < Distance(clock, prescale * floorTicks * rate) * ceilTicks; + const uint64_t prescale = clock / (rate * (maximumTicks + 1)) + 1; + const uint64_t floorTicks = std::max(clock / (prescale * rate), minimumTicks); + const uint64_t ceilTicks = std::min(floorTicks + 1, maximumTicks); + const bool roundUp = Distance(clock, prescale * ceilTicks * rate) * floorTicks < Distance(clock, prescale * floorTicks * rate) * ceilTicks; - return { static_cast(prescale - 1), static_cast((roundUp ? ceilTicks : floorTicks) - 1) }; - } + return { static_cast(prescale - 1), static_cast((roundUp ? ceilTicks : floorTicks) - 1) }; } template @@ -108,6 +128,34 @@ namespace validation return measuring; } + AdcFactoryStm::PwmTriggeredSequence::PwmTriggeredSequence(infra::MemoryRange samples, infra::MemoryRange inputs, hal::AdcStm& converter, hal::DmaStm::ReceiveStream& receiveStream, ChannelConfigs configs, uint32_t triggerSource) + : Sequence(samples, inputs, converter, receiveStream, configs, hal::AdcDmaMultiChannelStmBase::Unlimited{}) + { + HAL_ADC_Stop(&converter.Handle()); + + LL_ADC_REG_SetTriggerSource(converter.Handle().Instance, triggerSource); +#if defined(ADC_CFGR1_EXTEN) || defined(ADC_CFGR_EXTEN) + LL_ADC_REG_SetTriggerEdge(converter.Handle().Instance, ADC_EXTERNALTRIGCONVEDGE_RISING); +#endif + } + + void AdcFactoryStm::PwmTriggeredSequence::Measure(const infra::Function& onDone) + { + measuring = true; + Sequence::Measure(onDone); + } + + void AdcFactoryStm::PwmTriggeredSequence::Stop() + { + Sequence::Stop(); + measuring = false; + } + + bool AdcFactoryStm::PwmTriggeredSequence::Measuring() const + { + return measuring; + } + void AdcFactoryStm::RepeatedConversion::Attach(Sequence& sequence, Samples samples) { this->sequence = &sequence; @@ -160,10 +208,11 @@ namespace validation Convert(); } - AdcFactoryStm::AdcFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma, TimerAllocation& timers) + AdcFactoryStm::AdcFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma, TimerAllocation& timers, ResourceAllocation& resources) : naming(naming) , dma(dma) , timers(timers) + , resources(resources) {} std::size_t AdcFactoryStm::KeyPositionals() const @@ -203,8 +252,15 @@ namespace validation for (std::size_t i = 0; i != request.count && status == HilStatus::done; ++i) status = pins.ClaimAnalog(request.pins[i], claimed[i]); + if (status == HilStatus::done) + status = ClaimResources(); + if (status == HilStatus::done && request.timer) + { status = timers.Claim(*request.timer, services::HilOwners::adc); + if (status != HilStatus::done) + ReleaseResources(); + } if (status != HilStatus::done) return status; @@ -221,6 +277,9 @@ namespace validation if (auto triggered = std::get_if(&sequence); triggered != nullptr && triggered->Measuring()) triggered->Stop(); + if (auto triggered = std::get_if(&sequence); triggered != nullptr && triggered->Measuring()) + triggered->Stop(); + sequence.emplace(); analogPins.clear(); stream.reset(); @@ -229,6 +288,7 @@ namespace validation if (timer) timers.Release(*timer, services::HilOwners::adc); timer.reset(); + ReleaseResources(); onClosed(); } @@ -250,9 +310,18 @@ namespace validation request.timer = static_cast(number); } arguments.Number("rate", request.rate, 1, maximumRate, status); + if (arguments.Has("trgo")) + { + uint32_t number = 0; + arguments.Number("trgo", number, 0, static_cast(hal::peripheralTimer.size()), status); + request.triggerTimer = static_cast(number); + } if (status != HilStatus::done) return status; + if (request.triggerTimer && (request.timer || arguments.Has("rate"))) + return HilStatus::usage; + status = ParsePins(arguments, request); if (status != HilStatus::done) return status; @@ -263,9 +332,45 @@ namespace validation if (request.timer && !IsTriggerTimer(*request.timer)) return HilStatus::unsupported; + if (request.triggerTimer && !TimerExists(*request.triggerTimer)) + return HilStatus::range; + + if (request.triggerTimer) + return EvaluateTrigger(*request.triggerTimer); + + return HilStatus::done; + } + + HilStatus AdcFactoryStm::EvaluateTrigger(uint8_t timer) const + { + const auto owner = timers.Owner(timer); + if (!PwmTriggerSource(timer) || !owner) + return HilStatus::unsupported; + + if (*owner != services::HilOwners::pwm) + return HilStatus::busy; + return HilStatus::done; } + HilStatus AdcFactoryStm::ClaimResources() + { + HilStatus status = resources.Claim(Resource::adc, board::adc, services::HilOwners::adc); + if (status == HilStatus::done) + status = resources.Claim(Resource::dma1, board::adcDma.channel, services::HilOwners::adc); + + if (status != HilStatus::done) + ReleaseResources(); + + return status; + } + + void AdcFactoryStm::ReleaseResources() + { + resources.Release(Resource::adc, board::adc, services::HilOwners::adc); + resources.Release(Resource::dma1, board::adcDma.channel, services::HilOwners::adc); + } + HilStatus AdcFactoryStm::ParsePins(const services::HilArguments& arguments, Request& request) const { const auto list = arguments.Key("pins"); @@ -296,7 +401,7 @@ namespace validation void AdcFactoryStm::Construct(const Request& request, const std::array& claimed, services::HilAdcHandle& handle) { auto& converter = adc.emplace(board::adc); - auto& receiveStream = stream.emplace(dma, hal::DmaChannelId(1, board::adcDmaChannel, board::adcDmaRequest)); + auto& receiveStream = stream.emplace(dma, hal::DmaChannelId(board::adcDma.dma, board::adcDma.channel, board::adcDmaRequest)); for (std::size_t i = 0; i != request.count; ++i) { @@ -314,6 +419,12 @@ namespace validation return; } + if (request.triggerTimer) + { + handle.adc = &sequence.emplace(samples, infra::MakeRange(analogPins), converter, receiveStream, channels, *PwmTriggerSource(*request.triggerTimer)); + return; + } + repeated.Attach(sequence.emplace(samples, infra::MakeRange(analogPins), converter, receiveStream, channels, hal::AdcDmaMultiChannelStmBase::OneShot{}), samples); handle.adc = &repeated; } diff --git a/validation/firmware/AdcFactory.hpp b/validation/firmware/AdcFactory.hpp index a4342d93..be643ac7 100644 --- a/validation/firmware/AdcFactory.hpp +++ b/validation/firmware/AdcFactory.hpp @@ -12,6 +12,7 @@ #include "infra/util/MemoryRange.hpp" #include "services/hil/commands/HilAdcCommands.hpp" #include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/ResourceAllocation.hpp" #include "validation/firmware/TimerAllocation.hpp" #include #include @@ -22,13 +23,15 @@ namespace validation { + hal::TimerBaseStm::Timing TriggerTiming(uint8_t timer, uint32_t rate); + class AdcFactoryStm : public services::HilAdcFactory { public: static constexpr std::size_t slots = 1; - AdcFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma, TimerAllocation& timers); + AdcFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma, TimerAllocation& timers, ResourceAllocation& resources); std::size_t KeyPositionals() const override; services::HilStatus ParseKey(const services::HilArguments& arguments, uint16_t& key) const override; @@ -49,6 +52,7 @@ namespace validation uint32_t samplingTime = 0; std::optional timer; uint32_t rate = 0; + std::optional triggerTimer; }; class Sequence @@ -77,6 +81,21 @@ namespace validation std::atomic measuring{ false }; }; + class PwmTriggeredSequence + : public Sequence + { + public: + PwmTriggeredSequence(infra::MemoryRange samples, infra::MemoryRange inputs, hal::AdcStm& converter, hal::DmaStm::ReceiveStream& receiveStream, ChannelConfigs configs, uint32_t triggerSource); + + void Measure(const infra::Function& onDone) override; + void Stop() override; + + bool Measuring() const; + + private: + std::atomic measuring{ false }; + }; + class RepeatedConversion : public hal::AdcMultiChannel { @@ -101,18 +120,22 @@ namespace validation services::HilStatus Parse(const services::HilArguments& arguments, Request& request) const; services::HilStatus ParsePins(const services::HilArguments& arguments, Request& request) const; + services::HilStatus EvaluateTrigger(uint8_t timer) const; + services::HilStatus ClaimResources(); + void ReleaseResources(); void Construct(const Request& request, const std::array& claimed, services::HilAdcHandle& handle); private: const services::HilPinNaming& naming; hal::DmaStm& dma; TimerAllocation& timers; + ResourceAllocation& resources; std::optional adc; std::optional stream; infra::BoundedVector::WithMaxSize analogPins; std::array buffer{}; std::array configs{}; - std::variant sequence; + std::variant sequence; RepeatedConversion repeated; std::optional timer; }; diff --git a/validation/firmware/AnalogInputGroup.cpp b/validation/firmware/AnalogInputGroup.cpp new file mode 100644 index 00000000..85e9735a --- /dev/null +++ b/validation/firmware/AnalogInputGroup.cpp @@ -0,0 +1,287 @@ +#include "validation/firmware/AnalogInputGroup.hpp" +#include "BoardProfile.hpp" +#include "infra/event/EventDispatcher.hpp" +#include "validation/firmware/AdcFactory.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include +#include +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + using services::HilChoice; + using services::HilStatus; + + // AdcTriggeredByTimerWithDma paces its conversions with TIM2 whatever the caller wants + constexpr uint8_t burstTimer = 2; + constexpr uint32_t vddaMillivolts = 3300; + constexpr uint32_t maximumIndex = std::numeric_limits::max(); + constexpr uint32_t minimumRate = 10; + constexpr uint32_t maximumRate = 100000; + constexpr uint32_t maximumRepeat = 2; + constexpr std::size_t maximumListed = 64; + constexpr infra::Duration readTimeout = std::chrono::milliseconds(1000); + constexpr uint32_t burstMarginMs = 1000; + + constexpr std::array burstKeys{ { "n", "rate", "repeat", "out" } }; + + static_assert(board::adcDma.dma == 1); + } + + AnalogInputCommands::AnalogInputCommands(services::HilContext& context, const services::HilPinNaming& naming, hal::DmaStm& dma, TimerAllocation& timers, ResourceAllocation& resources) + : services::TerminalCommands(context.terminal) + , context(context) + , naming(naming) + , dma(dma) + , timers(timers) + , resources(resources) + , pins(context.pins, owners::analogInput) + , pending(context.response) + , commands{ { + services::HilBind("ain.read", " [sampling=]", *this, context.response), + services::HilBind("ain.burst", " n= rate= [repeat=] [out=]", *this, context.response), + } } + {} + + infra::MemoryRange AnalogInputCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus AnalogInputCommands::Read(const services::HilArguments& arguments) + { + if (!arguments.Shape(2, 2, { "sampling" })) + return HilStatus::usage; + + uint32_t index = 0; + uint32_t samplingTime = board::adcDefaultSamplingTime; + HilStatus status = HilStatus::done; + arguments.NumberAt(0, index, 0, maximumIndex, status); + arguments.Select("sampling", samplingTime, board::adcSamplingTimes, status); + if (status != HilStatus::done) + return status; + + if (index != board::adc) + return HilStatus::range; + + const bool internal = arguments.Positional(1) == "temp"; + std::optional pin; + if (!internal) + { + status = ParseAnalogPin(arguments.Positional(1), pin.emplace()); + if (status != HilStatus::done) + return status; + } + + if (pending.Busy()) + return HilStatus::busy; + + status = Claim(pin, false); + if (status != HilStatus::done) + return status; + + hal::detail::AdcStmChannelConfig config; + config.samplingTime = samplingTime; + temperature = internal; + + auto& converter = adc.emplace(board::adc); + operation = pending.Start(readTimeout); + + const auto onDone = [this](infra::MemoryRange samples) + { + code = samples.front(); + + // The driver still runs the callback that delivered the sample + infra::EventDispatcher::Instance().Schedule([this]() + { + FinishRead(); + }); + }; + + if (internal) + reader.emplace(converter, config).Measure(1, onDone); + else + reader.emplace(PinOrDummy(claimedPin), converter, config).Measure(1, onDone); + + return HilStatus::done; + } + + HilStatus AnalogInputCommands::Burst(const services::HilArguments& arguments) + { + if (!arguments.Shape(2, 2, infra::MakeRange(burstKeys)) || !arguments.Has("n") || !arguments.Has("rate")) + return HilStatus::usage; + + static constexpr std::array, 2> outputs{ { + { "list", Output::list }, + { "stats", Output::stats }, + } }; + + BurstRequest parsed; + uint32_t index = 0; + HilStatus status = HilStatus::done; + arguments.NumberAt(0, index, 0, maximumIndex, status); + arguments.Number("n", parsed.samples, 1, bufferSize, status); + arguments.Number("rate", parsed.rate, minimumRate, maximumRate, status); + arguments.Number("repeat", parsed.repeat, 1, maximumRepeat, status); + arguments.Select("out", parsed.output, outputs, status); + if (status != HilStatus::done) + return status; + + if (index != board::adc || (parsed.output == Output::list && parsed.samples > maximumListed)) + return HilStatus::range; + + status = ParseAnalogPin(arguments.Positional(1), parsed.pin); + if (status != HilStatus::done) + return status; + + if (pending.Busy()) + return HilStatus::busy; + + status = Claim(parsed.pin, true); + if (status != HilStatus::done) + return status; + + request = parsed; + completedRuns = 0; + + auto& converter = adc.emplace(board::adc); + auto& receiveStream = stream.emplace(dma, hal::DmaChannelId(board::adcDma.dma, board::adcDma.channel, board::adcDmaRequest)); + burst.emplace(infra::MakeRange(buffer), converter, receiveStream, TriggerTiming(burstTimer, request.rate), PinOrDummy(claimedPin)); + + const auto runMs = request.samples * 1000 / request.rate + 1; + operation = pending.Start(std::chrono::milliseconds(request.repeat * runMs + burstMarginMs)); + StartRun(); + return HilStatus::done; + } + + HilStatus AnalogInputCommands::ParseAnalogPin(infra::BoundedConstString text, HilPinId& pin) const + { + const auto parsed = services::HilArguments::ParsePin(text, naming); + if (!parsed || !SupportsAnalog(*parsed)) + return HilStatus::pin; + + pin = *parsed; + return HilStatus::done; + } + + HilStatus AnalogInputCommands::Claim(const std::optional& pin, bool timed) + { + HilStatus status = resources.Claim(Resource::adc, board::adc, owners::analogInput); + if (status == HilStatus::done && timed) + status = resources.Claim(Resource::dma1, board::adcDma.channel, owners::analogInput); + if (status == HilStatus::done && timed) + status = timers.Claim(burstTimer, owners::analogInput); + + claimedPin = nullptr; + if (status == HilStatus::done && pin) + status = pins.ClaimAnalog(*pin, claimedPin); + + if (status != HilStatus::done) + Release(); + + return status; + } + + void AnalogInputCommands::Release() + { + pins.Release(); + claimedPin = nullptr; + timers.Release(burstTimer, owners::analogInput); + resources.Release(Resource::dma1, board::adcDma.channel, owners::analogInput); + resources.Release(Resource::adc, board::adc, owners::analogInput); + } + + void AnalogInputCommands::FinishRead() + { + reader.emplace(); + adc.reset(); + Release(); + + if (!pending.Complete(operation)) + return; + + auto line = context.response.Ok(); + line << " code=" << static_cast(code); + + if (temperature) + { + const int32_t celsius = __LL_ADC_CALC_TEMPERATURE(vddaMillivolts, code, LL_ADC_RESOLUTION_12B); + line << " mcelsius=" << (celsius < 0 ? "-" : "") << static_cast(celsius < 0 ? -celsius : celsius) * 1000; + } + } + + void AnalogInputCommands::StartRun() + { + stopwatch.Start(); + burst->Measure(request.samples, [this](infra::MemoryRange samples) + { + RunDone(samples); + }); + } + + void AnalogInputCommands::RunDone(infra::MemoryRange samples) + { + const auto microseconds = stopwatch.ElapsedUs(); + + if (++completedRuns < request.repeat) + { + { + auto line = context.response.Event("ain"); + line << " index=" << static_cast(board::adc) << " run=" << completedRuns; + PrintRun(line, samples, microseconds); + } + + StartRun(); + return; + } + + lastSamples = samples; + lastMicroseconds = microseconds; + + // The driver still runs the callback that delivered the samples + infra::EventDispatcher::Instance().Schedule([this]() + { + FinishBurst(); + }); + } + + void AnalogInputCommands::FinishBurst() + { + burst.reset(); + stream.reset(); + adc.reset(); + Release(); + + if (!pending.Complete(operation)) + return; + + auto line = context.response.Ok(); + PrintRun(line, lastSamples, lastMicroseconds); + } + + void AnalogInputCommands::PrintRun(services::HilResponse::Line& line, infra::MemoryRange samples, uint32_t microseconds) const + { + if (request.output == Output::list) + { + line << " samples="; + for (std::size_t i = 0; i != samples.size(); ++i) + line << (i == 0 ? "" : ",") << static_cast(samples[i]); + } + else + { + uint32_t sum = 0; + for (auto sample : samples) + sum += sample; + + const auto count = static_cast(samples.size()); + const auto [minimum, maximum] = std::ranges::minmax(samples); + line << " n=" << count << " min=" << static_cast(minimum) << " max=" << static_cast(maximum) << " mean=" << (sum + count / 2) / count; + } + + line << " us=" << microseconds; + } +} diff --git a/validation/firmware/AnalogInputGroup.hpp b/validation/firmware/AnalogInputGroup.hpp new file mode 100644 index 00000000..8a7a8edd --- /dev/null +++ b/validation/firmware/AnalogInputGroup.hpp @@ -0,0 +1,83 @@ +#pragma once + +#include "hal_st/stm32fxxx/AdcDmaStm.hpp" +#include "hal_st/stm32fxxx/AnalogToDigitalPinStm.hpp" +#include "hal_st/stm32fxxx/DmaStm.hpp" +#include "infra/util/MemoryRange.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilPendingOperation.hpp" +#include "services/util/Terminal.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include "validation/firmware/Stopwatch.hpp" +#include "validation/firmware/TimerAllocation.hpp" +#include +#include +#include +#include +#include + +namespace validation +{ + class AnalogInputCommands + : public services::TerminalCommands + { + public: + AnalogInputCommands(services::HilContext& context, const services::HilPinNaming& naming, hal::DmaStm& dma, TimerAllocation& timers, ResourceAllocation& resources); + + infra::MemoryRange Commands() override; + + private: + static constexpr std::size_t bufferSize = 256; + + enum class Output : uint8_t + { + list, + stats, + }; + + struct BurstRequest + { + HilPinId pin; + uint32_t samples = 0; + uint32_t rate = 0; + uint32_t repeat = 1; + Output output = Output::list; + }; + + services::HilStatus Read(const services::HilArguments& arguments); + services::HilStatus Burst(const services::HilArguments& arguments); + services::HilStatus ParseAnalogPin(infra::BoundedConstString text, HilPinId& pin) const; + services::HilStatus Claim(const std::optional& pin, bool burst); + void Release(); + void FinishRead(); + void StartRun(); + void RunDone(infra::MemoryRange samples); + void FinishBurst(); + void PrintRun(services::HilResponse::Line& line, infra::MemoryRange samples, uint32_t microseconds) const; + + private: + services::HilContext& context; + const services::HilPinNaming& naming; + hal::DmaStm& dma; + TimerAllocation& timers; + ResourceAllocation& resources; + services::HilPinOwner pins; + services::HilPendingOperation pending; + hal::GpioPin* claimedPin = nullptr; + std::optional adc; + std::variant reader; + std::optional stream; + std::optional burst; + std::array buffer{}; + BurstRequest request; + uint32_t operation = 0; + uint16_t code = 0; + bool temperature = false; + uint32_t completedRuns = 0; + infra::MemoryRange lastSamples; + uint32_t lastMicroseconds = 0; + Stopwatch stopwatch; + std::array commands; + }; +} diff --git a/validation/firmware/CMakeLists.txt b/validation/firmware/CMakeLists.txt index 54ba178a..af8b61af 100644 --- a/validation/firmware/CMakeLists.txt +++ b/validation/firmware/CMakeLists.txt @@ -23,12 +23,16 @@ target_link_libraries(hal_st.validation_firmware PRIVATE target_sources(hal_st.validation_firmware PRIVATE AdcFactory.cpp AdcFactory.hpp + AnalogInputGroup.cpp + AnalogInputGroup.hpp BoardInfoStm.cpp BoardInfoStm.hpp BoardTypes.hpp ChannelPins.hpp Console.cpp Console.hpp + DmaGroup.cpp + DmaGroup.hpp EepromGroup.cpp EepromGroup.hpp HsemMaster.cpp diff --git a/validation/firmware/DmaGroup.cpp b/validation/firmware/DmaGroup.cpp new file mode 100644 index 00000000..97dbb2ad --- /dev/null +++ b/validation/firmware/DmaGroup.cpp @@ -0,0 +1,177 @@ +#include "validation/firmware/DmaGroup.hpp" +#include "BoardProfile.hpp" +#include "validation/firmware/AdcFactory.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + using services::HilStatus; + + // board::dmaGroupDma serves the TIM2 update request + constexpr uint8_t waveTimer = 2; + constexpr uint32_t maximumRate = 1000000; + constexpr uint32_t defaultDurationMs = 50; + constexpr uint32_t maximumDurationMs = 10000; + + constexpr std::array waveKeys{ { "rate", "pattern", "ms" } }; + + constexpr Resource dmaResource = board::dmaGroupDma.dma == 1 ? Resource::dma1 : Resource::dma2; + + GPIO_TypeDef* PortRegisters(hal::Port port) + { + const std::array ports{ +#if defined(GPIOA) + GPIOA, +#endif +#if defined(GPIOB) + GPIOB, +#endif +#if defined(GPIOC) + GPIOC, +#endif +#if defined(GPIOD) + GPIOD, +#endif +#if defined(GPIOE) + GPIOE, +#endif +#if defined(GPIOF) + GPIOF, +#endif +#if defined(GPIOG) + GPIOG, +#endif +#if defined(GPIOH) + GPIOH, +#endif + }; + + return ports[static_cast(port)]; + } + } + + DmaCommands::DmaCommands(services::HilContext& context, const services::HilPinNaming& naming, hal::DmaStm& dma, TimerAllocation& timers, ResourceAllocation& resources) + : services::TerminalCommands(context.terminal) + , context(context) + , naming(naming) + , dma(dma) + , timers(timers) + , resources(resources) + , pins(context.pins, owners::dma) + , commands{ { + services::HilBind("dma.wave", " rate= pattern= [ms=]", *this, context.response), + } } + {} + + infra::MemoryRange DmaCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus DmaCommands::Wave(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, infra::MakeRange(waveKeys)) || !arguments.Has("rate") || !arguments.Has("pattern")) + return HilStatus::usage; + + uint32_t rate = 0; + uint32_t durationMs = defaultDurationMs; + std::array pattern{}; + std::size_t patternSize = 0; + HilPinId pin; + HilStatus status = HilStatus::done; + arguments.Number("rate", rate, 1, maximumRate, status); + if (status == HilStatus::done) + status = services::HilArguments::ParseHex(*arguments.Key("pattern"), infra::MakeRange(pattern), patternSize); + if (status == HilStatus::done && patternSize == 0) + status = HilStatus::usage; + arguments.Number("ms", durationMs, 1, maximumDurationMs, status); + arguments.PinAt(0, naming, pin, status); + if (status != HilStatus::done) + return status; + + if (!IsBonded(pin)) + return HilStatus::pin; + + if (channel) + return HilStatus::busy; + + status = Claim(pin); + if (status != HilStatus::done) + return status; + + claimedPin->Config(hal::PinConfigType::output, false); + const auto count = FillWords(pin, infra::Head(infra::MakeRange(pattern), patternSize)); + + auto& updates = timer.emplace(waveTimer, TriggerTiming(waveTimer, rate), hal::TimerBaseStm::Config{ hal::TimerBaseStm::CounterMode::up }); + auto& transmitStream = stream.emplace(dma, hal::DmaChannelId(board::dmaGroupDma.dma, board::dmaGroupDma.channel, board::dmaGroupDmaRequest)); + auto& circular = channel.emplace(transmitStream, &PortRegisters(PortOf(pin))->BSRR, sizeof(uint32_t), []() {}, []() {}); + circular.StartTransmit(infra::MemoryRange(words.data(), words.data() + count)); + __HAL_TIM_ENABLE_DMA(&updates.Handle(), TIM_DMA_UPDATE); + updates.Start(); + + duration.Start(std::chrono::milliseconds(durationMs), [this]() + { + Finish(); + }); + + return HilStatus::done; + } + + HilStatus DmaCommands::Claim(HilPinId pin) + { + HilStatus status = pins.Claim(pin, services::HilPinPool::Use::exclusive, claimedPin); + if (status == HilStatus::done) + status = timers.Claim(waveTimer, owners::dma); + if (status == HilStatus::done) + status = resources.Claim(dmaResource, board::dmaGroupDma.channel, owners::dma); + + if (status != HilStatus::done) + Release(); + + return status; + } + + void DmaCommands::Release() + { + pins.Release(); + claimedPin = nullptr; + timers.Release(waveTimer, owners::dma); + resources.Release(dmaResource, board::dmaGroupDma.channel, owners::dma); + } + + // The circular channel always enables its half and full transfer interrupts: repeating the pattern over the whole buffer keeps them rare at high rates + std::size_t DmaCommands::FillWords(HilPinId pin, infra::ConstByteRange pattern) + { + const auto bits = pattern.size() * 8; + const auto count = bits * (words.size() / bits); + const uint32_t set = 1u << pin.index; + const uint32_t reset = set << 16; + + for (std::size_t i = 0; i != count; ++i) + { + const auto bit = i % bits; + words[i] = (pattern[bit / 8] & (1u << (bit % 8))) != 0 ? set : reset; + } + + return count; + } + + void DmaCommands::Finish() + { + timer->Stop(); + channel->StopTransfer(); + __HAL_TIM_DISABLE_DMA(&timer->Handle(), TIM_DMA_UPDATE); + + channel.reset(); + stream.reset(); + timer.reset(); + Release(); + + context.response.Ok(); + } +} diff --git a/validation/firmware/DmaGroup.hpp b/validation/firmware/DmaGroup.hpp new file mode 100644 index 00000000..d7019517 --- /dev/null +++ b/validation/firmware/DmaGroup.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include "hal_st/stm32fxxx/DmaStm.hpp" +#include "hal_st/stm32fxxx/TimerStm.hpp" +#include "infra/timer/Timer.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/util/Terminal.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include "validation/firmware/TimerAllocation.hpp" +#include +#include +#include +#include + +namespace validation +{ + class DmaCommands + : public services::TerminalCommands + { + public: + DmaCommands(services::HilContext& context, const services::HilPinNaming& naming, hal::DmaStm& dma, TimerAllocation& timers, ResourceAllocation& resources); + + infra::MemoryRange Commands() override; + + private: + static constexpr std::size_t maximumPatternBytes = 32; + static constexpr std::size_t maximumWords = maximumPatternBytes * 8; + + services::HilStatus Wave(const services::HilArguments& arguments); + services::HilStatus Claim(HilPinId pin); + void Release(); + std::size_t FillWords(HilPinId pin, infra::ConstByteRange pattern); + void Finish(); + + private: + services::HilContext& context; + const services::HilPinNaming& naming; + hal::DmaStm& dma; + TimerAllocation& timers; + ResourceAllocation& resources; + services::HilPinOwner pins; + hal::GpioPin* claimedPin = nullptr; + std::optional timer; + std::optional stream; + std::optional channel; + std::array words{}; + infra::TimerSingleShot duration; + std::array commands; + }; +} diff --git a/validation/firmware/Main.cpp b/validation/firmware/Main.cpp index a4dc0e74..7dbfb089 100644 --- a/validation/firmware/Main.cpp +++ b/validation/firmware/Main.cpp @@ -15,8 +15,10 @@ #include "services/hil/commands/HilWatchDogCommands.hpp" #include "services/peripheral/DebugLed.hpp" #include "validation/firmware/AdcFactory.hpp" +#include "validation/firmware/AnalogInputGroup.hpp" #include "validation/firmware/BoardInfoStm.hpp" #include "validation/firmware/Console.hpp" +#include "validation/firmware/DmaGroup.hpp" #include "validation/firmware/EepromGroup.hpp" #include "validation/firmware/I2cGroup.hpp" #include "validation/firmware/I2cTarget.hpp" @@ -68,7 +70,7 @@ int main() static validation::SpiFactoryStm spiFactory{ naming, console.dma, resources }; static services::HilSpiCommands::WithCapacity<64> spi{ context, spiFactory }; - static validation::AdcFactoryStm adcFactory{ naming, console.dma, timers }; + static validation::AdcFactoryStm adcFactory{ naming, console.dma, timers, resources }; static services::HilAdcCommands::WithCapacity adc{ context, adcFactory }; static validation::QeiFactoryStm qeiFactory{ naming, timers, resources }; @@ -101,6 +103,9 @@ int main() static validation::LpTimerPwmGroup lpTimerPwm{ context, lpTimerPwmFactory }; #endif + static validation::AnalogInputCommands analogInput{ context, naming, console.dma, timers, resources }; + static validation::DmaCommands dmaWave{ context, naming, console.dma, timers, resources }; + validation::CreateUnsupportedGroups(context); system.PrintBoot(); diff --git a/validation/firmware/PwmFactory.cpp b/validation/firmware/PwmFactory.cpp index 9d4e92fb..f572af3a 100644 --- a/validation/firmware/PwmFactory.cpp +++ b/validation/firmware/PwmFactory.cpp @@ -16,15 +16,32 @@ namespace validation { using services::HilChoice; using services::HilStatus; + using Alignment = hal::PwmStmBase::Alignment; + using TriggerOutput = hal::PwmStmBase::TriggerOutput; constexpr uint32_t maximumPrescaler = 0xffff; constexpr uint32_t maximumDeadTimeNs = 1000000; + constexpr uint32_t maximumBreakFilter = 15; - constexpr std::array openKeys{ { "channels", "pins", "freq", "mode", "prescaler", "dead", "inv", "invn", "idle", "idlen", "brk", "brkpol", "brkauto", "sync" } }; + constexpr std::array openKeys{ { "channels", "pins", "freq", "mode", "prescaler", "dead", "inv", "invn", "idle", "idlen", "brk", "brkpol", "brkauto", "sync", "preload", "brkfilter", "trgo" } }; - constexpr std::array, 2> alignments{ { - { "edge", false }, - { "center", true }, + constexpr std::array, 5> alignments{ { + { "edge", Alignment::edgeAligned }, + { "edgedown", Alignment::edgeAlignedDownCounting }, + { "center", Alignment::centerAlignedDownCounting }, + { "centerup", Alignment::centerAlignedUpCounting }, + { "centerboth", Alignment::centerAlignedBothCounting }, + } }; + + constexpr std::array, 8> triggerOutputs{ { + { "reset", TriggerOutput::reset }, + { "enable", TriggerOutput::enable }, + { "update", TriggerOutput::update }, + { "oc1", TriggerOutput::comparePulse }, + { "oc1ref", TriggerOutput::compareChannel1 }, + { "oc2ref", TriggerOutput::compareChannel2 }, + { "oc3ref", TriggerOutput::compareChannel3 }, + { "oc4ref", TriggerOutput::compareChannel4 }, } }; constexpr std::array, 2> breakPolarities{ { @@ -73,6 +90,11 @@ namespace validation return TimerClock(timer) / (prescaler + 1); } + bool IsCenterAligned(Alignment alignment) + { + return alignment != Alignment::edgeAligned && alignment != Alignment::edgeAlignedDownCounting; + } + bool ValidFrequency(uint8_t timer, uint32_t counterClock, bool centerAligned, uint32_t hertz) { const auto ticksPerPeriod = counterClock / hertz; @@ -198,7 +220,7 @@ namespace validation return status; } - timing = Timing{ CounterClock(index, request.prescaler), request.centerAligned }; + timing = Timing{ CounterClock(index, request.prescaler), IsCenterAligned(request.alignment) }; opened = &Construct(index, request, claimed); return HilStatus::done; } @@ -239,7 +261,10 @@ namespace validation return output.complementaryPin.has_value(); }); - if (request.centerAligned && !IS_TIM_COUNTER_MODE_SELECT_INSTANCE(Instance(timer))) + if (request.alignment != Alignment::edgeAligned && !IS_TIM_COUNTER_MODE_SELECT_INSTANCE(Instance(timer))) + return HilStatus::unsupported; + + if (request.triggerOutput && !IS_TIM_MASTER_INSTANCE(Instance(timer))) return HilStatus::unsupported; if ((complementary || request.deadTime || request.idleHigh || request.complementaryIdleHigh || request.breakPin) && !IS_TIM_BREAK_INSTANCE(Instance(timer))) @@ -252,7 +277,7 @@ namespace validation if (request.breakPin && !SupportsFunction(*request.breakPin, hal::PinConfigTypeStm::timerBreak, timer)) return HilStatus::pin; - if (!ValidFrequency(timer, CounterClock(timer, request.prescaler), request.centerAligned, request.frequency)) + if (!ValidFrequency(timer, CounterClock(timer, request.prescaler), IsCenterAligned(request.alignment), request.frequency)) return HilStatus::range; return HilStatus::done; @@ -262,7 +287,9 @@ namespace validation { HilStatus status = HilStatus::done; arguments.Number("freq", request.frequency, 1, std::numeric_limits::max(), status); - arguments.Select("mode", request.centerAligned, alignments, status); + arguments.Select("mode", request.alignment, alignments, status); + if (arguments.Has("trgo")) + arguments.Select("trgo", request.triggerOutput.emplace(), triggerOutputs, status); arguments.Number("prescaler", request.prescaler, 0, maximumPrescaler, status); if (auto deadTime = arguments.Key("dead"); deadTime && *deadTime != "off") @@ -278,11 +305,23 @@ namespace validation arguments.Flag("idlen", request.complementaryIdleHigh, status); arguments.Flag("brkauto", request.breakAutomaticOutput, status); arguments.Flag("sync", request.synchronous, status); + arguments.Flag("preload", request.preload, status); arguments.Select("brkpol", request.breakActiveHigh, breakPolarities, status); + + if (arguments.Has("brkfilter")) + { + uint32_t filter = 0; + arguments.Number("brkfilter", filter, 0, maximumBreakFilter, status); + request.breakFilter = static_cast(filter); + } + arguments.Pin("brk", naming, request.breakPin, status); if (status != HilStatus::done) return status; + if (request.breakFilter && !request.breakPin) + return HilStatus::usage; + return ParseOutputs(arguments, request); } @@ -442,9 +481,10 @@ namespace validation services::HilPwmHandle& PwmFactoryStm::Construct(uint8_t timer, const Request& request, const ClaimedPins& claimed) { hal::PwmStmBase::Config config; - // All three centre-aligned modes give the same waveform; they only differ in when CCxIF is set, which nothing here uses - config.alignment = request.centerAligned ? hal::PwmStmBase::Alignment::centerAlignedDownCounting : hal::PwmStmBase::Alignment::edgeAligned; + config.alignment = request.alignment; config.prescaler = static_cast(request.prescaler); + config.preloadEnabled = request.preload; + config.triggerOutput = request.triggerOutput; if (request.deadTime) config.deadTime.emplace().duration = std::chrono::nanoseconds(*request.deadTime); @@ -454,6 +494,7 @@ namespace validation auto& breakInput = config.breakInput.emplace(); breakInput.activeHigh = request.breakActiveHigh; breakInput.automaticOutputEnable = request.breakAutomaticOutput; + breakInput.filter = request.breakFilter.value_or(0); } infra::BoundedVector::WithMaxSize channels; diff --git a/validation/firmware/PwmFactory.hpp b/validation/firmware/PwmFactory.hpp index b9e780e3..677d221e 100644 --- a/validation/firmware/PwmFactory.hpp +++ b/validation/firmware/PwmFactory.hpp @@ -61,7 +61,7 @@ namespace validation { infra::BoundedVector::WithMaxSize outputs; uint32_t frequency = 10000; - bool centerAligned = false; + hal::PwmStmBase::Alignment alignment = hal::PwmStmBase::Alignment::edgeAligned; uint32_t prescaler = 0; std::optional deadTime; bool inverted = false; @@ -72,6 +72,9 @@ namespace validation bool breakActiveHigh = true; bool breakAutomaticOutput = false; bool synchronous = false; + bool preload = true; + std::optional breakFilter; + std::optional triggerOutput; }; struct ClaimedPins diff --git a/validation/host/boards/nucleo_wb55rg.yaml b/validation/host/boards/nucleo_wb55rg.yaml index df572def..33bb53c8 100644 --- a/validation/host/boards/nucleo_wb55rg.yaml +++ b/validation/host/boards/nucleo_wb55rg.yaml @@ -1014,6 +1014,101 @@ tests: frequency: 0.01 count: 0.05 latency_s: 0.02 + pwm_ext: + # A pwm.timers entry with two wired channels and a counter mode select. + alignment_timer: tim1 + modes: [edge, edgedown, center, centerup, centerboth] + alignment_frequency: 10000 + alignment_duties: [30, 60] + # edgedown cannot reach 0 %: the output stays active for one counter + # tick per period (PwmStm.hpp). The prescaler makes a tick `tick_us`. + zero_duty: + tick_us: 10 + frequency: 100 + periods: 5 + # pwm.duty 80 then 20, runt_repeats times while the LA records + # runt_window_s: with preload=0 at least one high pulse lies strictly + # between 20 % and 80 % of the period (beyond runt_margin), with + # preload=1 none. 1 MHz counter clock. + preload_timer: tim1 + preload_frequency: 200 + runt_repeats: 20 + runt_window_s: 0.5 + runt_margin: 0.03 + # pwm.timers entries with `brk`, each hit by one AD3 pulse of `ticks` + # timer kernel clocks (brkpol=high, brkauto=0). BKF 15 samples 8 times + # at fDTS / 32: 256 kernel clocks. + break_filter_timers: [tim1, tim17] + break_filter_cases: + - name: unfiltered_short + filter: 0 + ticks: 64 + trips: true + - name: filtered_short + filter: 15 + ticks: 64 + trips: false + - name: filtered_long + filter: 15 + ticks: 1024 + trips: true + break_frequency: 10000 + break_settle_s: 0.01 + # adc.open trgo= runs one sequence per period of the pwm + # group's timer (pwm.timers names); test_trgo_paces_adc compares + # n=trgo_runs with n=1 like test_adc.py::test_trigger_rate. + trgo: + timer: [tim1, tim2] + source: [update, oc1ref] + trgo_frequency: 1000 + trgo_runs: 64 + trgo_repeats: 3 + trgo_tolerance: 0.2 + trgo_jitter_s: 0.002 + # Driven by the pwm group, but no TRGO the ADC can trigger from. + trgo_unsupported_timers: [tim16] + capture_periods: 20 + tolerance: + frequency: 0.01 + duty: 1.0 + alignment_s: 2.0e-7 + ain: + # W1/W2 inputs (bundle1); codes against tests.adc vref/bits/tolerance. + pins: [ain4, ain3] + levels: [0.2, 1.65, 3.1] + sampling: ["2.5", "47.5", "640.5"] + # The sensor needs a long sampling time; the driver's default is below + # its minimum (B.14), so with it only "a code arrives" is checked. + temp_sampling: "640.5" + temp_range: [10, 45] + # One AdcTriggeredByTimerWithDma over a `buffer`-sample buffer per + # command, Measure(n) at `rate`: n samples in n / rate (B.12). + burst: + n: [16, 100] + rate: 1000 + buffer: 256 + tolerance: 0.1 + latency_s: 0.01 + # Wavegen ramp during an out=list burst: the codes rise with the ramp. + ramp: + low: 0.3 + high: 3.0 + frequency: 2 + n: 64 + rate: 1000 + dma: + # dma.wave output pin, wired to a DIO in every wiring set. + pin: gpio0 + wave: + rate: [1000, 100000, 1000000] + pattern: ["a5", "0ff0", "deadbeef", "0100000000000080"] + ms: 300 + samples_per_bit: 8 + record_bits: 200 + start_delay_s: 0.05 + tolerance: + rate: 0.01 + bit: 0.25 unsupported: commands: - comp.open 1 diff --git a/validation/host/boards/nucleo_wba55cg.yaml b/validation/host/boards/nucleo_wba55cg.yaml index c29f2777..38ca2efb 100644 --- a/validation/host/boards/nucleo_wba55cg.yaml +++ b/validation/host/boards/nucleo_wba55cg.yaml @@ -575,6 +575,22 @@ tests: pin: tim17ch1 npin: tim17ch1n brk: PA15 + # bundle2 only (DIO8/DIO9 on PA7/PA6, DIO11 on PB8; with bundle1 the + # tests that need their DIOs skip): TIM2 CH3/CH4 and TIM16 CH1 with + # CH1N, without break input (PB8 carries LD3). + - name: tim2_bundle2 + timer: 2 + channels: + - channel: 3 + pin: tim2ch3 + - channel: 4 + pin: tim2ch4 + - name: tim16_bundle2 + timer: 16 + channels: + - channel: 1 + pin: tim16ch1 + npin: tim16ch1n # One channel of each timer; ERR range exactly where the period does # not fit (expect.pwm_fits), unsupported for center on TIM16/TIM17. waveform: @@ -1135,6 +1151,104 @@ tests: tolerance: frequency: 0.01 duty: 0.01 + pwm_ext: + # A pwm.timers entry with two wired channels and a counter mode select. + alignment_timer: tim1 + modes: [edge, edgedown, center, centerup, centerboth] + alignment_frequency: 10000 + alignment_duties: [30, 60] + # edgedown cannot reach 0 %: the output stays active for one counter + # tick per period (PwmStm.hpp). The prescaler makes a tick `tick_us`. + zero_duty: + tick_us: 10 + frequency: 100 + periods: 5 + # pwm.duty 80 then 20, runt_repeats times while the LA records + # runt_window_s: with preload=0 at least one high pulse lies strictly + # between 20 % and 80 % of the period (beyond runt_margin), with + # preload=1 none. 1 MHz counter clock. + preload_timer: tim1 + preload_frequency: 200 + runt_repeats: 20 + runt_window_s: 0.5 + runt_margin: 0.03 + # pwm.timers entries with `brk`, each hit by one AD3 pulse of `ticks` + # timer kernel clocks (brkpol=high, brkauto=0). BKF 15 samples 8 times + # at fDTS / 32: 256 kernel clocks. + break_filter_timers: [tim1, tim16, tim17] + break_filter_cases: + - name: unfiltered_short + filter: 0 + ticks: 64 + trips: true + - name: filtered_short + filter: 15 + ticks: 64 + trips: false + - name: filtered_long + filter: 15 + ticks: 1024 + trips: true + break_frequency: 10000 + break_settle_s: 0.01 + # adc.open trgo= runs one sequence per period of the pwm + # group's timer (pwm.timers names); test_trgo_paces_adc compares + # n=trgo_runs with n=1 like test_adc.py::test_trigger_rate. + trgo: + timer: [tim2] + source: [update, oc1ref] + trgo_frequency: 1000 + trgo_runs: 64 + trgo_repeats: 3 + trgo_tolerance: 0.2 + trgo_jitter_s: 0.002 + # Driven by the pwm group, but no TRGO the ADC can trigger from: ADC4 + # reaches TIM1 through TRGO2 only, which PwmStm leaves at reset. + trgo_unsupported_timers: [tim1, tim16] + capture_periods: 20 + tolerance: + frequency: 0.01 + duty: 1.0 + alignment_s: 2.0e-7 + ain: + # W1/W2 inputs (bundle1); codes against tests.adc vref/bits/tolerance. + pins: [ain2, ain3] + levels: [0.2, 1.65, 3.1] + # Accepted, but the STM32WBA driver samples every channel with + # SamplingTimeCommon1 (79.5 cycles, AnalogToDigitalPinStm.cpp). + sampling: ["3.5", "39.5", "814.5"] + # The sensor needs a long sampling time; the driver's default is below + # its minimum (B.14), so with it only "a code arrives" is checked. + temp_sampling: "814.5" + temp_range: [10, 45] + # One AdcTriggeredByTimerWithDma over a `buffer`-sample buffer per + # command, Measure(n) at `rate`: n samples in n / rate (B.12). + burst: + n: [16, 100] + rate: 1000 + buffer: 256 + tolerance: 0.1 + latency_s: 0.01 + # Wavegen ramp during an out=list burst: the codes rise with the ramp. + ramp: + low: 0.3 + high: 3.0 + frequency: 2 + n: 64 + rate: 1000 + dma: + # dma.wave output pin, wired to a DIO in every wiring set. + pin: gpio0 + wave: + rate: [1000, 100000, 1000000] + pattern: ["a5", "0ff0", "deadbeef", "0100000000000080"] + ms: 300 + samples_per_bit: 8 + record_bits: 200 + start_delay_s: 0.05 + tolerance: + rate: 0.01 + bit: 0.25 unsupported: commands: - comp.open 1 diff --git a/validation/host/hal_st_validation/fake_firmware.py b/validation/host/hal_st_validation/fake_firmware.py index cb3cd380..b8cf035b 100644 --- a/validation/host/hal_st_validation/fake_firmware.py +++ b/validation/host/hal_st_validation/fake_firmware.py @@ -441,6 +441,8 @@ def unsupported(self) -> tuple[str, ...]: _RECEIVE_TIMEOUT_MAX_MS = 10_000 _ADC_MEASURE_TIMEOUT = 1.0 _ADC_DMA = ("dma1", 7) +# `adc.open trgo=`: PwmStm drives TRGO only, and the WBA55 ADC4 reaches TIM1 through TRGO2 alone (AdcFactory.cpp). +_ADC_PWM_TRIGGER_TIMERS = {"stm32wb55": frozenset({1, 2}), "stm32wba55": frozenset({2})} _QEI_RESOLUTION_16BIT = 65536 _QEI_VELOCITY_MAX_US = 1_000_000 _QEI_CAPTURES = ("a", "b", "ab") @@ -945,6 +947,9 @@ def _cmd_pwm_open(self, args: list[str], options: dict[str, str]) -> str: # PwmStm asserts a counter mode select instance for every alignment but edgeAligned (PwmStm.cpp:224). if mode != "edge" and not expect.timer_has_center_mode(timer): _fail("unsupported") + # `IS_TIM_MASTER_INSTANCE`: the timers with a counter mode select, on both MCUs. + if trgo is not None and not expect.timer_has_center_mode(timer): + _fail("unsupported") complementary = any(output.npin is not None for output in outputs) needs_break = complementary or dead is not None or flags["idle"] or flags["idlen"] or brk is not None if needs_break and not expect.timer_has_break(timer): @@ -1164,7 +1169,7 @@ def _cmd_adc_open(self, args: list[str], options: dict[str, str]) -> str: _fail("range") # The sequence runs on the TRGO of a timer the pwm group drives; the adc group does not own it. holder = self.timer_owners.get(trgo) - if trgo not in expect.ADC_TRIGGER_TIMERS or holder is None: + if trgo not in _ADC_PWM_TRIGGER_TIMERS[self.family] or holder is None: _fail("unsupported") assert holder is not None if holder[0] != "pwm": diff --git a/validation/host/hal_st_validation/fakes/analog.py b/validation/host/hal_st_validation/fakes/analog.py new file mode 100644 index 00000000..c79c1ef8 --- /dev/null +++ b/validation/host/hal_st_validation/fakes/analog.py @@ -0,0 +1,179 @@ +"""Fake `ain` and `dma` groups: the argument checks of validation/firmware/AnalogInputGroup.cpp and DmaGroup.cpp in +their order, and what they share with `adc`, `pwm` and the other timer users: the ADC and its DMA channel +(`ResourceAllocation` adc, dma1 7), TIM2 (`TimerAllocation`) and the `dma.wave` channel (WB55 DMA2 4, WBA55 GPDMA1 8). + +`ain.burst` and `dma.wave` answer later, as the firmware does: they hold their claims until the fake clock reaches +the end of the measurements or of the wave, and `poll()` prints the `EVT ain` lines and the final line then.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar + +from ..groups.analog import ( + AIN_BUFFER, + AIN_LIST_MAX, + AIN_RATE_MAX, + AIN_RATE_MIN, + AIN_REPEAT_MAX, + DMA_PATTERN_MAX, + DMA_RATE_MAX, + DMA_WAVE_DEFAULT_MS, + DMA_WAVE_MAX_MS, + burst_seconds, +) +from .base import FakeGroup, _choice, _fail, _hex, _number, _shape + +_INDEX_MAX = 0xFFFF +# `AdcTriggeredByTimerWithDma` paces its conversions with TIM2; TIM2's update request feeds `dma.wave`. +_PACING_TIMER = 2 +_ADC_DMA = ("dma1", 7) +_WAVE_DMA = {"stm32wb55": ("dma2", 4), "stm32wba55": ("dma1", 8)} +_OUTPUTS = ("list", "stats") +# What the fake temperature sensor reads: a code and 25 degrees Celsius. +TEMPERATURE_CODE = 940 +TEMPERATURE_MCELSIUS = 25_000 + + +@dataclass +class _Burst: + adc: int + code: int + samples: int + seconds: float + repeat: int + output: str + started: float + reported: int = 0 + + +class FakeAnalogInput(FakeGroup): + prefix = "ain" + owner: ClassVar[tuple[str, str]] = ("ain", "ain") + + def boot(self) -> None: + self.burst: _Burst | None = None + + def _index(self, text: str) -> int: + return _number(text, 0, _INDEX_MAX) + + def _analog_pin(self, text: str) -> str: + """`ParseAnalogPin`: a name of a pin with an ADC channel, else `ERR pin`.""" + pin = self.fw.pin(text) + assert pin is not None + if not self.fw.supports_analog(pin): + _fail("pin") + return pin + + def _claim(self, pin: str | None, timed: bool) -> None: + """`Claim`: the ADC, with `timed` its DMA channel and TIM2, and the pin as an analog input (all `busy`).""" + resources = [("adc", self.fw.spec.adc)] + ([_ADC_DMA] if timed else []) + self.fw.check_resources(self.owner, resources) + if timed and self.fw.timer_owners.get(_PACING_TIMER, self.owner) != self.owner: + _fail("busy") + self.fw.check_pins(self.owner, [pin], analog=True) + for kind, index in resources: + self.fw.claim_resource(kind, index, self.owner) + if timed: + self.fw.timer_owners[_PACING_TIMER] = self.owner + self.fw.claim(self.owner, [pin], analog=True) + + def cmd_read(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 2, ("sampling",)) + adc = self._index(args[0]) + _choice(options, "sampling", self.fw.spec.sampling, self.fw.spec.sampling[0]) + if adc != self.fw.spec.adc: + _fail("range") + temperature = args[1] == "temp" + pin = None if temperature else self._analog_pin(args[1]) + if self.burst is not None: + _fail("busy") + self._claim(pin, timed=False) + self.fw.release(self.owner) + if temperature: + return f"OK code={TEMPERATURE_CODE} mcelsius={TEMPERATURE_MCELSIUS}" + assert pin is not None + return f"OK code={self.fw.adc_codes.get(pin, 2048)}" + + def cmd_burst(self, args: list[str], options: dict[str, str]) -> None: + _shape(args, options, 2, 2, ("n", "rate", "repeat", "out")) + if "n" not in options or "rate" not in options: + _fail("usage") + adc = self._index(args[0]) + samples = _number(options["n"], 1, AIN_BUFFER) + rate = _number(options["rate"], AIN_RATE_MIN, AIN_RATE_MAX) + repeat = _number(options.get("repeat", "1"), 1, AIN_REPEAT_MAX) + output = _choice(options, "out", _OUTPUTS, "list") + if adc != self.fw.spec.adc or (output == "list" and samples > AIN_LIST_MAX): + _fail("range") + pin = self._analog_pin(args[1]) + if self.burst is not None: + _fail("busy") + self._claim(pin, timed=True) + seconds = burst_seconds(samples, self.fw.kernel_clock, rate) + code = self.fw.adc_codes.get(pin, 2048) + self.burst = _Burst(adc, code, samples, seconds, repeat, output, self.fw.clock()) + return None + + def _run_fields(self, burst: _Burst) -> str: + us = round(burst.seconds * 1e6) + if burst.output == "list": + return f"samples={','.join([str(burst.code)] * burst.samples)} us={us}" + return f"n={burst.samples} min={burst.code} max={burst.code} mean={burst.code} us={us}" + + def poll(self) -> None: + burst = self.burst + if burst is None: + return + while burst.reported < burst.repeat and self.fw.clock() >= burst.started + (burst.reported + 1) * burst.seconds: + burst.reported += 1 + if burst.reported < burst.repeat: + self.fw.event(f"EVT ain index={burst.adc} run={burst.reported} {self._run_fields(burst)}") + continue + self.burst = None + self.fw.release(self.owner) + self.fw.event(f"OK {self._run_fields(burst)}") + + +@dataclass +class _Wave: + ends: float + + +class FakeDma(FakeGroup): + prefix = "dma" + owner: ClassVar[tuple[str, str]] = ("dma", "wave") + + def boot(self) -> None: + self.wave: _Wave | None = None + + def cmd_wave(self, args: list[str], options: dict[str, str]) -> None: + _shape(args, options, 1, 1, ("rate", "pattern", "ms")) + if "rate" not in options or "pattern" not in options: + _fail("usage") + _number(options["rate"], 1, DMA_RATE_MAX) + if not _hex(options["pattern"], DMA_PATTERN_MAX): + _fail("usage") + ms = _number(options.get("ms", str(DMA_WAVE_DEFAULT_MS)), 1, DMA_WAVE_MAX_MS) + pin = self.fw.pin(args[0]) + assert pin is not None + if not self.fw.bonded(pin): + _fail("pin") + if self.wave is not None: + _fail("busy") + resource = _WAVE_DMA[self.fw.family] + self.fw.check_pins(self.owner, [pin]) + if self.fw.timer_owners.get(_PACING_TIMER, self.owner) != self.owner: + _fail("busy") + self.fw.check_resources(self.owner, [resource]) + self.fw.claim(self.owner, [pin]) + self.fw.timer_owners[_PACING_TIMER] = self.owner + self.fw.claim_resource(*resource, self.owner) + self.wave = _Wave(self.fw.clock() + ms / 1000) + return None + + def poll(self) -> None: + if self.wave is not None and self.fw.clock() >= self.wave.ends: + self.wave = None + self.fw.release(self.owner) + self.fw.event("OK") diff --git a/validation/host/hal_st_validation/groups/analog.py b/validation/host/hal_st_validation/groups/analog.py new file mode 100644 index 00000000..a0ac8785 --- /dev/null +++ b/validation/host/hal_st_validation/groups/analog.py @@ -0,0 +1,157 @@ +"""Analog input and DMA groups of validation/PROTOCOL.md: `ain` (`hal::AnalogToDigitalPinImplStm`, +`hal::AnalogToDigitalInternalTemperatureStm`, `hal::AdcTriggeredByTimerWithDma`) and `dma` +(`hal::CircularTransmitDmaChannel`), with the expected waveforms (pure functions). + +Sources: `validation/firmware/AnalogInputGroup.cpp` (one driver per command; `ain.burst` builds one +`AdcTriggeredByTimerWithDma` over a 256-sample buffer and calls `Measure(n)` `repeat` times, paced by TIM2 through +`TriggerTiming`), `validation/firmware/DmaGroup.cpp` (TIM2 update requests write one BSRR word per pattern bit, +least significant bit of each byte first). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from ad3_waveforms_bench.protocol import Event, Response + +from .base import Group, Pin + +__all__ = [ + "AIN_BUFFER", + "AIN_LIST_MAX", + "AIN_RATE_MAX", + "AIN_RATE_MIN", + "AIN_REPEAT_MAX", + "DMA_PATTERN_MAX", + "DMA_RATE_MAX", + "DMA_WAVE_DEFAULT_MS", + "DMA_WAVE_MAX_MS", + "GROUPS", + "AinReading", + "AnalogInput", + "BurstRun", + "Dma", + "burst_seconds", + "trigger_rate", + "trigger_timing", + "wave_bits", +] + +Output = Literal["list", "stats"] + +AIN_BUFFER = 256 +AIN_LIST_MAX = 64 +AIN_RATE_MIN = 10 +AIN_RATE_MAX = 100_000 +AIN_REPEAT_MAX = 2 +DMA_RATE_MAX = 1_000_000 +DMA_PATTERN_MAX = 32 +DMA_WAVE_DEFAULT_MS = 50 +DMA_WAVE_MAX_MS = 10_000 +# Extra time `ain.burst` allows itself on top of `repeat` measurements (AnalogInputGroup.cpp `burstMarginMs`). +_BURST_MARGIN_S = 1.0 + + +def trigger_timing(clock: int, rate: int, counter_bits: int = 32) -> tuple[int, int]: + """`TriggerTiming` (AdcFactory.cpp): (PSC, ARR) of the smallest prescaler that fits, with the tick count rounded + to the nearer of floor and ceiling, at least 2 ticks.""" + maximum_ticks = 1 << counter_bits + prescale = clock // (rate * (maximum_ticks + 1)) + 1 + floor_ticks = max(clock // (prescale * rate), 2) + ceil_ticks = min(floor_ticks + 1, maximum_ticks) + round_up = abs(clock - prescale * ceil_ticks * rate) * floor_ticks < abs(clock - prescale * floor_ticks * rate) * ceil_ticks + return prescale - 1, (ceil_ticks if round_up else floor_ticks) - 1 + + +def trigger_rate(clock: int, rate: int, counter_bits: int = 32) -> float: + """Update events per second the timer really runs at for a requested `rate` (TIM2 counts 32 bits).""" + prescaler, period = trigger_timing(clock, rate, counter_bits) + return clock / ((prescaler + 1) * (period + 1)) + + +def burst_seconds(samples: int, clock: int, rate: int) -> float: + """Duration of one `Measure(samples)`: one conversion per TIM2 update, the first one period after the start.""" + return samples / trigger_rate(clock, rate) + + +def wave_bits(pattern: bytes) -> list[int]: + """The levels `dma.wave` drives, one per update: byte by byte, least significant bit first.""" + return [(byte >> bit) & 1 for byte in pattern for bit in range(8)] + + +@dataclass(frozen=True) +class AinReading: + code: int + # Whole degrees Celsius times 1000 (`__LL_ADC_CALC_TEMPERATURE` returns whole degrees); only for `temp`. + mcelsius: int | None + + +@dataclass(frozen=True) +class BurstRun: + """One `Measure(n)` of `ain.burst`: `samples` with `out=list` (None with `out=stats`), the statistics the + firmware reports (or computes the same way: mean rounded half up), and `us`, the measurement time.""" + + n: int + minimum: int + maximum: int + mean: int + us: int + samples: tuple[int, ...] | None + + @classmethod + def from_values(cls, source: Response | Event) -> BurstRun: + us = source.as_int("us") + if "samples" in source: + samples = tuple(source.as_ints("samples")) + mean = (sum(samples) + len(samples) // 2) // len(samples) + return cls(n=len(samples), minimum=min(samples), maximum=max(samples), mean=mean, us=us, samples=samples) + return cls( + n=source.as_int("n"), + minimum=source.as_int("min"), + maximum=source.as_int("max"), + mean=source.as_int("mean"), + us=us, + samples=None, + ) + + +class AnalogInput(Group): + prefix = "ain" + + def read(self, adc: int, target: Pin, sampling: str | float | None = None) -> AinReading: + """`target` is a pin or `"temp"` (the internal temperature sensor); `sampling` in ADC clock cycles.""" + source = "temp" if target == "temp" else self._pin(target) + response = self._cmd("read", adc, source, sampling=sampling) + mcelsius = response.get("mcelsius") + return AinReading(code=response.as_int("code"), mcelsius=None if mcelsius is None else int(mcelsius)) + + def burst( + self, + adc: int, + pin: Pin, + n: int, + rate: int, + repeat: int | None = None, + out: Output | None = None, + cmd_timeout: float | None = None, + ) -> list[BurstRun]: + """One run per `Measure(n)`: every run but the last arrives as `EVT ain index= run=`, the last one is + the final line.""" + timeout = cmd_timeout or self._fw.terminal.timeout + (repeat or 1) * n / rate + _BURST_MARGIN_S + response = self._cmd("burst", adc, self._pin(pin), n=n, rate=rate, repeat=repeat, out=out, cmd_timeout=timeout) + events = [event for event in self._fw.terminal.drain_events("ain") if event.as_int("index") == adc] + return [BurstRun.from_values(event) for event in events] + [BurstRun.from_values(response)] + + +class Dma(Group): + prefix = "dma" + + def wave(self, pin: Pin, rate: int, pattern: bytes, ms: int | None = None, cmd_timeout: float | None = None) -> None: + """Drives `pattern` on `pin` at `rate` bits per second for `ms` (default 50) and answers afterwards.""" + duration = (ms or DMA_WAVE_DEFAULT_MS) / 1000 + timeout = cmd_timeout or self._fw.terminal.timeout + duration + self._cmd("wave", self._pin(pin), rate=rate, pattern=pattern, ms=ms, cmd_timeout=timeout) + + +GROUPS: dict[str, type[Group]] = {"ain": AnalogInput, "dma": Dma} diff --git a/validation/host/tests/hil/test_ain.py b/validation/host/tests/hil/test_ain.py new file mode 100644 index 00000000..31ada0b6 --- /dev/null +++ b/validation/host/tests/hil/test_ain.py @@ -0,0 +1,195 @@ +"""Analog input (`hal::AnalogToDigitalPinImplStm`, `hal::AnalogToDigitalInternalTemperatureStm`, +`hal::AdcTriggeredByTimerWithDma`, PROTOCOL.md D.11): single conversions against wavegen levels, the temperature +sensor, and TIM2-paced bursts on one driver over a 256-sample buffer (B.12: `Measure(n)` delivers exactly n samples +in n / rate, also a second time on the same driver). + +Wiring set `bundle1`: W1/W2 and the scope on `tests.ain.pins` (the ADC inputs of `tests.adc`); the burst counts, +the temperature and the argument checks need no AD3. +""" + +from __future__ import annotations + +import statistics +import time + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import expect +from hal_st_validation.groups import analog + + +@pytest.fixture +def ain_cfg(board_cfg): + return board_cfg.param("ain") + + +@pytest.fixture +def adc_cfg(board_cfg): + return board_cfg.param("adc") + + +def apply_level(ad3, need, pin, volts): + """Drive `pin` to `volts`; returns the level the scope measures (100 ms, whole mains periods), else `volts`.""" + ad3.wavegen.dc(need.wavegen(pin), volts) + time.sleep(0.02) + scope = need.optional_scope(pin) + if scope is None: + return volts + return statistics.fmean(ad3.scope.acquire([scope], rate=5e4, samples=5000, range_v=5.0, offset_v=1.65)[scope]) + + +def expected_code(volts, adc_cfg): + return expect.adc_code(volts, adc_cfg["vref"], adc_cfg["bits"]) + + +def check_timing(run, samples, board_cfg, ain_cfg): + settings = ain_cfg["burst"] + expected = analog.burst_seconds(samples, board_cfg.clock("timer"), settings["rate"]) + measured = run.us / 1e6 + assert abs(measured - expected) <= settings["tolerance"] * expected + settings["latency_s"], ( + f"{run.n} samples in {measured * 1000:.2f} ms, {samples} take {expected * 1000:.2f} ms" + ) + + +@pytest.mark.ad3 +@pytest.mark.board_params("pin", "ain.pins") +@pytest.mark.board_params("level", "ain.levels") +def test_read_dc(fw, ad3, need, adc_cfg, pin, level): + """`ain.read` converts the pin once (`AnalogToDigitalPinImplStm`).""" + volts = apply_level(ad3, need, pin, level) + code = fw.ain.read(adc_cfg["adc"], pin).code + assert code == pytest.approx(expected_code(volts, adc_cfg), abs=adc_cfg["tolerance_codes"]), f"{volts:.3f} V" + + +@pytest.mark.ad3 +@pytest.mark.board_params("sampling", "ain.sampling") +def test_read_sampling(fw, ad3, need, adc_cfg, ain_cfg, sampling): + pin = ain_cfg["pins"][0] + volts = apply_level(ad3, need, pin, 1.65) + code = fw.ain.read(adc_cfg["adc"], pin, sampling=sampling).code + assert code == pytest.approx(expected_code(volts, adc_cfg), abs=adc_cfg["tolerance_codes"]) + + +def test_temperature(fw, adc_cfg, ain_cfg): + """The internal sensor through `__LL_ADC_CALC_TEMPERATURE` (whole degrees) is at room temperature with a long + sampling time; with the driver's default, below the sensor's minimum (B.14), a code still arrives.""" + low, high = ain_cfg["temp_range"] + reading = fw.ain.read(adc_cfg["adc"], "temp", sampling=ain_cfg["temp_sampling"]) + assert reading.mcelsius is not None and reading.mcelsius % 1000 == 0, reading + assert low * 1000 <= reading.mcelsius <= high * 1000, f"{reading.mcelsius / 1000} C" + default = fw.ain.read(adc_cfg["adc"], "temp") + assert 0 < default.code < 1 << adc_cfg["bits"] + + +@pytest.mark.board_params("n", "ain.burst.n") +def test_burst_count(fw, need, board_cfg, adc_cfg, ain_cfg, n): + """`Measure(n)` with n below the buffer delivers exactly n samples in n / rate; the unfixed driver fills the + whole buffer (256 samples in 256 / rate).""" + pin = ain_cfg["pins"][0] + need.unloaded(pin) + assert n < ain_cfg["burst"]["buffer"] + (run,) = fw.ain.burst(adc_cfg["adc"], pin, n=n, rate=ain_cfg["burst"]["rate"], out="stats") + assert run.n == n + check_timing(run, n, board_cfg, ain_cfg) + + +@pytest.mark.board_params("n", "ain.burst.n") +def test_burst_twice(fw, need, board_cfg, adc_cfg, ain_cfg, n): + """`repeat=2` measures twice on one driver: the second `Measure(n)` starts from the state the first left (the + unfixed driver leaves the ADC enabled with conversions pending) and again gives n samples in n / rate.""" + pin = ain_cfg["pins"][0] + need.unloaded(pin) + runs = fw.ain.burst(adc_cfg["adc"], pin, n=n, rate=ain_cfg["burst"]["rate"], repeat=2, out="stats") + assert [run.n for run in runs] == [n, n] + for run in runs: + check_timing(run, n, board_cfg, ain_cfg) + + +@pytest.mark.ad3 +@pytest.mark.board_params("pin", "ain.pins") +def test_burst_values(fw, ad3, need, adc_cfg, ain_cfg, pin): + volts = apply_level(ad3, need, pin, 2.0) + (run,) = fw.ain.burst(adc_cfg["adc"], pin, n=16, rate=ain_cfg["burst"]["rate"]) + assert run.samples is not None and len(run.samples) == 16 + expected = expected_code(volts, adc_cfg) + assert all(abs(sample - expected) <= adc_cfg["tolerance_codes"] for sample in run.samples), run.samples + + +@pytest.mark.ad3 +def test_burst_follows_a_ramp(fw, ad3, need, board_cfg, adc_cfg, ain_cfg): + """A wavegen ramp during the burst: the samples rise at the ramp's slope per TIM2 period (the window holds at + most one wrap of the sawtooth, so the samples split into at most two rising pieces).""" + settings = ain_cfg["ramp"] + pin = ain_cfg["pins"][0] + ad3.wavegen.ramp(need.wavegen(pin), settings["low"], settings["high"], settings["frequency"]) + time.sleep(0.05) + (run,) = fw.ain.burst(adc_cfg["adc"], pin, n=settings["n"], rate=settings["rate"]) + assert run.samples is not None + span = expected_code(settings["high"], adc_cfg) - expected_code(settings["low"], adc_cfg) + pieces: list[list[int]] = [[]] + for sample in run.samples: + if pieces[-1] and sample < pieces[-1][-1] - span / 2: + pieces.append([]) + pieces[-1].append(sample) + assert len(pieces) <= 2, f"{len(pieces) - 1} wraps in one burst" + longest = max(pieces, key=len) + slope = statistics.linear_regression(range(len(longest)), longest).slope + rate = analog.trigger_rate(board_cfg.clock("timer"), settings["rate"]) + expected = span * settings["frequency"] / rate + assert slope == pytest.approx(expected, rel=0.3), f"{slope:.2f} codes per sample, the ramp gives {expected:.2f}" + + +def test_busy_with_adc_and_tim2(fw, need, board_cfg, adc_cfg, ain_cfg): + """One group at a time holds the ADC: while `adc` is open `ain.read` and `ain.burst` answer `ERR busy`. The + burst also needs TIM2 and the ADC's DMA channel, so a PWM on TIM2 blocks it while `ain.read` works.""" + adc = adc_cfg["adc"] + pin, other = ain_cfg["pins"][:2] + need.unloaded(pin, other) + fw.adc.open(adc, pins=[other]) + for target in (pin, "temp"): + with pytest.raises(FirmwareError) as error: + fw.ain.read(adc, target) + assert error.value.reason == "busy", target + with pytest.raises(FirmwareError) as error: + fw.ain.burst(adc, pin, n=16, rate=1000) + assert error.value.reason == "busy", "burst" + fw.adc.close(adc) + tim2 = next(timer for timer in board_cfg.param("pwm.timers") if timer["timer"] == 2) + fw.pwm.open(2, pins=[tim2["channels"][0]["pin"]]) + with pytest.raises(FirmwareError) as error: + fw.ain.burst(adc, pin, n=16, rate=1000) + assert error.value.reason == "busy", "TIM2 held by pwm" + fw.ain.read(adc, pin) + fw.pwm.close(2) + assert fw.ain.burst(adc, pin, n=16, rate=1000)[0].n == 16 + fw.adc.open(adc, pins=[other], timer=2) + + +def test_errors(fw, board_cfg, adc_cfg, ain_cfg): + adc, pin = adc_cfg["adc"], ain_cfg["pins"][0] + digital = board_cfg.param("gpio.output_pins")[0] + missing = next(number for number in range(8) if number != adc) + burst = {"n": 16, "rate": 1000} + cases = [ + (("ain.read", adc), {}, "usage"), + (("ain.read", adc, pin), {"sampling": "1"}, "usage"), + (("ain.read", missing, pin), {}, "range"), + (("ain.read", adc, digital), {}, "pin"), + (("ain.read", adc, "nosuchpin"), {}, "pin"), + (("ain.burst", adc, pin), {"n": 16}, "usage"), + (("ain.burst", adc, pin), {"rate": 1000}, "usage"), + (("ain.burst", adc, pin), {**burst, "n": 0}, "range"), + (("ain.burst", adc, pin), {**burst, "n": analog.AIN_BUFFER + 1}, "range"), + (("ain.burst", adc, pin), {**burst, "n": analog.AIN_LIST_MAX + 1}, "range"), + (("ain.burst", adc, pin), {**burst, "rate": analog.AIN_RATE_MIN - 1}, "range"), + (("ain.burst", adc, pin), {**burst, "rate": analog.AIN_RATE_MAX + 1}, "range"), + (("ain.burst", adc, pin), {**burst, "repeat": analog.AIN_REPEAT_MAX + 1}, "range"), + (("ain.burst", adc, pin), {**burst, "out": "hex"}, "usage"), + (("ain.burst", missing, pin), burst, "range"), + (("ain.burst", adc, digital), burst, "pin"), + ] + for (name, *args), options, reason in cases: + with pytest.raises(FirmwareError) as error: + fw.command(name, *args, **options) + assert error.value.reason == reason, (name, args, options) diff --git a/validation/host/tests/hil/test_dma.py b/validation/host/tests/hil/test_dma.py new file mode 100644 index 00000000..60935f10 --- /dev/null +++ b/validation/host/tests/hil/test_dma.py @@ -0,0 +1,115 @@ +"""DMA (`hal::CircularTransmitDmaChannel`, PROTOCOL.md D.12): `dma.wave` writes one 32-bit BSRR word per pattern bit +on every TIM2 update, memory and peripheral side 32 bits wide (B.4 on both MCUs), so the pin shows the pattern, +least significant bit first, at `rate` bits per second. + +Wiring set `bundle1` or `bundle2`: `tests.dma.pin` on a DIO; the argument and sharing checks need no AD3. +""" + +from __future__ import annotations + +import math +import time + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.firmware import settle +from hal_st_validation.groups import analog + + +@pytest.fixture +def dma_cfg(board_cfg): + return board_cfg.param("dma") + + +def decode_bits(levels, samples_per_bit, tolerance): + """The bits between the first and the last edge: every level run lasts a whole number of bit times.""" + runs = analysis.runs(levels)[1:-1] + assert runs, "fewer than two edges captured" + bits: list[int] = [] + for level, _, length in runs: + count = length / samples_per_bit + assert abs(count - round(count)) <= tolerance, f"a run of {count:.2f} bit times" + bits += [level] * round(count) + return bits, runs[0][1], runs[-1][1] + runs[-1][2] + + +def is_cyclic_part(bits, pattern): + repeated = pattern * (len(bits) // len(pattern) + 2) + return any(repeated[start : start + len(bits)] == bits for start in range(len(pattern))) + + +@pytest.mark.ad3 +@pytest.mark.matrix("dma.wave") +def test_wave_32_bit(fw, ad3, need, board_cfg, dma_cfg, rate, pattern): + """The LA sees `pattern` repeated at `rate` (TIM2 rounded to whole kernel clocks): each level lasts a whole + number of bit times and the bits between the first and the last edge are a part of the repeated pattern.""" + pin = dma_cfg["pin"] + dio = need.dio(pin) + data = bytes.fromhex(pattern) + actual = analog.trigger_rate(board_cfg.clock("timer"), rate) + sample_rate = min(ad3.logic.clock_hz, actual * dma_cfg["samples_per_bit"]) + samples = min(ad3.logic.buffer_size, math.ceil(dma_cfg["record_bits"] * sample_rate / actual)) + delay = dma_cfg["start_delay_s"] + ms = max(dma_cfg["ms"], math.ceil((delay + samples / sample_rate + 0.1) * 1000)) + pending = fw.dma.begin("wave", fw.pin(pin), rate=rate, pattern=data, ms=ms, cmd_timeout=fw.terminal.timeout + ms / 1000) + try: + time.sleep(delay) + capture = ad3.logic.record(sample_rate, samples, timeout=samples / sample_rate + 2) + finally: + response = settle(pending) + assert response is not None and response.ok, "dma.wave did not answer after ms" + tolerance = dma_cfg["tolerance"] + samples_per_bit = capture.rate / actual + bits, first, last = decode_bits(capture.channel(dio), samples_per_bit, tolerance["bit"]) + assert is_cyclic_part(bits, analog.wave_bits(data)), f"{''.join(map(str, bits))} is no part of the pattern" + measured = len(bits) * capture.rate / (last - first) + assert measured == pytest.approx(actual, rel=tolerance["rate"]), "bit rate" + + +def test_wave_errors(fw, board_cfg, dma_cfg): + """`rate` and `pattern` are required, `pattern` holds 1-32 bytes, `rate` 1-1000000 and `ms` 1-10000.""" + pin = dma_cfg["pin"] + unbonded = board_cfg.param("system.unbonded_pins")[0] + wave = {"rate": 1000, "pattern": "a5"} + cases = [ + ((pin,), {"rate": 1000}, "usage"), + ((pin,), {"pattern": "a5"}, "usage"), + ((pin,), {**wave, "pattern": "-"}, "usage"), + ((pin,), {**wave, "pattern": "abc"}, "usage"), + ((pin,), {**wave, "pattern": "00" * (analog.DMA_PATTERN_MAX + 1)}, "range"), + ((pin,), {**wave, "rate": 0}, "range"), + ((pin,), {**wave, "rate": analog.DMA_RATE_MAX + 1}, "range"), + ((pin,), {**wave, "ms": 0}, "range"), + ((pin,), {**wave, "ms": analog.DMA_WAVE_MAX_MS + 1}, "range"), + ((pin, pin), wave, "usage"), + ((unbonded,), wave, "pin"), + (("nosuchpin",), wave, "pin"), + (("terminaltx",), wave, "busy"), + ] + for args, options, reason in cases: + with pytest.raises(FirmwareError) as error: + fw.command("dma.wave", *args, **options) + assert error.value.reason == reason, (args, options) + + +def test_wave_shares_tim2_and_its_pin(fw, need, board_cfg, dma_cfg): + """TIM2 paces the wave, so a PWM on TIM2 makes `dma.wave` busy; so does its pin held as a GPIO. Both are free + again once the wave answered.""" + pin = dma_cfg["pin"] + need.unloaded(pin) + tim2 = next(timer for timer in board_cfg.param("pwm.timers") if timer["timer"] == 2) + fw.pwm.open(2, pins=[tim2["channels"][0]["pin"]]) + with pytest.raises(FirmwareError) as error: + fw.dma.wave(pin, rate=1000, pattern=b"\xa5", ms=10) + assert error.value.reason == "busy", "TIM2 held by pwm" + fw.pwm.close(2) + fw.gpio.cfg(pin, "out") + with pytest.raises(FirmwareError) as error: + fw.dma.wave(pin, rate=1000, pattern=b"\xa5", ms=10) + assert error.value.reason == "busy", "pin held by gpio" + fw.gpio.release(pin) + fw.dma.wave(pin, rate=1000, pattern=b"\xa5", ms=10) + fw.gpio.cfg(pin, "out") + fw.pwm.open(2, pins=[tim2["channels"][0]["pin"]]) diff --git a/validation/host/tests/hil/test_pwm_ext.py b/validation/host/tests/hil/test_pwm_ext.py new file mode 100644 index 00000000..95a887c7 --- /dev/null +++ b/validation/host/tests/hil/test_pwm_ext.py @@ -0,0 +1,296 @@ +"""PWM extensions (`hal::PwmStm`, PROTOCOL.md D.10): the five counter modes, compare preload, the break input +filter, and the trigger output an `adc.open trgo=` sequence runs on. + +Wiring set `bundle1`: the outputs and break inputs of `tests.pwm.timers` on DIOs (`tests.pwm_ext` names the +entries); the TRGO and argument tests need no AD3. +""" + +from __future__ import annotations + +import math +import statistics +import time + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import expect + +# The waveform each mode gives: every centre-aligned mode counts up and down; `edgedown` counts down. +ALIGNMENTS: dict[str, expect.PwmMode] = { + "edge": "edge", + "edgedown": "edge", + "center": "center", + "centerup": "center", + "centerboth": "center", +} +# Where the outputs of one timer line up: the period starts counting up (rising edges) and ends counting down +# (falling edges); centre-aligned pulses share their centres. +FEATURES = {"edge": "rising", "edgedown": "falling"} +# BDTR.BKF: (fDTS divider, consecutive samples); the filter needs divider x samples kernel clocks (CKD = 1). +BREAK_FILTERS = { + 0: (1, 0), + 1: (1, 2), + 2: (1, 4), + 3: (1, 8), + 4: (2, 6), + 5: (2, 8), + 6: (4, 6), + 7: (4, 8), + 8: (8, 6), + 9: (8, 8), + 10: (16, 5), + 11: (16, 6), + 12: (16, 8), + 13: (32, 5), + 14: (32, 6), + 15: (32, 8), +} + + +@pytest.fixture +def ext_cfg(board_cfg): + return board_cfg.param("pwm_ext") + + +@pytest.fixture(scope="module") +def timer_clock(board_cfg): + return board_cfg.clock("timer") + + +def pwm_timer(board_cfg, name): + return next(timer for timer in board_cfg.param("pwm.timers") if timer["name"] == name) + + +def break_filter_ticks(value): + divider, samples = BREAK_FILTERS[value] + return divider * samples + + +def record(ad3, ext_cfg, frequency, trigger_dio=None, periods=None): + periods = periods or ext_cfg["capture_periods"] + trigger = None if trigger_dio is None else (trigger_dio, "rising") + return ad3.logic.record_for(periods / frequency, trigger=trigger, timeout=periods / frequency + 2) + + +def feature_times(capture, dio, feature): + bits = capture.channel(dio) + if feature == "rising": + return analysis.rising_times(bits, capture.rate) + if feature == "falling": + return analysis.falling_times(bits, capture.rate) + return analysis.pulse_centers(bits, capture.rate) + + +@pytest.mark.ad3 +@pytest.mark.board_params("mode", "pwm_ext.modes") +def test_alignment(fw, ad3, need, board_cfg, ext_cfg, mode): + """Every mode gives the frequency and duty of its waveform; two outputs of one timer share their rising edges + counting up, their falling edges counting down (`edgedown`) and their pulse centres centre aligned.""" + timer = pwm_timer(board_cfg, ext_cfg["alignment_timer"]) + channels = timer["channels"][:2] + dios = [need.dio(channel["pin"]) for channel in channels] + frequency, duties = ext_cfg["alignment_frequency"], ext_cfg["alignment_duties"] + tolerance = ext_cfg["tolerance"] + pwmclk = fw.pwm.open(timer["timer"], pins=[channel["pin"] for channel in channels], freq=frequency, mode=mode) + fw.pwm.duty(timer["timer"], *duties) + capture = record(ad3, ext_cfg, frequency, dios[0]) + alignment = ALIGNMENTS[mode] + expected_frequency = expect.pwm_frequency(pwmclk, frequency, alignment) + for dio, duty in zip(dios, duties): + assert capture.frequency(dio) == pytest.approx(expected_frequency, rel=tolerance["frequency"]), f"DIO{dio} frequency" + quantisation = 100 * 2 * frequency / capture.rate + expect.pwm_duty_step(pwmclk, frequency, alignment) + expected_duty = expect.pwm_duty(pwmclk, frequency, alignment, duty) + assert capture.duty(dio) * 100 == pytest.approx(expected_duty, abs=tolerance["duty"] + quantisation), f"DIO{dio} duty" + feature = FEATURES.get(mode, "center") + reference = feature_times(capture, dios[0], feature)[1:-1] + assert reference, "no complete pulses captured" + offsets = analysis.nearest_offsets(reference, feature_times(capture, dios[1], feature)) + worst = max(abs(offset) for offset in offsets) + assert worst <= tolerance["alignment_s"] + 2 / capture.rate, f"{feature} edges off by {worst * 1e9:.0f} ns" + + +@pytest.mark.ad3 +def test_edgedown_cannot_reach_zero(fw, ad3, need, board_cfg, ext_cfg, timer_clock): + """At 0 % `mode=edge` stays low, while `mode=edgedown` keeps one counter tick high per period: counting down, + PWM mode 1 is active while CNT <= CCR, so CCR = 0 still matches once (PwmStm.hpp).""" + settings = ext_cfg["zero_duty"] + timer = pwm_timer(board_cfg, ext_cfg["alignment_timer"]) + pin = timer["channels"][0]["pin"] + dio = need.dio(pin) + tick = settings["tick_us"] / 1e6 + frequency, periods = settings["frequency"], settings["periods"] + prescaler = round(timer_clock * tick) - 1 + for mode in ("edge", "edgedown"): + fw.pwm.open(timer["timer"], pins=[pin], freq=frequency, mode=mode, prescaler=prescaler) + fw.pwm.duty(timer["timer"], 0) + capture = ad3.logic.record_for(periods / frequency, timeout=periods / frequency + 2) + fw.pwm.close(timer["timer"]) + highs = analysis.high_low_times(capture.channel(dio), capture.rate)[0] + if mode == "edge": + assert capture.edge_count(dio) == 0 and capture.channel(dio)[0] == 0, "edge aligned 0 % must stay low" + continue + assert len(highs) >= periods - 2, f"{len(highs)} pulses in {periods} periods" + assert statistics.median(highs) == pytest.approx(tick, abs=0.1 * tick + 2 / capture.rate) + + +@pytest.mark.board_params("timer", "pwm.timers") +def test_modes_and_trgo_need_a_master_timer(fw, timer): + """Every mode but `edge` needs a counter mode select and `trgo` a master mode (both: TIM1, TIM2 and on + STM32WBA55 TIM3); TIM16/TIM17 answer `ERR unsupported`, the others open.""" + number, pin = timer["timer"], timer["channels"][0]["pin"] + supported = expect.timer_has_center_mode(number) + cases = [{"mode": mode} for mode in ALIGNMENTS if mode != "edge"] + [{"trgo": "update"}, {"trgo": "oc1ref"}] + for options in cases: + try: + fw.pwm.open(number, pins=[pin], **options) + except FirmwareError as error: + assert error.reason == "unsupported" and not supported, f"{options}: ERR {error.reason}" + continue + assert supported, f"{options} opened on TIM{number}" + fw.pwm.close(number) + + +@pytest.mark.ad3 +@pytest.mark.parametrize("preload", [0, 1]) +def test_preload(fw, ad3, need, board_cfg, ext_cfg, timer_clock, preload): + """Duty 80 % then 20 %, written `runt_repeats` times while the LA records: without preload a write lands + mid-period and cuts or stretches that period's pulse (some high pulse lies strictly between 20 % and 80 %); + with preload every period shows 20 % or 80 %.""" + timer = pwm_timer(board_cfg, ext_cfg["preload_timer"]) + number, pin = timer["timer"], timer["channels"][0]["pin"] + dio = need.dio(pin) + frequency = ext_cfg["preload_frequency"] + fw.pwm.open(number, pins=[pin], freq=frequency, prescaler=timer_clock // 1_000_000 - 1, preload=bool(preload)) + fw.pwm.duty(number, 20) + window = ext_cfg["runt_window_s"] + rate = min(ad3.logic.clock_hz, ad3.logic.buffer_size / window) + pending = ad3.logic.arm(rate, math.floor(rate * window)) + started = time.monotonic() + for _ in range(ext_cfg["runt_repeats"]): + fw.pwm.duty(number, 80) + fw.pwm.duty(number, 20) + elapsed = time.monotonic() - started + capture = pending.wait(timeout=window + 2) + assert elapsed < window, f"the writes took {elapsed:.3f} s, longer than the {window} s recording" + period = 1 / frequency + margin = ext_cfg["runt_margin"] * period + 2 / capture.rate + highs = analysis.high_low_times(capture.channel(dio), capture.rate)[0] + assert highs, "no pulses captured" + runts = [high for high in highs if 0.2 * period + margin < high < 0.8 * period - margin] + if preload: + assert not runts, f"preload=1: pulses of {[round(high / period * 100, 1) for high in runts]} %" + else: + assert runts, f"preload=0: no cut pulse in {ext_cfg['runt_repeats']} writes" + + +@pytest.mark.ad3 +@pytest.mark.board_params("name", "pwm_ext.break_filter_timers") +@pytest.mark.board_params("case", "pwm_ext.break_filter_cases") +def test_break_filter(fw, ad3, need, board_cfg, ext_cfg, timer_clock, name, case): + """`brkfilter` (BDTR.BKF) ignores a break pulse shorter than the filter: one AD3 pulse of `ticks` kernel clocks + trips the break (outputs off, `brkauto=0`) exactly when it outlasts the filter; without a filter even the + short pulse trips it.""" + timer = pwm_timer(board_cfg, name) + pin, brk = timer["channels"][0]["pin"], timer.get("brk") + if brk is None: + pytest.skip(f"{name} has no wired break input") + dio, brk_dio = need.dio(pin), need.dio(brk) + filter_ticks = break_filter_ticks(case["filter"]) + assert case["trips"] == (case["ticks"] > filter_ticks), "tests.pwm_ext.break_filter_cases contradicts BKF" + frequency = ext_cfg["break_frequency"] + ad3.dio.drive(brk_dio, 0) + fw.pwm.open(timer["timer"], pins=[pin], freq=frequency, brk=brk, brkpol="high", brkfilter=case["filter"]) + fw.pwm.duty(timer["timer"], 50) + assert record(ad3, ext_cfg, frequency).edge_count(dio) > 0, "outputs must run before the pulse" + width = case["ticks"] / timer_clock + ad3.pattern.pulses(brk_dio, 1, 0.5 / width, 0.5) + ad3.pattern.wait_done(timeout=2) + time.sleep(ext_cfg["break_settle_s"]) + edges = record(ad3, ext_cfg, frequency).edge_count(dio) + assert (edges == 0) is case["trips"], f"{width * 1e6:.2f} us pulse, filter {filter_ticks} clocks: {edges} edges after it" + + +def timed_measure(fw, adc, runs, repeats, baud): + """Shortest of `repeats` round trips of `adc.measure n=`, less the transmission of its reply.""" + shortest = math.inf + for _ in range(repeats): + start = time.monotonic() + samples = fw.adc.measure(adc, n=runs, cmd_timeout=3.0) + shortest = min(shortest, time.monotonic() - start) + assert len(samples) == runs + return shortest - expect.uart_transfer_time(len(",".join(str(sample) for sample in samples)), baud) + + +@pytest.mark.matrix("pwm_ext.trgo") +def test_trgo_paces_adc(fw, board_cfg, ext_cfg, need, timer, source): + """`adc.open trgo=` converts once per TRGO of the timer the pwm group drives: with `trgo=update` or + `trgo=oc1ref` once per period, so `adc.measure n=` takes (runs - 1) periods longer than `n=1`.""" + entry = pwm_timer(board_cfg, timer) + adc_cfg = board_cfg.param("adc") + adc, pin = adc_cfg["adc"], adc_cfg["inputs"][0] + need.unloaded(pin, entry["channels"][0]["pin"]) + frequency, runs = ext_cfg["trgo_frequency"], ext_cfg["trgo_runs"] + pwmclk = fw.pwm.open(entry["timer"], pins=[entry["channels"][0]["pin"]], freq=frequency, trgo=source) + fw.adc.open(adc, pins=[pin], trgo=entry["timer"]) + fw.pwm.duty(entry["timer"], 50) + baud, repeats = board_cfg.terminal.baud, ext_cfg["trgo_repeats"] + single = timed_measure(fw, adc, 1, repeats, baud) + measured = timed_measure(fw, adc, runs, repeats, baud) - single + expected = (runs - 1) / expect.pwm_frequency(pwmclk, frequency, "edge") + tolerance, jitter = ext_cfg["trgo_tolerance"], ext_cfg["trgo_jitter_s"] + assert (1 - tolerance) * expected - jitter <= measured <= (1 + tolerance) * expected + jitter, ( + f"{runs - 1} more runs took {measured * 1000:.2f} ms, {expected * 1000:.2f} ms at {frequency} Hz" + ) + + +def test_trgo_errors(fw, board_cfg, ext_cfg): + """`trgo` excludes `timer` and `rate` (`ERR usage`); a timer the MCU lacks is `ERR range`; a timer nobody drives, + or one whose TRGO the ADC cannot trigger from, is `ERR unsupported`; one another group holds is `ERR busy`.""" + adc_cfg = board_cfg.param("adc") + adc, pin = adc_cfg["adc"], adc_cfg["inputs"][0] + number = pwm_timer(board_cfg, ext_cfg["trgo"]["timer"][0])["timer"] + cases = [ + ({"trgo": number}, "unsupported"), + ({"trgo": number, "timer": number}, "usage"), + ({"trgo": number, "rate": 1000}, "usage"), + ({"trgo": 18}, "range"), + ] + for options, reason in cases: + with pytest.raises(FirmwareError) as error: + fw.adc.open(adc, pins=[pin], **options) + assert error.value.reason == reason, options + for name in ext_cfg["trgo_unsupported_timers"]: + entry = pwm_timer(board_cfg, name) + fw.pwm.open(entry["timer"], pins=[entry["channels"][0]["pin"]]) + with pytest.raises(FirmwareError) as error: + fw.adc.open(adc, pins=[pin], trgo=entry["timer"]) + assert error.value.reason == "unsupported", name + fw.pwm.close(entry["timer"]) + encoder = next((instance for instance in board_cfg.param("qei.instances") if instance["index"] == number), None) + if encoder is not None: + fw.qei.open(number, a=encoder["a"], b=encoder["b"]) + with pytest.raises(FirmwareError) as error: + fw.adc.open(adc, pins=[pin], trgo=number) + assert error.value.reason == "busy", "the encoder holds the timer" + + +def test_open_errors(fw, board_cfg, ext_cfg): + """`brkfilter` 0-15 needs `brk` (`ERR usage`), `preload` is 0 or 1, `mode` and `trgo` take their names only.""" + timer = pwm_timer(board_cfg, ext_cfg["alignment_timer"]) + number, brk = timer["timer"], timer["brk"] + cases = [ + ({"brkfilter": 3}, "usage"), + ({"brkfilter": 16}, "range"), + ({"brkfilter": "x", "brk": brk}, "usage"), + ({"brkfilter": 16, "brk": brk}, "range"), + ({"preload": 2}, "range"), + ({"mode": "edgeup"}, "usage"), + ({"trgo": "oc5ref"}, "usage"), + ] + for options, reason in cases: + with pytest.raises(FirmwareError) as error: + fw.command("pwm.open", number, channels=1, **options) + assert error.value.reason == reason, options + fw.pwm.open(number, channels=[1], brk=brk, brkfilter=15, preload=False, mode="centerboth", trgo="oc4ref") diff --git a/validation/host/tests/unit/test_config.py b/validation/host/tests/unit/test_config.py index f0fd2c84..59faa94a 100644 --- a/validation/host/tests/unit/test_config.py +++ b/validation/host/tests/unit/test_config.py @@ -129,8 +129,8 @@ def test_wb55_bundle2_moves_the_encoder_inputs(): @pytest.mark.parametrize("name", BOARDS) def test_parameters_reference_wired_pins(name): - """Pins in the test parameters are wired in bundle1 (the LPTIM encoder in bundle2); SPI instances are wired in some - set, through the jumpers of their `option` when they name one.""" + """Pins in the test parameters are wired in bundle1 (the LPTIM encoder in bundle2); PWM timers and SPI instances are + wired in some set, SPI instances through the jumpers of their `option` when they name one.""" board = load_board(name) def wired(pin, kind="dio", bundle="bundle1"): @@ -149,9 +149,9 @@ def wired_in_a_set(pin, option=None): assert counting[2:] == other[2:] and counting[1] != other[1], "same EXTI line, other port" for timer in board.param("pwm.timers"): for channel in timer["channels"]: - assert wired(channel["pin"]), channel - assert channel.get("npin") is None or wired(channel["npin"]), channel - assert timer.get("brk") is None or wired(timer["brk"]), timer + assert wired_in_a_set(channel["pin"]), channel + assert channel.get("npin") is None or wired_in_a_set(channel["npin"]), channel + assert timer.get("brk") is None or wired_in_a_set(timer["brk"]), timer for instance in board.param("uart.instances"): assert all(wired(instance[key]) for key in ("tx", "rx", "rts", "cts")), instance for instance in board.param("spi.instances"): diff --git a/validation/host/tests/unit/test_fake_analog.py b/validation/host/tests/unit/test_fake_analog.py new file mode 100644 index 00000000..2ceaf260 --- /dev/null +++ b/validation/host/tests/unit/test_fake_analog.py @@ -0,0 +1,284 @@ +"""The fake `ain` and `dma` groups (argument order and reasons of validation/firmware/AnalogInputGroup.cpp and +DmaGroup.cpp; PROTOCOL.md D.11, D.12), what they share with `adc`, `pwm` and the other TIM2 users, their deferred +final lines over a fake clock, and the `groups.analog` wrappers and expectations.""" + +import pytest +from ad3_waveforms_bench.protocol import parse_response +from ad3_waveforms_bench.terminal import FirmwareTerminal + +from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS, FakeFirmware, FakeSerial +from hal_st_validation.fakes import analog as fake_analog +from hal_st_validation.firmware import Firmware +from hal_st_validation.groups import analog + + +class Clock: + def __init__(self) -> None: + self.now = 100.0 + + def __call__(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.now += seconds + + +def make(family="stm32wb55"): + clock = Clock() + fake = FakeFirmware(family=family, clock=clock, sleep=clock.sleep) + terminal = FirmwareTerminal(serial=FakeSerial(fake), timeout=0.5) + pins = WB55_PINS if family == "stm32wb55" else WBA55_PINS + return terminal, fake, clock, Firmware(terminal, pins) + + +def ticking(fake, clock, step): + """Advance the fake clock by `step` whenever the host reads or writes, so deferred replies arrive.""" + original = fake.poll + + def poll(): + clock.now += step + original() + + fake.poll = poll + + +def reason(terminal, line): + response = terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("ain.read", "usage"), + ("ain.read 1", "usage"), + ("ain.read 1 ain4 extra", "usage"), + ("ain.read 1 ain4 speed=1", "usage"), + ("ain.read x ain4", "usage"), + ("ain.read 65536 ain4", "range"), + ("ain.read 1 ain4 sampling=3.5", "usage"), + ("ain.read 2 ain4", "range"), + ("ain.read 2 ain4 sampling=3.5", "usage"), + ("ain.read 1 PZ1", "pin"), + ("ain.read 1 gpio0", "pin"), + ("ain.read 1 PC7", "pin"), + ("ain.read 1 terminaltx", "pin"), + ("ain.read 1 ain4", "ok"), + ("ain.read 1 ain4 sampling=640.5", "ok"), + ("ain.read 1 temp sampling=640.5", "ok"), + ], +) +def test_read_reasons(line, expected): + terminal, *_ = make() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("ain.burst 1 ain4", "usage"), + ("ain.burst 1 ain4 n=16", "usage"), + ("ain.burst 1 ain4 rate=1000", "usage"), + ("ain.burst 1 n=16 rate=1000", "usage"), + ("ain.burst 1 ain4 n=16 rate=1000 speed=1", "usage"), + ("ain.burst 1 ain4 n=x rate=1000", "usage"), + ("ain.burst 1 ain4 n=0 rate=1000", "range"), + ("ain.burst 1 ain4 n=257 rate=1000", "range"), + ("ain.burst 1 ain4 n=16 rate=9", "range"), + ("ain.burst 1 ain4 n=16 rate=100001", "range"), + ("ain.burst 1 ain4 n=16 rate=1000 repeat=0", "range"), + ("ain.burst 1 ain4 n=16 rate=1000 repeat=3", "range"), + ("ain.burst 1 ain4 n=16 rate=1000 out=hex", "usage"), + ("ain.burst 1 ain4 n=65 rate=1000", "range"), + ("ain.burst 4 ain4 n=16 rate=1000", "range"), + ("ain.burst 4 gpio0 n=16 rate=1000", "range"), + ("ain.burst 1 gpio0 n=16 rate=1000", "pin"), + ("ain.burst 1 PZ1 n=16 rate=1000", "pin"), + ], +) +def test_burst_reasons(line, expected): + terminal, *_ = make() + assert reason(terminal, line) == expected + + +def test_read_codes_and_temperature(): + _, fake, _, fw = make("stm32wba55") + fake.adc_codes["PA7"] = 1234 + assert fw.ain.read(4, "ain2") == analog.AinReading(code=1234, mcelsius=None) + assert fake.received[-1] == "ain.read 4 PA7" + reading = fw.ain.read(4, "temp", sampling="814.5") + assert reading.mcelsius == fake_analog.TEMPERATURE_MCELSIUS + assert fake.received[-1] == "ain.read 4 temp sampling=814.5" + assert fake.resources == {} and fake.claims == {} + + +def test_burst_answers_after_n_over_rate_and_holds_its_claims_until_then(): + terminal, fake, clock, _ = make() + fake.adc_codes["PC3"] = 777 + started = clock.now + pending = terminal.begin("ain.burst 1 ain4 n=100 rate=1000 out=stats") + clock.now = started + 0.099 + terminal.pump() + assert fake.group("ain").burst is not None, "answered before n / rate" + owner = fake_analog.FakeAnalogInput.owner + assert fake.resources == {("adc", 1): owner, ("dma1", 7): owner} + assert fake.timer_owners == {2: owner} and fake.claims["PC3"] == (owner, True) + clock.now = started + 0.1 + run = analog.BurstRun.from_values(pending.wait()) + assert run == analog.BurstRun(n=100, minimum=777, maximum=777, mean=777, us=100_000, samples=None) + assert fake.resources == {} and fake.timer_owners == {} and fake.claims == {} + + +def test_burst_twice_reports_the_first_run_as_event(): + terminal, fake, clock, fw = make() + fake.adc_codes["PC3"] = 100 + pending = fw.ain.begin("burst", 1, "PC3", n=16, rate=1000, repeat=2) + clock.now += 0.016 + assert [event.as_int("run") for event in terminal.events("ain")] == [1] + clock.now += 0.016 + response = pending.wait() + first = terminal.drain_events("ain") + runs = [analog.BurstRun.from_values(first[0]), analog.BurstRun.from_values(response)] + assert [run.samples for run in runs] == [(100,) * 16] * 2 + assert [run.us for run in runs] == [16_000, 16_000] + + +def test_burst_wrapper_collects_every_run(): + _, fake, clock, fw = make() + ticking(fake, clock, 0.001) + fake.adc_codes["PC3"] = 5 + runs = fw.ain.burst(1, "ain4", n=16, rate=1000, repeat=2) + assert [run.n for run in runs] == [16, 16] + assert all(run.mean == 5 and run.samples == (5,) * 16 for run in runs) + assert fw.ain.burst(1, "ain4", n=200, rate=10000, out="stats")[0].n == 200 + + +def test_ain_shares_the_adc_tim2_and_the_dma_channel(): + terminal, fake, clock, fw = make() + fw.adc.open(1, pins=["ain4"]) + assert reason(terminal, "ain.read 1 ain3") == "busy" + assert reason(terminal, "ain.read 1 temp") == "busy" + assert reason(terminal, "ain.burst 1 ain3 n=16 rate=1000") == "busy" + fw.adc.close(1) + fw.pwm.open(2, pins=["tim2ch1"]) + assert reason(terminal, "ain.burst 1 ain3 n=16 rate=1000") == "busy" + assert reason(terminal, "ain.read 1 ain3") == "ok" + fw.pwm.close(2) + fake.resources[("dma1", 7)] = ("spis", "1") + assert reason(terminal, "ain.burst 1 ain3 n=16 rate=1000") == "busy" + assert reason(terminal, "ain.read 1 ain3") == "ok" + del fake.resources[("dma1", 7)] + pending = terminal.begin("ain.burst 1 ain4 n=16 rate=1000") + clock.now += 0.016 + assert pending.wait().ok + assert reason(terminal, "adc.open 1 pins=PC2 timer=2") == "ok" + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("dma.wave", "usage"), + ("dma.wave gpio0 rate=1000", "usage"), + ("dma.wave gpio0 pattern=a5", "usage"), + ("dma.wave gpio0 PA1 rate=1000 pattern=a5", "usage"), + ("dma.wave gpio0 rate=1000 pattern=a5 speed=1", "usage"), + ("dma.wave gpio0 rate=x pattern=a5", "usage"), + ("dma.wave gpio0 rate=0 pattern=a5", "range"), + ("dma.wave gpio0 rate=1000001 pattern=a5", "range"), + ("dma.wave gpio0 rate=1000 pattern=-", "usage"), + ("dma.wave gpio0 rate=1000 pattern=a", "usage"), + ("dma.wave gpio0 rate=1000 pattern=zz", "usage"), + ("dma.wave gpio0 rate=1000 pattern=" + "00" * 33, "range"), + ("dma.wave gpio0 rate=1000 pattern=a5 ms=0", "range"), + ("dma.wave gpio0 rate=1000 pattern=a5 ms=10001", "range"), + ("dma.wave gpio0 rate=0 pattern=zz", "range"), + ("dma.wave PZ1 rate=1000 pattern=a5", "pin"), + ("dma.wave PC7 rate=1000 pattern=a5", "pin"), + ("dma.wave terminaltx rate=1000 pattern=a5", "busy"), + ], +) +def test_wave_reasons(line, expected): + terminal, *_ = make() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize(("family", "resource"), [("stm32wb55", ("dma2", 4)), ("stm32wba55", ("dma1", 8))]) +def test_wave_holds_its_claims_for_ms(family, resource): + terminal, fake, clock, _ = make(family) + pin = "PC6" if family == "stm32wb55" else "PB14" + pending = terminal.begin("dma.wave gpio0 rate=1000000 pattern=" + "ff" * 32 + " ms=200") + owner = fake_analog.FakeDma.owner + clock.now += 0.199 + terminal.pump() + assert fake.group("dma").wave is not None, "answered before ms" + assert fake.resources == {resource: owner} and fake.timer_owners == {2: owner} and fake.claims == {pin: (owner, False)} + clock.now += 0.001 + assert pending.wait().ok + assert fake.resources == {} and fake.timer_owners == {} and fake.claims == {} + assert reason(terminal, "gpio.cfg gpio0 out") == "ok" + + +def test_wave_busy_with_tim2_users_and_the_shared_channel(): + terminal, fake, clock, fw = make("stm32wba55") + fw.pwm.open(2, pins=["tim2ch1"]) + assert reason(terminal, "dma.wave gpio1 rate=1000 pattern=a5") == "busy" + fw.pwm.close(2) + fake.resources[("dma1", 8)] = ("spis", "1") + assert reason(terminal, "dma.wave gpio1 rate=1000 pattern=a5") == "busy" + del fake.resources[("dma1", 8)] + fw.gpio.cfg("gpio1", "out") + assert reason(terminal, "dma.wave gpio1 rate=1000 pattern=a5") == "busy" + pending = terminal.begin("dma.wave gpio0 rate=1000 pattern=a5") + clock.now += 0.05 + assert pending.wait().ok + + +def test_wave_wrapper_sends_hex_and_waits(): + _, fake, clock, fw = make() + ticking(fake, clock, 0.01) + fw.dma.wave("gpio0", rate=100000, pattern=bytes([0xA5, 0x0F]), ms=20) + assert fake.received[-1] == "dma.wave PC6 rate=100000 pattern=a50f ms=20" + + +def test_reboot_drops_the_pending_commands(): + terminal, fake, *_ = make() + terminal.begin("dma.wave gpio0 rate=1000 pattern=a5 ms=10000") + fake.boot() + assert fake.group("dma").wave is None and fake.resources == {} + assert fake.group("ain").burst is None + + +def test_wave_bits_are_lsb_first(): + assert analog.wave_bits(bytes([0x01, 0x80])) == [1, 0, 0, 0, 0, 0, 0, 0] + [0, 0, 0, 0, 0, 0, 0, 1] + + +@pytest.mark.parametrize( + ("clock", "rate", "expected"), + [ + (64_000_000, 1_000_000, (0, 63)), + (64_000_000, 1000, (0, 63_999)), + (100_000_000, 300_000, (0, 332)), + (64_000_000, 32_000_000, (0, 1)), + ], +) +def test_trigger_timing_matches_the_firmware(clock, rate, expected): + assert analog.trigger_timing(clock, rate) == expected + + +def test_trigger_timing_on_16_bit_counters(): + prescaler, period = analog.trigger_timing(64_000_000, 10, counter_bits=16) + assert period < 1 << 16 + assert 64_000_000 / ((prescaler + 1) * (period + 1)) == pytest.approx(10, rel=1e-4) + + +def test_burst_seconds_follow_the_real_rate(): + assert analog.burst_seconds(100, 64_000_000, 1000) == pytest.approx(0.1) + assert analog.burst_seconds(3, 100_000_000, 300_000) == pytest.approx(3 * 333 / 100_000_000) + + +def test_stats_and_list_runs_agree(): + listed = analog.BurstRun.from_values(parse_response("OK samples=1,2,4 us=30")) + assert (listed.n, listed.minimum, listed.maximum, listed.mean, listed.us) == (3, 1, 4, 2, 30) + assert listed.samples == (1, 2, 4) + stats = analog.BurstRun.from_values(parse_response("OK n=3 min=1 max=4 mean=2 us=30")) + assert stats == analog.BurstRun(n=3, minimum=1, maximum=4, mean=2, us=30, samples=None) diff --git a/validation/host/tests/unit/test_fake_firmware.py b/validation/host/tests/unit/test_fake_firmware.py index 1b2e88a7..6dc0f936 100644 --- a/validation/host/tests/unit/test_fake_firmware.py +++ b/validation/host/tests/unit/test_fake_firmware.py @@ -1164,6 +1164,8 @@ def test_spi_holds_its_peripheral(): ("pwm.open 1 channels=1 trgo=oc4ref", "ok"), ("pwm.open 2 channels=1 trgo=oc1", "ok"), ("pwm.open 1 channels=1 trgo=never", "usage"), + ("pwm.open 16 channels=1 trgo=update", "unsupported"), + ("pwm.open 17 channels=1 trgo=reset", "unsupported"), ], ) def test_pwm_open_extensions(line, expected): @@ -1217,6 +1219,15 @@ def test_adc_trgo_on_a_timer_another_group_holds_is_busy(): assert reason(terminal, "adc.open 1 pins=PB0 trgo=1") == "pin", "argument errors come first" +def test_adc_trgo_cannot_use_tim1_on_wba55(): + terminal, _ = wba() + terminal.command("pwm.open 1 channels=1 trgo=update") + assert reason(terminal, "adc.open 4 pins=PA7 trgo=1") == "unsupported", "ADC4 reaches TIM1 through TRGO2 only" + terminal.command("pwm.close 1") + terminal.command("pwm.open 2 channels=1 trgo=update") + assert reason(terminal, "adc.open 4 pins=PA7 trgo=2") == "ok" + + def test_adc_holds_its_peripheral_and_dma_channel(): terminal, firmware = wba() terminal.command("adc.open 4 pins=PA7") From 38dc4d25839d3497b213565424fea59afcb4ff5f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:46:54 +0000 Subject: [PATCH 43/57] feat(validation): validate send-only UART, synchronous GPIO, clocks and the UID uart.open takes sendonly=1 and builds hal::SynchronousUartStmSendOnly: the USART constructors, or on STM32WB the SyncLpUart LPUART constructors (lp=1; ERR unsupported on STM32WBA, which has none). rx becomes optional, flow is none or rts, parity and swap are refused, and sendonly excludes dma, duplex and sync. Add the sgpio group for the synchronous GPIO classes: sgpio.out/latch build a SynchronousOutputPinStm per pin, sgpio.af a SmallPeripheralPinStm on a timer channel's alternate function from the pinout table or on a raw one, and sgpio.multi a MultiGpioPinStm with a MultiPeripheralPinStm over up to four pins. SyncGpioDriver.cpp is the only translation unit that includes SynchronousGpioStm.hpp, which redefines hal::Port and friends of GpioStm.hpp, and it offers plain functions to the group. Pins are held in the pin pool while a driver object owns them. Add the clock group: clock.info reports the bus clocks, the oscillator ready flags and the RNG kernel clock; on STM32WB55 clock.mco selects the MCO source and divider and clock.hsi48 switches HSI48, as scaffolding for the clock and RNG tests (both names answer ERR unsupported on STM32WBA55). Host: groups/io.py (fw.sgpio, fw.clock, a least-squares edge-time frequency fit, the UID layout), fakes/io.py, test_uart_sendonly.py, test_sgpio.py, test_clock.py (bus clocks and flags; on the WB55 SYSCLK, HSI, HSE, LSE and HSI48 measured on the MCO pin), test_uid.py and the board YAML for them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- validation/PROTOCOL.md | 36 +- validation/README.md | 7 + validation/firmware/CMakeLists.txt | 6 + validation/firmware/ClockGroup.cpp | 150 +++++++ validation/firmware/ClockGroup.hpp | 31 ++ validation/firmware/Main.cpp | 5 + validation/firmware/SyncGpioDriver.cpp | 62 +++ validation/firmware/SyncGpioDriver.hpp | 19 + validation/firmware/SyncGpioGroup.cpp | 371 ++++++++++++++++++ validation/firmware/SyncGpioGroup.hpp | 57 +++ validation/firmware/UartFactory.cpp | 60 ++- validation/firmware/UartFactory.hpp | 4 +- validation/firmware/UnsupportedGroups.cpp | 4 +- validation/host/boards/nucleo_wb55rg.yaml | 112 ++++++ validation/host/boards/nucleo_wba55cg.yaml | 79 ++++ validation/host/hal_st_validation/fakes/io.py | 193 +++++++++ .../host/hal_st_validation/groups/io.py | 184 +++++++++ validation/host/tests/hil/test_clock.py | 118 ++++++ validation/host/tests/hil/test_sgpio.py | 208 ++++++++++ .../host/tests/hil/test_uart_sendonly.py | 148 +++++++ validation/host/tests/hil/test_uid.py | 41 ++ validation/host/tests/unit/test_fake_io.py | 281 +++++++++++++ 22 files changed, 2160 insertions(+), 16 deletions(-) create mode 100644 validation/firmware/ClockGroup.cpp create mode 100644 validation/firmware/ClockGroup.hpp create mode 100644 validation/firmware/SyncGpioDriver.cpp create mode 100644 validation/firmware/SyncGpioDriver.hpp create mode 100644 validation/firmware/SyncGpioGroup.cpp create mode 100644 validation/firmware/SyncGpioGroup.hpp create mode 100644 validation/host/hal_st_validation/fakes/io.py create mode 100644 validation/host/hal_st_validation/groups/io.py create mode 100644 validation/host/tests/hil/test_clock.py create mode 100644 validation/host/tests/hil/test_sgpio.py create mode 100644 validation/host/tests/hil/test_uart_sendonly.py create mode 100644 validation/host/tests/hil/test_uid.py create mode 100644 validation/host/tests/unit/test_fake_io.py diff --git a/validation/PROTOCOL.md b/validation/PROTOCOL.md index ae3d5028..de7d72ac 100644 --- a/validation/PROTOCOL.md +++ b/validation/PROTOCOL.md @@ -86,6 +86,16 @@ The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix - `reset` → no final line; the board resets and prints `EVT boot ...` with `reset=sw` - `delay ` → `OK` after the given time (lets the host synchronise with firmware timing) +## Clock (hal-st default clocks, `ConfigureDefaultClockNucleoWB55RG` and `ConfigureDefaultClockNucleoWBA55CG`) + +- `clock.info` → `OK sysclk= hclk= pclk1= pclk2= [pclk7=] hse=0|1 lse=0|1 hsi=0|1 [hsi48=0|1] pll=0|1 rngsel= [clk48=]` + - the frequencies come from `HAL_RCC_Get*Freq`, the flags are the oscillator ready flags (`LL_RCC_*_IsReady`; `pll` is PLL1 on STM32WBA55) + - `pclk7` on STM32WBA55 only; `hsi48` and `clk48` on STM32WB55 only + - `rngsel` is the RNG kernel clock selection: `clk48`, `lsi` or `lse` on STM32WB55, where `clk48` names the CLK48 source (`hsi48`, `pllsai1`, `pll` or `msi`); `lse`, `lsi`, `hsi` or `pll` (PLL1 Q) on STM32WBA55 +- `clock.mco [div=1|2|4|8|16]` → `OK` (STM32WB55): MCO source and divider (`LL_RCC_ConfigMCO`, default `div=1`); `off` stops the output. Validation scaffolding: `sgpio.af PA8 af=0` puts MCO on the pin +- `clock.hsi48 <0|1>` → `OK` (STM32WB55): switches HSI48 off or on and waits for its ready flag (`ERR timeout` after 10 ms). Validation scaffolding for the RNG tests; switch it back on afterwards +- `clock.mco` and `clock.hsi48` return `ERR unsupported` on STM32WBA55: its only MCO pin is the terminal RX (PA8), and it has no HSI48 + ## GPIO (`hal::GpioPinStm`) - `gpio.cfg [pull=none|up|down] [drive=low|medium|fast|high]` → `OK`; `out` starts low, `od` starts released and takes no pull; `pull` defaults to `none`, `drive` (the `hal::Speed` of the output stage) to `low` @@ -97,6 +107,18 @@ The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix - `gpio.count [clear=0|1]` → `OK count=` - `gpio.release ` → `OK`; the pin returns to a digital input with its configured pull +## Synchronous GPIO (`hal::SynchronousOutputPinStm`, `hal::SmallPeripheralPinStm`, `hal::MultiGpioPinStm` with `hal::MultiPeripheralPinStm`) + +- `sgpio.out <0|1> [od=0|1] [speed=low|medium|fast|high]` → `OK`: the first use of a pin builds a `SynchronousOutputPinStm` (push-pull, `speed=low` unless given), later uses set the level; an `od` or `speed` that differs from the pin's rebuilds it, an omitted one keeps its value. The pin has no pull: an open-drain high only releases it +- `sgpio.latch ` → `OK value=<0|1>`: the output latch (`GetOutputLatch`); `ERR notopen` for a pin `sgpio.out` does not hold +- `sgpio.af timer=<1-17> [ch=<1-4>]` → `OK af=`: a `SmallPeripheralPinStm` (push-pull, low speed, no pull) on the alternate function of channel `ch` (default 1) of TIM`timer` in the hal-st pinout table; a timer the MCU lacks returns `ERR range`, a pin without that channel `ERR pin` + - `sgpio.af af=<0-15>` muxes a raw alternate function instead (MCO is AF0 on PA8, alias `mco`); exactly one of `timer` and `af`, and `ch` only with `timer` (`ERR usage`) + - the group only muxes the pin: what drives the function (a `tpwm` channel with no pin of its own, `clock.mco`) is set up by its own group +- `sgpio.multi ,[,...] timer=<1-17> [ch=<1-4>]` → `OK`: a `MultiGpioPinStm` over 1-4 different pins (`ERR usage` otherwise) muxed together to channel `ch` (default 1) of TIM`timer` by a `MultiPeripheralPinStm`; a pin without that channel returns `ERR pin` +- `sgpio.release ` → `OK`: the pin returns to an input without pull; any pin of the `multi` set releases the whole set; `ERR notopen` for a pin the group does not hold +- The group holds up to 4 output pins, 4 alternate-function pins and one `multi` set (one more returns `ERR busy`); a pin serves one of them at a time, and a pin another group holds returns `ERR busy` +- `sgpio.out` checks the level, `od` and `speed` before the pin; `sgpio.af` and `sgpio.multi` check `timer`, `ch` and `af` (`usage`, then `range`) before the pins + ## PWM (`hal::PwmStm`, `sync=1` selects `hal::SynchronousPwmStm`) - `pwm.open [channels=[,...]] [pins=[:][,...]] [freq=] [mode=edge|edgedown|center|centerup|centerboth] [prescaler=] [dead=|off] [inv=0|1] [invn=0|1] [idle=0|1] [idlen=0|1] [brk=] [brkpol=low|high] [brkauto=0|1] [sync=0|1] [preload=0|1] [brkfilter=<0-15>] [trgo=reset|enable|update|oc1|oc1ref|oc2ref|oc3ref|oc4ref]` → `OK pwmclk=` @@ -122,17 +144,19 @@ The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix - A frequency whose period is under 2 counter ticks, or whose auto-reload does not fit the counter (16 bits, 32 bits on TIM2), returns `ERR range`, both in `pwm.open` and `pwm.freq`; with `ticks = pwmclk / hz` (rounded down) the auto-reload is `ticks - 1` for `edge` and `edgedown` and `ticks / 2` for the centre-aligned modes, where the counter runs up and down for a period of `2 x ARR` ticks - `pwm.close ` → `OK` -## UART (`hal::UartStm`, `dma=1` selects `hal::UartStmDma`, `duplex=1` selects `hal::UartStmDuplexDma`, `sync=1` selects `hal::SynchronousUartStm`) +## UART (`hal::UartStm`, `dma=1` selects `hal::UartStmDma`, `duplex=1` selects `hal::UartStmDuplexDma`, `sync=1` selects `hal::SynchronousUartStm`, `sendonly=1` selects `hal::SynchronousUartStmSendOnly`) -- `uart.open [lp=0|1] [tx=] [rx=] [rts=] [cts=] [baud=] [parity=none|even|odd] [flow=none|rts|cts|rtscts] [swap=0|1] [dma=0|1] [duplex=0|1] [sync=0|1]` → `OK` +- `uart.open [lp=0|1] [tx=] [rx=] [rts=] [cts=] [baud=] [parity=none|even|odd] [flow=none|rts|cts|rtscts] [swap=0|1] [dma=0|1] [duplex=0|1] [sync=0|1] [sendonly=0|1]` → `OK` - `lp=1` selects LPUART`` instead of USART``; USART1 is the terminal (`ERR busy`) - default 115200 8N1 (8 data bits, plus the parity bit when `parity` is not `none`; one stop bit) - `baud` is 300-12000000, at most 8000000 on STM32WB55 where the HAL asserts that limit (`ERR range` outside); a rate whose divider does not fit the baud-rate register of the instance at its kernel clock returns `ERR range` (USART: 16 to 65535 with 8× oversampling; LPUART: 0x300 to 0xFFFFF) - - without pins LPUART1 uses `lpuart1tx`/`lpuart1rx`; every other instance needs `tx` and `rx` - - `flow` needs the matching `rts`/`cts` pins; `rts` and `cts` alone are only offered by `sync=1` (`ERR unsupported` otherwise) - - `swap=1` exchanges the TX and RX functions of the two pins (`ERR unsupported` with `sync=1`) - - at most one of `dma`, `duplex` and `sync` (`ERR usage`) + - without pins LPUART1 uses `lpuart1tx`/`lpuart1rx`; every other instance needs `tx` and `rx` (`rx` is optional with `sendonly=1`) + - `flow` needs the matching `rts`/`cts` pins; `rts` and `cts` alone are only offered by `sync=1`, and `rts` alone by `sendonly=1` (`ERR unsupported` otherwise) + - `swap=1` exchanges the TX and RX functions of the two pins (`ERR unsupported` with `sync=1` or `sendonly=1`) + - at most one of `dma`, `duplex`, `sync` and `sendonly` (`ERR usage`) - `sync=1` supports only `parity=none` and not `lp=1` (`ERR unsupported`); `duplex=1` does not support `lp=1` (`ERR unsupported`) + - `sendonly=1` transmits only, polling the data register: `flow` is `none` or `rts` (`cts` and `rtscts` return `ERR unsupported`), `parity=none` only; a given (or default) `rx` pin is held but left unconfigured. With `lp=1` it uses the `SyncLpUart` constructors, which exist on STM32WB55 only: `lp=1 sendonly=1` returns `ERR unsupported` on STM32WBA55 + - with `sendonly=1`, `uart.send` answers once the last byte has left the data register (TXE), while its frame is still on the wire: a `uart.close` right after it may cut that frame; `uart.recv` returns no data - `uart.send ` → `OK` once the driver reports completion (up to 112 bytes; `ERR timeout` if the driver never completes) - `uart.recv [timeout=] [len=]` → `OK data=` with everything received since the last `uart.recv`, at most 256 bytes (waits up to `timeout`, default 1000, at most 10000, for `len` bytes when given, and returns what arrived even if fewer) - `uart.close ` → `OK` diff --git a/validation/README.md b/validation/README.md index d7d243f4..9a2916ca 100644 --- a/validation/README.md +++ b/validation/README.md @@ -452,7 +452,11 @@ SPI1 to SPI3; SPI3 is observed through DIO14, DIO10, DIO11 and DIO15. The PB8 en - `test_wiring.py` - the bench wiring, with GPIO commands only and the AD3 outputs and pulls off: continuity of the jumpers of every enabled option, its external pull-ups, the jumpers of offered options that are not enabled ("pass --with or remove the wiring"), and that the pins of `tests.wiring.undriven` follow both MCU pulls. Run it first after wiring the board; it skips with `--fake`. - `test_system.py` - `ping`, `info`, and the `board.pins` alias table against the board file in both directions, every alias accepted as a pin, reserved terminal/SWD/LSE/BOOT0 pins and the debug LED, unbonded pins, pin syntax, the terminal UART, error reasons (`usage`, `busy`, `notopen`, `range`, `unsupported`), missing instances (including 0), `delay`, `reset` and the `EVT boot` cause. +- `test_uid.py` - the 96-bit unique device ID of `info`: twelve bytes, not blank memory, the lot number in printable ASCII, the same after a reset. +- `test_clock.py` - `clock.info` against the board file: bus clocks, oscillator ready flags, the RNG kernel clock and (NUCLEO-WB55RG) the CLK48 source. On the NUCLEO-WB55RG also the clocks on the MCO pin (PA8, DIO0): SYSCLK/16 and HSI16/16 within 1 %, HSE/16 within 0.1 %, the LSE within 100 ppm (least-squares fit of the edge times), MCO off, and `clock.hsi48` stopping and restarting HSI48. - `test_gpio.py` - output levels with every drive (`hal::Speed`), inputs following the AD3 with every pull, pull-only idle levels, open drain, the user LED, eight pins at a time, pins held by other groups, interrupt counts for edge x handler type x pulse count x frequency against exact AD3 pulse trains, one EXTI line per port at a time, `gpio.pulse` timing. +- `test_sgpio.py` - for `SynchronousOutputPinStm`: levels with every speed, the open-drain low level and the output latch, rebuilding on a changed `od` and the input left by `sgpio.release`. + - For `SmallPeripheralPinStm` and `MultiGpioPinStm`/`MultiPeripheralPinStm`: a timer channel muxed to one pin, or to two or three pins at once, while `tpwm` runs that channel with no pin of its own (frequency and duty on every muxed pin); also the slots, pins shared with `gpio` and argument errors. - `test_pwm.py` - for `PwmStm` and `SynchronousPwmStm`: - frequency x duty x edge/centre alignment x prescaler, with `ERR range` exactly where the period does not fit the counter (16 or 32 bits); - 1-4 channels, complementary outputs and complementary-only outputs with dead time and inversion (shoot-through check), the dead-time limit; @@ -463,6 +467,7 @@ SPI1 to SPI3; SPI3 is observed through DIO14, DIO10, DIO11 and DIO15. The PB8 en - `test_uart.py` - every baud rate x parity x driver variant (interrupt, DMA, duplex DMA, synchronous) on LPUART1 and USART2, both directions against the AD3 UART, frames decoded from the firmware TX line and the bit rate measured on it, large payloads, full-duplex streaming, RTS/CTS flow control per variant, send timeout with CTS held, re-open with other settings, TX/RX swap, the baud limits of each instance, argument errors and default pins. +- `test_uart_sendonly.py` - for `SynchronousUartStmSendOnly` (NUCLEO-WB55RG LPUART1 through the `SyncLpUart` constructors, NUCLEO-WBA55CG USART2): frames against the AD3 UART at several baud rates, no received data, `flow=rts` driving RTS while CTS is ignored, the last frame that a `uart.close` right after `uart.send` may cut, optional `rx`, default pins and argument errors. - `test_spi.py` - SPI modes 0-3 x baud x driver variant (interrupt, DMA, synchronous) x GPIO chip select, decoded from the logic analyzer (MOSI, MISO, clock polarity and rate, chip-select release), continued sessions, receive-only first transfers, the largest transfer, the baud limits and argument errors, on SPI1 and on SPI2 (NUCLEO-WB55RG, `--with spiloop`) or SPI3 (NUCLEO-WBA55CG, `bundle2`). - `test_spi_ext.py` - the SPI master options: the bit order (`lsb=1`) on every driver, frame sizes of 4 to 16 bits (`bits=`, `SpiDataSizeConfiguratorStm` on the DMA driver) decoded word by word, the 8/16-bit limit of the WBA55 SPI3, an 8-bit transfer after a 16-bit one (DMA data width), the `nss`/`cs` checks, and the hardware slave select (a known gap). - `test_spi_slave.py` - `SpiSlaveStmDma` clocked by the AD3 SPI master: full duplex, send-only and receive-only transfers of 1-1024 bytes at several clock rates (CRC beyond 128 bytes), clocks while nothing is armed, a master clocking more or less than armed, `spis.result` waiting and busy, cancel and re-open, argument errors and the instance shared with `spi`; @@ -526,6 +531,7 @@ In `hal_st_validation` (hal-st specific): - `spiwords.py` - SPI words of 4-16 bits: a logic-capture decoder without the bench decoder's 8-bit mask (`spi_decode_words`) and the frame model of `SpiMasterStmDma` with a data size configurator (`spi_frames`, `spi_bytes`); `groups/spis.py` - the `fw.spis` wrapper; `fakes/spis.py` - its fake group and the SPI loop of the fake `spi.xfer` (`FakeFirmware.spi_loop`). - `groups/timers.py` - `fw.tim`, `fw.tpwm`, `fw.lptim`, `fw.lptpwm` and their expected waveforms (`timer_update_rate`, `lptim_update_rate`, `pwm_duty_fraction`); `fakes/timers.py` is their fake firmware model. - `groups/analog.py` - `fw.ain` (`read`, `burst` with one `BurstRun` per measurement) and `fw.dma` (`wave`), with the expectations of the TIM2 pacing (`trigger_timing`, `trigger_rate`, `burst_seconds`) and the wave bit order (`wave_bits`); `fakes/analog.py` models both groups and their deferred replies. +- `groups/io.py` - `fw.sgpio`, `fw.clock` (`ClockInfo`; `clock.mco` and `clock.hsi48` register their undo with `close_all`), `edge_fit_frequency` and `parse_uid`; `fakes/io.py` is their fake firmware model, with a unique device ID of the real layout. - `protocol.py` - the hal-st part of the protocol: error reasons, `P` pins (ports A-K, index 0-15) and the generic alias names (`normalize_pin`, `parse_pin_map`). - `config.py` - board file loading, wiring-set merging, parameter matrices, overrides and known gaps; `expect.py` - expected STM32 values (PWM quantisation and range, SPI prescaler, UART baud-rate register limits, WWDG prescaler and period, ADC codes, encoder counts). - `pairwise.py` - the full product and the deterministic pairwise generator behind `--depth`. @@ -542,3 +548,4 @@ In the firmware (`firmware/`): - `I2cGroup` (`i2c.*` over `I2cStmForHil`, an `I2cStm` whose hooks report `EVT i2c`), `I2cTiming` (TIMINGR from kernel clock and bus frequency), `I2cTarget` (`i2cs.*`, the LL I2C target scaffold), `I2cEepromStm` (a `hal::Eeprom` over `hal::I2cMaster` with page writes and ACK polling) and `EepromGroup` (`eeprom.attach`/`eeprom.detach`, the factory of EMIL's `eeprom` group). - `SpiSlaveGroup` (`spis.*`: `SpiSlaveFactory` builds `SpiSlaveStmDma` on the board's slave DMA channels, `SpiSlaveCommands` arms one transfer at a time and answers `spis.result` from its own timer); `SpiFactory` also takes `bits`, `lsb` and `nss` and holds the SPI instance in `ResourceAllocation`. - `TimerGroup`, `TimerPwmGroup`, `LpTimerGroup` and `LpTimerPwmGroup` serve `tim.*`, `tpwm.*`, `lptim.*` and `lptpwm.*`, each a factory and a single-instance group; `QeiFactory` also builds the LPTIM encoder with `cap=rise|fall` and holds its LPTIM in `ResourceAllocation`. +- `SyncGpioGroup` serves `sgpio.*` through `SyncGpioDriver`, the only translation unit that includes `SynchronousGpioStm.hpp`; `ClockGroup` serves `clock.*`; `UartFactory` also builds `SynchronousUartStmSendOnly` (`sendonly=1`). diff --git a/validation/firmware/CMakeLists.txt b/validation/firmware/CMakeLists.txt index af8b61af..5e17650f 100644 --- a/validation/firmware/CMakeLists.txt +++ b/validation/firmware/CMakeLists.txt @@ -29,6 +29,8 @@ target_sources(hal_st.validation_firmware PRIVATE BoardInfoStm.hpp BoardTypes.hpp ChannelPins.hpp + ClockGroup.cpp + ClockGroup.hpp Console.cpp Console.hpp DmaGroup.cpp @@ -68,6 +70,10 @@ target_sources(hal_st.validation_firmware PRIVATE SpiSlaveGroup.cpp SpiSlaveGroup.hpp Stopwatch.hpp + SyncGpioDriver.cpp + SyncGpioDriver.hpp + SyncGpioGroup.cpp + SyncGpioGroup.hpp TimerAllocation.cpp TimerAllocation.hpp TimerGroup.cpp diff --git a/validation/firmware/ClockGroup.cpp b/validation/firmware/ClockGroup.cpp new file mode 100644 index 00000000..a179e48e --- /dev/null +++ b/validation/firmware/ClockGroup.cpp @@ -0,0 +1,150 @@ +#include "validation/firmware/ClockGroup.hpp" +#include "validation/firmware/Stopwatch.hpp" +#include +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + using services::HilChoice; + using services::HilStatus; + +#if defined(STM32WB) + constexpr std::array, 3> rngSources{ { + { "clk48", RCC_RNGCLKSOURCE_CLK48 }, + { "lsi", RCC_RNGCLKSOURCE_LSI }, + { "lse", RCC_RNGCLKSOURCE_LSE }, + } }; + + constexpr std::array, 4> clk48Sources{ { + { "hsi48", LL_RCC_CLK48_CLKSOURCE_HSI48 }, + { "pllsai1", LL_RCC_CLK48_CLKSOURCE_PLLSAI1 }, + { "pll", LL_RCC_CLK48_CLKSOURCE_PLL }, + { "msi", LL_RCC_CLK48_CLKSOURCE_MSI }, + } }; + + constexpr std::array, 6> mcoSources{ { + { "sysclk", LL_RCC_MCO1SOURCE_SYSCLK }, + { "hse", LL_RCC_MCO1SOURCE_HSE }, + { "hsi", LL_RCC_MCO1SOURCE_HSI }, + { "lse", LL_RCC_MCO1SOURCE_LSE }, + { "hsi48", LL_RCC_MCO1SOURCE_HSI48 }, + { "off", LL_RCC_MCO1SOURCE_NOCLOCK }, + } }; + + constexpr std::array, 5> mcoDividers{ { + { "1", LL_RCC_MCO1_DIV_1 }, + { "2", LL_RCC_MCO1_DIV_2 }, + { "4", LL_RCC_MCO1_DIV_4 }, + { "8", LL_RCC_MCO1_DIV_8 }, + { "16", LL_RCC_MCO1_DIV_16 }, + } }; + + constexpr uint32_t hsi48TimeoutUs = 10000; +#else + constexpr std::array, 4> rngSources{ { + { "lse", RCC_RNGCLKSOURCE_LSE }, + { "lsi", RCC_RNGCLKSOURCE_LSI }, + { "hsi", RCC_RNGCLKSOURCE_HSI }, + { "pll", RCC_RNGCLKSOURCE_PLL1Q }, + } }; +#endif + + template + const char* NameOf(const std::array, N>& choices, uint32_t value) + { + for (const auto& choice : choices) + if (choice.value == value) + return choice.name; + + return "unknown"; + } + } + + ClockCommands::ClockCommands(services::HilContext& context) + : services::TerminalCommands(context.terminal) + , context(context) + , commands{ { + services::HilBind("clock.info", "", *this, context.response), +#if defined(STM32WB) + services::HilBind("clock.mco", " [div=1|2|4|8|16]", *this, context.response), + services::HilBind("clock.hsi48", "<0|1>", *this, context.response), +#endif + } } + {} + + infra::MemoryRange ClockCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus ClockCommands::Info(const services::HilArguments& arguments) + { + if (!arguments.Shape(0, 0, {})) + return HilStatus::usage; + + auto line = context.response.Ok(); + line << " sysclk=" << HAL_RCC_GetSysClockFreq() << " hclk=" << HAL_RCC_GetHCLKFreq() << " pclk1=" << HAL_RCC_GetPCLK1Freq() << " pclk2=" << HAL_RCC_GetPCLK2Freq(); +#if defined(STM32WBA) + line << " pclk7=" << HAL_RCC_GetPCLK7Freq(); +#endif + line << " hse=" << LL_RCC_HSE_IsReady() << " lse=" << LL_RCC_LSE_IsReady() << " hsi=" << LL_RCC_HSI_IsReady(); +#if defined(STM32WB) + line << " hsi48=" << LL_RCC_HSI48_IsReady() << " pll=" << LL_RCC_PLL_IsReady(); +#else + line << " pll=" << LL_RCC_PLL1_IsReady(); +#endif + line << " rngsel=" << NameOf(rngSources, __HAL_RCC_GET_RNG_SOURCE()); +#if defined(STM32WB) + line << " clk48=" << NameOf(clk48Sources, LL_RCC_GetCLK48ClockSource(LL_RCC_CLK48_CLKSOURCE)); +#endif + return HilStatus::done; + } + +#if defined(STM32WB) + HilStatus ClockCommands::Mco(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, { "div" })) + return HilStatus::usage; + + uint32_t source = LL_RCC_MCO1SOURCE_NOCLOCK; + uint32_t divider = LL_RCC_MCO1_DIV_1; + HilStatus status = HilStatus::done; + arguments.SelectAt(0, source, mcoSources, status); + arguments.Select("div", divider, mcoDividers, status); + if (status != HilStatus::done) + return status; + + LL_RCC_ConfigMCO(source, divider); + context.response.Ok(); + return HilStatus::done; + } + + HilStatus ClockCommands::Hsi48(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + uint32_t on = 0; + HilStatus status = HilStatus::done; + arguments.NumberAt(0, on, 0, 1, status); + if (status != HilStatus::done) + return status; + + if (on != 0) + LL_RCC_HSI48_Enable(); + else + LL_RCC_HSI48_Disable(); + + Stopwatch stopwatch; + stopwatch.Start(); + while (LL_RCC_HSI48_IsReady() != on) + if (stopwatch.ElapsedUs() > hsi48TimeoutUs) + return HilStatus::timeout; + + context.response.Ok(); + return HilStatus::done; + } +#endif +} diff --git a/validation/firmware/ClockGroup.hpp b/validation/firmware/ClockGroup.hpp new file mode 100644 index 00000000..1905577d --- /dev/null +++ b/validation/firmware/ClockGroup.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include "services/hil/HilCommand.hpp" +#include + +namespace validation +{ + class ClockCommands + : public services::TerminalCommands + { + public: + explicit ClockCommands(services::HilContext& context); + + infra::MemoryRange Commands() override; + + private: + services::HilStatus Info(const services::HilArguments& arguments); +#if defined(STM32WB) + services::HilStatus Mco(const services::HilArguments& arguments); + services::HilStatus Hsi48(const services::HilArguments& arguments); +#endif + + private: + services::HilContext& context; +#if defined(STM32WB) + std::array commands; +#else + std::array commands; +#endif + }; +} diff --git a/validation/firmware/Main.cpp b/validation/firmware/Main.cpp index 7dbfb089..3848cd85 100644 --- a/validation/firmware/Main.cpp +++ b/validation/firmware/Main.cpp @@ -17,6 +17,7 @@ #include "validation/firmware/AdcFactory.hpp" #include "validation/firmware/AnalogInputGroup.hpp" #include "validation/firmware/BoardInfoStm.hpp" +#include "validation/firmware/ClockGroup.hpp" #include "validation/firmware/Console.hpp" #include "validation/firmware/DmaGroup.hpp" #include "validation/firmware/EepromGroup.hpp" @@ -30,6 +31,7 @@ #include "validation/firmware/ResourceAllocation.hpp" #include "validation/firmware/SpiFactory.hpp" #include "validation/firmware/SpiSlaveGroup.hpp" +#include "validation/firmware/SyncGpioGroup.hpp" #include "validation/firmware/TimerAllocation.hpp" #include "validation/firmware/TimerGroup.hpp" #include "validation/firmware/TimerPwmGroup.hpp" @@ -106,6 +108,9 @@ int main() static validation::AnalogInputCommands analogInput{ context, naming, console.dma, timers, resources }; static validation::DmaCommands dmaWave{ context, naming, console.dma, timers, resources }; + static validation::SyncGpioCommands syncGpio{ context }; + static validation::ClockCommands clocks{ context }; + validation::CreateUnsupportedGroups(context); system.PrintBoot(); diff --git a/validation/firmware/SyncGpioDriver.cpp b/validation/firmware/SyncGpioDriver.cpp new file mode 100644 index 00000000..19cb408f --- /dev/null +++ b/validation/firmware/SyncGpioDriver.cpp @@ -0,0 +1,62 @@ +#include "validation/firmware/SyncGpioDriver.hpp" +#include "hal_st/synchronous_stm32fxxx/SynchronousGpioStm.hpp" +#include "infra/util/ReallyAssert.hpp" +#include +#include + +namespace validation::syncgpio +{ + namespace + { + constexpr uint8_t ports = static_cast(hal::Port::I) + 1; + constexpr uint8_t speeds = static_cast(hal::Speed::High) + 1; + constexpr uint8_t pinsPerPort = 16; + constexpr uint8_t alternateFunctions = 16; + + std::array, outputSlots> outputs; + std::array, peripheralSlots> peripherals; + } + + bool Open(uint8_t slot, uint8_t port, uint8_t index, bool openDrain, uint8_t speed) + { + if (slot >= outputs.size() || outputs[slot] || port >= ports || index >= pinsPerPort || speed >= speeds) + return false; + + outputs[slot].emplace(static_cast(port), index, openDrain ? hal::Drive::OpenDrain : hal::Drive::PushPull, static_cast(speed)); + return true; + } + + void Set(uint8_t slot, bool value) + { + really_assert(slot < outputs.size() && outputs[slot]); + outputs[slot]->Set(value); + } + + bool Latch(uint8_t slot) + { + really_assert(slot < outputs.size() && outputs[slot]); + return outputs[slot]->GetOutputLatch(); + } + + void Close(uint8_t slot) + { + really_assert(slot < outputs.size()); + outputs[slot].reset(); + } + + bool OpenAf(uint8_t slot, uint8_t port, uint8_t index, uint8_t af) + { + if (slot >= peripherals.size() || peripherals[slot] || port >= ports || index >= pinsPerPort || af >= alternateFunctions) + return false; + + const hal::SmallPeripheralPinStm::Definition<1> definition{ { { { 0, static_cast(port), index, af } } }, hal::Drive::Default, hal::Speed::Default, hal::WeakPull::Default }; + peripherals[slot].emplace(definition); + return true; + } + + void CloseAf(uint8_t slot) + { + really_assert(slot < peripherals.size()); + peripherals[slot].reset(); + } +} diff --git a/validation/firmware/SyncGpioDriver.hpp b/validation/firmware/SyncGpioDriver.hpp new file mode 100644 index 00000000..f22535d2 --- /dev/null +++ b/validation/firmware/SyncGpioDriver.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +// SynchronousGpioStm.hpp redefines hal::Port, hal::Drive, hal::Speed and hal::WeakPull of GpioStm.hpp, so only +// SyncGpioDriver.cpp includes it and this interface passes plain numbers: port is the letter offset (A = 0) +namespace validation::syncgpio +{ + inline constexpr uint8_t outputSlots = 4; + inline constexpr uint8_t peripheralSlots = 4; + + bool Open(uint8_t slot, uint8_t port, uint8_t index, bool openDrain, uint8_t speed); + void Set(uint8_t slot, bool value); + bool Latch(uint8_t slot); + void Close(uint8_t slot); + + bool OpenAf(uint8_t slot, uint8_t port, uint8_t index, uint8_t af); + void CloseAf(uint8_t slot); +} diff --git a/validation/firmware/SyncGpioGroup.cpp b/validation/firmware/SyncGpioGroup.cpp new file mode 100644 index 00000000..d4d10d45 --- /dev/null +++ b/validation/firmware/SyncGpioGroup.cpp @@ -0,0 +1,371 @@ +#include "validation/firmware/SyncGpioGroup.hpp" +#include "BoardProfile.hpp" +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include "generated/stm32fxxx/PinoutTableDefault.hpp" +#include "infra/util/ReallyAssert.hpp" +#include "infra/util/Tokenizer.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" + +namespace validation +{ + namespace + { + using services::HilArguments; + using services::HilPinId; + using services::HilStatus; + + constexpr uint32_t maximumChannel = 4; + constexpr uint32_t maximumAlternateFunction = 15; + + constexpr std::array, 4> speeds{ { + { "low", 0 }, + { "medium", 1 }, + { "fast", 2 }, + { "high", 3 }, + } }; + + constexpr std::array channelFunctions{ { + hal::PinConfigTypeStm::timerChannel1, + hal::PinConfigTypeStm::timerChannel2, + hal::PinConfigTypeStm::timerChannel3, + hal::PinConfigTypeStm::timerChannel4, + } }; + + uint32_t MaximumTimer() + { + return static_cast(hal::peripheralTimer.size()); + } + + uint8_t LetterOffset(HilPinId pin) + { + return static_cast(board::portLetters[pin.port] - 'A'); + } + + std::optional TimerAlternateFunction(HilPinId pin, uint32_t timer, uint32_t channel) + { + if (!IsBonded(pin)) + return std::nullopt; + + for (const auto& subTable : hal::pinoutTableDefaultStm) + for (const auto& table : subTable) + if (table.pinConfigType == channelFunctions[channel - 1]) + for (const auto& position : table.pinPositions) + if (position.peripheralIndex == timer && position.port == PortOf(pin) && position.pin == pin.index) + return position.alternateFunction; + + return std::nullopt; + } + + template + std::optional FreeSlot(const std::array, N>& slots) + { + for (uint8_t slot = 0; slot != N; ++slot) + if (!slots[slot]) + return slot; + + return std::nullopt; + } + } + + SyncGpioCommands::SyncGpioCommands(services::HilContext& context) + : services::TerminalCommands(context.terminal) + , context(context) + , pins(context.pins, owners::syncGpio) + , commands{ { + services::HilBind("sgpio.out", " <0|1> [od=0|1] [speed=low|medium|fast|high]", *this, context.response), + services::HilBind("sgpio.latch", "", *this, context.response), + services::HilBind("sgpio.af", " timer= [ch=<1-4>] | af=<0-15>", *this, context.response), + services::HilBind("sgpio.multi", ",[,...] timer= [ch=<1-4>]", *this, context.response), + services::HilBind("sgpio.release", "", *this, context.response), + } } + {} + + infra::MemoryRange SyncGpioCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus SyncGpioCommands::Out(const HilArguments& arguments) + { + if (!arguments.Shape(2, 2, { "od", "speed" })) + return HilStatus::usage; + + uint32_t value = 0; + bool openDrain = false; + uint8_t speed = 0; + HilPinId id{}; + HilStatus status = HilStatus::done; + arguments.NumberAt(1, value, 0, 1, status); + arguments.Flag("od", openDrain, status); + arguments.Select("speed", speed, speeds, status); + arguments.PinAt(0, context.naming, id, status); + if (status != HilStatus::done) + return status; + + auto slot = FindOutput(id); + if (slot) + { + if (!arguments.Has("od")) + openDrain = outputs[*slot]->openDrain; + if (!arguments.Has("speed")) + speed = outputs[*slot]->speed; + } + else + { + slot = FreeSlot(outputs); + hal::GpioPin* claimed = nullptr; + status = pins.Claim(id, services::HilPinPool::Use::exclusive, claimed); + if (status != HilStatus::done) + return status; + + if (!slot) + { + pins.Release(id); + return HilStatus::busy; + } + } + + auto& output = outputs[*slot]; + if (!output || openDrain != output->openDrain || speed != output->speed) + { + if (output) + syncgpio::Close(*slot); + + const bool opened = syncgpio::Open(*slot, LetterOffset(id), id.index, openDrain, speed); + really_assert(opened); + output = Output{ id, openDrain, speed }; + } + + syncgpio::Set(*slot, value != 0); + context.response.Ok(); + return HilStatus::done; + } + + HilStatus SyncGpioCommands::Latch(const HilArguments& arguments) + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + HilPinId id{}; + HilStatus status = HilStatus::done; + arguments.PinAt(0, context.naming, id, status); + if (status != HilStatus::done) + return status; + + const auto slot = FindOutput(id); + if (!slot) + return HilStatus::notOpen; + + context.response.Ok() << " value=" << static_cast(syncgpio::Latch(*slot) ? 1 : 0); + return HilStatus::done; + } + + HilStatus SyncGpioCommands::Af(const HilArguments& arguments) + { + if (!arguments.Shape(1, 1, { "timer", "ch", "af" })) + return HilStatus::usage; + + uint32_t timer = 0; + uint32_t channel = 1; + uint32_t alternateFunction = 0; + HilStatus status = HilStatus::done; + arguments.Number("timer", timer, 1, MaximumTimer(), status); + arguments.Number("ch", channel, 1, maximumChannel, status); + arguments.Number("af", alternateFunction, 0, maximumAlternateFunction, status); + if (status != HilStatus::done) + return status; + + const bool byTimer = arguments.Has("timer"); + if (byTimer == arguments.Has("af") || (arguments.Has("ch") && !byTimer)) + return HilStatus::usage; + + if (byTimer && !TimerExists(static_cast(timer))) + return HilStatus::range; + + HilPinId id{}; + arguments.PinAt(0, context.naming, id, status); + if (status != HilStatus::done) + return status; + + if (byTimer) + { + const auto found = TimerAlternateFunction(id, timer, channel); + if (!found) + return HilStatus::pin; + + alternateFunction = *found; + } + + const auto slot = FreeSlot(peripherals); + hal::GpioPin* claimed = nullptr; + status = pins.Claim(id, services::HilPinPool::Use::exclusive, claimed); + if (status != HilStatus::done) + return status; + + if (!slot) + { + pins.Release(id); + return HilStatus::busy; + } + + const bool opened = syncgpio::OpenAf(*slot, LetterOffset(id), id.index, static_cast(alternateFunction)); + really_assert(opened); + peripherals[*slot] = id; + context.response.Ok() << " af=" << alternateFunction; + return HilStatus::done; + } + + HilStatus SyncGpioCommands::Multi(const HilArguments& arguments) + { + if (!arguments.Shape(1, 1, { "timer", "ch" })) + return HilStatus::usage; + + uint32_t timer = 0; + uint32_t channel = 1; + HilStatus status = HilStatus::done; + arguments.Number("timer", timer, 1, MaximumTimer(), status); + arguments.Number("ch", channel, 1, maximumChannel, status); + if (status != HilStatus::done) + return status; + + if (!arguments.Has("timer")) + return HilStatus::usage; + + if (!TimerExists(static_cast(timer))) + return HilStatus::range; + + std::array ids{}; + std::size_t count = 0; + status = ParseMultiPins(arguments, ids, count); + if (status != HilStatus::done) + return status; + + const auto function = channelFunctions[channel - 1]; + for (std::size_t position = 0; position != count; ++position) + if (!SupportsFunction(ids[position], function, static_cast(timer))) + return HilStatus::pin; + + if (multiPeripheral) + return HilStatus::busy; + + for (std::size_t position = 0; position != count; ++position) + { + hal::GpioPin* claimed = nullptr; + status = pins.Claim(ids[position], services::HilPinPool::Use::exclusive, claimed); + if (status != HilStatus::done) + { + for (std::size_t held = 0; held != position; ++held) + pins.Release(ids[held]); + + return status; + } + + multiTable[position] = { PortOf(ids[position]), ids[position].index }; + } + + multiIds = ids; + multiCount = count; + multiPins.emplace(infra::MemoryRange>(multiTable.data(), multiTable.data() + count)); + multiPeripheral.emplace(*multiPins, function, static_cast(timer)); + context.response.Ok(); + return HilStatus::done; + } + + HilStatus SyncGpioCommands::Release(const HilArguments& arguments) + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + HilPinId id{}; + HilStatus status = HilStatus::done; + arguments.PinAt(0, context.naming, id, status); + if (status != HilStatus::done) + return status; + + if (const auto slot = FindOutput(id)) + { + syncgpio::Close(*slot); + outputs[*slot].reset(); + pins.Release(id); + } + else if (const auto peripheral = FindPeripheral(id)) + { + syncgpio::CloseAf(*peripheral); + peripherals[*peripheral].reset(); + pins.Release(id); + } + else if (InMulti(id)) + ReleaseMulti(); + else + return HilStatus::notOpen; + + context.response.Ok(); + return HilStatus::done; + } + + HilStatus SyncGpioCommands::ParseMultiPins(const HilArguments& arguments, std::array& ids, std::size_t& count) const + { + infra::Tokenizer tokens(arguments.Positional(0), ','); + count = tokens.Size(); + if (count == 0 || count > maximumMultiPins) + return HilStatus::usage; + + for (std::size_t position = 0; position != count; ++position) + { + const auto pin = HilArguments::ParsePin(tokens.Token(position), context.naming); + if (!pin) + return HilStatus::pin; + + for (std::size_t earlier = 0; earlier != position; ++earlier) + if (ids[earlier] == *pin) + return HilStatus::usage; + + ids[position] = *pin; + } + + return HilStatus::done; + } + + std::optional SyncGpioCommands::FindOutput(HilPinId pin) const + { + for (uint8_t slot = 0; slot != outputs.size(); ++slot) + if (outputs[slot] && outputs[slot]->pin == pin) + return slot; + + return std::nullopt; + } + + std::optional SyncGpioCommands::FindPeripheral(HilPinId pin) const + { + for (uint8_t slot = 0; slot != peripherals.size(); ++slot) + if (peripherals[slot] == pin) + return slot; + + return std::nullopt; + } + + bool SyncGpioCommands::InMulti(HilPinId pin) const + { + if (!multiPeripheral) + return false; + + for (std::size_t position = 0; position != multiCount; ++position) + if (multiIds[position] == pin) + return true; + + return false; + } + + void SyncGpioCommands::ReleaseMulti() + { + multiPeripheral.reset(); + multiPins.reset(); + + for (std::size_t position = 0; position != multiCount; ++position) + pins.Release(multiIds[position]); + + multiCount = 0; + } +} diff --git a/validation/firmware/SyncGpioGroup.hpp b/validation/firmware/SyncGpioGroup.hpp new file mode 100644 index 00000000..4c1c4115 --- /dev/null +++ b/validation/firmware/SyncGpioGroup.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include "hal_st/stm32fxxx/GpioStm.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/HilPinPool.hpp" +#include "validation/firmware/SyncGpioDriver.hpp" +#include +#include +#include +#include +#include + +namespace validation +{ + class SyncGpioCommands + : public services::TerminalCommands + { + public: + static constexpr std::size_t maximumMultiPins = 4; + + explicit SyncGpioCommands(services::HilContext& context); + + infra::MemoryRange Commands() override; + + private: + struct Output + { + services::HilPinId pin; + bool openDrain; + uint8_t speed; + }; + + services::HilStatus Out(const services::HilArguments& arguments); + services::HilStatus Latch(const services::HilArguments& arguments); + services::HilStatus Af(const services::HilArguments& arguments); + services::HilStatus Multi(const services::HilArguments& arguments); + services::HilStatus Release(const services::HilArguments& arguments); + + services::HilStatus ParseMultiPins(const services::HilArguments& arguments, std::array& ids, std::size_t& count) const; + std::optional FindOutput(services::HilPinId pin) const; + std::optional FindPeripheral(services::HilPinId pin) const; + bool InMulti(services::HilPinId pin) const; + void ReleaseMulti(); + + private: + services::HilContext& context; + services::HilPinOwner pins; + std::array, syncgpio::outputSlots> outputs; + std::array, syncgpio::peripheralSlots> peripherals; + std::array multiIds{}; + std::array, maximumMultiPins> multiTable{}; + std::size_t multiCount = 0; + std::optional multiPins; + std::optional multiPeripheral; + std::array commands; + }; +} diff --git a/validation/firmware/UartFactory.cpp b/validation/firmware/UartFactory.cpp index 8d0c9e60..abcfcf5a 100644 --- a/validation/firmware/UartFactory.cpp +++ b/validation/firmware/UartFactory.cpp @@ -14,7 +14,7 @@ namespace validation using services::HilStatus; using FlowControl = hal::SynchronousUartStm::HwFlowControl; - constexpr std::array openKeys{ { "lp", "tx", "rx", "rts", "cts", "baud", "parity", "flow", "swap", "dma", "duplex", "sync" } }; + constexpr std::array openKeys{ { "lp", "tx", "rx", "rts", "cts", "baud", "parity", "flow", "swap", "dma", "duplex", "sync", "sendonly" } }; constexpr std::array, 3> parities{ { { "none", USART_PARITY_NONE }, @@ -36,6 +36,13 @@ namespace validation constexpr uint32_t usartMinimumDivider = 0x10; constexpr uint32_t lpuartMinimumDivider = 0x300; + // SynchronousUartStmSendOnly has SyncLpUart constructors on STM32WB only +#if defined(STM32WB) + constexpr bool lpuartSendOnly = true; +#else + constexpr bool lpuartSendOnly = false; +#endif + struct PinFunctions { hal::PinConfigTypeStm tx; @@ -115,9 +122,14 @@ namespace validation return divider >= usartMinimumDivider && divider <= static_cast(USART_BRR_BRR); } - bool PinsSupportFunctions(uint8_t index, const PinFunctions& functions, HilPinId tx, HilPinId rx, const std::optional& rts, const std::optional& cts) + bool Supports(const std::optional& pin, hal::PinConfigTypeStm function, uint8_t index) { - return SupportsFunction(tx, functions.tx, index) && SupportsFunction(rx, functions.rx, index) && (!rts || SupportsFunction(*rts, functions.rts, index)) && (!cts || SupportsFunction(*cts, functions.cts, index)); + return !pin || SupportsFunction(*pin, function, index); + } + + bool PinsSupportFunctions(uint8_t index, const PinFunctions& functions, HilPinId tx, const std::optional& rx, const std::optional& rts, const std::optional& cts) + { + return SupportsFunction(tx, functions.tx, index) && Supports(rx, functions.rx, index) && Supports(rts, functions.rts, index) && Supports(cts, functions.cts, index); } template @@ -136,6 +148,27 @@ namespace validation return driver.template emplace(streams..., index, pins.tx, pins.rx, config); } + + template + Driver& EmplaceSendOnly(Variant& driver, uint8_t index, bool lpuart, bool rts, const DriverPins& pins, uint32_t baud) + { + constexpr auto rtsOnly = Driver::HwFlowControl::hwControlRtsEnable; + +#if defined(STM32WB) + if (lpuart) + { + if (rts) + return driver.template emplace(index, pins.tx, pins.rts, hal::SyncLpUart{}, rtsOnly, baud); + + return driver.template emplace(index, pins.tx, hal::SyncLpUart{}, baud); + } +#endif + + if (rts) + return driver.template emplace(index, pins.tx, pins.rts, rtsOnly, baud); + + return driver.template emplace(index, pins.tx, baud); + } } UartFactoryStm::UartFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma) @@ -196,7 +229,7 @@ namespace validation if (!InstanceExists(index, request.lpuart)) return HilStatus::range; - if (request.dma + request.duplex + request.synchronous > 1) + if (request.dma + request.duplex + request.synchronous + request.sendOnly > 1) return HilStatus::usage; if (UsesRts(request.flow) != request.rts.has_value() || UsesCts(request.flow) != request.cts.has_value()) @@ -205,10 +238,14 @@ namespace validation if (request.lpuart && (request.duplex || request.synchronous)) return HilStatus::unsupported; - if (request.synchronous && (request.parity != USART_PARITY_NONE || request.swap)) + const bool polled = request.synchronous || request.sendOnly; + if (polled && (request.parity != USART_PARITY_NONE || request.swap)) + return HilStatus::unsupported; + + if (request.sendOnly && ((request.flow != FlowControl::hwControlDisable && request.flow != FlowControl::hwControlRtsEnable) || (request.lpuart && !lpuartSendOnly))) return HilStatus::unsupported; - if (!request.synchronous && request.flow != FlowControl::hwControlDisable && request.flow != FlowControl::hwControlRtsCtsEnable) + if (!polled && request.flow != FlowControl::hwControlDisable && request.flow != FlowControl::hwControlRtsCtsEnable) return HilStatus::unsupported; if ((request.dma || request.duplex) && !board::UartDma(index, request.lpuart)) @@ -224,10 +261,10 @@ namespace validation request.rx = defaults->rx; } - if (!request.tx || !request.rx) + if (!request.tx || (!request.rx && !request.sendOnly)) return HilStatus::usage; - if (!PinsSupportFunctions(index, FunctionsOf(request.lpuart), *request.tx, *request.rx, request.rts, request.cts)) + if (!PinsSupportFunctions(index, FunctionsOf(request.lpuart), *request.tx, request.rx, request.rts, request.cts)) return HilStatus::pin; if (IsTerminal(index, request.lpuart)) @@ -251,6 +288,7 @@ namespace validation arguments.Flag("dma", request.dma, status); arguments.Flag("duplex", request.duplex, status); arguments.Flag("sync", request.synchronous, status); + arguments.Flag("sendonly", request.sendOnly, status); return status; } @@ -275,6 +313,12 @@ namespace validation const bool handshake = request.flow != FlowControl::hwControlDisable; handle.baudRate = request.baud; + if (request.sendOnly) + { + handle.synchronous = &EmplaceSendOnly(driver, index, request.lpuart, handshake, pins, request.baud); + return; + } + if (request.synchronous) { if (handshake) diff --git a/validation/firmware/UartFactory.hpp b/validation/firmware/UartFactory.hpp index f7140bda..67837d01 100644 --- a/validation/firmware/UartFactory.hpp +++ b/validation/firmware/UartFactory.hpp @@ -30,6 +30,7 @@ namespace validation using DuplexUart = hal::UartStmDuplexDma::WithRxBuffer<64>; // The polled driver buffers everything until uart.recv drains it: one ring slot stays empty, uart.recv returns up to 256 bytes using SynchronousUart = hal::SynchronousUartStm::WithStorage<257>; + using SendOnlyUart = hal::SynchronousUartStmSendOnly; struct Request { @@ -45,6 +46,7 @@ namespace validation bool dma = false; bool duplex = false; bool synchronous = false; + bool sendOnly = false; }; struct ClaimedPins @@ -65,7 +67,7 @@ namespace validation hal::DmaStm& dma; std::optional transmitStream; std::optional receiveStream; - std::variant driver; + std::variant driver; hal::SerialCommunication* serial = nullptr; }; } diff --git a/validation/firmware/UnsupportedGroups.cpp b/validation/firmware/UnsupportedGroups.cpp index 5b3d56a1..3ce954fb 100644 --- a/validation/firmware/UnsupportedGroups.cpp +++ b/validation/firmware/UnsupportedGroups.cpp @@ -27,7 +27,7 @@ namespace validation "lptpwm.close", } }; #elif defined(STM32WBA) - constexpr std::array commandNames{ { + constexpr std::array commandNames{ { "comp.open", "comp.read", "comp.irq", @@ -39,6 +39,8 @@ namespace validation "eth.open", "eth.status", "eth.close", + "clock.mco", + "clock.hsi48", } }; #endif } diff --git a/validation/host/boards/nucleo_wb55rg.yaml b/validation/host/boards/nucleo_wb55rg.yaml index 33bb53c8..9f2cdefe 100644 --- a/validation/host/boards/nucleo_wb55rg.yaml +++ b/validation/host/boards/nucleo_wb55rg.yaml @@ -1109,6 +1109,118 @@ tests: tolerance: rate: 0.01 bit: 0.25 + uart_sendonly: + # hal::SynchronousUartStmSendOnly; on STM32WB the LPUART goes through + # the SyncLpUart constructors. `rx` only gives the AD3 UART a transmit + # DIO: the send-only driver leaves the pin alone. + instances: + - name: lpuart1 + index: 1 + lp: 1 + tx: lpuart1tx + rx: lpuart1rx + rts: lpuart1rts + cts: lpuart1cts + # Opens on lpuart1tx/lpuart1rx without pins. + default_pins: true + # LPUART1 at 64 MHz takes 15626 Bd and more. + bauds: [19200, 115200, 921600] + payloads: ["55", "00ff", "0123456789abcdef", "48656c6c6f2c20484954"] + # uart.close right after uart.send: SendData returns at TXE, so the + # last frame (0.5 ms at 19200 Bd) may be cut. + cut: + baud: 19200 + payload: 16 + # LPUARTs without send-only constructors: none on STM32WB. + unsupported: [] + sgpio: + # SynchronousOutputPinStm; led0 carries LED2 and 680 ohm to GND. + out_pins: [tim1ch1, led0] + # Output pins without a board load, for the input-without-pull check. + float_pins: [tim1ch1] + speeds: [low, medium, fast, high] + # Five free pins for the four output and four alternate-function slots. + limit_pins: [gpio0, gpio1, gpio2, gpio3, gpio4] + # SmallPeripheralPinStm on TIM2 CH1 (AF1). + af: + pin: tim2ch1 + timer: 2 + ch: 1 + af: 1 + # MultiGpioPinStm + MultiPeripheralPinStm: TIM2 CH1 on PA15 and PA5. + multi: + pins: [tim2ch1, spi1clk] + timer: 2 + ch: 1 + # tpwm runs TIM2 CH1 without a pin of its own (`-`); CH2 drives + # tim2ch2. 64 MHz / 64 / 1000 = 1 kHz. + tpwm: + timer: 2 + pins: ["-", tim2ch2] + prescaler: 63 + period: 999 + duty: 50 + record_periods: 50 + tolerance: + frequency: 0.01 + duty: 0.02 + # No TIM2 CH1 function; a timer the MCU lacks. + foreign_pin: gpio0 + missing_timer: 4 + clock: + frequencies: + sysclk: 64000000 + hclk: 64000000 + pclk1: 64000000 + pclk2: 64000000 + # Oscillator ready flags after ConfigureDefaultClockNucleoWB55RG. + flags: + hse: 1 + lse: 1 + hsi: 1 + hsi48: 1 + pll: 1 + # RNG on CLK48, CLK48 on HSI48 (reset defaults of RCC_CCIPR). + rngsel: clk48 + clk48: hsi48 + # MCO on PA8 (AF0, DIO0 of bundle1), muxed with `sgpio.af PA8 af=0`. + mco: + pin: PA8 + af: 0 + # Rising edges of the least-squares fit. + periods: 1000 + outputs: + # SYSCLK = PLL from HSI16 (+-1 %). + - name: sysclk-div16 + source: sysclk + div: 16 + hz: 4000000 + tolerance: 0.01 + - name: hsi-div16 + source: hsi + div: 16 + hz: 1000000 + tolerance: 0.01 + # 32 MHz crystal. + - name: hse-div16 + source: hse + div: 16 + hz: 2000000 + tolerance: 0.001 + # 32.768 kHz crystal, +-100 ppm including the AD3 time base. + - name: lse + source: lse + div: 1 + hz: 32768 + tolerance: 0.0001 + # HSI48 without CRS trimming. + hsi48: + div: 16 + hz: 3000000 + tolerance: 0.05 + uid: + # Bytes [5, 12) of `info uid=` hold the lot number in ASCII. + lot: [5, 12] unsupported: commands: - comp.open 1 diff --git a/validation/host/boards/nucleo_wba55cg.yaml b/validation/host/boards/nucleo_wba55cg.yaml index 38ca2efb..9a2be692 100644 --- a/validation/host/boards/nucleo_wba55cg.yaml +++ b/validation/host/boards/nucleo_wba55cg.yaml @@ -1249,6 +1249,85 @@ tests: tolerance: rate: 0.01 bit: 0.25 + uart_sendonly: + # hal::SynchronousUartStmSendOnly on USART2. `rx` only gives the AD3 + # UART a transmit DIO: the send-only driver leaves the pin alone. + instances: + - name: usart2 + index: 2 + lp: 0 + tx: usart2tx + rx: usart2rx + rts: usart2rts + cts: usart2cts + bauds: [9600, 115200, 921600] + payloads: ["55", "00ff", "0123456789abcdef", "48656c6c6f2c20484954"] + # uart.close right after uart.send: SendData returns at TXE, so the + # last frame (1 ms at 9600 Bd) may be cut. + cut: + baud: 9600 + payload: 16 + # The SyncLpUart constructors exist on STM32WB only: ERR unsupported. + unsupported: + - name: lpuart1 + index: 1 + lp: 1 + tx: lpuart1tx + sgpio: + # SynchronousOutputPinStm; led0 (PB4) carries the blue LD1. + out_pins: [led0, tim1ch1] + # Output pins without a board load, for the input-without-pull check. + float_pins: [tim1ch1] + speeds: [low, medium, fast, high] + # Five free pins for the four output and four alternate-function slots + # (not PA0/PB9, which solder bridges may tie to ST-LINK lines). + limit_pins: [led0, tim1ch1, gpio0, gpio1, spi1cs] + # SmallPeripheralPinStm on TIM3 CH1 (AF2). + af: + pin: tim3ch1 + timer: 3 + ch: 1 + af: 2 + # MultiGpioPinStm + MultiPeripheralPinStm: TIM3 CH1 on PA10, PB5, PA2. + multi: + pins: [tim3ch1, PB5, PA2] + timer: 3 + ch: 1 + # tpwm runs TIM3 CH1 without a pin of its own (`-`); CH2 drives + # tim3ch2. 100 MHz / 64 / 1000 = 1.5625 kHz. + tpwm: + timer: 3 + pins: ["-", tim3ch2] + prescaler: 63 + period: 999 + duty: 50 + record_periods: 50 + tolerance: + frequency: 0.01 + duty: 0.02 + # No TIM3 CH1 function (TIM3 CH3); a timer the MCU lacks. + foreign_pin: gpio0 + missing_timer: 4 + clock: + frequencies: + sysclk: 100000000 + hclk: 100000000 + pclk1: 100000000 + pclk2: 100000000 + pclk7: 100000000 + # Oscillator ready flags after ConfigureDefaultClockNucleoWBA55CG; the + # LSE lives in the backup domain, which a reset keeps, so it is not + # asserted. + flags: + hse: 1 + hsi: 1 + pll: 1 + # RNG on HSI16 (B.7). + rngsel: hsi + # No MCO: its only pin, PA8, is the terminal RX. + uid: + # Bytes [5, 12) of `info uid=` hold the lot number in ASCII. + lot: [5, 12] unsupported: commands: - comp.open 1 diff --git a/validation/host/hal_st_validation/fakes/io.py b/validation/host/hal_st_validation/fakes/io.py new file mode 100644 index 00000000..5f225537 --- /dev/null +++ b/validation/host/hal_st_validation/fakes/io.py @@ -0,0 +1,193 @@ +"""Fake `sgpio` and `clock` groups: the argument checks of validation/firmware/SyncGpioGroup.cpp and ClockGroup.cpp in +their order, the pins `sgpio` holds in the pin pool (four outputs, four alternate-function pins and one `multi` set), +and the default clock tree of each board. + +The fake firmware's unique device ID follows the real layout here (lot number bytes in ASCII), so `test_uid.py` runs +against it; a UID passed to `FakeFirmware` explicitly is kept.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..groups.io import MCO_DIVIDERS, MCO_SOURCES, SPEEDS +from .base import FakeGroup, _choice, _fail, _flag, _number, _shape, _tokens + +if TYPE_CHECKING: + from ..fake_firmware import FakeFirmware + +_OUTPUT_SLOTS = 4 +_PERIPHERAL_SLOTS = 4 +_MULTI_PINS = 4 +_CHANNELS = 4 +_ALTERNATE_FUNCTION_MAX = 15 +# `hal::peripheralTimer` holds TIM1 .. TIM17. +_TIMER_TABLE_SIZE = 17 +# The timer channel alternate functions of the generated pinout tables (GPIO_AF1_TIM1, GPIO_AF1_TIM2, GPIO_AF2_TIM3, +# GPIO_AF14_TIM16, GPIO_AF14_TIM17), the same on both boards. +_TIMER_AF = {1: 1, 2: 1, 3: 2, 16: 14, 17: 14} +# X 0x0024, Y 0x0051, wafer 7, lot "HALSTFK". +FAKE_UID = "2400510007" + b"HALSTFK".hex() + +_CLOCK_TREES: dict[str, dict[str, Any]] = { + "stm32wb55": {"buses": ("sysclk", "hclk", "pclk1", "pclk2"), "flags": {"hse": 1, "lse": 1, "hsi": 1}, "rngsel": "clk48"}, + "stm32wba55": {"buses": ("sysclk", "hclk", "pclk1", "pclk2", "pclk7"), "flags": {"hse": 1, "lse": 0, "hsi": 1}, "rngsel": "hsi"}, +} + + +class FakeSyncGpio(FakeGroup): + prefix = "sgpio" + + def __init__(self, fw: FakeFirmware) -> None: + super().__init__(fw) + self.boot() + + def boot(self) -> None: + self.outputs: dict[str, dict[str, Any]] = {} + self.peripherals: dict[str, int] = {} + self.multi: tuple[str, ...] = () + + def cmd_out(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 2, ("od", "speed")) + value = _number(args[1], 0, 1) + od = _flag(options, "od") + speed = _choice(options, "speed", SPEEDS, "low") + pin = self.fw.pin(args[0]) + assert pin is not None + output = self.outputs.get(pin) + if output is None: + owner = (self.prefix, f"out:{pin}") + self.fw.check_pins(owner, [pin]) + if len(self.outputs) >= _OUTPUT_SLOTS: + _fail("busy") + self.fw.claim(owner, [pin]) + output = {"od": od, "speed": speed} + else: + output["od"] = od if "od" in options else output["od"] + output["speed"] = speed if "speed" in options else output["speed"] + output["latch"] = value + self.outputs[pin] = output + return "OK" + + def cmd_latch(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + pin = self.fw.pin(args[0]) + assert pin is not None + output = self.outputs.get(pin) + if output is None: + _fail("notopen") + assert output is not None + return f"OK value={output['latch']}" + + def _timer(self, options: dict[str, str]) -> int | None: + return _number(options["timer"], 1, _TIMER_TABLE_SIZE) if "timer" in options else None + + def cmd_af(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("timer", "ch", "af")) + timer = self._timer(options) + channel = _number(options["ch"], 1, _CHANNELS) if "ch" in options else 1 + af = _number(options["af"], 0, _ALTERNATE_FUNCTION_MAX) if "af" in options else 0 + if (timer is not None) == ("af" in options) or ("ch" in options and timer is None): + _fail("usage") + if timer is not None and not self.fw.timer_exists(timer): + _fail("range") + pin = self.fw.pin(args[0]) + assert pin is not None + if timer is not None: + if not self.fw.supports(f"timerChannel{channel}", timer, pin): + _fail("pin") + af = _TIMER_AF[timer] + owner = (self.prefix, f"af:{pin}") + self.fw.check_pins(owner, [pin]) + if pin in self.peripherals or len(self.peripherals) >= _PERIPHERAL_SLOTS: + _fail("busy") + self.fw.claim(owner, [pin]) + self.peripherals[pin] = af + return f"OK af={af}" + + def cmd_multi(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("timer", "ch")) + timer = self._timer(options) + channel = _number(options["ch"], 1, _CHANNELS) if "ch" in options else 1 + if timer is None: + _fail("usage") + assert timer is not None + if not self.fw.timer_exists(timer): + _fail("range") + tokens = _tokens(args[0]) + if not 1 <= len(tokens) <= _MULTI_PINS: + _fail("usage") + pins: list[str] = [] + for token in tokens: + pin = self.fw.pin(token) + assert pin is not None + if pin in pins: + _fail("usage") + pins.append(pin) + if any(not self.fw.supports(f"timerChannel{channel}", timer, pin) for pin in pins): + _fail("pin") + if self.multi: + _fail("busy") + owner = (self.prefix, "multi") + self.fw.check_pins(owner, pins) + self.fw.claim(owner, pins) + self.multi = tuple(pins) + return "OK" + + def cmd_release(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + pin = self.fw.pin(args[0]) + assert pin is not None + if pin in self.outputs: + del self.outputs[pin] + self.fw.release((self.prefix, f"out:{pin}")) + elif pin in self.peripherals: + del self.peripherals[pin] + self.fw.release((self.prefix, f"af:{pin}")) + elif pin in self.multi: + self.multi = () + self.fw.release((self.prefix, "multi")) + else: + _fail("notopen") + return "OK" + + +class FakeClock(FakeGroup): + """`clock.mco` and `clock.hsi48` exist on STM32WB55 only; on STM32WBA55 the unsupported names answer first.""" + + prefix = "clock" + + def __init__(self, fw: FakeFirmware) -> None: + super().__init__(fw) + if fw.uid == type(fw).uid: + fw.uid = FAKE_UID + self.boot() + + def boot(self) -> None: + self.mco = ("off", 1) + self.hsi48 = 1 + + def cmd_info(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 0, 0) + tree = _CLOCK_TREES[self.fw.family] + parts = [f"{bus}={self.fw.kernel_clock}" for bus in tree["buses"]] + flags = dict(tree["flags"]) + if self.fw.family == "stm32wb55": + flags["hsi48"] = self.hsi48 + flags["pll"] = 1 + parts += [f"{flag}={value}" for flag, value in flags.items()] + parts.append(f"rngsel={tree['rngsel']}") + if self.fw.family == "stm32wb55": + parts.append("clk48=hsi48") + return "OK " + " ".join(parts) + + def cmd_mco(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("div",)) + source = _choice({"source": args[0]}, "source", MCO_SOURCES, "off") + divider = _choice(options, "div", tuple(str(value) for value in MCO_DIVIDERS), "1") + self.mco = (source, int(divider)) + return "OK" + + def cmd_hsi48(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + self.hsi48 = _number(args[0], 0, 1) + return "OK" diff --git a/validation/host/hal_st_validation/groups/io.py b/validation/host/hal_st_validation/groups/io.py new file mode 100644 index 00000000..7d8668e0 --- /dev/null +++ b/validation/host/hal_st_validation/groups/io.py @@ -0,0 +1,184 @@ +"""I/O groups of validation/PROTOCOL.md: `sgpio` (`hal::SynchronousOutputPinStm`, `hal::SmallPeripheralPinStm`, +`hal::MultiGpioPinStm` with `hal::MultiPeripheralPinStm`) and `clock` (the hal-st default clock configuration, MCO and +HSI48 scaffolding on STM32WB55), with the expectations of the clock and unique device ID tests (pure functions). + +The send-only UART (`hal::SynchronousUartStmSendOnly`) is `fw.uart.open(..., sendonly=True)`. + +Sources: `SyncGpioGroup.cpp`, `ClockGroup.cpp`; the unique device ID layout of RM0434 / RM0493 ("Unique device ID +register (96 bits)"): bytes 0-3 the X and Y wafer coordinates, byte 4 the wafer number, bytes 5-11 the lot number in +ASCII. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal + +from ad3_waveforms_bench import analysis + +from .base import Group, Pin + +if TYPE_CHECKING: + from ..firmware import Firmware + +__all__ = [ + "GROUPS", + "MCO_DIVIDERS", + "MCO_SOURCES", + "SPEEDS", + "UID_BYTES", + "UID_LOT", + "Clock", + "ClockInfo", + "SyncGpio", + "Uid", + "edge_fit_frequency", + "parse_uid", +] + +Speed = Literal["low", "medium", "fast", "high"] +McoSource = Literal["sysclk", "hse", "hsi", "lse", "hsi48", "off"] + +SPEEDS: tuple[Speed, ...] = ("low", "medium", "fast", "high") +MCO_SOURCES: tuple[McoSource, ...] = ("sysclk", "hse", "hsi", "lse", "hsi48", "off") +MCO_DIVIDERS = (1, 2, 4, 8, 16) +UID_BYTES = 12 +UID_LOT = slice(5, 12) +_CLOCK_FLAGS = ("hse", "lse", "hsi", "hsi48", "pll") + + +class SyncGpio(Group): + """`sgpio`: one owner for every pin it holds; `release` of any pin of a `multi` set releases the whole set.""" + + prefix = "sgpio" + + def __init__(self, firmware: Firmware) -> None: + super().__init__(firmware) + self._multi: tuple[str, ...] = () + + def out(self, pin: Pin, value: int | bool, od: bool | None = None, speed: Speed | None = None) -> None: + """The first use constructs the pin; `od` and `speed` rebuild it when they change, else they stay.""" + pin = self._fw.pin(pin) + self._cmd("out", pin, int(bool(value)), od=od, speed=speed) + self._fw.track(("sgpio", pin), "sgpio.release", pin) + + def latch(self, pin: Pin) -> int: + return self._cmd("latch", self._fw.pin(pin)).as_int("value") + + def af(self, pin: Pin, timer: int | None = None, ch: int | None = None, af: int | None = None) -> int: + """Mux `pin` to channel `ch` of TIM`timer` (the alternate function of the pinout table) or to the raw `af`; + returns the alternate function number.""" + pin = self._fw.pin(pin) + response = self._cmd("af", pin, timer=timer, ch=ch, af=af) + self._fw.track(("sgpio", pin), "sgpio.release", pin) + return response.as_int("af") + + def multi(self, pins: Sequence[Pin], timer: int, ch: int | None = None) -> None: + resolved = tuple(self._fw.pin(pin) for pin in pins) + self._cmd("multi", list(resolved), timer=timer, ch=ch) + self._multi = resolved + self._fw.track(("sgpio", "multi"), "sgpio.release", resolved[0]) + + def release(self, pin: Pin) -> None: + pin = self._fw.pin(pin) + self._cmd("release", pin) + self._fw.untrack(("sgpio", pin)) + if pin in self._multi: + self._multi = () + self._fw.untrack(("sgpio", "multi")) + + +@dataclass(frozen=True) +class ClockInfo: + """`clock.info`: bus clocks in Hz, oscillator ready flags, the RNG kernel clock selection and (STM32WB) the + CLK48 source; `pclk7` and the `hsi48` flag only where the MCU has them.""" + + sysclk: int + hclk: int + pclk1: int + pclk2: int + pclk7: int | None + flags: dict[str, int] + rngsel: str + clk48: str | None + raw: str + + +class Clock(Group): + prefix = "clock" + + def info(self) -> ClockInfo: + response = self._cmd("info") + pclk7 = response.get("pclk7") + return ClockInfo( + sysclk=response.as_int("sysclk"), + hclk=response.as_int("hclk"), + pclk1=response.as_int("pclk1"), + pclk2=response.as_int("pclk2"), + pclk7=None if pclk7 is None else int(pclk7), + flags={flag: response.as_int(flag) for flag in _CLOCK_FLAGS if flag in response}, + rngsel=response["rngsel"], + clk48=response.get("clk48"), + raw=response.raw, + ) + + def mco(self, source: McoSource, div: int | None = None) -> None: + """STM32WB55 only; the pin is muxed separately (`sgpio.af PA8 af=0`). Anything but `off` is undone by + `close_all`.""" + self._cmd("mco", source, div=div) + if source == "off": + self._fw.untrack(("clock", "mco")) + else: + self._fw.track(("clock", "mco"), "clock.mco", "off") + + def hsi48(self, on: bool) -> None: + """STM32WB55 only: switch HSI48 and wait for its ready flag; `close_all` switches it back on.""" + self._cmd("hsi48", int(bool(on))) + if on: + self._fw.untrack(("clock", "hsi48")) + else: + self._fw.track(("clock", "hsi48"), "clock.hsi48", 1) + + +def edge_fit_frequency(bits: Sequence[int], rate: float) -> float: + """Frequency of a periodic signal from a least-squares line through its rising edge times (edge k at t0 + k T): + far more precise than first-to-last edge counting for long captures, and robust to the sample quantisation.""" + times = [edge.index / rate for edge in analysis.edges(bits) if edge.rising] + if len(times) < 3: + raise ValueError("fewer than three rising edges") + rough = (times[-1] - times[0]) / (len(times) - 1) + periods = [round((time - times[0]) / rough) for time in times] + count = len(times) + mean_k = sum(periods) / count + mean_t = sum(times) / count + covariance = sum((k - mean_k) * (t - mean_t) for k, t in zip(periods, times)) + variance = sum((k - mean_k) ** 2 for k in periods) + return variance / covariance + + +@dataclass(frozen=True) +class Uid: + raw: bytes + x: int + y: int + wafer: int + lot: bytes + + +def parse_uid(text: str) -> Uid: + """`info uid=<24 hex digits>`: the 96-bit unique device ID as the firmware prints its bytes, lowest address + first (X in bytes 0-1, Y in bytes 2-3, little endian).""" + raw = bytes.fromhex(text) + if len(raw) != UID_BYTES: + raise ValueError(f"uid has {len(raw)} bytes, not {UID_BYTES}") + return Uid( + raw=raw, + x=int.from_bytes(raw[0:2], "little"), + y=int.from_bytes(raw[2:4], "little"), + wafer=raw[4], + lot=raw[UID_LOT], + ) + + +GROUPS: dict[str, type[Group]] = {"sgpio": SyncGpio, "clock": Clock} diff --git a/validation/host/tests/hil/test_clock.py b/validation/host/tests/hil/test_clock.py new file mode 100644 index 00000000..cd453abf --- /dev/null +++ b/validation/host/tests/hil/test_clock.py @@ -0,0 +1,118 @@ +"""The hal-st default clock configurations (`ConfigureDefaultClockNucleoWB55RG`, `ConfigureDefaultClockNucleoWBA55CG`) +through the `clock` group: bus clocks, oscillator ready flags and the RNG kernel clock selection against +`tests.clock`, and on STM32WB55 the clocks themselves on the MCO pin. + +MCO (STM32WB55 only; the only MCO pin of the STM32WBA55 is the terminal RX): `sgpio.af af=0` muxes the +pin, `clock.mco` selects source and divider, and the logic analyzer measures the frequency with a least-squares fit +of the rising edge times (`groups.io.edge_fit_frequency`), precise enough for the LSE crystal's +-100 ppm. +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.groups.io import MCO_SOURCES, edge_fit_frequency + + +@pytest.fixture +def clock_cfg(board_cfg): + cfg = board_cfg.param("clock", None) + if cfg is None: + pytest.skip("tests.clock not configured") + return cfg + + +@pytest.fixture +def mco_cfg(clock_cfg): + cfg = clock_cfg.get("mco") + if cfg is None: + pytest.skip("no MCO pin on this board") + return cfg + + +def measure(ad3, dio, hz, periods): + capture = ad3.logic.record_for(periods / hz, timeout=periods / hz + 2) + return edge_fit_frequency(capture.channel(dio), capture.rate) + + +def test_bus_clocks(fw, board_cfg, clock_cfg): + info = fw.clock.info() + for bus, hz in clock_cfg["frequencies"].items(): + assert getattr(info, bus) == hz, f"{bus}: {info.raw}" + assert info.pclk7 is None or "pclk7" in clock_cfg["frequencies"], "pclk7 only where the MCU has APB7" + assert info.sysclk == board_cfg.sysclk == fw.system.info().sysclk + assert (info.pclk1, info.pclk2) == (board_cfg.clock("pclk1"), board_cfg.clock("pclk2")), "the clocks the expectations use" + + +def test_oscillators_and_rng_clock(fw, clock_cfg): + info = fw.clock.info() + for flag, ready in clock_cfg["flags"].items(): + assert info.flags.get(flag) == ready, f"{flag}: {info.raw}" + assert info.rngsel == clock_cfg["rngsel"], info.raw + assert info.clk48 == clock_cfg.get("clk48"), info.raw + + +@pytest.mark.ad3 +@pytest.mark.board_params("output", "clock.mco.outputs") +def test_mco_frequency(fw, ad3, need, mco_cfg, output): + dio = need.dio(mco_cfg["pin"]) + fw.sgpio.af(mco_cfg["pin"], af=mco_cfg["af"]) + fw.clock.mco(output["source"], div=output["div"]) + measured = measure(ad3, dio, output["hz"], mco_cfg["periods"]) + assert measured == pytest.approx(output["hz"], rel=output["tolerance"]), f"{output['source']}/{output['div']}" + + +@pytest.mark.ad3 +def test_mco_off(fw, ad3, need, mco_cfg): + dio = need.dio(mco_cfg["pin"]) + fw.sgpio.af(mco_cfg["pin"], af=mco_cfg["af"]) + fw.clock.mco("sysclk", div=16) + fw.clock.mco("off") + capture = ad3.logic.record_for(1e-3, timeout=2.0) + assert analysis.edge_count(capture.channel(dio)) == 0, "MCO still running after clock.mco off" + + +@pytest.mark.ad3 +def test_hsi48_switch(fw, ad3, need, mco_cfg): + """`clock.hsi48 0` stops HSI48 (no MCO edges, ready flag 0); `clock.hsi48 1` brings both back.""" + output = mco_cfg["hsi48"] + dio = need.dio(mco_cfg["pin"]) + fw.sgpio.af(mco_cfg["pin"], af=mco_cfg["af"]) + fw.clock.mco("hsi48", div=output["div"]) + fw.clock.hsi48(False) + assert fw.clock.info().flags["hsi48"] == 0 + capture = ad3.logic.record_for(1e-3, timeout=2.0) + assert analysis.edge_count(capture.channel(dio)) == 0, "HSI48 still on the MCO pin" + fw.clock.hsi48(True) + assert fw.clock.info().flags["hsi48"] == 1 + measured = measure(ad3, dio, output["hz"], mco_cfg["periods"]) + assert measured == pytest.approx(output["hz"], rel=output["tolerance"]) + + +def expect_reason(fw, line, reason): + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line + + +def test_info_errors(fw, clock_cfg): + expect_reason(fw, "clock.info 1", "usage") + expect_reason(fw, "clock.info all=1", "usage") + + +def test_mco_errors(fw, mco_cfg): + for line, reason in ( + ("clock.mco", "usage"), + ("clock.mco pll", "usage"), + ("clock.mco sysclk div=3", "usage"), + ("clock.mco sysclk div=32", "usage"), + ("clock.mco sysclk speed=1", "usage"), + ("clock.hsi48", "usage"), + ("clock.hsi48 2", "range"), + ): + expect_reason(fw, line, reason) + for source in MCO_SOURCES: + fw.clock.mco(source) + fw.clock.hsi48(True) diff --git a/validation/host/tests/hil/test_sgpio.py b/validation/host/tests/hil/test_sgpio.py new file mode 100644 index 00000000..38083d35 --- /dev/null +++ b/validation/host/tests/hil/test_sgpio.py @@ -0,0 +1,208 @@ +"""Synchronous GPIO (`hal::SynchronousOutputPinStm`, `hal::SmallPeripheralPinStm`, `hal::MultiGpioPinStm` with +`hal::MultiPeripheralPinStm`) through the `sgpio` group. + +Wiring set `bundle1`: the pins of `tests.sgpio` on DIOs. The output pins are read with the AD3 static I/O. An open-drain +output has no pull (`SynchronousOutputPinStm` takes none), so only its low level and its latch are asserted. The +alternate-function cases mux a timer channel to the pins while the `tpwm` group runs that channel with no pin of its +own (`-`): the PWM appears on every muxed pin exactly when the mux works. +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.groups.timers import pwm_duty_fraction, timer_update_rate + + +@pytest.fixture +def sgpio_cfg(board_cfg): + cfg = board_cfg.param("sgpio", None) + if cfg is None: + pytest.skip("tests.sgpio not configured") + return cfg + + +def expect_error(reason, call, *args, **options): + with pytest.raises(FirmwareError) as error: + call(*args, **options) + assert error.value.reason == reason, (args, options) + + +def follows_pulls(ad3, dio): + """An input without pull reads whatever pull the AD3 applies.""" + ad3.dio.pull(up=[dio]) + up = ad3.dio.read(dio) + ad3.dio.pull(down=[dio]) + down = ad3.dio.read(dio) + ad3.dio.pull() + return up == 1 and down == 0 + + +def run_channel(fw, cfg): + """Start the timer of `tests.sgpio.tpwm` with its first channel unused (`-`); returns the PWM frequency and the + expected high fraction.""" + if not hasattr(fw, "tpwm"): + pytest.skip("needs the tpwm group") + tpwm = cfg["tpwm"] + pins = [None if pin == "-" else pin for pin in tpwm["pins"]] + timclk = fw.tpwm.open(tpwm["timer"], pins, prescaler=tpwm["prescaler"], period=tpwm["period"]) + fw.tpwm.duty(tpwm["timer"], 1, tpwm["duty"]) + fw.tpwm.start(tpwm["timer"]) + return timer_update_rate(timclk, tpwm["prescaler"], tpwm["period"]), pwm_duty_fraction(tpwm["period"], tpwm["duty"]) + + +def check_pwm(ad3, cfg, dios, frequency, fraction): + periods = cfg["record_periods"] + capture = ad3.logic.record_for(periods / frequency, timeout=periods / frequency + 2) + tolerance = cfg["tolerance"] + for dio in dios: + assert capture.frequency(dio) == pytest.approx(frequency, rel=tolerance["frequency"]), f"DIO{dio} frequency" + quantisation = 2 * frequency / capture.rate + assert capture.duty(dio) == pytest.approx(fraction, abs=tolerance["duty"] + quantisation), f"DIO{dio} duty" + + +@pytest.mark.ad3 +@pytest.mark.board_params("pin", "sgpio.out_pins") +@pytest.mark.board_params("speed", "sgpio.speeds") +def test_push_pull_levels(fw, ad3, need, pin, speed): + dio = need.dio(pin) + ad3.dio.release(dio) + for level in (1, 0, 1, 0): + fw.sgpio.out(pin, level, speed=speed) + assert ad3.dio.read(dio) == level, f"{pin} set to {level}" + assert fw.sgpio.latch(pin) == level + + +@pytest.mark.ad3 +@pytest.mark.board_params("pin", "sgpio.out_pins") +def test_open_drain_low_and_latch(fw, ad3, need, pin): + dio = need.dio(pin) + ad3.dio.release(dio) + fw.sgpio.out(pin, 0, od=True) + assert ad3.dio.read(dio) == 0, "open drain must pull low" + assert fw.sgpio.latch(pin) == 0 + fw.sgpio.out(pin, 1) + assert fw.sgpio.latch(pin) == 1, "a later out keeps open drain and sets the latch" + fw.sgpio.out(pin, 0) + assert ad3.dio.read(dio) == 0 + + +@pytest.mark.ad3 +@pytest.mark.board_params("pin", "sgpio.float_pins") +def test_switch_drive_and_release(fw, ad3, need, pin): + """A changed `od` rebuilds the pin; `sgpio.release` leaves an input without pull (`float_pins` carry no board + load, so the AD3 pulls decide its level).""" + dio = need.dio(pin) + ad3.dio.release(dio) + fw.sgpio.out(pin, 1, od=True) + fw.sgpio.out(pin, 1, od=False) + assert ad3.dio.read(dio) == 1, "push-pull high after open drain" + fw.sgpio.release(pin) + assert follows_pulls(ad3, dio), f"{pin} must be an input without pull after sgpio.release" + + +@pytest.mark.ad3 +def test_af_by_timer(fw, ad3, need, sgpio_cfg): + af = sgpio_cfg["af"] + dio = need.dio(af["pin"]) + frequency, fraction = run_channel(fw, sgpio_cfg) + assert fw.sgpio.af(af["pin"], timer=af["timer"], ch=af["ch"]) == af["af"] + check_pwm(ad3, sgpio_cfg, [dio], frequency, fraction) + fw.sgpio.release(af["pin"]) + assert follows_pulls(ad3, dio), "SmallPeripheralPinStm must leave an input without pull" + + +@pytest.mark.ad3 +def test_multi(fw, ad3, need, sgpio_cfg): + multi = sgpio_cfg["multi"] + dios = [need.dio(pin) for pin in multi["pins"]] + frequency, fraction = run_channel(fw, sgpio_cfg) + fw.sgpio.multi(multi["pins"], timer=multi["timer"], ch=multi["ch"]) + check_pwm(ad3, sgpio_cfg, dios, frequency, fraction) + fw.sgpio.release(multi["pins"][-1]) + ad3.dio.pull(down=dios) + capture = ad3.logic.record_for(sgpio_cfg["record_periods"] / frequency, timeout=sgpio_cfg["record_periods"] / frequency + 2) + for dio in dios: + assert analysis.edge_count(capture.channel(dio)) == 0, f"DIO{dio} still muxed after releasing the set" + + +def test_af_number(fw, sgpio_cfg): + """`af=` muxes a raw alternate function and reports it.""" + pin = sgpio_cfg["af"]["pin"] + assert fw.sgpio.af(pin, af=sgpio_cfg["af"]["af"]) == sgpio_cfg["af"]["af"] + fw.sgpio.release(pin) + + +def test_slots(fw, sgpio_cfg): + """Four outputs, four alternate-function pins and one set at a time; a pin serves one use.""" + pins = sgpio_cfg["limit_pins"] + for pin in pins[:4]: + fw.sgpio.out(pin, 0) + expect_error("busy", fw.sgpio.out, pins[4], 0) + expect_error("busy", fw.sgpio.af, pins[0], af=0) + for pin in pins[:4]: + fw.sgpio.release(pin) + for pin in pins[:4]: + fw.sgpio.af(pin, af=0) + expect_error("busy", fw.sgpio.af, pins[4], af=0) + expect_error("busy", fw.sgpio.out, pins[0], 0) + for pin in pins[:4]: + fw.sgpio.release(pin) + multi = sgpio_cfg["multi"] + fw.sgpio.multi(multi["pins"], timer=multi["timer"], ch=multi["ch"]) + expect_error("busy", fw.sgpio.multi, multi["pins"][:1], timer=multi["timer"], ch=multi["ch"]) + expect_error("busy", fw.sgpio.out, multi["pins"][0], 0) + fw.sgpio.release(multi["pins"][0]) + for pin in multi["pins"]: + fw.sgpio.out(pin, 0) + + +def test_shared_with_gpio(fw, sgpio_cfg): + pin = sgpio_cfg["out_pins"][0] + fw.gpio.cfg(pin, "in") + expect_error("busy", fw.sgpio.out, pin, 1) + fw.gpio.release(pin) + fw.sgpio.out(pin, 0) + expect_error("busy", fw.gpio.cfg, pin, "in") + + +def test_errors(fw, board_cfg, sgpio_cfg): + out_pin = sgpio_cfg["out_pins"][0] + af = sgpio_cfg["af"] + multi = sgpio_cfg["multi"] + reserved = board_cfg.terminal.pins[0] + unbonded = board_cfg.param("system.unbonded_pins")[0] + for line, reason in ( + (f"sgpio.out {fw.pin(out_pin)} 2", "range"), + (f"sgpio.out {fw.pin(out_pin)} 1 od=2", "range"), + (f"sgpio.out {fw.pin(out_pin)} 1 speed=fastest", "usage"), + (f"sgpio.out {fw.pin(out_pin)}", "usage"), + (f"sgpio.out {fw.pin(out_pin)} 1 pull=up", "usage"), + (f"sgpio.out {reserved} 1", "busy"), + (f"sgpio.out {unbonded} 1", "pin"), + ("sgpio.out nosuchpin 1", "pin"), + (f"sgpio.latch {fw.pin(out_pin)}", "notopen"), + (f"sgpio.release {fw.pin(out_pin)}", "notopen"), + (f"sgpio.af {fw.pin(af['pin'])}", "usage"), + (f"sgpio.af {fw.pin(af['pin'])} timer={af['timer']} af=1", "usage"), + (f"sgpio.af {fw.pin(af['pin'])} af=1 ch=1", "usage"), + (f"sgpio.af {fw.pin(af['pin'])} timer=0", "range"), + (f"sgpio.af {fw.pin(af['pin'])} timer=18", "range"), + (f"sgpio.af {fw.pin(af['pin'])} timer={sgpio_cfg['missing_timer']}", "range"), + (f"sgpio.af {fw.pin(af['pin'])} timer={af['timer']} ch=5", "range"), + (f"sgpio.af {fw.pin(af['pin'])} af=16", "range"), + (f"sgpio.af {fw.pin(sgpio_cfg['foreign_pin'])} timer={af['timer']} ch={af['ch']}", "pin"), + (f"sgpio.af {unbonded} af=1", "pin"), + (f"sgpio.af {reserved} af=1", "busy"), + (f"sgpio.multi {fw.pin(multi['pins'][0])}", "usage"), + (f"sgpio.multi , timer={multi['timer']}", "usage"), + (f"sgpio.multi {fw.pin(multi['pins'][0])},{fw.pin(multi['pins'][0])} timer={multi['timer']}", "usage"), + (f"sgpio.multi {','.join([fw.pin(multi['pins'][0])] * 5)} timer={multi['timer']}", "usage"), + (f"sgpio.multi {fw.pin(multi['pins'][0])} timer={sgpio_cfg['missing_timer']}", "range"), + (f"sgpio.multi {fw.pin(sgpio_cfg['foreign_pin'])} timer={multi['timer']}", "pin"), + ): + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line diff --git a/validation/host/tests/hil/test_uart_sendonly.py b/validation/host/tests/hil/test_uart_sendonly.py new file mode 100644 index 00000000..0befb1a1 --- /dev/null +++ b/validation/host/tests/hil/test_uart_sendonly.py @@ -0,0 +1,148 @@ +"""Send-only UART (`hal::SynchronousUartStmSendOnly`, `uart.open ... sendonly=1`) against the AD3 protocol UART. + +Wiring set `bundle1`: TX, RTS and CTS of every `tests.uart_sendonly.instances` entry on DIOs; the AD3 UART also +needs a transmit DIO, the instance's `rx` pin, which the firmware leaves alone. STM32WB55 builds LPUART1 through the +`SyncLpUart` constructors; STM32WBA55 has none, so its LPUART answers `ERR unsupported` and USART2 is the instance. + +`SendData` returns once the last byte has left the data register (TXE), not the wire (TC): `uart.send` answers while +the last frame is still shifting out, and a `uart.close` that follows at once may cut that frame +(`test_close_right_after_send`). +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import expect + + +@pytest.fixture +def sendonly_cfg(board_cfg): + cfg = board_cfg.param("uart_sendonly", None) + if cfg is None: + pytest.skip("tests.uart_sendonly not configured") + return cfg + + +def open_sendonly(fw, instance, **options): + fw.uart.open(instance["index"], lp=instance["lp"], tx=instance["tx"], sendonly=True, **options) + + +def listen(ad3, need, instance, baud): + """Point the AD3 UART at the firmware TX pin; returns its DIO.""" + rx = need.dio(instance["tx"]) + ad3.uart.configure(tx=need.dio(instance["rx"]), rx=rx, baud=baud) + ad3.uart.flush() + return rx + + +def send_and_receive(fw, ad3, index, payload, baud): + transfer = expect.uart_transfer_time(len(payload), baud) + fw.uart.send(index, payload, cmd_timeout=2.0 + transfer) + return ad3.uart.read(len(payload), timeout=transfer + 1.0) + + +def expect_error(reason, call, *args, **options): + with pytest.raises(FirmwareError) as error: + call(*args, **options) + assert error.value.reason == reason, (args, options) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "uart_sendonly.instances") +@pytest.mark.board_params("baud", "uart_sendonly.bauds") +def test_transmit(fw, ad3, need, sendonly_cfg, instance, baud): + listen(ad3, need, instance, baud) + open_sendonly(fw, instance, baud=baud) + for text in sendonly_cfg["payloads"]: + payload = bytes.fromhex(text) + assert send_and_receive(fw, ad3, instance["index"], payload, baud) == payload, f"{baud} Bd" + assert ad3.uart.parity_errors == 0 + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "uart_sendonly.instances") +def test_receive_returns_nothing(fw, ad3, need, instance): + """`ReceiveData` of the send-only driver fails at once: bytes on the `rx` line never arrive.""" + listen(ad3, need, instance, 115200) + open_sendonly(fw, instance, rx=instance["rx"]) + ad3.uart.write(b"\x55\xaa") + assert fw.uart.recv(instance["index"], timeout=100, len=2) == b"" + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "uart_sendonly.instances") +def test_rts_only_mapping(fw, ad3, need, sendonly_cfg, instance): + """`flow=rts` maps RTS and nothing else: the RTS pin is driven by the UART (it follows neither AD3 pull) and a + deasserted CTS does not hold the transmitter.""" + baud = 115200 + rts, cts = need.dio(instance["rts"]), need.dio(instance["cts"]) + listen(ad3, need, instance, baud) + ad3.dio.drive(cts, 1) + open_sendonly(fw, instance, baud=baud, flow="rts", rts=instance["rts"]) + ad3.dio.pull(up=[rts]) + pulled_up = ad3.dio.read(rts) + ad3.dio.pull(down=[rts]) + pulled_down = ad3.dio.read(rts) + assert pulled_up == pulled_down, "RTS must be driven by the UART" + payload = bytes.fromhex(sendonly_cfg["payloads"][-1]) + assert send_and_receive(fw, ad3, instance["index"], payload, baud) == payload, "CTS deasserted must not hold the data" + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "uart_sendonly.instances") +def test_close_right_after_send(fw, ad3, need, sendonly_cfg, instance): + """`uart.close` right after `uart.send`: every frame but the last arrives intact; the last one may be cut (the + driver waits for TXE, not TC).""" + cut = sendonly_cfg["cut"] + baud = cut["baud"] + rx = listen(ad3, need, instance, baud) + ad3.dio.pull(up=[rx]) + open_sendonly(fw, instance, baud=baud) + payload = bytes((0x30 + i) & 0x7F for i in range(cut["payload"])) + fw.uart.send(instance["index"], payload, cmd_timeout=2.0 + expect.uart_transfer_time(len(payload), baud)) + fw.uart.close(instance["index"]) + received = ad3.uart.read(len(payload), timeout=expect.uart_transfer_time(len(payload), baud) + 1.0) + assert received[: len(payload) - 1] == payload[:-1] + assert len(received) <= len(payload) + + +@pytest.mark.board_params("instance", "uart_sendonly.instances") +def test_rx_is_optional(fw, instance): + open_sendonly(fw, instance) + assert fw.uart.recv(instance["index"]) == b"" + fw.uart.close(instance["index"]) + open_sendonly(fw, instance, rx=instance["rx"]) + + +@pytest.mark.board_params("instance", "uart_sendonly.instances") +def test_open_errors(fw, instance): + """`sendonly` excludes the other driver selections and the options `SynchronousUartStmSendOnly` lacks.""" + for options, reason in ( + ({"dma": True}, "usage"), + ({"duplex": True}, "usage"), + ({"sync": True}, "usage"), + ({"flow": "rts"}, "usage"), + ({"parity": "even"}, "unsupported"), + ({"swap": True}, "unsupported"), + ({"flow": "cts", "cts": instance["cts"]}, "unsupported"), + ({"flow": "rtscts", "rts": instance["rts"], "cts": instance["cts"]}, "unsupported"), + ({"rts": instance["tx"], "flow": "rts"}, "pin"), + ): + expect_error(reason, open_sendonly, fw, instance, **options) + expect_error("usage", fw.uart.open, instance["index"], lp=instance["lp"], sendonly=True, rx=instance["rx"]) + + +@pytest.mark.board_params("instance", "uart_sendonly.unsupported") +def test_unsupported_instance(fw, instance): + """LPUARTs without send-only constructors (STM32WBA).""" + expect_error("unsupported", open_sendonly, fw, instance) + + +@pytest.mark.board_params("instance", "uart_sendonly.instances") +def test_default_pins(fw, instance): + """Without pins the defaults of the instance apply (LPUART1), as for the other drivers.""" + if not instance.get("default_pins"): + pytest.skip("the instance has no default pins") + fw.uart.open(instance["index"], lp=instance["lp"], sendonly=True) diff --git a/validation/host/tests/hil/test_uid.py b/validation/host/tests/hil/test_uid.py new file mode 100644 index 00000000..d0a2265c --- /dev/null +++ b/validation/host/tests/hil/test_uid.py @@ -0,0 +1,41 @@ +"""Unique device ID (`hal::UniqueDeviceId`, `info uid=`): 96 bits read from UID_BASE, the same after a reset, not erased +flash (all 0x00 or all 0xFF), with the lot number bytes in printable ASCII (RM0434 / RM0493 "Unique device ID register +(96 bits)": bytes 0-3 wafer X/Y, byte 4 wafer number, bytes 5-11 lot number).""" + +from __future__ import annotations + +import pytest + +from hal_st_validation.groups.io import UID_BYTES, parse_uid + + +@pytest.fixture +def uid_cfg(board_cfg): + return board_cfg.param("uid", {}) + + +def read_uid(fw): + info = fw.system.info() + assert info.uid is not None, info.raw + return parse_uid(info.uid) + + +def test_uid_layout(fw, uid_cfg): + uid = read_uid(fw) + assert len(uid.raw) == UID_BYTES + assert uid.raw not in (bytes(UID_BYTES), b"\xff" * UID_BYTES), "the UID reads like erased or blank memory" + first, end = uid_cfg.get("lot", [5, 12]) + lot = uid.raw[first:end] + assert all(0x20 <= byte <= 0x7E for byte in lot), f"lot number {lot.hex()} is not printable ASCII" + + +def test_uid_is_stable(fw): + assert read_uid(fw) == read_uid(fw) + + +@pytest.mark.resets_board +def test_uid_survives_reset(fw, board_cfg): + before = read_uid(fw) + fw.system.reset(timeout=board_cfg.param("system.boot_timeout", 5.0)) + fw.system.ping() + assert read_uid(fw) == before diff --git a/validation/host/tests/unit/test_fake_io.py b/validation/host/tests/unit/test_fake_io.py new file mode 100644 index 00000000..71bb5b2f --- /dev/null +++ b/validation/host/tests/unit/test_fake_io.py @@ -0,0 +1,281 @@ +"""The fake `sgpio` and `clock` groups (argument order and reasons of validation/firmware/SyncGpioGroup.cpp and +ClockGroup.cpp; PROTOCOL.md D.14, D.15), the send-only UART checks of `uart.open sendonly=1` (D.13), the +`groups.io` wrappers and the clock and UID expectations.""" + +import math + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal + +from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS, FakeFirmware, FakeSerial +from hal_st_validation.fakes.io import FAKE_UID +from hal_st_validation.firmware import Firmware +from hal_st_validation.groups.io import UID_BYTES, edge_fit_frequency, parse_uid + + +def make(family="stm32wb55", **fake_options): + fake = FakeFirmware(family=family, **fake_options) + terminal = FirmwareTerminal(serial=FakeSerial(fake), timeout=0.5) + pins = WB55_PINS if family == "stm32wb55" else WBA55_PINS + return terminal, fake, Firmware(terminal, pins) + + +def reason(terminal, line): + response = terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("sgpio.out", "usage"), + ("sgpio.out PA8", "usage"), + ("sgpio.out PA8 1 2", "usage"), + ("sgpio.out PA8 1 pull=up", "usage"), + ("sgpio.out PA8 x", "usage"), + ("sgpio.out PA8 2", "range"), + ("sgpio.out PA8 1 od=2", "range"), + ("sgpio.out PA8 1 speed=fastest", "usage"), + ("sgpio.out PZ8 2", "range"), + ("sgpio.out PZ8 1", "pin"), + ("sgpio.out PC7 1", "pin"), + ("sgpio.out terminaltx 1", "busy"), + ("sgpio.out PB5 1", "busy"), + ("sgpio.out PA8 1 od=1 speed=high", "ok"), + ("sgpio.latch PA8", "notopen"), + ("sgpio.latch", "usage"), + ("sgpio.release PA8", "notopen"), + ("sgpio.af PA15", "usage"), + ("sgpio.af PA15 timer=2 af=1", "usage"), + ("sgpio.af PA15 af=1 ch=1", "usage"), + ("sgpio.af PA15 timer=0", "range"), + ("sgpio.af PA15 timer=18", "range"), + ("sgpio.af PA15 timer=3", "range"), + ("sgpio.af PA15 timer=2 ch=5", "range"), + ("sgpio.af PA15 af=16", "range"), + ("sgpio.af PZ1 timer=2", "pin"), + ("sgpio.af PC6 timer=2", "pin"), + ("sgpio.af PA15 timer=2 ch=2", "pin"), + ("sgpio.af PC7 af=1", "pin"), + ("sgpio.af terminalrx af=7", "busy"), + ("sgpio.af PA15 timer=2", "ok"), + ("sgpio.af PA1 timer=2 ch=2", "ok"), + ("sgpio.af PA6 timer=16 ch=1", "ok"), + ("sgpio.af PA8 af=0", "ok"), + ("sgpio.multi PA15", "usage"), + ("sgpio.multi , timer=2", "usage"), + ("sgpio.multi PA15,PA15 timer=2", "usage"), + ("sgpio.multi PA15,PA5,PA0,PA15,PA5 timer=2", "usage"), + ("sgpio.multi PA15 timer=4", "range"), + ("sgpio.multi PA15 timer=2 ch=0", "range"), + ("sgpio.multi PA15,PZ1 timer=2", "pin"), + ("sgpio.multi PA15,PC6 timer=2", "pin"), + ("sgpio.multi PA15,PA5,PA0 timer=2", "ok"), + ], +) +def test_sgpio_reasons_wb55(line, expected): + terminal, _, _ = make() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("sgpio.af PA10 timer=3", "ok"), + ("sgpio.af PA10 timer=4", "range"), + ("sgpio.af PB14 timer=3", "pin"), + ("sgpio.af PB14 timer=3 ch=3", "ok"), + ("sgpio.out PA9 1", "busy"), + ("sgpio.out PB8 1", "ok"), + ("sgpio.out PA3 1", "pin"), + ("sgpio.multi PA10,PB5,PA2 timer=3", "ok"), + ], +) +def test_sgpio_reasons_wba55(line, expected): + terminal, _, _ = make("stm32wba55") + assert reason(terminal, line) == expected + + +def test_sgpio_af_reports_the_table_function(): + _, _, fw = make() + assert fw.sgpio.af("tim2ch1", timer=2) == 1 + assert fw.sgpio.af("tim16ch1", timer=16) == 14 + assert fw.sgpio.af("PA8", af=0) == 0 + _, _, fw = make("stm32wba55") + assert fw.sgpio.af("tim3ch1", timer=3) == 2 + + +def test_sgpio_outputs_latch_and_rebuild(): + terminal, _, fw = make() + fw.sgpio.out("PA8", 1) + assert fw.sgpio.latch("PA8") == 1 + fw.sgpio.out("PA8", 0, od=True) + fw.sgpio.out("PA8", 1) + assert fw.sgpio.latch("PA8") == 1 + assert terminal.command("sgpio.out PA8 0 speed=high").ok + assert fw.sgpio.latch("PA8") == 0 + fw.sgpio.release("PA8") + assert reason(terminal, "sgpio.latch PA8") == "notopen" + + +def test_sgpio_slots_and_pin_sharing(): + terminal, _, fw = make() + for pin in ("PC6", "PC10", "PC12", "PC13"): + fw.sgpio.out(pin, 0) + assert reason(terminal, "sgpio.out PE4 0") == "busy" + assert reason(terminal, "sgpio.af PC6 af=0") == "busy", "a pin serves one use" + for pin in ("PA15", "PA5", "PA0", "PA6"): + fw.sgpio.af(pin, af=1) + assert reason(terminal, "sgpio.af PA1 af=1") == "busy" + assert reason(terminal, "sgpio.af PA15 af=1") == "busy", "already muxed" + assert reason(terminal, "gpio.cfg PA15 in") == "busy" + assert reason(terminal, "sgpio.multi PA15 timer=2") == "busy" + fw.sgpio.release("PA15") + fw.sgpio.release("PA5") + fw.sgpio.multi(["PA15", "PA5"], timer=2) + assert reason(terminal, "sgpio.multi PA0 timer=2") == "busy" + fw.sgpio.release("PA5") + assert ("sgpio", "multi") not in fw.open_instances + fw.sgpio.multi(["PA15", "PA5"], timer=2) + + +def test_sgpio_close_all_releases_everything(): + terminal, fake, fw = make() + fw.sgpio.out("PA8", 1) + fw.sgpio.af("PA15", timer=2) + fw.sgpio.multi(["PA5", "PA0"], timer=2) + assert fw.close_all() == [] + assert fake.claims == {} + assert terminal.command("gpio.cfg PA5 in").ok + + +def test_sgpio_boot_forgets_everything(): + terminal, fake, fw = make() + fw.sgpio.out("PA8", 1) + fake.boot() + assert reason(terminal, "sgpio.latch PA8") == "notopen" + fw.sgpio.out("PA8", 0) + + +def test_clock_info_wb55(): + _, _, fw = make() + info = fw.clock.info() + assert (info.sysclk, info.hclk, info.pclk1, info.pclk2, info.pclk7) == (64_000_000,) * 4 + (None,) + assert info.flags == {"hse": 1, "lse": 1, "hsi": 1, "hsi48": 1, "pll": 1} + assert (info.rngsel, info.clk48) == ("clk48", "hsi48") + + +def test_clock_info_wba55(): + _, _, fw = make("stm32wba55") + info = fw.clock.info() + assert info.pclk7 == 100_000_000 + assert "hsi48" not in info.flags + assert (info.rngsel, info.clk48) == ("hsi", None) + + +def test_clock_scaffolding_tracks_its_undo(): + _, _, fw = make() + fw.clock.hsi48(False) + assert fw.clock.info().flags["hsi48"] == 0 + fw.clock.mco("lse") + assert set(fw.open_instances) == {("clock", "hsi48"), ("clock", "mco")} + assert fw.close_all() == [] + assert fw.clock.info().flags["hsi48"] == 1 + fw.clock.mco("sysclk", div=16) + fw.clock.mco("off") + assert fw.open_instances == [] + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("clock.info 1", "usage"), + ("clock.info x=1", "usage"), + ("clock.mco", "usage"), + ("clock.mco pll", "usage"), + ("clock.mco sysclk div=3", "usage"), + ("clock.mco sysclk div=16", "ok"), + ("clock.mco hsi48 div=1", "ok"), + ("clock.hsi48", "usage"), + ("clock.hsi48 2", "range"), + ("clock.hsi48 1", "ok"), + ], +) +def test_clock_reasons_wb55(line, expected): + terminal, _, _ = make() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize("line", ["clock.mco sysclk", "clock.hsi48 1"]) +def test_clock_scaffolding_unsupported_on_wba55(line): + terminal, _, _ = make("stm32wba55") + assert reason(terminal, line) == "unsupported" + + +@pytest.mark.parametrize( + ("family", "line", "expected"), + [ + ("stm32wb55", "uart.open 1 lp=1 sendonly=1", "ok"), + ("stm32wb55", "uart.open 1 lp=1 sendonly=1 tx=PA2", "ok"), + ("stm32wb55", "uart.open 1 lp=1 sendonly=1 rx=PA3", "usage"), + ("stm32wb55", "uart.open 1 lp=1 sendonly=1 dma=1", "usage"), + ("stm32wb55", "uart.open 1 lp=1 sendonly=1 sync=1", "usage"), + ("stm32wb55", "uart.open 1 lp=1 sendonly=1 tx=PA2 flow=rts", "usage"), + ("stm32wb55", "uart.open 1 lp=1 sendonly=1 tx=PA2 flow=rts rts=PB12", "ok"), + ("stm32wb55", "uart.open 1 lp=1 sendonly=1 tx=PA2 flow=rts rts=PA2", "pin"), + ("stm32wb55", "uart.open 1 lp=1 sendonly=1 tx=PA2 flow=cts cts=PA6", "unsupported"), + ("stm32wb55", "uart.open 1 lp=1 sendonly=1 tx=PA2 parity=even", "unsupported"), + ("stm32wba55", "uart.open 2 sendonly=1 tx=PB0", "ok"), + ("stm32wba55", "uart.open 2 sendonly=1 tx=PB0 swap=1", "unsupported"), + ("stm32wba55", "uart.open 1 lp=1 sendonly=1 tx=PB5", "unsupported"), + ], +) +def test_uart_sendonly_reasons(family, line, expected): + terminal, _, _ = make(family) + assert reason(terminal, line) == expected + + +def test_uart_sendonly_receives_nothing(): + _, _, fw = make() + fw.uart.open(1, lp=True, tx="lpuart1tx", sendonly=True) + assert fw.uart.recv(1) == b"" + fw.uart.send(1, b"\x55") + + +def test_fake_uid_has_the_real_layout(): + _, fake, fw = make() + assert fake.uid == FAKE_UID + uid = parse_uid(fw.system.info().uid or "") + assert len(uid.raw) == UID_BYTES + assert uid.lot == b"HALSTFK" + assert (uid.x, uid.y, uid.wafer) == (0x24, 0x51, 7) + _, fake, _ = make(uid="00112233445566778899aabb") + assert fake.uid == "00112233445566778899aabb", "an explicit UID is kept" + + +def test_parse_uid_rejects_other_lengths(): + with pytest.raises(ValueError): + parse_uid("0011") + + +def square(hz, rate, seconds, phase=0.3): + """A 50 % square wave sampled at `rate`.""" + return [1 if math.fmod((i / rate) * hz + phase, 1.0) < 0.5 else 0 for i in range(int(seconds * rate))] + + +@pytest.mark.parametrize(("hz", "rate"), [(32768.0, 1_000_000.0), (32771.3, 1_250_000.0), (4_000_000.0, 100_000_000.0)]) +def test_edge_fit_frequency(hz, rate): + bits = square(hz, rate, 1000 / hz) + assert edge_fit_frequency(bits, rate) == pytest.approx(hz, rel=5e-6) + + +def test_edge_fit_frequency_needs_edges(): + with pytest.raises(ValueError): + edge_fit_frequency([0, 1, 1, 0], 1e6) + + +def test_sgpio_wrapper_errors_raise(): + _, _, fw = make() + with pytest.raises(FirmwareError) as error: + fw.sgpio.af("PA15") + assert error.value.reason == "usage" From 9e0f6f0e75cec521b6c8b2ef39f9f1ef06adc77c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:27:22 +0000 Subject: [PATCH 44/57] feat(validation): validate the RNG, AES and PKA drivers Add the rng group: rng.read returns 1-128 random bytes from hal::SynchronousRandomDataGeneratorStm, hal::RandomDataGeneratorStm (variant=async, completion through HilPendingOperation) or, on STM32WB, hal::SynchronousSynchronizedRandomDataGeneratorStm (variant=hsem, through the shared HSEM master) and then reports the HSI48 ready flag. lock5=1 holds HSEM semaphore 5 around a synchronized read so the driver's Hsi48Enabler takes its locked branch. rng.stats generates up to 64 KiB in 256-byte chunks and reports the set bits, the bit runs, the byte chi-square, the CRC-32 and the duration. Add the aes group: aes.enc and aes.dec run hal::SynchronousAes128EcbStm on one to five blocks with each data swapping mode. Add the pka group: pka.mul, pka.check and pka.cmp run hal::PkaStm on secp256r1, with operands left-padded to 32 bytes by the firmware; the PkaStm is built on first use and kept. Host: crypto_ref.py (pure-Python AES-128 with a model of the STM32 data swapping, affine P-256 arithmetic, the FIPS-197, SP 800-38A and CAVP ECC CDH vectors), rngstats.py (the rng.stats counts with monobit, chi-square and runs bounds), groups/crypto.py, fakes/crypto.py, test_rng.py (lengths, distinct reads, statistics per variant, and on the STM32WB55 HSI48 kept on, switched off again and the locked branch, with HSEM semaphores 0 and 5 free afterwards), test_aes.py, test_pka.py, their unit tests and the board YAML for them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- validation/PROTOCOL.md | 31 ++ validation/README.md | 7 + validation/firmware/AesGroup.cpp | 88 +++++ validation/firmware/AesGroup.hpp | 30 ++ validation/firmware/CMakeLists.txt | 6 + validation/firmware/Main.cpp | 7 + validation/firmware/PkaGroup.cpp | 214 ++++++++++++ validation/firmware/PkaGroup.hpp | 43 +++ validation/firmware/RngGroup.cpp | 292 ++++++++++++++++ validation/firmware/RngGroup.hpp | 92 ++++++ validation/host/boards/nucleo_wb55rg.yaml | 60 ++++ validation/host/boards/nucleo_wba55cg.yaml | 62 ++++ .../host/hal_st_validation/crypto_ref.py | 311 ++++++++++++++++++ .../host/hal_st_validation/fakes/crypto.py | 155 +++++++++ .../host/hal_st_validation/groups/crypto.py | 95 ++++++ validation/host/hal_st_validation/rngstats.py | 96 ++++++ validation/host/tests/hil/test_aes.py | 114 +++++++ validation/host/tests/hil/test_pka.py | 124 +++++++ validation/host/tests/hil/test_rng.py | 169 ++++++++++ validation/host/tests/unit/test_crypto_ref.py | 125 +++++++ .../host/tests/unit/test_fake_crypto.py | 198 +++++++++++ validation/host/tests/unit/test_rngstats.py | 75 +++++ 22 files changed, 2394 insertions(+) create mode 100644 validation/firmware/AesGroup.cpp create mode 100644 validation/firmware/AesGroup.hpp create mode 100644 validation/firmware/PkaGroup.cpp create mode 100644 validation/firmware/PkaGroup.hpp create mode 100644 validation/firmware/RngGroup.cpp create mode 100644 validation/firmware/RngGroup.hpp create mode 100644 validation/host/hal_st_validation/crypto_ref.py create mode 100644 validation/host/hal_st_validation/fakes/crypto.py create mode 100644 validation/host/hal_st_validation/groups/crypto.py create mode 100644 validation/host/hal_st_validation/rngstats.py create mode 100644 validation/host/tests/hil/test_aes.py create mode 100644 validation/host/tests/hil/test_pka.py create mode 100644 validation/host/tests/hil/test_rng.py create mode 100644 validation/host/tests/unit/test_crypto_ref.py create mode 100644 validation/host/tests/unit/test_fake_crypto.py create mode 100644 validation/host/tests/unit/test_rngstats.py diff --git a/validation/PROTOCOL.md b/validation/PROTOCOL.md index de7d72ac..8aca6455 100644 --- a/validation/PROTOCOL.md +++ b/validation/PROTOCOL.md @@ -362,6 +362,37 @@ Not a hal-st driver (hal-st has no I2C slave): the other end of the bus for the - `eeprom.erase` writes 0xFF pages over `size` (about 6.7 ms per 64-byte page at 400 kHz: attach with a `size` that fits EMIL's 5 s) - a NACK or bus error outside the polling prints `EVT eeprom error= address=` and leaves the command pending (EMIL answers `ERR timeout`); `eeprom.detach` recovers +## Random number generator (`hal::SynchronousRandomDataGeneratorStm`, `variant=async` selects `hal::RandomDataGeneratorStm`, `variant=hsem` selects `hal::SynchronousSynchronizedRandomDataGeneratorStm`) + +- `rng.read [variant=sync|async|hsem] [lock5=0|1]` → `OK data=`: `len` random bytes, 1-128; after a `variant=hsem` read the line ends with ` hsi48=<0|1>`, the HSI48 ready flag (`LL_RCC_HSI48_IsReady`) once the driver is done + - every command builds the driver of its variant and destroys it afterwards; `variant` defaults to `sync` + - `variant=async` answers from the driver's completion (RNG interrupt), `ERR timeout` after 1 s + - `variant=hsem` exists on STM32WB55 only (`ERR unsupported` on STM32WBA55): the driver holds HSEM semaphore 0 during the read, through the firmware's one `SynchronousHardwareSemaphoreMasterStm` (shared with `hsem` and `flash`) + - unless this core holds semaphore 5 (clock configuration), the driver's `Hsi48Enabler` starts HSI48, the RNG kernel clock, for the read when it is off and stops it again afterwards + - `lock5=1` (validation scaffolding, STM32WB55 only: `ERR unsupported` on STM32WBA55; only with `variant=hsem`: `ERR usage` otherwise) holds semaphore 5 (`HAL_HSEM_FastTake`, process 0) around the read, so `Hsi48Enabler` takes its locked branch; `ERR busy` when semaphore 5 is taken, `ERR failed` when HSI48 is off (the driver asserts that HSI48 runs while semaphore 5 is locked) +- `rng.stats [variant=sync|async|hsem]` → `OK n= ones= runs= chisq= crc= us=`: `len` bytes, 16-65536, generated by one driver in chunks of 256 bytes; `variant=async` answers `ERR timeout` after 5 s + - the bytes form one bit stream, each byte most significant bit first: `ones` counts its set bits, `runs` its bit transitions plus one + - `chisq` is the chi-square of the byte histogram against the uniform distribution (255 degrees of freedom) times 1000, rounded down and at most 4294967295; `crc` is the CRC-32 of the bytes (8 lower-case hex digits, as `out=crc`); `us` is the generation time +- After an `ERR timeout` of `variant=async` the next `rng` command drops that driver and runs; until the timeout another `rng` command returns `ERR busy` + +## AES (`hal::SynchronousAes128EcbStm`) + +- `aes.enc [swap=none|half|byte|bit]` → `OK data=`: AES-128 ECB encryption of `data` under `key`; `aes.dec` takes the same arguments and decrypts + - `key` is 16 bytes (32 hex digits) and `data` 16-80 bytes in whole 16-byte blocks; any other length, an empty (`-`) or malformed hex returns `ERR usage` + - `swap` is the driver's data swapping (`DataSwapping`, AES_CR.DATATYPE) of every 32-bit word written to and read from the peripheral, default `byte`, which gives standard AES on the byte string; the key is not swapped + - `none`, `half` and `bit` give `T(AES(T(data)))`, where T reverses the bytes of every 32-bit word (`none`), swaps the two bytes of every 16-bit half-word (`half`) or reverses the bits of every byte (`bit`) + - every command builds the driver, sets the key, runs and destroys the driver + +## Public key accelerator (`hal::PkaStm` on `services::secp256r1`) + +- `pka.mul [k=] [x= y=]` → `OK x= y= us=`: the scalar multiplication k × (x, y) on the NIST P-256 curve; each result coordinate has 32 bytes (64 hex digits); `us` runs from the start of the operation to its completion + - `k` defaults to 1 and the point to the base point G of the curve; `x` and `y` come together (`ERR usage` otherwise) +- `pka.check x= y=` → `OK on=<0|1>`: whether (x, y) lies on the curve +- `pka.cmp a= b=` → `OK cmp=lt|eq|gt`: compares two numbers of the same length, 4-60 bytes: different lengths return `ERR usage`, a length outside 4-60 `ERR range` (60 bytes keep the line within 255 characters) +- Operands are big-endian hex. `mul` and `check` take 1-32 bytes and the firmware left-pads them with zeros to 32 bytes, as `PkaStm` itself pads by two words only. A missing operand, an empty (`-`), odd or non-hex one returns `ERR usage` and one above the size limit `ERR range`; every `usage` check of a command comes before its `range` checks +- The answer comes from the driver's completion (PKA interrupt, dispatched to the event loop), `ERR timeout` after 5 s. One operation runs at a time: until its timeout another `pka` command returns `ERR busy`; after an `ERR timeout` the next one starts a new operation +- The firmware builds one `PkaStm` on the first `pka` command and keeps it: its constructor waits for the PKA to initialise, on STM32WBA55 from the RNG kernel clock + ## Not available on these boards hal-st has no comparator, CAN or Ethernet driver for STM32WB55/STM32WBA55, so `comp.open`, `comp.read`, `comp.irq`, `comp.count`, `comp.close`, `can.open`, `can.send`, `can.close`, `eth.open`, `eth.status` and `eth.close` return `ERR unsupported`. diff --git a/validation/README.md b/validation/README.md index 9a2916ca..60d79bf3 100644 --- a/validation/README.md +++ b/validation/README.md @@ -490,6 +490,10 @@ SPI1 to SPI3; SPI3 is observed through DIO14, DIO10, DIO11 and DIO15. The PB8 en clock stretching, a bus error from a misplaced STOP, the general call, close while the bus is held and both instance directions. - `test_eeprom.py` - EMIL's `eeprom.*` over the `I2cEepromStm` adapter on the external 24Cxx (`--with i2c`): the address probe, attach/detach, writes inside and across pages, reads across pages, write-read-write-read, erase, data across a reset, the size limit, ACK polling on the logic analyzer, recovery from an absent chip, detach while busy, and 1-byte word addresses against the `i2cs` register file. +- `test_rng.py` - for `SynchronousRandomDataGeneratorStm`, `RandomDataGeneratorStm` and (NUCLEO-WB55RG) `SynchronousSynchronizedRandomDataGeneratorStm`: exact lengths, two reads that differ, 64 KiB per variant through the monobit, byte chi-square and runs tests of `rngstats`, variants taking turns on the RNG. + - On the NUCLEO-WB55RG the synchronized variant keeps HSI48 running when it was on and switches it off again when it was off, takes its locked branch with semaphore 5 held (`lock5=1`), and leaves HSEM semaphores 0 and 5 free (`hsem.status`, when that group is built in). +- `test_aes.py` - for `SynchronousAes128EcbStm`: the FIPS-197 C.1 and SP 800-38A F.1.1 known answers in both directions, block by block and four blocks at once, one to five blocks per command, a key change between commands, and every data swapping mode (`swap=none|half|byte|bit`) against the model of `crypto_ref`; argument errors. +- `test_pka.py` - for `PkaStm` on secp256r1: G, 2G and 3G, the default operands, a short scalar padded by the firmware, (n - 1)G = -G, the NIST CAVP ECC CDH vector, a given base point, points on and off the curve, comparisons of 4, 32 and 60-byte numbers, the duration of one multiplication and argument errors; every result against the P-256 arithmetic of `crypto_ref`. - `test_unsupported.py` - every comparator, CAN and Ethernet command, and the commands of the groups the running MCU lacks (PROTOCOL.md, "Not available on these boards"), answer `ERR unsupported`. ## Customising @@ -532,6 +536,8 @@ In `hal_st_validation` (hal-st specific): - `groups/timers.py` - `fw.tim`, `fw.tpwm`, `fw.lptim`, `fw.lptpwm` and their expected waveforms (`timer_update_rate`, `lptim_update_rate`, `pwm_duty_fraction`); `fakes/timers.py` is their fake firmware model. - `groups/analog.py` - `fw.ain` (`read`, `burst` with one `BurstRun` per measurement) and `fw.dma` (`wave`), with the expectations of the TIM2 pacing (`trigger_timing`, `trigger_rate`, `burst_seconds`) and the wave bit order (`wave_bits`); `fakes/analog.py` models both groups and their deferred replies. - `groups/io.py` - `fw.sgpio`, `fw.clock` (`ClockInfo`; `clock.mco` and `clock.hsi48` register their undo with `close_all`), `edge_fit_frequency` and `parse_uid`; `fakes/io.py` is their fake firmware model, with a unique device ID of the real layout. +- `crypto_ref.py` - pure-Python AES-128 with the data swapping model of the STM32 AES peripheral, and affine P-256 arithmetic, with the FIPS-197, SP 800-38A and CAVP ECC CDH vectors; `rngstats.py` - the `rng.stats` counts and the monobit, chi-square and runs bounds. +- `groups/crypto.py` - `fw.rng`, `fw.aes`, `fw.pka`; `fakes/crypto.py` is their fake firmware model (a deterministic RNG, AES and PKA through `crypto_ref`). - `protocol.py` - the hal-st part of the protocol: error reasons, `P` pins (ports A-K, index 0-15) and the generic alias names (`normalize_pin`, `parse_pin_map`). - `config.py` - board file loading, wiring-set merging, parameter matrices, overrides and known gaps; `expect.py` - expected STM32 values (PWM quantisation and range, SPI prescaler, UART baud-rate register limits, WWDG prescaler and period, ADC codes, encoder counts). - `pairwise.py` - the full product and the deterministic pairwise generator behind `--depth`. @@ -549,3 +555,4 @@ In the firmware (`firmware/`): - `SpiSlaveGroup` (`spis.*`: `SpiSlaveFactory` builds `SpiSlaveStmDma` on the board's slave DMA channels, `SpiSlaveCommands` arms one transfer at a time and answers `spis.result` from its own timer); `SpiFactory` also takes `bits`, `lsb` and `nss` and holds the SPI instance in `ResourceAllocation`. - `TimerGroup`, `TimerPwmGroup`, `LpTimerGroup` and `LpTimerPwmGroup` serve `tim.*`, `tpwm.*`, `lptim.*` and `lptpwm.*`, each a factory and a single-instance group; `QeiFactory` also builds the LPTIM encoder with `cap=rise|fall` and holds its LPTIM in `ResourceAllocation`. - `SyncGpioGroup` serves `sgpio.*` through `SyncGpioDriver`, the only translation unit that includes `SynchronousGpioStm.hpp`; `ClockGroup` serves `clock.*`; `UartFactory` also builds `SynchronousUartStmSendOnly` (`sendonly=1`). +- `RngGroup`, `AesGroup` and `PkaGroup` are stateless command groups: `rng` and `aes` build their driver per command; `pka` builds one `PkaStm` on first use and keeps it. diff --git a/validation/firmware/AesGroup.cpp b/validation/firmware/AesGroup.cpp new file mode 100644 index 00000000..f2414231 --- /dev/null +++ b/validation/firmware/AesGroup.cpp @@ -0,0 +1,88 @@ +#include "validation/firmware/AesGroup.hpp" +#include "validation/firmware/Payload.hpp" + +namespace validation +{ + namespace + { + using services::HilChoice; + using services::HilStatus; + using DataSwapping = hal::detail::DataSwapping; + + constexpr std::size_t blockSize = 16; + + constexpr std::array, 4> swappings{ { + { "none", DataSwapping::disabled }, + { "half", DataSwapping::halfWord }, + { "byte", DataSwapping::byte }, + { "bit", DataSwapping::bit }, + } }; + + HilStatus ParseBlocks(infra::BoundedConstString text, infra::ByteRange storage, std::size_t& size) + { + if (services::HilArguments::ParseHex(text, storage, size) != HilStatus::done || size == 0 || size % blockSize != 0) + return HilStatus::usage; + + return HilStatus::done; + } + } + + AesCommands::AesCommands(services::HilContext& context) + : services::TerminalCommands(context.terminal) + , context(context) + , commands{ { + services::HilBind("aes.enc", " [swap=none|half|byte|bit]", *this, context.response), + services::HilBind("aes.dec", " [swap=none|half|byte|bit]", *this, context.response), + } } + {} + + infra::MemoryRange AesCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus AesCommands::Encrypt(const services::HilArguments& arguments) + { + return Run(arguments, false); + } + + HilStatus AesCommands::Decrypt(const services::HilArguments& arguments) + { + return Run(arguments, true); + } + + HilStatus AesCommands::Run(const services::HilArguments& arguments, bool decrypt) + { + if (!arguments.Shape(2, 2, { "swap" })) + return HilStatus::usage; + + std::size_t keySize = 0; + std::size_t size = 0; + if (ParseBlocks(arguments.Positional(0), infra::MakeRange(key), keySize) != HilStatus::done || keySize != key.size()) + return HilStatus::usage; + + if (ParseBlocks(arguments.Positional(1), infra::MakeRange(input), size) != HilStatus::done) + return HilStatus::usage; + + HilStatus status = HilStatus::done; + hal::SynchronousAes128EcbStm::Config config; + arguments.Select("swap", config.dataSwapping, swappings, status); + if (status != HilStatus::done) + return status; + + auto data = infra::Head(infra::MakeRange(input), size); + auto result = infra::Head(infra::MakeRange(output), size); + + hal::SynchronousAes128EcbStm aes{ config }; + aes.SetKey(key); + + if (decrypt) + aes.Decrypt(data, result); + else + aes.Encrypt(data, result); + + auto line = context.response.Ok(); + PrintData(line, result, Output::hex); + return HilStatus::done; + } +} diff --git a/validation/firmware/AesGroup.hpp b/validation/firmware/AesGroup.hpp new file mode 100644 index 00000000..8fafba8e --- /dev/null +++ b/validation/firmware/AesGroup.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include "hal_st/synchronous_stm32fxxx/SynchronousAesStm.hpp" +#include "services/hil/HilCommand.hpp" +#include +#include + +namespace validation +{ + class AesCommands + : public services::TerminalCommands + { + public: + explicit AesCommands(services::HilContext& context); + + infra::MemoryRange Commands() override; + + private: + services::HilStatus Encrypt(const services::HilArguments& arguments); + services::HilStatus Decrypt(const services::HilArguments& arguments); + services::HilStatus Run(const services::HilArguments& arguments, bool decrypt); + + private: + services::HilContext& context; + std::array key{}; + alignas(uint32_t) std::array input{}; + alignas(uint32_t) std::array output{}; + std::array commands; + }; +} diff --git a/validation/firmware/CMakeLists.txt b/validation/firmware/CMakeLists.txt index 5e17650f..e7f1a9a5 100644 --- a/validation/firmware/CMakeLists.txt +++ b/validation/firmware/CMakeLists.txt @@ -23,6 +23,8 @@ target_link_libraries(hal_st.validation_firmware PRIVATE target_sources(hal_st.validation_firmware PRIVATE AdcFactory.cpp AdcFactory.hpp + AesGroup.cpp + AesGroup.hpp AnalogInputGroup.cpp AnalogInputGroup.hpp BoardInfoStm.cpp @@ -59,12 +61,16 @@ target_sources(hal_st.validation_firmware PRIVATE PeripheralClocks.hpp PinFactoryStm.cpp PinFactoryStm.hpp + PkaGroup.cpp + PkaGroup.hpp PwmFactory.cpp PwmFactory.hpp QeiFactory.cpp QeiFactory.hpp ResourceAllocation.cpp ResourceAllocation.hpp + RngGroup.cpp + RngGroup.hpp SpiFactory.cpp SpiFactory.hpp SpiSlaveGroup.cpp diff --git a/validation/firmware/Main.cpp b/validation/firmware/Main.cpp index 3848cd85..9c896982 100644 --- a/validation/firmware/Main.cpp +++ b/validation/firmware/Main.cpp @@ -15,6 +15,7 @@ #include "services/hil/commands/HilWatchDogCommands.hpp" #include "services/peripheral/DebugLed.hpp" #include "validation/firmware/AdcFactory.hpp" +#include "validation/firmware/AesGroup.hpp" #include "validation/firmware/AnalogInputGroup.hpp" #include "validation/firmware/BoardInfoStm.hpp" #include "validation/firmware/ClockGroup.hpp" @@ -26,9 +27,11 @@ #include "validation/firmware/LpTimerGroup.hpp" #include "validation/firmware/LpTimerPwmGroup.hpp" #include "validation/firmware/PinFactoryStm.hpp" +#include "validation/firmware/PkaGroup.hpp" #include "validation/firmware/PwmFactory.hpp" #include "validation/firmware/QeiFactory.hpp" #include "validation/firmware/ResourceAllocation.hpp" +#include "validation/firmware/RngGroup.hpp" #include "validation/firmware/SpiFactory.hpp" #include "validation/firmware/SpiSlaveGroup.hpp" #include "validation/firmware/SyncGpioGroup.hpp" @@ -111,6 +114,10 @@ int main() static validation::SyncGpioCommands syncGpio{ context }; static validation::ClockCommands clocks{ context }; + static validation::RngCommands rng{ context }; + static validation::AesCommands aes{ context }; + static validation::PkaCommands pka{ context }; + validation::CreateUnsupportedGroups(context); system.PrintBoot(); diff --git a/validation/firmware/PkaGroup.cpp b/validation/firmware/PkaGroup.cpp new file mode 100644 index 00000000..a736c663 --- /dev/null +++ b/validation/firmware/PkaGroup.cpp @@ -0,0 +1,214 @@ +#include "validation/firmware/PkaGroup.hpp" +#include "services/crypto/Secp256r1.hpp" +#include +#include + +namespace validation +{ + namespace + { + using services::HilStatus; + using PointOnCurveResult = services::EllipticCurveOperations::PointOnCurveResult; + using ComparisonResult = services::EllipticCurveOperations::ComparisonResult; + + constexpr std::size_t minimumComparison = 4; + constexpr std::chrono::milliseconds timeout{ 5000 }; + + bool IsHexDigit(char c) + { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + } + + bool HexSize(const std::optional& text, std::size_t& size) + { + if (!text || text->empty() || text->size() % 2 != 0 || !std::all_of(text->begin(), text->end(), IsHexDigit)) + return false; + + size = text->size() / 2; + return true; + } + + template + void LoadPadded(infra::BoundedConstString text, std::array& operand) + { + std::size_t size = 0; + operand.fill(0); + services::HilArguments::ParseHex(text, infra::Tail(infra::MakeRange(operand), text.size() / 2), size); + } + } + + PkaCommands::PkaCommands(services::HilContext& context) + : services::TerminalCommands(context.terminal) + , context(context) + , pending(context.response) + , commands{ { + services::HilBind("pka.mul", "[k=] [x= y=]", *this, context.response), + services::HilBind("pka.check", "x= y=", *this, context.response), + services::HilBind("pka.cmp", "a= b=", *this, context.response), + } } + {} + + infra::MemoryRange PkaCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus PkaCommands::Multiply(const services::HilArguments& arguments) + { + if (!arguments.Shape(0, 0, { "k", "x", "y" })) + return HilStatus::usage; + + auto k = arguments.Key("k"); + auto pointX = arguments.Key("x"); + auto pointY = arguments.Key("y"); + std::size_t kSize = 0; + std::size_t xSize = 0; + std::size_t ySize = 0; + if (pointX.has_value() != pointY.has_value() || (k && !HexSize(k, kSize)) || (pointX && (!HexSize(pointX, xSize) || !HexSize(pointY, ySize)))) + return HilStatus::usage; + + if (std::max({ kSize, xSize, ySize }) > scalar.size()) + return HilStatus::range; + + auto status = Reserve(); + if (status != HilStatus::done) + return status; + + if (k) + LoadPadded(*k, scalar); + else + { + scalar.fill(0); + scalar.back() = 1; + } + + infra::ConstByteRange initialX; + infra::ConstByteRange initialY; + if (pointX) + { + LoadPadded(*pointX, x); + LoadPadded(*pointY, y); + initialX = infra::MakeRange(x); + initialY = infra::MakeRange(y); + } + + auto& driver = Pka(); + driver.ScalarMultiplication(services::secp256r1, infra::MakeRange(scalar), initialX, initialY, [this, operation = Start()](infra::ConstByteRange resultX, infra::ConstByteRange resultY) + { + auto elapsed = stopwatch.ElapsedUs(); + + if (!pending.Complete(operation)) + return; + + auto line = context.response.Ok(); + line << " x="; + line.Hex(resultX); + line << " y="; + line.Hex(resultY); + line << " us=" << elapsed; + }); + + return HilStatus::done; + } + + HilStatus PkaCommands::CheckPoint(const services::HilArguments& arguments) + { + if (!arguments.Shape(0, 0, { "x", "y" })) + return HilStatus::usage; + + auto pointX = arguments.Key("x"); + auto pointY = arguments.Key("y"); + std::size_t xSize = 0; + std::size_t ySize = 0; + if (!HexSize(pointX, xSize) || !HexSize(pointY, ySize)) + return HilStatus::usage; + + if (std::max(xSize, ySize) > x.size()) + return HilStatus::range; + + auto status = Reserve(); + if (status != HilStatus::done) + return status; + + LoadPadded(*pointX, x); + LoadPadded(*pointY, y); + + auto& driver = Pka(); + driver.CheckPointOnCurve(services::secp256r1, infra::MakeRange(x), infra::MakeRange(y), [this, operation = Start()](PointOnCurveResult result) + { + if (pending.Complete(operation)) + context.response.Ok() << " on=" << static_cast(result == PointOnCurveResult::pointOnCurve ? 1 : 0); + }); + + return HilStatus::done; + } + + HilStatus PkaCommands::Compare(const services::HilArguments& arguments) + { + if (!arguments.Shape(0, 0, { "a", "b" })) + return HilStatus::usage; + + auto textA = arguments.Key("a"); + auto textB = arguments.Key("b"); + std::size_t size = 0; + std::size_t sizeB = 0; + if (!HexSize(textA, size) || !HexSize(textB, sizeB) || size != sizeB) + return HilStatus::usage; + + if (size < minimumComparison || size > a.size()) + return HilStatus::range; + + auto status = Reserve(); + if (status != HilStatus::done) + return status; + + services::HilArguments::ParseHex(*textA, infra::MakeRange(a), sizeB); + services::HilArguments::ParseHex(*textB, infra::MakeRange(b), sizeB); + + auto& driver = Pka(); + driver.Comparison(infra::Head(infra::MakeRange(a), size), infra::Head(infra::MakeRange(b), size), [this, operation = Start()](ComparisonResult result) + { + if (!pending.Complete(operation)) + return; + + const char* name = "lt"; + if (result == ComparisonResult::aEqualsB) + name = "eq"; + else if (result == ComparisonResult::aGreaterThanB) + name = "gt"; + + context.response.Ok() << " cmp=" << name; + }); + + return HilStatus::done; + } + + HilStatus PkaCommands::Reserve() + { + if (!pending.Busy()) + return HilStatus::done; + + if (infra::Now() < deadline) + return HilStatus::busy; + + // An operation whose ERR timeout went out may never complete; the next command may start another one + pending.Cancel(); + return HilStatus::done; + } + + uint32_t PkaCommands::Start() + { + deadline = infra::Now() + timeout; + stopwatch.Start(); + return pending.Start(timeout); + } + + const hal::PkaStm& PkaCommands::Pka() + { + // Built on first use and kept: PkaStm::Enable waits for the PKA without a timeout, which must not stall the boot + if (!pka) + pka.emplace(); + + return *pka; + } +} diff --git a/validation/firmware/PkaGroup.hpp b/validation/firmware/PkaGroup.hpp new file mode 100644 index 00000000..07c26679 --- /dev/null +++ b/validation/firmware/PkaGroup.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include "hal_st/stm32fxxx/PkaStm.hpp" +#include "infra/timer/Timer.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilPendingOperation.hpp" +#include "validation/firmware/Stopwatch.hpp" +#include +#include +#include + +namespace validation +{ + class PkaCommands + : public services::TerminalCommands + { + public: + explicit PkaCommands(services::HilContext& context); + + infra::MemoryRange Commands() override; + + private: + services::HilStatus Multiply(const services::HilArguments& arguments); + services::HilStatus CheckPoint(const services::HilArguments& arguments); + services::HilStatus Compare(const services::HilArguments& arguments); + services::HilStatus Reserve(); + uint32_t Start(); + const hal::PkaStm& Pka(); + + private: + services::HilContext& context; + services::HilPendingOperation pending; + std::optional pka; + Stopwatch stopwatch; + std::array scalar{}; + std::array x{}; + std::array y{}; + std::array a{}; + std::array b{}; + infra::TimePoint deadline; + std::array commands; + }; +} diff --git a/validation/firmware/RngGroup.cpp b/validation/firmware/RngGroup.cpp new file mode 100644 index 00000000..a3065f84 --- /dev/null +++ b/validation/firmware/RngGroup.cpp @@ -0,0 +1,292 @@ +#include "validation/firmware/RngGroup.hpp" +#include "infra/util/Endian.hpp" +#include "validation/firmware/Payload.hpp" +#include +#include +#include +#include +#include DEVICE_HEADER +#if defined(STM32WBA) +#include "stm32wbaxx_ll_rng.h" +#elif defined(STM32WB) +#include "hal_st/synchronous_stm32fxxx/SynchronousSynchronizedRandomDataGeneratorStm.hpp" +#include "stm32wbxx_ll_rng.h" +#include "validation/firmware/HsemMaster.hpp" +#endif + +namespace validation +{ + namespace + { + using services::HilChoice; + using services::HilStatus; + using Variant = RngCommands::Variant; + + constexpr std::array readKeys{ { "variant", "lock5" } }; + constexpr std::array statsKeys{ { "variant" } }; + + constexpr std::array, 3> variants{ { + { "sync", Variant::sync }, + { "async", Variant::async }, + { "hsem", Variant::hsem }, + } }; + + constexpr uint32_t minimumStats = 16; + constexpr uint32_t maximumStats = 65536; + constexpr std::chrono::milliseconds readTimeout{ 1000 }; + constexpr std::chrono::milliseconds statsTimeout{ 5000 }; + } + + RngCommands::RngCommands(services::HilContext& context) + : services::TerminalCommands(context.terminal) + , context(context) + , pending(context.response) + , commands{ { + services::HilBind("rng.read", " [variant=sync|async|hsem] [lock5=0|1]", *this, context.response), + services::HilBind("rng.stats", " [variant=sync|async|hsem]", *this, context.response), + } } + {} + + infra::MemoryRange RngCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus RngCommands::Read(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, infra::MakeRange(readKeys))) + return HilStatus::usage; + + Request newRequest; + HilStatus status = HilStatus::done; + arguments.NumberAt(0, newRequest.length, 1, maximumHexOutput, status); + arguments.Select("variant", newRequest.variant, variants, status); + arguments.Flag("lock5", newRequest.lockClock, status); + if (status != HilStatus::done) + return status; + + return Run(newRequest); + } + + HilStatus RngCommands::Stats(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, infra::MakeRange(statsKeys))) + return HilStatus::usage; + + Request newRequest; + newRequest.job = Job::stats; + HilStatus status = HilStatus::done; + arguments.NumberAt(0, newRequest.length, minimumStats, maximumStats, status); + arguments.Select("variant", newRequest.variant, variants, status); + if (status != HilStatus::done) + return status; + + return Run(newRequest); + } + + HilStatus RngCommands::Check(const Request& candidate) const + { +#if !defined(STM32WB) + if (candidate.variant == Variant::hsem || candidate.lockClock) + return HilStatus::unsupported; +#endif + + if (candidate.lockClock && candidate.variant != Variant::hsem) + return HilStatus::usage; + + return HilStatus::done; + } + + HilStatus RngCommands::Reserve() + { + if (!pending.Busy()) + return HilStatus::done; + + if (infra::Now() < deadline) + return HilStatus::busy; + + // A run whose ERR timeout went out may never complete; drop it so the RNG serves the next command + pending.Cancel(); + operation = 0; + LL_RNG_DisableIT(RNG); + asyncGenerator.reset(); + return HilStatus::done; + } + + HilStatus RngCommands::Run(const Request& newRequest) + { + auto status = Check(newRequest); + if (status == HilStatus::done) + status = Reserve(); + if (status != HilStatus::done) + return status; + + request = newRequest; + remaining = request.length; + statistics.Reset(); + + if (request.variant != Variant::async) + return RunSynchronous(); + + StartAsynchronous(); + return HilStatus::done; + } + + HilStatus RngCommands::RunSynchronous() + { +#if defined(STM32WB) + if (request.variant == Variant::hsem) + { + constexpr auto clockSemaphore = static_cast(hal::Semaphore::recoveryAndIndependentClockConfiguration); + auto& master = HsemMaster(); + + // With semaphore 5 locked, Hsi48Enabler asserts that HSI48 runs instead of starting it + if (request.lockClock && LL_RCC_HSI48_IsReady() == 0) + return HilStatus::failed; + + if (request.lockClock && HAL_HSEM_FastTake(clockSemaphore) != HAL_OK) + return HilStatus::busy; + + hal::SynchronousSynchronizedRandomDataGeneratorStm generator{ creator, master }; + Generate(generator); + + if (request.lockClock) + HAL_HSEM_Release(clockSemaphore, 0); + + Finish(); + return HilStatus::done; + } +#endif + + hal::SynchronousRandomDataGeneratorStm generator; + Generate(generator); + Finish(); + return HilStatus::done; + } + + void RngCommands::Generate(hal::SynchronousRandomDataGenerator& generator) + { + stopwatch.Start(); + + while (remaining != 0) + { + auto chunk = NextChunk(); + generator.GenerateRandomData(chunk); + Consume(chunk); + } + } + + void RngCommands::StartAsynchronous() + { + auto timeout = request.job == Job::read ? readTimeout : statsTimeout; + asyncGenerator.emplace(); + operation = pending.Start(timeout); + deadline = infra::Now() + timeout; + stopwatch.Start(); + RequestChunk(); + } + + void RngCommands::RequestChunk() + { + asyncGenerator->GenerateRandomData(NextChunk(), [this, chunkOperation = operation]() + { + ChunkDone(chunkOperation); + }); + } + + void RngCommands::ChunkDone(uint32_t chunkOperation) + { + if (chunkOperation != operation) + return; + + Consume(infra::Head(infra::MakeRange(buffer), chunkSize)); + + if (remaining != 0) + { + RequestChunk(); + return; + } + + operation = 0; + asyncGenerator.reset(); + + if (pending.Complete(chunkOperation)) + Finish(); + } + + infra::ByteRange RngCommands::NextChunk() + { + chunkSize = std::min(remaining, buffer.size()); + return infra::Head(infra::MakeRange(buffer), chunkSize); + } + + void RngCommands::Consume(infra::ConstByteRange chunk) + { + if (request.job == Job::stats) + statistics.Add(chunk); + + remaining -= chunk.size(); + } + + void RngCommands::Finish() + { + auto elapsed = stopwatch.ElapsedUs(); + auto line = context.response.Ok(); + + if (request.job == Job::read) + { + PrintData(line, infra::Head(infra::MakeRange(buffer), request.length), Output::hex); +#if defined(STM32WB) + if (request.variant == Variant::hsem) + line << " hsi48=" << static_cast(LL_RCC_HSI48_IsReady() != 0 ? 1 : 0); +#endif + return; + } + + infra::BigEndian crc{ statistics.crc.Result() }; + line << " n=" << statistics.bytes << " ones=" << statistics.ones << " runs=" << statistics.transitions + 1 << " chisq=" << statistics.ChiSquareTimes1000() << " crc="; + line.Hex(infra::MakeByteRange(crc)); + line << " us=" << elapsed; + } + + void RngCommands::Statistics::Reset() + { + bytes = 0; + ones = 0; + transitions = 0; + last = 0; + histogram.fill(0); + crc.Reset(); + } + + void RngCommands::Statistics::Add(infra::ConstByteRange data) + { + for (auto byte : data) + { + if (bytes != 0) + transitions += (last ^ (byte >> 7)) & 1u; + + transitions += static_cast(std::popcount(static_cast((byte ^ (byte >> 1)) & 0x7f))); + ones += static_cast(std::popcount(byte)); + ++histogram[byte]; + last = byte; + ++bytes; + } + + crc.Update(data); + } + + uint32_t RngCommands::Statistics::ChiSquareTimes1000() const + { + uint64_t sum = 0; + + for (auto count : histogram) + { + auto difference = static_cast(count) * static_cast(histogram.size()) - static_cast(bytes); + sum += static_cast(difference * difference); + } + + auto value = sum * 1000 / (static_cast(histogram.size()) * bytes); + return static_cast(std::min(value, std::numeric_limits::max())); + } +} diff --git a/validation/firmware/RngGroup.hpp b/validation/firmware/RngGroup.hpp new file mode 100644 index 00000000..87613e64 --- /dev/null +++ b/validation/firmware/RngGroup.hpp @@ -0,0 +1,92 @@ +#pragma once + +#include "hal_st/stm32fxxx/RandomDataGeneratorStm.hpp" +#include "hal_st/synchronous_stm32fxxx/SynchronousRandomDataGeneratorStm.hpp" +#include "infra/timer/Timer.hpp" +#include "infra/util/Crc.hpp" +#include "infra/util/ProxyCreator.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilPendingOperation.hpp" +#include "validation/firmware/Stopwatch.hpp" +#include +#include +#include + +namespace validation +{ + class RngCommands + : public services::TerminalCommands + { + public: + enum class Variant : uint8_t + { + sync, + async, + hsem, + }; + + explicit RngCommands(services::HilContext& context); + + infra::MemoryRange Commands() override; + + private: + enum class Job : uint8_t + { + read, + stats, + }; + + struct Request + { + Job job = Job::read; + uint32_t length = 0; + Variant variant = Variant::sync; + bool lockClock = false; + }; + + struct Statistics + { + void Reset(); + void Add(infra::ConstByteRange data); + uint32_t ChiSquareTimes1000() const; + + uint32_t bytes = 0; + uint32_t ones = 0; + uint32_t transitions = 0; + uint8_t last = 0; + std::array histogram{}; + infra::Crc32 crc; + }; + + services::HilStatus Read(const services::HilArguments& arguments); + services::HilStatus Stats(const services::HilArguments& arguments); + services::HilStatus Check(const Request& request) const; + services::HilStatus Reserve(); + services::HilStatus Run(const Request& request); + services::HilStatus RunSynchronous(); + void Generate(hal::SynchronousRandomDataGenerator& generator); + void StartAsynchronous(); + void RequestChunk(); + void ChunkDone(uint32_t chunkOperation); + infra::ByteRange NextChunk(); + void Consume(infra::ConstByteRange chunk); + void Finish(); + + private: + services::HilContext& context; + services::HilPendingOperation pending; + std::optional asyncGenerator; +#if defined(STM32WB) + infra::Creator creator; +#endif + Stopwatch stopwatch; + Request request; + Statistics statistics; + std::array buffer{}; + uint32_t remaining = 0; + uint32_t chunkSize = 0; + uint32_t operation = 0; + infra::TimePoint deadline; + std::array commands; + }; +} diff --git a/validation/host/boards/nucleo_wb55rg.yaml b/validation/host/boards/nucleo_wb55rg.yaml index 9f2cdefe..8fd4cc53 100644 --- a/validation/host/boards/nucleo_wb55rg.yaml +++ b/validation/host/boards/nucleo_wb55rg.yaml @@ -1221,6 +1221,66 @@ tests: uid: # Bytes [5, 12) of `info uid=` hold the lot number in ASCII. lot: [5, 12] + rng: + # sync: SynchronousRandomDataGeneratorStm; async: RandomDataGeneratorStm; + # hsem: SynchronousSynchronizedRandomDataGeneratorStm (HSEM 0 around the + # read, HSI48 through Hsi48Enabler unless semaphore 5 is locked). + variants: [sync, async, hsem] + lengths: [1, 2, 3, 4, 5, 7, 16, 127, 128] + stats: + length: 65536 + # Seconds the host waits for the rng.stats reply (the firmware gives + # the async variant 5 s). + timeout_s: 6.0 + # HSEM semaphores the hsem variant takes (0) or reads (5): free after + # every read (hsem.status, when the hsem group is built in). + semaphores: [0, 5] + # Every variant exists on STM32WB55. + unsupported: [] + aes: + # FIPS-197 C.1 and NIST SP 800-38A F.1.1 (ECB-AES128, all four blocks); + # a list of hex strings is one byte string. + vectors: + - name: fips197-c1 + key: "000102030405060708090a0b0c0d0e0f" + plaintext: "00112233445566778899aabbccddeeff" + ciphertext: "69c4e0d86a7b0430d8cdb78070b4c55a" + - name: sp800-38a-f11 + key: "2b7e151628aed2a6abf7158809cf4f3c" + plaintext: + - "6bc1bee22e409f96e93d7e117393172a" + - "ae2d8a571e03ac9c9eb76fac45af8e51" + - "30c81c46a35ce411e5fbc1191a0a52ef" + - "f69f2445df4f9b17ad2b417be66c3710" + ciphertext: + - "3ad77bb40d7a3660a89ecaf32466ef97" + - "f5d3d58503b9699de785895a96fdbaaf" + - "43b1cd7f598ece23881b00e3ed030688" + - "7b0c785e27e8ad3f8223207104725dd4" + # AES_CR.DATATYPE of hal::detail::DataSwapping, against + # crypto_ref.aes_stm (model from the reference manual). + swaps: [none, half, byte, bit] + # Bytes per command: one to five blocks (the group holds 80). + lengths: [16, 32, 48, 64, 80] + pka: + # Multiples of G on services::secp256r1 (P-256): k -> (x, y). + multiples: + - name: k1 + k: "01" + x: "6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296" + y: "4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5" + - name: k2 + k: "02" + x: "7cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc47669978" + y: "07775510db8ed040293d9ac69f7430dbba7dade63ce982299e04b79d227873d1" + - name: k3 + k: "03" + x: "5ecbe4d1a6330a44c8f7ef951d4bf165e6c6b721efada985fb41661bc6e7fd6c" + y: "8734640c4998ff7e374b06ce1a64a2ecd82ab036384fb83d9a79b127a27d5032" + # pka.cmp operand sizes in bytes (4..60; 60 keeps the line at 253). + compare_lengths: [4, 32, 60] + # One full-scalar multiplication at 64 MHz takes tens of ms. + max_us: 500000 unsupported: commands: - comp.open 1 diff --git a/validation/host/boards/nucleo_wba55cg.yaml b/validation/host/boards/nucleo_wba55cg.yaml index 9a2be692..451e23c8 100644 --- a/validation/host/boards/nucleo_wba55cg.yaml +++ b/validation/host/boards/nucleo_wba55cg.yaml @@ -1328,6 +1328,68 @@ tests: uid: # Bytes [5, 12) of `info uid=` hold the lot number in ASCII. lot: [5, 12] + rng: + # sync: SynchronousRandomDataGeneratorStm; async: RandomDataGeneratorStm + # (RNG kernel clock HSI16, B.7). The HSEM-synchronized variant exists on + # STM32WB only. + variants: [sync, async] + lengths: [1, 2, 3, 4, 5, 7, 16, 127, 128] + stats: + length: 65536 + # Seconds the host waits for the rng.stats reply (the firmware gives + # the async variant 5 s). + timeout_s: 6.0 + semaphores: [] + # variant=hsem and lock5=1: STM32WB only. + unsupported: + - rng.read 4 variant=hsem + - rng.read 4 variant=hsem lock5=1 + - rng.read 4 lock5=1 + - rng.stats 16 variant=hsem + aes: + # FIPS-197 C.1 and NIST SP 800-38A F.1.1 (ECB-AES128, all four blocks); + # a list of hex strings is one byte string. + vectors: + - name: fips197-c1 + key: "000102030405060708090a0b0c0d0e0f" + plaintext: "00112233445566778899aabbccddeeff" + ciphertext: "69c4e0d86a7b0430d8cdb78070b4c55a" + - name: sp800-38a-f11 + key: "2b7e151628aed2a6abf7158809cf4f3c" + plaintext: + - "6bc1bee22e409f96e93d7e117393172a" + - "ae2d8a571e03ac9c9eb76fac45af8e51" + - "30c81c46a35ce411e5fbc1191a0a52ef" + - "f69f2445df4f9b17ad2b417be66c3710" + ciphertext: + - "3ad77bb40d7a3660a89ecaf32466ef97" + - "f5d3d58503b9699de785895a96fdbaaf" + - "43b1cd7f598ece23881b00e3ed030688" + - "7b0c785e27e8ad3f8223207104725dd4" + # AES_CR.DATATYPE of hal::detail::DataSwapping, against + # crypto_ref.aes_stm (model from the reference manual). + swaps: [none, half, byte, bit] + # Bytes per command: one to five blocks (the group holds 80). + lengths: [16, 32, 48, 64, 80] + pka: + # Multiples of G on services::secp256r1 (P-256): k -> (x, y). + multiples: + - name: k1 + k: "01" + x: "6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296" + y: "4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5" + - name: k2 + k: "02" + x: "7cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc47669978" + y: "07775510db8ed040293d9ac69f7430dbba7dade63ce982299e04b79d227873d1" + - name: k3 + k: "03" + x: "5ecbe4d1a6330a44c8f7ef951d4bf165e6c6b721efada985fb41661bc6e7fd6c" + y: "8734640c4998ff7e374b06ce1a64a2ecd82ab036384fb83d9a79b127a27d5032" + # pka.cmp operand sizes in bytes (4..60; 60 keeps the line at 253). + compare_lengths: [4, 32, 60] + # One full-scalar multiplication at 100 MHz takes tens of ms. + max_us: 500000 unsupported: commands: - comp.open 1 diff --git a/validation/host/hal_st_validation/crypto_ref.py b/validation/host/hal_st_validation/crypto_ref.py new file mode 100644 index 00000000..4ffbebbe --- /dev/null +++ b/validation/host/hal_st_validation/crypto_ref.py @@ -0,0 +1,311 @@ +"""Reference models of the `aes` and `pka` groups (validation/PROTOCOL.md): pure-Python AES-128 (FIPS-197) with the +data swapping of the STM32 AES peripheral as `hal::SynchronousAes128EcbStm` uses it, and affine arithmetic on the +NIST P-256 curve (`services::secp256r1`). + +Data swapping (RM0434 / RM0493, AES_CR.DATATYPE): the driver writes each 32-bit word of the input to AES_DINR as the +little-endian CPU loads it, the first word being bits 127:96 of the block, and stores AES_DOUTR the same way; the +peripheral swaps the halves (`half`), the bytes (`byte`) or the bits (`bit`) of every word on the way in and out. +So the result is `T(AES(T(data)))` with T per 4-byte word: `none` reverses the bytes, `half` gives b1 b0 b3 b2, +`byte` is the identity (standard AES on the byte string, the driver's default) and `bit` reverses the bits of every +byte. The key is unaffected: the driver writes it byte-reversed per word (`__REV`), i.e. in standard order. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Literal + +__all__ = [ + "AES_FIPS197_C1", + "AES_SP800_38A_F11", + "INVERSE_SBOX", + "P256", + "P256_CAVP_CDH", + "SBOX", + "SWAPS", + "AesVector", + "CdhVector", + "Curve", + "Point", + "Swap", + "aes_decrypt_block", + "aes_ecb_decrypt", + "aes_ecb_encrypt", + "aes_encrypt_block", + "aes_stm", + "from_bytes", + "point_add", + "point_double", + "scalar_multiply", + "swap_words", + "to_bytes", +] + +Swap = Literal["none", "half", "byte", "bit"] +SWAPS: tuple[Swap, ...] = ("none", "half", "byte", "bit") + +BLOCK = 16 + + +def _multiply(a: int, b: int) -> int: + """Product in GF(2^8) modulo x^8 + x^4 + x^3 + x + 1.""" + result = 0 + while b: + if b & 1: + result ^= a + a = ((a << 1) ^ 0x11B) if a & 0x80 else a << 1 + b >>= 1 + return result + + +def _power(a: int, exponent: int) -> int: + result = 1 + while exponent: + if exponent & 1: + result = _multiply(result, a) + a = _multiply(a, a) + exponent >>= 1 + return result + + +def _sbox() -> tuple[tuple[int, ...], tuple[int, ...]]: + """FIPS-197 5.1.1: the multiplicative inverse followed by the affine transformation.""" + inverse = [0] + [_power(value, 254) for value in range(1, 256)] + forward = [] + for value in inverse: + result = 0x63 + for shift in range(5): + result ^= ((value << shift) | (value >> (8 - shift))) & 0xFF + forward.append(result) + backward = [0] * 256 + for index, value in enumerate(forward): + backward[value] = index + return tuple(forward), tuple(backward) + + +SBOX, INVERSE_SBOX = _sbox() + + +def _expand_key(key: bytes) -> list[list[int]]: + """FIPS-197 5.2: eleven round keys of 16 bytes for AES-128.""" + if len(key) != BLOCK: + raise ValueError("AES-128 takes a 16-byte key") + words = [list(key[i : i + 4]) for i in range(0, BLOCK, 4)] + rcon = 1 + for index in range(4, 44): + word = list(words[index - 1]) + if index % 4 == 0: + word = [SBOX[byte] for byte in word[1:] + word[:1]] + word[0] ^= rcon + rcon = _multiply(rcon, 2) + words.append([a ^ b for a, b in zip(words[index - 4], word)]) + return [sum(words[round_ * 4 : round_ * 4 + 4], []) for round_ in range(11)] + + +def _add(state: list[int], round_key: list[int]) -> list[int]: + return [a ^ b for a, b in zip(state, round_key)] + + +def _shift_rows(state: list[int], direction: int) -> list[int]: + """The state is column major (byte r + 4c is row r, column c); row r rotates left by `direction * r`.""" + return [state[(row + 4 * (column + direction * row)) % 16] for column in range(4) for row in range(4)] + + +def _mix_columns(state: list[int], matrix: tuple[int, int, int, int]) -> list[int]: + result = [] + for column in range(4): + values = state[4 * column : 4 * column + 4] + for row in range(4): + byte = 0 + for k in range(4): + byte ^= _multiply(matrix[(k - row) % 4], values[k]) + result.append(byte) + return result + + +def aes_encrypt_block(key: bytes, block: bytes) -> bytes: + keys = _expand_key(key) + state = _add(list(block), keys[0]) + for round_ in range(1, 11): + state = _shift_rows([SBOX[byte] for byte in state], 1) + if round_ != 10: + state = _mix_columns(state, (2, 3, 1, 1)) + state = _add(state, keys[round_]) + return bytes(state) + + +def aes_decrypt_block(key: bytes, block: bytes) -> bytes: + keys = _expand_key(key) + state = _add(list(block), keys[10]) + for round_ in range(9, -1, -1): + state = [INVERSE_SBOX[byte] for byte in _shift_rows(state, -1)] + state = _add(state, keys[round_]) + if round_ != 0: + state = _mix_columns(state, (14, 11, 13, 9)) + return bytes(state) + + +def _ecb(function: Callable[[bytes, bytes], bytes], key: bytes, data: bytes) -> bytes: + if len(data) % BLOCK: + raise ValueError("ECB data must be a multiple of 16 bytes") + return b"".join(function(key, data[i : i + BLOCK]) for i in range(0, len(data), BLOCK)) + + +def aes_ecb_encrypt(key: bytes, data: bytes) -> bytes: + return _ecb(aes_encrypt_block, key, data) + + +def aes_ecb_decrypt(key: bytes, data: bytes) -> bytes: + return _ecb(aes_decrypt_block, key, data) + + +def _reverse_bits(byte: int) -> int: + return int(f"{byte:08b}"[::-1], 2) + + +def swap_words(data: bytes, swap: Swap) -> bytes: + """The memory image of the 128-bit block the AES core sees (or the inverse: every T is an involution).""" + if len(data) % 4: + raise ValueError("data must be whole 32-bit words") + if swap == "byte": + return bytes(data) + if swap == "bit": + return bytes(_reverse_bits(byte) for byte in data) + order = {"none": (3, 2, 1, 0), "half": (1, 0, 3, 2)}[swap] + return b"".join(bytes(data[i + j] for j in order) for i in range(0, len(data), 4)) + + +def aes_stm(key: bytes, data: bytes, swap: Swap = "byte", decrypt: bool = False) -> bytes: + """What `aes.enc`/`aes.dec` return for `swap`.""" + function = aes_ecb_decrypt if decrypt else aes_ecb_encrypt + return swap_words(function(key, swap_words(data, swap)), swap) + + +@dataclass(frozen=True) +class AesVector: + name: str + key: bytes + plaintext: bytes + ciphertext: bytes + + +# FIPS-197 Appendix C.1 (AES-128). +AES_FIPS197_C1 = AesVector( + "fips197-c1", + bytes.fromhex("000102030405060708090a0b0c0d0e0f"), + bytes.fromhex("00112233445566778899aabbccddeeff"), + bytes.fromhex("69c4e0d86a7b0430d8cdb78070b4c55a"), +) + +# NIST SP 800-38A F.1.1 (ECB-AES128.Encrypt), all four blocks. +AES_SP800_38A_F11 = AesVector( + "sp800-38a-f11", + bytes.fromhex("2b7e151628aed2a6abf7158809cf4f3c"), + bytes.fromhex( + "6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e5130c81c46a35ce411e5fbc1191a0a52eff69f2445df4f9b17ad2b417be66c3710" + ), + bytes.fromhex( + "3ad77bb40d7a3660a89ecaf32466ef97f5d3d58503b9699de785895a96fdbaaf43b1cd7f598ece23881b00e3ed0306887b0c785e27e8ad3f8223207104725dd4" + ), +) + +Point = tuple[int, int] | None + + +@dataclass(frozen=True) +class Curve: + """y^2 = x^3 + ax + b over GF(p), base point (gx, gy) of order n.""" + + p: int + a: int + b: int + gx: int + gy: int + n: int + + @property + def g(self) -> tuple[int, int]: + return (self.gx, self.gy) + + def on_curve(self, point: Point) -> bool: + if point is None: + return True + x, y = point + return 0 <= x < self.p and 0 <= y < self.p and (y * y - x * x * x - self.a * x - self.b) % self.p == 0 + + +# FIPS 186-4 D.1.2.3 (= services::secp256r1). +P256 = Curve( + p=0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF, + a=0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC, + b=0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B, + gx=0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296, + gy=0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5, + n=0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551, +) + + +def point_double(curve: Curve, point: Point) -> Point: + if point is None or point[1] == 0: + return None + x, y = point + slope = (3 * x * x + curve.a) * pow(2 * y, -1, curve.p) % curve.p + rx = (slope * slope - 2 * x) % curve.p + return rx, (slope * (x - rx) - y) % curve.p + + +def point_add(curve: Curve, first: Point, second: Point) -> Point: + if first is None: + return second + if second is None: + return first + (x1, y1), (x2, y2) = first, second + if x1 == x2: + return point_double(curve, first) if (y1 + y2) % curve.p else None + slope = (y2 - y1) * pow(x2 - x1, -1, curve.p) % curve.p + rx = (slope * slope - x1 - x2) % curve.p + return rx, (slope * (x1 - rx) - y1) % curve.p + + +def scalar_multiply(curve: Curve, k: int, point: Point | None = None) -> Point: + """k * point (default the base point) by double-and-add from the most significant bit; None is the point at + infinity.""" + base = curve.g if point is None else point + result: Point = None + for bit in range(k.bit_length() - 1, -1, -1): + result = point_double(curve, result) + if (k >> bit) & 1: + result = point_add(curve, result, base) + return result + + +def to_bytes(value: int, length: int = 32) -> bytes: + return value.to_bytes(length, "big") + + +def from_bytes(data: bytes) -> int: + return int.from_bytes(data, "big") + + +@dataclass(frozen=True) +class CdhVector: + """NIST CAVP ECC CDH primitive test (KAS_ECC_CDH_PrimitiveTest, P-256 COUNT 0): `d * Q` has the x coordinate + `z`, and `d * G` is the public key (`qx`, `qy`).""" + + d: int + peer_x: int + peer_y: int + qx: int + qy: int + z: int + + +P256_CAVP_CDH = CdhVector( + d=0x7D7DC5F71EB29DDAF80D6214632EEAE03D9058AF1FB6D22ED80BADB62BC1A534, + peer_x=0x700C48F77F56584C5CC632CA65640DB91B6BACCE3A4DF6B42CE7CC838833D287, + peer_y=0xDB71E509E3FD9B060DDB20BA5C51DCC5948D46FBF640DFE0441782CAB85FA4AC, + qx=0xEAD218590119E8876B29146FF89CA61770C4EDBBF97D38CE385ED281D8A6B230, + qy=0x28AF61281FD35E2FA7002523ACC85A429CB06EE6648325389F59EDFCE1405141, + z=0x46FC62106420FF012E54A434FBDD2D25CCC5852060561E68040DD7778997BD7B, +) diff --git a/validation/host/hal_st_validation/fakes/crypto.py b/validation/host/hal_st_validation/fakes/crypto.py new file mode 100644 index 00000000..9eecb509 --- /dev/null +++ b/validation/host/hal_st_validation/fakes/crypto.py @@ -0,0 +1,155 @@ +"""Fake `rng`, `aes` and `pka` groups: the argument checks of validation/firmware/RngGroup.cpp, AesGroup.cpp and +PkaGroup.cpp in their order, a deterministic RNG (the `prbs` payload pattern from a new seed per call, so two reads +differ), and AES/PKA results from `crypto_ref`. + +`variant=hsem` exists on STM32WB55 only; its `hsi48=` is the fake clock group's HSI48 state, which the driver leaves +as it found it (it switches HSI48 on for the read only when it was off). `lock5=1` with HSI48 off answers +`ERR failed`, as the firmware refuses what would trip the driver's assertion.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from .. import crypto_ref, patterns, rngstats +from ..groups.crypto import VARIANTS +from .base import FakeGroup, _choice, _fail, _flag, _number, _shape + +if TYPE_CHECKING: + from ..fake_firmware import FakeFirmware + +_READ_MAX = 128 +_STATS_MIN = 16 +_STATS_MAX = 65536 +_AES_DATA_MAX = 80 +_AES_BLOCK = 16 +_OPERAND = 32 +_COMPARE_MIN = 4 +_COMPARE_MAX = 60 +# A P-256 scalar multiplication on the PKA takes some ten milliseconds; the fake reports a fixed duration. +_MULTIPLY_US = 20000 +_HEX_DIGITS = frozenset("0123456789abcdefABCDEF") + + +class FakeRng(FakeGroup): + prefix = "rng" + + def __init__(self, fw: FakeFirmware) -> None: + super().__init__(fw) + self.boot() + + def boot(self) -> None: + self.seed = 0 + + def _generate(self, length: int) -> bytes: + self.seed = (self.seed + 0x9E3779B9) & patterns.SEED_MAX + return patterns.generate(length, "prbs", self.seed) + + def _check(self, variant: str, lock5: bool) -> None: + if (variant == "hsem" or lock5) and self.fw.family != "stm32wb55": + _fail("unsupported") + if lock5 and variant != "hsem": + _fail("usage") + + def _hsi48(self) -> int: + clock = self.fw.group("clock") + return int(getattr(clock, "hsi48", 1)) + + def cmd_read(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("variant", "lock5")) + length = _number(args[0], 1, _READ_MAX) + variant = _choice(options, "variant", VARIANTS, "sync") + lock5 = _flag(options, "lock5") + self._check(variant, lock5) + if lock5 and not self._hsi48(): + _fail("failed") + line = f"OK data={self._generate(length).hex()}" + if variant == "hsem": + line += f" hsi48={self._hsi48()}" + return line + + def cmd_stats(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("variant",)) + length = _number(args[0], _STATS_MIN, _STATS_MAX) + variant = _choice(options, "variant", VARIANTS, "sync") + self._check(variant, False) + stats = rngstats.byte_stats(self._generate(length)) + return f"OK n={stats.n} ones={stats.ones} runs={stats.runs} chisq={stats.chisq_x1000} crc={stats.crc:08x} us={length // 4 + 50}" + + +def _blocks(text: str, capacity: int) -> bytes: + """`ParseBlocks` of AesGroup.cpp: every malformed, empty, too long or partial-block hex is `ERR usage`.""" + if text == "-" or not text or len(text) % 2 or len(text) // 2 > capacity or not set(text) <= _HEX_DIGITS: + _fail("usage") + data = bytes.fromhex(text) + if len(data) % _AES_BLOCK: + _fail("usage") + return data + + +class FakeAes(FakeGroup): + prefix = "aes" + + def _run(self, args: list[str], options: dict[str, str], decrypt: bool) -> str: + _shape(args, options, 2, 2, ("swap",)) + key = _blocks(args[0], _AES_BLOCK) + data = _blocks(args[1], _AES_DATA_MAX) + swap = _choice(options, "swap", crypto_ref.SWAPS, "byte") + return f"OK data={crypto_ref.aes_stm(key, data, cast(crypto_ref.Swap, swap), decrypt).hex()}" + + def cmd_enc(self, args: list[str], options: dict[str, str]) -> str: + return self._run(args, options, False) + + def cmd_dec(self, args: list[str], options: dict[str, str]) -> str: + return self._run(args, options, True) + + +def _hex_size(text: str | None) -> int | None: + """`HexSize` of PkaGroup.cpp: the byte count of a non-empty, even, all-hex operand, else None (`ERR usage`).""" + if not text or len(text) % 2 or not set(text) <= _HEX_DIGITS: + return None + return len(text) // 2 + + +def _operand(text: str) -> int: + return int(text, 16) + + +class FakePka(FakeGroup): + prefix = "pka" + + def cmd_mul(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 0, 0, ("k", "x", "y")) + k, x, y = options.get("k"), options.get("x"), options.get("y") + sizes = [_hex_size(text) for text in (k, x, y) if text is not None] + if (x is None) != (y is None) or None in sizes: + _fail("usage") + if any(size is not None and size > _OPERAND for size in sizes): + _fail("range") + curve = crypto_ref.P256 + point = None if x is None or y is None else (_operand(x), _operand(y)) + result = crypto_ref.scalar_multiply(curve, 1 if k is None else _operand(k), point) + rx, ry = result if result is not None else (0, 0) + return f"OK x={crypto_ref.to_bytes(rx).hex()} y={crypto_ref.to_bytes(ry).hex()} us={_MULTIPLY_US}" + + def cmd_check(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 0, 0, ("x", "y")) + x, y = options.get("x"), options.get("y") + sizes = [_hex_size(x), _hex_size(y)] + if None in sizes: + _fail("usage") + if any(size is not None and size > _OPERAND for size in sizes): + _fail("range") + assert x is not None and y is not None + return f"OK on={int(crypto_ref.P256.on_curve((_operand(x), _operand(y))))}" + + def cmd_cmp(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 0, 0, ("a", "b")) + a, b = options.get("a"), options.get("b") + size, size_b = _hex_size(a), _hex_size(b) + if size is None or size != size_b: + _fail("usage") + assert size is not None and a is not None and b is not None + if not _COMPARE_MIN <= size <= _COMPARE_MAX: + _fail("range") + first, second = _operand(a), _operand(b) + return "OK cmp=" + ("eq" if first == second else "gt" if first > second else "lt") diff --git a/validation/host/hal_st_validation/groups/crypto.py b/validation/host/hal_st_validation/groups/crypto.py new file mode 100644 index 00000000..86a87d65 --- /dev/null +++ b/validation/host/hal_st_validation/groups/crypto.py @@ -0,0 +1,95 @@ +"""Crypto groups of validation/PROTOCOL.md: `rng` (`hal::SynchronousRandomDataGeneratorStm`, `variant=async` +`hal::RandomDataGeneratorStm`, `variant=hsem` `hal::SynchronousSynchronizedRandomDataGeneratorStm` on STM32WB55), +`aes` (`hal::SynchronousAes128EcbStm`) and `pka` (`hal::PkaStm` on `services::secp256r1`). All three are stateless: +nothing to close. The reference models are `crypto_ref` and `rngstats`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from .base import Group + +__all__ = ["GROUPS", "VARIANTS", "Aes", "Pka", "PkaProduct", "Rng", "RngRead", "RngStats"] + +Variant = Literal["sync", "async", "hsem"] +Swap = Literal["none", "half", "byte", "bit"] + +VARIANTS: tuple[Variant, ...] = ("sync", "async", "hsem") + + +@dataclass(frozen=True) +class RngRead: + data: bytes + # `LL_RCC_HSI48_IsReady` after a `variant=hsem` read, else None. + hsi48: int | None + raw: str + + +@dataclass(frozen=True) +class RngStats: + n: int + ones: int + runs: int + chisq_x1000: int + crc: int + us: int + raw: str + + +class Rng(Group): + prefix = "rng" + + def read(self, length: int, variant: Variant | None = None, lock5: bool | None = None) -> RngRead: + response = self._cmd("read", length, variant=variant, lock5=lock5) + hsi48 = response.get("hsi48") + return RngRead(data=response.as_bytes("data"), hsi48=None if hsi48 is None else int(hsi48), raw=response.raw) + + def stats(self, length: int, variant: Variant | None = None, cmd_timeout: float | None = None) -> RngStats: + response = self._cmd("stats", length, variant=variant, cmd_timeout=cmd_timeout) + return RngStats( + n=response.as_int("n"), + ones=response.as_int("ones"), + runs=response.as_int("runs"), + chisq_x1000=response.as_int("chisq"), + crc=int(response["crc"], 16), + us=response.as_int("us"), + raw=response.raw, + ) + + +class Aes(Group): + prefix = "aes" + + def enc(self, key: bytes, data: bytes, swap: Swap | None = None) -> bytes: + return self._cmd("enc", key, data, swap=swap).as_bytes("data") + + def dec(self, key: bytes, data: bytes, swap: Swap | None = None) -> bytes: + return self._cmd("dec", key, data, swap=swap).as_bytes("data") + + +@dataclass(frozen=True) +class PkaProduct: + x: bytes + y: bytes + us: int + + +class Pka(Group): + """Operands are big-endian byte strings, 1-32 bytes (the firmware pads them to 32), or `cmp` 4-60 bytes each.""" + + prefix = "pka" + + def mul(self, k: bytes | None = None, x: bytes | None = None, y: bytes | None = None, cmd_timeout: float | None = None) -> PkaProduct: + response = self._cmd("mul", k=k, x=x, y=y, cmd_timeout=cmd_timeout) + return PkaProduct(x=response.as_bytes("x"), y=response.as_bytes("y"), us=response.as_int("us")) + + def check(self, x: bytes, y: bytes) -> bool: + return self._cmd("check", x=x, y=y).as_bool("on") + + def cmp(self, a: bytes, b: bytes) -> str: + return self._cmd("cmp", a=a, b=b)["cmp"] + + +GROUPS: dict[str, type[Group]] = {"rng": Rng, "aes": Aes, "pka": Pka} diff --git a/validation/host/hal_st_validation/rngstats.py b/validation/host/hal_st_validation/rngstats.py new file mode 100644 index 00000000..e3667511 --- /dev/null +++ b/validation/host/hal_st_validation/rngstats.py @@ -0,0 +1,96 @@ +"""Statistics of `rng.stats` (validation/PROTOCOL.md) and the pass bounds the RNG tests apply to them. + +The firmware reports, over `n` generated bytes read as one bit stream (each byte most significant bit first): +`ones` (set bits), `runs` (bit transitions + 1), `chisq` (the chi-square of the byte histogram against a uniform +distribution, 255 degrees of freedom, times 1000 rounded down and saturated at 2^32 - 1) and `crc` (zlib CRC-32). +`byte_stats` computes the same from the data, for the fake firmware and to check the firmware's definitions. + +Bounds: monobit |ones - N/2| < 4 sigma with N = 8n bits and sigma = sqrt(N)/2 (NIST SP 800-22 2.1 at p ~ 6e-5); the +byte chi-square below 330.5 (the 0.999 quantile of chi-square with 255 degrees of freedom, p > 0.001); the runs +test of NIST SP 800-22 2.3 (prerequisite |pi - 1/2| < 2/sqrt(N)) with |runs - 2N pi (1 - pi)| below 4 of its +standard deviations 2 sqrt(N) pi (1 - pi). +""" + +from __future__ import annotations + +import math +import zlib +from dataclasses import dataclass + +__all__ = [ + "CHI_SQUARE_LIMIT", + "SIGMAS", + "ByteStats", + "byte_stats", + "chi_square_ok", + "chi_square_x1000", + "failures", + "monobit_ok", + "runs_ok", +] + +CHI_SQUARE_LIMIT = 330.5 +SIGMAS = 4.0 +_UINT32_MAX = 0xFFFFFFFF + + +@dataclass(frozen=True) +class ByteStats: + n: int + ones: int + runs: int + chisq_x1000: int + crc: int + + +def chi_square_x1000(histogram: list[int], n: int) -> int: + """sum((c - n/256)^2 / (n/256)) * 1000, in the firmware's integer arithmetic.""" + total = sum((count * 256 - n) ** 2 for count in histogram) + return min(total * 1000 // (256 * n), _UINT32_MAX) + + +def byte_stats(data: bytes) -> ByteStats: + histogram = [0] * 256 + for byte in data: + histogram[byte] += 1 + bits = 8 * len(data) + stream = int.from_bytes(data, "big") + # Bit i of stream ^ (stream >> 1) is b(i) ^ b(i+1); the top one compares the first bit with nothing. + transitions = ((stream ^ (stream >> 1)) & ((1 << max(bits - 1, 0)) - 1)).bit_count() + return ByteStats( + n=len(data), + ones=stream.bit_count(), + runs=transitions + 1, + chisq_x1000=chi_square_x1000(histogram, len(data)) if data else 0, + crc=zlib.crc32(data), + ) + + +def monobit_ok(n: int, ones: int, sigmas: float = SIGMAS) -> bool: + bits = 8 * n + return abs(ones - bits / 2) < sigmas * math.sqrt(bits) / 2 + + +def chi_square_ok(chisq_x1000: int, limit: float = CHI_SQUARE_LIMIT) -> bool: + return chisq_x1000 < limit * 1000 + + +def runs_ok(n: int, ones: int, runs: int, sigmas: float = SIGMAS) -> bool: + bits = 8 * n + pi = ones / bits + if abs(pi - 0.5) >= 2 / math.sqrt(bits): + return False + spread = 2 * math.sqrt(bits) * pi * (1 - pi) + return abs(runs - 2 * bits * pi * (1 - pi)) < sigmas * spread + + +def failures(n: int, ones: int, runs: int, chisq_x1000: int) -> list[str]: + """The names of the tests the counts fail (empty when random enough).""" + failed = [] + if not monobit_ok(n, ones): + failed.append(f"monobit: {ones} ones in {8 * n} bits") + if not chi_square_ok(chisq_x1000): + failed.append(f"chi-square: {chisq_x1000 / 1000} >= {CHI_SQUARE_LIMIT}") + if not runs_ok(n, ones, runs): + failed.append(f"runs: {runs} runs, {ones} ones in {8 * n} bits") + return failed diff --git a/validation/host/tests/hil/test_aes.py b/validation/host/tests/hil/test_aes.py new file mode 100644 index 00000000..092b6cf1 --- /dev/null +++ b/validation/host/tests/hil/test_aes.py @@ -0,0 +1,114 @@ +"""AES-128 ECB (`hal::SynchronousAes128EcbStm`) through the `aes` group: the FIPS-197 C.1 and SP 800-38A F.1.1 +known answers, decryption, one to five blocks per command, key changes between commands, and every data swapping mode +(`swap=none|half|byte|bit`, AES_CR.DATATYPE) against the model of `crypto_ref.aes_stm`. No wiring. +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import crypto_ref, patterns + +BLOCK = 16 + + +@pytest.fixture +def aes_cfg(board_cfg): + return board_cfg.param("aes") + + +def hex_bytes(value): + """A hex string, or a list of them (the board file splits long vectors into blocks).""" + return bytes.fromhex(value if isinstance(value, str) else "".join(value)) + + +def unpack(vector): + return hex_bytes(vector["key"]), hex_bytes(vector["plaintext"]), hex_bytes(vector["ciphertext"]) + + +def expect_reason(fw, line, reason): + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line + + +@pytest.mark.board_params("vector", "aes.vectors") +def test_known_answer(fw, vector): + key, plaintext, ciphertext = unpack(vector) + assert crypto_ref.aes_ecb_encrypt(key, plaintext) == ciphertext, "the vector itself" + assert fw.aes.enc(key, plaintext) == ciphertext + assert fw.aes.enc(key, plaintext, swap="byte") == ciphertext, "swap=byte is the default" + assert fw.aes.dec(key, ciphertext) == plaintext + + +@pytest.mark.board_params("vector", "aes.vectors") +def test_block_by_block(fw, vector): + """ECB: each block on its own gives the same as all blocks in one command.""" + key, plaintext, ciphertext = unpack(vector) + for offset in range(0, len(plaintext), BLOCK): + assert fw.aes.enc(key, plaintext[offset : offset + BLOCK]) == ciphertext[offset : offset + BLOCK], offset + + +@pytest.mark.board_params("length", "aes.lengths") +def test_round_trip(fw, length): + key = patterns.generate(BLOCK, "prbs", 7) + data = patterns.generate(length, "prbs", length) + ciphertext = fw.aes.enc(key, data) + assert ciphertext == crypto_ref.aes_ecb_encrypt(key, data) + assert fw.aes.dec(key, ciphertext) == data + + +def test_key_change(fw): + """The key of one command does not leak into the next.""" + data = patterns.generate(2 * BLOCK, "inc", 0) + first = patterns.generate(BLOCK, "prbs", 1) + second = patterns.generate(BLOCK, "prbs", 2) + with_first = fw.aes.enc(first, data) + with_second = fw.aes.enc(second, data) + assert with_first == crypto_ref.aes_ecb_encrypt(first, data) + assert with_second == crypto_ref.aes_ecb_encrypt(second, data) + assert with_first != with_second + assert fw.aes.enc(first, data) == with_first + assert fw.aes.dec(second, with_second) == data + + +@pytest.mark.board_params("swap", "aes.swaps") +def test_swap(fw, swap): + """Encryption and decryption with the data swapping of AES_CR.DATATYPE (model: `crypto_ref.aes_stm`).""" + key = patterns.generate(BLOCK, "prbs", 3) + data = patterns.generate(3 * BLOCK, "prbs", 4) + ciphertext = fw.aes.enc(key, data, swap=swap) + assert ciphertext == crypto_ref.aes_stm(key, data, swap), swap + assert fw.aes.dec(key, ciphertext, swap=swap) == data + assert fw.aes.dec(key, data, swap=swap) == crypto_ref.aes_stm(key, data, swap, decrypt=True) + + +KEY = "000102030405060708090a0b0c0d0e0f" +DATA = "00112233445566778899aabbccddeeff" + + +@pytest.mark.parametrize( + ("line", "reason"), + [ + ("aes.enc", "usage"), + (f"aes.enc {KEY}", "usage"), + (f"aes.enc {KEY} {DATA} {DATA}", "usage"), + (f"aes.enc {KEY} {DATA} mode=cbc", "usage"), + (f"aes.enc {KEY[:-2]} {DATA}", "usage"), + (f"aes.enc {KEY}00 {DATA}", "usage"), + (f"aes.enc {KEY}{KEY} {DATA}", "usage"), + (f"aes.enc - {DATA}", "usage"), + (f"aes.enc {KEY} -", "usage"), + (f"aes.enc {KEY} {DATA[:-2]}", "usage"), + (f"aes.enc {KEY} {DATA}00", "usage"), + (f"aes.enc {KEY} {DATA[:-1]}", "usage"), + (f"aes.enc {KEY} {DATA[:-2]}zz", "usage"), + (f"aes.enc {KEY} {DATA * 6}", "usage"), + (f"aes.enc {KEY} {DATA} swap=word", "usage"), + (f"aes.dec {KEY} {DATA * 6}", "usage"), + (f"aes.dec {KEY} {DATA} swap=nibble", "usage"), + ], +) +def test_errors(fw, line, reason): + expect_reason(fw, line, reason) diff --git a/validation/host/tests/hil/test_pka.py b/validation/host/tests/hil/test_pka.py new file mode 100644 index 00000000..56c2c970 --- /dev/null +++ b/validation/host/tests/hil/test_pka.py @@ -0,0 +1,124 @@ +"""Public key accelerator (`hal::PkaStm` on `services::secp256r1`) through the `pka` group: scalar multiplication +(multiples of G, the CAVP ECC CDH vector, n - 1, operands shorter than 32 bytes padded by the firmware), the point +check on and off the curve, the comparison of 4-60 byte operands and the duration of one multiplication. Results +are checked against `crypto_ref`'s affine P-256 arithmetic. No wiring. +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.crypto_ref import P256, P256_CAVP_CDH, from_bytes, scalar_multiply, to_bytes + + +@pytest.fixture +def pka_cfg(board_cfg): + return board_cfg.param("pka") + + +def expect_reason(fw, line, reason): + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line + + +def as_point(product): + return from_bytes(product.x), from_bytes(product.y) + + +def test_default_is_g(fw): + """Without operands the firmware multiplies G by 1.""" + assert as_point(fw.pka.mul()) == P256.g + + +@pytest.mark.board_params("multiple", "pka.multiples") +def test_multiple_of_g(fw, multiple): + k = bytes.fromhex(multiple["k"]) + expected = (int(multiple["x"], 16), int(multiple["y"], 16)) + assert scalar_multiply(P256, from_bytes(k)) == expected, "the vector itself" + product = fw.pka.mul(k=k) + assert len(product.x) == len(product.y) == 32 + assert as_point(product) == expected + + +def test_short_scalar_is_padded(fw): + """A 1-byte scalar equals the same value in 32 bytes: the firmware left-pads every operand.""" + assert as_point(fw.pka.mul(k=b"\x05")) == as_point(fw.pka.mul(k=to_bytes(5))) == scalar_multiply(P256, 5) + + +def test_order_minus_one(fw): + assert as_point(fw.pka.mul(k=to_bytes(P256.n - 1))) == (P256.gx, P256.p - P256.gy) + + +def test_cavp_cdh(fw): + """NIST CAVP ECC CDH (P-256): d * G is the public key, d * Q has the shared secret as x coordinate.""" + vector = P256_CAVP_CDH + public = fw.pka.mul(k=to_bytes(vector.d)) + assert as_point(public) == (vector.qx, vector.qy) + shared = fw.pka.mul(k=to_bytes(vector.d), x=to_bytes(vector.peer_x), y=to_bytes(vector.peer_y)) + assert from_bytes(shared.x) == vector.z + assert as_point(shared) == scalar_multiply(P256, vector.d, (vector.peer_x, vector.peer_y)) + + +def test_given_point(fw): + """`x`/`y` replace G: 3 * (2G) = 6G.""" + double = scalar_multiply(P256, 2) + assert double is not None + product = fw.pka.mul(k=b"\x03", x=to_bytes(double[0]), y=to_bytes(double[1])) + assert as_point(product) == scalar_multiply(P256, 6) + + +def test_point_check(fw): + double = scalar_multiply(P256, 2) + assert double is not None + assert fw.pka.check(to_bytes(P256.gx), to_bytes(P256.gy)) + assert fw.pka.check(to_bytes(double[0]), to_bytes(double[1])) + assert not fw.pka.check(to_bytes(P256.gx), to_bytes(P256.gy + 1)) + assert not fw.pka.check(b"\x01", b"\x02") + + +@pytest.mark.board_params("length", "pka.compare_lengths") +def test_compare(fw, length): + low = bytes(length - 1) + b"\x01" + high = b"\x01" + bytes(length - 1) + assert fw.pka.cmp(low, high) == "lt" + assert fw.pka.cmp(high, low) == "gt" + assert fw.pka.cmp(high, high) == "eq" + assert fw.pka.cmp(low, low[:-1] + b"\x02") == "lt" + + +def test_duration(fw, pka_cfg): + """One multiplication with a full 256-bit scalar is timed by the firmware (`us`).""" + product = fw.pka.mul(k=to_bytes(P256.n - 2)) + assert 0 < product.us <= pka_cfg["max_us"], product.us + + +OPERAND_33 = "01" * 33 + + +@pytest.mark.parametrize( + ("line", "reason"), + [ + ("pka.mul 1", "usage"), + ("pka.mul z=01", "usage"), + ("pka.mul x=01", "usage"), + ("pka.mul y=01", "usage"), + ("pka.mul k=1", "usage"), + ("pka.mul k=-", "usage"), + ("pka.mul k=zz", "usage"), + ("pka.mul k=01 x=01 y=0", "usage"), + (f"pka.mul k={OPERAND_33}", "range"), + (f"pka.mul x={OPERAND_33} y=01", "range"), + ("pka.check", "usage"), + ("pka.check x=01", "usage"), + ("pka.check x=01 y=02 k=01", "usage"), + (f"pka.check x=01 y={OPERAND_33}", "range"), + ("pka.cmp a=00000001", "usage"), + ("pka.cmp a=00000001 b=0000000001", "usage"), + ("pka.cmp a=0000000g b=00000001", "usage"), + ("pka.cmp a=000001 b=000002", "range"), + ], +) +def test_errors(fw, line, reason): + expect_reason(fw, line, reason) diff --git a/validation/host/tests/hil/test_rng.py b/validation/host/tests/hil/test_rng.py new file mode 100644 index 00000000..3b745a63 --- /dev/null +++ b/validation/host/tests/hil/test_rng.py @@ -0,0 +1,169 @@ +"""Random number generator through the `rng` group: `hal::SynchronousRandomDataGeneratorStm` (`variant=sync`), +`hal::RandomDataGeneratorStm` (`variant=async`, interrupt driven) and on STM32WB55 +`hal::SynchronousSynchronizedRandomDataGeneratorStm` (`variant=hsem`: HSEM semaphore 0 around the read, HSI48 managed +by its `Hsi48Enabler` unless semaphore 5 is locked). + +Every variant returns exactly the requested bytes, two reads differ, and 64 KiB pass the monobit, byte chi-square and +runs tests of `rngstats`. On STM32WB55 the synchronized variant keeps HSI48 (the RNG kernel clock) running when it was +on and switches it off again when it was off (B.9b), takes the locked branch with `lock5=1`, and leaves HSEM semaphores +0 and 5 free (B.9). No wiring. +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import rngstats + + +@pytest.fixture +def rng_cfg(board_cfg): + return board_cfg.param("rng") + + +def expect_reason(fw, line, reason): + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line + + +def assert_semaphores_free(fw, rng_cfg): + """`hsem.status` reads the semaphores without taking them (the hsem group of area-system, when built in).""" + if not hasattr(fw, "hsem"): + return + for semaphore in rng_cfg["semaphores"]: + response = fw.command("hsem.status", semaphore) + assert response.as_int("locked") == 0, f"semaphore {semaphore}: {response.raw}" + + +def needs_clock_group(fw): + if not hasattr(fw, "clock"): + pytest.skip("needs the clock group (clock.hsi48, clock.info)") + + +@pytest.mark.board_params("variant", "rng.variants") +@pytest.mark.board_params("length", "rng.lengths") +def test_read_length(fw, variant, length): + read = fw.rng.read(length, variant=variant) + assert len(read.data) == length, read.raw + assert (read.hsi48 is not None) == (variant == "hsem"), read.raw + + +@pytest.mark.board_params("variant", "rng.variants") +def test_reads_differ(fw, variant): + first = fw.rng.read(16, variant=variant).data + second = fw.rng.read(16, variant=variant).data + assert first != second + assert first not in (bytes(16), b"\xff" * 16) + + +@pytest.mark.board_params("variant", "rng.variants") +def test_statistics(fw, rng_cfg, variant): + """64 KiB generated in 256-byte chunks pass the monobit, byte chi-square and runs bounds of `rngstats`.""" + settings = rng_cfg["stats"] + stats = fw.rng.stats(settings["length"], variant=variant, cmd_timeout=settings["timeout_s"]) + assert stats.n == settings["length"], stats.raw + assert rngstats.failures(stats.n, stats.ones, stats.runs, stats.chisq_x1000) == [], stats.raw + assert stats.us > 0, stats.raw + + +@pytest.mark.board_params("variant", "rng.variants") +def test_statistics_differ(fw, variant): + first = fw.rng.stats(256, variant=variant) + second = fw.rng.stats(256, variant=variant) + assert first.n == second.n == 256 + assert first.crc != second.crc + + +def test_variants_take_turns(fw, rng_cfg): + """Each variant enables and disables the RNG on its own; one after the other they all still deliver.""" + variants = list(rng_cfg["variants"]) + for variant in variants + variants[::-1]: + assert len(fw.rng.read(32, variant=variant).data) == 32, variant + + +@pytest.mark.family("stm32wb55") +def test_hsem_keeps_hsi48(fw, rng_cfg): + """With HSI48 running (the default clock's RNG kernel clock) the synchronized read leaves it on, so the other + variants still have their clock afterwards (B.9b).""" + read = fw.rng.read(16, variant="hsem") + assert read.hsi48 == 1, read.raw + assert len(fw.rng.read(16, variant="sync").data) == 16 + assert len(fw.rng.read(16, variant="async").data) == 16 + if hasattr(fw, "clock"): + assert fw.clock.info().flags["hsi48"] == 1 + assert_semaphores_free(fw, rng_cfg) + + +@pytest.mark.family("stm32wb55") +def test_hsem_restores_hsi48_off(fw, rng_cfg): + """With HSI48 off the synchronized read starts it for the read and switches it off again afterwards.""" + needs_clock_group(fw) + fw.clock.hsi48(False) + try: + read = fw.rng.read(16, variant="hsem") + assert read.hsi48 == 0, read.raw + assert len(read.data) == 16 + assert fw.clock.info().flags["hsi48"] == 0 + finally: + fw.clock.hsi48(True) + assert fw.rng.read(16, variant="hsem").hsi48 == 1 + assert_semaphores_free(fw, rng_cfg) + + +@pytest.mark.family("stm32wb55") +def test_hsem_clock_locked(fw, rng_cfg): + """`lock5=1` holds semaphore 5 (clock configuration) around the read: `Hsi48Enabler` takes its locked branch and + leaves HSI48 alone; both semaphores are free afterwards.""" + first = fw.rng.read(16, variant="hsem", lock5=True) + second = fw.rng.read(16, variant="hsem", lock5=True) + assert first.hsi48 == second.hsi48 == 1, (first.raw, second.raw) + assert first.data != second.data + assert_semaphores_free(fw, rng_cfg) + + +@pytest.mark.family("stm32wb55") +@pytest.mark.parametrize("line", ["rng.read 4 lock5=1", "rng.read 4 variant=async lock5=1"]) +def test_clock_lock_needs_hsem_variant(fw, line): + """`lock5` only applies to the synchronized variant.""" + expect_reason(fw, line, "usage") + + +@pytest.mark.family("stm32wb55") +def test_clock_locked_needs_hsi48(fw, rng_cfg): + """With semaphore 5 locked the driver asserts that HSI48 runs; the firmware refuses that read with HSI48 off.""" + needs_clock_group(fw) + fw.clock.hsi48(False) + try: + expect_reason(fw, "rng.read 4 variant=hsem lock5=1", "failed") + finally: + fw.clock.hsi48(True) + assert_semaphores_free(fw, rng_cfg) + + +@pytest.mark.parametrize( + ("line", "reason"), + [ + ("rng.read", "usage"), + ("rng.read 4 5", "usage"), + ("rng.read x", "usage"), + ("rng.read 4 speed=1", "usage"), + ("rng.read 0", "range"), + ("rng.read 129", "range"), + ("rng.read 4 variant=dma", "usage"), + ("rng.read 4 lock5=2", "range"), + ("rng.stats", "usage"), + ("rng.stats 16 lock5=1", "usage"), + ("rng.stats 15", "range"), + ("rng.stats 65537", "range"), + ("rng.stats 16 variant=x", "usage"), + ], +) +def test_errors(fw, line, reason): + expect_reason(fw, line, reason) + + +@pytest.mark.board_params("line", "rng.unsupported") +def test_unsupported(fw, line): + expect_reason(fw, line, "unsupported") diff --git a/validation/host/tests/unit/test_crypto_ref.py b/validation/host/tests/unit/test_crypto_ref.py new file mode 100644 index 00000000..463b238a --- /dev/null +++ b/validation/host/tests/unit/test_crypto_ref.py @@ -0,0 +1,125 @@ +"""`crypto_ref`: AES-128 against FIPS-197 and SP 800-38A, the data swapping model of the STM32 AES peripheral, and +P-256 arithmetic against known multiples of G and the NIST CAVP ECC CDH vector.""" + +import pytest + +from hal_st_validation import crypto_ref +from hal_st_validation.crypto_ref import P256 + +KEY = bytes(range(16)) + + +@pytest.mark.parametrize("vector", [crypto_ref.AES_FIPS197_C1, crypto_ref.AES_SP800_38A_F11], ids=lambda vector: vector.name) +def test_aes_vectors(vector): + assert crypto_ref.aes_ecb_encrypt(vector.key, vector.plaintext) == vector.ciphertext + assert crypto_ref.aes_ecb_decrypt(vector.key, vector.ciphertext) == vector.plaintext + + +def test_fips197_c1_literal(): + key = bytes.fromhex("000102030405060708090a0b0c0d0e0f") + plaintext = bytes.fromhex("00112233445566778899aabbccddeeff") + assert crypto_ref.aes_encrypt_block(key, plaintext).hex() == "69c4e0d86a7b0430d8cdb78070b4c55a" + + +def test_sbox_known_entries(): + """FIPS-197 Figure 7: S(00) = 63, S(53) = ed, S(ff) = 16; the inverse undoes it.""" + assert (crypto_ref.SBOX[0x00], crypto_ref.SBOX[0x53], crypto_ref.SBOX[0xFF]) == (0x63, 0xED, 0x16) + assert all(crypto_ref.INVERSE_SBOX[crypto_ref.SBOX[value]] == value for value in range(256)) + + +def test_ecb_needs_whole_blocks(): + with pytest.raises(ValueError): + crypto_ref.aes_ecb_encrypt(KEY, bytes(15)) + with pytest.raises(ValueError): + crypto_ref.aes_ecb_encrypt(bytes(15), bytes(16)) + + +def test_swap_words(): + data = bytes([0x01, 0x02, 0x03, 0x04, 0x80, 0x40, 0x20, 0x10]) + assert crypto_ref.swap_words(data, "byte") == data + assert crypto_ref.swap_words(data, "none") == bytes([0x04, 0x03, 0x02, 0x01, 0x10, 0x20, 0x40, 0x80]) + assert crypto_ref.swap_words(data, "half") == bytes([0x02, 0x01, 0x04, 0x03, 0x40, 0x80, 0x10, 0x20]) + assert crypto_ref.swap_words(data, "bit") == bytes([0x80, 0x40, 0xC0, 0x20, 0x01, 0x02, 0x04, 0x08]) + with pytest.raises(ValueError): + crypto_ref.swap_words(bytes(3), "none") + + +@pytest.mark.parametrize("swap", crypto_ref.SWAPS) +def test_swap_is_an_involution_and_round_trips(swap): + data = bytes(range(48)) + assert crypto_ref.swap_words(crypto_ref.swap_words(data, swap), swap) == data + ciphertext = crypto_ref.aes_stm(KEY, data, swap) + assert crypto_ref.aes_stm(KEY, ciphertext, swap, decrypt=True) == data + + +def test_byte_swap_is_standard_aes_and_the_others_differ(): + vector = crypto_ref.AES_FIPS197_C1 + assert crypto_ref.aes_stm(vector.key, vector.plaintext) == vector.ciphertext + others = {crypto_ref.aes_stm(vector.key, vector.plaintext, swap) for swap in ("none", "half", "bit")} + assert vector.ciphertext not in others and len(others) == 3 + + +def test_none_swap_reverses_each_word_around_aes(): + vector = crypto_ref.AES_FIPS197_C1 + swapped = crypto_ref.swap_words(vector.plaintext, "none") + assert crypto_ref.aes_stm(vector.key, swapped, "none") == crypto_ref.swap_words(vector.ciphertext, "none") + + +def test_generator_on_curve(): + assert P256.on_curve(P256.g) + assert not P256.on_curve((P256.gx, P256.gy + 1)) + assert not P256.on_curve((P256.p, 0)) + assert P256.on_curve(None) + + +@pytest.mark.parametrize( + ("k", "x", "y"), + [ + ( + 1, + 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296, + 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5, + ), + ( + 2, + 0x7CF27B188D034F7E8A52380304B51AC3C08969E277F21B35A60B48FC47669978, + 0x07775510DB8ED040293D9AC69F7430DBBA7DADE63CE982299E04B79D227873D1, + ), + ( + 3, + 0x5ECBE4D1A6330A44C8F7EF951D4BF165E6C6B721EFADA985FB41661BC6E7FD6C, + 0x8734640C4998FF7E374B06CE1A64A2ECD82AB036384FB83D9A79B127A27D5032, + ), + ], +) +def test_multiples_of_g(k, x, y): + assert crypto_ref.scalar_multiply(P256, k) == (x, y) + + +def test_group_order(): + assert crypto_ref.scalar_multiply(P256, P256.n) is None + assert crypto_ref.scalar_multiply(P256, P256.n - 1) == (P256.gx, P256.p - P256.gy) + assert crypto_ref.scalar_multiply(P256, 0) is None + + +def test_addition_rules(): + double = crypto_ref.point_double(P256, P256.g) + assert crypto_ref.point_add(P256, P256.g, P256.g) == double + assert crypto_ref.point_add(P256, None, P256.g) == P256.g + assert crypto_ref.point_add(P256, P256.g, None) == P256.g + assert crypto_ref.point_add(P256, P256.g, (P256.gx, P256.p - P256.gy)) is None + assert crypto_ref.point_add(P256, double, P256.g) == crypto_ref.scalar_multiply(P256, 3) + + +def test_cavp_cdh_vector(): + vector = crypto_ref.P256_CAVP_CDH + assert crypto_ref.scalar_multiply(P256, vector.d) == (vector.qx, vector.qy) + shared = crypto_ref.scalar_multiply(P256, vector.d, (vector.peer_x, vector.peer_y)) + assert shared is not None and shared[0] == vector.z + assert P256.on_curve((vector.peer_x, vector.peer_y)) and P256.on_curve(shared) + + +def test_bytes_conversion(): + assert crypto_ref.to_bytes(1) == bytes(31) + b"\x01" + assert crypto_ref.from_bytes(b"\x01\x00") == 256 + assert crypto_ref.to_bytes(0x0102, 4) == b"\x00\x00\x01\x02" diff --git a/validation/host/tests/unit/test_fake_crypto.py b/validation/host/tests/unit/test_fake_crypto.py new file mode 100644 index 00000000..4253017a --- /dev/null +++ b/validation/host/tests/unit/test_fake_crypto.py @@ -0,0 +1,198 @@ +"""The fake `rng`, `aes` and `pka` groups (argument order and reasons of validation/firmware/RngGroup.cpp, +AesGroup.cpp and PkaGroup.cpp; PROTOCOL.md D.16) and the `groups.crypto` wrappers.""" + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal + +from hal_st_validation import crypto_ref, rngstats +from hal_st_validation.crypto_ref import P256 +from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS, FakeFirmware, FakeSerial +from hal_st_validation.firmware import Firmware + +KEY = "000102030405060708090a0b0c0d0e0f" +DATA = "00112233445566778899aabbccddeeff" + + +def make(family="stm32wb55"): + fake = FakeFirmware(family=family) + terminal = FirmwareTerminal(serial=FakeSerial(fake), timeout=0.5) + return terminal, fake, Firmware(terminal, WB55_PINS if family == "stm32wb55" else WBA55_PINS) + + +def reason(terminal, line): + response = terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("rng.read", "usage"), + ("rng.read 4 5", "usage"), + ("rng.read 4 speed=1", "usage"), + ("rng.read x", "usage"), + ("rng.read 0", "range"), + ("rng.read 129", "range"), + ("rng.read 0 variant=x", "range"), + ("rng.read 4 variant=x", "usage"), + ("rng.read 4 lock5=2", "range"), + ("rng.read 4 lock5=1", "usage"), + ("rng.read 4 variant=sync lock5=1", "usage"), + ("rng.read 128 variant=async", "ok"), + ("rng.read 1 variant=hsem lock5=1", "ok"), + ("rng.stats", "usage"), + ("rng.stats 16 lock5=0", "usage"), + ("rng.stats 15", "range"), + ("rng.stats 65537", "range"), + ("rng.stats 16 variant=x", "usage"), + ("rng.stats 16 variant=hsem", "ok"), + ], +) +def test_rng_errors_wb55(line, expected): + terminal, _, _ = make() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("rng.read 4 variant=hsem", "unsupported"), + ("rng.read 4 variant=hsem lock5=1", "unsupported"), + ("rng.read 4 lock5=1", "unsupported"), + ("rng.read 4 lock5=0", "ok"), + ("rng.read 4 lock5=2", "range"), + ("rng.read 0 variant=hsem", "range"), + ("rng.stats 16 variant=hsem", "unsupported"), + ("rng.read 4 variant=async", "ok"), + ], +) +def test_rng_errors_wba55(line, expected): + terminal, _, _ = make("stm32wba55") + assert reason(terminal, line) == expected + + +def test_rng_reads_differ_and_have_the_length(): + _, _, fw = make() + first = fw.rng.read(16) + second = fw.rng.read(16, variant="async") + assert len(first.data) == len(second.data) == 16 and first.data != second.data + assert first.hsi48 is None + assert fw.rng.read(1, variant="hsem").hsi48 == 1 + + +def test_rng_hsem_reports_hsi48_of_the_clock_group(): + _, fake, fw = make() + fw.clock.hsi48(False) + assert fw.rng.read(4, variant="hsem").hsi48 == 0 + with pytest.raises(FirmwareError) as error: + fw.rng.read(4, variant="hsem", lock5=True) + assert error.value.reason == "failed" + fw.clock.hsi48(True) + assert fw.rng.read(4, variant="hsem", lock5=True).hsi48 == 1 + assert fake.group("rng") is not None + + +def test_rng_stats_are_consistent_and_random(): + _, _, fw = make() + stats = fw.rng.stats(65536) + assert stats.n == 65536 and stats.us > 0 + assert rngstats.failures(stats.n, stats.ones, stats.runs, stats.chisq_x1000) == [] + assert fw.rng.stats(256).crc != fw.rng.stats(256).crc + + +def test_rng_restarts_at_boot(): + _, fake, fw = make() + first = fw.rng.read(8).data + fake.boot() + assert fw.rng.read(8).data == first + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("aes.enc", "usage"), + (f"aes.enc {KEY}", "usage"), + (f"aes.enc {KEY} {DATA} {DATA}", "usage"), + (f"aes.enc {KEY} {DATA} mode=ecb", "usage"), + (f"aes.enc {KEY[:-2]} {DATA}", "usage"), + (f"aes.enc {KEY}{KEY} {DATA}", "usage"), + (f"aes.enc - {DATA}", "usage"), + (f"aes.enc {KEY} -", "usage"), + (f"aes.enc {KEY} {DATA[:-1]}", "usage"), + (f"aes.enc {KEY} {DATA[:-2]}", "usage"), + (f"aes.enc {KEY} {DATA[:-2]}zz", "usage"), + (f"aes.enc {KEY} {DATA * 6}", "usage"), + (f"aes.enc {KEY} {DATA * 5}", "ok"), + (f"aes.enc {KEY} {DATA} swap=word", "usage"), + (f"aes.dec {KEY} {DATA} swap=none", "ok"), + ], +) +def test_aes_errors(line, expected): + terminal, _, _ = make() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize("swap", [None, *crypto_ref.SWAPS]) +def test_aes_follows_the_model(swap): + _, _, fw = make("stm32wba55") + vector = crypto_ref.AES_SP800_38A_F11 + ciphertext = fw.aes.enc(vector.key, vector.plaintext, swap=swap) + assert ciphertext == crypto_ref.aes_stm(vector.key, vector.plaintext, swap or "byte") + assert fw.aes.dec(vector.key, ciphertext, swap=swap) == vector.plaintext + if swap in (None, "byte"): + assert ciphertext == vector.ciphertext + + +OPERAND_33 = "01" * 33 + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("pka.mul 1", "usage"), + ("pka.mul z=01", "usage"), + ("pka.mul x=01", "usage"), + ("pka.mul y=01", "usage"), + ("pka.mul k=1", "usage"), + ("pka.mul k=-", "usage"), + ("pka.mul k=zz", "usage"), + (f"pka.mul k={OPERAND_33} x=01", "usage"), + (f"pka.mul k={OPERAND_33}", "range"), + (f"pka.mul k=01 x=01 y={OPERAND_33}", "range"), + ("pka.mul k=02", "ok"), + ("pka.check", "usage"), + ("pka.check x=01", "usage"), + (f"pka.check x={OPERAND_33} y=0", "usage"), + (f"pka.check x={OPERAND_33} y=01", "range"), + ("pka.check x=01 y=02", "ok"), + ("pka.cmp", "usage"), + ("pka.cmp a=00000001", "usage"), + ("pka.cmp a=00000001 b=0000000001", "usage"), + ("pka.cmp a=0001 b=000002", "usage"), + ("pka.cmp a=000001 b=000002", "range"), + ("pka.cmp a=00000001 b=00000002 c=1", "usage"), + (f"pka.cmp a={'01' * 60} b={'02' * 60}", "ok"), + ], +) +def test_pka_errors(line, expected): + terminal, _, _ = make() + assert reason(terminal, line) == expected + + +def test_pka_results(): + _, _, fw = make() + default = fw.pka.mul() + assert (crypto_ref.from_bytes(default.x), crypto_ref.from_bytes(default.y)) == P256.g + product = fw.pka.mul(k=b"\x02") + assert (crypto_ref.from_bytes(product.x), crypto_ref.from_bytes(product.y)) == crypto_ref.scalar_multiply(P256, 2) + assert len(product.x) == 32 and product.us > 0 + assert fw.pka.check(crypto_ref.to_bytes(P256.gx), crypto_ref.to_bytes(P256.gy)) + assert not fw.pka.check(b"\x01", b"\x02") + assert fw.pka.cmp(b"\x00\x00\x00\x01", b"\x00\x00\x00\x02") == "lt" + assert fw.pka.cmp(b"\x00\x00\x01\x00", b"\x00\x00\x00\x02") == "gt" + assert fw.pka.cmp(bytes(8), bytes(8)) == "eq" + + +def test_crypto_groups_are_attached(): + _, _, fw = make() + assert {"rng", "aes", "pka"} <= {name for name in vars(fw)} diff --git a/validation/host/tests/unit/test_rngstats.py b/validation/host/tests/unit/test_rngstats.py new file mode 100644 index 00000000..78628405 --- /dev/null +++ b/validation/host/tests/unit/test_rngstats.py @@ -0,0 +1,75 @@ +"""`rngstats`: the `rng.stats` counts (most significant bit first, chi-square times 1000 in integer arithmetic, +zlib CRC-32) and the monobit, chi-square and runs bounds.""" + +import math +import random +import zlib + +import pytest + +from hal_st_validation import patterns, rngstats + + +def test_counts_of_small_streams(): + stats = rngstats.byte_stats(b"\x00\xff") + assert (stats.n, stats.ones, stats.runs) == (2, 8, 2) + assert rngstats.byte_stats(b"\x55").runs == 8 + assert rngstats.byte_stats(b"\x80\x01").runs == 3 + assert rngstats.byte_stats(b"\x01\x80").runs == 3 + assert rngstats.byte_stats(b"\x01").runs == 2 + assert rngstats.byte_stats(b"\x00").runs == 1 + + +def test_counts_match_a_bitwise_walk(): + data = bytes(random.Random(5).getrandbits(8) for _ in range(300)) + bits = [(byte >> (7 - position)) & 1 for byte in data for position in range(8)] + stats = rngstats.byte_stats(data) + assert stats.ones == sum(bits) + assert stats.runs == 1 + sum(1 for a, b in zip(bits, bits[1:]) if a != b) + assert stats.crc == zlib.crc32(data) + + +def test_chi_square(): + """A flat histogram is 0; all bytes alike is 255 n; the integer form truncates sum((c - E)^2 / E) * 1000.""" + assert rngstats.byte_stats(bytes(range(256)) * 4).chisq_x1000 == 0 + assert rngstats.byte_stats(bytes(64)).chisq_x1000 == 255 * 64 * 1000 + data = bytes(random.Random(1).getrandbits(8) for _ in range(4096)) + expected = 4096 / 256 + exact = sum((data.count(value) - expected) ** 2 / expected for value in range(256)) + assert rngstats.byte_stats(data).chisq_x1000 == math.floor(exact * 1000) + + +def test_chi_square_saturates_like_the_firmware(): + assert rngstats.byte_stats(bytes(65536)).chisq_x1000 == 0xFFFFFFFF + + +def test_random_data_passes(): + for seed in (1, 2, 3): + data = patterns.generate(65536, "prbs", seed) + stats = rngstats.byte_stats(data) + assert rngstats.failures(stats.n, stats.ones, stats.runs, stats.chisq_x1000) == [], seed + + +@pytest.mark.parametrize( + ("data", "failed"), + [ + (bytes([0x0F, 0xFF]) * 4096, "monobit"), + (bytes([0x55, 0xAA]) * 4096, "chi-square"), + (bytes([0x0F]) * 8192, "runs"), + ], +) +def test_biased_data_fails(data, failed): + stats = rngstats.byte_stats(data) + assert any(message.startswith(failed) for message in rngstats.failures(stats.n, stats.ones, stats.runs, stats.chisq_x1000)) + + +def test_bounds(): + bits = 8 * 65536 + sigma = math.sqrt(bits) / 2 + assert rngstats.monobit_ok(65536, bits // 2 + int(3.9 * sigma)) + assert not rngstats.monobit_ok(65536, bits // 2 + int(4.1 * sigma)) + assert rngstats.chi_square_ok(330499) + assert not rngstats.chi_square_ok(330500) + assert rngstats.runs_ok(65536, bits // 2, bits // 2) + assert not rngstats.runs_ok(65536, bits // 2, bits // 2 + int(4.1 * math.sqrt(bits) / 2)) + assert not rngstats.runs_ok(65536, bits // 2 + bits // 100, bits // 2) From 684bad1c4b17d74587cfccd65f997452c860be3d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:41:14 +0000 Subject: [PATCH 45/57] feat(validation): validate flash, HSEM, backup RAM and low-power drivers Add the flash group for the internal flash drivers over a scratch region of absolute pages 64-127: variant=sync builds SynchronousFlash(Homogeneous)InternalStm, variant=async Flash(Homogeneous)InternalStm and, on STM32WB55, variant=coord FlashCoordinatedWithWirelessStack over the async driver; layout=homogeneous has one sector per page, layout=table a sector-size table of single pages followed by the page pattern 1, 1, 2, 4 twice. Erases and writes are accepted from the image end page on, so even an erase that takes the sector index for the absolute page cannot reach the running image, and a write over a flash word that is not erased answers ERR failed instead of tripping the driver's assertion. Reads stream any length as hex or CRC. flash.stack starting keeps a coordinated driver whose steps wait until stopped/fus (FirmwareUpgradeServicesReady); a step completing after its ERR timeout prints EVT flash. The coordinated driver borrows the WWDG from the watchdog factory: the started one, or an unstarted one that only has its clock and interrupt; wdt.start answers ERR busy while an unstarted one is borrowed. It holds HSEM 0 of the resource allocation against hsem.lock, as both rely on the HSEM interrupt. Add the hsem group (STM32WB55): two-step take/release per process with an optional EMIL hold timer, the lock state read from R (never RLR), hsem.lock over SynchronousHardwareSemaphoreStm with the scaffold timer TIM17 releasing a process-1 lock from its interrupt after hold us, and hsem.mine over IsLockedByCurrentCore. Add the bkp group over one BackupRamStm used as hal::BackupRam (info, write, read, fill, check), and the lpm group: LowPowerModeStm entered with PRIMASK set and every NVIC interrupt but the wake line's EXTI and TIM17 disabled and the SysTick tick off; a marker pin is low while the core sleeps, TIM17 counts the time asleep in 1 us ticks (CYCCNT stops in Sleep) and ends the window after timeout ms. Host: groups/system_ext.py (fw.flash, fw.hsem, fw.bkp, fw.lpm, the table layout rule, flash words, the bkp fill), fakes/system_ext.py (flash memory with page erase, HSEM state, backup words that survive reset, lpm waking at once, and wdt.start refused while the coordinated flash borrows the watchdog), test_flash.py (geometry, the region page of every erase per variant and layout, write shapes on distinct words, the coordinated driver against HSEM 7, the stack hold and the shared watchdog), test_hsem.py, test_backup_ram.py, test_low_power.py and the board YAML for them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- validation/PROTOCOL.md | 72 ++ validation/README.md | 17 + validation/firmware/BackupRamGroup.cpp | 138 ++++ validation/firmware/BackupRamGroup.hpp | 32 + validation/firmware/CMakeLists.txt | 8 + validation/firmware/FlashGroup.cpp | 620 ++++++++++++++++++ validation/firmware/FlashGroup.hpp | 131 ++++ validation/firmware/HsemGroup.cpp | 210 ++++++ validation/firmware/HsemGroup.hpp | 45 ++ validation/firmware/LowPowerGroup.cpp | 238 +++++++ validation/firmware/LowPowerGroup.hpp | 52 ++ validation/firmware/Main.cpp | 11 + validation/firmware/UnsupportedGroups.cpp | 8 +- validation/firmware/WatchDogFactory.cpp | 19 + validation/firmware/WatchDogFactory.hpp | 4 + validation/host/boards/nucleo_wb55rg.yaml | 54 ++ validation/host/boards/nucleo_wba55cg.yaml | 34 + .../hal_st_validation/fakes/system_ext.py | 422 ++++++++++++ .../hal_st_validation/groups/system_ext.py | 274 ++++++++ validation/host/tests/hil/test_backup_ram.py | 65 ++ validation/host/tests/hil/test_flash.py | 314 +++++++++ validation/host/tests/hil/test_hsem.py | 119 ++++ validation/host/tests/hil/test_low_power.py | 117 ++++ .../host/tests/unit/test_fake_system_ext.py | 326 +++++++++ 24 files changed, 3329 insertions(+), 1 deletion(-) create mode 100644 validation/firmware/BackupRamGroup.cpp create mode 100644 validation/firmware/BackupRamGroup.hpp create mode 100644 validation/firmware/FlashGroup.cpp create mode 100644 validation/firmware/FlashGroup.hpp create mode 100644 validation/firmware/HsemGroup.cpp create mode 100644 validation/firmware/HsemGroup.hpp create mode 100644 validation/firmware/LowPowerGroup.cpp create mode 100644 validation/firmware/LowPowerGroup.hpp create mode 100644 validation/host/hal_st_validation/fakes/system_ext.py create mode 100644 validation/host/hal_st_validation/groups/system_ext.py create mode 100644 validation/host/tests/hil/test_backup_ram.py create mode 100644 validation/host/tests/hil/test_flash.py create mode 100644 validation/host/tests/hil/test_hsem.py create mode 100644 validation/host/tests/hil/test_low_power.py create mode 100644 validation/host/tests/unit/test_fake_system_ext.py diff --git a/validation/PROTOCOL.md b/validation/PROTOCOL.md index 8aca6455..2ebade80 100644 --- a/validation/PROTOCOL.md +++ b/validation/PROTOCOL.md @@ -292,6 +292,8 @@ Each command builds its drivers, holds the ADC (and for `ain.burst` TIM2 and the - with `feed=auto` (default) the firmware refreshes on every early warning; the watchdog resets the board one counter tick after a warning that is not answered - `pin` is driven low and toggled in the early-warning interrupt, so its period can be measured; it stays claimed until reset - a started watchdog cannot be stopped: it runs until reset + - the coordinated flash driver (`flash ... variant=coord`, `flash.stack starting`, STM32WB55) borrows the WWDG to refresh it around the steps it runs with the interrupts masked: a running watchdog keeps running, an unstarted one is created for the borrow + - while an unstarted watchdog is borrowed, `wdt.start` returns `ERR busy` (after the argument checks) until the driver gives it back - `wdt.feed ` → `OK` - Early warning: `EVT wdt index=0 warning=`. After a watchdog reset the next `EVT boot` reports `reset=wwdg`. @@ -393,6 +395,76 @@ Not a hal-st driver (hal-st has no I2C slave): the other end of the bus for the - The answer comes from the driver's completion (PKA interrupt, dispatched to the event loop), `ERR timeout` after 5 s. One operation runs at a time: until its timeout another `pka` command returns `ERR busy`; after an `ERR timeout` the next one starts a new operation - The firmware builds one `PkaStm` on the first `pka` command and keeps it: its constructor waits for the PKA to initialise, on STM32WBA55 from the RNG kernel clock +## Internal flash (`hal::FlashHomogeneousInternalStm`, `hal::FlashInternalStm`, their `hal::Synchronous*` twins, on STM32WB55 `hal::FlashCoordinatedWithWirelessStack`) + +- The commands work on a scratch region of the flash, absolute pages 64-127 on both boards; addresses are relative to the region: + - STM32WB55: 0x08040000-0x0807FFFF in 4 KB pages, below the 512 KB the linker script gives the firmware and below the secure area of the wireless stack + - STM32WBA55: 0x08080000-0x080FFFFF in 8 KB pages +- `variant=sync|async|coord` (default `sync`) selects the driver; each command builds its driver and destroys it when done: + - `sync`: the `Synchronous*InternalStm` classes + - `async`: the `hal::Flash` classes (completion scheduled on the event loop) + - `coord` (STM32WB55; `ERR unsupported` on STM32WBA55): `FlashCoordinatedWithWirelessStack` over the async driver +- `layout=homogeneous|table` (default `homogeneous`) selects the sectors: + - `homogeneous`: one sector per page (`Flash*HomogeneousInternalStm`) + - `table`: a sector-size table (`FlashInternalStm`/`SynchronousFlashInternalStm`) of single pages followed by the page pattern 1, 1, 2, 4 twice; over 64 pages that is 56 sectors, of which sectors 48-55 have 1, 1, 2, 4, 1, 1, 2, 4 pages +- `flash.info [variant=] [layout=]` → `OK base=<0x........> sectors= size= first= image= layout=` + - `image` is the first absolute page past the running image (`_sidata` plus the size of `.data`) + - `first` is the first sector `flash.erase` and `flash.write` accept, `min(image, sectors)`. Every sector starts at or past the page of its index, so even an erase that took the sector index for the absolute page (as `EraseSectors` did on STM32WB/WBA before its fix) cannot reach the running image + - when the image reaches page 64, every `flash.*` command returns `ERR unsupported` +- `flash.erase [variant=] [layout=]` → `OK us=`: erases sectors `first` up to `end` (exclusive) + - `first` below the accepted first sector, `first >= end` or `end` past the last sector return `ERR range` + - `us` is the duration (CYCCNT); with `variant=sync` it covers the blocking erase +- `flash.write
[len=] [pattern=inc|const|prbs] [seed=] [variant=] [layout=]` → `OK us=` + - the payload is the hex bytes or, with `-` and `len=1..512`, the firmware-generated pattern of the payload convention (`inc` default, `seed` default 0); an empty payload, `len` with hex, or `pattern`/`seed` without `len` return `ERR usage` + - a payload outside the region or starting before the first accepted sector returns `ERR range` + - a write that touches a flash word (64 bits on STM32WB55, 128 bits on STM32WBA55) that is not erased returns `ERR failed`: the drivers program whole words (padding with 0xFF), and programming a word twice between erases fails their assertion +- `flash.read
[out=hex|crc] [variant=] [layout=]` → `OK data=` or `OK len= crc=<8 hex digits>` + - anywhere in the region, `len` from 1 up to the region size; `out=hex` (default) up to 128 bytes, else `ERR range` +- `flash.stack [layout=]` → `OK` (STM32WB55) + - `starting` builds a coordinated driver of `layout` that stays until `stopped` or `fus`, and calls `WirelessStackStarting()`: its steps wait; `starting` while one is held returns `ERR busy` + - `stopped` and `fus` both call `FirmwareUpgradeServicesReady()`, the driver's only way out of `starting`, which runs a waiting step; the driver is destroyed once idle. Without a held driver they answer `OK` +- Coordinated steps: + - each step takes HSEM 7 (CPU2's flash semaphore; the tests hold it with `hsem.take 7 procid=

`) with the interrupts masked and the watchdog refreshed + - while HSEM 7 is held or the stack is starting, a step waits for the HSEM interrupt +- Timeouts: `flash.erase` answers `ERR timeout` after 10 s, `flash.write` and `flash.read` after 2 s; an operation that completes after its `ERR timeout` prints `EVT flash op= us=` +- Busy rules: + - while an operation is in flight every flash command returns `ERR busy`, except `flash.read` with `variant=sync` (a plain read), `flash.info` and `flash.stack stopped|fus`; a `flash.write` checks this before its payload + - an operation stays in flight until it completes, also after its `ERR timeout`: a coordinated step waiting for HSEM 7 runs once the semaphore is released (`hsem.release 7 procid=

`), one held by `flash.stack starting` after `stopped` or `fus` + - while `flash.stack starting` holds the driver, only `variant=coord` commands of its layout run (and the exceptions above) + - the coordinated driver and `hsem.lock` exclude each other (`ERR busy`): both rely on the HSEM interrupt + +## Hardware semaphore (STM32WB55: `HAL_HSEM_*`, `hal::SynchronousHardwareSemaphoreMasterStm`, `hal::SynchronousHardwareSemaphoreStm`) + +- Semaphores 0-31, processes 1-255. The lock state is read from the R register, never from RLR, whose read is itself a one-step lock attempt +- `hsem.take procid=<1..255> [hold=<1..60000 ms>]` → `OK`: a two-step lock (`HAL_HSEM_Take`); `ERR busy` when another process or core holds it + - with `hold` an EMIL timer releases it after `hold` ms; one `hold` at a time (`ERR busy`) +- `hsem.release procid=<1..255>` → `OK` (`HAL_HSEM_Release`; a release by a process that does not hold it changes nothing); it cancels a pending `hold` of that semaphore and process +- `hsem.status ` → `OK locked=0|1 core= procid=

`: `core` is the COREID field (4 = CPU1, 8 = CPU2, 0 when free) +- `hsem.lock [hold=<2..65536 us>]` → `OK waited=`: constructs and destroys a `SynchronousHardwareSemaphoreStm` on `n` and reports how long the constructor waited (CYCCNT) + - with `hold` the firmware first takes `n` for process 1 and starts the scaffold timer (TIM17, 1 us ticks), whose interrupt releases it after `hold` us: the lock must wait that long + - a semaphore already held returns `ERR busy` (nothing could free it while the lock blocks the event loop); so do TIM17 held by another group (with `hold`) and the coordinated flash driver +- `hsem.mine ` → `OK mine=0|1` (`IsLockedByCurrentCore`, a query without side effect) + +## Backup RAM (`hal::BackupRamStm` as `hal::BackupRam`) + +- STM32WB55: RTC BKP0R-BKP19R (20 words); STM32WBA55: TAMP BKP0R-BKP15R (16 words). The words survive a system reset (`reset`, the watchdog), not a power cycle +- `bkp.info` → `OK words=` +- `bkp.write ` → `OK`: `value` a 32-bit number (decimal or `0x` hex); an index past the last word returns `ERR range` +- `bkp.read ` → `OK value=<8 hex digits>` +- `bkp.fill ` → `OK`: word i becomes `seed XOR (0x9E3779B9 × (i + 1))` (32 bits) +- `bkp.check ` → `OK mismatches=`: the words that differ from that fill + +## Low power (`hal::LowPowerModeStm`) + +- `lpm.enter [wake=] [edge=rising|falling] [marker=] [timeout=<1..10000 ms>]` → `OK woke=exti restored= us=` + - defaults: `wake` STM32WB55 PC6 (`gpio0`), STM32WBA55 PB14 (`gpio0`); `marker` STM32WB55 PB0 (`led0`), STM32WBA55 PA2 (`tim1bkin`); `edge=rising`; `timeout=2000` + - the wake pin is an input with the pull against its edge (pull-down for `rising`) and an EXTI interrupt; the marker is an output, high, driven low while the core sleeps and high again right after it wakes + - inside the window the interrupts are masked (PRIMASK), every NVIC interrupt but the wake line's EXTI and TIM17's is disabled and the SysTick tick interrupt is off: WFI returns only on the wake edge or a TIM17 update + - the scaffold timer TIM17 counts 1 us ticks (CYCCNT stops while the core sleeps) and gives `us`, the time asleep; at `timeout` ms it ends the window with `ERR timeout` + - `deep` maps to Sleep on STM32WB/WBA (`LowPowerModeStm::Stop`), so it never calls the clock-restore callback: `restored` counts its calls and is 0 + - the wake and marker pins must differ (`ERR usage`); a pin the package lacks returns `ERR pin`, a wake pin whose EXTI line serves a pin of another port `ERR unsupported`, a held pin or TIM17 held by another group `ERR busy` + - the terminal and every other interrupt (including the watchdog's early warning: a running watchdog is not fed) are blocked for the window: send nothing until the final line + ## Not available on these boards hal-st has no comparator, CAN or Ethernet driver for STM32WB55/STM32WBA55, so `comp.open`, `comp.read`, `comp.irq`, `comp.count`, `comp.close`, `can.open`, `can.send`, `can.close`, `eth.open`, `eth.status` and `eth.close` return `ERR unsupported`. diff --git a/validation/README.md b/validation/README.md index 60d79bf3..1ffe1cf3 100644 --- a/validation/README.md +++ b/validation/README.md @@ -494,6 +494,20 @@ SPI1 to SPI3; SPI3 is observed through DIO14, DIO10, DIO11 and DIO15. The PB8 en - On the NUCLEO-WB55RG the synchronized variant keeps HSI48 running when it was on and switches it off again when it was off, takes its locked branch with semaphore 5 held (`lock5=1`), and leaves HSEM semaphores 0 and 5 free (`hsem.status`, when that group is built in). - `test_aes.py` - for `SynchronousAes128EcbStm`: the FIPS-197 C.1 and SP 800-38A F.1.1 known answers in both directions, block by block and four blocks at once, one to five blocks per command, a key change between commands, and every data swapping mode (`swap=none|half|byte|bit`) against the model of `crypto_ref`; argument errors. - `test_pka.py` - for `PkaStm` on secp256r1: G, 2G and 3G, the default operands, a short scalar padded by the firmware, (n - 1)G = -G, the NIST CAVP ECC CDH vector, a given base point, points on and off the curve, comparisons of 4, 32 and 60-byte numbers, the duration of one multiplication and argument errors; every result against the P-256 arithmetic of `crypto_ref`. +- `test_backup_ram.py` - `hal::BackupRamStm` as `hal::BackupRam` (RTC BKP0R-BKP19R on the NUCLEO-WB55RG, TAMP BKP0R-BKP15R on the NUCLEO-WBA55CG): the word count, every word written and read back with four patterns, fill and check, the words kept through a reset, argument errors. +- `test_flash.py` - the internal flash drivers (`FlashHomogeneousInternalStm`, `FlashInternalStm`, their synchronous twins and, on the NUCLEO-WB55RG, `FlashCoordinatedWithWirelessStack`) over a scratch region of 64 pages, with one sector per page and with a sector-size table of 1-, 2- and 4-page sectors: + - the geometry and the first sector the firmware lets the tests erase (an erase that took the sector index for the absolute page could not reach the running image from there on); + - every variant and layout erases exactly the pages of the sector it is given (the marker in the next sector stays, the previous sector is unchanged); + - aligned, unaligned, odd-length, page-crossing and generated 300-byte writes on distinct flash words, read back exactly through every variant, with the bytes around them still erased; a programmed word refused with `ERR failed`; the page erase time; + - NUCLEO-WB55RG: a coordinated write and erase wait for HSEM 7 while it is held; `flash.stack starting` holds a write (its `ERR timeout` goes out, the flash stays erased) until `flash.stack fus` completes it with `EVT flash`; + - NUCLEO-WB55RG: the coordinated driver borrows the watchdog (`wdt.start` busy) and excludes `hsem.lock`, and a coordinated erase under a running watchdog does not reset the board. +- `test_hsem.py` - NUCLEO-WB55RG hardware semaphores: + - two-step take and release per process with the lock state read from R, another process refused, `hold=` releasing on time; + - `SynchronousHardwareSemaphoreStm` waiting until the scaffold timer frees a semaphore held by process 1 (and returning at once on a free one), a held semaphore refused instead of a lock that would block for good; + - `IsLockedByCurrentCore` as a query that takes no lock, argument errors. +- `test_low_power.py` - `hal::LowPowerModeStm` in `sleep` and `deep` (Sleep on these MCUs: the clock-restore callback is never called): + - with only the wake line and TIM17 enabled, the marker pin stays low until the AD3 edge on the wake pin (both edges) and rises within 50 us of it, and `us` covers the time asleep; + - without an edge the TIM17 timeout ends the window and the tick runs again; pins released; argument errors. - `test_unsupported.py` - every comparator, CAN and Ethernet command, and the commands of the groups the running MCU lacks (PROTOCOL.md, "Not available on these boards"), answer `ERR unsupported`. ## Customising @@ -538,6 +552,8 @@ In `hal_st_validation` (hal-st specific): - `groups/io.py` - `fw.sgpio`, `fw.clock` (`ClockInfo`; `clock.mco` and `clock.hsi48` register their undo with `close_all`), `edge_fit_frequency` and `parse_uid`; `fakes/io.py` is their fake firmware model, with a unique device ID of the real layout. - `crypto_ref.py` - pure-Python AES-128 with the data swapping model of the STM32 AES peripheral, and affine P-256 arithmetic, with the FIPS-197, SP 800-38A and CAVP ECC CDH vectors; `rngstats.py` - the `rng.stats` counts and the monobit, chi-square and runs bounds. - `groups/crypto.py` - `fw.rng`, `fw.aes`, `fw.pka`; `fakes/crypto.py` is their fake firmware model (a deterministic RNG, AES and PKA through `crypto_ref`). +- `groups/system_ext.py` - `fw.flash`, `fw.hsem`, `fw.bkp`, `fw.lpm` (`flash.stack starting` and `hsem.take` without `hold` register their undo with `close_all`), the sector table of `layout=table`, `flash_words` and `bkp_fill_value`. +- `fakes/system_ext.py` - their fake firmware model: a flash array with page erase, HSEM state on the fake clock, backup words that survive `reset`, an `lpm.enter` that wakes at once, and the `wdt.start` refusal while the coordinated flash borrows the watchdog. - `protocol.py` - the hal-st part of the protocol: error reasons, `P` pins (ports A-K, index 0-15) and the generic alias names (`normalize_pin`, `parse_pin_map`). - `config.py` - board file loading, wiring-set merging, parameter matrices, overrides and known gaps; `expect.py` - expected STM32 values (PWM quantisation and range, SPI prescaler, UART baud-rate register limits, WWDG prescaler and period, ADC codes, encoder counts). - `pairwise.py` - the full product and the deterministic pairwise generator behind `--depth`. @@ -556,3 +572,4 @@ In the firmware (`firmware/`): - `TimerGroup`, `TimerPwmGroup`, `LpTimerGroup` and `LpTimerPwmGroup` serve `tim.*`, `tpwm.*`, `lptim.*` and `lptpwm.*`, each a factory and a single-instance group; `QeiFactory` also builds the LPTIM encoder with `cap=rise|fall` and holds its LPTIM in `ResourceAllocation`. - `SyncGpioGroup` serves `sgpio.*` through `SyncGpioDriver`, the only translation unit that includes `SynchronousGpioStm.hpp`; `ClockGroup` serves `clock.*`; `UartFactory` also builds `SynchronousUartStmSendOnly` (`sendonly=1`). - `RngGroup`, `AesGroup` and `PkaGroup` are stateless command groups: `rng` and `aes` build their driver per command; `pka` builds one `PkaStm` on first use and keeps it. +- `FlashGroup`, `HsemGroup` (STM32WB), `BackupRamGroup` and `LowPowerGroup` serve `flash.*`, `hsem.*`, `bkp.*` and `lpm.*`; `WatchDogFactory` lends the WWDG to the coordinated flash driver (`Borrow`/`Return`). diff --git a/validation/firmware/BackupRamGroup.cpp b/validation/firmware/BackupRamGroup.cpp new file mode 100644 index 00000000..2b546af8 --- /dev/null +++ b/validation/firmware/BackupRamGroup.cpp @@ -0,0 +1,138 @@ +#include "validation/firmware/BackupRamGroup.hpp" +#include "hal_st/stm32fxxx/BackupRamStm.hpp" +#include "infra/util/Endian.hpp" +#include + +namespace validation +{ + namespace + { + using services::HilStatus; + + constexpr uint32_t fillStep = 0x9e3779b9; + + hal::BackupRam& Instance() + { + static hal::BackupRamStm backupRam; + return backupRam; + } + + uint32_t FillValue(uint32_t seed, uint32_t index) + { + return seed ^ (fillStep * (index + 1)); + } + + void PrintValue(services::HilResponse::Line& line, uint32_t value) + { + infra::BigEndian bytes{ value }; + line << " value="; + line.Hex(infra::MakeByteRange(bytes)); + } + } + + BackupRamCommands::BackupRamCommands(services::HilContext& context) + : services::TerminalCommands(context.terminal) + , context(context) + , backupRam(Instance()) + , commands{ { + services::HilBind("bkp.info", "", *this, context.response), + services::HilBind("bkp.write", " ", *this, context.response), + services::HilBind("bkp.read", "", *this, context.response), + services::HilBind("bkp.fill", "", *this, context.response), + services::HilBind("bkp.check", "", *this, context.response), + } } + {} + + infra::MemoryRange BackupRamCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus BackupRamCommands::Info(const services::HilArguments& arguments) + { + if (!arguments.Shape(0, 0, {})) + return HilStatus::usage; + + context.response.Ok() << " words=" << static_cast(backupRam.Get().size()); + return HilStatus::done; + } + + HilStatus BackupRamCommands::Write(const services::HilArguments& arguments) + { + if (!arguments.Shape(2, 2, {})) + return HilStatus::usage; + + uint32_t index = 0; + uint32_t value = 0; + auto status = ParseIndex(arguments, index); + arguments.NumberAt(1, value, 0, std::numeric_limits::max(), status); + if (status != HilStatus::done) + return status; + + backupRam.Get()[index] = value; + context.response.Ok(); + return HilStatus::done; + } + + HilStatus BackupRamCommands::Read(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + uint32_t index = 0; + auto status = ParseIndex(arguments, index); + if (status != HilStatus::done) + return status; + + auto line = context.response.Ok(); + PrintValue(line, backupRam.Get()[index]); + return HilStatus::done; + } + + HilStatus BackupRamCommands::Fill(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + uint32_t seed = 0; + auto status = HilStatus::done; + arguments.NumberAt(0, seed, 0, std::numeric_limits::max(), status); + if (status != HilStatus::done) + return status; + + auto words = backupRam.Get(); + for (uint32_t index = 0; index != words.size(); ++index) + words[index] = FillValue(seed, index); + + context.response.Ok(); + return HilStatus::done; + } + + HilStatus BackupRamCommands::Check(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + uint32_t seed = 0; + auto status = HilStatus::done; + arguments.NumberAt(0, seed, 0, std::numeric_limits::max(), status); + if (status != HilStatus::done) + return status; + + auto words = backupRam.Get(); + uint32_t mismatches = 0; + for (uint32_t index = 0; index != words.size(); ++index) + if (words[index] != FillValue(seed, index)) + ++mismatches; + + context.response.Ok() << " mismatches=" << mismatches; + return HilStatus::done; + } + + HilStatus BackupRamCommands::ParseIndex(const services::HilArguments& arguments, uint32_t& index) const + { + auto status = HilStatus::done; + arguments.NumberAt(0, index, 0, static_cast(backupRam.Get().size()) - 1, status); + return status; + } +} diff --git a/validation/firmware/BackupRamGroup.hpp b/validation/firmware/BackupRamGroup.hpp new file mode 100644 index 00000000..f5ce1d4f --- /dev/null +++ b/validation/firmware/BackupRamGroup.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include "hal/interfaces/BackupRam.hpp" +#include "services/hil/HilCommand.hpp" +#include +#include + +namespace validation +{ + class BackupRamCommands + : public services::TerminalCommands + { + public: + explicit BackupRamCommands(services::HilContext& context); + + infra::MemoryRange Commands() override; + + private: + services::HilStatus Info(const services::HilArguments& arguments); + services::HilStatus Write(const services::HilArguments& arguments); + services::HilStatus Read(const services::HilArguments& arguments); + services::HilStatus Fill(const services::HilArguments& arguments); + services::HilStatus Check(const services::HilArguments& arguments); + + services::HilStatus ParseIndex(const services::HilArguments& arguments, uint32_t& index) const; + + private: + services::HilContext& context; + hal::BackupRam& backupRam; + std::array commands; + }; +} diff --git a/validation/firmware/CMakeLists.txt b/validation/firmware/CMakeLists.txt index e7f1a9a5..0fb345f8 100644 --- a/validation/firmware/CMakeLists.txt +++ b/validation/firmware/CMakeLists.txt @@ -27,6 +27,8 @@ target_sources(hal_st.validation_firmware PRIVATE AesGroup.hpp AnalogInputGroup.cpp AnalogInputGroup.hpp + BackupRamGroup.cpp + BackupRamGroup.hpp BoardInfoStm.cpp BoardInfoStm.hpp BoardTypes.hpp @@ -39,6 +41,10 @@ target_sources(hal_st.validation_firmware PRIVATE DmaGroup.hpp EepromGroup.cpp EepromGroup.hpp + FlashGroup.cpp + FlashGroup.hpp + HsemGroup.cpp + HsemGroup.hpp HsemMaster.cpp HsemMaster.hpp I2cEepromStm.cpp @@ -49,6 +55,8 @@ target_sources(hal_st.validation_firmware PRIVATE I2cTarget.hpp I2cTiming.cpp I2cTiming.hpp + LowPowerGroup.cpp + LowPowerGroup.hpp LpTimerGroup.cpp LpTimerGroup.hpp LpTimerPwmGroup.cpp diff --git a/validation/firmware/FlashGroup.cpp b/validation/firmware/FlashGroup.cpp new file mode 100644 index 00000000..bdd523f3 --- /dev/null +++ b/validation/firmware/FlashGroup.cpp @@ -0,0 +1,620 @@ +#include "validation/firmware/FlashGroup.hpp" +#include "BoardProfile.hpp" +#include "infra/event/EventDispatcher.hpp" +#include "infra/util/Endian.hpp" +#include +#include +#include +#include +#include DEVICE_HEADER +#if defined(STM32WB) +#include "validation/firmware/HsemMaster.hpp" +#endif + +extern "C" +{ + extern const uint8_t _sidata[]; + extern const uint8_t _sdata[]; + extern const uint8_t _edata[]; +} + +namespace validation +{ + namespace + { + using services::HilChoice; + using services::HilStatus; + using Variant = FlashCommands::Variant; + using Layout = FlashCommands::Layout; + + constexpr std::array, 3> variants{ { + { "sync", Variant::sync }, + { "async", Variant::async }, + { "coord", Variant::coord }, + } }; + + constexpr std::array, 2> layouts{ { + { "homogeneous", Layout::homogeneous }, + { "table", Layout::table }, + } }; + + constexpr std::array driverKeys{ { "variant", "layout" } }; + constexpr std::array writeKeys{ { "len", "pattern", "seed", "variant", "layout" } }; + constexpr std::array readKeys{ { "out", "variant", "layout" } }; + +#if defined(STM32WBA) + constexpr uint32_t flashWord = 16; +#else + constexpr uint32_t flashWord = 8; +#endif + constexpr std::array tablePattern{ { 1, 1, 2, 4 } }; + constexpr uint32_t tablePatternPages = 8; + constexpr uint32_t tablePatternRepeats = 2; + constexpr std::size_t readChunk = 64; + constexpr std::chrono::seconds eraseTimeout{ 10 }; + constexpr std::chrono::seconds transferTimeout{ 2 }; + +#if defined(STM32WB) + enum class StackState : uint8_t + { + stopped, + starting, + fus, + }; + + constexpr std::array, 3> stackStates{ { + { "stopped", StackState::stopped }, + { "starting", StackState::starting }, + { "fus", StackState::fus }, + } }; + + constexpr std::array stackKeys{ { "layout" } }; + + // hsem.lock holds HSEM 0 as owners::scaffold; the coordinated flash needs an owner of its own to exclude it + constexpr services::HilOwner coordinatedOwner = services::HilOwners::last; +#endif + + uint32_t Address(const uint8_t* pointer) + { + return static_cast(reinterpret_cast(pointer)); + } + + void PrintCrc(services::HilResponse::Line& line, uint32_t length, uint32_t value) + { + infra::BigEndian crc{ value }; + line << " len=" << length << " crc="; + line.Hex(infra::MakeByteRange(crc)); + } + } + + FlashCommands::FlashCommands(services::HilContext& context, WatchDogFactoryStm& watchDogFactory, ResourceAllocation& resources) + : services::TerminalCommands(context.terminal) + , context(context) + , watchDogFactory(watchDogFactory) + , resources(resources) + , pending(context.response) + , commands{ { + services::HilBind("flash.info", "[variant=sync|async|coord] [layout=homogeneous|table]", *this, context.response), + services::HilBind("flash.erase", " [variant=] [layout=]", *this, context.response), + services::HilBind("flash.write", "

[len=] [pattern=] [seed=] [variant=] [layout=]", *this, context.response), + services::HilBind("flash.read", "
[out=hex|crc] [variant=] [layout=]", *this, context.response), +#if defined(STM32WB) + services::HilBind("flash.stack", " [layout=]", *this, context.response), +#endif + } } + { + static_assert(board::flashScratchEndPage - board::flashScratchFirstPage <= std::tuple_size_v); + + uint32_t endPage = board::flashScratchEndPage; +#if defined(STM32WB) + FLASH_OBProgramInitTypeDef options{}; + HAL_FLASHEx_OBGetConfig(&options); + if (options.SecureMode == SYSTEM_IN_SECURE_MODE) + endPage = std::min(endPage, (options.SecureFlashStartAddr - FLASH_BASE) / FLASH_PAGE_SIZE); +#endif + + const auto imageEnd = Address(_sidata) + (Address(_edata) - Address(_sdata)); + imageEndPage = (imageEnd - FLASH_BASE + FLASH_PAGE_SIZE - 1) / FLASH_PAGE_SIZE; + usable = imageEndPage <= board::flashScratchFirstPage && endPage > board::flashScratchFirstPage; + + const auto pages = usable ? endPage - board::flashScratchFirstPage : 0; + const auto begin = reinterpret_cast(FLASH_BASE + board::flashScratchFirstPage * FLASH_PAGE_SIZE); + region = infra::ConstByteRange(begin, begin + pages * FLASH_PAGE_SIZE); + + // Single pages first: an unfixed erase (B.5) takes the sector index for an absolute page, so the multi-page + // sectors need indices past the image end page to be accepted (see LayoutGeometry) + const auto repeats = std::min(tablePatternRepeats, pages / tablePatternPages); + for (uint32_t page = 0; page != pages - repeats * tablePatternPages; ++page) + sectorSizes[tableSectors++] = FLASH_PAGE_SIZE; + for (uint32_t repeat = 0; repeat != repeats; ++repeat) + for (auto size : tablePattern) + sectorSizes[tableSectors++] = size * FLASH_PAGE_SIZE; + } + + infra::MemoryRange FlashCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus FlashCommands::Info(const services::HilArguments& arguments) + { + if (!arguments.Shape(0, 0, infra::MakeRange(driverKeys))) + return HilStatus::usage; + + Request candidate; + auto status = ParseDriver(arguments, candidate); + if (status == HilStatus::done) + status = CheckDriver(candidate); + if (status != HilStatus::done) + return status; + + const auto geometry = LayoutGeometry(candidate.layout); + infra::BigEndian base{ Address(region.begin()) }; + auto line = context.response.Ok(); + line << " base=0x"; + line.Hex(infra::MakeByteRange(base)); + line << " sectors=" << geometry.sectors << " size=" << RegionSize() << " first=" << geometry.first << " image=" << imageEndPage << " layout=" << (candidate.layout == Layout::table ? "table" : "homogeneous"); + return HilStatus::done; + } + + HilStatus FlashCommands::Erase(const services::HilArguments& arguments) + { + if (!arguments.Shape(2, 2, infra::MakeRange(driverKeys))) + return HilStatus::usage; + + Request candidate; + candidate.job = Job::erase; + auto status = HilStatus::done; + arguments.NumberAt(0, candidate.begin, 0, std::numeric_limits::max(), status); + arguments.NumberAt(1, candidate.end, 0, std::numeric_limits::max(), status); + if (status == HilStatus::done) + status = ParseDriver(arguments, candidate); + if (status != HilStatus::done) + return status; + + if (!usable) + return HilStatus::unsupported; + + const auto geometry = LayoutGeometry(candidate.layout); + if (candidate.begin < geometry.first || candidate.begin >= candidate.end || candidate.end > geometry.sectors) + return HilStatus::range; + + status = CheckDriver(candidate); + if (status == HilStatus::done) + status = Reserve(candidate); + if (status != HilStatus::done) + return status; + + return Run(candidate); + } + + HilStatus FlashCommands::Write(const services::HilArguments& arguments) + { + if (!arguments.Shape(2, 2, infra::MakeRange(writeKeys))) + return HilStatus::usage; + + Request candidate; + candidate.job = Job::write; + auto status = HilStatus::done; + arguments.NumberAt(0, candidate.address, 0, std::numeric_limits::max(), status); + if (status == HilStatus::done) + status = ParseDriver(arguments, candidate); + if (status != HilStatus::done) + return status; + + // An operation in flight may still program from the payload buffer + if (pending.Busy()) + return HilStatus::busy; + + status = ParsePayload(arguments, 1, infra::MakeRange(buffer), payload); + if (status == HilStatus::done && payload.empty()) + status = HilStatus::usage; + if (status != HilStatus::done) + return status; + + if (!usable) + return HilStatus::unsupported; + + candidate.length = static_cast(payload.size()); + const auto geometry = LayoutGeometry(candidate.layout); + if (candidate.address < AddressOfSector(candidate.layout, geometry.first) || candidate.address > RegionSize() || candidate.length > RegionSize() - candidate.address) + return HilStatus::range; + + status = CheckDriver(candidate); + if (status == HilStatus::done) + status = Reserve(candidate); + if (status != HilStatus::done) + return status; + + // Programming a flash word that is not erased fails the driver's assertion + if (!Erased(candidate.address, candidate.length)) + return HilStatus::failed; + + return Run(candidate); + } + + HilStatus FlashCommands::Read(const services::HilArguments& arguments) + { + if (!arguments.Shape(2, 2, infra::MakeRange(readKeys))) + return HilStatus::usage; + + Request candidate; + auto status = HilStatus::done; + arguments.NumberAt(0, candidate.address, 0, std::numeric_limits::max(), status); + arguments.NumberAt(1, candidate.length, 1, std::numeric_limits::max(), status); + if (status == HilStatus::done) + status = ParseOutput(arguments, candidate.output); + if (status == HilStatus::done) + status = ParseDriver(arguments, candidate); + if (status != HilStatus::done) + return status; + + if (!usable) + return HilStatus::unsupported; + + if (candidate.address > RegionSize() || candidate.length > RegionSize() - candidate.address || CheckOutput(candidate.length, candidate.output) != HilStatus::done) + return HilStatus::range; + + status = CheckDriver(candidate); + if (status == HilStatus::done) + status = Reserve(candidate); + if (status != HilStatus::done) + return status; + + return Run(candidate); + } + +#if defined(STM32WB) + HilStatus FlashCommands::Stack(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, infra::MakeRange(stackKeys))) + return HilStatus::usage; + + auto state = StackState::stopped; + Request candidate; + candidate.variant = Variant::coord; + auto status = HilStatus::done; + arguments.SelectAt(0, state, stackStates, status); + arguments.Select("layout", candidate.layout, layouts, status); + if (status != HilStatus::done) + return status; + + if (!usable) + return HilStatus::unsupported; + + if (state != StackState::starting) + { + if (stackHeld) + { + // The driver has no way out of `starting` but FirmwareUpgradeServicesReady, which also runs the held step + stackHeld = false; + coordinated->FirmwareUpgradeServicesReady(); + if (operation == 0) + ReleaseAsynchronous(); + } + + context.response.Ok(); + return HilStatus::done; + } + + if (stackHeld) + return HilStatus::busy; + + status = Reserve(candidate); + if (status != HilStatus::done) + return status; + + request = candidate; + status = ConstructAsynchronous(); + if (status != HilStatus::done) + return status; + + coordinated->WirelessStackStarting(); + stackHeld = true; + context.response.Ok(); + return HilStatus::done; + } +#endif + + HilStatus FlashCommands::ParseDriver(const services::HilArguments& arguments, Request& candidate) const + { + auto status = HilStatus::done; + arguments.Select("variant", candidate.variant, variants, status); + arguments.Select("layout", candidate.layout, layouts, status); + return status; + } + + HilStatus FlashCommands::CheckDriver(const Request& candidate) const + { + if (!usable) + return HilStatus::unsupported; + +#if !defined(STM32WB) + if (candidate.variant == Variant::coord) + return HilStatus::unsupported; +#endif + + return HilStatus::done; + } + + FlashCommands::Geometry FlashCommands::LayoutGeometry(Layout layout) const + { + const auto sectors = layout == Layout::table ? tableSectors : RegionSize() / FLASH_PAGE_SIZE; + + // An unfixed erase (B.5) takes the sector index for the absolute page; every sector starts at or past the + // page of its index, so sectors from the image end page on cannot reach the running image + return { sectors, std::min(imageEndPage, sectors) }; + } + + uint32_t FlashCommands::AddressOfSector(Layout layout, uint32_t sector) const + { + if (layout == Layout::homogeneous) + return sector * FLASH_PAGE_SIZE; + + uint32_t address = 0; + for (uint32_t index = 0; index != sector; ++index) + address += sectorSizes[index]; + + return address; + } + + uint32_t FlashCommands::RegionSize() const + { + return static_cast(region.size()); + } + + bool FlashCommands::Erased(uint32_t start, uint32_t length) const + { + const auto first = start / flashWord * flashWord; + const auto last = std::min((start + length + flashWord - 1) / flashWord * flashWord, RegionSize()); + + return std::all_of(region.begin() + first, region.begin() + last, [](uint8_t byte) + { + return byte == 0xff; + }); + } + + HilStatus FlashCommands::Reserve(const Request& candidate) + { + if (candidate.job == Job::read && candidate.variant == Variant::sync) + return HilStatus::done; + +#if defined(STM32WB) + if (stackHeld) + return !pending.Busy() && candidate.variant == Variant::coord && candidate.layout == activeLayout ? HilStatus::done : HilStatus::busy; +#endif + + // A coordinated step waiting for HSEM 7 runs once the semaphore is released, also after its ERR timeout + return pending.Busy() ? HilStatus::busy : HilStatus::done; + } + + HilStatus FlashCommands::Run(const Request& candidate) + { + // A synchronous read may run while an operation is held, which keeps its own request + if (candidate.variant == Variant::sync) + { + RunSynchronous(candidate); + return HilStatus::done; + } + + request = candidate; + auto status = HilStatus::done; +#if defined(STM32WB) + if (!stackHeld) + status = ConstructAsynchronous(); +#else + status = ConstructAsynchronous(); +#endif + if (status != HilStatus::done) + return status; + + StartAsynchronous(); + return HilStatus::done; + } + + void FlashCommands::RunSynchronous(const Request& synchronous) + { + if (synchronous.layout == Layout::table) + { + hal::SynchronousFlashInternalStm flash{ infra::MemoryRange(sectorSizes.data(), sectorSizes.data() + tableSectors), region }; + RunSynchronous(flash, synchronous); + } + else + { + hal::SynchronousFlashHomogeneousInternalStm flash{ RegionSize() / FLASH_PAGE_SIZE, FLASH_PAGE_SIZE, region }; + RunSynchronous(flash, synchronous); + } + } + + void FlashCommands::RunSynchronous(hal::SynchronousFlash& flash, const Request& synchronous) + { + if (synchronous.job == Job::read) + { + ReadSynchronous(flash, synchronous); + return; + } + + stopwatch.Start(); + if (synchronous.job == Job::erase) + flash.EraseSectors(synchronous.begin, synchronous.end); + else + flash.WriteBuffer(payload, synchronous.address); + + context.response.Ok() << " us=" << stopwatch.ElapsedUs(); + } + + void FlashCommands::ReadSynchronous(hal::SynchronousFlash& flash, const Request& synchronous) + { + // A held coordinated write still programs from `buffer`, so a synchronous read streams through a small chunk + std::array chunk; + infra::Crc32 checksum; + auto line = context.response.Ok(); + + if (synchronous.output == Output::hex) + line << " data="; + + for (uint32_t offset = 0; offset != synchronous.length;) + { + auto part = infra::Head(infra::MakeRange(chunk), std::min(synchronous.length - offset, chunk.size())); + flash.ReadBuffer(part, synchronous.address + offset); + offset += static_cast(part.size()); + + if (synchronous.output == Output::hex) + line.Hex(part); + else + checksum.Update(part); + } + + if (synchronous.output == Output::crc) + PrintCrc(line, synchronous.length, checksum.Result()); + } + + HilStatus FlashCommands::ConstructAsynchronous() + { + ReleaseAsynchronous(); + +#if defined(STM32WB) + if (request.variant == Variant::coord) + { + auto status = resources.Claim(Resource::hsem, 0, coordinatedOwner); + if (status != HilStatus::done) + return status; + + HsemMaster(); + } +#endif + + activeLayout = request.layout; + + hal::FlashInternalStmBase* base = nullptr; + if (request.layout == Layout::table) + base = &asyncFlash.emplace(infra::MemoryRange(sectorSizes.data(), sectorSizes.data() + tableSectors), region); + else + base = &asyncFlash.emplace(RegionSize() / FLASH_PAGE_SIZE, FLASH_PAGE_SIZE, region); + + activeFlash = base; +#if defined(STM32WB) + if (request.variant == Variant::coord) + activeFlash = &coordinated.emplace(*base, watchDogFactory.Borrow(), hal::FlashCoordinatedWithWirelessStack::WirelessStack::stopped); +#endif + + return HilStatus::done; + } + + void FlashCommands::StartAsynchronous() + { + const auto timeout = request.job == Job::erase ? eraseTimeout : transferTimeout; + operation = pending.Start(timeout); + stopwatch.Start(); + + switch (request.job) + { + case Job::erase: + activeFlash->EraseSectors(request.begin, request.end, [this, doneOperation = operation]() + { + Done(doneOperation); + }); + break; + case Job::write: + activeFlash->WriteBuffer(payload, request.address, [this, doneOperation = operation]() + { + Done(doneOperation); + }); + break; + case Job::read: + crc.Reset(); + address = request.address; + remaining = request.length; + ReadNextChunk(); + break; + } + } + + void FlashCommands::ReadNextChunk() + { + auto chunk = infra::Head(infra::MakeRange(buffer), std::min(remaining, buffer.size())); + activeFlash->ReadBuffer(chunk, address, [this, chunkOperation = operation]() + { + ChunkRead(chunkOperation); + }); + } + + void FlashCommands::ChunkRead(uint32_t chunkOperation) + { + if (chunkOperation != operation) + return; + + const auto size = static_cast(std::min(remaining, buffer.size())); + if (request.output == Output::crc) + crc.Update(infra::Head(infra::MakeRange(buffer), size)); + + address += size; + remaining -= size; + + if (remaining != 0) + ReadNextChunk(); + else + Done(chunkOperation); + } + + void FlashCommands::Done(uint32_t doneOperation) + { + if (doneOperation != operation) + return; + + operation = 0; + elapsed = stopwatch.ElapsedUs(); + + // The driver calls this from its own scheduled callback; destroy it one event-loop turn later + infra::EventDispatcher::Instance().Schedule([this]() + { +#if defined(STM32WB) + if (stackHeld) + return; +#endif + if (operation == 0) + ReleaseAsynchronous(); + }); + + if (pending.Complete(doneOperation)) + { + Finish(); + return; + } + + const char* job = "read"; + if (request.job == Job::erase) + job = "erase"; + else if (request.job == Job::write) + job = "write"; + + context.response.Event("flash") << " op=" << job << " us=" << elapsed; + } + + void FlashCommands::Finish() + { + auto line = context.response.Ok(); + + if (request.job != Job::read) + line << " us=" << elapsed; + else if (request.output == Output::hex) + PrintData(line, infra::Head(infra::MakeRange(buffer), request.length), Output::hex); + else + PrintCrc(line, request.length, crc.Result()); + } + + void FlashCommands::ReleaseAsynchronous() + { +#if defined(STM32WB) + if (coordinated) + { + coordinated.reset(); + watchDogFactory.Return(); + resources.Release(Resource::hsem, 0, coordinatedOwner); + } +#endif + + asyncFlash.emplace(); + activeFlash = nullptr; + } +} diff --git a/validation/firmware/FlashGroup.hpp b/validation/firmware/FlashGroup.hpp new file mode 100644 index 00000000..714b3434 --- /dev/null +++ b/validation/firmware/FlashGroup.hpp @@ -0,0 +1,131 @@ +#pragma once + +#include "hal_st/stm32fxxx/FlashInternalStm.hpp" +#include "hal_st/synchronous_stm32fxxx/SynchronousFlashInternalStm.hpp" +#include "infra/util/Crc.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilPendingOperation.hpp" +#include "validation/firmware/Payload.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include "validation/firmware/Stopwatch.hpp" +#include "validation/firmware/WatchDogFactory.hpp" +#include +#include +#include +#include +#if defined(STM32WB) +#include "hal_st/stm32fxxx/FlashCoordinatedWithWirelessStack.hpp" +#endif + +namespace validation +{ + class FlashCommands + : public services::TerminalCommands + { + public: + enum class Variant : uint8_t + { + sync, + async, + coord, + }; + + enum class Layout : uint8_t + { + homogeneous, + table, + }; + + FlashCommands(services::HilContext& context, WatchDogFactoryStm& watchDogFactory, ResourceAllocation& resources); + + infra::MemoryRange Commands() override; + + private: + enum class Job : uint8_t + { + erase, + write, + read, + }; + + struct Request + { + Job job = Job::read; + Variant variant = Variant::sync; + Layout layout = Layout::homogeneous; + uint32_t begin = 0; + uint32_t end = 0; + uint32_t address = 0; + uint32_t length = 0; + Output output = Output::hex; + }; + + struct Geometry + { + uint32_t sectors = 0; + uint32_t first = 0; + }; + + services::HilStatus Info(const services::HilArguments& arguments); + services::HilStatus Erase(const services::HilArguments& arguments); + services::HilStatus Write(const services::HilArguments& arguments); + services::HilStatus Read(const services::HilArguments& arguments); +#if defined(STM32WB) + services::HilStatus Stack(const services::HilArguments& arguments); +#endif + + services::HilStatus ParseDriver(const services::HilArguments& arguments, Request& request) const; + services::HilStatus CheckDriver(const Request& request) const; + Geometry LayoutGeometry(Layout layout) const; + uint32_t AddressOfSector(Layout layout, uint32_t sector) const; + uint32_t RegionSize() const; + bool Erased(uint32_t address, uint32_t length) const; + services::HilStatus Reserve(const Request& request); + services::HilStatus Run(const Request& request); + + void RunSynchronous(const Request& synchronous); + void RunSynchronous(hal::SynchronousFlash& flash, const Request& synchronous); + void ReadSynchronous(hal::SynchronousFlash& flash, const Request& synchronous); + services::HilStatus ConstructAsynchronous(); + void StartAsynchronous(); + void ReadNextChunk(); + void ChunkRead(uint32_t chunkOperation); + void Done(uint32_t doneOperation); + void Finish(); + void ReleaseAsynchronous(); + + private: + services::HilContext& context; + WatchDogFactoryStm& watchDogFactory; + ResourceAllocation& resources; + services::HilPendingOperation pending; + infra::ConstByteRange region; + uint32_t imageEndPage = 0; + bool usable = false; + std::array sectorSizes{}; + uint32_t tableSectors = 0; + + std::variant asyncFlash; +#if defined(STM32WB) + std::optional coordinated; + bool stackHeld = false; +#endif + hal::Flash* activeFlash = nullptr; + Layout activeLayout = Layout::homogeneous; + + Request request; + Stopwatch stopwatch; + std::array buffer{}; + infra::ByteRange payload; + infra::Crc32 crc; + uint32_t address = 0; + uint32_t remaining = 0; + uint32_t elapsed = 0; + uint32_t operation = 0; +#if defined(STM32WB) + std::array commands; +#else + std::array commands; +#endif + }; +} diff --git a/validation/firmware/HsemGroup.cpp b/validation/firmware/HsemGroup.cpp new file mode 100644 index 00000000..fb47d7dc --- /dev/null +++ b/validation/firmware/HsemGroup.cpp @@ -0,0 +1,210 @@ +#include "validation/firmware/HsemGroup.hpp" + +#if defined(STM32WB) + +#include "BoardProfile.hpp" +#include "hal_st/stm32fxxx/TimerStm.hpp" +#include "hal_st/synchronous_stm32fxxx/SynchronousHardwareSemaphoreStm.hpp" +#include "stm32wbxx_ll_hsem.h" +#include "validation/firmware/HsemMaster.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include +#include +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + using services::HilStatus; + + constexpr uint32_t maximumHoldMs = 60000; + constexpr uint32_t minimumHoldUs = 2; + constexpr uint32_t maximumHoldUs = 65536; + constexpr uint32_t ticksPerSecond = 1000000; + constexpr uint32_t lockHoldProcess = 1; + } + + HsemCommands::HsemCommands(services::HilContext& context, TimerAllocation& timers, ResourceAllocation& resources) + : services::TerminalCommands(context.terminal) + , context(context) + , timers(timers) + , resources(resources) + , commands{ { + services::HilBind("hsem.take", " procid=<1..255> [hold=]", *this, context.response), + services::HilBind("hsem.release", " procid=<1..255>", *this, context.response), + services::HilBind("hsem.status", "", *this, context.response), + services::HilBind("hsem.lock", " [hold=]", *this, context.response), + services::HilBind("hsem.mine", "", *this, context.response), + } } + {} + + infra::MemoryRange HsemCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus HsemCommands::Take(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, { "procid", "hold" })) + return HilStatus::usage; + + uint32_t semaphore = 0; + uint32_t process = 0; + uint32_t hold = 0; + auto status = ParseProcess(arguments, semaphore, process); + arguments.Number("hold", hold, 1, maximumHoldMs, status); + if (status != HilStatus::done) + return status; + + HsemMaster(); + if (hold != 0 && holdTimer.Armed()) + return HilStatus::busy; + + if (HAL_HSEM_Take(semaphore, process) != HAL_OK) + return HilStatus::busy; + + if (hold != 0) + { + heldSemaphore = semaphore; + heldProcess = process; + holdTimer.Start(std::chrono::milliseconds(hold), [this]() + { + HAL_HSEM_Release(heldSemaphore, heldProcess); + }); + } + + context.response.Ok(); + return HilStatus::done; + } + + HilStatus HsemCommands::Release(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, { "procid" })) + return HilStatus::usage; + + uint32_t semaphore = 0; + uint32_t process = 0; + auto status = ParseProcess(arguments, semaphore, process); + if (status != HilStatus::done) + return status; + + HsemMaster(); + HAL_HSEM_Release(semaphore, process); + if (holdTimer.Armed() && heldSemaphore == semaphore && heldProcess == process) + holdTimer.Cancel(); + + context.response.Ok(); + return HilStatus::done; + } + + HilStatus HsemCommands::Status(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + uint32_t semaphore = 0; + auto status = HilStatus::done; + arguments.NumberAt(0, semaphore, 0, HSEM_SEMID_MAX, status); + if (status != HilStatus::done) + return status; + + HsemMaster(); + context.response.Ok() << " locked=" << LL_HSEM_IsSemaphoreLocked(HSEM, semaphore) << " core=" << (LL_HSEM_GetCoreId(HSEM, semaphore) >> HSEM_R_COREID_Pos) << " procid=" << LL_HSEM_GetProcessId(HSEM, semaphore); + return HilStatus::done; + } + + HilStatus HsemCommands::Lock(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, { "hold" })) + return HilStatus::usage; + + uint32_t semaphore = 0; + uint32_t hold = 0; + auto status = HilStatus::done; + arguments.NumberAt(0, semaphore, 0, HSEM_SEMID_MAX, status); + arguments.Number("hold", hold, minimumHoldUs, maximumHoldUs, status); + if (status != HilStatus::done) + return status; + + HsemMaster(); + status = resources.Claim(Resource::hsem, 0, owners::scaffold); + if (status == HilStatus::done && hold != 0) + { + status = timers.Claim(board::scaffoldTimer, owners::scaffold); + if (status != HilStatus::done) + resources.Release(Resource::hsem, 0, owners::scaffold); + } + if (status != HilStatus::done) + return status; + + // The wait blocks the event loop: a semaphore held by anything but the scaffold timer would never be freed + uint32_t waited = 0; + if (LL_HSEM_IsSemaphoreLocked(HSEM, semaphore) != 0) + status = HilStatus::busy; + else + waited = Wait(semaphore, hold); + + if (hold != 0) + timers.Release(board::scaffoldTimer, owners::scaffold); + resources.Release(Resource::hsem, 0, owners::scaffold); + if (status != HilStatus::done) + return status; + + context.response.Ok() << " waited=" << waited; + return HilStatus::done; + } + + HilStatus HsemCommands::Mine(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + uint32_t semaphore = 0; + auto status = HilStatus::done; + arguments.NumberAt(0, semaphore, 0, HSEM_SEMID_MAX, status); + if (status != HilStatus::done) + return status; + + const bool mine = HsemMaster().IsLockedByCurrentCore(static_cast(semaphore)); + context.response.Ok() << " mine=" << static_cast(mine ? 1 : 0); + return HilStatus::done; + } + + HilStatus HsemCommands::ParseProcess(const services::HilArguments& arguments, uint32_t& semaphore, uint32_t& process) const + { + if (!arguments.Has("procid")) + return HilStatus::usage; + + auto status = HilStatus::done; + arguments.NumberAt(0, semaphore, 0, HSEM_SEMID_MAX, status); + arguments.Number("procid", process, 1, HSEM_PROCESSID_MAX, status); + return status; + } + + uint32_t HsemCommands::Wait(uint32_t semaphore, uint32_t holdUs) + { + std::optional releaser; + + if (holdUs != 0) + { + HAL_HSEM_Take(semaphore, lockHoldProcess); + + auto& timer = releaser.emplace(board::scaffoldTimer, hal::TimerBaseStm::Timing{ TimerClock(board::scaffoldTimer) / ticksPerSecond - 1, holdUs - 1 }); + __HAL_TIM_CLEAR_FLAG(&timer.Handle(), TIM_FLAG_UPDATE); + timer.Start([&timer, semaphore]() + { + HAL_HSEM_Release(semaphore, lockHoldProcess); + timer.Stop(); + }, + hal::InterruptType::immediate); + } + + stopwatch.Start(); + hal::SynchronousHardwareSemaphoreStm lock{ HsemMaster(), static_cast(semaphore) }; + return stopwatch.ElapsedUs(); + } +} + +#endif diff --git a/validation/firmware/HsemGroup.hpp b/validation/firmware/HsemGroup.hpp new file mode 100644 index 00000000..598604cd --- /dev/null +++ b/validation/firmware/HsemGroup.hpp @@ -0,0 +1,45 @@ +#pragma once + +#if defined(STM32WB) + +#include "infra/timer/Timer.hpp" +#include "services/hil/HilCommand.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include "validation/firmware/Stopwatch.hpp" +#include "validation/firmware/TimerAllocation.hpp" +#include +#include + +namespace validation +{ + class HsemCommands + : public services::TerminalCommands + { + public: + HsemCommands(services::HilContext& context, TimerAllocation& timers, ResourceAllocation& resources); + + infra::MemoryRange Commands() override; + + private: + services::HilStatus Take(const services::HilArguments& arguments); + services::HilStatus Release(const services::HilArguments& arguments); + services::HilStatus Status(const services::HilArguments& arguments); + services::HilStatus Lock(const services::HilArguments& arguments); + services::HilStatus Mine(const services::HilArguments& arguments); + + services::HilStatus ParseProcess(const services::HilArguments& arguments, uint32_t& semaphore, uint32_t& process) const; + uint32_t Wait(uint32_t semaphore, uint32_t holdUs); + + private: + services::HilContext& context; + TimerAllocation& timers; + ResourceAllocation& resources; + infra::TimerSingleShot holdTimer; + uint32_t heldSemaphore = 0; + uint32_t heldProcess = 0; + Stopwatch stopwatch; + std::array commands; + }; +} + +#endif diff --git a/validation/firmware/LowPowerGroup.cpp b/validation/firmware/LowPowerGroup.cpp new file mode 100644 index 00000000..0dee1d14 --- /dev/null +++ b/validation/firmware/LowPowerGroup.cpp @@ -0,0 +1,238 @@ +#include "validation/firmware/LowPowerGroup.hpp" +#include "BoardProfile.hpp" +#include "hal_st/stm32fxxx/TimerStm.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + using services::HilChoice; + using services::HilStatus; + + constexpr std::array enterKeys{ { "wake", "edge", "marker", "timeout" } }; + + constexpr std::array, 2> modes{ { + { "sleep", hal::PowerMode::sleep }, + { "deep", hal::PowerMode::deepSleep }, + } }; + + constexpr std::array, 2> edges{ { + { "rising", true }, + { "falling", false }, + } }; + + constexpr uint32_t maximumTimeoutMs = 10000; + constexpr uint32_t ticksPerSecond = 1000000; + constexpr uint32_t ticksPerUpdate = 1000; + constexpr std::size_t nvicWords = sizeof(NVIC_Type::ISER) / sizeof(uint32_t); + + static_assert(board::scaffoldTimer == 17); +#if defined(STM32WB) + constexpr IRQn_Type scaffoldIrq = TIM1_TRG_COM_TIM17_IRQn; +#else + constexpr IRQn_Type scaffoldIrq = TIM17_IRQn; +#endif + + IRQn_Type ExtiIrq(uint8_t line) + { +#if defined(STM32WB) + if (line >= 10) + return EXTI15_10_IRQn; + if (line >= 5) + return EXTI9_5_IRQn; +#endif + return static_cast(EXTI0_IRQn + line); + } + + bool ExtiPending(uint32_t mask) + { + return __HAL_GPIO_EXTI_GET_IT(mask) != 0; + } + } + + LowPowerCommands::LowPowerCommands(services::HilContext& context, TimerAllocation& timers) + : services::TerminalCommands(context.terminal) + , context(context) + , timers(timers) + , pins(context.pins, owners::lowPower) + , lowPower([this]() + { + ++restored; + }) + , commands{ { + services::HilBind("lpm.enter", " [wake=] [edge=rising|falling] [marker=] [timeout=]", *this, context.response), + } } + {} + + infra::MemoryRange LowPowerCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus LowPowerCommands::Enter(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, infra::MakeRange(enterKeys))) + return HilStatus::usage; + + Request request; + auto status = Parse(arguments, request); + if (status != HilStatus::done) + return status; + + hal::GpioPin* wake = nullptr; + hal::GpioPin* marker = nullptr; + status = Claim(request, wake, marker); + if (status != HilStatus::done) + return status; + + restored = 0; + const auto outcome = Sleep(request, *marker); + + wake->DisableInterrupt(); + wake->ResetConfig(); + marker->ResetConfig(); + pins.Release(); + timers.Release(board::scaffoldTimer, owners::lowPower); + + if (!outcome.woke) + return HilStatus::timeout; + + context.response.Ok() << " woke=exti restored=" << restored.load() << " us=" << outcome.us; + return HilStatus::done; + } + + HilStatus LowPowerCommands::Parse(const services::HilArguments& arguments, Request& request) const + { + std::optional wake; + std::optional marker; + auto status = HilStatus::done; + arguments.SelectAt(0, request.mode, modes, status); + arguments.Select("edge", request.rising, edges, status); + arguments.Number("timeout", request.timeoutMs, 1, maximumTimeoutMs, status); + arguments.Pin("wake", context.naming, wake, status); + arguments.Pin("marker", context.naming, marker, status); + if (status != HilStatus::done) + return status; + + request.wake = wake.value_or(board::lowPowerDefaults.wake); + request.marker = marker.value_or(board::lowPowerDefaults.marker); + if (request.wake == request.marker) + return HilStatus::usage; + + if (!IsBonded(request.wake) || !IsBonded(request.marker)) + return HilStatus::pin; + + if (!context.pins.Factory().SupportsInterrupt(request.wake)) + return HilStatus::unsupported; + + return HilStatus::done; + } + + HilStatus LowPowerCommands::Claim(const Request& request, hal::GpioPin*& wake, hal::GpioPin*& marker) + { + auto status = timers.Claim(board::scaffoldTimer, owners::lowPower); + if (status != HilStatus::done) + return status; + + services::HilPinOptions wakeOptions; + wakeOptions.pull = request.rising ? services::HilPull::down : services::HilPull::up; + status = pins.Claim(request.wake, services::HilPinPool::Use::exclusive, wake, wakeOptions); + if (status == HilStatus::done) + status = pins.Claim(request.marker, services::HilPinPool::Use::exclusive, marker); + if (status != HilStatus::done) + { + pins.Release(); + timers.Release(board::scaffoldTimer, owners::lowPower); + return status; + } + + woke = false; + wake->Config(hal::PinConfigType::input); + marker->Config(hal::PinConfigType::output, true); + wake->EnableInterrupt([this]() + { + woke = true; + }, + request.rising ? hal::InterruptTrigger::risingEdge : hal::InterruptTrigger::fallingEdge, hal::InterruptType::immediate); + return HilStatus::done; + } + + LowPowerCommands::Outcome LowPowerCommands::Sleep(const Request& request, hal::GpioPin& marker) + { + // CYCCNT stops while the core sleeps: the scaffold timer counts the window in microseconds instead + hal::TimerWithInterruptStm timer{ board::scaffoldTimer, { TimerClock(board::scaffoldTimer) / ticksPerSecond - 1, ticksPerUpdate - 1 } }; + auto& handle = timer.Handle(); + const uint32_t wakeMask = 1u << request.wake.index; + const auto wakeIrq = ExtiIrq(request.wake.index); + uint32_t updates = 0; + + __HAL_TIM_CLEAR_FLAG(&handle, TIM_FLAG_UPDATE); + timer.Start([]() {}, hal::InterruptType::immediate); + + // With PRIMASK set WFI still returns on any enabled interrupt that becomes pending, so only the wake line and + // the scaffold timer stay enabled; their handlers run once PRIMASK is restored + const auto primask = __get_PRIMASK(); + __disable_irq(); + + std::array enabled; + for (std::size_t word = 0; word != enabled.size(); ++word) + { + enabled[word] = NVIC->ISER[word]; + for (uint32_t bit = 0; bit != 32; ++bit) + { + const auto irq = static_cast(word * 32 + bit); + if ((enabled[word] & (1u << bit)) != 0 && irq != wakeIrq && irq != scaffoldIrq) + HAL_NVIC_DisableIRQ(irq); + } + } + + const bool tick = (SysTick->CTRL & SysTick_CTRL_TICKINT_Msk) != 0; + HAL_SuspendTick(); + // A tick that pended before TICKINT was cleared would end every WFI at once + SCB->ICSR = SCB_ICSR_PENDSTCLR_Msk; + + __HAL_TIM_SET_COUNTER(&handle, 0); + __HAL_TIM_CLEAR_FLAG(&handle, TIM_FLAG_UPDATE); + HAL_NVIC_ClearPendingIRQ(scaffoldIrq); + marker.Set(false); + + while (!woke && !ExtiPending(wakeMask) && updates != request.timeoutMs) + { + lowPower.Enter(request.mode); + + if (__HAL_TIM_GET_FLAG(&handle, TIM_FLAG_UPDATE) != 0) + { + __HAL_TIM_CLEAR_FLAG(&handle, TIM_FLAG_UPDATE); + HAL_NVIC_ClearPendingIRQ(scaffoldIrq); + ++updates; + } + } + + marker.Set(true); + + const uint32_t counter = __HAL_TIM_GET_COUNTER(&handle); + const bool wrapped = __HAL_TIM_GET_FLAG(&handle, TIM_FLAG_UPDATE) != 0 && counter < ticksPerUpdate / 2; + Outcome outcome{ woke || ExtiPending(wakeMask), (updates + (wrapped ? 1 : 0)) * ticksPerUpdate + counter }; + + timer.Stop(); + __HAL_TIM_CLEAR_FLAG(&handle, TIM_FLAG_UPDATE); + HAL_NVIC_ClearPendingIRQ(scaffoldIrq); + + if (tick) + HAL_ResumeTick(); + + for (std::size_t word = 0; word != enabled.size(); ++word) + for (uint32_t bit = 0; bit != 32; ++bit) + if ((enabled[word] & (1u << bit)) != 0) + HAL_NVIC_EnableIRQ(static_cast(word * 32 + bit)); + + __set_PRIMASK(primask); + return outcome; + } +} diff --git a/validation/firmware/LowPowerGroup.hpp b/validation/firmware/LowPowerGroup.hpp new file mode 100644 index 00000000..d0529543 --- /dev/null +++ b/validation/firmware/LowPowerGroup.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include "hal/interfaces/LowPowerMode.hpp" +#include "hal_st/stm32fxxx/LowPowerModeStm.hpp" +#include "services/hil/HilCommand.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/TimerAllocation.hpp" +#include +#include +#include + +namespace validation +{ + class LowPowerCommands + : public services::TerminalCommands + { + public: + LowPowerCommands(services::HilContext& context, TimerAllocation& timers); + + infra::MemoryRange Commands() override; + + private: + struct Request + { + hal::PowerMode mode = hal::PowerMode::sleep; + HilPinId wake; + HilPinId marker; + bool rising = true; + uint32_t timeoutMs = 2000; + }; + + struct Outcome + { + bool woke = false; + uint32_t us = 0; + }; + + services::HilStatus Enter(const services::HilArguments& arguments); + services::HilStatus Parse(const services::HilArguments& arguments, Request& request) const; + services::HilStatus Claim(const Request& request, hal::GpioPin*& wake, hal::GpioPin*& marker); + Outcome Sleep(const Request& request, hal::GpioPin& marker); + + private: + services::HilContext& context; + TimerAllocation& timers; + services::HilPinOwner pins; + hal::LowPowerModeStm lowPower; + std::atomic restored{ 0 }; + std::atomic woke{ false }; + std::array commands; + }; +} diff --git a/validation/firmware/Main.cpp b/validation/firmware/Main.cpp index 9c896982..9333db98 100644 --- a/validation/firmware/Main.cpp +++ b/validation/firmware/Main.cpp @@ -17,13 +17,17 @@ #include "validation/firmware/AdcFactory.hpp" #include "validation/firmware/AesGroup.hpp" #include "validation/firmware/AnalogInputGroup.hpp" +#include "validation/firmware/BackupRamGroup.hpp" #include "validation/firmware/BoardInfoStm.hpp" #include "validation/firmware/ClockGroup.hpp" #include "validation/firmware/Console.hpp" #include "validation/firmware/DmaGroup.hpp" #include "validation/firmware/EepromGroup.hpp" +#include "validation/firmware/FlashGroup.hpp" +#include "validation/firmware/HsemGroup.hpp" #include "validation/firmware/I2cGroup.hpp" #include "validation/firmware/I2cTarget.hpp" +#include "validation/firmware/LowPowerGroup.hpp" #include "validation/firmware/LpTimerGroup.hpp" #include "validation/firmware/LpTimerPwmGroup.hpp" #include "validation/firmware/PinFactoryStm.hpp" @@ -118,6 +122,13 @@ int main() static validation::AesCommands aes{ context }; static validation::PkaCommands pka{ context }; + static validation::FlashCommands flash{ context, watchDogFactory, resources }; +#if defined(STM32WB) + static validation::HsemCommands hsem{ context, timers, resources }; +#endif + static validation::BackupRamCommands backupRam{ context }; + static validation::LowPowerCommands lowPower{ context, timers }; + validation::CreateUnsupportedGroups(context); system.PrintBoot(); diff --git a/validation/firmware/UnsupportedGroups.cpp b/validation/firmware/UnsupportedGroups.cpp index 3ce954fb..f1c9b3e5 100644 --- a/validation/firmware/UnsupportedGroups.cpp +++ b/validation/firmware/UnsupportedGroups.cpp @@ -27,7 +27,7 @@ namespace validation "lptpwm.close", } }; #elif defined(STM32WBA) - constexpr std::array commandNames{ { + constexpr std::array commandNames{ { "comp.open", "comp.read", "comp.irq", @@ -41,6 +41,12 @@ namespace validation "eth.close", "clock.mco", "clock.hsi48", + "hsem.take", + "hsem.release", + "hsem.status", + "hsem.lock", + "hsem.mine", + "flash.stack", } }; #endif } diff --git a/validation/firmware/WatchDogFactory.cpp b/validation/firmware/WatchDogFactory.cpp index b5f1b07e..c5206592 100644 --- a/validation/firmware/WatchDogFactory.cpp +++ b/validation/firmware/WatchDogFactory.cpp @@ -102,6 +102,9 @@ namespace validation if (status != HilStatus::done) return status; + if (watchDog) + return HilStatus::busy; + hal::GpioPin* gpio = nullptr; if (request.pin) { @@ -118,9 +121,25 @@ namespace validation if (gpio != nullptr) created = &toggle.emplace(*watchDog, *gpio); + started = true; return HilStatus::done; } + hal::WatchDogStm& WatchDogFactoryStm::Borrow() + { + // Until wdt.start, the borrowed WWDG only has its clock and interrupt; refreshing it does not start it + if (!watchDog) + watchDog.emplace(); + + return *watchDog; + } + + void WatchDogFactoryStm::Return() + { + if (!started) + watchDog.reset(); + } + HilStatus WatchDogFactoryStm::Parse(const services::HilArguments& arguments, Request& request) const { HilStatus status = HilStatus::done; diff --git a/validation/firmware/WatchDogFactory.hpp b/validation/firmware/WatchDogFactory.hpp index effba6ea..8cc5c8f1 100644 --- a/validation/firmware/WatchDogFactory.hpp +++ b/validation/firmware/WatchDogFactory.hpp @@ -20,6 +20,9 @@ namespace validation services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; services::HilStatus Create(uint8_t index, infra::Duration timeout, const services::HilArguments& arguments, hal::Watchdog*& watchDog) override; + hal::WatchDogStm& Borrow(); + void Return(); + private: class WarningToggle : public hal::Watchdog @@ -51,5 +54,6 @@ namespace validation services::HilPinOwner warningPins; std::optional watchDog; std::optional toggle; + bool started = false; }; } diff --git a/validation/host/boards/nucleo_wb55rg.yaml b/validation/host/boards/nucleo_wb55rg.yaml index 8fd4cc53..6a1b8040 100644 --- a/validation/host/boards/nucleo_wb55rg.yaml +++ b/validation/host/boards/nucleo_wb55rg.yaml @@ -1281,6 +1281,60 @@ tests: compare_lengths: [4, 32, 60] # One full-scalar multiplication at 64 MHz takes tens of ms. max_us: 500000 + flash: + # sync: SynchronousFlash*InternalStm; async: Flash*InternalStm; coord: + # FlashCoordinatedWithWirelessStack over the async driver. + variants: [sync, async, coord] + # homogeneous: one sector per page; table: single pages, then the + # pattern 1, 1, 2, 4 pages twice (groups.system_ext.table_sectors). + layouts: [homogeneous, table] + # Scratch region: absolute pages 64-127 (0x08040000, below the 512 KB + # the linker script gives the firmware), 4 KB pages, 64-bit flash words. + base: 0x08040000 + first_page: 64 + pages: 64 + page: 4096 + word: 8 + # Page erase time (DS11929: 22 ms typical), wide bounds per page. + erase_us_per_page: [5000, 50000] + # hsem.take 7 hold= before a coordinated step; the step may start up to + # hold_margin_ms early (the time between the two commands). + hsem_hold_ms: 200 + hold_margin_ms: 50 + # The firmware's ERR timeout of a held write. + transfer_timeout_s: 2.0 + # wdt.start timeout for the coordinated erase under a running watchdog: + # warning period 64.5 ms, longer than one page erase with interrupts + # masked. + watchdog_timeout_ms: 50 + hsem: + # A semaphore nothing else uses (rng takes 0 and reads 5, the + # coordinated flash 7). + semaphore: 12 + # COREID of CPU1 in HSEM_R. + core: 4 + holds_us: [2000, 20000] + tolerance: 0.1 + # A free semaphore locks within this time; also the slack of the hold. + free_wait_max_us: 100 + bkp: + # RTC BKP0R-BKP19R. + words: 20 + lowpower: + # Defaults of the firmware (BoardProfile lowPowerDefaults). + wake: gpio0 + marker: led0 + modes: [sleep, deep] + edges: [rising, falling] + # Wake edge to marker high. + latency_us: 50 + # Host delay between lpm.enter and the AD3 edge. + wake_delay_s: 0.05 + wake_timeout_ms: 2000 + # test_timeout: no edge. + timeout_ms: 50 + la_rate: 4000000 + unbonded_pin: PC7 unsupported: commands: - comp.open 1 diff --git a/validation/host/boards/nucleo_wba55cg.yaml b/validation/host/boards/nucleo_wba55cg.yaml index 451e23c8..4cdbc97a 100644 --- a/validation/host/boards/nucleo_wba55cg.yaml +++ b/validation/host/boards/nucleo_wba55cg.yaml @@ -1390,6 +1390,40 @@ tests: compare_lengths: [4, 32, 60] # One full-scalar multiplication at 100 MHz takes tens of ms. max_us: 500000 + flash: + # sync: SynchronousFlash*InternalStm; async: Flash*InternalStm. No + # wireless-stack coordination on the STM32WBA55. + variants: [sync, async] + # homogeneous: one sector per page; table: single pages, then the + # pattern 1, 1, 2, 4 pages twice (groups.system_ext.table_sectors). + layouts: [homogeneous, table] + # Scratch region: absolute pages 64-127 (0x08080000-0x080FFFFF, the + # upper half of the 1 MB flash), 8 KB pages, 128-bit flash words. + base: 0x08080000 + first_page: 64 + pages: 64 + page: 8192 + word: 16 + # Page erase time, wide bounds per page. + erase_us_per_page: [200, 50000] + bkp: + # TAMP BKP0R-BKP15R. + words: 16 + lowpower: + # Defaults of the firmware (BoardProfile lowPowerDefaults). + wake: gpio0 + marker: tim1bkin + modes: [sleep, deep] + edges: [rising, falling] + # Wake edge to marker high. + latency_us: 50 + # Host delay between lpm.enter and the AD3 edge. + wake_delay_s: 0.05 + wake_timeout_ms: 2000 + # test_timeout: no edge. + timeout_ms: 50 + la_rate: 4000000 + unbonded_pin: PA4 unsupported: commands: - comp.open 1 diff --git a/validation/host/hal_st_validation/fakes/system_ext.py b/validation/host/hal_st_validation/fakes/system_ext.py new file mode 100644 index 00000000..934cdd20 --- /dev/null +++ b/validation/host/hal_st_validation/fakes/system_ext.py @@ -0,0 +1,422 @@ +"""Fake `flash`, `hsem`, `bkp` and `lpm` groups: the argument checks of validation/firmware/FlashGroup.cpp, +HsemGroup.cpp, BackupRamGroup.cpp and LowPowerGroup.cpp in their order, and simple models behind them. + +- `flash`: the scratch region as a byte array with page erase, both layouts, the double-safe first sector and the + erased-word check. `variant=coord` (STM32WB55) waits while HSEM 7 is held or `flash.stack starting` holds it; the + coordinated driver borrows the watchdog (`wdt.start` answers `ERR busy` meanwhile) and holds HSEM 0 against + `hsem.lock`. +- `hsem`: lock state of the 32 semaphores, `hold=` timers on the fake clock; `hsem.lock hold=` waits the hold. +- `bkp`: words that survive `reset` (the backup domain keeps them). +- `lpm`: wakes at once (`woke=exti`), as if the wake edge came right after the marker went low. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, ClassVar, cast + +from ..groups.system_ext import LAYOUTS, VARIANTS, bkp_fill_value, table_sectors +from ..patterns import PATTERNS, SEED_MAX, Pattern, crc_text, generate +from .base import UINT32_MAX, FakeGroup, _choice, _fail, _hex, _number, _shape + +if TYPE_CHECKING: + from ..fake_firmware import FakeFirmware + +__all__ = ["FakeBackupRam", "FakeFlash", "FakeHsem", "FakeLowPower", "FakeWatchdogShare"] + +_PAYLOAD = 512 +_HEX_OUTPUT_MAX = 128 +_FIRST_PAGE = 64 +_END_PAGE = 128 +_FLASH_BASE = 0x08000000 +# The fake image ends a little below the region, like the real one (C.10 of the design: ~200 KB on STM32WB55). +_GEOMETRY = {"stm32wb55": (4096, 8, 50), "stm32wba55": (8192, 16, 24)} +_ERASE_US_PER_PAGE = {"stm32wb55": 22000, "stm32wba55": 3000} +_WRITE_US_PER_WORD = 90 +_ERASE_TIMEOUT = 10.0 +_TRANSFER_TIMEOUT = 2.0 +_FLASH_SEMAPHORE = 7 +_STACK_STATES = ("stopped", "starting", "fus") +_FLASH_OWNER = ("flash", "coord") + +_SEMAPHORES = 32 +_PROCESS_MAX = 255 +_HOLD_MAX_MS = 60000 +_LOCK_HOLD_MIN_US = 2 +_LOCK_HOLD_MAX_US = 65536 +_LOCK_HOLD_PROCESS = 1 +_CORE_CPU1 = 4 +_HSEM_OWNER = ("hsem", "lock") +_SCAFFOLD_TIMER = 17 + +_BKP_WORDS = {"stm32wb55": 20, "stm32wba55": 16} + +_LPM_DEFAULTS = {"stm32wb55": ("PC6", "PB0"), "stm32wba55": ("PB14", "PA2")} +_LPM_TIMEOUT_MAX_MS = 10000 +_LPM_OWNER = ("lpm", "enter") +_LPM_WAKE_US = 100 + + +def _payload(text: str, options: dict[str, str], capacity: int) -> bytes: + """`ParsePayload` of validation/firmware/Payload.cpp.""" + generated = "len" in options + patterned = "pattern" in options or "seed" in options + if text != "-": + if generated or patterned: + _fail("usage") + return _hex(text, capacity) + if not generated: + if patterned: + _fail("usage") + return b"" + pattern = _choice(options, "pattern", PATTERNS, "inc") + seed = _number(options.get("seed", "0"), 0, SEED_MAX) + length = _number(options["len"], 1, capacity) + return generate(length, cast(Pattern, pattern), seed) + + +@dataclass +class _Held: + """A coordinated erase or write waiting for HSEM 7 or for the end of `flash.stack starting`.""" + + job: str + apply: Callable[[], int] + started: float + deadline: float + timed_out: bool = False + + +class FakeFlash(FakeGroup): + prefix = "flash" + + def __init__(self, fw: FakeFirmware) -> None: + super().__init__(fw) + page, self.word, self.image = _GEOMETRY.get(fw.family, _GEOMETRY["stm32wb55"]) + self.page = page + self.pages = _END_PAGE - _FIRST_PAGE + self.memory = bytearray(b"\xff" * (self.pages * page)) + self.table = table_sectors(self.pages) + self.boot() + + def boot(self) -> None: + self.held: _Held | None = None + self.stack_held = False + self.layout = "homogeneous" + + @property + def base(self) -> int: + return _FLASH_BASE + _FIRST_PAGE * self.page + + def borrowing(self) -> bool: + """The coordinated driver exists (it holds the watchdog and HSEM 0).""" + return self.stack_held or self.held is not None + + def _sizes(self, layout: str) -> list[int]: + return self.table if layout == "table" else [1] * self.pages + + def _address(self, layout: str, sector: int) -> int: + return sum(self._sizes(layout)[:sector]) * self.page + + def _first(self, layout: str) -> int: + return min(self.image, len(self._sizes(layout))) + + def _driver(self, options: dict[str, str]) -> tuple[str, str]: + variant = _choice(options, "variant", VARIANTS, "sync") + layout = _choice(options, "layout", LAYOUTS, "homogeneous") + return variant, layout + + def _check_variant(self, variant: str) -> None: + if variant == "coord" and self.fw.family != "stm32wb55": + _fail("unsupported") + + def _semaphore_free(self) -> bool: + hsem = self.fw.group("hsem") + return not isinstance(hsem, FakeHsem) or hsem.holder(_FLASH_SEMAPHORE) is None + + def _reserve(self, variant: str, layout: str, synchronous_read: bool = False) -> None: + """`FlashCommands::Reserve`.""" + if synchronous_read: + return + if self.stack_held: + if self.held is not None or variant != "coord" or layout != self.layout: + _fail("busy") + return + if self.held is not None: + _fail("busy") + + def _release(self) -> None: + self.held = None + self.fw.release_resources(_FLASH_OWNER) + + def _run(self, variant: str, job: str, apply: Callable[[], int], timeout: float) -> str | None: + if variant != "coord": + return f"OK us={apply()}" + self.fw.claim_resource("hsem", 0, _FLASH_OWNER) + if not self.stack_held and self._semaphore_free(): + us = apply() + self._release() + return f"OK us={us}" + now = self.fw.clock() + self.held = _Held(job, apply, now, now + timeout) + return None + + def poll(self) -> None: + held = self.held + if held is None: + return + if not self.stack_held and self._semaphore_free(): + apply_us = held.apply() + us = max(apply_us, round((self.fw.clock() - held.started) * 1e6)) + self.held = None + if not self.stack_held: + self.fw.release_resources(_FLASH_OWNER) + self.fw.event(f"EVT flash op={held.job} us={us}" if held.timed_out else f"OK us={us}") + return + if not held.timed_out and self.fw.clock() >= held.deadline: + held.timed_out = True + self.fw.event("ERR timeout") + + def cmd_info(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 0, 0, ("variant", "layout")) + variant, layout = self._driver(options) + self._check_variant(variant) + sectors = len(self._sizes(layout)) + return ( + f"OK base=0x{self.base:08x} sectors={sectors} size={len(self.memory)} first={self._first(layout)} " + f"image={self.image} layout={layout}" + ) + + def cmd_erase(self, args: list[str], options: dict[str, str]) -> str | None: + _shape(args, options, 2, 2, ("variant", "layout")) + begin = _number(args[0]) + end = _number(args[1]) + variant, layout = self._driver(options) + sectors = len(self._sizes(layout)) + if begin < self._first(layout) or begin >= end or end > sectors: + _fail("range") + self._check_variant(variant) + self._reserve(variant, layout) + start, stop = self._address(layout, begin), self._address(layout, end) + + def apply() -> int: + self.memory[start:stop] = b"\xff" * (stop - start) + return (stop - start) // self.page * _ERASE_US_PER_PAGE.get(self.fw.family, 22000) + + return self._run(variant, "erase", apply, _ERASE_TIMEOUT) + + def cmd_write(self, args: list[str], options: dict[str, str]) -> str | None: + _shape(args, options, 2, 2, ("len", "pattern", "seed", "variant", "layout")) + address = _number(args[0]) + variant, layout = self._driver(options) + if self.held is not None: + _fail("busy") + data = _payload(args[1], options, _PAYLOAD) + if not data: + _fail("usage") + if address < self._address(layout, self._first(layout)) or address + len(data) > len(self.memory): + _fail("range") + self._check_variant(variant) + self._reserve(variant, layout) + first = address // self.word * self.word + last = min((address + len(data) + self.word - 1) // self.word * self.word, len(self.memory)) + if any(byte != 0xFF for byte in self.memory[first:last]): + _fail("failed") + + def apply() -> int: + self.memory[address : address + len(data)] = data + return (last - first) // self.word * _WRITE_US_PER_WORD + + return self._run(variant, "write", apply, _TRANSFER_TIMEOUT) + + def cmd_read(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 2, ("out", "variant", "layout")) + address = _number(args[0]) + length = _number(args[1], 1, UINT32_MAX) + output = _choice(options, "out", ("hex", "crc"), "hex") + variant, layout = self._driver(options) + if address + length > len(self.memory) or (output == "hex" and length > _HEX_OUTPUT_MAX): + _fail("range") + self._check_variant(variant) + self._reserve(variant, layout, synchronous_read=variant == "sync") + data = bytes(self.memory[address : address + length]) + if output == "crc": + return f"OK len={length} crc={crc_text(data)}" + return f"OK data={data.hex()}" + + def cmd_stack(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("layout",)) + state = args[0] + if state not in _STACK_STATES: + _fail("usage") + layout = _choice(options, "layout", LAYOUTS, "homogeneous") + if state != "starting": + if self.stack_held: + self.stack_held = False + if self.held is None: + self._release() + return "OK" + if self.stack_held: + _fail("busy") + self._reserve("coord", layout) + self.fw.claim_resource("hsem", 0, _FLASH_OWNER) + self.stack_held = True + self.layout = layout + return "OK" + + +class FakeWatchdogShare(FakeGroup): + """`wdt.start` of WatchDogFactory.cpp while the coordinated flash borrows the unstarted WWDG: `ERR busy` after the + argument checks; the other `wdt` commands stay with the fake firmware.""" + + prefix = "wdt" + + def cmd_start(self, args: list[str], options: dict[str, str]) -> str: + flash = self.fw.group("flash") + borrowed = isinstance(flash, FakeFlash) and flash.borrowing() and self.fw.watchdog is None + result = self.fw._cmd_wdt_start(args, options) + if borrowed: + self.fw.watchdog = None + self.fw.release(("wdt", args[0])) + _fail("busy") + return result + + +class FakeHsem(FakeGroup): + prefix = "hsem" + + def __init__(self, fw: FakeFirmware) -> None: + super().__init__(fw) + self.boot() + + def boot(self) -> None: + self.locks: dict[int, int] = {} + self.hold: tuple[int, int, float] | None = None + + def holder(self, semaphore: int) -> int | None: + """The process that holds `semaphore`, after the `hold=` timer had its turn.""" + if self.hold is not None and self.fw.clock() >= self.hold[2]: + held, process, _ = self.hold + self.hold = None + if self.locks.get(held) == process: + del self.locks[held] + return self.locks.get(semaphore) + + def poll(self) -> None: + self.holder(0) + + def _semaphore(self, text: str) -> int: + return _number(text, 0, _SEMAPHORES - 1) + + def _process(self, args: list[str], options: dict[str, str]) -> tuple[int, int]: + if "procid" not in options: + _fail("usage") + return self._semaphore(args[0]), _number(options["procid"], 1, _PROCESS_MAX) + + def cmd_take(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("procid", "hold")) + semaphore, process = self._process(args, options) + hold = _number(options["hold"], 1, _HOLD_MAX_MS) if "hold" in options else None + holder = self.holder(semaphore) + if hold is not None and self.hold is not None: + _fail("busy") + if holder is not None and holder != process: + _fail("busy") + self.locks[semaphore] = process + if hold is not None: + self.hold = (semaphore, process, self.fw.clock() + hold / 1000) + return "OK" + + def cmd_release(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("procid",)) + semaphore, process = self._process(args, options) + if self.holder(semaphore) == process: + del self.locks[semaphore] + if self.hold is not None and self.hold[:2] == (semaphore, process): + self.hold = None + return "OK" + + def cmd_status(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + process = self.holder(self._semaphore(args[0])) + if process is None: + return "OK locked=0 core=0 procid=0" + return f"OK locked=1 core={_CORE_CPU1} procid={process}" + + def cmd_lock(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("hold",)) + semaphore = self._semaphore(args[0]) + hold = _number(options["hold"], _LOCK_HOLD_MIN_US, _LOCK_HOLD_MAX_US) if "hold" in options else 0 + self.fw.check_resources(_HSEM_OWNER, [("hsem", 0)]) + if hold and self.fw.timer_owners.get(_SCAFFOLD_TIMER, _HSEM_OWNER) != _HSEM_OWNER: + _fail("busy") + if self.holder(semaphore) is not None: + _fail("busy") + return f"OK waited={hold}" + + def cmd_mine(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + return f"OK mine={int(self.holder(self._semaphore(args[0])) is not None)}" + + +class FakeBackupRam(FakeGroup): + prefix = "bkp" + + def __init__(self, fw: FakeFirmware) -> None: + super().__init__(fw) + self.words = [0] * _BKP_WORDS.get(fw.family, 20) + + def _index(self, text: str) -> int: + return _number(text, 0, len(self.words) - 1) + + def cmd_info(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 0, 0) + return f"OK words={len(self.words)}" + + def cmd_write(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 2) + index = self._index(args[0]) + self.words[index] = _number(args[1]) + return "OK" + + def cmd_read(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + return f"OK value={self.words[self._index(args[0])]:08x}" + + def cmd_fill(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + seed = _number(args[0]) + self.words = [bkp_fill_value(seed, index) for index in range(len(self.words))] + return "OK" + + def cmd_check(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + seed = _number(args[0]) + mismatches = sum(1 for index, word in enumerate(self.words) if word != bkp_fill_value(seed, index)) + return f"OK mismatches={mismatches}" + + +class FakeLowPower(FakeGroup): + prefix = "lpm" + owner: ClassVar[tuple[str, str]] = _LPM_OWNER + + def cmd_enter(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("wake", "edge", "marker", "timeout")) + if args[0] not in ("sleep", "deep"): + _fail("usage") + _choice(options, "edge", ("rising", "falling"), "rising") + if "timeout" in options: + _number(options["timeout"], 1, _LPM_TIMEOUT_MAX_MS) + default_wake, default_marker = _LPM_DEFAULTS.get(self.fw.family, _LPM_DEFAULTS["stm32wb55"]) + wake = self.fw.pin(options.get("wake")) or default_wake + marker = self.fw.pin(options.get("marker")) or default_marker + if wake == marker: + _fail("usage") + if not self.fw.bonded(wake) or not self.fw.bonded(marker): + _fail("pin") + if not self.fw.supports_interrupt(wake): + _fail("unsupported") + if self.fw.timer_owners.get(_SCAFFOLD_TIMER, self.owner) != self.owner: + _fail("busy") + self.fw.check_pins(self.owner, [wake, marker]) + return f"OK woke=exti restored=0 us={_LPM_WAKE_US}" diff --git a/validation/host/hal_st_validation/groups/system_ext.py b/validation/host/hal_st_validation/groups/system_ext.py new file mode 100644 index 00000000..ecc51d68 --- /dev/null +++ b/validation/host/hal_st_validation/groups/system_ext.py @@ -0,0 +1,274 @@ +"""System groups of validation/PROTOCOL.md: `flash` (`hal::FlashInternalStm`/`FlashHomogeneousInternalStm`, +`variant=sync` their `hal::Synchronous*` twins, on STM32WB55 `variant=coord` `hal::FlashCoordinatedWithWirelessStack`) +over the scratch region of the board, `hsem` (STM32WB55: `HAL_HSEM_*`, `hal::SynchronousHardwareSemaphoreStm`), `bkp` +(`hal::BackupRamStm`) and `lpm` (`hal::LowPowerModeStm`). All four are stateless; `flash.stack starting` and +`hsem.take` register their undo with `close_all`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from ad3_waveforms_bench.protocol import Event +from ad3_waveforms_bench.terminal import PendingCommand + +from ..patterns import Pattern +from .base import Group, Pin + +__all__ = [ + "BKP_FILL_STEP", + "GROUPS", + "LAYOUTS", + "VARIANTS", + "BackupRam", + "Flash", + "FlashInfo", + "Hsem", + "HsemStatus", + "LowPower", + "LowPowerWake", + "bkp_fill_value", + "flash_words", + "sector_addresses", + "table_sectors", +] + +Variant = Literal["sync", "async", "coord"] +Layout = Literal["homogeneous", "table"] +StackState = Literal["stopped", "starting", "fus"] +Output = Literal["hex", "crc"] +PowerMode = Literal["sleep", "deep"] + +VARIANTS: tuple[Variant, ...] = ("sync", "async", "coord") +LAYOUTS: tuple[Layout, ...] = ("homogeneous", "table") + +# `layout=table` (FlashGroup.cpp): single pages first, then this page pattern repeated twice, so the multi-page sectors +# keep indices past the image end page. +TABLE_PATTERN = (1, 1, 2, 4) +TABLE_PATTERN_REPEATS = 2 +# `bkp.fill`/`bkp.check` (BackupRamGroup.cpp): word i = seed ^ (step * (i + 1)), 32 bits. +BKP_FILL_STEP = 0x9E3779B9 +_MASK32 = 0xFFFFFFFF + + +def table_sectors(pages: int) -> list[int]: + """Sector sizes in pages of `layout=table` for a region of `pages` pages.""" + pattern = sum(TABLE_PATTERN) + repeats = min(TABLE_PATTERN_REPEATS, pages // pattern) + return [1] * (pages - repeats * pattern) + list(TABLE_PATTERN) * repeats + + +def sector_addresses(sizes: list[int], page: int) -> list[int]: + """Region-relative start address of every sector, plus the region size as the last entry.""" + addresses = [0] + for size in sizes: + addresses.append(addresses[-1] + size * page) + return addresses + + +def flash_words(address: int, length: int, word: int) -> range: + """Indices of the flash words (`word` bytes: 8 on STM32WB55, 16 on STM32WBA55) a write of `length` bytes at + `address` programs.""" + return range(address // word, (address + length + word - 1) // word) + + +def bkp_fill_value(seed: int, index: int) -> int: + return (seed ^ (BKP_FILL_STEP * (index + 1))) & _MASK32 + + +@dataclass(frozen=True) +class FlashInfo: + base: int + sectors: int + size: int + # The first sector `flash.erase`/`flash.write` accept: an unfixed erase cannot reach the running image from it on. + first: int + # The first absolute page past the running image. + image: int + layout: str + raw: str + + +class Flash(Group): + """Addresses are relative to the scratch region; sectors are those of the selected layout.""" + + prefix = "flash" + + def info(self, variant: Variant | None = None, layout: Layout | None = None) -> FlashInfo: + response = self._cmd("info", variant=variant, layout=layout) + return FlashInfo( + base=response.as_int("base"), + sectors=response.as_int("sectors"), + size=response.as_int("size"), + first=response.as_int("first"), + image=response.as_int("image"), + layout=response["layout"], + raw=response.raw, + ) + + def erase( + self, first: int, end: int, variant: Variant | None = None, layout: Layout | None = None, cmd_timeout: float | None = None + ) -> int: + """Erases sectors `first` up to `end` (exclusive); returns `us`.""" + return self._cmd("erase", first, end, variant=variant, layout=layout, cmd_timeout=cmd_timeout).as_int("us") + + def write( + self, + address: int, + data: bytes | None = None, + length: int | None = None, + pattern: Pattern | None = None, + seed: int | None = None, + variant: Variant | None = None, + layout: Layout | None = None, + cmd_timeout: float | None = None, + ) -> int: + """Writes `data`, or `length` bytes the firmware generates (`pattern`, `seed`); returns `us`.""" + return self.begin_write(address, data, length, pattern, seed, variant, layout, cmd_timeout).wait().as_int("us") + + def begin_write( + self, + address: int, + data: bytes | None = None, + length: int | None = None, + pattern: Pattern | None = None, + seed: int | None = None, + variant: Variant | None = None, + layout: Layout | None = None, + cmd_timeout: float | None = None, + ) -> PendingCommand: + payload = data if data is not None else b"" + return self.begin( + "write", address, payload, len=length, pattern=pattern, seed=seed, variant=variant, layout=layout, cmd_timeout=cmd_timeout + ) + + def read(self, address: int, length: int, variant: Variant | None = None, layout: Layout | None = None) -> bytes: + return self._cmd("read", address, length, variant=variant, layout=layout).as_bytes("data") + + def read_crc(self, address: int, length: int, variant: Variant | None = None, layout: Layout | None = None) -> int: + """CRC-32 of `length` bytes (`out=crc`, any length within the region).""" + response = self._cmd("read", address, length, out="crc", variant=variant, layout=layout) + if response.as_int("len") != length: + raise AssertionError(f"read {response['len']} bytes instead of {length}: {response.raw}") + return int(response["crc"], 16) + + def stack(self, state: StackState, layout: Layout | None = None) -> None: + """STM32WB55: `starting` keeps a coordinated driver whose steps wait until `stopped` or `fus`.""" + self._cmd("stack", state, layout=layout) + if state == "starting": + self._fw.track(("flash", "stack"), "flash.stack", "fus") + else: + self._fw.untrack(("flash", "stack")) + + def wait_done(self, timeout: float) -> Event: + """The `EVT flash` of an operation that completed after its `ERR timeout`.""" + return self._fw.terminal.wait_event("flash", timeout=timeout) + + +@dataclass(frozen=True) +class HsemStatus: + locked: bool + core: int + procid: int + + +class Hsem(Group): + """STM32WB55 hardware semaphores 0-31.""" + + prefix = "hsem" + + def take(self, semaphore: int, procid: int, hold: int | None = None) -> None: + """`hold` (ms): an EMIL timer releases it; without, `close_all` does.""" + self._cmd("take", semaphore, procid=procid, hold=hold) + if hold is None: + self._fw.track(("hsem", semaphore, procid), "hsem.release", semaphore, f"procid={procid}") + + def release(self, semaphore: int, procid: int) -> None: + self._cmd("release", semaphore, procid=procid) + self._fw.untrack(("hsem", semaphore, procid)) + + def status(self, semaphore: int) -> HsemStatus: + response = self._cmd("status", semaphore) + return HsemStatus(locked=response.as_bool("locked"), core=response.as_int("core"), procid=response.as_int("procid")) + + def lock(self, semaphore: int, hold: int | None = None, cmd_timeout: float | None = None) -> int: + """Locks through `hal::SynchronousHardwareSemaphoreStm`; with `hold` (us) the scaffold timer holds it that long + first. Returns `waited` (us).""" + return self._cmd("lock", semaphore, hold=hold, cmd_timeout=cmd_timeout).as_int("waited") + + def mine(self, semaphore: int) -> bool: + return self._cmd("mine", semaphore).as_bool("mine") + + +class BackupRam(Group): + prefix = "bkp" + + def info(self) -> int: + return self._cmd("info").as_int("words") + + def write(self, index: int, value: int) -> None: + self._cmd("write", index, f"0x{value:08x}") + + def read(self, index: int) -> int: + return int(self._cmd("read", index)["value"], 16) + + def fill(self, seed: int) -> None: + self._cmd("fill", seed) + + def check(self, seed: int) -> int: + """Words that differ from `bkp_fill_value(seed, i)`.""" + return self._cmd("check", seed).as_int("mismatches") + + +@dataclass(frozen=True) +class LowPowerWake: + woke: str + restored: int + us: int + raw: str + + +class LowPower(Group): + """`lpm.enter` masks every interrupt but the wake line and the scaffold timer: nothing may be sent to the board + until its final line.""" + + prefix = "lpm" + + def enter( + self, + mode: PowerMode, + wake: Pin | None = None, + edge: Literal["rising", "falling"] | None = None, + marker: Pin | None = None, + timeout: int | None = None, + cmd_timeout: float | None = None, + ) -> LowPowerWake: + return self.begin_enter(mode, wake, edge, marker, timeout, cmd_timeout).wait() + + def begin_enter( + self, + mode: PowerMode, + wake: Pin | None = None, + edge: Literal["rising", "falling"] | None = None, + marker: Pin | None = None, + timeout: int | None = None, + cmd_timeout: float | None = None, + ) -> PendingEnter: + limit = cmd_timeout if cmd_timeout is not None else self._fw.terminal.timeout + (timeout if timeout is not None else 2000) / 1000 + pending = self.begin("enter", mode, wake=self._pin(wake), edge=edge, marker=self._pin(marker), timeout=timeout, cmd_timeout=limit) + return PendingEnter(pending) + + +class PendingEnter: + """An `lpm.enter` whose final line is still to come.""" + + def __init__(self, pending: PendingCommand) -> None: + self.pending = pending + + def wait(self, timeout: float | None = None) -> LowPowerWake: + response = self.pending.wait(timeout) + return LowPowerWake(woke=response["woke"], restored=response.as_int("restored"), us=response.as_int("us"), raw=response.raw) + + +GROUPS: dict[str, type[Group]] = {"flash": Flash, "hsem": Hsem, "bkp": BackupRam, "lpm": LowPower} diff --git a/validation/host/tests/hil/test_backup_ram.py b/validation/host/tests/hil/test_backup_ram.py new file mode 100644 index 00000000..3f4349ae --- /dev/null +++ b/validation/host/tests/hil/test_backup_ram.py @@ -0,0 +1,65 @@ +"""Backup registers through the `bkp` group: `hal::BackupRamStm` used as `hal::BackupRam` (B.8: +public inheritance; on STM32WBA55 the driver also clocks the TAMP registers and opens the backup domain). STM32WB55: +RTC BKP0R-BKP19R; STM32WBA55: TAMP BKP0R-BKP15R. The words survive a system reset. No wiring. +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.groups.system_ext import bkp_fill_value + + +@pytest.fixture +def bkp_cfg(board_cfg): + return board_cfg.param("bkp") + + +def test_words(fw, bkp_cfg): + assert fw.bkp.info() == bkp_cfg["words"] + + +@pytest.mark.parametrize("pattern", [0xA5A5A5A5, 0x5A5A5A5A, 0x00000000, 0xFFFFFFFF]) +def test_write_read_every_word(fw, bkp_cfg, pattern): + """Each word holds its own value: written one by one, all read back, none disturbed by its neighbours.""" + words = bkp_cfg["words"] + values = [(pattern ^ (index * 0x01010101)) & 0xFFFFFFFF for index in range(words)] + for index, value in enumerate(values): + fw.bkp.write(index, value) + assert [fw.bkp.read(index) for index in range(words)] == values + + +@pytest.mark.parametrize("seed", [1, 0xDEADBEEF]) +def test_fill_and_check(fw, bkp_cfg, seed): + fw.bkp.fill(seed) + assert fw.bkp.check(seed) == 0 + assert fw.bkp.check(seed ^ 1) == bkp_cfg["words"] + assert fw.bkp.read(0) == bkp_fill_value(seed, 0) + + +@pytest.mark.resets_board +def test_retained_across_reset(fw, board_cfg, bkp_cfg): + """The backup domain keeps the words through a system reset.""" + seed = 0x13579BDF + fw.bkp.fill(seed) + fw.system.reset(timeout=board_cfg.param("system.boot_timeout", 5.0)) + assert fw.bkp.check(seed) == 0 + assert fw.bkp.read(bkp_cfg["words"] - 1) == bkp_fill_value(seed, bkp_cfg["words"] - 1) + + +def test_errors(fw, bkp_cfg): + words = bkp_cfg["words"] + for line, reason in [ + (f"bkp.read {words}", "range"), + (f"bkp.write {words} 1", "range"), + ("bkp.write 0 0x100000000", "usage"), + ("bkp.write 0", "usage"), + ("bkp.read", "usage"), + ("bkp.info 1", "usage"), + ("bkp.fill", "usage"), + ("bkp.check x", "usage"), + ]: + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line diff --git a/validation/host/tests/hil/test_flash.py b/validation/host/tests/hil/test_flash.py new file mode 100644 index 00000000..cbe5e9f9 --- /dev/null +++ b/validation/host/tests/hil/test_flash.py @@ -0,0 +1,314 @@ +"""Internal flash through the `flash` group, over the scratch region of the board (absolute pages 64-127): +`hal::FlashHomogeneousInternalStm` / `hal::FlashInternalStm` (`variant=async`), their synchronous twins +(`variant=sync`) and on STM32WB55 `hal::FlashCoordinatedWithWirelessStack` (`variant=coord`) over the async driver. + +`layout=homogeneous` has one sector per page; `layout=table` has single pages first and the page pattern 1, 1, 2, 4 +twice at the end (`groups.system_ext.table_sectors`). The firmware accepts erases and writes only from the sector +`first` on: an unfixed erase (B.5) takes the sector index for the absolute page, which from there on cannot reach the +running image. Every write targets flash words (8 bytes on STM32WB55, 16 on STM32WBA55) no earlier write programmed +since the last erase; programming one twice would fail the driver's assertion, so the firmware answers `ERR failed`. + +The coordinated driver waits for HSEM 7 (CPU2 holds it while it needs the flash), holds its steps while the wireless +stack is starting (`flash.stack starting` until `fus`), refreshes the watchdog it shares with `wdt` and excludes +`hsem.lock`. No wiring. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import patterns +from hal_st_validation.firmware import settle +from hal_st_validation.groups.system_ext import FlashInfo, flash_words, sector_addresses, table_sectors + + +@pytest.fixture +def flash_cfg(board_cfg): + return board_cfg.param("flash") + + +@dataclass(frozen=True) +class Geometry: + info: FlashInfo + layout: str + page: int + word: int + pages: list[int] + addresses: list[int] + + @classmethod + def read(cls, fw, flash_cfg, layout: str) -> Geometry: + info = fw.flash.info(layout=layout) + page = flash_cfg["page"] + count = info.size // page + pages = table_sectors(count) if layout == "table" else [1] * count + return cls(info, layout, page, flash_cfg["word"], pages, sector_addresses(pages, page)) + + def size(self, sector: int) -> int: + return self.pages[sector] * self.page + + def pick(self, pages: int, following: int = 1) -> int: + """The first accepted sector of `pages` pages followed by `following` accepted sectors.""" + for sector in range(self.info.first, len(self.pages) - following): + if self.pages[sector] == pages: + return sector + pytest.skip(f"no accepted {pages}-page sector in layout {self.layout} (first={self.info.first})") + + +def erased(length: int) -> int: + return patterns.crc32(b"\xff" * length) + + +def expect_reason(fw, line: str, reason: str) -> None: + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line + + +@pytest.mark.board_params("layout", "flash.layouts") +def test_geometry(fw, flash_cfg, layout): + """The region, its sectors and the first accepted sector agree with the board file and the layout rule; every + variant reports the same geometry.""" + geometry = Geometry.read(fw, flash_cfg, layout) + info = geometry.info + assert info.base == flash_cfg["base"], info.raw + assert info.size == flash_cfg["pages"] * flash_cfg["page"], info.raw + assert info.sectors == len(geometry.pages), info.raw + assert info.layout == layout + assert info.image <= flash_cfg["first_page"], f"the image reaches the scratch region: {info.raw}" + assert info.first == min(info.image, info.sectors), info.raw + assert info.first < info.sectors, f"no sector left to test: {info.raw}" + for variant in flash_cfg["variants"]: + other = fw.flash.info(variant=variant, layout=layout) + assert (other.base, other.sectors, other.size, other.first, other.image) == ( + info.base, + info.sectors, + info.size, + info.first, + info.image, + ) + + +@pytest.mark.board_params("variant", "flash.variants") +@pytest.mark.board_params("layout", "flash.layouts") +def test_erase_hits_region_page(fw, flash_cfg, variant, layout): + """Erasing sector r erases exactly its pages of the region (B.5): a marker in r disappears, the markers in the + next sector and the content of the previous one stay. The table layout erases a 2- and a 4-page sector.""" + geometry = Geometry.read(fw, flash_cfg, layout) + for pages in (2, 4) if layout == "table" else (1,): + sector = geometry.pick(pages) + start, following = geometry.addresses[sector], geometry.addresses[sector + 1] + marker = patterns.generate(geometry.word, "prbs", sector + 1) + fw.flash.erase(sector, sector + 2, variant=variant, layout=layout) + assert fw.flash.read_crc(start, geometry.size(sector) + geometry.size(sector + 1)) == erased( + geometry.size(sector) + geometry.size(sector + 1) + ) + previous = None + if sector - 1 >= geometry.info.first: + previous = fw.flash.read_crc(geometry.addresses[sector - 1], geometry.size(sector - 1)) + fw.flash.write(start, marker, variant=variant, layout=layout) + fw.flash.write(start + geometry.size(sector) - geometry.word, marker, variant=variant, layout=layout) + fw.flash.write(following, marker, variant=variant, layout=layout) + + fw.flash.erase(sector, sector + 1, variant=variant, layout=layout) + + assert fw.flash.read_crc(start, geometry.size(sector)) == erased(geometry.size(sector)), f"sector {sector} ({pages} pages)" + assert fw.flash.read(following, len(marker)) == marker, f"sector {sector + 1} after erasing {sector}" + if previous is not None: + assert fw.flash.read_crc(geometry.addresses[sector - 1], geometry.size(sector - 1)) == previous + + +@pytest.mark.board_params("variant", "flash.variants") +@pytest.mark.board_params("layout", "flash.layouts") +def test_write_shapes(fw, flash_cfg, variant, layout): + """Aligned, unaligned, odd-length, page-crossing and long writes on distinct flash words read back exactly, the + bytes around them stay erased, and every variant reads the same data.""" + geometry = Geometry.read(fw, flash_cfg, layout) + if layout == "table": + first = geometry.pick(2, following=0) + end = first + 1 + else: + first = geometry.pick(1, following=1) + end = first + 2 + base, word, page = geometry.addresses[first], geometry.word, geometry.page + span = geometry.addresses[end] - base + writes: list[tuple[int, bytes]] = [ + (base, patterns.generate(word, "inc", 0x10)), + (base + 2 * word + 3, patterns.generate(3, "inc", 0x20)), + (base + 4 * word + 1, patterns.generate(2 * word + 3, "prbs", 3)), + (base + page - 5, patterns.generate(2 * word, "inc", 0x40)), + ] + long_address, long_length = base + 16 * word, 300 + used = [set(flash_words(address, len(data), word)) for address, data in writes] + used.append(set(flash_words(long_address, long_length, word))) + assert all(a.isdisjoint(b) for index, a in enumerate(used) for b in used[index + 1 :]), "test writes share a flash word" + + fw.flash.erase(first, end, variant=variant, layout=layout) + model = bytearray(b"\xff" * span) + for address, data in writes: + fw.flash.write(address, data, variant=variant, layout=layout) + model[address - base : address - base + len(data)] = data + fw.flash.write(long_address, length=long_length, pattern="prbs", seed=11, variant=variant, layout=layout) + model[long_address - base : long_address - base + long_length] = patterns.generate(long_length, "prbs", 11) + + for reader in flash_cfg["variants"]: + for address, data in writes: + assert fw.flash.read(address, len(data), variant=reader, layout=layout) == data, f"{reader} at {address:#x}" + assert fw.flash.read_crc(base, span, variant=reader, layout=layout) == patterns.crc32(bytes(model)), reader + + +def test_programmed_word_is_refused(fw, flash_cfg): + """A second write to a programmed flash word answers `ERR failed` (the driver would assert); a word next to it + is still writable.""" + geometry = Geometry.read(fw, flash_cfg, "homogeneous") + sector = geometry.pick(1, following=0) + start, word = geometry.addresses[sector], geometry.word + fw.flash.erase(sector, sector + 1) + fw.flash.write(start + 1, b"\x5a") + expect_reason(fw, f"flash.write {start} 00", "failed") + expect_reason(fw, f"flash.write {start + word - 1} 0000", "failed") + fw.flash.write(start + word, b"\xa5") + assert fw.flash.read(start, word + 1) == b"\xff\x5a" + b"\xff" * (word - 2) + b"\xa5" + + +def test_erase_duration(fw, flash_cfg): + """A synchronous erase blocks for the page erase time of the flash (`us` per page within the board's bounds).""" + geometry = Geometry.read(fw, flash_cfg, "homogeneous") + sector = geometry.pick(1, following=1) + low, high = flash_cfg["erase_us_per_page"] + us = fw.flash.erase(sector, sector + 2) + assert 2 * low <= us <= 2 * high, f"{us} us for two pages" + + +def test_errors(fw, flash_cfg): + geometry = Geometry.read(fw, flash_cfg, "homogeneous") + first, sectors, size = geometry.info.first, geometry.info.sectors, geometry.info.size + start = geometry.addresses[first] + lines = [ + ("flash.erase", "usage"), + (f"flash.erase {first} {first + 1} variant=fast", "usage"), + (f"flash.erase {first} {first + 1} layout=uneven", "usage"), + (f"flash.erase {first} {first}", "range"), + (f"flash.erase {first} {sectors + 1}", "range"), + (f"flash.write {start} -", "usage"), + (f"flash.write {start} 0011 len=2", "usage"), + (f"flash.write {start} - pattern=inc", "usage"), + (f"flash.write {start} 001", "usage"), + (f"flash.write {start} - len=513", "range"), + (f"flash.write {size - 1} 0011", "range"), + (f"flash.read {size} 1", "range"), + ("flash.read 0 0", "range"), + ("flash.read 0 129", "range"), + ("flash.read 0 4 out=bin", "usage"), + ("flash.info 1", "usage"), + ] + if first > 0: + lines += [(f"flash.erase {first - 1} {first}", "range"), (f"flash.write {start - 1} 00", "range")] + if "coord" not in flash_cfg["variants"]: + lines += [("flash.info variant=coord", "unsupported"), ("flash.read 0 4 variant=coord", "unsupported")] + else: + lines += [("flash.stack running", "usage"), ("flash.stack", "usage")] + for line, reason in lines: + expect_reason(fw, line, reason) + + +@pytest.mark.family("stm32wb55") +@pytest.mark.parametrize("job", ["write", "erase"]) +def test_coord_waits_for_cpu2_semaphore(fw, flash_cfg, job): + """While HSEM 7 (CPU2's flash request) is held, a coordinated step waits; it runs once the semaphore is freed + (here by the `hold=` timer of `hsem.take`), which the HSEM interrupt reports to the driver.""" + geometry = Geometry.read(fw, flash_cfg, "homogeneous") + sector = geometry.pick(1, following=0) + start = geometry.addresses[sector] + data = patterns.generate(geometry.word * 2, "prbs", 5) + hold_ms, margin_ms = flash_cfg["hsem_hold_ms"], flash_cfg["hold_margin_ms"] + timeout = fw.terminal.timeout + hold_ms / 1000 + fw.flash.erase(sector, sector + 1) + if job == "erase": + fw.flash.write(start, data) + fw.hsem.take(7, procid=1, hold=hold_ms) + if job == "write": + us = fw.flash.write(start, data, variant="coord", cmd_timeout=timeout) + assert fw.flash.read(start, len(data)) == data + else: + us = fw.flash.erase(sector, sector + 1, variant="coord", cmd_timeout=timeout) + assert fw.flash.read_crc(start, geometry.size(sector)) == erased(geometry.size(sector)) + assert us >= (hold_ms - margin_ms) * 1000, f"{job} took {us} us with HSEM 7 held {hold_ms} ms" + assert not fw.hsem.status(7).locked + + +@pytest.mark.family("stm32wb55") +def test_coord_stack_starting_holds_until_fus(fw, flash_cfg): + """`flash.stack starting` (`WirelessStackStarting()`) holds a coordinated write: its `ERR timeout` goes out, the + flash stays erased, and `flash.stack fus` (`FirmwareUpgradeServicesReady()`) lets it complete with `EVT flash`.""" + geometry = Geometry.read(fw, flash_cfg, "homogeneous") + sector = geometry.pick(1, following=0) + start = geometry.addresses[sector] + data = patterns.generate(geometry.word, "inc", 0x33) + fw.flash.erase(sector, sector + 1) + fw.flash.stack("starting") + try: + held = fw.flash.begin_write(start, data, variant="coord", cmd_timeout=flash_cfg["transfer_timeout_s"] + 2) + response = settle(held) + assert response is not None and not response.ok and response.reason == "timeout", response + assert fw.flash.read(start, len(data)) == b"\xff" * len(data), "written while the stack was starting" + finally: + fw.flash.stack("fus") + event = fw.flash.wait_done(timeout=2.0) + assert event["op"] == "write", event.raw + assert fw.flash.read(start, len(data)) == data + + +@pytest.mark.family("stm32wb55") +def test_coord_excludes_hsem_lock(fw): + """The coordinated driver and `hsem.lock` both rely on the HSEM interrupt: they exclude each other (HSEM 0 of + `ResourceAllocation`).""" + fw.flash.stack("starting") + try: + expect_reason(fw, "hsem.lock 12", "busy") + finally: + fw.flash.stack("fus") + assert fw.hsem.lock(12) >= 0 + + +@pytest.fixture +def reset_afterwards(fw, board_cfg): + """A started watchdog cannot be stopped.""" + yield + boot_timeout = board_cfg.param("system.boot_timeout", 5.0) + fw.terminal.drain_events() + try: + fw.system.ping() + fw.system.reset(timeout=boot_timeout) + except Exception: # noqa: BLE001 - the board may be rebooting right now + fw.system.wait_boot(timeout=boot_timeout) + fw.terminal.drain_events() + + +@pytest.mark.family("stm32wb55") +@pytest.mark.resets_board +def test_watchdog_borrowed_while_stack_starting(fw, reset_afterwards): + """The coordinated driver borrows the unstarted WWDG: `wdt.start` answers `ERR busy` until it is given back.""" + fw.flash.stack("starting") + try: + expect_reason(fw, "wdt.start 0 timeout=100", "busy") + finally: + fw.flash.stack("fus") + fw.wdt.start(0, timeout=100, feed="auto") + + +@pytest.mark.family("stm32wb55") +@pytest.mark.resets_board +def test_coord_shares_running_watchdog(fw, flash_cfg, reset_afterwards): + """With the watchdog running (`feed=auto`), a coordinated erase refreshes it around every step it runs with the + interrupts masked, so the board does not reset.""" + geometry = Geometry.read(fw, flash_cfg, "homogeneous") + sector = geometry.pick(1, following=3) + fw.wdt.start(0, timeout=flash_cfg["watchdog_timeout_ms"], feed="auto") + fw.flash.erase(sector, sector + 4, variant="coord", cmd_timeout=fw.terminal.timeout + 2) + fw.terminal.collect_events("wdt", 0.3) + assert not fw.terminal.events("boot"), "the board reset during a coordinated erase" + fw.system.ping() diff --git a/validation/host/tests/hil/test_hsem.py b/validation/host/tests/hil/test_hsem.py new file mode 100644 index 00000000..df1bb778 --- /dev/null +++ b/validation/host/tests/hil/test_hsem.py @@ -0,0 +1,119 @@ +"""Hardware semaphores of the STM32WB55 through the `hsem` group: two-step locks per process (`HAL_HSEM_Take`/ +`HAL_HSEM_Release`), the lock state read from R (never RLR, whose read is itself a lock attempt), and +`hal::SynchronousHardwareSemaphoreStm` over `hal::SynchronousHardwareSemaphoreMasterStm`. + +`hsem.lock hold=` takes the semaphore for process 1 and lets the scaffold timer (TIM17) free it from its +interrupt after `hold`: the synchronous lock must wait that long (B.9: `WaitLock` used to return at once), and the +query `IsLockedByCurrentCore` must not lock a free semaphore. No wiring. +""" + +from __future__ import annotations + +import time + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +pytestmark = pytest.mark.family("stm32wb55") + + +@pytest.fixture +def hsem_cfg(board_cfg): + return board_cfg.param("hsem") + + +@pytest.fixture +def semaphore(fw, hsem_cfg): + number = hsem_cfg["semaphore"] + assert not fw.hsem.status(number).locked, f"semaphore {number} is held before the test" + yield number + + +def expect_reason(fw, line: str, reason: str) -> None: + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line + + +def test_take_and_release(fw, hsem_cfg, semaphore): + """A two-step lock shows this core and the process in R; release frees it.""" + fw.hsem.take(semaphore, procid=3) + status = fw.hsem.status(semaphore) + assert (status.locked, status.core, status.procid) == (True, hsem_cfg["core"], 3) + assert fw.hsem.mine(semaphore) + fw.hsem.release(semaphore, procid=3) + assert not fw.hsem.status(semaphore).locked + + +def test_other_process_is_refused(fw, semaphore): + """A semaphore held by one process cannot be taken by another; a release by the wrong process changes nothing.""" + fw.hsem.take(semaphore, procid=3) + expect_reason(fw, f"hsem.take {semaphore} procid=4", "busy") + fw.terminal.command(f"hsem.release {semaphore} procid=4") + assert fw.hsem.status(semaphore).procid == 3 + fw.hsem.release(semaphore, procid=3) + + +def test_hold_frees_the_semaphore(fw, semaphore): + """`hold=` frees the semaphore from an EMIL timer.""" + fw.hsem.take(semaphore, procid=2, hold=100) + assert fw.hsem.status(semaphore).locked + time.sleep(0.3) + assert not fw.hsem.status(semaphore).locked + + +@pytest.mark.board_params("hold_us", "hsem.holds_us") +def test_lock_waits_for_foreign_owner(fw, hsem_cfg, semaphore, hold_us): + """`SynchronousHardwareSemaphoreStm` waits until process 1 frees the semaphore from the timer interrupt, and + leaves it free afterwards (B.9).""" + waited = fw.hsem.lock(semaphore, hold=hold_us) + tolerance = hsem_cfg["tolerance"] + assert hold_us * (1 - tolerance) - hsem_cfg["free_wait_max_us"] <= waited <= hold_us * (1 + tolerance) + hsem_cfg["free_wait_max_us"] + assert not fw.hsem.status(semaphore).locked + + +def test_lock_of_free_semaphore_returns_at_once(fw, hsem_cfg, semaphore): + assert fw.hsem.lock(semaphore) <= hsem_cfg["free_wait_max_us"] + assert not fw.hsem.status(semaphore).locked + + +def test_lock_refuses_held_semaphore(fw, semaphore): + """Nothing could free a semaphore held by another process while the lock blocks the event loop: `ERR busy`.""" + fw.hsem.take(semaphore, procid=5) + expect_reason(fw, f"hsem.lock {semaphore}", "busy") + expect_reason(fw, f"hsem.lock {semaphore} hold=1000", "busy") + fw.hsem.release(semaphore, procid=5) + + +def test_query_takes_no_lock(fw, semaphore): + """`IsLockedByCurrentCore` reads R: a free semaphore is not ours and stays free (B.9).""" + assert not fw.hsem.mine(semaphore) + assert not fw.hsem.status(semaphore).locked + fw.hsem.take(semaphore, procid=7) + assert fw.hsem.mine(semaphore) + fw.hsem.release(semaphore, procid=7) + assert not fw.hsem.mine(semaphore) + + +@pytest.mark.parametrize( + "line, reason", + [ + ("hsem.take 1", "usage"), + ("hsem.take procid=1", "usage"), + ("hsem.take 32 procid=1", "range"), + ("hsem.take 1 procid=0", "range"), + ("hsem.take 1 procid=256", "range"), + ("hsem.take 1 procid=1 hold=0", "range"), + ("hsem.take 1 procid=1 hold=60001", "range"), + ("hsem.release 1", "usage"), + ("hsem.status", "usage"), + ("hsem.status 32", "range"), + ("hsem.lock 32", "range"), + ("hsem.lock 1 hold=1", "range"), + ("hsem.lock 1 hold=65537", "range"), + ("hsem.lock 1 procid=1", "usage"), + ("hsem.mine x", "usage"), + ], +) +def test_errors(fw, line, reason): + expect_reason(fw, line, reason) diff --git a/validation/host/tests/hil/test_low_power.py b/validation/host/tests/hil/test_low_power.py new file mode 100644 index 00000000..3d3670cf --- /dev/null +++ b/validation/host/tests/hil/test_low_power.py @@ -0,0 +1,117 @@ +"""Low-power mode through the `lpm` group: `hal::LowPowerModeStm::Enter` inside a window where only the wake line's +EXTI interrupt and the scaffold timer (TIM17, 1 us ticks, also the safety timeout) are enabled and the SysTick tick is +off. The marker pin is low while the core sleeps. + +`deep` maps to Sleep on STM32WB/WBA (`LowPowerModeStm::Stop`), so it behaves like `sleep` and never calls the +clock-restore callback (`restored=0`). The wake edge comes from the AD3 on the wake pin (bundle1 `gpio0`); the logic +analyzer sees the marker low before the edge and high right after it. +""" + +from __future__ import annotations + +import time + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.firmware import settle + + +@pytest.fixture +def lp_cfg(board_cfg): + return board_cfg.param("lowpower") + + +def expect_reason(fw, line: str, reason: str) -> None: + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line + + +@pytest.mark.ad3 +@pytest.mark.board_params("mode", "lowpower.modes") +@pytest.mark.board_params("edge", "lowpower.edges") +def test_wake_on_edge(request, fw, ad3, need, lp_cfg, mode, edge): + """The core sleeps (marker low) until the edge on the wake pin; the marker rises within `latency_us` of it and + `us` covers the time asleep.""" + if request.config.getoption("--fake"): + pytest.skip("the fake AD3 sees no board") + wake_dio, marker_dio = need.dio(lp_cfg["wake"]), need.dio(lp_cfg["marker"]) + idle, active = (0, 1) if edge == "rising" else (1, 0) + delay = lp_cfg["wake_delay_s"] + rate = min(ad3.logic.clock_hz, lp_cfg["la_rate"]) + samples = ad3.logic.buffer_size + ad3.dio.drive(wake_dio, idle) + capture = ad3.logic.arm(rate, samples, trigger=(wake_dio, edge), pretrigger=0.5) + timeout_ms = lp_cfg["wake_timeout_ms"] + pending = fw.lpm.begin_enter(mode, wake=lp_cfg["wake"], edge=edge, marker=lp_cfg["marker"], timeout=timeout_ms) + try: + time.sleep(delay) + ad3.dio.drive(wake_dio, active) + result = capture.wait(timeout=2.0) + wake = pending.wait() + finally: + settle(pending.pending, timeout_ms / 1000 + 1) + ad3.dio.release(wake_dio) + assert (wake.woke, wake.restored) == ("exti", 0), wake.raw + assert delay * 1e6 * 0.5 <= wake.us <= timeout_ms * 1000, wake.raw + marker = result.channel(marker_dio) + trigger = result.trigger_index + assert trigger is not None + assert all(bit == 0 for bit in marker[:trigger]), "the marker was high before the wake edge" + rises = [change.index for change in analysis.edges(marker) if change.index >= trigger and change.rising] + assert rises, "the marker did not rise after the wake edge" + latency = (rises[0] - trigger) / result.rate + assert latency <= lp_cfg["latency_us"] * 1e-6, f"woke {latency * 1e6:.1f} us after the edge" + + +def test_timeout(request, fw, need, lp_cfg): + """Without an edge the scaffold timer ends the window: `ERR timeout` after `timeout` ms; the board then answers, + and its tick runs again (`delay` lasts its time).""" + if request.config.getoption("--fake"): + pytest.skip("the fake firmware has no wake pin model: it wakes at once") + need.unloaded(lp_cfg["wake"], lp_cfg["marker"]) + timeout_ms = lp_cfg["timeout_ms"] + started = time.monotonic() + expect_reason(fw, f"lpm.enter sleep wake={lp_cfg['wake']} marker={lp_cfg['marker']} timeout={timeout_ms}", "timeout") + assert time.monotonic() - started >= timeout_ms / 1000 * 0.9 + fw.system.ping() + started = time.monotonic() + fw.system.delay(100) + assert time.monotonic() - started >= 0.09, "the SysTick tick did not resume" + + +def test_pins_released(fw, lp_cfg): + """`lpm.enter` frees the wake and marker pins and the scaffold timer, whatever its outcome.""" + try: + fw.lpm.enter("sleep", wake=lp_cfg["wake"], marker=lp_cfg["marker"], timeout=lp_cfg["timeout_ms"]) + except FirmwareError as error: + assert error.reason == "timeout" + for pin in (lp_cfg["wake"], lp_cfg["marker"]): + fw.gpio.cfg(pin, "in") + fw.gpio.release(pin) + + +def test_errors(fw, lp_cfg): + wake, marker = lp_cfg["wake"], lp_cfg["marker"] + lines = [ + ("lpm.enter", "usage"), + ("lpm.enter nap", "usage"), + ("lpm.enter sleep edge=both", "usage"), + ("lpm.enter sleep timeout=0", "range"), + ("lpm.enter sleep timeout=10001", "range"), + (f"lpm.enter sleep wake={wake} marker={wake}", "usage"), + ("lpm.enter sleep wake=PA16", "pin"), + (f"lpm.enter sleep wake={lp_cfg['unbonded_pin']}", "pin"), + (f"lpm.enter sleep marker={lp_cfg['unbonded_pin']}", "pin"), + ("lpm.enter sleep extra", "usage"), + ] + for line, reason in lines: + expect_reason(fw, line, reason) + + fw.gpio.cfg(marker, "out") + try: + expect_reason(fw, f"lpm.enter sleep wake={wake} marker={marker} timeout=1", "busy") + finally: + fw.gpio.release(marker) diff --git a/validation/host/tests/unit/test_fake_system_ext.py b/validation/host/tests/unit/test_fake_system_ext.py new file mode 100644 index 00000000..1fd5dda2 --- /dev/null +++ b/validation/host/tests/unit/test_fake_system_ext.py @@ -0,0 +1,326 @@ +"""The fake `flash`, `hsem`, `bkp` and `lpm` groups (argument order and reasons of validation/firmware/FlashGroup.cpp, +HsemGroup.cpp, BackupRamGroup.cpp and LowPowerGroup.cpp; PROTOCOL.md D.17-D.19), the `wdt.start` refusal while the +coordinated flash borrows the watchdog, and the `groups.system_ext` wrappers and helpers.""" + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal + +from hal_st_validation import patterns +from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS, FakeFirmware, FakeSerial +from hal_st_validation.firmware import Firmware +from hal_st_validation.groups.system_ext import bkp_fill_value, flash_words, sector_addresses, table_sectors + +WB55_FIRST = 50 +WBA55_FIRST = 24 + + +class Clock: + def __init__(self) -> None: + self.now = 100.0 + + def __call__(self) -> float: + return self.now + + def advance(self, seconds: float) -> None: + self.now += seconds + + +def make(family="stm32wb55", clock=None): + # Final lines end with a line break (as the HIL conftest sets it up), so an `EVT` printed right after a final + # line cannot join it and the prompt. + fake = ( + FakeFirmware(family=family, style="line") + if clock is None + else FakeFirmware(family=family, style="line", clock=clock, sleep=clock.advance) + ) + terminal = FirmwareTerminal(serial=FakeSerial(fake), timeout=0.5) + return terminal, fake, Firmware(terminal, WB55_PINS if family == "stm32wb55" else WBA55_PINS) + + +def reason(terminal, line): + response = terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +def test_table_sectors(): + assert table_sectors(64) == [1] * 48 + [1, 1, 2, 4, 1, 1, 2, 4] + assert table_sectors(12) == [1] * 4 + [1, 1, 2, 4] + assert table_sectors(7) == [1] * 7 + assert sum(table_sectors(64)) == 64 + assert sector_addresses([1, 2, 4], 4096) == [0, 4096, 12288, 28672] + + +def test_flash_words_and_fill_value(): + assert flash_words(0, 8, 8) == range(0, 1) + assert flash_words(5, 4, 8) == range(0, 2) + assert flash_words(8187, 32, 16) == range(511, 514) + assert bkp_fill_value(0, 0) == 0x9E3779B9 + assert bkp_fill_value(0xFFFFFFFF, 1) == (0xFFFFFFFF ^ (2 * 0x9E3779B9)) & 0xFFFFFFFF + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("flash.info", "ok"), + ("flash.info 1", "usage"), + ("flash.info variant=fast", "usage"), + ("flash.info layout=table variant=coord", "ok"), + ("flash.erase", "usage"), + ("flash.erase 50", "usage"), + ("flash.erase x 51", "usage"), + ("flash.erase 50 51 variant=fast", "usage"), + ("flash.erase 49 50", "range"), + ("flash.erase 50 50", "range"), + ("flash.erase 63 65", "range"), + ("flash.erase 50 57 layout=table", "range"), + ("flash.erase 50 51", "ok"), + ("flash.write 204800 -", "usage"), + ("flash.write 204800 0011 len=2", "usage"), + ("flash.write 204800 - seed=1", "usage"), + ("flash.write 204800 001", "usage"), + ("flash.write 204800 - len=513", "range"), + ("flash.write 204799 00", "range"), + ("flash.write 262143 0011", "range"), + ("flash.read 262144 1", "range"), + ("flash.read 0 0", "range"), + ("flash.read 0 129", "range"), + ("flash.read 0 4 out=bin", "usage"), + ("flash.read 0 262144 out=crc", "ok"), + ("flash.stack", "usage"), + ("flash.stack running", "usage"), + ("flash.stack fus layout=uneven", "usage"), + ("flash.stack fus", "ok"), + ], +) +def test_flash_errors_wb55(line, expected): + terminal, _, _ = make() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("flash.info variant=coord", "unsupported"), + ("flash.erase 24 25 variant=coord", "unsupported"), + ("flash.erase 23 24 variant=coord", "range"), + ("flash.read 0 129 variant=coord", "range"), + ("flash.read 0 4 variant=coord", "unsupported"), + ("flash.stack starting", "unsupported"), + ("flash.erase 24 25", "ok"), + ], +) +def test_flash_errors_wba55(line, expected): + terminal, _, _ = make("stm32wba55") + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("family", "first", "page", "base"), [("stm32wb55", WB55_FIRST, 4096, 0x08040000), ("stm32wba55", WBA55_FIRST, 8192, 0x08080000)] +) +def test_flash_geometry(family, first, page, base): + _, _, fw = make(family) + info = fw.flash.info() + assert (info.base, info.sectors, info.size, info.first, info.layout) == (base, 64, 64 * page, first, "homogeneous") + table = fw.flash.info(layout="table") + assert (table.sectors, table.first) == (56, first) + + +@pytest.mark.parametrize("variant", ["sync", "async", "coord"]) +@pytest.mark.parametrize("layout", ["homogeneous", "table"]) +def test_flash_memory_model(variant, layout): + """Erase gives FF over the sector's pages only; writes land where addressed; a programmed word is refused.""" + terminal, _, fw = make() + sizes = table_sectors(64) if layout == "table" else [1] * 64 + addresses = sector_addresses(sizes, 4096) + sector = 50 + start, end = addresses[sector], addresses[sector + 1] + fw.flash.erase(sector, sector + 2, variant=variant, layout=layout) + data = patterns.generate(20, "prbs", 3) + fw.flash.write(start + 3, data, variant=variant, layout=layout) + fw.flash.write(end, b"\x77", variant=variant, layout=layout) + assert fw.flash.read(start, 24, variant=variant, layout=layout) == b"\xff" * 3 + data + b"\xff" + assert reason(terminal, f"flash.write {start + 22} 00 layout={layout}") == "failed" + fw.flash.write(start + 24, length=300, pattern="inc", seed=9, variant=variant, layout=layout) + assert fw.flash.read_crc(start + 24, 300, variant=variant, layout=layout) == patterns.crc32(patterns.generate(300, "inc", 9)) + fw.flash.erase(sector, sector + 1, variant=variant, layout=layout) + assert fw.flash.read_crc(start, end - start) == patterns.crc32(b"\xff" * (end - start)) + assert fw.flash.read(end, 1) == b"\x77" + + +def test_coord_waits_for_semaphore_7(): + clock = Clock() + terminal, _, fw = make(clock=clock) + fw.flash.erase(WB55_FIRST, WB55_FIRST + 1) + fw.hsem.take(7, procid=1, hold=200) + pending = fw.flash.begin_write(WB55_FIRST * 4096, b"\x01\x02", variant="coord") + clock.advance(0.25) + response = pending.wait() + assert response.as_int("us") >= 200000 + assert not fw.hsem.status(7).locked + assert reason(terminal, "hsem.lock 3") == "ok" + + +def test_coord_held_while_stack_starting(): + """A held write answers `ERR timeout` after 2 s and completes after `fus` with `EVT flash`; meanwhile the + watchdog and HSEM 0 are borrowed and only synchronous reads and the stack command go through.""" + clock = Clock() + terminal, fake, fw = make(clock=clock) + start = WB55_FIRST * 4096 + fw.flash.erase(WB55_FIRST, WB55_FIRST + 1) + fw.flash.stack("starting") + assert reason(terminal, "flash.stack starting") == "busy" + assert reason(terminal, "wdt.start 0 timeout=100") == "busy" + assert reason(terminal, "wdt.start 0") == "usage" + assert reason(terminal, "hsem.lock 1") == "busy" + assert reason(terminal, f"flash.erase {WB55_FIRST} {WB55_FIRST + 1}") == "busy" + assert reason(terminal, f"flash.erase {WB55_FIRST} {WB55_FIRST + 1} variant=coord layout=table") == "busy" + pending = fw.flash.begin_write(start, b"\xab", variant="coord") + clock.advance(2.1) + response = pending.wait(check=False) + assert (response.ok, response.reason) == (False, "timeout") + assert fw.flash.read(start, 1) == b"\xff" + assert reason(terminal, f"flash.write {start + 8} 01 variant=coord") == "busy" + fw.flash.stack("fus") + event = fw.flash.wait_done(timeout=0.5) + assert event["op"] == "write" + assert fw.flash.read(start, 1) == b"\xab" + assert fake.watchdog is None + assert reason(terminal, "wdt.start 0 timeout=100") == "ok" + + +def test_coord_timeout_completes_once_semaphore_7_is_released(): + """A coordinated step stuck on HSEM 7 past its `ERR timeout` keeps the group busy and completes, with `EVT flash`, + once the semaphore is released.""" + clock = Clock() + terminal, _, fw = make(clock=clock) + start = WB55_FIRST * 4096 + fw.flash.erase(WB55_FIRST, WB55_FIRST + 1) + fw.hsem.take(7, procid=2) + pending = fw.flash.begin_write(start, b"\x01", variant="coord") + assert reason_after(pending, clock, 2.1) == "timeout" + assert reason(terminal, f"flash.erase {WB55_FIRST} {WB55_FIRST + 1} variant=async") == "busy" + assert reason(terminal, f"flash.write {start + 8} zz") == "busy" + assert fw.flash.read(start, 1) == b"\xff" + fw.hsem.release(7, procid=2) + assert fw.flash.wait_done(timeout=0.5)["op"] == "write" + assert fw.flash.read(start, 1) == b"\x01" + + +def reason_after(pending, clock, seconds): + clock.advance(seconds) + response = pending.wait(check=False) + return "ok" if response.ok else response.reason + + +def test_hsem_state(): + clock = Clock() + terminal, _, fw = make(clock=clock) + fw.hsem.take(4, procid=9) + status = fw.hsem.status(4) + assert (status.locked, status.core, status.procid) == (True, 4, 9) + assert fw.hsem.mine(4) + assert reason(terminal, "hsem.take 4 procid=8") == "busy" + assert reason(terminal, "hsem.take 4 procid=9") == "ok" + assert reason(terminal, "hsem.lock 4") == "busy" + fw.hsem.release(4, procid=9) + assert not fw.hsem.mine(4) + fw.hsem.take(5, procid=1, hold=100) + assert reason(terminal, "hsem.take 6 procid=1 hold=100") == "busy" + clock.advance(0.2) + assert not fw.hsem.status(5).locked + assert fw.hsem.lock(5, hold=1500) == 1500 + assert fw.hsem.lock(5) == 0 + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("hsem.take 1", "usage"), + ("hsem.take procid=1", "usage"), + ("hsem.take 32 procid=1", "range"), + ("hsem.take 1 procid=0", "range"), + ("hsem.take 1 procid=1 hold=60001", "range"), + ("hsem.release 1", "usage"), + ("hsem.status 32", "range"), + ("hsem.lock 1 hold=1", "range"), + ("hsem.lock 1 hold=65537", "range"), + ("hsem.lock 1 procid=1", "usage"), + ("hsem.mine", "usage"), + ], +) +def test_hsem_errors(line, expected): + terminal, _, _ = make() + assert reason(terminal, line) == expected + + +def test_hsem_lock_needs_scaffold_timer(): + terminal, fake, _ = make() + fake.timer_owners[17] = ("tim", "17") + assert reason(terminal, "hsem.lock 1 hold=100") == "busy" + assert reason(terminal, "hsem.lock 1") == "ok" + + +@pytest.mark.parametrize(("family", "words"), [("stm32wb55", 20), ("stm32wba55", 16)]) +def test_backup_ram(family, words): + terminal, _, fw = make(family) + assert fw.bkp.info() == words + fw.bkp.write(words - 1, 0x89ABCDEF) + assert fw.bkp.read(words - 1) == 0x89ABCDEF + assert terminal.command(f"bkp.read {words - 1}")["value"] == "89abcdef" + fw.bkp.fill(7) + assert fw.bkp.check(7) == 0 + assert fw.bkp.check(8) == words + fw.system.reset() + assert fw.bkp.check(7) == 0 + for line, expected in [(f"bkp.read {words}", "range"), ("bkp.write 0 0x100000000", "usage"), ("bkp.info 0", "usage")]: + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("lpm.enter", "usage"), + ("lpm.enter nap", "usage"), + ("lpm.enter sleep extra", "usage"), + ("lpm.enter sleep edge=both", "usage"), + ("lpm.enter sleep timeout=0", "range"), + ("lpm.enter sleep timeout=10001", "range"), + ("lpm.enter sleep wake=PA16", "pin"), + ("lpm.enter sleep wake=gpio0 marker=gpio0", "usage"), + ("lpm.enter sleep wake=PC7", "pin"), + ("lpm.enter deep marker=PE0", "pin"), + ("lpm.enter deep", "ok"), + ], +) +def test_lpm_errors_wb55(line, expected): + terminal, _, _ = make() + assert reason(terminal, line) == expected + + +def test_lpm_wakes_and_checks_resources(): + terminal, fake, fw = make("stm32wba55") + wake = fw.lpm.enter("sleep") + assert (wake.woke, wake.restored) == ("exti", 0) + fw.gpio.cfg("tim1bkin", "out") + assert reason(terminal, "lpm.enter sleep") == "busy" + fw.gpio.release("tim1bkin") + fake.timer_owners[17] = ("tim", "17") + assert reason(terminal, "lpm.enter sleep") == "busy" + + +def test_groups_track_their_undo(): + _, _, fw = make() + fw.flash.stack("starting") + fw.hsem.take(9, procid=4) + fw.hsem.take(10, procid=4, hold=1000) + assert ("flash", "stack") in fw.open_instances + assert ("hsem", 9, 4) in fw.open_instances + assert ("hsem", 10, 4) not in fw.open_instances + assert fw.close_all() == [] + assert not fw.hsem.status(9).locked + fw.flash.stack("starting", layout="table") + with pytest.raises(FirmwareError) as error: + fw.flash.stack("starting") + assert error.value.reason == "busy" + fw.flash.stack("stopped") + assert ("flash", "stack") not in fw.open_instances From 98f2c0bc58945d71ac6b1f40b4c5dd77b71650b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 04:41:14 +0000 Subject: [PATCH 46/57] feat(validation): validate QUADSPI on the NUCLEO-WB55RG Add the qspi command group (PROTOCOL.md "QUADSPI"), STM32WB55 only: - qspi.open builds QuadSpiStm (variant=poll), QuadSpiStmDma on DMA2 channel 3 (variant=dma) or QuadSpiStmDma with SingleSpeedQuadSpiStmDma on top (variant=spi), over the six pins of the board profile, and reports the bus clock (HCLK4 / (prescaler+1)). - qspi.cmd issues one SendData/ReceiveData with every phase optional (instruction, address, alternate bytes, dummy cycles, data on 1 or 4 lines); writes answer flevel, the FIFO level read in the completion callback, and repeat= issues the next write from that callback. - qspi.poll runs PollStatus; qspi.xfer runs SingleSpeedQuadSpiStmDma as a half-duplex SpiMaster. The group is a factory plus a HilSingleInstanceGroup. Commands answer through a HilPendingOperation (ERR timeout after 2000 ms; the group stays busy until qspi.close). Close cancels the operation and destroys the driver one event-loop turn later, after stopping its DMA channel, which HAL_DMA_Init would otherwise keep enabled for the next open. The DMA variants hold DMA2 channel 3 in ResourceAllocation. STM32WBA55 answers the qspi names with ERR unsupported. Host: groups/qspi.py (fw.qspi and the decoding of QUADSPI captures), fakes/qspi.py, test_qspi.py and the board YAML. The AD3 never drives a line the QUADSPI drives: writes are decoded on the logic analyzer, and reads take static AD3 levels only on lines the QUADSPI receives on (IO1 in single-line mode; IO0-IO3 in a 4-line read without instruction, address or alternate bytes, with two dummy cycles against the QUADSPI data-phase-only read erratum, and the weakest AD3 drive). Every case passes the load gate, so --with loopback, spiloop or i2c skip them. QuadSpiStm's endless PollStatus (B.14) is a hanging known gap of test_poll_timeout[poll]. test_fake_groups.py loses its stand-in qspi fake group, which now clashes with fakes/qspi.py. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- validation/PROTOCOL.md | 25 + validation/README.md | 5 + validation/firmware/CMakeLists.txt | 2 + validation/firmware/Main.cpp | 6 + validation/firmware/QuadSpiGroup.cpp | 505 ++++++++++++++++++ validation/firmware/QuadSpiGroup.hpp | 128 +++++ validation/firmware/UnsupportedGroups.cpp | 7 +- validation/host/boards/nucleo_wb55rg.yaml | 49 ++ .../host/hal_st_validation/fakes/qspi.py | 193 +++++++ .../host/hal_st_validation/groups/qspi.py | 235 ++++++++ validation/host/tests/hil/test_qspi.py | 455 ++++++++++++++++ .../host/tests/unit/test_fake_groups.py | 9 +- validation/host/tests/unit/test_fake_qspi.py | 357 +++++++++++++ 13 files changed, 1967 insertions(+), 9 deletions(-) create mode 100644 validation/firmware/QuadSpiGroup.cpp create mode 100644 validation/firmware/QuadSpiGroup.hpp create mode 100644 validation/host/hal_st_validation/fakes/qspi.py create mode 100644 validation/host/hal_st_validation/groups/qspi.py create mode 100644 validation/host/tests/hil/test_qspi.py create mode 100644 validation/host/tests/unit/test_fake_qspi.py diff --git a/validation/PROTOCOL.md b/validation/PROTOCOL.md index 2ebade80..dfdd76ab 100644 --- a/validation/PROTOCOL.md +++ b/validation/PROTOCOL.md @@ -465,6 +465,31 @@ Not a hal-st driver (hal-st has no I2C slave): the other end of the bus for the - the wake and marker pins must differ (`ERR usage`); a pin the package lacks returns `ERR pin`, a wake pin whose EXTI line serves a pin of another port `ERR unsupported`, a held pin or TIM17 held by another group `ERR busy` - the terminal and every other interrupt (including the watchdog's early warning: a running watchdog is not fed) are blocked for the window: send nothing until the final line +## QUADSPI (`hal::QuadSpiStm`, `variant=dma` selects `hal::QuadSpiStmDma`, `variant=spi` adds `hal::SingleSpeedQuadSpiStmDma`, NUCLEO-WB55RG only) + +- `qspi.open <1> [variant=poll|dma|spi] [prescaler=<0-255>] [size=<1-32>]` → `OK clk=` + - QUADSPI 1 is the only instance (`ERR range` otherwise); the pins come from the board profile: `qspiclk` PA3, `qspincs` PA2, `qspiio0`-`qspiio3` PB9, PB8, PA7, PA6 (all six held, `ERR busy` when one is taken) + - defaults `variant=poll prescaler=2 size=24`; `clk` is the QUADSPI kernel clock (HCLK4) divided by `prescaler + 1`; `size` is the flash size as log2 of bytes (DCR FSIZE + 1) + - `variant=poll` is the blocking `QuadSpiStm` (HAL); `variant=dma` is `QuadSpiStmDma` on DMA2 channel 3; `variant=spi` is `QuadSpiStmDma` with `SingleSpeedQuadSpiStmDma` on top, for `qspi.xfer` +- `qspi.cmd <1> [instr=<0-0xff>] [addr= [abytes=1-4]] [alt= [altbytes=1-4]] [dummy=<0-31>] [lines=1|4] [tx=|len= [pattern=] [seed=]|rx= [out=hex|crc]] [repeat=<1-8>]` → writes `OK flevel=`, reads `OK data=` (or `OK len= crc=` with `out=crc`) + - one command of `hal::QuadSpi` (`SendData` without `rx`, `ReceiveData` with it) on every variant; every phase is optional: without `instr`, `addr` and `alt` the command has no instruction, address or alternate-byte phase, and without `tx`/`len`/`rx` no data phase + - `addr` takes `abytes` bytes (default 3) and `alt` `altbytes` bytes (default 1), both sent most significant byte first; a value wider than its bytes returns `ERR range`, `abytes` without `addr` or `altbytes` without `alt` `ERR usage` + - `lines` (default 1) applies to every phase; 2 returns `ERR range` + - `tx` is the data in hex; `len` generates it (`pattern=inc|const|prbs` `seed=`, see "Line length" in General), 1-256 bytes; `rx` reads 1-256 bytes, above 128 only with `out=crc`; at most one of `tx`, `len` and `rx` + - `flevel` is the QUADSPI FIFO level read in the completion callback: 0 once the last byte has left the pins + - `repeat` issues the write again from each completion callback (writes only) and answers after the last one + - a read with a data phase only hangs the QUADSPI with BUSY set (STM32 QUADSPI erratum "cannot be used in indirect read mode when only data phase is activated"): `variant=dma` answers `ERR timeout`, `variant=poll` blocks the firmware; add `dummy=2` to such a read, the erratum's workaround, which drives no IO line either +- `qspi.poll <1> match= mask= [size=1-4] [instr=] [addr=] [abytes=] [alt=] [altbytes=] [dummy=] [lines=1|4]` → `OK` + - `hal::QuadSpi::PollStatus`: reads `size` status bytes (default 1) until the status masked with `mask` equals `match` + - `variant=dma`: `ERR timeout` after 2000 ms; the group then answers `ERR busy` until `qspi.close`, which stops the polling (`~QuadSpiStmDma` clears CR) + - `variant=poll` blocks the firmware until the status matches (`QuadSpiStm` waits with `HAL_MAX_DELAY`, a known gap) +- `qspi.xfer <1> [len= [pattern=] [seed=]] [rx=<1-128>] [repeat=<1-8>]` → writes `OK flevel=`, reads `OK rx=` + - `variant=spi` only (`ERR unsupported` otherwise): `SingleSpeedQuadSpiStmDma::SendAndReceive`, SPI mode 0 with MOSI on IO0, MISO on IO1 and NCS as chip select + - half duplex: exactly one of the data (`txhex` or `len`) and `rx`, else `ERR usage`; `repeat` as for `qspi.cmd` + - a read has a data phase only, so the erratum above applies to it +- `qspi.close <1>` → `OK` +- `qspi.cmd`, `qspi.poll` and `qspi.xfer` answer within 2000 ms or `ERR timeout`; a command that timed out keeps the group busy until `qspi.close` + ## Not available on these boards hal-st has no comparator, CAN or Ethernet driver for STM32WB55/STM32WBA55, so `comp.open`, `comp.read`, `comp.irq`, `comp.count`, `comp.close`, `can.open`, `can.send`, `can.close`, `eth.open`, `eth.status` and `eth.close` return `ERR unsupported`. diff --git a/validation/README.md b/validation/README.md index 1ffe1cf3..ee85a1a1 100644 --- a/validation/README.md +++ b/validation/README.md @@ -494,6 +494,10 @@ SPI1 to SPI3; SPI3 is observed through DIO14, DIO10, DIO11 and DIO15. The PB8 en - On the NUCLEO-WB55RG the synchronized variant keeps HSI48 running when it was on and switches it off again when it was off, takes its locked branch with semaphore 5 held (`lock5=1`), and leaves HSEM semaphores 0 and 5 free (`hsem.status`, when that group is built in). - `test_aes.py` - for `SynchronousAes128EcbStm`: the FIPS-197 C.1 and SP 800-38A F.1.1 known answers in both directions, block by block and four blocks at once, one to five blocks per command, a key change between commands, and every data swapping mode (`swap=none|half|byte|bit`) against the model of `crypto_ref`; argument errors. - `test_pka.py` - for `PkaStm` on secp256r1: G, 2G and 3G, the default operands, a short scalar padded by the firmware, (n - 1)G = -G, the NIST CAVP ECC CDH vector, a given base point, points on and off the curve, comparisons of 4, 32 and 60-byte numbers, the duration of one multiplication and argument errors; every result against the P-256 arithmetic of `crypto_ref`. +- `test_qspi.py` (NUCLEO-WB55RG) - for `QuadSpiStm` (`variant=poll`), `QuadSpiStmDma` (`variant=dma`) and `SingleSpeedQuadSpiStmDma` (`variant=spi`): instruction, address, alternate bytes and data of writes decoded on the logic analyzer on 1 and on 4 lines, and SPI mode 0 transfers. + - Reads: 4-line reads of static AD3 levels, a status poll that matches and one that times out (`variant=dma`; `variant=poll` hangs, a known gap). + - Also the B.15 fix: writes answer the FIFO level of their completion callback (`flevel=0`) and two writes issued from one completion callback both reach the bus; the clock, the CRC of 256-byte reads, argument errors and the pins held while open. + - The AD3 never drives a line the QUADSPI drives: it decodes writes and holds only lines the QUADSPI receives on, with its weakest drive. Every case skips while `--with loopback`, `--with spiloop` or `--with i2c` loads a QUADSPI pin. - `test_backup_ram.py` - `hal::BackupRamStm` as `hal::BackupRam` (RTC BKP0R-BKP19R on the NUCLEO-WB55RG, TAMP BKP0R-BKP15R on the NUCLEO-WBA55CG): the word count, every word written and read back with four patterns, fill and check, the words kept through a reset, argument errors. - `test_flash.py` - the internal flash drivers (`FlashHomogeneousInternalStm`, `FlashInternalStm`, their synchronous twins and, on the NUCLEO-WB55RG, `FlashCoordinatedWithWirelessStack`) over a scratch region of 64 pages, with one sector per page and with a sector-size table of 1-, 2- and 4-page sectors: - the geometry and the first sector the firmware lets the tests erase (an erase that took the sector index for the absolute page could not reach the running image from there on); @@ -554,6 +558,7 @@ In `hal_st_validation` (hal-st specific): - `groups/crypto.py` - `fw.rng`, `fw.aes`, `fw.pka`; `fakes/crypto.py` is their fake firmware model (a deterministic RNG, AES and PKA through `crypto_ref`). - `groups/system_ext.py` - `fw.flash`, `fw.hsem`, `fw.bkp`, `fw.lpm` (`flash.stack starting` and `hsem.take` without `hold` register their undo with `close_all`), the sector table of `layout=table`, `flash_words` and `bkp_fill_value`. - `fakes/system_ext.py` - their fake firmware model: a flash array with page erase, HSEM state on the fake clock, backup words that survive `reset`, an `lpm.enter` that wakes at once, and the `wdt.start` refusal while the coordinated flash borrows the watchdog. +- `groups/qspi.py` - `fw.qspi` and the decoding of QUADSPI captures (`qspi_samples`, `qspi_bytes`, `qspi_frame`); `fakes/qspi.py` is its fake firmware model (STM32WB55). - `protocol.py` - the hal-st part of the protocol: error reasons, `P` pins (ports A-K, index 0-15) and the generic alias names (`normalize_pin`, `parse_pin_map`). - `config.py` - board file loading, wiring-set merging, parameter matrices, overrides and known gaps; `expect.py` - expected STM32 values (PWM quantisation and range, SPI prescaler, UART baud-rate register limits, WWDG prescaler and period, ADC codes, encoder counts). - `pairwise.py` - the full product and the deterministic pairwise generator behind `--depth`. diff --git a/validation/firmware/CMakeLists.txt b/validation/firmware/CMakeLists.txt index 0fb345f8..d4c33879 100644 --- a/validation/firmware/CMakeLists.txt +++ b/validation/firmware/CMakeLists.txt @@ -75,6 +75,8 @@ target_sources(hal_st.validation_firmware PRIVATE PwmFactory.hpp QeiFactory.cpp QeiFactory.hpp + QuadSpiGroup.cpp + QuadSpiGroup.hpp ResourceAllocation.cpp ResourceAllocation.hpp RngGroup.cpp diff --git a/validation/firmware/Main.cpp b/validation/firmware/Main.cpp index 9333db98..d2e3761c 100644 --- a/validation/firmware/Main.cpp +++ b/validation/firmware/Main.cpp @@ -34,6 +34,7 @@ #include "validation/firmware/PkaGroup.hpp" #include "validation/firmware/PwmFactory.hpp" #include "validation/firmware/QeiFactory.hpp" +#include "validation/firmware/QuadSpiGroup.hpp" #include "validation/firmware/ResourceAllocation.hpp" #include "validation/firmware/RngGroup.hpp" #include "validation/firmware/SpiFactory.hpp" @@ -129,6 +130,11 @@ int main() static validation::BackupRamCommands backupRam{ context }; static validation::LowPowerCommands lowPower{ context, timers }; +#if defined(HAS_PERIPHERAL_QUADSPI) + static validation::QuadSpiFactoryStm quadSpiFactory{ console.dma, resources }; + static validation::QuadSpiGroup quadSpi{ context, quadSpiFactory }; +#endif + validation::CreateUnsupportedGroups(context); system.PrintBoot(); diff --git a/validation/firmware/QuadSpiGroup.cpp b/validation/firmware/QuadSpiGroup.cpp new file mode 100644 index 00000000..f0d8c2b2 --- /dev/null +++ b/validation/firmware/QuadSpiGroup.cpp @@ -0,0 +1,505 @@ +#include "validation/firmware/QuadSpiGroup.hpp" + +#if defined(HAS_PERIPHERAL_QUADSPI) + +#include "BoardProfile.hpp" +#include "infra/event/EventDispatcher.hpp" +#include "infra/util/ReallyAssert.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include +#include +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + using services::HilChoice; + using services::HilStatus; + + constexpr uint8_t quadSpiIndex = 1; + constexpr uint8_t pinFunctionInstance = 0; + constexpr uint32_t maximumPrescaler = 255; + constexpr uint32_t maximumFlashSizeLog2 = 32; + constexpr uint32_t maximumDummyCycles = 31; + constexpr uint32_t maximumPhaseBytes = 4; + constexpr uint32_t maximumRepeat = 8; + constexpr infra::Duration operationTimeout = std::chrono::milliseconds(2000); + + constexpr std::array openKeys{ { "variant", "prescaler", "size" } }; + constexpr std::array commandKeys{ { "instr", "addr", "abytes", "alt", "altbytes", "dummy", "lines", "tx", "len", "pattern", "seed", "rx", "out", "repeat" } }; + constexpr std::array pollKeys{ { "instr", "addr", "abytes", "alt", "altbytes", "dummy", "lines", "match", "mask", "size" } }; + constexpr std::array transferKeys{ { "len", "pattern", "seed", "rx", "repeat" } }; + + constexpr std::array, 3> variantChoices{ { + { "poll", QuadSpiVariant::poll }, + { "dma", QuadSpiVariant::dma }, + { "spi", QuadSpiVariant::spi }, + } }; + + constexpr std::array pinIds{ { board::qspiPins.clk, board::qspiPins.ncs, board::qspiPins.io[0], board::qspiPins.io[1], board::qspiPins.io[2], board::qspiPins.io[3] } }; + constexpr std::array pinFunctions{ { + hal::PinConfigTypeStm::quadSpiClock, + hal::PinConfigTypeStm::quadSpiSlaveSelect, + hal::PinConfigTypeStm::quadSpiData0, + hal::PinConfigTypeStm::quadSpiData1, + hal::PinConfigTypeStm::quadSpiData2, + hal::PinConfigTypeStm::quadSpiData3, + } }; + + static_assert(board::qspiDma.dma == 2 && LL_DMA_CHANNEL_1 == 1 && LL_DMA_CHANNEL_7 == 7); + + bool Fits(uint32_t value, uint32_t bytes) + { + return bytes >= maximumPhaseBytes || value < (uint32_t{ 1 } << (8 * bytes)); + } + + uint32_t FifoLevel() + { + return READ_BIT(QUADSPI->SR, QUADSPI_SR_FLEVEL) >> QUADSPI_SR_FLEVEL_Pos; + } + + bool PhasesShape(const services::HilArguments& arguments) + { + return (arguments.Has("addr") || !arguments.Has("abytes")) && (arguments.Has("alt") || !arguments.Has("altbytes")); + } + + bool CommandShape(const services::HilArguments& arguments) + { + const bool transmit = arguments.Has("tx"); + const bool generated = arguments.Has("len"); + const bool receive = arguments.Has("rx"); + const bool patterned = arguments.Has("pattern") || arguments.Has("seed"); + const int dataPhases = static_cast(transmit) + static_cast(generated) + static_cast(receive); + + return dataPhases <= 1 && (generated || !patterned) && (receive || !arguments.Has("out")) && !(receive && arguments.Has("repeat")) && PhasesShape(arguments); + } + + bool TransferShape(const services::HilArguments& arguments) + { + const bool transmit = arguments.Positional(1) != "-" || arguments.Has("len"); + const bool receive = arguments.Has("rx"); + + return transmit != receive && !(receive && arguments.Has("repeat")); + } + + template + HilStatus ParsePhases(const services::HilArguments& arguments, Phases& phases) + { + uint32_t instruction = 0; + uint32_t address = 0; + uint32_t addressBytes = 3; + uint32_t alternate = 0; + uint32_t alternateBytes = 1; + uint32_t dummyCycles = 0; + uint32_t lines = 1; + + HilStatus status = HilStatus::done; + arguments.Number("instr", instruction, 0, std::numeric_limits::max(), status); + arguments.Number("addr", address, 0, std::numeric_limits::max(), status); + arguments.Number("abytes", addressBytes, 1, maximumPhaseBytes, status); + arguments.Number("alt", alternate, 0, std::numeric_limits::max(), status); + arguments.Number("altbytes", alternateBytes, 1, maximumPhaseBytes, status); + arguments.Number("dummy", dummyCycles, 0, maximumDummyCycles, status); + arguments.Number("lines", lines, 1, 4, status); + if (status != HilStatus::done) + return status; + + if ((lines != 1 && lines != 4) || !Fits(address, addressBytes) || !Fits(alternate, alternateBytes)) + return HilStatus::range; + + if (arguments.Has("instr")) + phases.header.instruction = static_cast(instruction); + if (arguments.Has("addr")) + phases.header.address = hal::QuadSpi::AddressToVector(address, static_cast(addressBytes)); + if (arguments.Has("alt")) + phases.header.alternate = hal::QuadSpi::AddressToVector(alternate, static_cast(alternateBytes)); + phases.header.nofDummyCycles = static_cast(dummyCycles); + phases.lines = lines == 4 ? hal::QuadSpi::Lines::QuadSpeed() : hal::QuadSpi::Lines::SingleSpeed(); + return HilStatus::done; + } + } + + QuadSpiFactoryStm::QuadSpiFactoryStm(hal::DmaStm& dma, ResourceAllocation& resources) + : dma(dma) + , resources(resources) + {} + + uint8_t QuadSpiFactoryStm::Instances() const + { + return quadSpiIndex + 1; + } + + infra::MemoryRange QuadSpiFactoryStm::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus QuadSpiFactoryStm::Prepare(uint8_t index, const services::HilArguments& arguments) + { + Request request; + return Evaluate(index, arguments, request); + } + + void QuadSpiFactoryStm::Close(uint8_t, const infra::Function& onClosed) + { + this->onClosed = onClosed; + + // QuadSpiStm schedules its completion with a pointer to the driver, which must still be alive when it runs + infra::EventDispatcher::Instance().Schedule([this]() + { + Destroy(); + }); + } + + HilStatus QuadSpiFactoryStm::Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) + { + Request request; + ClaimedPins claimed{}; + + HilStatus status = Evaluate(index, arguments, request); + if (status == HilStatus::done) + status = Claim(request, pins, claimed); + if (status == HilStatus::done) + Construct(request, claimed); + + return status; + } + + uint32_t QuadSpiFactoryStm::Clock() const + { + return clock; + } + + hal::QuadSpi& QuadSpiFactoryStm::QuadSpi() + { + hal::QuadSpi* quadSpi = std::get_if(&driver); + if (quadSpi == nullptr) + quadSpi = std::get_if(&driver); + + really_assert(quadSpi != nullptr); + return *quadSpi; + } + + hal::SpiMaster* QuadSpiFactoryStm::Spi() + { + return spi ? &*spi : nullptr; + } + + HilStatus QuadSpiFactoryStm::Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const + { + HilStatus status = HilStatus::done; + arguments.Select("variant", request.variant, variantChoices, status); + arguments.Number("prescaler", request.prescaler, 0, maximumPrescaler, status); + arguments.Number("size", request.flashSizeLog2, 1, maximumFlashSizeLog2, status); + if (status != HilStatus::done) + return status; + + return index == quadSpiIndex ? HilStatus::done : HilStatus::range; + } + + HilStatus QuadSpiFactoryStm::Claim(const Request& request, services::HilPinOwner& pins, ClaimedPins& claimed) + { + for (std::size_t i = 0; i != pinIds.size(); ++i) + { + HilStatus status = pins.ClaimFunction(pinIds[i], Function(pinFunctions[i]), pinFunctionInstance, claimed[i]); + if (status != HilStatus::done) + return status; + } + + if (request.variant == QuadSpiVariant::poll) + return HilStatus::done; + + return resources.Claim(Resource::dma2, board::qspiDma.channel, owners::quadSpi); + } + + void QuadSpiFactoryStm::Construct(const Request& request, const ClaimedPins& claimed) + { + clock = HAL_RCC_GetHCLK4Freq() / (request.prescaler + 1); + + if (request.variant == QuadSpiVariant::poll) + { + hal::QuadSpiStm::Config config; + config.prescaler = request.prescaler; + config.flashSizeLog2 = request.flashSizeLog2; + driver.emplace(PinOrDummy(claimed[0]), PinOrDummy(claimed[1]), PinOrDummy(claimed[2]), PinOrDummy(claimed[3]), PinOrDummy(claimed[4]), PinOrDummy(claimed[5]), config); + return; + } + + hal::QuadSpiStmDma::Config config; + config.prescaler = static_cast(request.prescaler); + config.flashSizeLog2 = request.flashSizeLog2; + auto& transceiveStream = stream.emplace(dma, hal::DmaChannelId(board::qspiDma.dma, board::qspiDma.channel, board::qspiDmaRequest)); + auto& quadSpi = driver.emplace(transceiveStream, PinOrDummy(claimed[0]), PinOrDummy(claimed[1]), PinOrDummy(claimed[2]), PinOrDummy(claimed[3]), PinOrDummy(claimed[4]), PinOrDummy(claimed[5]), config); + + if (request.variant == QuadSpiVariant::spi) + spi.emplace(quadSpi); + } + + void QuadSpiFactoryStm::Destroy() + { + spi.reset(); + + // A command that timed out leaves its channel running, and HAL_DMA_Init keeps CCR.EN for the next open + if (stream) + LL_DMA_DisableChannel(DMA2, board::qspiDma.channel); + + driver.emplace(); + stream.reset(); + resources.Release(Resource::dma2, board::qspiDma.channel, owners::quadSpi); + onClosed(); + } + + QuadSpiGroup::QuadSpiGroup(services::HilContext& context, QuadSpiFactoryStm& factory) + : services::HilSingleInstanceGroup(context, factory, owners::quadSpi) + , factory(factory) + , pending(context.response) + , commands{ { + OpenCommand("qspi.open", "<1> [variant=poll|dma|spi] [prescaler=] [size=]"), + services::HilBind("qspi.cmd", "<1> [instr=] [addr=] [alt=] [dummy=] [lines=] [tx=|len=|rx=]", *this, context.response), + services::HilBind("qspi.poll", "<1> match= mask= [size=] [instr=] [lines=]", *this, context.response), + services::HilBind("qspi.xfer", "<1> [rx=]", *this, context.response), + CloseCommand("qspi.close", "<1>"), + } } + {} + + infra::MemoryRange QuadSpiGroup::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus QuadSpiGroup::OpenInstance(uint8_t index, const services::HilArguments& arguments) + { + return factory.Open(index, arguments, Pins()); + } + + void QuadSpiGroup::Opened(services::HilResponse::Line& line) const + { + line << " clk=" << factory.Clock(); + } + + void QuadSpiGroup::CloseInstance() + { + pending.Cancel(); + remaining = 0; + } + + HilStatus QuadSpiGroup::Execute(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, infra::MakeRange(commandKeys))) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done) + return status; + + if (!CommandShape(arguments)) + return HilStatus::usage; + + Request next; + status = ParsePhases(arguments, next.phases); + if (status == HilStatus::done) + status = ParseCommandData(arguments, next); + if (status != HilStatus::done) + return status; + + if (pending.Busy()) + return HilStatus::busy; + + Start(next); + return HilStatus::done; + } + + HilStatus QuadSpiGroup::Poll(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, infra::MakeRange(pollKeys))) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done) + return status; + + if (!arguments.Has("match") || !arguments.Has("mask") || !PhasesShape(arguments)) + return HilStatus::usage; + + Request next; + uint32_t match = 0; + uint32_t mask = 0; + uint32_t size = 1; + status = ParsePhases(arguments, next.phases); + arguments.Number("match", match, 0, std::numeric_limits::max(), status); + arguments.Number("mask", mask, 0, std::numeric_limits::max(), status); + arguments.Number("size", size, 1, maximumPhaseBytes, status); + if (status != HilStatus::done) + return status; + + if (!Fits(match, size) || !Fits(mask, size)) + return HilStatus::range; + + if (pending.Busy()) + return HilStatus::busy; + + request = next; + const auto operation = current = pending.Start(operationTimeout); + factory.QuadSpi().PollStatus(request.phases.header, static_cast(size), match, mask, request.phases.lines, [this, operation]() + { + Polled(operation); + }); + + return HilStatus::done; + } + + HilStatus QuadSpiGroup::Transfer(const services::HilArguments& arguments) + { + if (!arguments.Shape(2, 2, infra::MakeRange(transferKeys))) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done) + return status; + + if (!TransferShape(arguments)) + return HilStatus::usage; + + Request next; + status = ParseTransferData(arguments, next); + if (status != HilStatus::done) + return status; + + if (factory.Spi() == nullptr) + return HilStatus::unsupported; + + if (pending.Busy()) + return HilStatus::busy; + + Start(next); + return HilStatus::done; + } + + HilStatus QuadSpiGroup::ParseCommandData(const services::HilArguments& arguments, Request& next) + { + HilStatus status = HilStatus::done; + + if (auto transmit = arguments.Key("tx")) + { + std::size_t size = 0; + status = services::HilArguments::ParseHex(*transmit, infra::MakeRange(buffer), size); + next.data = infra::Head(infra::MakeRange(buffer), size); + } + else if (arguments.Has("len")) + { + Pattern pattern = Pattern::inc; + uint32_t seed = 0; + uint32_t length = 0; + arguments.Select("pattern", pattern, patternChoices, status); + arguments.Number("seed", seed, 0, std::numeric_limits::max(), status); + arguments.Number("len", length, 1, static_cast(buffer.size()), status); + if (status == HilStatus::done) + { + next.data = infra::Head(infra::MakeRange(buffer), length); + Generate(next.data, pattern, seed); + } + } + else if (arguments.Has("rx")) + { + uint32_t length = 0; + arguments.Number("rx", length, 1, static_cast(buffer.size()), status); + if (status == HilStatus::done) + status = ParseOutput(arguments, next.output); + if (status == HilStatus::done) + status = CheckOutput(length, next.output); + + next.read = true; + next.data = infra::Head(infra::MakeRange(buffer), length); + } + + arguments.Number("repeat", next.repeat, 1, maximumRepeat, status); + return status; + } + + HilStatus QuadSpiGroup::ParseTransferData(const services::HilArguments& arguments, Request& next) + { + HilStatus status = ParsePayload(arguments, 1, infra::MakeRange(buffer), next.data); + + if (arguments.Has("rx")) + { + uint32_t length = 0; + arguments.Number("rx", length, 1, static_cast(maximumHexOutput), status); + next.read = true; + next.data = infra::Head(infra::MakeRange(buffer), length); + } + + arguments.Number("repeat", next.repeat, 1, maximumRepeat, status); + next.spi = true; + return status; + } + + void QuadSpiGroup::Start(const Request& next) + { + request = next; + remaining = request.repeat; + current = pending.Start(operationTimeout); + Issue(); + } + + void QuadSpiGroup::Issue() + { + const auto operation = current; + auto onWritten = [this, operation]() + { + Written(operation); + }; + auto onReceived = [this, operation]() + { + Received(operation); + }; + + if (request.spi && request.read) + factory.Spi()->SendAndReceive({}, request.data, hal::SpiAction::stop, onReceived); + else if (request.spi) + factory.Spi()->SendAndReceive(request.data, {}, hal::SpiAction::stop, onWritten); + else if (request.read) + factory.QuadSpi().ReceiveData(request.phases.header, request.data, request.phases.lines, onReceived); + else + factory.QuadSpi().SendData(request.phases.header, request.data, request.phases.lines, onWritten); + } + + void QuadSpiGroup::Written(uint32_t operation) + { + const auto level = FifoLevel(); + + if (!pending.Busy() || operation != current) + return; + + if (--remaining != 0) + { + Issue(); + return; + } + + if (pending.Complete(operation)) + Context().response.Ok() << " flevel=" << level; + } + + void QuadSpiGroup::Received(uint32_t operation) + { + if (!pending.Complete(operation)) + return; + + auto line = Context().response.Ok(); + if (request.spi) + (line << " rx=").Hex(request.data); + else + PrintData(line, request.data, request.output); + } + + void QuadSpiGroup::Polled(uint32_t operation) + { + if (pending.Complete(operation)) + Context().response.Ok(); + } +} + +#endif diff --git a/validation/firmware/QuadSpiGroup.hpp b/validation/firmware/QuadSpiGroup.hpp new file mode 100644 index 00000000..55db2d22 --- /dev/null +++ b/validation/firmware/QuadSpiGroup.hpp @@ -0,0 +1,128 @@ +#pragma once + +#include "generated/stm32fxxx/PeripheralTable.hpp" + +#if defined(HAS_PERIPHERAL_QUADSPI) + +#include "hal/interfaces/QuadSpi.hpp" +#include "hal/interfaces/Spi.hpp" +#include "hal_st/stm32fxxx/DmaStm.hpp" +#include "hal_st/stm32fxxx/QuadSpiStm.hpp" +#include "hal_st/stm32fxxx/QuadSpiStmDma.hpp" +#include "hal_st/stm32fxxx/SingleSpeedQuadSpiStmDma.hpp" +#include "infra/util/AutoResetFunction.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilPendingOperation.hpp" +#include "services/hil/commands/HilSingleInstanceGroup.hpp" +#include "validation/firmware/Payload.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include +#include +#include +#include + +namespace validation +{ + enum class QuadSpiVariant : uint8_t + { + poll, + dma, + spi, + }; + + class QuadSpiFactoryStm + : public services::HilInstanceFactory + { + public: + QuadSpiFactoryStm(hal::DmaStm& dma, ResourceAllocation& resources); + + uint8_t Instances() const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + void Close(uint8_t index, const infra::Function& onClosed) override; + + services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins); + uint32_t Clock() const; + hal::QuadSpi& QuadSpi(); + hal::SpiMaster* Spi(); + + private: + struct Request + { + QuadSpiVariant variant = QuadSpiVariant::poll; + uint32_t prescaler = 2; + uint32_t flashSizeLog2 = 24; + }; + + using ClaimedPins = std::array; + + services::HilStatus Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const; + services::HilStatus Claim(const Request& request, services::HilPinOwner& pins, ClaimedPins& claimed); + void Construct(const Request& request, const ClaimedPins& claimed); + void Destroy(); + + private: + hal::DmaStm& dma; + ResourceAllocation& resources; + std::optional stream; + std::variant driver; + std::optional spi; + uint32_t clock = 0; + infra::AutoResetFunction onClosed; + }; + + class QuadSpiGroup + : public services::HilSingleInstanceGroup + { + public: + QuadSpiGroup(services::HilContext& context, QuadSpiFactoryStm& factory); + + infra::MemoryRange Commands() override; + + protected: + services::HilStatus OpenInstance(uint8_t index, const services::HilArguments& arguments) override; + void Opened(services::HilResponse::Line& line) const override; + void CloseInstance() override; + + private: + struct Phases + { + hal::QuadSpi::Header header{ std::nullopt, {}, {}, 0 }; + hal::QuadSpi::Lines lines = hal::QuadSpi::Lines::SingleSpeed(); + }; + + struct Request + { + Phases phases; + infra::ByteRange data; + bool read = false; + bool spi = false; + Output output = Output::hex; + uint32_t repeat = 1; + }; + + services::HilStatus Execute(const services::HilArguments& arguments); + services::HilStatus Poll(const services::HilArguments& arguments); + services::HilStatus Transfer(const services::HilArguments& arguments); + + services::HilStatus ParseCommandData(const services::HilArguments& arguments, Request& next); + services::HilStatus ParseTransferData(const services::HilArguments& arguments, Request& next); + + void Start(const Request& next); + void Issue(); + void Written(uint32_t operation); + void Received(uint32_t operation); + void Polled(uint32_t operation); + + private: + QuadSpiFactoryStm& factory; + services::HilPendingOperation pending; + uint32_t current = 0; + uint32_t remaining = 0; + Request request; + std::array buffer{}; + std::array commands; + }; +} + +#endif diff --git a/validation/firmware/UnsupportedGroups.cpp b/validation/firmware/UnsupportedGroups.cpp index f1c9b3e5..9ce81d65 100644 --- a/validation/firmware/UnsupportedGroups.cpp +++ b/validation/firmware/UnsupportedGroups.cpp @@ -27,7 +27,7 @@ namespace validation "lptpwm.close", } }; #elif defined(STM32WBA) - constexpr std::array commandNames{ { + constexpr std::array commandNames{ { "comp.open", "comp.read", "comp.irq", @@ -47,6 +47,11 @@ namespace validation "hsem.lock", "hsem.mine", "flash.stack", + "qspi.open", + "qspi.cmd", + "qspi.poll", + "qspi.xfer", + "qspi.close", } }; #endif } diff --git a/validation/host/boards/nucleo_wb55rg.yaml b/validation/host/boards/nucleo_wb55rg.yaml index 6a1b8040..07161872 100644 --- a/validation/host/boards/nucleo_wb55rg.yaml +++ b/validation/host/boards/nucleo_wb55rg.yaml @@ -24,6 +24,8 @@ clocks: lptim: 1: 64000000 2: 64000000 + # QUADSPI kernel clock (HCLK4); qspi.open divides it by prescaler + 1. + qspi: 64000000 terminal: baud: 921600 @@ -390,6 +392,13 @@ known_gaps: hal_st/synchronous_stm32fxxx/SynchronousSpiMasterStm.cpp:23 - the SPI masters mux slaveSelect to NSS but initialise SPI_NSS_SOFT, so NSS is never driven; hardware NSS framing needs a decision (DESIGN B.14) + - tests: + - "test_qspi.py::test_poll_timeout[*poll*]" + reason: >- + QuadSpiStm.cpp:52,73,97 - QuadSpiStm waits with HAL_MAX_DELAY, so a + PollStatus whose status never matches blocks the event loop for good + instead of answering ERR timeout (B.14, needs a decision) + hangs: true # Test parameters. A mapping used by `@pytest.mark.matrix` is one dimension # per key: `--depth full` runs the cartesian product, `--depth quick` a @@ -1335,6 +1344,46 @@ tests: timeout_ms: 50 la_rate: 4000000 unbonded_pin: PC7 + qspi: + index: 1 + # board::qspiPins (aliases qspiclk, qspincs, qspiio0-3), all on bundle1 + # DIOs: CLK DIO13, NCS DIO12, IO0 DIO5, IO1 DIO3, IO2 DIO1, IO3 DIO7. + pins: + clk: PA3 + ncs: PA2 + io: [PB9, PB8, PA7, PA6] + # 64 MHz / 8 = 8 MHz: 12.5 samples per clock at the AD3's 100 MS/s. + decode_prescaler: 7 + # 64 MHz / 256 = 250 kHz: 16 bytes in the FIFO take 0.5 ms to leave on + # one line (B.15, flevel). + slow_prescaler: 255 + flevel_length: 64 + # Decoded writes: every phase, on 1 and on 4 lines. + lines: [1, 4] + write: + instr: 0x38 + addr: 0x123456 + abytes: 3 + alt: 0xa55a + altbytes: 2 + data: "00ff5aa5c3" + # An instruction-only write, and the instruction of the 1-line reads. + instruction: 0x06 + read_instr: 0x0b + read_length: 4 + # 4-line reads of static AD3 levels: each byte is the nibble twice. + nibbles: [0x0, 0x5, 0xa, 0xf] + # Status poll: 1-line instruction, 1-line status on IO1 (DIO3). + poll: + instr: 0x05 + match: 0x01 + mask: 0x01 + # Two writes from one completion callback (repeat), 1 line. + back_to_back: + length: 32 + seed: 1 + repeat: 2 + xfer: "9f0102a5" unsupported: commands: - comp.open 1 diff --git a/validation/host/hal_st_validation/fakes/qspi.py b/validation/host/hal_st_validation/fakes/qspi.py new file mode 100644 index 00000000..2a6eafc3 --- /dev/null +++ b/validation/host/hal_st_validation/fakes/qspi.py @@ -0,0 +1,193 @@ +"""Fake `qspi` group (STM32WB55): the argument checks of validation/firmware/QuadSpiGroup.cpp in their order, the +QUADSPI pins and the DMA2 channel 3 it claims, and a bus whose IO lines rest at the levels of `io` (IO0 in bit 0; +the AD3 is not modelled): reads return those levels, a poll matches them or times out. The STM32WBA55 has no +QUADSPI; its `qspi.*` names are unsupported commands of the fake firmware, which answer before this group. + +`variant=poll` blocks for good on a status that never matches (`QuadSpiStm` waits with `HAL_MAX_DELAY`, a known +gap); the fake answers `ERR timeout` for it as for `variant=dma`, which is what test_qspi.py expects.""" + +from __future__ import annotations + +from typing import Any + +from ..groups.qspi import ( + QSPI_BUFFER, + QSPI_DUMMY_MAX, + QSPI_HEX_MAX, + QSPI_INDEX, + QSPI_PRESCALER_MAX, + QSPI_REPEAT_MAX, + QSPI_SIZE_MAX, + QSPI_TIMEOUT_S, + qspi_clock, +) +from ..patterns import PATTERNS, SEED_MAX, crc_text, generate +from .base import UINT32_MAX, FakeGroup, _choice, _fail, _hex, _number, _shape + +_VARIANTS = ("poll", "dma", "spi") +_OUTPUTS = ("hex", "crc") +# `board::qspiPins` with the pin functions `QuadSpiStm` claims (instance 0 in the pinout table). +_PINS = ( + ("quadSpiClock", "PA3"), + ("quadSpiSlaveSelect", "PA2"), + ("quadSpiData0", "PB9"), + ("quadSpiData1", "PB8"), + ("quadSpiData2", "PA7"), + ("quadSpiData3", "PA6"), +) +_DMA = ("dma2", 3) +_PHASE_KEYS = ("instr", "addr", "abytes", "alt", "altbytes", "dummy", "lines") +_COMMAND_KEYS = (*_PHASE_KEYS, "tx", "len", "pattern", "seed", "rx", "out", "repeat") +_POLL_KEYS = (*_PHASE_KEYS, "match", "mask", "size") +_TRANSFER_KEYS = ("len", "pattern", "seed", "rx", "repeat") +_PHASE_BYTES_MAX = 4 +_IDLE_LEVELS = 0xF + + +def _fits(value: int, size: int) -> bool: + return size >= _PHASE_BYTES_MAX or value < 1 << (8 * size) + + +def _phases_shape(options: dict[str, str]) -> bool: + return ("addr" in options or "abytes" not in options) and ("alt" in options or "altbytes" not in options) + + +class FakeQuadSpi(FakeGroup): + prefix = "qspi" + io = _IDLE_LEVELS + + def boot(self) -> None: + self.io = _IDLE_LEVELS + + def _byte(self, lines: int) -> int: + """A received byte: the nibble of IO3-IO0 twice with 4 lines, IO1 eight times with 1 line.""" + if lines == 4: + return self.io << 4 | self.io + return 0xFF if self.io & 0b10 else 0x00 + + def _find(self, args: list[str], options: dict[str, str], low: int, high: int, keys: tuple[str, ...]) -> dict[str, Any]: + _shape(args, options, low, high, keys) + return self.fw.find_instance(self.prefix, str(_number(args[0], 0, QSPI_INDEX))) + + def _phases(self, options: dict[str, str]) -> int: + """`ParsePhases`: the numbers in order, then `lines` other than 1 and 4 or values wider than their bytes + (`range`); returns `lines`.""" + _number(options.get("instr", "0"), 0, 0xFF) + address = _number(options.get("addr", "0"), 0, UINT32_MAX) + address_bytes = _number(options.get("abytes", "3"), 1, _PHASE_BYTES_MAX) + alternate = _number(options.get("alt", "0"), 0, UINT32_MAX) + alternate_bytes = _number(options.get("altbytes", "1"), 1, _PHASE_BYTES_MAX) + _number(options.get("dummy", "0"), 0, QSPI_DUMMY_MAX) + lines = _number(options.get("lines", "1"), 1, 4) + if lines not in (1, 4) or not _fits(address, address_bytes) or not _fits(alternate, alternate_bytes): + _fail("range") + return lines + + @staticmethod + def _generated(options: dict[str, str]) -> bytes: + pattern = _choice(options, "pattern", PATTERNS, "inc") + seed = _number(options.get("seed", "0"), 0, SEED_MAX) + length = _number(options["len"], 1, QSPI_BUFFER) + return generate(length, pattern, seed) + + @staticmethod + def _check_busy(state: dict[str, Any]) -> None: + if state["stuck"]: + _fail("busy") + + def cmd_open(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("variant", "prescaler", "size")) + index = _number(args[0], 0, QSPI_INDEX) + variant = _choice(options, "variant", _VARIANTS, "poll") + prescaler = _number(options.get("prescaler", "2"), 0, QSPI_PRESCALER_MAX) + _number(options.get("size", "24"), 1, QSPI_SIZE_MAX) + if index not in self.fw.spec.qspi: + _fail("range") + for function, pin in _PINS: + self.fw.check_function(function, 0, pin) + state = {"variant": variant, "prescaler": prescaler, "stuck": False} + resources = [] if variant == "poll" else [_DMA] + self.fw.open_instance(self.prefix, str(index), state, [pin for _, pin in _PINS], resources=resources) + return f"OK clk={qspi_clock(self.fw.kernel_clock, prescaler)}" + + def cmd_cmd(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options, 1, 1, _COMMAND_KEYS) + transmit, generated, receive = "tx" in options, "len" in options, "rx" in options + patterned = "pattern" in options or "seed" in options + if ( + transmit + generated + receive > 1 + or (patterned and not generated) + or ("out" in options and not receive) + or (receive and "repeat" in options) + or not _phases_shape(options) + ): + _fail("usage") + lines = self._phases(options) + length = 0 + output = "hex" + if transmit: + _hex(options["tx"], QSPI_BUFFER) + elif generated: + self._generated(options) + elif receive: + length = _number(options["rx"], 1, QSPI_BUFFER) + output = _choice(options, "out", _OUTPUTS, "hex") + if output == "hex" and length > QSPI_HEX_MAX: + _fail("range") + _number(options.get("repeat", "1"), 1, QSPI_REPEAT_MAX) + self._check_busy(state) + if not receive: + return "OK flevel=0" + data = bytes([self._byte(lines)]) * length + if output == "crc": + return f"OK len={length} crc={crc_text(data)}" + return f"OK data={data.hex()}" + + def cmd_poll(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options, 1, 1, _POLL_KEYS) + if "match" not in options or "mask" not in options or not _phases_shape(options): + _fail("usage") + lines = self._phases(options) + match = _number(options["match"], 0, UINT32_MAX) + mask = _number(options["mask"], 0, UINT32_MAX) + size = _number(options.get("size", "1"), 1, _PHASE_BYTES_MAX) + if not _fits(match, size) or not _fits(mask, size): + _fail("range") + self._check_busy(state) + status = int.from_bytes(bytes([self._byte(lines)]) * size, "big") + if status & mask == match & mask: + return "OK" + self.fw.sleep(QSPI_TIMEOUT_S) + state["stuck"] = True + return "ERR timeout" + + def cmd_xfer(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options, 2, 2, _TRANSFER_KEYS) + text = args[1] + receive = "rx" in options + if (text != "-" or "len" in options) == receive or (receive and "repeat" in options): + _fail("usage") + generated = "len" in options + patterned = "pattern" in options or "seed" in options + if text != "-": + if generated or patterned: + _fail("usage") + _hex(text, QSPI_BUFFER) + elif generated: + self._generated(options) + elif patterned: + _fail("usage") + length = _number(options["rx"], 1, QSPI_HEX_MAX) if receive else 0 + _number(options.get("repeat", "1"), 1, QSPI_REPEAT_MAX) + if state["variant"] != "spi": + _fail("unsupported") + self._check_busy(state) + if not receive: + return "OK flevel=0" + return f"OK rx={(bytes([self._byte(1)]) * length).hex()}" + + def cmd_close(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + key = str(_number(args[0], 0, QSPI_INDEX)) + self.fw.find_instance(self.prefix, key) + return self.fw.close_instance(self.prefix, key) diff --git a/validation/host/hal_st_validation/groups/qspi.py b/validation/host/hal_st_validation/groups/qspi.py new file mode 100644 index 00000000..cd20e499 --- /dev/null +++ b/validation/host/hal_st_validation/groups/qspi.py @@ -0,0 +1,235 @@ +"""QUADSPI group of validation/PROTOCOL.md (STM32WB55 only): `qspi` over `hal::QuadSpiStm` (`variant=poll`), +`hal::QuadSpiStmDma` (`variant=dma`) and `hal::SingleSpeedQuadSpiStmDma` as `hal::SpiMaster` (`variant=spi`), plus +the decoding of a logic-analyzer capture of the bus (pure functions). + +Sources: validation/firmware/QuadSpiGroup.cpp (one instance, QUADSPI 1, pins of the board profile; every command +answers within 2000 ms or `ERR timeout`), `QuadSpiStm::CreateConfig` and `QuadSpiStmDma::SetConfig` (phases in the +order instruction, address, alternate bytes, dummy cycles, data; `lines` applies to every phase), EMIL +`hal::QuadSpi::AddressToVector` (address and alternate bytes go out most significant byte first), clock mode 0 +(CLK idles low, the IO lines are sampled on the rising edge). +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Literal + +from .base import Group + +__all__ = [ + "GROUPS", + "QSPI_BUFFER", + "QSPI_DUMMY_MAX", + "QSPI_HEX_MAX", + "QSPI_INDEX", + "QSPI_PRESCALER_MAX", + "QSPI_REPEAT_MAX", + "QSPI_SIZE_MAX", + "QSPI_TIMEOUT_S", + "QuadSpi", + "QuadSpiReply", + "Variant", + "qspi_bytes", + "qspi_clock", + "qspi_frame", + "qspi_samples", +] + +Variant = Literal["poll", "dma", "spi"] +Lines = Literal[1, 4] + +QSPI_INDEX = 1 +QSPI_BUFFER = 256 +QSPI_HEX_MAX = 128 +QSPI_PRESCALER_MAX = 255 +QSPI_SIZE_MAX = 32 +QSPI_DUMMY_MAX = 31 +QSPI_REPEAT_MAX = 8 +QSPI_TIMEOUT_S = 2.0 + + +def qspi_clock(kernel: int, prescaler: int) -> int: + """`clk=` of `qspi.open`: the QUADSPI kernel clock (HCLK4) divided by `prescaler + 1`.""" + return kernel // (prescaler + 1) + + +def qspi_frame(instr: int | None = None, address: bytes = b"", alternate: bytes = b"", data: bytes = b"") -> bytes: + """The bytes of one command on the bus, without the dummy cycles: instruction, address, alternate bytes, data.""" + return (b"" if instr is None else bytes([instr])) + address + alternate + data + + +def qspi_samples(clk: Sequence[int], ncs: Sequence[int], ios: Sequence[Sequence[int]]) -> list[list[int]]: + """One list per chip-select-low region: the IO levels at each rising CLK edge, as nibbles with IO0 in bit 0.""" + frames: list[list[int]] = [] + current: list[int] | None = None + for index in range(1, len(clk)): + if ncs[index]: + if current: + frames.append(current) + current = None + continue + if current is None: + current = [] + if clk[index] and not clk[index - 1]: + current.append(sum((io[index] & 1) << bit for bit, io in enumerate(ios))) + if current: + frames.append(current) + return frames + + +def qspi_bytes(samples: Sequence[int], lines: int, line: int = 0) -> bytes: + """Bytes of consecutive samples, most significant bit first: 4 lines carry a nibble per clock (IO3 is bit 3), + 1 line one bit per clock on IO `line` (IO0 out, IO1 in). A partial last byte is dropped.""" + if lines == 4: + return bytes((samples[i] << 4 | samples[i + 1]) & 0xFF for i in range(0, len(samples) - 1, 2)) + if lines != 1: + raise ValueError(f"lines must be 1 or 4: {lines}") + result = bytearray() + for start in range(0, len(samples) - 7, 8): + value = 0 + for sample in samples[start : start + 8]: + value = value << 1 | (sample >> line) & 1 + result.append(value) + return bytes(result) + + +@dataclass(frozen=True) +class QuadSpiReply: + """A write answers `flevel` (the FIFO level sampled in the completion callback, B.15), a read its `data`, or + with `out=crc` its length and CRC-32 (`crc` as 8 hex digits).""" + + data: bytes | None = None + flevel: int | None = None + crc: str | None = None + + +class QuadSpi(Group): + prefix = "qspi" + + def _timeout(self, cmd_timeout: float | None) -> float: + return cmd_timeout or self._fw.terminal.timeout + QSPI_TIMEOUT_S + + def open( + self, + index: int, + variant: Variant | None = None, + prescaler: int | None = None, + size: int | None = None, + ) -> int: + """Returns `clk`, the QUADSPI clock; `size` is the flash size as log2 of bytes (DCR FSIZE + 1).""" + response = self._cmd("open", index, variant=variant, prescaler=prescaler, size=size) + self._fw.track((self.prefix, index), f"{self.prefix}.close", index) + return response.as_int("clk") + + def cmd( + self, + index: int, + instr: int | None = None, + addr: int | None = None, + abytes: int | None = None, + alt: int | None = None, + altbytes: int | None = None, + dummy: int | None = None, + lines: Lines | None = None, + tx: bytes | None = None, + len: int | None = None, + pattern: str | None = None, + seed: int | None = None, + rx: int | None = None, + out: Literal["hex", "crc"] | None = None, + repeat: int | None = None, + cmd_timeout: float | None = None, + ) -> QuadSpiReply: + """`hal::QuadSpi::SendData` (`tx`, or `len`/`pattern`/`seed`, or no data phase) or `ReceiveData` (`rx`); + `repeat` issues the write again from each completion callback. Every phase is optional.""" + response = self._cmd( + "cmd", + index, + instr=instr, + addr=addr, + abytes=abytes, + alt=alt, + altbytes=altbytes, + dummy=dummy, + lines=lines, + tx=tx, + len=len, + pattern=pattern, + seed=seed, + rx=rx, + out=out, + repeat=repeat, + cmd_timeout=self._timeout(cmd_timeout), + ) + if rx is None: + return QuadSpiReply(flevel=response.as_int("flevel")) + if "crc" in response: + return QuadSpiReply(crc=response["crc"]) + return QuadSpiReply(data=response.as_bytes("data")) + + def poll( + self, + index: int, + match: int, + mask: int, + size: int | None = None, + instr: int | None = None, + addr: int | None = None, + abytes: int | None = None, + alt: int | None = None, + altbytes: int | None = None, + dummy: int | None = None, + lines: Lines | None = None, + cmd_timeout: float | None = None, + ) -> None: + """`hal::QuadSpi::PollStatus`: answers once the status bytes, masked with `mask`, equal `match`.""" + self._cmd( + "poll", + index, + instr=instr, + addr=addr, + abytes=abytes, + alt=alt, + altbytes=altbytes, + dummy=dummy, + lines=lines, + match=match, + mask=mask, + size=size, + cmd_timeout=self._timeout(cmd_timeout), + ) + + def xfer( + self, + index: int, + tx: bytes = b"", + rx: int | None = None, + len: int | None = None, + pattern: str | None = None, + seed: int | None = None, + repeat: int | None = None, + cmd_timeout: float | None = None, + ) -> QuadSpiReply: + """`hal::SingleSpeedQuadSpiStmDma::SendAndReceive` (`variant=spi`), half duplex: `tx` (or `len`) or `rx`.""" + response = self._cmd( + "xfer", + index, + tx, + len=len, + pattern=pattern, + seed=seed, + rx=rx, + repeat=repeat, + cmd_timeout=self._timeout(cmd_timeout), + ) + if rx is None: + return QuadSpiReply(flevel=response.as_int("flevel")) + return QuadSpiReply(data=response.as_bytes("rx")) + + def close(self, index: int) -> None: + self._cmd("close", index) + self._fw.untrack((self.prefix, index)) + + +GROUPS: dict[str, type[Group]] = {"qspi": QuadSpi} diff --git a/validation/host/tests/hil/test_qspi.py b/validation/host/tests/hil/test_qspi.py new file mode 100644 index 00000000..1bda1091 --- /dev/null +++ b/validation/host/tests/hil/test_qspi.py @@ -0,0 +1,455 @@ +"""QUADSPI (`hal::QuadSpiStm`, `hal::QuadSpiStmDma`, `hal::SingleSpeedQuadSpiStmDma`) through the `qspi` group, +NUCLEO-WB55RG only. + +Wiring set `bundle1` (or `bundle2`): every QUADSPI pin is on a DIO (CLK PA3 DIO13, NCS PA2 DIO12, IO0 PB9 DIO5, +IO1 PB8 DIO3, IO2 PA7 DIO1, IO3 PA6 DIO7). The AD3 never drives a line the QUADSPI drives: writes are decoded with +the logic analyzer only; reads take their data from AD3 levels on lines the QUADSPI only receives on (IO1 in +single-line mode; IO0-IO3 in a 4-line read without instruction, address or alternate bytes, driven only between +`qspi.open` and the reply, with the weakest AD3 drive). Every case resolves the six pins through the load gate, so +`--with loopback` (PA6-PA7), `--with spiloop` (PA6, PA7) and `--with i2c` (PB8, PB9) skip them. + +A 4-line read with a data phase only hangs the QUADSPI with BUSY set (the STM32 QUADSPI erratum "cannot be used in +indirect read mode when only data phase is activated"; its workaround is two dummy cycles), so the receive tests +add `dummy=2`, which keeps IO0-IO3 undriven. `SingleSpeedQuadSpiStmDma` receives with a data phase only +(`SingleSpeedQuadSpiStmDma.cpp:27`): test_xfer_receive shows whether the erratum hits it. The tests that can leave +the QUADSPI busy reset the board when they fail, so the next test starts clean. + +B.15 (QuadSpiStmDma completes a write on the DMA transfer-complete, while the FIFO still drains): writes answer +`flevel`, the FIFO level sampled in the completion callback, which must be 0 (test_write_completes_after_last_byte), +and two writes issued from one completion callback must both reach the bus (test_back_to_back_writes). +""" + +from __future__ import annotations + +import contextlib +from collections.abc import Iterator +from ctypes import byref, c_double, c_int +from typing import Any + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.firmware import quiesce, settle +from hal_st_validation.groups.qspi import ( + QSPI_BUFFER, + QSPI_DUMMY_MAX, + QSPI_HEX_MAX, + QSPI_PRESCALER_MAX, + QSPI_REPEAT_MAX, + QSPI_SIZE_MAX, + QSPI_TIMEOUT_S, + qspi_bytes, + qspi_clock, + qspi_frame, + qspi_samples, +) +from hal_st_validation.patterns import crc_text, generate + +pytestmark = pytest.mark.family("stm32wb55") + +READ_DUMMY_CYCLES = 2 + + +@pytest.fixture +def qspi_cfg(board_cfg): + return board_cfg.param("qspi") + + +def qspi_pins(cfg) -> dict[str, str]: + pins = cfg["pins"] + return {"clk": pins["clk"], "ncs": pins["ncs"], **{f"io{line}": pin for line, pin in enumerate(pins["io"])}} + + +@pytest.fixture +def index(need, qspi_cfg) -> int: + """The QUADSPI drives all six pins: skip while an enabled option loads one of them.""" + need.unloaded(*qspi_pins(qspi_cfg).values()) + return qspi_cfg["index"] + + +def qspi_dios(need, cfg) -> dict[str, int]: + return {name: need.dio(pin) for name, pin in qspi_pins(cfg).items()} + + +def expect_error(reason, call, *args, **options): + with pytest.raises(FirmwareError) as error: + call(*args, **options) + assert error.value.reason == reason, (args, options) + + +def write_frame(cfg) -> tuple[dict[str, Any], bytes]: + """The `qspi.cmd` options of `tests.qspi.write` and the bytes the bus must carry for them.""" + write = cfg["write"] + options = {key: write[key] for key in ("instr", "addr", "abytes", "alt", "altbytes")} + data = bytes.fromhex(write["data"]) + frame = qspi_frame( + write["instr"], write["addr"].to_bytes(write["abytes"], "big"), write["alt"].to_bytes(write["altbytes"], "big"), data + ) + return {**options, "tx": data}, frame + + +def arm(ad3, dios, clock, duration): + """The logic analyzer on NCS falling, at the highest rate that holds `duration` (at least 4 samples per clock).""" + rate = min(ad3.logic.clock_hz, ad3.logic.buffer_size / duration) + if rate < 4 * clock: + pytest.skip(f"{duration * 1e6:.0f} us at {clock} Hz do not fit the logic analyzer buffer") + return ad3.logic.arm(rate, int(duration * rate), trigger=(dios["ncs"], "falling"), pretrigger=0.02) + + +def frames(capture, dios): + ios = [capture.channel(dios[f"io{line}"]) for line in range(4)] + return qspi_samples(capture.channel(dios["clk"]), capture.channel(dios["ncs"]), ios) + + +def bus_seconds(length, lines, clock, frames_count=1, gap=200e-6): + return frames_count * (length * 8 / lines / clock + gap) + + +@contextlib.contextmanager +def weakest_drive(ad3, dios) -> Iterator[None]: + """The weakest AD3 output current where `FDwfDigitalIODriveInfo` offers a choice, restored afterwards; a device or + runtime without drive control (or the `--fake` API) keeps its default.""" + saved: list[tuple[int, float, int]] = [] + api, handle = ad3.api, ad3.handle + try: + for dio in dios: + with contextlib.suppress(AttributeError, RuntimeError): + low, high, steps, slews = c_double(), c_double(), c_int(), c_int() + api.FDwfDigitalIODriveInfo(handle, c_int(dio), byref(low), byref(high), byref(steps), byref(slews)) + if steps.value > 1: + amplitude, slew = c_double(), c_int() + api.FDwfDigitalIODriveGet(handle, c_int(dio), byref(amplitude), byref(slew)) + api.FDwfDigitalIODriveSet(handle, c_int(dio), c_double(low.value), c_int(0)) + saved.append((dio, amplitude.value, slew.value)) + yield + finally: + for dio, amplitude, slew in saved: + with contextlib.suppress(AttributeError, RuntimeError): + api.FDwfDigitalIODriveSet(handle, c_int(dio), c_double(amplitude), c_int(slew)) + with contextlib.suppress(AttributeError, RuntimeError): + api.FDwfDigitalIOConfigure(handle) + + +@contextlib.contextmanager +def reset_on_failure(fw) -> Iterator[None]: + """A QUADSPI left busy by a command that never completed fails every later command: reset the board.""" + try: + yield + except Exception: + with contextlib.suppress(Exception): + fw.system.reset() + raise + + +@pytest.mark.board_params("variant", values=["poll", "dma", "spi"]) +@pytest.mark.board_params("prescaler", values=[0, 7, QSPI_PRESCALER_MAX]) +def test_open_reports_the_clock(fw, board_cfg, index, variant, prescaler): + assert fw.qspi.open(index, variant=variant, prescaler=prescaler) == qspi_clock(board_cfg.clock("qspi"), prescaler) + + +def test_open_errors(fw, index): + cases = [ + ("qspi.open", "usage"), + ("qspi.open 1 2", "usage"), + ("qspi.open 1 speed=1", "usage"), + ("qspi.open x", "usage"), + ("qspi.open 2", "range"), + ("qspi.open 0", "range"), + ("qspi.open 0 variant=octal", "usage"), + ("qspi.open 1 variant=octal", "usage"), + ("qspi.open 1 prescaler=x", "usage"), + (f"qspi.open 1 prescaler={QSPI_PRESCALER_MAX + 1}", "range"), + ("qspi.open 1 size=0", "range"), + (f"qspi.open 1 size={QSPI_SIZE_MAX + 1}", "range"), + ] + for line, reason in cases: + response = fw.terminal.command(line, check=False) + assert response.reason == reason, line + fw.qspi.open(index, variant="dma", size=QSPI_SIZE_MAX) + expect_error("busy", fw.qspi.open, index) + + +def test_pins_are_held(fw, qspi_cfg, index): + """`qspi.open` claims the six pins of the board profile: a pin held elsewhere makes it busy, and its pins are busy + for the other groups until `qspi.close`.""" + pins = qspi_pins(qspi_cfg) + fw.gpio.cfg(pins["io2"], "out") + expect_error("busy", fw.qspi.open, index) + fw.gpio.release(pins["io2"]) + fw.qspi.open(index, variant="spi") + for pin in pins.values(): + expect_error("busy", fw.gpio.cfg, pin, "in") + fw.qspi.close(index) + fw.gpio.cfg(pins["clk"], "in") + + +def test_commands_need_an_open_instance(fw, index): + for line in ("qspi.cmd 1 instr=0x06", "qspi.poll 1 match=1 mask=1", "qspi.xfer 1 9f", "qspi.close 1", "qspi.cmd 0"): + assert fw.terminal.command(line, check=False).reason == "notopen", line + for line in ("qspi.cmd 2", "qspi.close 2"): + assert fw.terminal.command(line, check=False).reason == "range", line + + +def test_command_errors(fw, index): + fw.qspi.open(index, variant="dma") + cases = [ + ("qspi.cmd 1 2", "usage"), + ("qspi.cmd 1 speed=1", "usage"), + ("qspi.cmd 1 tx=00 rx=1", "usage"), + ("qspi.cmd 1 tx=00 len=1", "usage"), + ("qspi.cmd 1 len=1 rx=1", "usage"), + ("qspi.cmd 1 pattern=inc", "usage"), + ("qspi.cmd 1 tx=00 seed=1", "usage"), + ("qspi.cmd 1 tx=00 out=crc", "usage"), + ("qspi.cmd 1 instr=0x03 rx=1 repeat=2", "usage"), + ("qspi.cmd 1 abytes=2", "usage"), + ("qspi.cmd 1 altbytes=2", "usage"), + ("qspi.cmd 1 lines=3 tx=00 rx=1", "usage"), + ("qspi.cmd 1 instr=x", "usage"), + ("qspi.cmd 1 instr=0x100", "range"), + ("qspi.cmd 1 addr=0x10000 abytes=2", "range"), + ("qspi.cmd 1 addr=0 abytes=5", "range"), + ("qspi.cmd 1 alt=0x100", "range"), + (f"qspi.cmd 1 dummy={QSPI_DUMMY_MAX + 1}", "range"), + ("qspi.cmd 1 lines=2", "range"), + ("qspi.cmd 1 lines=0", "range"), + ("qspi.cmd 1 tx=abc", "usage"), + ("qspi.cmd 1 len=0", "range"), + (f"qspi.cmd 1 len={QSPI_BUFFER + 1}", "range"), + ("qspi.cmd 1 len=4 pattern=noise", "usage"), + ("qspi.cmd 1 rx=0", "range"), + (f"qspi.cmd 1 rx={QSPI_BUFFER + 1} out=crc", "range"), + (f"qspi.cmd 1 rx={QSPI_HEX_MAX + 1}", "range"), + ("qspi.cmd 1 rx=4 out=bin", "usage"), + ("qspi.cmd 1 tx=00 repeat=0", "range"), + (f"qspi.cmd 1 tx=00 repeat={QSPI_REPEAT_MAX + 1}", "range"), + ] + for line, reason in cases: + assert fw.terminal.command(line, check=False).reason == reason, line + + +def test_poll_errors(fw, index): + fw.qspi.open(index, variant="dma") + cases = [ + ("qspi.poll 1", "usage"), + ("qspi.poll 1 match=1", "usage"), + ("qspi.poll 1 mask=1", "usage"), + ("qspi.poll 1 match=1 mask=1 tx=00", "usage"), + ("qspi.poll 1 match=1 mask=1 abytes=1", "usage"), + ("qspi.poll 1 match=x mask=1", "usage"), + ("qspi.poll 1 match=1 mask=1 size=0", "range"), + ("qspi.poll 1 match=1 mask=1 size=5", "range"), + ("qspi.poll 1 match=0x100 mask=1", "range"), + ("qspi.poll 1 match=1 mask=0x1ff size=1", "range"), + ("qspi.poll 1 match=1 mask=1 lines=2", "range"), + ] + for line, reason in cases: + assert fw.terminal.command(line, check=False).reason == reason, line + + +def test_xfer_errors(fw, index): + """`qspi.xfer` needs `variant=spi` and exactly one of tx and rx: `SingleSpeedQuadSpiStmDma` is half duplex.""" + fw.qspi.open(index, variant="dma") + expect_error("unsupported", fw.qspi.xfer, index, b"\x9f") + fw.qspi.close(index) + fw.qspi.open(index, variant="spi") + cases = [ + ("qspi.xfer 1", "usage"), + ("qspi.xfer 1 -", "usage"), + ("qspi.xfer 1 9f rx=1", "usage"), + ("qspi.xfer 1 - len=2 rx=1", "usage"), + ("qspi.xfer 1 - rx=1 repeat=2", "usage"), + ("qspi.xfer 1 9f tx=1", "usage"), + ("qspi.xfer 1 9f len=1", "usage"), + ("qspi.xfer 1 - pattern=inc rx=1", "usage"), + ("qspi.xfer 1 9", "usage"), + ("qspi.xfer 1 - rx=0", "range"), + (f"qspi.xfer 1 - rx={QSPI_HEX_MAX + 1}", "range"), + (f"qspi.xfer 1 - len={QSPI_BUFFER + 1}", "range"), + (f"qspi.xfer 1 9f repeat={QSPI_REPEAT_MAX + 1}", "range"), + ] + for line, reason in cases: + assert fw.terminal.command(line, check=False).reason == reason, line + + +@pytest.mark.board_params("variant", values=["poll", "dma", "spi"]) +def test_replies(fw, qspi_cfg, index, variant): + """Writes answer `flevel=0`, reads their data (or `len` and `crc` with `out=crc`); every phase is optional. The + reads have an instruction phase (read_instr), so they never take the data-only path of the erratum.""" + instr = qspi_cfg["read_instr"] + fw.qspi.open(index, variant=variant) + assert fw.qspi.cmd(index, instr=qspi_cfg["instruction"]).flevel == 0 + options, _ = write_frame(qspi_cfg) + assert fw.qspi.cmd(index, lines=4, **options).flevel == 0 + assert fw.qspi.cmd(index, lines=1, len=QSPI_BUFFER, pattern="prbs", seed=7).flevel == 0 + data = fw.qspi.cmd(index, instr=instr, lines=1, rx=qspi_cfg["read_length"]).data + assert data is not None and len(data) == qspi_cfg["read_length"] + crc = fw.qspi.cmd(index, instr=instr, addr=0, lines=4, dummy=READ_DUMMY_CYCLES, rx=QSPI_BUFFER, out="crc").crc + assert crc is not None and len(crc) == 8 + if variant == "spi": + assert fw.qspi.xfer(index, bytes.fromhex(qspi_cfg["xfer"])).flevel == 0 + + +@pytest.mark.board_params("variant", values=["dma", "spi"]) +def test_write_completes_after_last_byte(fw, qspi_cfg, index, variant): + """B.15: at the slowest clock the last bytes of a write take a while to leave the FIFO; the completion + callback must see it empty.""" + fw.qspi.open(index, variant=variant, prescaler=qspi_cfg["slow_prescaler"]) + length = qspi_cfg["flevel_length"] + if variant == "spi": + reply = fw.qspi.xfer(index, len=length, pattern="prbs", seed=1) + else: + reply = fw.qspi.cmd(index, instr=qspi_cfg["instruction"], lines=1, len=length, pattern="prbs", seed=1) + assert reply.flevel == 0 + + +@pytest.mark.ad3 +@pytest.mark.board_params("variant", values=["poll", "dma"]) +@pytest.mark.board_params("lines", "qspi.lines") +def test_write_decoded(fw, ad3, need, qspi_cfg, index, variant, lines): + """Instruction, address, alternate bytes and data of a write on the bus, all on 1 or on 4 lines.""" + dios = qspi_dios(need, qspi_cfg) + clock = fw.qspi.open(index, variant=variant, prescaler=qspi_cfg["decode_prescaler"]) + options, expected = write_frame(qspi_cfg) + capture = arm(ad3, dios, clock, bus_seconds(len(expected), lines, clock)) + assert fw.qspi.cmd(index, lines=lines, **options).flevel == 0 + found = frames(capture.wait(timeout=2.0), dios) + assert found, "no chip-select frame captured" + assert qspi_bytes(found[0], lines) == expected + + +@pytest.mark.ad3 +@pytest.mark.resets_board +@pytest.mark.board_params("variant", values=["poll", "dma", "spi"]) +def test_back_to_back_writes(fw, ad3, need, qspi_cfg, index, variant): + """B.15: `repeat=2` issues the second write from the completion callback of the first; both reach the bus.""" + dios = qspi_dios(need, qspi_cfg) + cfg = qspi_cfg["back_to_back"] + clock = fw.qspi.open(index, variant=variant, prescaler=qspi_cfg["decode_prescaler"]) + payload = generate(cfg["length"], "prbs", cfg["seed"]) + instr = None if variant == "spi" else qspi_cfg["instruction"] + expected = qspi_frame(instr, data=payload) + capture = arm(ad3, dios, clock, bus_seconds(len(expected), 1, clock, cfg["repeat"])) + with reset_on_failure(fw): + if variant == "spi": + reply = fw.qspi.xfer(index, len=cfg["length"], pattern="prbs", seed=cfg["seed"], repeat=cfg["repeat"]) + else: + reply = fw.qspi.cmd(index, instr=instr, lines=1, len=cfg["length"], pattern="prbs", seed=cfg["seed"], repeat=cfg["repeat"]) + assert reply.flevel == 0 + found = frames(capture.wait(timeout=2.0), dios) + assert [qspi_bytes(frame, 1) for frame in found] == [expected] * cfg["repeat"] + + +@pytest.mark.ad3 +@pytest.mark.resets_board +@pytest.mark.board_params("variant", values=["poll", "dma"]) +@pytest.mark.board_params("nibble", "qspi.nibbles") +def test_receive_nibbles(fw, ad3, need, qspi_cfg, index, variant, nibble): + """The first command after `qspi.open` is a 4-line read without instruction, address or alternate bytes, so the + QUADSPI drives none of IO0-IO3 while the AD3 holds them at `nibble`: every byte is the nibble twice.""" + dios = qspi_dios(need, qspi_cfg) + ios = [dios[f"io{line}"] for line in range(4)] + length = qspi_cfg["read_length"] + fw.qspi.open(index, variant=variant, prescaler=qspi_cfg["decode_prescaler"]) + with reset_on_failure(fw): + try: + with weakest_drive(ad3, ios): + ad3.dio.drive_many({dio: (nibble >> line) & 1 for line, dio in enumerate(ios)}) + reply = fw.qspi.cmd(index, lines=4, dummy=READ_DUMMY_CYCLES, rx=length) + finally: + ad3.dio.release(*ios) + assert reply.data == bytes([nibble << 4 | nibble]) * length + + +@pytest.mark.ad3 +@pytest.mark.board_params("variant", values=["poll", "dma"]) +def test_poll_match(fw, ad3, need, qspi_cfg, index, variant): + """A 1-line instruction followed by a 1-line status read on IO1, which the AD3 holds high: the status matches + at once. The status is read once first, because one that never matches blocks `variant=poll` for good.""" + dios = qspi_dios(need, qspi_cfg) + poll = qspi_cfg["poll"] + clock = fw.qspi.open(index, variant=variant, prescaler=qspi_cfg["decode_prescaler"]) + ad3.dio.drive(dios["io1"], 1) + status = fw.qspi.cmd(index, instr=poll["instr"], lines=1, rx=1).data + if status != b"\xff": + pytest.fail(f"IO1 reads {status!r} with the AD3 driving it high: check DIO3 on PB8") + capture = arm(ad3, dios, clock, bus_seconds(2, 1, clock)) + fw.qspi.poll(index, match=poll["match"], mask=poll["mask"], instr=poll["instr"], lines=1) + found = frames(capture.wait(timeout=2.0), dios) + assert found and len(found[0]) >= 16, "no instruction and status on the bus" + assert qspi_bytes(found[0][:8], 1, line=0) == bytes([poll["instr"]]) + assert qspi_bytes(found[0][8:16], 1, line=1) == b"\xff" + + +@pytest.mark.ad3 +@pytest.mark.board_params("variant", values=["poll", "dma", "spi"]) +@pytest.mark.board_params("level", values=[0, 1]) +def test_read_crc(fw, ad3, need, qspi_cfg, index, variant, level): + """A full-buffer 1-line read of IO1, which the AD3 holds at `level`, answers the CRC-32 of those bytes.""" + dios = qspi_dios(need, qspi_cfg) + fw.qspi.open(index, variant=variant) + ad3.dio.drive(dios["io1"], level) + reply = fw.qspi.cmd(index, instr=qspi_cfg["read_instr"], lines=1, rx=QSPI_BUFFER, out="crc") + assert reply.crc == crc_text(bytes([0xFF * level]) * QSPI_BUFFER) + + +@pytest.mark.ad3 +@pytest.mark.slow +@pytest.mark.resets_board +@pytest.mark.board_params("variant", values=["dma", "poll"]) +def test_poll_timeout(fw, ad3, need, qspi_cfg, index, variant): + """A status that never matches answers `ERR timeout` after 2 s; the group stays busy until `qspi.close`, which + stops the polling (`~QuadSpiStmDma` clears CR), and a new open works. `variant=poll` blocks instead (B.14): + the AD3 then makes the status match, so the firmware returns.""" + dios = qspi_dios(need, qspi_cfg) + poll = qspi_cfg["poll"] + fw.qspi.open(index, variant=variant) + ad3.dio.drive(dios["io1"], 0) + with reset_on_failure(fw): + pending = fw.qspi.begin( + "poll", + index, + match=poll["match"], + mask=poll["mask"], + instr=poll["instr"], + lines=1, + cmd_timeout=fw.terminal.timeout + QSPI_TIMEOUT_S, + ) + response = settle(pending) + if response is None: + ad3.dio.drive(dios["io1"], 1) + quiesce(fw.terminal, quiet=0.5) + assert response is not None and response.reason == "timeout", f"qspi.poll answered {response!r}" + expect_error("busy", fw.qspi.cmd, index, instr=qspi_cfg["instruction"]) + fw.qspi.close(index) + ad3.dio.release(dios["io1"]) + fw.qspi.open(index, variant=variant) + assert fw.qspi.cmd(index, instr=qspi_cfg["instruction"]).flevel == 0 + + +@pytest.mark.ad3 +def test_xfer_decoded(fw, ad3, need, qspi_cfg, index): + """`variant=spi`: `SingleSpeedQuadSpiStmDma` writes are SPI mode 0 with MOSI on IO0 and NCS as chip select.""" + dios = qspi_dios(need, qspi_cfg) + payload = bytes.fromhex(qspi_cfg["xfer"]) + clock = fw.qspi.open(index, variant="spi", prescaler=qspi_cfg["decode_prescaler"]) + capture = arm(ad3, dios, clock, bus_seconds(len(payload), 1, clock)) + assert fw.qspi.xfer(index, payload).flevel == 0 + result = capture.wait(timeout=2.0) + spi_frames = result.spi(dios["clk"], dios["io0"], None, dios["ncs"], 0) + assert analysis.spi_join(spi_frames)[0] == payload + assert analysis.clock_idle_level(result.channel(dios["clk"]), result.channel(dios["ncs"])) == 0 + + +@pytest.mark.ad3 +@pytest.mark.resets_board +@pytest.mark.board_params("level", values=[0, 1]) +def test_xfer_receive(fw, ad3, need, qspi_cfg, index, level): + """`variant=spi` receives on IO1 (MISO), which the AD3 holds at `level`; the read has a data phase only.""" + dios = qspi_dios(need, qspi_cfg) + length = qspi_cfg["read_length"] + fw.qspi.open(index, variant="spi", prescaler=qspi_cfg["decode_prescaler"]) + ad3.dio.drive(dios["io1"], level) + with reset_on_failure(fw): + assert fw.qspi.xfer(index, rx=length).data == bytes([0xFF * level]) * length diff --git a/validation/host/tests/unit/test_fake_groups.py b/validation/host/tests/unit/test_fake_groups.py index 7ff787a7..451c55d6 100644 --- a/validation/host/tests/unit/test_fake_groups.py +++ b/validation/host/tests/unit/test_fake_groups.py @@ -44,13 +44,6 @@ def cmd_event(self, args, options): return ["EVT dummy line=2", "OK"] -class Qspi(FakeGroup): - prefix = "qspi" - - def cmd_close(self, args, options): - return "OK" - - class Helper: prefix = "helper" """ @@ -175,7 +168,7 @@ def test_unsupported_names_come_first(area): terminal, _ = make("stm32wba55") assert reason(terminal, "qspi.close 1") == "unsupported" terminal, _ = make("stm32wb55") - assert reason(terminal, "qspi.close 1") == "ok" + assert reason(terminal, "qspi.close 1") == "notopen", "the qspi group of fakes/qspi.py serves the STM32WB55" def test_firmware_attaches_the_groups(area): diff --git a/validation/host/tests/unit/test_fake_qspi.py b/validation/host/tests/unit/test_fake_qspi.py new file mode 100644 index 00000000..5dae7695 --- /dev/null +++ b/validation/host/tests/unit/test_fake_qspi.py @@ -0,0 +1,357 @@ +"""The fake `qspi` group (argument order and reasons of validation/firmware/QuadSpiGroup.cpp; PROTOCOL.md D.20), the +pins and DMA channel it holds, its bus model, the `groups.qspi` wrappers and the decoding of QUADSPI captures.""" + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal + +from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS, FakeFirmware, FakeSerial +from hal_st_validation.fakes.qspi import FakeQuadSpi +from hal_st_validation.firmware import Firmware +from hal_st_validation.groups import qspi +from hal_st_validation.patterns import crc_text, generate + + +class Clock: + def __init__(self) -> None: + self.now = 100.0 + + def __call__(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.now += seconds + + +def make(family="stm32wb55"): + clock = Clock() + fake = FakeFirmware(family=family, clock=clock, sleep=clock.sleep) + terminal = FirmwareTerminal(serial=FakeSerial(fake), timeout=0.5) + pins = WB55_PINS if family == "stm32wb55" else WBA55_PINS + return terminal, fake, clock, Firmware(terminal, pins) + + +def reason(terminal, line): + response = terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +def bus(fake) -> FakeQuadSpi: + group = fake.group("qspi") + assert isinstance(group, FakeQuadSpi) + return group + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("qspi.open", "usage"), + ("qspi.open 1 2", "usage"), + ("qspi.open 1 speed=1", "usage"), + ("qspi.open x", "usage"), + ("qspi.open 2", "range"), + ("qspi.open 0", "range"), + ("qspi.open 0 variant=octal", "usage"), + ("qspi.open 1 variant=octal", "usage"), + ("qspi.open 1 prescaler=x", "usage"), + ("qspi.open 1 prescaler=256", "range"), + ("qspi.open 1 prescaler=256 variant=octal", "usage"), + ("qspi.open 1 size=0", "range"), + ("qspi.open 1 size=33", "range"), + ("qspi.open 1", "ok"), + ("qspi.open 1 variant=spi prescaler=255 size=32", "ok"), + ], +) +def test_open_reasons(line, expected): + terminal, *_ = make() + assert reason(terminal, line) == expected + + +def test_open_reports_the_clock_and_tracks(): + terminal, fake, _, fw = make() + assert fw.qspi.open(1, variant="dma", prescaler=7) == 8_000_000 + assert fake.received[-1] == "qspi.open 1 variant=dma prescaler=7" + assert fw.open_instances == [("qspi", 1)] + assert reason(terminal, "qspi.open 1") == "busy" + fw.close_all() + assert fake.received[-1] == "qspi.close 1" + assert fw.qspi.open(1) == qspi.qspi_clock(64_000_000, 2) == 21_333_333 + + +@pytest.mark.parametrize("pin", ["PA3", "PA2", "PB9", "PB8", "PA7", "PA6"]) +def test_open_holds_the_pins(pin): + terminal, _, _, fw = make() + fw.gpio.cfg(pin, "out") + assert reason(terminal, "qspi.open 1") == "busy" + fw.gpio.release(pin) + fw.qspi.open(1) + assert reason(terminal, f"gpio.cfg {pin} in") == "busy" + fw.qspi.close(1) + assert reason(terminal, f"gpio.cfg {pin} in") == "ok" + + +@pytest.mark.parametrize(("variant", "claimed"), [("poll", False), ("dma", True), ("spi", True)]) +def test_dma_channel_held_by_the_dma_variants(variant, claimed): + _, fake, _, fw = make() + fw.qspi.open(1, variant=variant) + assert (("dma2", 3) in fake.resources) == claimed + fw.qspi.close(1) + assert ("dma2", 3) not in fake.resources + + +def test_wba55_has_no_quadspi(): + terminal, *_ = make("stm32wba55") + for line in ("qspi.open 1", "qspi.cmd 1 instr=0x9f rx=3", "qspi.poll 1 match=1 mask=1", "qspi.xfer 1 9f", "qspi.close 1"): + assert reason(terminal, line) == "unsupported", line + + +def test_commands_need_an_open_instance(): + terminal, *_ = make() + for line in ("qspi.cmd 1 instr=0x06", "qspi.poll 1 match=1 mask=1", "qspi.xfer 1 9f", "qspi.close 1", "qspi.cmd 0"): + assert reason(terminal, line) == "notopen", line + for line in ("qspi.cmd 2", "qspi.close 2", "qspi.cmd x"): + assert reason(terminal, line) == ("usage" if line.endswith("x") else "range"), line + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("qspi.cmd 1", "ok"), + ("qspi.cmd 1 2", "usage"), + ("qspi.cmd 1 speed=1", "usage"), + ("qspi.cmd 1 tx=00 rx=1", "usage"), + ("qspi.cmd 1 tx=00 len=1", "usage"), + ("qspi.cmd 1 len=1 rx=1", "usage"), + ("qspi.cmd 1 pattern=inc", "usage"), + ("qspi.cmd 1 tx=00 seed=1", "usage"), + ("qspi.cmd 1 tx=00 out=crc", "usage"), + ("qspi.cmd 1 instr=0x03 rx=1 repeat=2", "usage"), + ("qspi.cmd 1 abytes=2", "usage"), + ("qspi.cmd 1 altbytes=2", "usage"), + ("qspi.cmd 1 lines=3 tx=00 rx=1", "usage"), + ("qspi.cmd 1 instr=x", "usage"), + ("qspi.cmd 1 instr=0x100", "range"), + ("qspi.cmd 1 instr=0x100 dummy=x", "range"), + ("qspi.cmd 1 addr=0x10000 abytes=2", "range"), + ("qspi.cmd 1 addr=0xffff abytes=2", "ok"), + ("qspi.cmd 1 addr=0xffffffff abytes=4", "ok"), + ("qspi.cmd 1 addr=0 abytes=5", "range"), + ("qspi.cmd 1 alt=0x100", "range"), + ("qspi.cmd 1 alt=0x100 altbytes=2", "ok"), + ("qspi.cmd 1 dummy=32", "range"), + ("qspi.cmd 1 dummy=31", "ok"), + ("qspi.cmd 1 lines=2", "range"), + ("qspi.cmd 1 lines=0", "range"), + ("qspi.cmd 1 lines=4 tx=00", "ok"), + ("qspi.cmd 1 tx=abc", "usage"), + ("qspi.cmd 1 tx=-", "ok"), + ("qspi.cmd 1 len=0", "range"), + ("qspi.cmd 1 len=257", "range"), + ("qspi.cmd 1 len=4 pattern=noise", "usage"), + ("qspi.cmd 1 len=4 seed=x pattern=noise", "usage"), + ("qspi.cmd 1 len=256 pattern=prbs seed=0xffffffff", "ok"), + ("qspi.cmd 1 rx=0", "range"), + ("qspi.cmd 1 rx=257 out=crc", "range"), + ("qspi.cmd 1 rx=256 out=crc", "ok"), + ("qspi.cmd 1 rx=129", "range"), + ("qspi.cmd 1 rx=128", "ok"), + ("qspi.cmd 1 rx=4 out=bin", "usage"), + ("qspi.cmd 1 tx=00 repeat=0", "range"), + ("qspi.cmd 1 tx=00 repeat=9", "range"), + ("qspi.cmd 1 len=8 repeat=8", "ok"), + ], +) +def test_command_reasons(line, expected): + terminal, _, _, fw = make() + fw.qspi.open(1, variant="dma") + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("qspi.poll 1", "usage"), + ("qspi.poll 1 match=1", "usage"), + ("qspi.poll 1 mask=1", "usage"), + ("qspi.poll 1 match=1 mask=1 tx=00", "usage"), + ("qspi.poll 1 match=1 mask=1 abytes=1", "usage"), + ("qspi.poll 1 match=x mask=1", "usage"), + ("qspi.poll 1 match=1 mask=1 size=0", "range"), + ("qspi.poll 1 match=1 mask=1 size=5", "range"), + ("qspi.poll 1 match=0x100 mask=1", "range"), + ("qspi.poll 1 match=1 mask=0x1ff size=1", "range"), + ("qspi.poll 1 match=0x1ff mask=0x1ff size=2", "ok"), + ("qspi.poll 1 match=1 mask=1 lines=2", "range"), + ("qspi.poll 1 match=1 mask=1 instr=0x05", "ok"), + ], +) +def test_poll_reasons(line, expected): + terminal, _, _, fw = make() + fw.qspi.open(1, variant="dma") + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("qspi.xfer 1", "usage"), + ("qspi.xfer 1 -", "usage"), + ("qspi.xfer 1 9f rx=1", "usage"), + ("qspi.xfer 1 - len=2 rx=1", "usage"), + ("qspi.xfer 1 - rx=1 repeat=2", "usage"), + ("qspi.xfer 1 9f tx=1", "usage"), + ("qspi.xfer 1 9f len=1", "usage"), + ("qspi.xfer 1 - pattern=inc rx=1", "usage"), + ("qspi.xfer 1 9", "usage"), + ("qspi.xfer 1 - rx=0", "range"), + ("qspi.xfer 1 - rx=129", "range"), + ("qspi.xfer 1 - len=257", "range"), + ("qspi.xfer 1 9f repeat=9", "range"), + ("qspi.xfer 1 9f", "ok"), + ("qspi.xfer 1 - len=256 pattern=const seed=0xa5 repeat=8", "ok"), + ("qspi.xfer 1 - rx=128", "ok"), + ], +) +def test_xfer_reasons(line, expected): + terminal, _, _, fw = make() + fw.qspi.open(1, variant="spi") + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize("variant", ["poll", "dma"]) +def test_xfer_needs_the_spi_variant(variant): + terminal, _, _, fw = make() + fw.qspi.open(1, variant=variant) + assert reason(terminal, "qspi.xfer 1 9f") == "unsupported" + assert reason(terminal, "qspi.xfer 1 9f rx=1") == "usage" + + +def test_wrappers_format_and_parse(): + _, fake, _, fw = make() + fw.qspi.open(1, variant="spi") + reply = fw.qspi.cmd(1, instr=0x38, addr=0x123456, abytes=3, alt=0xA55A, altbytes=2, lines=4, tx=b"\x00\xff") + assert fake.received[-1] == "qspi.cmd 1 instr=56 addr=1193046 abytes=3 alt=42330 altbytes=2 lines=4 tx=00ff" + assert reply == qspi.QuadSpiReply(flevel=0) + assert fw.qspi.cmd(1, len=8, pattern="prbs", seed=1, repeat=2) == qspi.QuadSpiReply(flevel=0) + assert fake.received[-1] == "qspi.cmd 1 len=8 pattern=prbs seed=1 repeat=2" + assert fw.qspi.cmd(1, instr=0x0B, rx=3) == qspi.QuadSpiReply(data=b"\xff\xff\xff") + assert fw.qspi.cmd(1, instr=0x0B, rx=256, out="crc") == qspi.QuadSpiReply(crc=crc_text(b"\xff" * 256)) + fw.qspi.poll(1, match=1, mask=1, instr=5, lines=1) + assert fake.received[-1] == "qspi.poll 1 instr=5 lines=1 match=1 mask=1" + assert fw.qspi.xfer(1, b"\x9f\x01") == qspi.QuadSpiReply(flevel=0) + assert fake.received[-1] == "qspi.xfer 1 9f01" + assert fw.qspi.xfer(1, len=4, repeat=2).flevel == 0 + assert fake.received[-1] == "qspi.xfer 1 - len=4 repeat=2" + assert fw.qspi.xfer(1, rx=2) == qspi.QuadSpiReply(data=b"\xff\xff") + assert fake.received[-1] == "qspi.xfer 1 - rx=2" + + +@pytest.mark.parametrize(("io", "lines", "byte"), [(0x0, 4, 0x00), (0x5, 4, 0x55), (0xA, 4, 0xAA), (0x2, 1, 0xFF), (0xD, 1, 0x00)]) +def test_reads_follow_the_io_levels(io, lines, byte): + _, fake, _, fw = make() + fw.qspi.open(1, variant="dma") + bus(fake).io = io + assert fw.qspi.cmd(1, lines=lines, dummy=2, rx=4).data == bytes([byte]) * 4 + + +def test_spi_reads_follow_io1(): + _, fake, _, fw = make() + fw.qspi.open(1, variant="spi") + bus(fake).io = 0b1101 + assert fw.qspi.xfer(1, rx=2).data == b"\x00\x00" + bus(fake).io = 0b0010 + assert fw.qspi.xfer(1, rx=2).data == b"\xff\xff" + + +@pytest.mark.parametrize("variant", ["poll", "dma"]) +def test_poll_timeout_keeps_the_group_busy_until_close(variant): + terminal, fake, clock, fw = make() + fw.qspi.open(1, variant=variant) + fw.qspi.poll(1, match=0x01, mask=0x01, lines=1) + bus(fake).io = 0b0000 + start = clock.now + with pytest.raises(FirmwareError) as error: + fw.qspi.poll(1, match=0x01, mask=0x01, lines=1) + assert error.value.reason == "timeout" + assert clock.now - start == qspi.QSPI_TIMEOUT_S + assert reason(terminal, "qspi.cmd 1 instr=0x06") == "busy" + assert reason(terminal, "qspi.cmd 1 instr=0x100") == "range" + fw.qspi.close(1) + fw.qspi.open(1, variant=variant) + assert fw.qspi.cmd(1, instr=0x06).flevel == 0 + fw.qspi.poll(1, match=0x00, mask=0x01, size=2, lines=4) + + +def test_boot_resets_the_bus_and_the_instance(): + terminal, fake, _, fw = make() + fw.qspi.open(1) + bus(fake).io = 0 + fw.system.reset() + fw.forget_open() + assert bus(fake).io == 0xF + assert reason(terminal, "qspi.cmd 1") == "notopen" + + +def test_qspi_clock_and_frame(): + assert qspi.qspi_clock(64_000_000, 0) == 64_000_000 + assert qspi.qspi_clock(64_000_000, 255) == 250_000 + assert qspi.qspi_frame(0x38, b"\x12\x34", b"\xa5", b"\x00") == b"\x38\x12\x34\xa5\x00" + assert qspi.qspi_frame(data=b"\x01") == b"\x01" + + +def synthesize(frames: list[bytes], lines: int, half: int = 3, gap: int = 7) -> tuple[list[int], list[int], list[list[int]]]: + """CLK, NCS and IO0-IO3 samples of QUADSPI writes in clock mode 0: each bit (1 line, on IO0) or nibble (4 lines) + is set while CLK is low and sampled on the rising edge; NCS is high for `gap` samples around each frame.""" + clk: list[int] = [] + ncs: list[int] = [] + ios: list[list[int]] = [[], [], [], []] + + def sample(clock: int, select: int, nibble: int) -> None: + clk.append(clock) + ncs.append(select) + for line in range(4): + ios[line].append(nibble >> line & 1) + + for frame in frames: + for _ in range(gap): + sample(0, 1, 0) + units = ( + [byte >> shift & 0xF for byte in frame for shift in (4, 0)] + if lines == 4 + else [byte >> (7 - bit) & 1 for byte in frame for bit in range(8)] + ) + for unit in units: + for _ in range(half): + sample(0, 0, unit) + for _ in range(half): + sample(1, 0, unit) + for _ in range(half): + sample(0, 0, 0) + for _ in range(gap): + sample(0, 1, 0) + return clk, ncs, ios + + +@pytest.mark.parametrize("lines", [1, 4]) +def test_decode_writes(lines): + frames = [qspi.qspi_frame(0x38, b"\x12\x34\x56", b"\xa5\x5a", b"\x00\xff\x5a\xa5\xc3"), generate(32, "prbs", 1)] + clk, ncs, ios = synthesize(frames, lines) + samples = qspi.qspi_samples(clk, ncs, ios) + assert len(samples) == 2 + assert [qspi.qspi_bytes(frame, lines) for frame in samples] == frames + + +def test_decode_one_line_on_io1_and_partial_bytes(): + nibbles = [0b0010] * 8 + [0b0000] * 8 + [0b0010] * 3 + assert qspi.qspi_bytes(nibbles, 1, line=1) == b"\xff\x00" + assert qspi.qspi_bytes(nibbles, 1, line=0) == b"\x00\x00" + assert qspi.qspi_bytes([0x5, 0xA, 0xF], 4) == b"\x5a" + with pytest.raises(ValueError): + qspi.qspi_bytes([0, 0], 2) + + +def test_decode_ignores_edges_outside_the_chip_select(): + clk = [0, 1, 0, 1, 0, 1, 0, 1, 0] + ncs = [1, 1, 1, 0, 0, 0, 0, 1, 1] + ios = [[1] * 9, [0] * 9, [0] * 9, [0] * 9] + assert qspi.qspi_samples(clk, ncs, ios) == [[1, 1]] + assert qspi.qspi_samples([0, 1, 0], [1, 1, 1], ios) == [] From 81428df8f8476b7afc394c8e3f310511383169b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:21:27 +0000 Subject: [PATCH 47/57] fix(rng): register the RNG interrupt under the name each device declares Including PeripheralTable.hpp made RandomDataGeneratorStm build on every MCU with an RNG, but the IRQ was picked per family: STM32F410/F412/F413/ F423 declare only RNG_IRQn and STM32G041/G061/G081/G0C1 only AES_RNG_IRQn, so those builds failed. HASH_RNG_IRQn is now used only on the F4 parts that declare nothing else. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/RandomDataGeneratorStm.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/hal_st/stm32fxxx/RandomDataGeneratorStm.cpp b/hal_st/stm32fxxx/RandomDataGeneratorStm.cpp index 10c0b638..a00a5b6b 100644 --- a/hal_st/stm32fxxx/RandomDataGeneratorStm.cpp +++ b/hal_st/stm32fxxx/RandomDataGeneratorStm.cpp @@ -7,8 +7,10 @@ namespace hal { RandomDataGeneratorStm::RandomDataGeneratorStm() { -#if defined(STM32F4) +#if defined(STM32F415xx) || defined(STM32F417xx) || defined(STM32F427xx) || defined(STM32F429xx) || defined(STM32F437xx) || defined(STM32F439xx) || defined(STM32F469xx) || defined(STM32F479xx) Register(HASH_RNG_IRQn); +#elif defined(STM32G0) + Register(AES_RNG_IRQn); #else Register(RNG_IRQn); #endif From 9efd9cec6bd65195c0b917835bc39ef0dcc870bc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:21:28 +0000 Subject: [PATCH 48/57] fix(qspi): derive the QUADSPI FIFO threshold from the FTHRES field QuadSpiStm passed FifoThreshold = 32, which HAL_QSPI_Init rejects on parts with a 16-byte FIFO (STM32WB, STM32G4) and so aborted under USE_FULL_ASSERT. The threshold is now the field maximum plus one: 16 on WB/G4, still 32 on F7. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/QuadSpiStm.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hal_st/stm32fxxx/QuadSpiStm.cpp b/hal_st/stm32fxxx/QuadSpiStm.cpp index f6bbc08e..0041d44f 100644 --- a/hal_st/stm32fxxx/QuadSpiStm.cpp +++ b/hal_st/stm32fxxx/QuadSpiStm.cpp @@ -19,7 +19,7 @@ namespace hal handle.Instance = QUADSPI; handle.Init.ClockPrescaler = config.prescaler; - handle.Init.FifoThreshold = 32; + handle.Init.FifoThreshold = (QUADSPI_CR_FTHRES >> QUADSPI_CR_FTHRES_Pos) + 1; handle.Init.SampleShifting = QSPI_SAMPLE_SHIFTING_HALFCYCLE; handle.Init.FlashSize = config.flashSizeLog2 - 1; handle.Init.ChipSelectHighTime = QSPI_CS_HIGH_TIME_2_CYCLE; From e80bf9bc56aaafb8d606b3634a4da48572b4d08f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:21:28 +0000 Subject: [PATCH 49/57] fix(flash): erase per bank on dual-bank STM32WBA The WB/WBA page erase left FLASH_EraseInitTypeDef.Banks uninitialised and passed an absolute page across both banks, so on STM32WBA62-65 it erased a page of a random bank. The synchronous driver took the G0 dual-bank path on WBA6x and treated the region-relative sector as a physical page. Both drivers now share detail::ErasePages, which splits the range per bank, honours SWAP_BANK and passes bank-relative pages; single-bank WB/WBA keep their behaviour. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/FlashInternalStm.cpp | 11 +++---- hal_st/stm32fxxx/FlashInternalStmDetail.hpp | 32 +++++++++++++++++++ .../SynchronousFlashInternalStm.cpp | 10 +++--- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/hal_st/stm32fxxx/FlashInternalStm.cpp b/hal_st/stm32fxxx/FlashInternalStm.cpp index 18a57409..54b82d32 100644 --- a/hal_st/stm32fxxx/FlashInternalStm.cpp +++ b/hal_st/stm32fxxx/FlashInternalStm.cpp @@ -89,19 +89,16 @@ namespace hal { HAL_FLASH_Unlock(); -#if defined(STM32WB) || defined(STM32G4) || defined(STM32G0) || defined(STM32WBA) +#if defined(STM32WB) || defined(STM32WBA) + const auto regionOffset = reinterpret_cast(flashMemory.begin()) - FLASH_BASE; + detail::ErasePages((regionOffset + AddressOfSector(beginIndex)) / FLASH_PAGE_SIZE, (regionOffset + AddressOfSector(endIndex)) / FLASH_PAGE_SIZE); +#elif defined(STM32G4) || defined(STM32G0) uint32_t pageError = 0; FLASH_EraseInitTypeDef eraseInitStruct; eraseInitStruct.TypeErase = FLASH_TYPEERASE_PAGES; -#if defined(STM32WB) || defined(STM32WBA) - const auto regionOffset = reinterpret_cast(flashMemory.begin()) - FLASH_BASE; - eraseInitStruct.Page = (regionOffset + AddressOfSector(beginIndex)) / FLASH_PAGE_SIZE; - eraseInitStruct.NbPages = (AddressOfSector(endIndex) - AddressOfSector(beginIndex)) / FLASH_PAGE_SIZE; -#else eraseInitStruct.Page = beginIndex; eraseInitStruct.NbPages = endIndex - beginIndex; -#endif auto result = HAL_FLASHEx_Erase(&eraseInitStruct, &pageError); really_assert(result == HAL_OK); diff --git a/hal_st/stm32fxxx/FlashInternalStmDetail.hpp b/hal_st/stm32fxxx/FlashInternalStmDetail.hpp index f449ac54..1e6d11a8 100644 --- a/hal_st/stm32fxxx/FlashInternalStmDetail.hpp +++ b/hal_st/stm32fxxx/FlashInternalStmDetail.hpp @@ -2,7 +2,9 @@ #define HAL_FLASH_INTERNAL_STM_DETAIL_HPP #include DEVICE_HEADER +#include "infra/util/ReallyAssert.hpp" #include "services/flash/FlashAlign.hpp" +#include #include namespace hal @@ -53,6 +55,36 @@ namespace hal chunk = flashAlign.Next(); } } + +#if defined(STM32WB) || defined(STM32WBA) + inline void ErasePages(uint32_t page, uint32_t endPage) + { +#if defined(FLASH_DBANK_SUPPORT) + const uint32_t pagesPerBank = FLASH_PAGE_NB; + const bool swapped = READ_BIT(FLASH->OPTR, FLASH_OPTR_SWAP_BANK) != 0; +#endif + + while (page != endPage) + { + FLASH_EraseInitTypeDef eraseInitStruct{}; + eraseInitStruct.TypeErase = FLASH_TYPEERASE_PAGES; +#if defined(FLASH_DBANK_SUPPORT) + const uint32_t bankEndPage = std::min(endPage, (page / pagesPerBank + 1) * pagesPerBank); + eraseInitStruct.Banks = (page >= pagesPerBank) == swapped ? FLASH_BANK_1 : FLASH_BANK_2; + eraseInitStruct.Page = page % pagesPerBank; +#else + const uint32_t bankEndPage = endPage; + eraseInitStruct.Page = page; +#endif + eraseInitStruct.NbPages = bankEndPage - page; + + uint32_t pageError = 0; + auto result = HAL_FLASHEx_Erase(&eraseInitStruct, &pageError); + really_assert(result == HAL_OK); + page = bankEndPage; + } + } +#endif } } diff --git a/hal_st/synchronous_stm32fxxx/SynchronousFlashInternalStm.cpp b/hal_st/synchronous_stm32fxxx/SynchronousFlashInternalStm.cpp index bb9b6165..11297787 100644 --- a/hal_st/synchronous_stm32fxxx/SynchronousFlashInternalStm.cpp +++ b/hal_st/synchronous_stm32fxxx/SynchronousFlashInternalStm.cpp @@ -14,7 +14,7 @@ namespace #endif }; -#if defined(FLASH_DBANK_SUPPORT) +#if defined(STM32G0) && defined(FLASH_DBANK_SUPPORT) uint32_t GetBank(uint32_t sectorIndex) { return sectorIndex < FLASH_PAGE_NB ? FLASH_BANK_1 : FLASH_BANK_2; @@ -72,7 +72,10 @@ namespace hal { HAL_FLASH_Unlock(); -#if defined(STM32WB) || defined(STM32G4) || defined(STM32G0) || defined(STM32WBA) +#if defined(STM32WB) || defined(STM32WBA) + const auto regionOffset = reinterpret_cast(flashMemory.begin()) - FLASH_BASE; + detail::ErasePages((regionOffset + AddressOfSector(beginIndex)) / FLASH_PAGE_SIZE, (regionOffset + AddressOfSector(endIndex)) / FLASH_PAGE_SIZE); +#elif defined(STM32G4) || defined(STM32G0) auto erase = [](uint32_t beginIndex, uint32_t nbPages, uint32_t bank) { @@ -98,9 +101,6 @@ namespace hal erase(beginIndex, FLASH_PAGE_NB - beginIndex, FLASH_BANK_1); erase(0, endIndex - FLASH_PAGE_NB + 1, FLASH_BANK_2); } -#elif defined(STM32WB) || defined(STM32WBA) - const auto regionOffset = reinterpret_cast(flashMemory.begin()) - FLASH_BASE; - erase((regionOffset + AddressOfSector(beginIndex)) / FLASH_PAGE_SIZE, (AddressOfSector(endIndex) - AddressOfSector(beginIndex)) / FLASH_PAGE_SIZE, 0); #else erase(beginIndex, endIndex - beginIndex, 0); #endif From 5cf9d712d9ccb9e38c458404ec5ce2b9ea6b8c5e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:21:28 +0000 Subject: [PATCH 50/57] fix(flash): unlock the flash for the ECC error page erase The NMI handler erased the page of an ECC error with FLASH_CR locked, so the erase did nothing. It now reads ECCR before clearing the flag, skips the erase while a flash operation is in progress, and unlocks and relocks the flash around the erase when it was locked. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- .../FlashCoordinatedWithWirelessStack.cpp | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/hal_st/stm32fxxx/FlashCoordinatedWithWirelessStack.cpp b/hal_st/stm32fxxx/FlashCoordinatedWithWirelessStack.cpp index a452399a..04a0964d 100644 --- a/hal_st/stm32fxxx/FlashCoordinatedWithWirelessStack.cpp +++ b/hal_st/stm32fxxx/FlashCoordinatedWithWirelessStack.cpp @@ -208,11 +208,23 @@ namespace hal { if (__HAL_FLASH_GET_FLAG(FLASH_FLAG_ECCD)) { - __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_ECCD); const uint32_t errorAddress = READ_BIT(FLASH->ECCR, FLASH_ECCR_ADDR_ECC); + __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_ECCD); + + if (pFlash.Lock == HAL_LOCKED || __HAL_FLASH_GET_FLAG(FLASH_FLAG_BSY)) + return; + + // An unlocked FLASH_CR belongs to an interrupted WriteBuffer/EraseSectors, which must find it still unlocked + const bool wasLocked = READ_BIT(FLASH->CR, FLASH_CR_LOCK) != 0; + if (wasLocked && HAL_FLASH_Unlock() != HAL_OK) + return; + uint32_t pageError = 0; FLASH_EraseInitTypeDef eraseInitStruct{ .TypeErase = FLASH_TYPEERASE_PAGES, .Page = (errorAddress * 8U) / FLASH_PAGE_SIZE, .NbPages = 1 }; HAL_FLASHEx_Erase(&eraseInitStruct, &pageError); + + if (wasLocked) + HAL_FLASH_Lock(); } } From 0711b8702b40984a4d34ec4d6988d14c4e0e57d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:21:28 +0000 Subject: [PATCH 51/57] fix(hsem): do not enable the HSEM interrupt while waiting for a lock WaitLock enabled C1IER for the semaphore it spins on, so the other core's release raised an HSEM interrupt that nothing clears for that semaphore (FlashCoordinatedWithWirelessStack owns the HSEM IRQ) and the CPU looped in it. WaitLock now only polls, and Release uses HAL_HSEM_Release. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- .../SynchronousHardwareSemaphoreStm.cpp | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/hal_st/synchronous_stm32fxxx/SynchronousHardwareSemaphoreStm.cpp b/hal_st/synchronous_stm32fxxx/SynchronousHardwareSemaphoreStm.cpp index 4a4fccd0..d8d71d41 100644 --- a/hal_st/synchronous_stm32fxxx/SynchronousHardwareSemaphoreStm.cpp +++ b/hal_st/synchronous_stm32fxxx/SynchronousHardwareSemaphoreStm.cpp @@ -16,8 +16,6 @@ namespace hal void SynchronousHardwareSemaphoreMasterStm::WaitLock(hal::Semaphore semaphore) const { - HSEM->C1IER |= 1 << static_cast(semaphore); - while (HAL_HSEM_FastTake(static_cast(semaphore)) != HAL_OK) { } @@ -25,10 +23,7 @@ namespace hal void SynchronousHardwareSemaphoreMasterStm::Release(hal::Semaphore semaphore) const { - uint32_t mask = 1 << static_cast(semaphore); - HSEM->C1ICR = mask; - HSEM->R[static_cast(semaphore)] = HSEM_CR_COREID_CURRENT; - HSEM->C1IER &= ~mask; + HAL_HSEM_Release(static_cast(semaphore), 0); } bool SynchronousHardwareSemaphoreMasterStm::IsLockedByCurrentCore(hal::Semaphore semaphore) const From b741c5e7474c80468e0f868cf4235ddd21bdce34 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:21:28 +0000 Subject: [PATCH 52/57] fix(timer): set the counter direction after clearing CMS and SMS CR1.DIR ignores writes while the counter is centre aligned or in encoder mode, which an earlier user (an encoder or a centre-aligned PWM) may leave, so TimerBaseStm could count the wrong way. It now initialises up-counting first, selects the internal clock, then initialises again with the requested mode. Start also clears a pending update flag left by the initialisation's update event, which fired a spurious first callback. Affects every family; the result on a timer in reset state is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/TimerStm.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/hal_st/stm32fxxx/TimerStm.cpp b/hal_st/stm32fxxx/TimerStm.cpp index 55632abc..892d3814 100644 --- a/hal_st/stm32fxxx/TimerStm.cpp +++ b/hal_st/stm32fxxx/TimerStm.cpp @@ -166,7 +166,7 @@ namespace hal handle.Instance = peripheralTimer[timerIndex]; handle.Init.Prescaler = timing.prescaler; handle.Init.Period = timing.period; - handle.Init.CounterMode = infra::enum_cast(config.counterMode); + handle.Init.CounterMode = TIM_COUNTERMODE_UP; handle.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; handle.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; HAL_TIM_Base_Init(&handle); @@ -174,6 +174,12 @@ namespace hal clockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; HAL_TIM_ConfigClockSource(&handle, &clockSourceConfig); + // CR1.DIR ignores writes while centre-aligned or in encoder mode, either of which an + // earlier user may have left, so the direction is only written once the two calls + // above have cleared CMS and SMS. + handle.Init.CounterMode = infra::enum_cast(config.counterMode); + HAL_TIM_Base_Init(&handle); + if (config.trigger) ConfigureTrigger(); } @@ -227,6 +233,9 @@ namespace hal { this->onIrq = onIrq; this->type = type; + // Some HAL versions leave UIF set from the UG of HAL_TIM_Base_Init, which would fire + // a callback as soon as the interrupt is enabled. + __HAL_TIM_CLEAR_FLAG(&handle, TIM_FLAG_UPDATE); auto result = HAL_TIM_Base_Start_IT(&handle); assert(result == HAL_OK); } From 483ea3a18ee69d002a1b206b10a925d0fcc2300a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:21:29 +0000 Subject: [PATCH 53/57] fix(pwm): reset the break and dead-time register in TimerPwmStm Neither the HAL de-init nor gating the clock resets BDTR, so a break input left enabled by an earlier user (PwmStm) kept MOE cleared and the outputs off. TimerPwmBaseStm now writes a cleared BDTR on timers with a break function; on a timer in reset state this changes nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- hal_st/stm32fxxx/TimerPwmStm.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/hal_st/stm32fxxx/TimerPwmStm.cpp b/hal_st/stm32fxxx/TimerPwmStm.cpp index fe170d76..4fbf64aa 100644 --- a/hal_st/stm32fxxx/TimerPwmStm.cpp +++ b/hal_st/stm32fxxx/TimerPwmStm.cpp @@ -65,7 +65,16 @@ namespace hal { TimerPwmBaseStm::TimerPwmBaseStm(uint8_t oneBasedIndex, TimerBaseStm::Timing timing) : timer(oneBasedIndex, timing, { TimerBaseStm::CounterMode::up }) - {} + { + // Neither the HAL de-inits nor gating the clock reset BDTR, so a break input left + // enabled by an earlier user would keep MOE cleared. + if (IS_TIM_BREAK_INSTANCE(timer.Handle().Instance)) + { + TIM_BreakDeadTimeConfigTypeDef breakDeadTime{}; + auto result = HAL_TIMEx_ConfigBreakDeadTime(&timer.Handle(), &breakDeadTime); + really_assert(result == HAL_OK); + } + } PwmChannelGpio& TimerPwmBaseStm::Channel(uint8_t channelOneBasedIndex) { From 037af253c5e7cd21876b9c6f1275fdcc23a1331d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:21:38 +0000 Subject: [PATCH 54/57] fix(validation): address the review of the new command groups and tests Firmware: - i2cs: the target no longer keeps slave byte control on for reads, where the NBYTES left by HAL init or the last write capped the bytes sent. - spis.arm and qspi.cmd/xfer check busy before touching the armed or shared DMA buffer; qspi.close resets a QUADSPI left busy by a timed-out command so close/reopen recovers. - tim/lptim refuse update-interrupt rates that would starve the event loop. - rng variant=hsem claims HSEM 0 in ResourceAllocation (busy against the coordinated flash driver and hsem.lock), answers busy when this core already holds semaphore 0 and failed when semaphore 5 is held with HSI48 off; sync/async reads answer failed while the RNG kernel clock is stopped, and clock.hsi48 0 answers busy while an RNG driver exists. - lpm.enter reports how many times the core slept. Tests: - test_i2c.py captures the whole repeated-start transaction and measures the target's clock stretch instead of the host round trip. - test_spi_ext.py no longer claims to prove the 16->8 bit DMA width reset it cannot reach; test_spi_slave.py checks a rejected arm keeps the armed payload; test_qspi.py checks close/reopen after a timed-out read. - test_timer*.py check the counting direction after the encoder and a break left by pwm; test_rng.py, test_sgpio.py (open drain releases the line), test_uart_sendonly.py (RTS only) and test_low_power.py (sleeps counted) now detect the defects they target. Fakes and PROTOCOL.md follow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- validation/PROTOCOL.md | 22 +++-- validation/README.md | 2 +- validation/firmware/ClockGroup.cpp | 4 + validation/firmware/I2cTarget.cpp | 2 + validation/firmware/LowPowerGroup.cpp | 6 +- validation/firmware/LowPowerGroup.hpp | 1 + validation/firmware/LpTimerGroup.cpp | 4 + validation/firmware/Main.cpp | 2 +- validation/firmware/QuadSpiGroup.cpp | 22 +++-- validation/firmware/RngGroup.cpp | 65 +++++++++++---- validation/firmware/RngGroup.hpp | 7 +- validation/firmware/SpiSlaveGroup.cpp | 7 +- validation/firmware/TimerGroup.cpp | 3 + validation/firmware/TimerGroup.hpp | 3 + .../host/hal_st_validation/fakes/crypto.py | 34 +++++++- .../host/hal_st_validation/fakes/qspi.py | 8 +- .../host/hal_st_validation/fakes/spis.py | 4 +- .../hal_st_validation/fakes/system_ext.py | 4 +- .../host/hal_st_validation/fakes/timers.py | 14 +++- .../hal_st_validation/groups/system_ext.py | 9 ++- .../host/hal_st_validation/groups/timers.py | 4 + validation/host/tests/hil/test_i2c.py | 28 +++++-- validation/host/tests/hil/test_low_power.py | 7 +- validation/host/tests/hil/test_lptim.py | 4 + validation/host/tests/hil/test_qspi.py | 19 +++++ validation/host/tests/hil/test_rng.py | 80 ++++++++++++++++++- validation/host/tests/hil/test_sgpio.py | 16 ++-- validation/host/tests/hil/test_spi_ext.py | 10 ++- validation/host/tests/hil/test_spi_slave.py | 24 ++++++ validation/host/tests/hil/test_timer.py | 40 +++++++++- validation/host/tests/hil/test_timer_pwm.py | 24 ++++++ .../host/tests/hil/test_uart_sendonly.py | 12 +-- .../host/tests/unit/test_fake_crypto.py | 39 +++++++++ validation/host/tests/unit/test_fake_qspi.py | 18 +++++ validation/host/tests/unit/test_fake_spis.py | 4 +- .../host/tests/unit/test_fake_system_ext.py | 2 +- .../host/tests/unit/test_fake_timers.py | 14 ++++ 37 files changed, 484 insertions(+), 84 deletions(-) diff --git a/validation/PROTOCOL.md b/validation/PROTOCOL.md index dfdd76ab..600ecc09 100644 --- a/validation/PROTOCOL.md +++ b/validation/PROTOCOL.md @@ -69,10 +69,10 @@ The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix The debug LED blinks while the firmware runs: WB55 PB5, the blue LD1; WBA55 PA9, the green LD2 (alias `led1`), which is not connected on a stock NUCLEO-WBA55CG (SB28 open), so it stays dark unless SB28 is closed. A pin held by another open instance returns `ERR busy`; a pin the hal-st pinout table does not offer for the requested function and instance returns `ERR pin`. - A timer serves one group at a time: a timer held by an open PWM, encoder, timer-triggered ADC, timer (`tim`) or timer PWM (`tpwm`), by a running `ain.burst` or `dma.wave` (both TIM2), or by the scaffold timer TIM17 while `lpm.enter` or `hsem.lock hold=` uses it, returns `ERR busy` to the other groups. -- One peripheral instance serves one group at a time: an I2C instance is shared by `i2c`, `i2cs` and `eeprom`, an SPI instance by `spi` and `spis`, the ADC by `adc` and `ain`, an LPTIM by `qei` (`lp=1`), `lptim` and `lptpwm`, and HSEM semaphore 0 by `flash ... variant=coord` and `hsem.lock` (WB55). +- One peripheral instance serves one group at a time: an I2C instance is shared by `i2c`, `i2cs` and `eeprom`, an SPI instance by `spi` and `spis`, the ADC by `adc` and `ain`, an LPTIM by `qei` (`lp=1`), `lptim` and `lptpwm`, and the HSEM interrupt (claimed as HSEM 0, the semaphore the RNG driver waits on) by `flash ... variant=coord`, `hsem.lock` and `rng ... variant=hsem` (WB55). So are the DMA channels some groups share: WB55 DMA1 channel 7 (`adc`, `ain.burst`); WBA55 GPDMA1 channel 7 (`adc`, `ain.burst`, `spis` receive) and channel 8 (`spis` transmit, `dma.wave`). An instance or channel another group holds returns `ERR busy`. - RAM limits how many instances are open at the same time: 1 PWM timer, 1 UART besides the terminal, 1 SPI master, 1 SPI slave, 1 I2C master, 1 I2C target, 1 attached EEPROM, 1 ADC, 1 encoder, 1 timer (`tim`), 1 timer PWM (`tpwm`), 1 LPTIM, 1 LPTIM PWM, 1 QUADSPI, 1 watchdog and 8 GPIO pins; one more returns `ERR busy`. -- Argument errors (`usage`, `range`, `pin`, `unsupported`) are reported before `ERR busy`. +- Argument errors (`usage`, `range`, `pin`, `unsupported`) are reported before `ERR busy`, except for the arguments a command parses into a buffer an operation in flight still uses: the payload of `flash.write`, the payload and `rx` of `spis.arm`, the data arguments of `qspi.cmd` and `qspi.xfer` (see their sections). - Line length: a command line holds at most 255 characters (`terminal.max_command_length` in the board files). Commands that move more data than fits take a payload the firmware generates and can answer with a CRC instead of the data: - in place of a hex payload, `-` with `len=` (1 up to the command's capacity, `ERR range` above) `[pattern=inc|const|prbs]` (default `inc`) `[seed=<0..0xFFFFFFFF>]` (default 0); `len` with a hex payload, or `pattern`/`seed` without `len`, returns `ERR usage` - `inc`: byte i is (seed + i) & 0xFF; `const`: every byte is seed & 0xFF; `prbs`: per byte, a 32-bit xorshift `x ^= x << 13; x ^= x >> 17; x ^= x << 5` then `x & 0xFF`, starting from `seed` (1 when `seed` is 0). `inc` with seed 0xFE gives `fe ff 00 01`, `prbs` with seed 1 gives `21 01 c5 4f d1 d0 1a b2` @@ -93,7 +93,7 @@ The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix - `pclk7` on STM32WBA55 only; `hsi48` and `clk48` on STM32WB55 only - `rngsel` is the RNG kernel clock selection: `clk48`, `lsi` or `lse` on STM32WB55, where `clk48` names the CLK48 source (`hsi48`, `pllsai1`, `pll` or `msi`); `lse`, `lsi`, `hsi` or `pll` (PLL1 Q) on STM32WBA55 - `clock.mco [div=1|2|4|8|16]` → `OK` (STM32WB55): MCO source and divider (`LL_RCC_ConfigMCO`, default `div=1`); `off` stops the output. Validation scaffolding: `sgpio.af PA8 af=0` puts MCO on the pin -- `clock.hsi48 <0|1>` → `OK` (STM32WB55): switches HSI48 off or on and waits for its ready flag (`ERR timeout` after 10 ms). Validation scaffolding for the RNG tests; switch it back on afterwards +- `clock.hsi48 <0|1>` → `OK` (STM32WB55): switches HSI48 off or on and waits for its ready flag (`ERR timeout` after 10 ms). Validation scaffolding for the RNG tests; switch it back on afterwards. `clock.hsi48 0` answers `ERR busy` while an `rng` driver holds the RNG clock (a `variant=async` read in flight, or one left by its `ERR timeout` until the next `rng` command): it would lose its kernel clock - `clock.mco` and `clock.hsi48` return `ERR unsupported` on STM32WBA55: its only MCO pin is the terminal RX (PA8), and it has no HSI48 ## GPIO (`hal::GpioPinStm`) @@ -183,7 +183,7 @@ The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix - `spis.arm [rx=] [len=] [pattern=inc|const|prbs] [seed=]` → `OK` once the transfer is handed to the driver (`SendAndReceive`) - full duplex: `rx` left out or equal to the transmit length (another value returns `ERR usage`); send only: `rx=0`; receive only: `-` with `rx=`; nothing to send or receive returns `ERR usage`. Lengths 1-1024 (`ERR range`) - `-` with `len=` sends a payload generated in firmware (see "Framing"); `pattern`/`seed` without `len`, or `len` with hex, return `ERR usage` - - a transfer already armed returns `ERR busy` + - a transfer already armed returns `ERR busy`, checked before the payload and `rx` (the armed DMA still reads the transmit buffer) - while nothing is armed the slave is disabled: frames a master clocks then are dropped. Frames beyond the armed length are dropped as well; the transfer completes with the armed length - receive only sends the DMA's dummy word on MISO (undefined content) - `spis.result [wait=] [out=hex|crc]` → `OK done=1 rx=` (or `OK done=1 len= crc=` with `out=crc`) once the transfer is done; `OK done=0` at once when nothing is armed, or after `wait` ms (0-10000, default 1000) with the transfer still armed @@ -251,6 +251,7 @@ Each command builds its drivers, holds the ADC (and for `ain.burst` TIM2 and the - defaults `prescaler=0 period=999 irq=dispatched mode=up`; `period` is the auto-reload, 1-65535 (1-4294967295 on TIM2); `timclk` is the timer kernel clock - one update per `period + 1` ticks of `timclk / (prescaler + 1)` - `irq=none` builds a `FreeRunningTimerStm` without interrupt; `immediate` and `dispatched` build a `TimerWithInterruptStm` whose update callback runs in the interrupt or from the event loop. Dispatched callbacks coalesce while one is queued: above about 1 kHz `irqs` counts fewer than the updates + - with `irq=immediate|dispatched` an update rate `timclk / ((prescaler + 1) (period + 1))` (integer division) above 100 kHz returns `ERR range`: the interrupt runs on every update in both modes and would starve the event loop - `pin` (any free bonded pin) is driven low and toggled by every update callback, so it runs at half the update rate; `pin` with `irq=none` returns `ERR usage` - `mode=down` counts down from `period`; it needs `irq=none` and a timer with a counter mode select (TIM1, TIM2, TIM3), `ERR unsupported` otherwise - `tim.start `, `tim.stop ` → `OK`; starting a running or stopping a stopped timer changes nothing @@ -274,7 +275,7 @@ Each command builds its drivers, holds the ADC (and for `ain.burst` TIM2 and the - LPTIM 1-2; defaults `period=999 prescaler=1 irq=dispatched rep=0`; `prescaler` is the clock divider, another number returns `ERR range` - one update per `period + 1` ticks of `lptimclk / prescaler`, and with `rep` once every `rep + 1` periods; `lptimclk` is the LPTIM kernel clock (PCLK1 on STM32WB55; PCLK7 for LPTIM1 and PCLK1 for LPTIM2 on STM32WBA55) - `rep` is the repetition counter of the STM32WBA LPTIM (`ERR unsupported` on STM32WB55, whose LPTIM has none) - - `irq` and `pin` as for `tim.open` + - `irq` and `pin` as for `tim.open`; with `irq=immediate|dispatched` an update rate `lptimclk / (prescaler (period + 1))` (integer division, `rep` left out: the autoreload-match interrupt runs every period) above 100 kHz returns `ERR range` - `lptim.start `, `lptim.stop `, `lptim.count ` → `OK cnt= irqs=`, `lptim.close ` as for `tim` ## LPTIM PWM (`hal::LpTimerPwmWithChannels`, NUCLEO-WBA55CG only) @@ -371,8 +372,10 @@ Not a hal-st driver (hal-st has no I2C slave): the other end of the bus for the - `variant=async` answers from the driver's completion (RNG interrupt), `ERR timeout` after 1 s - `variant=hsem` exists on STM32WB55 only (`ERR unsupported` on STM32WBA55): the driver holds HSEM semaphore 0 during the read, through the firmware's one `SynchronousHardwareSemaphoreMasterStm` (shared with `hsem` and `flash`) - unless this core holds semaphore 5 (clock configuration), the driver's `Hsi48Enabler` starts HSI48, the RNG kernel clock, for the read when it is off and stops it again afterwards + - `variant=hsem` answers `ERR busy` while this core holds semaphore 0 (`hsem.take 0`: nothing could free it while the driver blocks the event loop) or the coordinated flash driver exists (HSEM 0 of the sharing rule), and `ERR failed` while this core holds semaphore 5 (`lock5=1` or `hsem.take 5`, any process) and HSI48 is off + - on STM32WB55 `variant=sync` and `variant=async` answer `ERR failed` while HSI48, their kernel clock (CLK48), is off: only `Hsi48Enabler` starts it, and the plain drivers would abort on the clock error - `lock5=1` (validation scaffolding, STM32WB55 only: `ERR unsupported` on STM32WBA55; only with `variant=hsem`: `ERR usage` otherwise) holds semaphore 5 (`HAL_HSEM_FastTake`, process 0) around the read, so `Hsi48Enabler` takes its locked branch; `ERR busy` when semaphore 5 is taken, `ERR failed` when HSI48 is off (the driver asserts that HSI48 runs while semaphore 5 is locked) -- `rng.stats [variant=sync|async|hsem]` → `OK n= ones= runs= chisq= crc= us=`: `len` bytes, 16-65536, generated by one driver in chunks of 256 bytes; `variant=async` answers `ERR timeout` after 5 s +- `rng.stats [variant=sync|async|hsem]` → `OK n= ones= runs= chisq= crc= us=`: `len` bytes, 16-65536, generated by one driver in chunks of 256 bytes; `variant=async` answers `ERR timeout` after 5 s; `variant` follows the rules of `rng.read` - the bytes form one bit stream, each byte most significant bit first: `ones` counts its set bits, `runs` its bit transitions plus one - `chisq` is the chi-square of the byte histogram against the uniform distribution (255 degrees of freedom) times 1000, rounded down and at most 4294967295; `crc` is the CRC-32 of the bytes (8 lower-case hex digits, as `out=crc`); `us` is the generation time - After an `ERR timeout` of `variant=async` the next `rng` command drops that driver and runs; until the timeout another `rng` command returns `ERR busy` @@ -432,6 +435,7 @@ Not a hal-st driver (hal-st has no I2C slave): the other end of the bus for the - an operation stays in flight until it completes, also after its `ERR timeout`: a coordinated step waiting for HSEM 7 runs once the semaphore is released (`hsem.release 7 procid=

`), one held by `flash.stack starting` after `stopped` or `fus` - while `flash.stack starting` holds the driver, only `variant=coord` commands of its layout run (and the exceptions above) - the coordinated driver and `hsem.lock` exclude each other (`ERR busy`): both rely on the HSEM interrupt + - so does `rng ... variant=hsem`, whose wait on semaphore 0 enables its HSEM interrupt as well: it answers `ERR busy` while the coordinated driver exists ## Hardware semaphore (STM32WB55: `HAL_HSEM_*`, `hal::SynchronousHardwareSemaphoreMasterStm`, `hal::SynchronousHardwareSemaphoreStm`) @@ -456,11 +460,12 @@ Not a hal-st driver (hal-st has no I2C slave): the other end of the bus for the ## Low power (`hal::LowPowerModeStm`) -- `lpm.enter [wake=] [edge=rising|falling] [marker=] [timeout=<1..10000 ms>]` → `OK woke=exti restored= us=` +- `lpm.enter [wake=] [edge=rising|falling] [marker=] [timeout=<1..10000 ms>]` → `OK woke=exti restored= us= sleeps=` - defaults: `wake` STM32WB55 PC6 (`gpio0`), STM32WBA55 PB14 (`gpio0`); `marker` STM32WB55 PB0 (`led0`), STM32WBA55 PA2 (`tim1bkin`); `edge=rising`; `timeout=2000` - the wake pin is an input with the pull against its edge (pull-down for `rising`) and an EXTI interrupt; the marker is an output, high, driven low while the core sleeps and high again right after it wakes - inside the window the interrupts are masked (PRIMASK), every NVIC interrupt but the wake line's EXTI and TIM17's is disabled and the SysTick tick interrupt is off: WFI returns only on the wake edge or a TIM17 update - the scaffold timer TIM17 counts 1 us ticks (CYCCNT stops while the core sleeps) and gives `us`, the time asleep; at `timeout` ms it ends the window with `ERR timeout` + - `sleeps` counts the `LowPowerModeStm::Enter` calls: WFI returns only on a TIM17 update (every millisecond) or the wake edge, so a core that sleeps makes about one call per elapsed millisecond plus one, a driver that returns without sleeping hundreds per millisecond - `deep` maps to Sleep on STM32WB/WBA (`LowPowerModeStm::Stop`), so it never calls the clock-restore callback: `restored` counts its calls and is 0 - the wake and marker pins must differ (`ERR usage`); a pin the package lacks returns `ERR pin`, a wake pin whose EXTI line serves a pin of another port `ERR unsupported`, a held pin or TIM17 held by another group `ERR busy` - the terminal and every other interrupt (including the watchdog's early warning: a running watchdog is not fed) are blocked for the window: send nothing until the final line @@ -487,8 +492,9 @@ Not a hal-st driver (hal-st has no I2C slave): the other end of the bus for the - `variant=spi` only (`ERR unsupported` otherwise): `SingleSpeedQuadSpiStmDma::SendAndReceive`, SPI mode 0 with MOSI on IO0, MISO on IO1 and NCS as chip select - half duplex: exactly one of the data (`txhex` or `len`) and `rx`, else `ERR usage`; `repeat` as for `qspi.cmd` - a read has a data phase only, so the erratum above applies to it -- `qspi.close <1>` → `OK` +- `qspi.close <1>` → `OK`: also resets the QUADSPI (RCC), so one left BUSY by a timed-out command (the erratum above) is idle at the next `qspi.open` - `qspi.cmd`, `qspi.poll` and `qspi.xfer` answer within 2000 ms or `ERR timeout`; a command that timed out keeps the group busy until `qspi.close` +- while a command is in flight, `qspi.cmd` returns `ERR busy` before checking its data arguments (`tx`, `len`, `pattern`, `seed`, `rx`, `out`, `repeat`) and `qspi.xfer` before its payload, `rx` and `repeat` (the command in flight uses the data buffer they fill); `qspi.xfer` reports `ERR unsupported` before that ## Not available on these boards diff --git a/validation/README.md b/validation/README.md index ee85a1a1..0fac26cd 100644 --- a/validation/README.md +++ b/validation/README.md @@ -469,7 +469,7 @@ SPI1 to SPI3; SPI3 is observed through DIO14, DIO10, DIO11 and DIO15. The PB8 en each instance, argument errors and default pins. - `test_uart_sendonly.py` - for `SynchronousUartStmSendOnly` (NUCLEO-WB55RG LPUART1 through the `SyncLpUart` constructors, NUCLEO-WBA55CG USART2): frames against the AD3 UART at several baud rates, no received data, `flow=rts` driving RTS while CTS is ignored, the last frame that a `uart.close` right after `uart.send` may cut, optional `rx`, default pins and argument errors. - `test_spi.py` - SPI modes 0-3 x baud x driver variant (interrupt, DMA, synchronous) x GPIO chip select, decoded from the logic analyzer (MOSI, MISO, clock polarity and rate, chip-select release), continued sessions, receive-only first transfers, the largest transfer, the baud limits and argument errors, on SPI1 and on SPI2 (NUCLEO-WB55RG, `--with spiloop`) or SPI3 (NUCLEO-WBA55CG, `bundle2`). -- `test_spi_ext.py` - the SPI master options: the bit order (`lsb=1`) on every driver, frame sizes of 4 to 16 bits (`bits=`, `SpiDataSizeConfiguratorStm` on the DMA driver) decoded word by word, the 8/16-bit limit of the WBA55 SPI3, an 8-bit transfer after a 16-bit one (DMA data width), the `nss`/`cs` checks, and the hardware slave select (a known gap). +- `test_spi_ext.py` - the SPI master options: the bit order (`lsb=1`) on every driver, frame sizes of 4 to 16 bits (`bits=`, `SpiDataSizeConfiguratorStm` on the DMA driver) decoded word by word, the 8/16-bit limit of the WBA55 SPI3, an 8-bit master reopened after a 16-bit one, the `nss`/`cs` checks, and the hardware slave select (a known gap). - `test_spi_slave.py` - `SpiSlaveStmDma` clocked by the AD3 SPI master: full duplex, send-only and receive-only transfers of 1-1024 bytes at several clock rates (CRC beyond 128 bytes), clocks while nothing is armed, a master clocking more or less than armed, `spis.result` waiting and busy, cancel and re-open, argument errors and the instance shared with `spi`; with `--with spiloop` the board's own SPI master (every driver) against the slave on the other instance, both ways. - `test_adc.py` - wavegen DC levels against raw 12-bit codes (checked against the scope when it is wired), sequences of up to 8 conversions, every sampling time, timer-triggered rates and the measure timeout, timer sharing with PWM and the encoder, unsupported trigger timers, argument errors and limits, and ADC pins against GPIO. diff --git a/validation/firmware/ClockGroup.cpp b/validation/firmware/ClockGroup.cpp index a179e48e..17414d90 100644 --- a/validation/firmware/ClockGroup.cpp +++ b/validation/firmware/ClockGroup.cpp @@ -132,6 +132,10 @@ namespace validation if (status != HilStatus::done) return status; + // A running rng driver would lose its kernel clock (CLK48 from HSI48) and abort on the clock error + if (on == 0 && __HAL_RCC_RNG_IS_CLK_ENABLED() != 0) + return HilStatus::busy; + if (on != 0) LL_RCC_HSI48_Enable(); else diff --git a/validation/firmware/I2cTarget.cpp b/validation/firmware/I2cTarget.cpp index 64b7300f..e71b0e62 100644 --- a/validation/firmware/I2cTarget.cpp +++ b/validation/firmware/I2cTarget.cpp @@ -192,6 +192,7 @@ namespace validation ++writes; lastSize = 0; LL_I2C_DisableIT_TX(peripheral); + LL_I2C_EnableSlaveByteControl(peripheral); LL_I2C_EnableReloadMode(peripheral); LL_I2C_SetTransferSize(peripheral, 1); } @@ -200,6 +201,7 @@ namespace validation ++reads; patternState = settings.seed; LL_I2C_DisableReloadMode(peripheral); + LL_I2C_DisableSlaveByteControl(peripheral); LL_I2C_ClearFlag_TXE(peripheral); LL_I2C_EnableIT_TX(peripheral); } diff --git a/validation/firmware/LowPowerGroup.cpp b/validation/firmware/LowPowerGroup.cpp index 0dee1d14..9e16822c 100644 --- a/validation/firmware/LowPowerGroup.cpp +++ b/validation/firmware/LowPowerGroup.cpp @@ -103,7 +103,7 @@ namespace validation if (!outcome.woke) return HilStatus::timeout; - context.response.Ok() << " woke=exti restored=" << restored.load() << " us=" << outcome.us; + context.response.Ok() << " woke=exti restored=" << restored.load() << " us=" << outcome.us << " sleeps=" << outcome.sleeps; return HilStatus::done; } @@ -171,6 +171,7 @@ namespace validation const uint32_t wakeMask = 1u << request.wake.index; const auto wakeIrq = ExtiIrq(request.wake.index); uint32_t updates = 0; + uint32_t sleeps = 0; __HAL_TIM_CLEAR_FLAG(&handle, TIM_FLAG_UPDATE); timer.Start([]() {}, hal::InterruptType::immediate); @@ -205,6 +206,7 @@ namespace validation while (!woke && !ExtiPending(wakeMask) && updates != request.timeoutMs) { lowPower.Enter(request.mode); + ++sleeps; if (__HAL_TIM_GET_FLAG(&handle, TIM_FLAG_UPDATE) != 0) { @@ -218,7 +220,7 @@ namespace validation const uint32_t counter = __HAL_TIM_GET_COUNTER(&handle); const bool wrapped = __HAL_TIM_GET_FLAG(&handle, TIM_FLAG_UPDATE) != 0 && counter < ticksPerUpdate / 2; - Outcome outcome{ woke || ExtiPending(wakeMask), (updates + (wrapped ? 1 : 0)) * ticksPerUpdate + counter }; + Outcome outcome{ woke || ExtiPending(wakeMask), (updates + (wrapped ? 1 : 0)) * ticksPerUpdate + counter, sleeps }; timer.Stop(); __HAL_TIM_CLEAR_FLAG(&handle, TIM_FLAG_UPDATE); diff --git a/validation/firmware/LowPowerGroup.hpp b/validation/firmware/LowPowerGroup.hpp index d0529543..da9d05f9 100644 --- a/validation/firmware/LowPowerGroup.hpp +++ b/validation/firmware/LowPowerGroup.hpp @@ -33,6 +33,7 @@ namespace validation { bool woke = false; uint32_t us = 0; + uint32_t sleeps = 0; }; services::HilStatus Enter(const services::HilArguments& arguments); diff --git a/validation/firmware/LpTimerGroup.cpp b/validation/firmware/LpTimerGroup.cpp index f5c881e3..942c6872 100644 --- a/validation/firmware/LpTimerGroup.cpp +++ b/validation/firmware/LpTimerGroup.cpp @@ -176,6 +176,10 @@ namespace validation return HilStatus::unsupported; #endif + // ARRM interrupts on every period, whatever the repetition counter + if (request.irq != TimerIrq::none && LpTimerClock(index) / (request.prescaler * (request.period + 1)) > maximumUpdateInterruptRate) + return HilStatus::range; + return HilStatus::done; } diff --git a/validation/firmware/Main.cpp b/validation/firmware/Main.cpp index d2e3761c..494d6470 100644 --- a/validation/firmware/Main.cpp +++ b/validation/firmware/Main.cpp @@ -119,7 +119,7 @@ int main() static validation::SyncGpioCommands syncGpio{ context }; static validation::ClockCommands clocks{ context }; - static validation::RngCommands rng{ context }; + static validation::RngCommands rng{ context, resources }; static validation::AesCommands aes{ context }; static validation::PkaCommands pka{ context }; diff --git a/validation/firmware/QuadSpiGroup.cpp b/validation/firmware/QuadSpiGroup.cpp index f0d8c2b2..40863952 100644 --- a/validation/firmware/QuadSpiGroup.cpp +++ b/validation/firmware/QuadSpiGroup.cpp @@ -246,6 +246,10 @@ namespace validation if (stream) LL_DMA_DisableChannel(DMA2, board::qspiDma.channel); + // A data-only indirect read hangs the QUADSPI with BUSY set (erratum): only an abort or a reset clears it + __HAL_RCC_QUADSPI_FORCE_RESET(); + __HAL_RCC_QUADSPI_RELEASE_RESET(); + driver.emplace(); stream.reset(); resources.Release(Resource::dma2, board::qspiDma.channel, owners::quadSpi); @@ -300,14 +304,17 @@ namespace validation Request next; status = ParsePhases(arguments, next.phases); - if (status == HilStatus::done) - status = ParseCommandData(arguments, next); if (status != HilStatus::done) return status; + // The command in flight still reads or writes buffer, which the data arguments fill if (pending.Busy()) return HilStatus::busy; + status = ParseCommandData(arguments, next); + if (status != HilStatus::done) + return status; + Start(next); return HilStatus::done; } @@ -363,17 +370,18 @@ namespace validation if (!TransferShape(arguments)) return HilStatus::usage; - Request next; - status = ParseTransferData(arguments, next); - if (status != HilStatus::done) - return status; - if (factory.Spi() == nullptr) return HilStatus::unsupported; + // The command in flight still reads or writes buffer, which the data arguments fill if (pending.Busy()) return HilStatus::busy; + Request next; + status = ParseTransferData(arguments, next); + if (status != HilStatus::done) + return status; + Start(next); return HilStatus::done; } diff --git a/validation/firmware/RngGroup.cpp b/validation/firmware/RngGroup.cpp index a3065f84..cbb5f315 100644 --- a/validation/firmware/RngGroup.cpp +++ b/validation/firmware/RngGroup.cpp @@ -12,6 +12,7 @@ #include "hal_st/synchronous_stm32fxxx/SynchronousSynchronizedRandomDataGeneratorStm.hpp" #include "stm32wbxx_ll_rng.h" #include "validation/firmware/HsemMaster.hpp" +#include "validation/firmware/Owners.hpp" #endif namespace validation @@ -35,11 +36,19 @@ namespace validation constexpr uint32_t maximumStats = 65536; constexpr std::chrono::milliseconds readTimeout{ 1000 }; constexpr std::chrono::milliseconds statsTimeout{ 5000 }; + +#if defined(STM32WB) + bool RngKernelClockStopped() + { + return __HAL_RCC_GET_RNG_SOURCE() == RCC_RNGCLKSOURCE_CLK48 && LL_RCC_GetCLK48ClockSource(LL_RCC_CLK48_CLKSOURCE) == LL_RCC_CLK48_CLKSOURCE_HSI48 && LL_RCC_HSI48_IsReady() == 0; + } +#endif } - RngCommands::RngCommands(services::HilContext& context) + RngCommands::RngCommands(services::HilContext& context, ResourceAllocation& resources) : services::TerminalCommands(context.terminal) , context(context) + , resources(resources) , pending(context.response) , commands{ { services::HilBind("rng.read", " [variant=sync|async|hsem] [lock5=0|1]", *this, context.response), @@ -94,6 +103,12 @@ namespace validation if (candidate.lockClock && candidate.variant != Variant::hsem) return HilStatus::usage; +#if defined(STM32WB) + // Only Hsi48Enabler (variant=hsem) starts HSI48; the plain drivers would abort on the clock error or wait forever for data + if (candidate.variant != Variant::hsem && RngKernelClockStopped()) + return HilStatus::failed; +#endif + return HilStatus::done; } @@ -137,21 +152,14 @@ namespace validation #if defined(STM32WB) if (request.variant == Variant::hsem) { - constexpr auto clockSemaphore = static_cast(hal::Semaphore::recoveryAndIndependentClockConfiguration); - auto& master = HsemMaster(); - - // With semaphore 5 locked, Hsi48Enabler asserts that HSI48 runs instead of starting it - if (request.lockClock && LL_RCC_HSI48_IsReady() == 0) - return HilStatus::failed; - - if (request.lockClock && HAL_HSEM_FastTake(clockSemaphore) != HAL_OK) - return HilStatus::busy; + auto status = resources.Claim(Resource::hsem, 0, owners::scaffold); + if (status != HilStatus::done) + return status; - hal::SynchronousSynchronizedRandomDataGeneratorStm generator{ creator, master }; - Generate(generator); - - if (request.lockClock) - HAL_HSEM_Release(clockSemaphore, 0); + status = GenerateSynchronized(); + resources.Release(Resource::hsem, 0, owners::scaffold); + if (status != HilStatus::done) + return status; Finish(); return HilStatus::done; @@ -164,6 +172,33 @@ namespace validation return HilStatus::done; } +#if defined(STM32WB) + HilStatus RngCommands::GenerateSynchronized() + { + constexpr auto clockSemaphore = hal::Semaphore::recoveryAndIndependentClockConfiguration; + auto& master = HsemMaster(); + + // The driver spins on semaphore 0 with the event loop blocked: one this core already holds would never be freed + if (master.IsLockedByCurrentCore(hal::Semaphore::randomNumberGenerator)) + return HilStatus::busy; + + // With semaphore 5 locked by this core (any process), Hsi48Enabler asserts that HSI48 runs instead of starting it + if ((request.lockClock || master.IsLockedByCurrentCore(clockSemaphore)) && LL_RCC_HSI48_IsReady() == 0) + return HilStatus::failed; + + if (request.lockClock && HAL_HSEM_FastTake(static_cast(clockSemaphore)) != HAL_OK) + return HilStatus::busy; + + hal::SynchronousSynchronizedRandomDataGeneratorStm generator{ creator, master }; + Generate(generator); + + if (request.lockClock) + HAL_HSEM_Release(static_cast(clockSemaphore), 0); + + return HilStatus::done; + } +#endif + void RngCommands::Generate(hal::SynchronousRandomDataGenerator& generator) { stopwatch.Start(); diff --git a/validation/firmware/RngGroup.hpp b/validation/firmware/RngGroup.hpp index 87613e64..5c91d45f 100644 --- a/validation/firmware/RngGroup.hpp +++ b/validation/firmware/RngGroup.hpp @@ -7,6 +7,7 @@ #include "infra/util/ProxyCreator.hpp" #include "services/hil/HilCommand.hpp" #include "services/hil/commands/HilPendingOperation.hpp" +#include "validation/firmware/ResourceAllocation.hpp" #include "validation/firmware/Stopwatch.hpp" #include #include @@ -25,7 +26,7 @@ namespace validation hsem, }; - explicit RngCommands(services::HilContext& context); + RngCommands(services::HilContext& context, ResourceAllocation& resources); infra::MemoryRange Commands() override; @@ -64,6 +65,9 @@ namespace validation services::HilStatus Reserve(); services::HilStatus Run(const Request& request); services::HilStatus RunSynchronous(); +#if defined(STM32WB) + services::HilStatus GenerateSynchronized(); +#endif void Generate(hal::SynchronousRandomDataGenerator& generator); void StartAsynchronous(); void RequestChunk(); @@ -74,6 +78,7 @@ namespace validation private: services::HilContext& context; + ResourceAllocation& resources; services::HilPendingOperation pending; std::optional asyncGenerator; #if defined(STM32WB) diff --git a/validation/firmware/SpiSlaveGroup.cpp b/validation/firmware/SpiSlaveGroup.cpp index b859a6ee..5e151a2f 100644 --- a/validation/firmware/SpiSlaveGroup.cpp +++ b/validation/firmware/SpiSlaveGroup.cpp @@ -204,6 +204,10 @@ namespace validation if (status != HilStatus::done) return status; + // The armed transfer's transmit DMA still reads transmitBuffer + if (armed) + return HilStatus::busy; + infra::ByteRange send; status = ParsePayload(arguments, 1, infra::MakeRange(transmitBuffer), send); auto receive = static_cast(send.size()); @@ -215,9 +219,6 @@ namespace validation if ((send.empty() && receive == 0) || (!send.empty() && receive != 0 && receive != send.size())) return HilStatus::usage; - if (armed) - return HilStatus::busy; - armed = true; done = false; receiveSize = receive; diff --git a/validation/firmware/TimerGroup.cpp b/validation/firmware/TimerGroup.cpp index 01d4123a..482b8bb3 100644 --- a/validation/firmware/TimerGroup.cpp +++ b/validation/firmware/TimerGroup.cpp @@ -272,6 +272,9 @@ namespace validation if (request.mode == CounterMode::down && (request.irq != TimerIrq::none || !IS_TIM_COUNTER_MODE_SELECT_INSTANCE(Instance(timer)))) return HilStatus::unsupported; + if (request.irq != TimerIrq::none && TimerClock(timer) / ((uint64_t{ request.prescaler } + 1) * (uint64_t{ request.period } + 1)) > maximumUpdateInterruptRate) + return HilStatus::range; + return HilStatus::done; } diff --git a/validation/firmware/TimerGroup.hpp b/validation/firmware/TimerGroup.hpp index 620cbdad..110f754b 100644 --- a/validation/firmware/TimerGroup.hpp +++ b/validation/firmware/TimerGroup.hpp @@ -27,6 +27,9 @@ namespace validation { "dispatched", TimerIrq::dispatched }, } }; + // Above this the update interrupt, which runs on every update in both irq modes, starves the event loop. + inline constexpr uint32_t maximumUpdateInterruptRate = 100000; + class TimerMarker { public: diff --git a/validation/host/hal_st_validation/fakes/crypto.py b/validation/host/hal_st_validation/fakes/crypto.py index 9eecb509..9e82ceb2 100644 --- a/validation/host/hal_st_validation/fakes/crypto.py +++ b/validation/host/hal_st_validation/fakes/crypto.py @@ -3,8 +3,10 @@ differ), and AES/PKA results from `crypto_ref`. `variant=hsem` exists on STM32WB55 only; its `hsi48=` is the fake clock group's HSI48 state, which the driver leaves -as it found it (it switches HSI48 on for the read only when it was off). `lock5=1` with HSI48 off answers -`ERR failed`, as the firmware refuses what would trip the driver's assertion.""" +as it found it (it switches HSI48 on for the read only when it was off). Like the firmware it answers `ERR busy` while +the coordinated flash driver holds HSEM 0 or `hsem.take` holds semaphore 0, and `ERR failed` with semaphore 5 locked +(`lock5=1` or `hsem.take 5`) and HSI48 off, which would trip the driver's assertion. On STM32WB55 the other variants +answer `ERR failed` while HSI48, their kernel clock, is off.""" from __future__ import annotations @@ -13,6 +15,7 @@ from .. import crypto_ref, patterns, rngstats from ..groups.crypto import VARIANTS from .base import FakeGroup, _choice, _fail, _flag, _number, _shape +from .system_ext import FakeHsem if TYPE_CHECKING: from ..fake_firmware import FakeFirmware @@ -28,6 +31,10 @@ # A P-256 scalar multiplication on the PKA takes some ten milliseconds; the fake reports a fixed duration. _MULTIPLY_US = 20000 _HEX_DIGITS = frozenset("0123456789abcdefABCDEF") +# The firmware's `owners::scaffold`, which `hsem.lock` claims HSEM 0 with as well (fakes/system_ext.py). +_HSEM_OWNER = ("hsem", "lock") +_RNG_SEMAPHORE = 0 +_CLOCK_SEMAPHORE = 5 class FakeRng(FakeGroup): @@ -49,19 +56,36 @@ def _check(self, variant: str, lock5: bool) -> None: _fail("unsupported") if lock5 and variant != "hsem": _fail("usage") + if variant != "hsem" and self.fw.family == "stm32wb55" and not self._hsi48(): + _fail("failed") def _hsi48(self) -> int: clock = self.fw.group("clock") return int(getattr(clock, "hsi48", 1)) + def _synchronized(self, lock5: bool) -> None: + """`RunSynchronous` and `GenerateSynchronized` of RngGroup.cpp: HSEM 0 against the coordinated flash driver, + then the semaphores the fake `hsem` group holds (all of them on this core).""" + self.fw.check_resources(_HSEM_OWNER, [("hsem", 0)]) + hsem = self.fw.group("hsem") + held: set[int] = set() + if isinstance(hsem, FakeHsem): + held = {semaphore for semaphore in (_RNG_SEMAPHORE, _CLOCK_SEMAPHORE) if hsem.holder(semaphore) is not None} + if _RNG_SEMAPHORE in held: + _fail("busy") + if (lock5 or _CLOCK_SEMAPHORE in held) and not self._hsi48(): + _fail("failed") + if lock5 and _CLOCK_SEMAPHORE in held: + _fail("busy") + def cmd_read(self, args: list[str], options: dict[str, str]) -> str: _shape(args, options, 1, 1, ("variant", "lock5")) length = _number(args[0], 1, _READ_MAX) variant = _choice(options, "variant", VARIANTS, "sync") lock5 = _flag(options, "lock5") self._check(variant, lock5) - if lock5 and not self._hsi48(): - _fail("failed") + if variant == "hsem": + self._synchronized(lock5) line = f"OK data={self._generate(length).hex()}" if variant == "hsem": line += f" hsi48={self._hsi48()}" @@ -72,6 +96,8 @@ def cmd_stats(self, args: list[str], options: dict[str, str]) -> str: length = _number(args[0], _STATS_MIN, _STATS_MAX) variant = _choice(options, "variant", VARIANTS, "sync") self._check(variant, False) + if variant == "hsem": + self._synchronized(False) stats = rngstats.byte_stats(self._generate(length)) return f"OK n={stats.n} ones={stats.ones} runs={stats.runs} chisq={stats.chisq_x1000} crc={stats.crc:08x} us={length // 4 + 50}" diff --git a/validation/host/hal_st_validation/fakes/qspi.py b/validation/host/hal_st_validation/fakes/qspi.py index 2a6eafc3..8cf5d23a 100644 --- a/validation/host/hal_st_validation/fakes/qspi.py +++ b/validation/host/hal_st_validation/fakes/qspi.py @@ -123,6 +123,7 @@ def cmd_cmd(self, args: list[str], options: dict[str, str]) -> str: ): _fail("usage") lines = self._phases(options) + self._check_busy(state) length = 0 output = "hex" if transmit: @@ -135,7 +136,6 @@ def cmd_cmd(self, args: list[str], options: dict[str, str]) -> str: if output == "hex" and length > QSPI_HEX_MAX: _fail("range") _number(options.get("repeat", "1"), 1, QSPI_REPEAT_MAX) - self._check_busy(state) if not receive: return "OK flevel=0" data = bytes([self._byte(lines)]) * length @@ -167,6 +167,9 @@ def cmd_xfer(self, args: list[str], options: dict[str, str]) -> str: receive = "rx" in options if (text != "-" or "len" in options) == receive or (receive and "repeat" in options): _fail("usage") + if state["variant"] != "spi": + _fail("unsupported") + self._check_busy(state) generated = "len" in options patterned = "pattern" in options or "seed" in options if text != "-": @@ -179,9 +182,6 @@ def cmd_xfer(self, args: list[str], options: dict[str, str]) -> str: _fail("usage") length = _number(options["rx"], 1, QSPI_HEX_MAX) if receive else 0 _number(options.get("repeat", "1"), 1, QSPI_REPEAT_MAX) - if state["variant"] != "spi": - _fail("unsupported") - self._check_busy(state) if not receive: return "OK flevel=0" return f"OK rx={(bytes([self._byte(1)]) * length).hex()}" diff --git a/validation/host/hal_st_validation/fakes/spis.py b/validation/host/hal_st_validation/fakes/spis.py index 73c477b2..70e72bc3 100644 --- a/validation/host/hal_st_validation/fakes/spis.py +++ b/validation/host/hal_st_validation/fakes/spis.py @@ -78,12 +78,12 @@ def cmd_open(self, args: list[str], options: dict[str, str]) -> str: def cmd_arm(self, args: list[str], options: dict[str, str]) -> str: state = self._find(args, options, 2, 2, _ARM_KEYS) + if state["armed"]: + _fail("busy") send = _payload(args, options, 1, SPIS_BUFFER) receive = _number(options.get("rx", str(len(send))), 0, SPIS_BUFFER) if (not send and receive == 0) or (send and receive not in (0, len(send))): _fail("usage") - if state["armed"]: - _fail("busy") state.update(armed=True, done=False, tx=send, rx=receive, received=bytearray(), clocked=0) return "OK" diff --git a/validation/host/hal_st_validation/fakes/system_ext.py b/validation/host/hal_st_validation/fakes/system_ext.py index 934cdd20..cbe2676d 100644 --- a/validation/host/hal_st_validation/fakes/system_ext.py +++ b/validation/host/hal_st_validation/fakes/system_ext.py @@ -7,7 +7,7 @@ `hsem.lock`. - `hsem`: lock state of the 32 semaphores, `hold=` timers on the fake clock; `hsem.lock hold=` waits the hold. - `bkp`: words that survive `reset` (the backup domain keeps them). -- `lpm`: wakes at once (`woke=exti`), as if the wake edge came right after the marker went low. +- `lpm`: wakes at once (`woke=exti`, `sleeps=1`), as if the wake edge came right after the marker went low. """ from __future__ import annotations @@ -419,4 +419,4 @@ def cmd_enter(self, args: list[str], options: dict[str, str]) -> str: if self.fw.timer_owners.get(_SCAFFOLD_TIMER, self.owner) != self.owner: _fail("busy") self.fw.check_pins(self.owner, [wake, marker]) - return f"OK woke=exti restored=0 us={_LPM_WAKE_US}" + return f"OK woke=exti restored=0 us={_LPM_WAKE_US} sleeps=1" diff --git a/validation/host/hal_st_validation/fakes/timers.py b/validation/host/hal_st_validation/fakes/timers.py index 3aa27611..15816e2d 100644 --- a/validation/host/hal_st_validation/fakes/timers.py +++ b/validation/host/hal_st_validation/fakes/timers.py @@ -7,7 +7,14 @@ from typing import Any, ClassVar from .. import expect -from ..groups.timers import LPTIM_PERIOD_MAX, LPTIM_PRESCALERS, LPTIM_REPETITION_MAX, TIMER_PRESCALER_MAX, timer_period_max +from ..groups.timers import ( + LPTIM_PERIOD_MAX, + LPTIM_PRESCALERS, + LPTIM_REPETITION_MAX, + TIMER_PRESCALER_MAX, + UPDATE_INTERRUPT_MAX_HZ, + timer_period_max, +) from .base import FakeGroup, _choice, _fail, _number, _shape, _tokens _IRQS = ("none", "immediate", "dispatched") @@ -95,6 +102,8 @@ def cmd_open(self, args: list[str], options: dict[str, str]) -> str: if mode == "down" and (irq != "none" or not expect.timer_has_center_mode(timer)): _fail("unsupported") timclk = self.fw.kernel_clock + if irq != "none" and timclk // ((prescaler + 1) * (period + 1)) > UPDATE_INTERRUPT_MAX_HZ: + _fail("range") state = self._counter_state(timclk / (prescaler + 1), period, irq, mode == "down") state.update(timer=timer, prescaler=prescaler, pin=pin, timclk=timclk) self.fw.open_instance(self.prefix, str(timer), state, [pin], timer=timer) @@ -122,6 +131,9 @@ def cmd_open(self, args: list[str], options: dict[str, str]) -> str: if "rep" in options and self.fw.family == "stm32wb55": _fail("unsupported") lptimclk = self.fw.kernel_clock + # ARRM interrupts on every period, whatever `rep`. + if irq != "none" and lptimclk // (divider * (period + 1)) > UPDATE_INTERRUPT_MAX_HZ: + _fail("range") state = self._counter_state(lptimclk / divider / (repetition + 1), period, irq) state.update(index=index, divider=divider, repetition=repetition, pin=pin, lptimclk=lptimclk) self.fw.open_instance(self.prefix, str(index), state, [pin], resources=[("lpTimer", index)]) diff --git a/validation/host/hal_st_validation/groups/system_ext.py b/validation/host/hal_st_validation/groups/system_ext.py index ecc51d68..23db21c9 100644 --- a/validation/host/hal_st_validation/groups/system_ext.py +++ b/validation/host/hal_st_validation/groups/system_ext.py @@ -226,6 +226,7 @@ class LowPowerWake: woke: str restored: int us: int + sleeps: int raw: str @@ -268,7 +269,13 @@ def __init__(self, pending: PendingCommand) -> None: def wait(self, timeout: float | None = None) -> LowPowerWake: response = self.pending.wait(timeout) - return LowPowerWake(woke=response["woke"], restored=response.as_int("restored"), us=response.as_int("us"), raw=response.raw) + return LowPowerWake( + woke=response["woke"], + restored=response.as_int("restored"), + us=response.as_int("us"), + sleeps=response.as_int("sleeps"), + raw=response.raw, + ) GROUPS: dict[str, type[Group]] = {"flash": Flash, "hsem": Hsem, "bkp": BackupRam, "lpm": LowPower} diff --git a/validation/host/hal_st_validation/groups/timers.py b/validation/host/hal_st_validation/groups/timers.py index 01773808..3427f858 100644 --- a/validation/host/hal_st_validation/groups/timers.py +++ b/validation/host/hal_st_validation/groups/timers.py @@ -23,6 +23,7 @@ "LPTIM_REPETITION_MAX", "TIMER_DISPATCHED_MAX_HZ", "TIMER_PRESCALER_MAX", + "UPDATE_INTERRUPT_MAX_HZ", "LpTimer", "LpTimerPwm", "Timer", @@ -44,6 +45,9 @@ LPTIM_REPETITION_MAX = 0xFF # Above this update rate `irq=dispatched` callbacks coalesce in the event loop (one queued callback at a time). TIMER_DISPATCHED_MAX_HZ = 1000 +# Above this update rate `tim.open`/`lptim.open` with an update interrupt are `ERR range`: the interrupt runs on every +# update in both irq modes and would starve the event loop. +UPDATE_INTERRUPT_MAX_HZ = 100_000 def timer_period_max(timer: int) -> int: diff --git a/validation/host/tests/hil/test_i2c.py b/validation/host/tests/hil/test_i2c.py index 61f0c728..c007e4d1 100644 --- a/validation/host/tests/hil/test_i2c.py +++ b/validation/host/tests/hil/test_i2c.py @@ -19,6 +19,7 @@ from __future__ import annotations +from collections.abc import Callable from typing import Any import pytest @@ -448,11 +449,14 @@ def test_repeated_start_on_bus(fw, ad3, need, bench, loop): master, target, address = open_loop(fw, loop, freq=100_000) scl, sda = need.dio(loop["master"]["scl"]), need.dio(loop["master"]["sda"]) fw.i2cs.regs(target, 0x20, b"\x5a") - capture = arm_bus(ad3, scl, sda, 100_000, 600e-6) + # The master holds SCL low after the write until the host's `i2c.read` arrives: the window spans that round trip. + capture = arm_bus(ad3, scl, sda, 100_000, 30e-3) fw.i2c.write(master, address, b"\x20", next="restart") fw.i2c.read(master, address, 1) samples = capture.wait(timeout=2.0) - write, read = i2c_decode(samples.channel(scl), samples.channel(sda))[:2] + transfers = i2c_decode(samples.channel(scl), samples.channel(sda)) + assert len(transfers) >= 2, f"{len(transfers)} transfer(s) captured: the read fell outside the window" + write, read = transfers[:2] assert (write.read, write.payload, write.stop) == (False, b"\x20", False) assert (read.restart, read.read, read.payload, read.stop) == (True, True, b"\x5a", True) @@ -482,16 +486,20 @@ def test_receive_continue_session(fw, loop): assert (status.reads, status.stops) == (1, 1) -def _stretched_transfer(fw, loop, direction: str, position: int, us: int) -> tuple[int, int, int]: +def _stretched_transfer(fw, loop, direction: str, position: int, us: int, arm: Callable[[], Any] = lambda: None) -> Any: + """One stretched transfer, `arm()` (its result returned) called right before it. A read's pointer is written + first, unstretched and with a STOP, so the read is a transfer of its own.""" master, target, address = open_loop(fw, loop, freq=100_000) fw.i2cs.regs(target, 0, generate(8)) + if direction == "read": + assert fw.i2c.write(master, address, b"\x00").result == "complete" fw.i2cs.cfg(target, stretch=us, stretchat=position) + capture = arm() if direction == "write": assert fw.i2c.write(master, address, bytes([0x80]) + generate(4)).result == "complete" else: - fw.i2c.write(master, address, b"\x00", next="restart") assert fw.i2c.read(master, address, 4).data == generate(4) - return master, target, address + return capture @pytest.mark.requires_option("i2c") @@ -507,12 +515,16 @@ def test_clock_stretch_completes(fw, loop, us, direction, position): @pytest.mark.board_params("us", "i2c.stretch_us") @pytest.mark.board_params("direction,position", values=STRETCH_POSITIONS) def test_clock_stretch_on_bus(fw, ad3, need, bench, loop, us, direction, position): - """The longest SCL low phase of the transfer is at least the stretch (5 % slack for the Stopwatch).""" + """The longest SCL low phase of the stretched transfer alone is at least the stretch (5 % slack for the + Stopwatch).""" scl = need.dio(loop["master"]["scl"]) seconds = us * 1e-6 + 2e-3 rate = min(ad3.logic.clock_hz, ad3.logic.buffer_size / seconds, 10e6) - capture = ad3.logic.arm(rate, int(rate * seconds), trigger=(scl, "falling"), pretrigger=0.01) - _stretched_transfer(fw, loop, direction, position, us) + + def arm(): + return ad3.logic.arm(rate, int(rate * seconds), trigger=(scl, "falling"), pretrigger=0.01) + + capture = _stretched_transfer(fw, loop, direction, position, us, arm) trace = capture.wait(timeout=2.0).channel(scl) edges = [index for index in range(1, len(trace)) if trace[index] != trace[index - 1]] lows = [(edges[i + 1] - edges[i]) / rate for i in range(len(edges) - 1) if not trace[edges[i]]] diff --git a/validation/host/tests/hil/test_low_power.py b/validation/host/tests/hil/test_low_power.py index 3d3670cf..485f181d 100644 --- a/validation/host/tests/hil/test_low_power.py +++ b/validation/host/tests/hil/test_low_power.py @@ -1,6 +1,7 @@ """Low-power mode through the `lpm` group: `hal::LowPowerModeStm::Enter` inside a window where only the wake line's EXTI interrupt and the scaffold timer (TIM17, 1 us ticks, also the safety timeout) are enabled and the SysTick tick is -off. The marker pin is low while the core sleeps. +off. The marker pin is low while the core sleeps; `sleeps` (the `Enter` calls, about one per millisecond when WFI +really sleeps) shows that it did. `deep` maps to Sleep on STM32WB/WBA (`LowPowerModeStm::Stop`), so it behaves like `sleep` and never calls the clock-restore callback (`restored=0`). The wake edge comes from the AD3 on the wake pin (bundle1 `gpio0`); the logic @@ -34,7 +35,8 @@ def expect_reason(fw, line: str, reason: str) -> None: @pytest.mark.board_params("edge", "lowpower.edges") def test_wake_on_edge(request, fw, ad3, need, lp_cfg, mode, edge): """The core sleeps (marker low) until the edge on the wake pin; the marker rises within `latency_us` of it and - `us` covers the time asleep.""" + `us` covers the time asleep. `sleeps` proves the WFI: a sleeping core returns from `Enter` once per TIM17 update + (1 ms) plus once for the edge, a driver that never sleeps busy-polls some 10^5 calls in that window.""" if request.config.getoption("--fake"): pytest.skip("the fake AD3 sees no board") wake_dio, marker_dio = need.dio(lp_cfg["wake"]), need.dio(lp_cfg["marker"]) @@ -56,6 +58,7 @@ def test_wake_on_edge(request, fw, ad3, need, lp_cfg, mode, edge): ad3.dio.release(wake_dio) assert (wake.woke, wake.restored) == ("exti", 0), wake.raw assert delay * 1e6 * 0.5 <= wake.us <= timeout_ms * 1000, wake.raw + assert 1 <= wake.sleeps <= 2 * (wake.us // 1000) + 2, wake.raw marker = result.channel(marker_dio) trigger = result.trigger_index assert trigger is not None diff --git a/validation/host/tests/hil/test_lptim.py b/validation/host/tests/hil/test_lptim.py index 809e9b38..cd243c13 100644 --- a/validation/host/tests/hil/test_lptim.py +++ b/validation/host/tests/hil/test_lptim.py @@ -167,9 +167,13 @@ def test_open_errors(fw, board_cfg, lptim_cfg): ({"prescaler": 3}, "range"), ({"prescaler": 256}, "range"), ({"pin": board_cfg.param("system.unbonded_pins")[0]}, "pin"), + ({"period": 1}, "range"), + ({"period": 1, "irq": "immediate"}, "range"), ] for options, reason in cases: expect_error(reason, fw.lptim.open, index, **options) + assert fw.lptim.open(index, period=1, irq="none") > 0, "the update interrupt rate limit needs an interrupt" + fw.lptim.close(index) for missing in lptim_cfg["missing"]: expect_error("range", fw.lptim.open, missing) for command in ("start", "stop", "count", "close"): diff --git a/validation/host/tests/hil/test_qspi.py b/validation/host/tests/hil/test_qspi.py index 1bda1091..25ecde42 100644 --- a/validation/host/tests/hil/test_qspi.py +++ b/validation/host/tests/hil/test_qspi.py @@ -428,6 +428,25 @@ def test_poll_timeout(fw, ad3, need, qspi_cfg, index, variant): assert fw.qspi.cmd(index, instr=qspi_cfg["instruction"]).flevel == 0 +@pytest.mark.slow +@pytest.mark.resets_board +def test_close_recovers_from_a_data_only_read(fw, qspi_cfg, index): + """A 4-line read with a data phase only hangs the QUADSPI with BUSY set (erratum), which only an abort or a + reset clears: `variant=dma` answers `ERR timeout`. `qspi.close` resets the QUADSPI, so after a new open both + drivers complete a write again (`QuadSpiStm` would otherwise wait in `HAL_QSPI_Init` and fail every command).""" + fw.qspi.open(index, variant="dma") + with reset_on_failure(fw): + response = settle(fw.qspi.begin("cmd", index, lines=4, rx=4, cmd_timeout=fw.terminal.timeout + QSPI_TIMEOUT_S)) + if response is not None and response.ok: + pytest.skip("the data-only read completed: the erratum did not hang the QUADSPI") + assert response is not None and response.reason == "timeout", f"qspi.cmd answered {response!r}" + fw.qspi.close(index) + for variant in ("dma", "poll"): + fw.qspi.open(index, variant=variant) + assert fw.qspi.cmd(index, instr=qspi_cfg["instruction"]).flevel == 0, variant + fw.qspi.close(index) + + @pytest.mark.ad3 def test_xfer_decoded(fw, ad3, need, qspi_cfg, index): """`variant=spi`: `SingleSpeedQuadSpiStmDma` writes are SPI mode 0 with MOSI on IO0 and NCS as chip select.""" diff --git a/validation/host/tests/hil/test_rng.py b/validation/host/tests/hil/test_rng.py index 3b745a63..f4c74ca9 100644 --- a/validation/host/tests/hil/test_rng.py +++ b/validation/host/tests/hil/test_rng.py @@ -5,8 +5,10 @@ Every variant returns exactly the requested bytes, two reads differ, and 64 KiB pass the monobit, byte chi-square and runs tests of `rngstats`. On STM32WB55 the synchronized variant keeps HSI48 (the RNG kernel clock) running when it was -on and switches it off again when it was off (B.9b), takes the locked branch with `lock5=1`, and leaves HSEM semaphores -0 and 5 free (B.9). No wiring. +on and switches it off again when it was off (B.9b), reads with semaphore 5 held (`lock5=1`), and leaves HSEM +semaphores 0 and 5 free (B.9). The firmware refuses what would hang or abort it: the plain variants while HSI48 is off, +the synchronized one while this core holds semaphore 0, while semaphore 5 is locked with HSI48 off, and while the +coordinated flash driver exists. No wiring. """ from __future__ import annotations @@ -42,6 +44,11 @@ def needs_clock_group(fw): pytest.skip("needs the clock group (clock.hsi48, clock.info)") +def needs_hsem_group(fw): + if not hasattr(fw, "hsem"): + pytest.skip("needs the hsem group (hsem.take, hsem.release)") + + @pytest.mark.board_params("variant", "rng.variants") @pytest.mark.board_params("length", "rng.lengths") def test_read_length(fw, variant, length): @@ -114,8 +121,10 @@ def test_hsem_restores_hsi48_off(fw, rng_cfg): @pytest.mark.family("stm32wb55") def test_hsem_clock_locked(fw, rng_cfg): - """`lock5=1` holds semaphore 5 (clock configuration) around the read: `Hsi48Enabler` takes its locked branch and - leaves HSI48 alone; both semaphores are free afterwards.""" + """`lock5=1` holds semaphore 5 (clock configuration) around the read. With HSI48 on, the read works, leaves HSI48 on + and frees both semaphores. The locked branch of `Hsi48Enabler` differs from the unlocked one only by its assert with + HSI48 off, which the firmware refuses (test_clock_locked_needs_hsi48); the query itself is covered by + test_hsem.py::test_query_takes_no_lock.""" first = fw.rng.read(16, variant="hsem", lock5=True) second = fw.rng.read(16, variant="hsem", lock5=True) assert first.hsi48 == second.hsi48 == 1, (first.raw, second.raw) @@ -142,6 +151,69 @@ def test_clock_locked_needs_hsi48(fw, rng_cfg): assert_semaphores_free(fw, rng_cfg) +@pytest.mark.family("stm32wb55") +def test_plain_variants_need_hsi48(fw): + """HSI48 is the RNG kernel clock (CLK48) and only `Hsi48Enabler` starts it: with HSI48 off the plain drivers would + abort on the clock error or wait forever for data, so the firmware refuses them.""" + needs_clock_group(fw) + fw.clock.hsi48(False) + try: + for line in ("rng.read 4", "rng.read 4 variant=async", "rng.stats 16", "rng.stats 16 variant=async"): + expect_reason(fw, line, "failed") + assert len(fw.rng.read(4, variant="hsem").data) == 4 + finally: + fw.clock.hsi48(True) + assert len(fw.rng.read(4).data) == 4 + + +@pytest.mark.family("stm32wb55") +def test_hsem_refuses_semaphore_0_of_this_core(fw, rng_cfg): + """The driver waits for semaphore 0 with the event loop blocked: held by `hsem.take` nothing could free it.""" + needs_hsem_group(fw) + fw.hsem.take(0, procid=1) + try: + expect_reason(fw, "rng.read 4 variant=hsem", "busy") + expect_reason(fw, "rng.stats 16 variant=hsem", "busy") + finally: + fw.hsem.release(0, procid=1) + assert len(fw.rng.read(4, variant="hsem").data) == 4 + assert_semaphores_free(fw, rng_cfg) + + +@pytest.mark.family("stm32wb55") +def test_hsem_semaphore_5_of_another_process(fw, rng_cfg): + """`IsLockedByCurrentCore` ignores the process: semaphore 5 taken by `hsem.take` leaves HSI48 to the clock owner, + and with HSI48 off the firmware refuses the read the driver would assert on.""" + needs_hsem_group(fw) + needs_clock_group(fw) + fw.hsem.take(5, procid=1) + try: + assert fw.rng.read(4, variant="hsem").hsi48 == 1 + expect_reason(fw, "rng.read 4 variant=hsem lock5=1", "busy") + fw.clock.hsi48(False) + try: + expect_reason(fw, "rng.read 4 variant=hsem", "failed") + finally: + fw.clock.hsi48(True) + finally: + fw.hsem.release(5, procid=1) + assert_semaphores_free(fw, rng_cfg) + + +@pytest.mark.family("stm32wb55") +def test_hsem_excludes_coordinated_flash(fw): + """`variant=hsem` shares HSEM 0 of `ResourceAllocation` with the coordinated flash driver and `hsem.lock`.""" + if not hasattr(fw, "flash"): + pytest.skip("needs the flash group (flash.stack)") + fw.flash.stack("starting") + try: + expect_reason(fw, "rng.read 16 variant=hsem", "busy") + assert len(fw.rng.read(16).data) == 16 + finally: + fw.flash.stack("fus") + assert len(fw.rng.read(16, variant="hsem").data) == 16 + + @pytest.mark.parametrize( ("line", "reason"), [ diff --git a/validation/host/tests/hil/test_sgpio.py b/validation/host/tests/hil/test_sgpio.py index 38083d35..a1c2841e 100644 --- a/validation/host/tests/hil/test_sgpio.py +++ b/validation/host/tests/hil/test_sgpio.py @@ -2,9 +2,10 @@ `hal::MultiPeripheralPinStm`) through the `sgpio` group. Wiring set `bundle1`: the pins of `tests.sgpio` on DIOs. The output pins are read with the AD3 static I/O. An open-drain -output has no pull (`SynchronousOutputPinStm` takes none), so only its low level and its latch are asserted. The -alternate-function cases mux a timer channel to the pins while the `tpwm` group runs that channel with no pin of its -own (`-`): the PWM appears on every muxed pin exactly when the mux works. +output has no pull (`SynchronousOutputPinStm` takes none): on `out_pins` only its low level and its latch are asserted, +on `float_pins` (no board load) the AD3 pulls show that a high level releases the line. The alternate-function cases +mux a timer channel to the pins while the `tpwm` group runs that channel with no pin of its own (`-`): the PWM appears +on every muxed pin exactly when the mux works. """ from __future__ import annotations @@ -92,12 +93,17 @@ def test_open_drain_low_and_latch(fw, ad3, need, pin): @pytest.mark.ad3 @pytest.mark.board_params("pin", "sgpio.float_pins") def test_switch_drive_and_release(fw, ad3, need, pin): - """A changed `od` rebuilds the pin; `sgpio.release` leaves an input without pull (`float_pins` carry no board - load, so the AD3 pulls decide its level).""" + """Open drain releases the line at 1 and holds it low at 0; a changed `od` rebuilds the pin as push-pull, which + drives the high level against both pulls; `sgpio.release` leaves an input without pull (`float_pins` carry no board load, so the AD3 + pulls decide its level).""" dio = need.dio(pin) ad3.dio.release(dio) fw.sgpio.out(pin, 1, od=True) + assert follows_pulls(ad3, dio), f"{pin} open drain at 1 must release the line" + fw.sgpio.out(pin, 0, od=True) + assert not follows_pulls(ad3, dio), f"{pin} open drain at 0 must hold the line low" fw.sgpio.out(pin, 1, od=False) + assert not follows_pulls(ad3, dio), f"{pin} push-pull high must override the AD3 pull-down" assert ad3.dio.read(dio) == 1, "push-pull high after open drain" fw.sgpio.release(pin) assert follows_pulls(ad3, dio), f"{pin} must be an input without pull after sgpio.release" diff --git a/validation/host/tests/hil/test_spi_ext.py b/validation/host/tests/hil/test_spi_ext.py index 54374ef2..7df63f48 100644 --- a/validation/host/tests/hil/test_spi_ext.py +++ b/validation/host/tests/hil/test_spi_ext.py @@ -1,6 +1,6 @@ """SPI master extensions (PROTOCOL.md "SPI master"): the bit order (`lsb=1`), frame sizes (`bits=`, a -`hal::SpiDataSizeConfiguratorStm` on `hal::SpiMasterStmDma`), the DMA data width after a 16-bit transfer (B.4) and -the hardware slave select (`nss=`). +`hal::SpiDataSizeConfiguratorStm` on `hal::SpiMasterStmDma`), an 8-bit master reopened after a 16-bit one and the +hardware slave select (`nss=`). The master is observed with the logic analyzer (`tests.spi_ext.instances`; an entry with `option` is reached only through that option's jumpers). Words are decoded with `spiwords` at the frame size and compared with the driver's @@ -126,8 +126,10 @@ def test_frame_sizes(fw, ad3, need, wiring, board_cfg, ext_cfg, instance, bits): @pytest.mark.ad3 @pytest.mark.board_params("instance", "spi_ext.instances") -def test_width_restored_after_16_bit(fw, ad3, need, wiring, board_cfg, ext_cfg, instance): - """An 8-bit transfer after a 16-bit one decodes exactly: the DMA data widths go back to bytes (B.4).""" +def test_8_bit_after_16_bit_reopen(fw, ad3, need, wiring, board_cfg, ext_cfg, instance): + """A master reopened at 8 bits after a 16-bit open runs 8-bit frames. Every open builds new DMA streams, whose + constructor resets the data widths, so this cannot catch a width that sticks on a live GPDMA channel (B.4a is + proved by review; its 32-bit encoding by test_dma.py::test_wave_32_bit).""" if 16 not in instance["bits"]: pytest.skip(f"{instance['name']} has no 16-bit frames") dios, loop = prepare(ad3, need, wiring, board_cfg, instance) diff --git a/validation/host/tests/hil/test_spi_slave.py b/validation/host/tests/hil/test_spi_slave.py index ef19882f..bc0da0c8 100644 --- a/validation/host/tests/hil/test_spi_slave.py +++ b/validation/host/tests/hil/test_spi_slave.py @@ -167,6 +167,27 @@ def test_second_result_while_waiting_is_busy(fw, ad3, need, wiring, spis_cfg, in assert "OK done=1 rx=a3a4" in fw.terminal.history +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spis.instances") +def test_rejected_arm_keeps_the_armed_payload(fw, ad3, need, wiring, spis_cfg, instance): + """An `spis.arm` while a transfer is armed answers `ERR busy` before it parses its payload: the transmit DMA + still reads the armed payload, longer than any SPI FIFO here, and the master receives it unchanged.""" + ad3_master(ad3, need, wiring, instance, spis_cfg["freqs"][0]) + index = open_slave(fw, instance) + length = 8 * WORD + slave_tx = generate(length, "prbs", 11) + master_tx = generate(length, "inc", 0x30) + fw.spis.arm(index, slave_tx) + for line in ( + f"spis.arm {index} {generate(length, 'inc', 0xA0).hex()}", + f"spis.arm {index} 0102 rx=1", + f"spis.arm {index} - len={length} pattern=const seed=255", + ): + assert fw.terminal.command(line, check=False).reason == "busy", line + assert ad3.spi.transfer(master_tx) == slave_tx + expect_received(fw, index, master_tx) + + @pytest.mark.ad3 @pytest.mark.board_params("instance", "spis.instances") def test_reopen_after_close(fw, ad3, need, wiring, spis_cfg, instance): @@ -265,6 +286,9 @@ def test_arm_and_result_errors(fw, instance): with pytest.raises(FirmwareError) as error: fw.spis.arm(index, b"\x01") assert error.value.reason == "busy" + with pytest.raises(FirmwareError) as error: + fw.terminal.command(f"spis.arm {index} 0102 rx=1") + assert error.value.reason == "busy", "busy before the payload: the armed DMA reads it" assert fw.spis.result(index, wait=50, out="crc") == SpisResult(False) assert fw.spis.cancel(index) is True assert fw.spis.cancel(index) is False diff --git a/validation/host/tests/hil/test_timer.py b/validation/host/tests/hil/test_timer.py index ed0bd42c..c89ba2a4 100644 --- a/validation/host/tests/hil/test_timer.py +++ b/validation/host/tests/hil/test_timer.py @@ -97,6 +97,40 @@ def test_free_running_counter(fw, timer_cfg, timer, mode): assert (before.irqs, after.irqs) == (0, 0) +@pytest.mark.board_params("timer", "timer.timers") +@pytest.mark.board_params("irq", values=["immediate", "dispatched"]) +def test_no_callback_before_the_first_update(fw, timer_cfg, timer, irq): + """`tim.start` raises no update callback of its own: the update flag that `HAL_TIM_Base_Init` leaves on some HAL + versions is cleared, so a timer with an update period of many seconds has `irqs=0` right after it starts.""" + index = timer["timer"] + slow = timer_cfg["free_running"] + fw.tim.open(index, prescaler=slow["prescaler"], period=slow["period"], irq=irq) + fw.tim.start(index) + assert fw.tim.count(index).irqs == 0 + + +@pytest.mark.ad3 +@pytest.mark.board_params("encoder", "qei.instances") +@pytest.mark.board_params("mode", values=["up", "down"]) +def test_counts_the_requested_way_after_the_encoder(fw, ad3, need, board_cfg, timer_cfg, encoder, mode): + """An encoder leaves its timer in encoder mode with CR1.DIR at its last direction, where DIR ignores writes: + `tim.open` must still count the requested way. The encoder runs against `mode` before it closes.""" + index = encoder["index"] + if index not in [entry["timer"] for entry in timer_cfg["timers"]]: + pytest.skip(f"TIM{index} is not under test in `tests.timer.timers`") + a, b = need.dio(encoder["a"]), need.dio(encoder["b"]) + fw.qei.open(index, a=encoder["a"], b=encoder["b"], res=board_cfg.param("qei.resolution")) + ad3.pattern.quadrature(a, b, 1000, 10, "rev" if mode == "up" else "fwd") + ad3.pattern.wait_done(timeout=3) + fw.qei.close(index) + free = timer_cfg["free_running"] + fw.tim.open(index, prescaler=free["prescaler"], period=free["period"], irq="none", mode=mode) + fw.tim.start(index) + after, before, _ = count_rate(fw, index, timer_cfg["window_s"]) + delta = after.cnt - before.cnt if mode == "up" else before.cnt - after.cnt + assert delta > 0, (mode, before.cnt, after.cnt) + + @pytest.mark.board_params("timer", "timer.timers") def test_stop_holds_the_counter(fw, timer_cfg, timer): """`tim.stop` freezes the counter and the interrupts; `tim.start` resumes; repeated start and stop are harmless.""" @@ -146,7 +180,7 @@ def test_marker_is_released_on_close(fw, need, timer_cfg, timer): def test_open_errors(fw, board_cfg, timer_cfg): - """Argument errors in the protocol order: usage, range, pin, unsupported.""" + """Argument errors in the protocol order: usage, range, pin, unsupported, then the update interrupt rate (range).""" first = timer_cfg["timers"][0]["timer"] wide = next((entry["timer"] for entry in timer_cfg["timers"] if timer_period_max(entry["timer"]) > 0xFFFF), None) narrow = next(entry["timer"] for entry in timer_cfg["timers"] if timer_period_max(entry["timer"]) == 0xFFFF) @@ -160,6 +194,8 @@ def test_open_errors(fw, board_cfg, timer_cfg): ((narrow,), {"period": 0x10000}, "range"), ((first,), {"pin": board_cfg.param("system.unbonded_pins")[0]}, "pin"), ((first,), {"mode": "down"}, "unsupported"), + ((first,), {"prescaler": 0, "period": 1}, "range"), + ((first,), {"prescaler": 0, "period": 1, "irq": "immediate"}, "range"), ] cases += [((timer,), {}, "range") for timer in timer_cfg["missing"]] cases += [((timer,), {"irq": "none", "mode": "down"}, "unsupported") for timer in timer_cfg["up_only"]] @@ -168,6 +204,8 @@ def test_open_errors(fw, board_cfg, timer_cfg): if wide is not None: assert fw.tim.open(wide, period=0xFFFFFFFF, irq="none") > 0 fw.tim.close(wide) + assert fw.tim.open(first, prescaler=0, period=1, irq="none") > 0, "the update interrupt rate limit needs an interrupt" + fw.tim.close(first) for command in ("start", "stop", "count", "close"): expect_error("notopen", getattr(fw.tim, command), first) for line in ("tim.count", f"tim.count {first} {first}", f"tim.start {first} ch=1"): diff --git a/validation/host/tests/hil/test_timer_pwm.py b/validation/host/tests/hil/test_timer_pwm.py index 13aea184..56e3b51d 100644 --- a/validation/host/tests/hil/test_timer_pwm.py +++ b/validation/host/tests/hil/test_timer_pwm.py @@ -177,6 +177,30 @@ def test_start_and_stop_one_channel(fw, ad3, need, tpwm_cfg, timer): check_channel(record(ad3, tpwm_cfg, frequency), second, frequency, fraction, tpwm_cfg) +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "timer_pwm.timers") +def test_break_left_by_pwm_is_disabled(fw, ad3, need, board_cfg, tpwm_cfg, timer): + """`pwm.close` leaves BDTR with the break input enabled; with `brkpol=low` and the break pin back in analog mode + that break stays active, so `tpwm` on the same timer only drives its outputs because it rewrites BDTR.""" + index = timer["timer"] + pwm_timer = next((entry for entry in board_cfg.param("pwm.timers") if entry["timer"] == index and entry.get("brk")), None) + if pwm_timer is None: + pytest.skip(f"no pwm break input on TIM{index}") + dios = wired(need, timer["pins"]) + fw.pwm.open(index, pins=[pwm_timer["channels"][0]["pin"]], brk=pwm_timer["brk"], brkpol="low") + fw.pwm.close(index) + timclk = open_tpwm(fw, tpwm_cfg, timer) + for channel, dio in enumerate(dios, 1): + if dio is not None: + fw.tpwm.duty(index, channel, 50) + fw.tpwm.start(index) + frequency = timer_update_rate(timclk, tpwm_cfg["prescaler"], tpwm_cfg["period"]) + capture = record(ad3, tpwm_cfg, frequency) + for dio in dios: + if dio is not None: + check_channel(capture, dio, frequency, pwm_duty_fraction(tpwm_cfg["period"], 50), tpwm_cfg) + + def test_open_errors(fw, tpwm_cfg, board_cfg): """Argument errors in the protocol order: usage, range, pin, unsupported.""" timer = next(entry for entry in tpwm_cfg["timers"] if entry["timer"] in expect.SINGLE_CHANNEL_TIMERS) diff --git a/validation/host/tests/hil/test_uart_sendonly.py b/validation/host/tests/hil/test_uart_sendonly.py index 0befb1a1..17be55ab 100644 --- a/validation/host/tests/hil/test_uart_sendonly.py +++ b/validation/host/tests/hil/test_uart_sendonly.py @@ -74,20 +74,20 @@ def test_receive_returns_nothing(fw, ad3, need, instance): @pytest.mark.ad3 @pytest.mark.board_params("instance", "uart_sendonly.instances") def test_rts_only_mapping(fw, ad3, need, sendonly_cfg, instance): - """`flow=rts` maps RTS and nothing else: the RTS pin is driven by the UART (it follows neither AD3 pull) and a - deasserted CTS does not hold the transmitter.""" + """`flow=rts` muxes RTS and enables RTS flow control: the UART drives the RTS pin low (receiver ready) against both + AD3 pulls, and transmission is not held. CTSE cannot be observed: the send-only driver never muxes a CTS pin.""" baud = 115200 - rts, cts = need.dio(instance["rts"]), need.dio(instance["cts"]) + rts = need.dio(instance["rts"]) listen(ad3, need, instance, baud) - ad3.dio.drive(cts, 1) open_sendonly(fw, instance, baud=baud, flow="rts", rts=instance["rts"]) ad3.dio.pull(up=[rts]) pulled_up = ad3.dio.read(rts) ad3.dio.pull(down=[rts]) pulled_down = ad3.dio.read(rts) - assert pulled_up == pulled_down, "RTS must be driven by the UART" + ad3.dio.pull() + assert pulled_up == pulled_down == 0, "RTS must be driven and asserted (receiver ready) by the UART" payload = bytes.fromhex(sendonly_cfg["payloads"][-1]) - assert send_and_receive(fw, ad3, instance["index"], payload, baud) == payload, "CTS deasserted must not hold the data" + assert send_and_receive(fw, ad3, instance["index"], payload, baud) == payload, "flow=rts must not block transmission" @pytest.mark.ad3 diff --git a/validation/host/tests/unit/test_fake_crypto.py b/validation/host/tests/unit/test_fake_crypto.py index 4253017a..9b2549a0 100644 --- a/validation/host/tests/unit/test_fake_crypto.py +++ b/validation/host/tests/unit/test_fake_crypto.py @@ -92,6 +92,45 @@ def test_rng_hsem_reports_hsi48_of_the_clock_group(): assert fake.group("rng") is not None +def test_rng_plain_variants_need_hsi48(): + """On STM32WB55 HSI48 is the RNG kernel clock: only `variant=hsem` starts it.""" + terminal, _, fw = make() + fw.clock.hsi48(False) + for line in ("rng.read 4", "rng.read 4 variant=async", "rng.stats 16", "rng.stats 16 variant=async"): + assert reason(terminal, line) == "failed", line + assert reason(terminal, "rng.read 4 lock5=1") == "usage" + assert reason(terminal, "rng.stats 16 variant=hsem") == "ok" + fw.clock.hsi48(True) + assert reason(terminal, "rng.read 4") == "ok" + + +def test_rng_hsem_refuses_semaphores_held_by_this_core(): + terminal, _, fw = make() + fw.hsem.take(0, procid=1) + assert reason(terminal, "rng.read 4 variant=hsem") == "busy" + assert reason(terminal, "rng.stats 16 variant=hsem") == "busy" + fw.hsem.release(0, procid=1) + fw.hsem.take(5, procid=1) + assert reason(terminal, "rng.read 4 variant=hsem") == "ok" + assert reason(terminal, "rng.read 4 variant=hsem lock5=1") == "busy" + fw.clock.hsi48(False) + assert reason(terminal, "rng.read 4 variant=hsem") == "failed" + assert reason(terminal, "rng.stats 16 variant=hsem") == "failed" + fw.clock.hsi48(True) + fw.hsem.release(5, procid=1) + assert reason(terminal, "rng.read 4 variant=hsem lock5=1") == "ok" + + +def test_rng_hsem_excludes_the_coordinated_flash(): + terminal, _, fw = make() + fw.flash.stack("starting") + assert reason(terminal, "rng.read 16 variant=hsem") == "busy" + assert reason(terminal, "rng.stats 16 variant=hsem") == "busy" + assert reason(terminal, "rng.read 16") == "ok" + fw.flash.stack("stopped") + assert reason(terminal, "rng.read 16 variant=hsem") == "ok" + + def test_rng_stats_are_consistent_and_random(): _, _, fw = make() stats = fw.rng.stats(65536) diff --git a/validation/host/tests/unit/test_fake_qspi.py b/validation/host/tests/unit/test_fake_qspi.py index 5dae7695..9889faef 100644 --- a/validation/host/tests/unit/test_fake_qspi.py +++ b/validation/host/tests/unit/test_fake_qspi.py @@ -275,12 +275,30 @@ def test_poll_timeout_keeps_the_group_busy_until_close(variant): assert clock.now - start == qspi.QSPI_TIMEOUT_S assert reason(terminal, "qspi.cmd 1 instr=0x06") == "busy" assert reason(terminal, "qspi.cmd 1 instr=0x100") == "range" + assert reason(terminal, "qspi.cmd 1 tx=0011") == "busy" + assert reason(terminal, "qspi.cmd 1 tx=00zz") == "busy", "busy before the data: the command in flight uses the buffer" + assert reason(terminal, "qspi.cmd 1 len=0") == "busy" + assert reason(terminal, "qspi.cmd 1 tx=00 rx=1") == "usage" + assert reason(terminal, "qspi.xfer 1 zz") == "unsupported" fw.qspi.close(1) fw.qspi.open(1, variant=variant) assert fw.qspi.cmd(1, instr=0x06).flevel == 0 fw.qspi.poll(1, match=0x00, mask=0x01, size=2, lines=4) +def test_xfer_checks_busy_before_its_data(): + terminal, fake, _, fw = make() + fw.qspi.open(1, variant="spi") + bus(fake).io = 0b0000 + with pytest.raises(FirmwareError) as error: + fw.qspi.poll(1, match=0x01, mask=0x01, lines=1) + assert error.value.reason == "timeout" + assert reason(terminal, "qspi.xfer 1 9f") == "busy" + assert reason(terminal, "qspi.xfer 1 zz") == "busy" + assert reason(terminal, "qspi.xfer 1 - rx=0") == "busy" + assert reason(terminal, "qspi.xfer 1 9f rx=1") == "usage" + + def test_boot_resets_the_bus_and_the_instance(): terminal, fake, _, fw = make() fw.qspi.open(1) diff --git a/validation/host/tests/unit/test_fake_spis.py b/validation/host/tests/unit/test_fake_spis.py index eae2fb45..387cbc6a 100644 --- a/validation/host/tests/unit/test_fake_spis.py +++ b/validation/host/tests/unit/test_fake_spis.py @@ -169,7 +169,9 @@ def test_arm_needs_the_open_instance_and_answers_busy_while_armed(): assert reason(terminal, "spis.arm 4 01") == "range" fw.spis.arm(2, b"\x01") assert reason(terminal, "spis.arm 2 02") == "busy" - assert reason(terminal, "spis.arm 2 0203 rx=1") == "usage" + assert reason(terminal, "spis.arm 2 0203 rx=1") == "busy", "busy before the payload: the armed DMA reads it" + assert reason(terminal, "spis.arm 2 - len=1025") == "busy" + assert reason(terminal, "spis.arm 2 01 extra=1") == "usage" @pytest.mark.parametrize( diff --git a/validation/host/tests/unit/test_fake_system_ext.py b/validation/host/tests/unit/test_fake_system_ext.py index 1fd5dda2..928312cc 100644 --- a/validation/host/tests/unit/test_fake_system_ext.py +++ b/validation/host/tests/unit/test_fake_system_ext.py @@ -300,7 +300,7 @@ def test_lpm_errors_wb55(line, expected): def test_lpm_wakes_and_checks_resources(): terminal, fake, fw = make("stm32wba55") wake = fw.lpm.enter("sleep") - assert (wake.woke, wake.restored) == ("exti", 0) + assert (wake.woke, wake.restored, wake.sleeps) == ("exti", 0, 1) fw.gpio.cfg("tim1bkin", "out") assert reason(terminal, "lpm.enter sleep") == "busy" fw.gpio.release("tim1bkin") diff --git a/validation/host/tests/unit/test_fake_timers.py b/validation/host/tests/unit/test_fake_timers.py index d68302ec..0c40f607 100644 --- a/validation/host/tests/unit/test_fake_timers.py +++ b/validation/host/tests/unit/test_fake_timers.py @@ -58,6 +58,12 @@ def reason(terminal, line): ("tim.open 2 period=0xFFFFFFFF irq=none", "ok"), ("tim.open 17 pin=gpio0 irq=immediate", "ok"), ("tim.open 1 pin=terminaltx", "busy"), + ("tim.open 2 prescaler=0 period=1", "range"), + ("tim.open 2 prescaler=0 period=1 irq=immediate", "range"), + ("tim.open 2 prescaler=0 period=638", "range"), + ("tim.open 2 prescaler=0 period=639", "ok"), + ("tim.open 2 prescaler=0 period=1 irq=none", "ok"), + ("tim.open 2 prescaler=0 period=1 mode=down", "unsupported"), ], ) def test_timer_open_reasons(line, expected): @@ -195,6 +201,14 @@ def test_timer_pwm_commands(): ("stm32wb55", "lptim.open 2 prescaler=128 pin=gpio0", "ok"), ("stm32wba55", "lptim.open 1 rep=255", "ok"), ("stm32wba55", "lptim.open 2 prescaler=64 irq=immediate pin=gpio0", "ok"), + ("stm32wb55", "lptim.open 1 period=1", "range"), + ("stm32wb55", "lptim.open 1 period=638 irq=immediate", "range"), + ("stm32wb55", "lptim.open 1 period=639", "ok"), + ("stm32wb55", "lptim.open 1 period=1 irq=none", "ok"), + ("stm32wb55", "lptim.open 1 period=1 rep=0", "unsupported"), + ("stm32wba55", "lptim.open 1", "ok"), + ("stm32wba55", "lptim.open 1 period=998", "range"), + ("stm32wba55", "lptim.open 1 period=1 rep=255", "range"), ], ) def test_lptim_open_reasons(family, line, expected): From 6dc23f32c0c90307a613491534ffd6084c42d8e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:39:28 +0000 Subject: [PATCH 55/57] ci: allowlist the NIST AES-128 test-vector keys in the secret scan betterleaks flags the FIPS-197 C.1 and SP 800-38A F.1.1 keys in the board files' AES known-answer vectors as generic API keys. They are public test vectors, so the scan config allowlists those two values. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- .gitleaks.toml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitleaks.toml b/.gitleaks.toml index 02169f39..51876152 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -4,7 +4,11 @@ title = "secret scan config" useDefault = true [allowlist] -description = "Generated KiCad footprint cache — datasheet URLs, not secrets" +description = "Generated KiCad footprint cache (datasheet URLs) and the public NIST AES-128 test-vector keys of the validation suite, not secrets" paths = [ '''integration_test/pcb/integration_test_board/fp-info-cache''', ] +regexes = [ + '''000102030405060708090a0b0c0d0e0f''', + '''2b7e151628aed2a6abf7158809cf4f3c''', +] From 9e8b2a1808c688b0aa6f12c48a1ca82889f20aa5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:43:02 +0000 Subject: [PATCH 56/57] docs(validation): drop the space inside the hsi48 code span Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- validation/PROTOCOL.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/validation/PROTOCOL.md b/validation/PROTOCOL.md index 600ecc09..3c6422fc 100644 --- a/validation/PROTOCOL.md +++ b/validation/PROTOCOL.md @@ -367,7 +367,7 @@ Not a hal-st driver (hal-st has no I2C slave): the other end of the bus for the ## Random number generator (`hal::SynchronousRandomDataGeneratorStm`, `variant=async` selects `hal::RandomDataGeneratorStm`, `variant=hsem` selects `hal::SynchronousSynchronizedRandomDataGeneratorStm`) -- `rng.read [variant=sync|async|hsem] [lock5=0|1]` → `OK data=`: `len` random bytes, 1-128; after a `variant=hsem` read the line ends with ` hsi48=<0|1>`, the HSI48 ready flag (`LL_RCC_HSI48_IsReady`) once the driver is done +- `rng.read [variant=sync|async|hsem] [lock5=0|1]` → `OK data=`: `len` random bytes, 1-128; after a `variant=hsem` read the line ends with `hsi48=<0|1>`, the HSI48 ready flag (`LL_RCC_HSI48_IsReady`) once the driver is done - every command builds the driver of its variant and destroys it afterwards; `variant` defaults to `sync` - `variant=async` answers from the driver's completion (RNG interrupt), `ERR timeout` after 1 s - `variant=hsem` exists on STM32WB55 only (`ERR unsupported` on STM32WBA55): the driver holds HSEM semaphore 0 during the read, through the firmware's one `SynchronousHardwareSemaphoreMasterStm` (shared with `hsem` and `flash`) From bd4a3fedbba7a549e04e152a098e9860bc262ee8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:48:53 +0000 Subject: [PATCH 57/57] docs(validation): code-span the --with hint so GitHub renders it Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno --- validation/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/validation/README.md b/validation/README.md index 0fac26cd..4f8ccaec 100644 --- a/validation/README.md +++ b/validation/README.md @@ -239,7 +239,7 @@ The tables follow the `wiring_sets` of the board files (keep both in step); the - One dupont housing per header pin. AD3 leads (female) sit on male morpho pins; jumper ends go to the free female Arduino sockets (male-ended wire) or to free morpho pins (female-ended wire). - Where the only header position of a pin already carries an AD3 lead (NUCLEO-WB55RG PB12 at CN10-16, NUCLEO-WBA55CG PB8 at CN4-38), the net goes through a breadboard row: one male-female wire from the header pin to the row, the AD3 lead on a male pin in that row, and the jumper into the same row. - The I2C option is built on a breadboard: an SCL row, an SDA row, a 3V3 rail and a GND rail. Each rail is fed by one wire from one named header pin; the pull-ups, the EEPROM VCC and GND and (NUCLEO-WBA55CG) the AD3 ground share the rail, never a header pin. -- Fit option wiring only together with its `--with `, and remove it otherwise. `test_wiring.py` fails when it finds the jumpers of an option that is not enabled ("pass --with or remove the wiring"). +- Fit option wiring only together with its `--with `, and remove it otherwise. `test_wiring.py` fails when it finds the jumpers of an option that is not enabled (`pass --with or remove the wiring`). ### Parts for the I2C option @@ -450,7 +450,7 @@ SPI1 to SPI3; SPI3 is observed through DIO14, DIO10, DIO11 and DIO15. The PB8 en ## What is tested -- `test_wiring.py` - the bench wiring, with GPIO commands only and the AD3 outputs and pulls off: continuity of the jumpers of every enabled option, its external pull-ups, the jumpers of offered options that are not enabled ("pass --with or remove the wiring"), and that the pins of `tests.wiring.undriven` follow both MCU pulls. Run it first after wiring the board; it skips with `--fake`. +- `test_wiring.py` - the bench wiring, with GPIO commands only and the AD3 outputs and pulls off: continuity of the jumpers of every enabled option, its external pull-ups, the jumpers of offered options that are not enabled (`pass --with or remove the wiring`), and that the pins of `tests.wiring.undriven` follow both MCU pulls. Run it first after wiring the board; it skips with `--fake`. - `test_system.py` - `ping`, `info`, and the `board.pins` alias table against the board file in both directions, every alias accepted as a pin, reserved terminal/SWD/LSE/BOOT0 pins and the debug LED, unbonded pins, pin syntax, the terminal UART, error reasons (`usage`, `busy`, `notopen`, `range`, `unsupported`), missing instances (including 0), `delay`, `reset` and the `EVT boot` cause. - `test_uid.py` - the 96-bit unique device ID of `info`: twelve bytes, not blank memory, the lot number in printable ASCII, the same after a reset. - `test_clock.py` - `clock.info` against the board file: bus clocks, oscillator ready flags, the RNG kernel clock and (NUCLEO-WB55RG) the CLK48 source. On the NUCLEO-WB55RG also the clocks on the MCO pin (PA8, DIO0): SYSCLK/16 and HSI16/16 within 1 %, HSE/16 within 0.1 %, the LSE within 100 ppm (least-squares fit of the edge times), MCO off, and `clock.hsi48` stopping and restarting HSI48.