Skip to content

feat(validation): cover every hal-st peripheral driver on NUCLEO-WB55RG/WBA55CG and fix the drivers it exercises - #96

Merged
gabrielfrasantos merged 58 commits into
mainfrom
ccr-b7e792a2-full-coverage
Oct 5, 2026
Merged

gabrielfrasantos merged 58 commits into
mainfrom
ccr-b7e792a2-full-coverage

Conversation

@gabrielfrasantos

@gabrielfrasantos gabrielfrasantos commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Follows #95 (merged). This PR extends the hardware-in-the-loop validation app so it exercises every hal-st peripheral driver built for the NUCLEO-WB55RG and NUCLEO-WBA55CG. USB, the radio stacks, services/st_util and RTC/tamper are out of scope; RTC/tamper drivers exist only for H5.

It also fixes the driver bugs found along the way, and enables three drivers that compiled to nothing on these MCUs.

New coverage

EMIL's HIL terminal has no command group for most of these peripherals, so the validation firmware adds its own groups (specified in validation/PROTOCOL.md). Each has a host test file and a fake-firmware model.

Group Drivers
i2c, i2cs, eeprom.* I2cStm on I2C1/I2C3 against an LL I2C target scaffold on the other instance (ACK/NACK at byte k, clock stretching, bus errors), plus EMIL's eeprom.* over a new I2cEepromStm adapter on an external 24LC256
spis, spi bits=/lsb=/nss= SpiSlaveStmDma (AD3 master and an SPI1↔SPI2/SPI3 loop), SpiDataSizeConfiguratorStm, the second SPI instance
tim, tpwm, lptim, lptpwm, qei lp=1 TimerStm, TimerPwmStm, LpTimerStm, LpTimerPwmStm, the WBA55 LPTIM encoder
pwm options, adc trgo=, ain, dma.wave PWM alignments, preload, break filter and TRGO; single-pin and temperature ADC; AdcTriggeredByTimerWithDma; CircularTransmitDmaChannel
uart sendonly=1, sgpio, clock, UID SynchronousUartStmSendOnly, the synchronous GPIO classes, default clocks and MCO, UniqueDeviceId
rng, aes, pka sync, async and HSEM-synchronized RNG, SynchronousAes128EcbStm, PkaStm (secp256r1), checked against pure-Python references
flash, hsem, bkp, lpm async and sync internal flash on a run-time-checked scratch region; FlashCoordinatedWithWirelessStack; HSEM; BackupRamStm; LowPowerModeStm
qspi (WB55) QuadSpiStm, QuadSpiStmDma, SingleSpeedQuadSpiStmDma

New infrastructure:

  • ResourceAllocation and owner ids keep groups that share I2C, SPI, ADC, LPTIM, DMA channels or HSEM off each other.
  • Payloads can be generated by the firmware (len=/pattern=), with CRC replies, so transfers can exceed the 255-character line.
  • Board profile fixes: WBA55 bonding mask (PA3/PB10/PB11/PB13 are not bonded); WBA55 debug LED moved from PB8 to PA9.

Bench

All extra wiring is in validation/README.md. Every position is cited from UM2435 for WB55, and from ST readmes and Zephyr for WBA55.

  • NUCLEO-WB55RG:
    • --with i2c: PB8–PC0 and PB9–PC1 jumpers, 4.7 kΩ pull-ups and a 24LC256 on a breadboard.
    • --with spiloop: SPI1↔SPI2 jumpers.
  • NUCLEO-WBA55CG: a new bundle2 (W1/W2 unplugged), with --with i2c (PB2/PB1↔PA6/PA7) and --with spiloop (SPI1↔SPI3).
  • Common:
    • test_wiring.py checks continuity and pull-ups before a run.
    • A load gate skips tests whose pins a fitted option loads.

Driver fixes (one commit each, guarded to WB/WBA unless noted)

  • I2C:
    • I2cStm recovers from NACK, bus error and arbitration loss, and clears NACKF.
    • Correct partial byte counts and continueSession completion.
    • An in-spec default TIMINGR and DeInit in the destructor.
  • SPI:
    • SpiSlaveStmDma actually enables the SPI on WB/WBA and clears SPE between transfers.
    • SpiMasterStmDma's data-size check can fire.
  • DMA: 16/32-bit width encoding on GPDMA and DMA v1.
  • Flash:
    • Erase uses the region's own pages, async and sync, per bank on dual-bank WBA6x.
    • The ECC-error page erase now unlocks the flash and computes its page correctly.
  • RNG:
    • RandomDataGeneratorStm builds, with the IRQ name each device declares.
    • The WBA55 RNG is clocked from HSI.
    • The synchronized RNG keeps a running HSI48 on.
  • HSEM: operator-precedence fix, a non-locking query, and no interrupt while waiting for a lock.
  • BackupRamStm: usable as hal::BackupRam, with TAMP access on WBA.
  • ADC: AdcTriggeredByTimerWithDma takes the requested sample count and stops conversions afterwards.
  • SynchronousGpioStm: port table indexed by port letter.
  • QSPI:
    • QuadSpiStmDma completes writes on TCF.
    • QuadSpiStm sizes its FIFO threshold from FTHRES (still 32 on F7).
  • Timers (all families):
    • TimerStm writes the counter direction only after CMS/SMS are cleared, and drops a stale update flag before Start.
    • TimerPwmStm resets BDTR.
  • Generator:
    • Leading-underscore IPs are generated, so WB55 gets QUADSPI.
    • LPTIM is added to the WBA peripheral table.

Needs a decision (documented, tests assert current behaviour or mark a known gap)

  • SPI masters: they never drive hardware NSS (SPI_NSS_SOFT).
  • 16-bit SPI frames: they are carried as zero-extended bytes.
  • QuadSpiStm: PollStatus waits with HAL_MAX_DELAY. This is a hanging known gap.
  • Single-pin and temperature ADC:
    • They ignore numberOfSamples.
    • The temperature sensor's default sampling time is too short.
  • DMA bridge channels: they are unusable as written, so they are listed as untestable.
  • LPTIM PWM polarity: to confirm on the bench.

Testing

  • Builds: all 13 MCU presets build in RelWithDebInfo with -Werror. The validation firmware is about 241 KB (WB55) and 218 KB (WBA55); WB55 stays below the flash scratch region at 256 KB.
  • Host:
    • 1809 unit tests pass; ruff, yamllint and clang-format pass.
    • The fake-firmware HIL suite passes on both boards, for every bundle and --with option.
  • Review: a multi-reviewer pass over the whole diff confirmed 30 findings, all fixed in the last 8 commits.
  • Not yet run on hardware. Some I2C v2 behaviours the tests rely on come from the reference manual and haven't been seen on a bench (DESIGN risk R2). The same goes for the WBA55 power-pin positions, which have a single source.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno


Generated by Claude Code

claude and others added 30 commits October 3, 2026 20:28
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
…UCLEO-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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
…r 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
…uite

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
…der 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
Formatted with markdown-table-formatter, as MegaLinter suggests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
… 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
…nsfers

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
…B/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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
…as 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
claude added 19 commits October 4, 2026 22:17
…p 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
…r 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<GpioPinStm> 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 <prefix>.<verb> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
…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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
…ond 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
…nd 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
…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=<MMS source>; 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=<timer> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
…nd 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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<volatile uint32_t> (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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
@gabrielfrasantos

gabrielfrasantos commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor Author

✅⚠️MegaLinter analysis: Success with warnings

Descriptor Linter Files Fixed Errors Max errors Warnings Elapsed time
✅ ACTION actionlint 5 0 0 0.06s
✅ CPP clang-format 100 6 0 0 1.88s
✅ CPP cppcheck 100 0 0 3.29s
✅ DOCKERFILE hadolint 1 0 0 0.04s
✅ JSON jsonlint 9 0 0 0.11s
✅ JSON prettier 9 3 0 0 1.12s
⚠️ MARKDOWN markdownlint 18 1 20 0 2.52s
✅ MARKDOWN markdown-table-formatter 18 1 0 0 1.08s
✅ REPOSITORY betterleaks yes no no 1.04s
✅ REPOSITORY checkov yes no no 20.45s
✅ REPOSITORY git_diff yes no no 0.86s
✅ REPOSITORY grype yes no no 96.06s
✅ REPOSITORY ls-lint yes no no 0.01s
⚠️ REPOSITORY osv-scanner yes no 1 0.85s
✅ REPOSITORY secretlint yes no no 1.88s
✅ REPOSITORY syft yes no no 2.19s
✅ REPOSITORY trivy yes no no 12.76s
✅ REPOSITORY trivy-sbom yes no no 0.5s
✅ REPOSITORY trufflehog yes no no 5.84s
⚠️ SPELL lychee 50 6 0 16.47s
✅ YAML prettier 8 1 0 0 1.13s
✅ YAML v8r 8 0 0 5.14s
✅ YAML yamllint 8 0 0 0.54s

Detailed Issues

⚠️ SPELL / lychee - 6 errors
📝 Summary
---------------------
🔍 Total..........289
🔗 Unique.........263
✅ Successful.....278
⏳ Timeouts.........0
🔀 Redirected.....111
👻 Excluded.........5
❓ Unknown..........0
🚫 Errors...........6
⛔ Unsupported......6

Errors in .github/agents/orchestrator.agent.md
[ERROR] file://.github/copilot-instructions.md (at 52:23) | File not found. Check if file exists and path is correct
[ERROR] file://hal_st/cortex/InterruptCortex.hpp (at 56:22) | File not found. Check if file exists and path is correct

Errors in .github/ISSUE_TEMPLATE/add-or-update-hal-driver.md
[ERROR] http://mcd.rou.st.com/modules.php?name=mcu (at 28:63) | Connection failed. Check network connectivity and firewall settings

Errors in README.md
[404] https://api.securityscorecards.dev/projects/github.com/embedded-pro/hal-st (at 3:199) | Rejected status code: 404 Not Found
[ERROR] https://st.com/ (at 5:93) | HTTP/2 protocol error. Server may not support HTTP/2 properly | Followed 1 redirect. Redirects: https://st.com/ --[301]--> https://www.st.com/

Errors in validation/README.md
[403] https://digilent.com/reference/software/waveforms/waveforms-3/start (at 190:12) | Rejected status code: 403 Forbidden

Hint: Followed 111 redirects. You might want to consider replacing redirecting URLs with the resolved URLs. Use verbose mode (`-v`/`-vv`) to see redirection details.
Hint: You can configure accepted/rejected response codes with `-a` or `--accept`
⚠️ MARKDOWN / markdownlint - 20 errors
.github/agents/executor.agent.md:11 error MD041/first-line-heading/first-line-h1 First line in a file should be a top-level heading [Context: "You are the executor agent for..."]
.github/agents/orchestrator.agent.md:18:401 error MD013/line-length Line length [Expected: 400; Actual: 420]
.github/agents/orchestrator.agent.md:18 error MD041/first-line-heading/first-line-h1 First line in a file should be a top-level heading [Context: "You are the orchestrator agent..."]
.github/agents/planner.agent.md:11 error MD041/first-line-heading/first-line-h1 First line in a file should be a top-level heading [Context: "You are the planner agent for ..."]
.github/agents/planner.agent.md:39 error MD040/fenced-code-language Fenced code blocks should have a language specified [Context: "```"]
.github/agents/reviewer.agent.md:14 error MD041/first-line-heading/first-line-h1 First line in a file should be a top-level heading [Context: "You are the reviewer agent for..."]
.github/agents/reviewer.agent.md:122 error MD040/fenced-code-language Fenced code blocks should have a language specified [Context: "```"]
.github/instructions/hal-st-cpp.instructions.md:18 error MD040/fenced-code-language Fenced code blocks should have a language specified [Context: "```"]
.github/ISSUE_TEMPLATE/add-or-update-hal-driver.md:10 error MD025/single-title/single-h1 Multiple top-level headings in the same document [Context: "How to import a HAL driver"]
.github/prompts/orchestrate.prompt.md:8 error MD041/first-line-heading/first-line-h1 First line in a file should be a top-level heading [Context: "Start a new development workfl..."]
CHANGELOG.md:83 error MD024/no-duplicate-heading Multiple headings with the same content [Context: "⚠ BREAKING CHANGES"]
CHANGELOG.md:87 error MD024/no-duplicate-heading Multiple headings with the same content [Context: "Features"]
CHANGELOG.md:94 error MD024/no-duplicate-heading Multiple headings with the same content [Context: "Bug Fixes"]
CHANGELOG.md:103 error MD024/no-duplicate-heading Multiple headings with the same content [Context: "Features"]
CHANGELOG.md:122 error MD024/no-duplicate-heading Multiple headings with the same content [Context: "Bug Fixes"]
CHANGELOG.md:140 error MD024/no-duplicate-heading Multiple headings with the same content [Context: "⚠ BREAKING CHANGES"]
CHANGELOG.md:144 error MD024/no-duplicate-heading Multiple headings with the same content [Context: "Features"]
CHANGELOG.md:151 error MD024/no-duplicate-heading Multiple headings with the same content [Context: "Features"]
CHANGELOG.md:162 error MD024/no-duplicate-heading Multiple headings with the same content [Context: "Bug Fixes"]
CLAUDE.md:1 error MD041/first-line-heading/first-line-h1 First line in a file should be a top-level heading [Context: "hal-st — Claude Instructions"]
⚠️ REPOSITORY / osv-scanner - 1 warning
Scanning dir .
Starting filesystem walk for root: /
End status: 64 dirs visited, 639 inodes visited, 0 Extract calls, 14.970719ms elapsed, 14.971009ms wall time
No package sources found, --help for usage information.


[REPOSITORY_OSV_SCANNER_ERROR_NO_PACKAGE_SOURCES] osv-scanner found no lockfiles, manifests, or SBOMs to scan in the repository.
This is a configuration/scope issue, not a vulnerability finding.
Resolutions:
  - Verify the repository actually contains a supported lockfile (package-lock.json, go.sum, Gemfile.lock, Pipfile.lock, etc.) at the scanned path.
  - If this is expected for some sub-projects, disable osv-scanner for the affected paths or mark it non-blocking via `DISABLE_ERRORS_LINTERS`.

Notices

⚠️ Your configuration references items that have been removed from MegaLinter and are ignored: REPOSITORY_GITLEAKS, REPOSITORY_KICS. See Removed linters to find their replacements.

See detailed reports in MegaLinter artifacts

Your project could benefit from a custom flavor, which would allow you to run only the linters you need, and thus improve runtime performances. (Skip this info by defining FLAVOR_SUGGESTIONS: false)

  • Documentation: Custom Flavors
  • Command: npx mega-linter-runner@10.1.0 --custom-flavor-setup --custom-flavor-linters ACTION_ACTIONLINT,CPP_CPPCHECK,CPP_CLANG_FORMAT,DOCKERFILE_HADOLINT,JSON_JSONLINT,JSON_PRETTIER,MARKDOWN_MARKDOWNLINT,MARKDOWN_MARKDOWN_TABLE_FORMATTER,REPOSITORY_CHECKOV,REPOSITORY_GIT_DIFF,REPOSITORY_BETTERLEAKS,REPOSITORY_GRYPE,REPOSITORY_LS_LINT,REPOSITORY_OSV_SCANNER,REPOSITORY_SECRETLINT,REPOSITORY_SYFT,REPOSITORY_TRIVY,REPOSITORY_TRIVY_SBOM,REPOSITORY_TRUFFLEHOG,SPELL_LYCHEE,YAML_PRETTIER,YAML_YAMLLINT,YAML_V8R

MegaLinter is provided by OX Security
Show us your support by starring ⭐ the repository

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno
Comment thread validation/PROTOCOL.md Outdated
Base automatically changed from ccr-b7e792a2-g2fxib to main October 5, 2026 13:30
main's #95 squash commit has the same tree as the #95 head this branch
is built on, so every add/add conflict resolves to this branch's version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQVqtLNig9iboP9KR4Snno

Copy link
Copy Markdown
Contributor Author

The Linting check on dc7084e is red, but the failure is not caused by this PR.

The error comes from MegaLinter's trivy: it could not download its vulnerability database. mirror.gcr.io/aquasec/trivy-db:2 answered BLOB_UNKNOWN, then ghcr.io/aquasecurity/trivy-db:2 answered DENIED/TOOMANYREQUESTS, then the --skip-db-update fallback failed with "cannot be specified on the first run".

The same error fails main's own push run for the #95 squash commit (run 37317391893). dc7084e also has exactly the same tree as bd4a3fe, where Linting passed this morning. All other linters pass; markdownlint, osv-scanner and lychee only show their usual warnings.

There is no code fix, because this is the upstream database registry. I'll re-run the failed job once, after giving the registry time to recover.


Generated by Claude Code

@gabrielfrasantos
gabrielfrasantos merged commit c744a38 into main Oct 5, 2026
15 of 16 checks passed
@gabrielfrasantos
gabrielfrasantos deleted the ccr-b7e792a2-full-coverage branch October 5, 2026 14:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants