Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e1f6699
build(deps): bump EMIL to 0059646 for the services/hil terminal
claude Oct 3, 2026
dccd1be
fix(uart): disable the LPUART clock when an LPUART UartStm is destroyed
claude Oct 3, 2026
ebde276
feat(validation): add HIL validation firmware for NUCLEO-WB55RG and N…
claude Oct 4, 2026
fa1142f
fix(validation): harden the SPI, UART, ADC and encoder factories afte…
claude Oct 4, 2026
cfcabcf
feat(validation): add the hal_st_validation host package and pytest s…
claude Oct 4, 2026
7ff9dbc
fix(pwm): make PwmStm constructible and reset timer state between users
claude Oct 4, 2026
349d49c
fix(adc): support multi-channel sequences and TIM1 triggers on ADC4
claude Oct 4, 2026
cb03b77
fix(spi): start SPI v2 transfers that begin with a receive
claude Oct 4, 2026
01ae3f9
fix(dma): stop DMA streams without a late completion
claude Oct 4, 2026
df2de25
fix(uart): leave no UART state behind and survive overruns
claude Oct 4, 2026
4692418
fix(gpio): select the right port in EXTICR
claude Oct 4, 2026
4c5084a
fix(qei): reset the LPTIM before configuring the encoder
claude Oct 4, 2026
e2ba885
test(validation): guard the driver fixes instead of expecting the gaps
claude Oct 4, 2026
63133a7
fix(validation): wire the WB55 TIM2 encoder B to PA1 and document hea…
claude Oct 4, 2026
4fe3600
fix(uart): deinitialise the UART before gating its clock
claude Oct 4, 2026
d93f14b
fix(adc): reject a repeated channel with a different sampling time
claude Oct 4, 2026
e7dfbd4
docs(validation): align the markdown tables
claude Oct 4, 2026
1a622fa
devcontainer updated
gabrielfrasantos Oct 4, 2026
49d3731
docs: wrap markdown lines that exceed MegaLinter's 400-character limit
claude Oct 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
FROM gabrielfrasantos/embedded-devcontainer-cpp:latest@sha256:4f92e9c86dfc5a9688b6134944e7a40c9eb3c658022ea62dc9e57e4c3b056940
FROM gabrielfrasantos/embedded-devcontainer-cpp:v7.3.0@sha256:4f92e9c86dfc5a9688b6134944e7a40c9eb3c658022ea62dc9e57e4c3b056940

HEALTHCHECK NONE
14 changes: 14 additions & 0 deletions .devcontainer/devcontainer-lock.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
5 changes: 5 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,10 @@
"build": {
"context": "..",
"dockerfile": "Dockerfile"
},
"runArgs": ["--add-host=host.docker.internal:host-gateway"],
"features": {
"ghcr.io/devcontainers/features/node:1": {},
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {}
}
}
18 changes: 18 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
]
}
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -54,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 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

Expand All @@ -65,6 +69,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
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 4 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 .)

Expand Down
60 changes: 58 additions & 2 deletions hal_st/stm32fxxx/AdcDmaMultiChannelStm.cpp
Original file line number Diff line number Diff line change
@@ -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 <algorithm>
#include <cstdint>
#include <iterator>
#include <optional>
#include <utility>
#include <variant>
#include DEVICE_HEADER
Expand All @@ -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<uint32_t, hal::AdcDmaMultiChannelStmBase::MaxChannels> 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<uint32_t, 2> samplingTimeCommon = { ADC_SAMPLINGTIME_COMMON_1, ADC_SAMPLINGTIME_COMMON_2 };

uint32_t SelectSamplingTimeCommon(ADC_TypeDef* adc, std::array<std::optional<uint32_t>, 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
Expand Down Expand Up @@ -94,24 +131,43 @@ namespace hal

void AdcDmaMultiChannelStmBase::ConfigureChannels(infra::MemoryRange<const detail::AdcStmChannelConfig> 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<std::optional<uint32_t>, samplingTimeCommon.size()> assignedSamplingTimes;
#endif

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
channelConfig.SamplingTime = configs[i].samplingTime;
Comment thread
gabrielfrasantos marked this conversation as resolved.
#endif
#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);
}
LL_ADC_REG_SetSequencerLength(adc.Handle().Instance, analogPins.size() - 1);
LL_ADC_REG_SetSequencerLength(adc.Handle().Instance, sequencerLength[analogPins.size() - 1]);
}

void AdcDmaMultiChannelStmBase::TransferDone()
Expand Down
4 changes: 4 additions & 0 deletions hal_st/stm32fxxx/AdcDmaMultiChannelStm.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
{};
Expand Down
1 change: 1 addition & 0 deletions hal_st/stm32fxxx/AdcDmaStm.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ namespace hal
#endif
ReconfigureTrigger();

adc.SelectSingleConversion();
auto result = HAL_ADC_ConfigChannel(&adc.Handle(), &channelConfig);
assert(result == HAL_OK);

Expand Down
19 changes: 18 additions & 1 deletion hal_st/stm32fxxx/AdcTimerTriggeredBase.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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>({ TimerBaseStm::Trigger::TriggerOutput::update, false }) })
, timer(oneBasedTimerIndex, timing, { TimerBaseStm::CounterMode::up, std::make_optional(TimerTrigger(oneBasedTimerIndex - 1)) })
, timerIndex(oneBasedTimerIndex - 1)
{}

Expand Down
20 changes: 20 additions & 0 deletions hal_st/stm32fxxx/AnalogToDigitalPinStm.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions hal_st/stm32fxxx/AnalogToDigitalPinStm.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ namespace hal

uint32_t Channel(const hal::AnalogPinStm& pin) const;
ADC_HandleTypeDef& Handle();
void SelectSingleConversion();

void EnableOverrunInterrupt();

Expand Down
14 changes: 14 additions & 0 deletions hal_st/stm32fxxx/DmaStm.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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
Expand Down
12 changes: 7 additions & 5 deletions hal_st/stm32fxxx/GpioStm.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -531,14 +531,16 @@ namespace hal

void GpioStm::EnableInterrupt(Port port, uint8_t index, const infra::Function<void()>& 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<uint8_t>(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<uint8_t>(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;
Expand Down
Loading
Loading