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 f5db2f46..05d91df0 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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": {} } } 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/AGENTS.md b/AGENTS.md index d559e91d..eb8fa93b 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,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 @@ -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 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/CMakeLists.txt b/CMakeLists.txt index 6e6638fb..c5880356 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) @@ -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/hal_st/stm32fxxx/AdcDmaMultiChannelStm.cpp b/hal_st/stm32fxxx/AdcDmaMultiChannelStm.cpp index c7013b7e..b07f3726 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,24 +131,43 @@ 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) { + 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; +#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() 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/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 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; 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, 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/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 diff --git a/hal_st/stm32fxxx/UartStm.cpp b/hal_st/stm32fxxx/UartStm.cpp index 57055447..0c0aa787 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,17 @@ 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); + HAL_UART_DeInit(&uartHandle); + +#if defined(HAS_PERIPHERAL_LPUART) + if (uartArray.begin() == peripheralLpuart.begin()) + { + DisableClockLpuart(uartIndex); + return; + } +#endif + DisableClockUart(uartIndex); } @@ -113,17 +120,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) @@ -163,17 +171,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/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; 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(); } diff --git a/hal_st/synchronous_stm32fxxx/SynchronousUartStm.cpp b/hal_st/synchronous_stm32fxxx/SynchronousUartStm.cpp index 67632cf3..123ff7cc 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,12 @@ 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); + + UART_HandleTypeDef uartHandle = {}; + uartHandle.Instance = peripheralUart[uartIndex]; + HAL_UART_DeInit(&uartHandle); + DisableClockUart(uartIndex); } void SynchronousUartStm::SendData(infra::ConstByteRange data) @@ -122,6 +131,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 +189,24 @@ namespace hal SynchronousUartStmSendOnly::~SynchronousUartStmSendOnly() { - uartBase->CR1 &= ~(USART_CR1_TE | USART_CR1_RE); + 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) @@ -221,6 +256,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); 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..99435e0f --- /dev/null +++ b/validation/PROTOCOL.md @@ -0,0 +1,162 @@ +# 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 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) | + +- 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 +- `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 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`) + +- `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/README.md b/validation/README.md new file mode 100644 index 00000000..b30c50ff --- /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 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 + +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 | +|-------|----------------------|------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------| +| 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. + +## 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 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 + +`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 + +| 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 | + +## 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/firmware/AdcFactory.cpp b/validation/firmware/AdcFactory.cpp new file mode 100644 index 00000000..46f221ba --- /dev/null +++ b/validation/firmware/AdcFactory.cpp @@ -0,0 +1,320 @@ +#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 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 uint64_t minimumTicks = 2; + + // 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: + 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; + } + + // 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; + + return { static_cast(prescale - 1), static_cast((roundUp ? ceilTicks : floorTicks) - 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() + { + InterruptsDisabled interruptsDisabled; + 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 + { + if (arguments.Has("rate") && !arguments.Has("timer")) + return HilStatus::usage; + + 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; + + 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..d4fdcab1 --- /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)) + 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..9d4e92fb --- /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) + { + const auto ticksPerPeriod = counterClock / hertz; + if (ticksPerPeriod < 2) + return false; + + const uint32_t maximumCompare = IS_TIM_32B_COUNTER_INSTANCE(Instance(timer)) ? 0xffffffffu : 0xffffu; + return (centerAligned ? 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..a8552bf2 --- /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 + { + if (request.lowPower && (arguments.Has("cap") || arguments.Has("offset") || arguments.Has("invb"))) + return HilStatus::unsupported; + + 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 && !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", "", *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..55645508 --- /dev/null +++ b/validation/firmware/SpiFactory.cpp @@ -0,0 +1,278 @@ +#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 + +namespace validation +{ + namespace + { + 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{ { + 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 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) + : 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) + { + 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(); + 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; + } + + hal::SpiMaster* spi = &ConstructAsynchronous(index, request, claimed); + + if (claimed.chipSelect != nullptr) + 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) + { + 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..33fa7a56 --- /dev/null +++ b/validation/firmware/SpiFactory.hpp @@ -0,0 +1,96 @@ +#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 "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" +#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; + }; + + 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; + hal::DmaStm& dma; + std::optional transmitStream; + std::optional receiveStream; + std::variant driver; + std::optional chipSelect; + std::optional synchronousChipSelect; + std::optional tracked; + infra::TimerSingleShot quiesceTimer; + infra::AutoResetFunction onClosed; + }; +} 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..8d0c9e60 --- /dev/null +++ b/validation/firmware/UartFactory.cpp @@ -0,0 +1,313 @@ +#include "validation/firmware/UartFactory.hpp" +#include "BoardProfile.hpp" +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include +#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 UART_BRR_MIN and 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 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)) + 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; + + const auto defaults = DefaultPins(index, request.lpuart); + if (defaults && !request.tx && !request.rx && !request.rts && !request.cts) + { + request.tx = defaults->tx; + request.rx = defaults->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; + + if (IsTerminal(index, request.lpuart)) + return HilStatus::busy; + + 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, static_cast(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..394481ce --- /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::A, 1), 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::A, 1) }, + { "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(); + } +} 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..1ba007d2 --- /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: PA1 + tim2ch3: PA2 + tim2ch4: PA3 + tim16ch1: PA6 + tim17ch1: PB9 + qei1a: PA8 + qei1b: PA9 + qei2a: PA15 + qei2b: PA1 + 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. 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: >- + 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 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: + - >- + 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 (CN10-25; D6 = CN9-7) + 1: + pin: tim1ch1n + note: >- + TIM1 CH1N, SPI1 MOSI, 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) + 3: + pin: tim1ch2n + note: >- + TIM1 CH2N, GPIO (CN10-3; D15 = CN5-10) + 4: + pin: tim1ch3 + note: >- + TIM1 CH3, TIM17 break, GPIO (CN10-31 via SB11; D3 = CN9-4) + 5: + pin: tim1ch3n + note: >- + TIM1 CH3N, TIM17 CH1, GPIO (CN10-5; D14 = CN5-9) + 6: + pin: spi1clk + note: >- + SPI1 SCK, GPIO (CN10-11; D13 = CN5-6) + 7: + pin: spi1miso + 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 (CN10-17 via SB5; D10 = CN5-3 via SB41) + 9: + pin: qei2a + note: >- + TIM2 CH1, encoder TIM2 A, GPIO (CN10-27; D5 = CN9-6) + 10: + pin: qei2b + note: >- + TIM2 CH2, encoder TIM2 B, GPIO (CN7-32 via SB14; A2 = CN8-3) + 11: + pin: gpio0 + 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 (CN10-35 via SB15; D1 = CN9-2) + 13: + pin: lpuart1rx + note: >- + LPUART1 RX, TIM2 CH4, GPIO (CN10-37; D0 = CN9-1) + 14: + pin: lpuart1rts + note: >- + LPUART1 RTS, TIM1 break, GPIO (CN10-16) + 15: + pin: led0 + note: >- + green LED2 output, GPIO; LED2 and 680 ohm load the pin (CN10-22) + wavegen: + 1: + pin: ain4 + note: ADC1 IN4 (CN7-36; A4 = CN8-5) + 2: + pin: ain3 + note: ADC1 IN3 (CN7-38; A5 = CN8-6) + 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 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: + - >- + 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 (CN10-25; D6 = CN9-7) + 1: + pin: tim1ch1n + note: >- + TIM1 CH1N, SPI1 MOSI, 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) + 3: + pin: tim1ch2n + note: >- + TIM1 CH2N, GPIO (CN10-3; D15 = CN5-10) + 4: + pin: tim1ch3 + note: >- + TIM1 CH3, TIM17 break, GPIO (CN10-31 via SB11; D3 = CN9-4) + 5: + pin: tim1ch3n + note: >- + TIM1 CH3N, TIM17 CH1, GPIO (CN10-5; D14 = CN5-9) + 6: + pin: spi1clk + note: >- + SPI1 SCK, GPIO (CN10-11; D13 = CN5-6) + 7: + pin: spi1miso + 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 (CN10-17 via SB5; D10 = CN5-3 via SB41) + 9: + pin: lptim1in1 + note: >- + LPTIM1 IN1, encoder LPTIM1 A, ADC1 IN1 (CN7-28; A0 = CN8-1) + 10: + pin: lptim1in2 + note: >- + LPTIM1 IN2, encoder LPTIM1 B, ADC1 IN3 (CN7-38; A5 = CN8-6) + 11: + pin: gpio0 + 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 (CN10-35 via SB15; D1 = CN9-2) + 13: + pin: lpuart1rx + note: >- + LPUART1 RX, TIM2 CH4, GPIO (CN10-37; D0 = CN9-1) + 14: + pin: lpuart1rts + note: >- + LPUART1 RTS, TIM1 break, GPIO (CN10-16) + 15: + pin: led0 + note: >- + green LED2 output, GPIO; LED2 and 680 ohm load the pin (CN10-22) + wavegen: + 1: + pin: ain4 + note: ADC1 IN4 (CN7-36; A4 = CN8-5) + 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. +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 +# 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 (codes not checked). + 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..9f2149fa --- /dev/null +++ b/validation/host/boards/nucleo_wba55cg.yaml @@ -0,0 +1,574 @@ +--- +# 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); 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 + 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. +known_gaps: + - 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:62 - with sync=1 + and CTS held off SendData waits for TXE without a timeout and blocks the + event loop for good + 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 +# 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..96b1054f --- /dev/null +++ b/validation/host/hal_st_validation/expect.py @@ -0,0 +1,350 @@ +"""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). +""" + +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: + """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 counter ticks per period, and ARR within the + counter.""" + if frequency <= 0: + return False + return pwmclk // frequency >= 2 and pwm_auto_reload(pwmclk, frequency, mode) <= 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_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.""" + 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: + 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_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 pwmclk // frequency < 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..6a9f86f6 --- /dev/null +++ b/validation/host/hal_st_validation/fake_firmware.py @@ -0,0 +1,1199 @@ +"""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": "PA1", + "tim2ch3": "PA2", + "tim2ch4": "PA3", + "tim16ch1": "PA6", + "tim17ch1": "PB9", + "qei1a": "PA8", + "qei1b": "PA9", + "qei2a": "PA15", + "qei2b": "PA1", + "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", "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}, + **{"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: + """An EXTI line serves one port at a time.""" + owner = self.exti.get(int(pin[2:])) + 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",)) + 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..674ec4ed --- /dev/null +++ b/validation/host/tests/hil/test_pwm.py @@ -0,0 +1,400 @@ +"""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`). +""" + +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" + + +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(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 + 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..51ba5c04 --- /dev/null +++ b/validation/host/tests/hil/test_uart.py @@ -0,0 +1,419 @@ +"""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 drops bytes +(`UartStm` clears ORE and goes on), so a streaming failure 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 = "bytes missing: a receive overrun drops them" + + +@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}" + 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 + + +# 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 + 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..99c43d41 --- /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("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 + 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..2ccb3f6c --- /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, 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), + (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_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, 32_000_000)), + (WB55, "edge", 0xFFFFFFFF, (1, 32_000_000)), + (WBA55, "edge", 0xFFFF, (1526, 50_000_000)), + (WBA55, "center", 0xFFFF, (763, 50_000_000)), + (976, "edge", 0xFFFF, (1, 488)), + (976, "center", 0xFFFF, (1, 488)), + ], +) +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..2472fe24 --- /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_interrupt(): + """PH3 (BOOT0) is the only port H pin and is reserved, so the rule is checked on the model.""" + _, firmware = make_terminal() + assert 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..c404ca01 --- /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=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 + 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..f383a9fe --- /dev/null +++ b/validation/host/tests/unit/test_known_gaps.py @@ -0,0 +1,170 @@ +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.""" + for gap in load_board(name).known_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 + + +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") + sync = marks[CTS_HELD + "sync]"] + assert sync == [("skip", sync[0][1])] and sync[0][1].startswith("known gap (firmware aborts/hangs): ") + 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" + + +def test_run_known_gaps_turns_skips_into_expected_failures(tmp_path): + 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) 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"