diff --git a/.gitleaks.toml b/.gitleaks.toml index 02169f39..51876152 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -4,7 +4,11 @@ title = "secret scan config" useDefault = true [allowlist] -description = "Generated KiCad footprint cache — datasheet URLs, not secrets" +description = "Generated KiCad footprint cache (datasheet URLs) and the public NIST AES-128 test-vector keys of the validation suite, not secrets" paths = [ '''integration_test/pcb/integration_test_board/fp-info-cache''', ] +regexes = [ + '''000102030405060708090a0b0c0d0e0f''', + '''2b7e151628aed2a6abf7158809cf4f3c''', +] diff --git a/hal_st/stm32fxxx/AdcDmaStm.cpp b/hal_st/stm32fxxx/AdcDmaStm.cpp index 106f8640..4827e78d 100644 --- a/hal_st/stm32fxxx/AdcDmaStm.cpp +++ b/hal_st/stm32fxxx/AdcDmaStm.cpp @@ -1,5 +1,7 @@ #include "hal_st/stm32fxxx/AdcDmaStm.hpp" #include "infra/event/EventDispatcher.hpp" +#include "infra/util/ReallyAssert.hpp" +#include namespace hal { @@ -59,7 +61,14 @@ namespace hal timer.Start(); Configure(); +#if defined(STM32WB) || defined(STM32WBA) + const auto samples = std::min(numberOfSamples, buffer.size()); + really_assert(samples != 0); + measurement = infra::Head(buffer, samples); + dmaStream.StartReceive(measurement); +#else dmaStream.StartReceive(buffer); +#endif LL_ADC_REG_StartConversion(adc.Handle().Instance); } @@ -91,6 +100,13 @@ namespace hal void AdcTriggeredByTimerWithDma::TransferDone() { +#if defined(STM32WB) || defined(STM32WBA) + // External-trigger mode keeps ADSTART set, and ADC_Disable refuses while it is set + LL_ADC_REG_StopConversion(adc.Handle().Instance); + while (LL_ADC_REG_IsStopConversionOngoing(adc.Handle().Instance)) + { + } +#endif auto result = ADC_Disable(&adc.Handle()); assert(result == HAL_OK); @@ -99,7 +115,11 @@ namespace hal if (this->onDone) infra::EventDispatcher::Instance().Schedule([this]() { +#if defined(STM32WB) || defined(STM32WBA) + onDone(measurement); +#else onDone(buffer); +#endif }); } } diff --git a/hal_st/stm32fxxx/AdcDmaStm.hpp b/hal_st/stm32fxxx/AdcDmaStm.hpp index 08caaa29..4903b31f 100644 --- a/hal_st/stm32fxxx/AdcDmaStm.hpp +++ b/hal_st/stm32fxxx/AdcDmaStm.hpp @@ -32,6 +32,9 @@ namespace hal AnalogPinStm analogPin; FreeRunningTimerStm timer; infra::AutoResetFunction)> onDone; +#if defined(STM32WB) || defined(STM32WBA) + infra::MemoryRange measurement; +#endif }; } diff --git a/hal_st/stm32fxxx/BackupRamStm.cpp b/hal_st/stm32fxxx/BackupRamStm.cpp index f74b6901..3100239c 100644 --- a/hal_st/stm32fxxx/BackupRamStm.cpp +++ b/hal_st/stm32fxxx/BackupRamStm.cpp @@ -3,6 +3,14 @@ namespace hal { + BackupRamStm::BackupRamStm() + { +#if defined(STM32WBA) + __HAL_RCC_RTCAPB_CLK_ENABLE(); + HAL_PWR_EnableBkUpAccess(); +#endif + } + infra::MemoryRange BackupRamStm::Get() const { #if defined(STM32G0) diff --git a/hal_st/stm32fxxx/BackupRamStm.hpp b/hal_st/stm32fxxx/BackupRamStm.hpp index 64e6377d..0c53a1ae 100644 --- a/hal_st/stm32fxxx/BackupRamStm.hpp +++ b/hal_st/stm32fxxx/BackupRamStm.hpp @@ -7,9 +7,11 @@ namespace hal { class BackupRamStm - : hal::BackupRam + : public hal::BackupRam { public: + BackupRamStm(); + infra::MemoryRange Get() const override; }; } diff --git a/hal_st/stm32fxxx/DefaultClockNucleoWBA55CG.cpp b/hal_st/stm32fxxx/DefaultClockNucleoWBA55CG.cpp index e0ccfa8b..f41a62ff 100644 --- a/hal_st/stm32fxxx/DefaultClockNucleoWBA55CG.cpp +++ b/hal_st/stm32fxxx/DefaultClockNucleoWBA55CG.cpp @@ -16,6 +16,7 @@ * PLL_Q = 2 * PLL_R = 2 * Flash Latency(WS) = 3 + * RNG kernel clock = HSI */ void ConfigureDefaultClockNucleoWBA55CG() { @@ -52,4 +53,14 @@ void ConfigureDefaultClockNucleoWBA55CG() RCC_ClkInitStruct.AHB5_PLL1_CLKDivider = RCC_SYSCLK_PLL1_DIV4; RCC_ClkInitStruct.AHB5_HSEHSI_CLKDivider = RCC_SYSCLK_HSEHSI_DIV1; HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_3); + + LL_RCC_HSI_Enable(); + while (!LL_RCC_HSI_IsReady()) + { + } + + RCC_PeriphCLKInitTypeDef RCC_PeriphClkInitStruct = { 0 }; + RCC_PeriphClkInitStruct.PeriphClockSelection = RCC_PERIPHCLK_RNG; + RCC_PeriphClkInitStruct.RngClockSelection = RCC_RNGCLKSOURCE_HSI; + HAL_RCCEx_PeriphCLKConfig(&RCC_PeriphClkInitStruct); } diff --git a/hal_st/stm32fxxx/DmaStm.cpp b/hal_st/stm32fxxx/DmaStm.cpp index c8116951..e2c51411 100644 --- a/hal_st/stm32fxxx/DmaStm.cpp +++ b/hal_st/stm32fxxx/DmaStm.cpp @@ -531,6 +531,13 @@ namespace hal auto streamRegister = DmaChannel[dmaIndex][streamIndex]; #if defined(GPDMA1) +#if defined(STM32WBA) + const uint32_t log2 = dataSizeInBytes == 4 ? 2 : (dataSizeInBytes == 2 ? 1 : 0); + if (!(streamRegister->CTR2 & DMA_MEMORY_TO_MEMORY) && (streamRegister->CTR2 & DMA_MEMORY_TO_PERIPH)) + MODIFY_REG(streamRegister->CTR1, DMA_CTR1_DDW_LOG2, log2 << DMA_CTR1_DDW_LOG2_Pos); + else if (!(streamRegister->CTR2 & DMA_MEMORY_TO_MEMORY)) + MODIFY_REG(streamRegister->CTR1, DMA_CTR1_SDW_LOG2, log2 << DMA_CTR1_SDW_LOG2_Pos); +#else dataSizeInBytes &= 0x03; uint32_t mask{ 0 }; if (!(streamRegister->CTR2 & DMA_MEMORY_TO_MEMORY) && (streamRegister->CTR2 & DMA_MEMORY_TO_PERIPH)) @@ -539,8 +546,11 @@ namespace hal mask = (dataSizeInBytes >> 1) << DMA_CTR1_SDW_LOG2_Pos; streamRegister->CTR1 = (streamRegister->CTR1 & ~mask) | (streamRegister->CTR1 | mask); +#endif #elif defined(DMA_SxCR_PSIZE) streamRegister->CR = (streamRegister->CR & ~DMA_SxCR_PSIZE) | ((dataSizeInBytes & 1) == 0 ? DMA_SxCR_PSIZE_0 : 0) | (dataSizeInBytes > 2 ? DMA_SxCR_PSIZE_1 : 0); +#elif defined(STM32WB) + streamRegister->CCR = (streamRegister->CCR & ~DMA_CCR_PSIZE) | (dataSizeInBytes == 2 ? DMA_CCR_PSIZE_0 : 0) | (dataSizeInBytes == 4 ? DMA_CCR_PSIZE_1 : 0); #else streamRegister->CCR = (streamRegister->CCR & ~DMA_CCR_PSIZE) | ((dataSizeInBytes & 1) == 0 ? DMA_CCR_PSIZE_0 : 0) | (dataSizeInBytes > 2 ? DMA_CCR_PSIZE_1 : 0); #endif @@ -550,6 +560,13 @@ namespace hal { auto streamRegister = DmaChannel[dmaIndex][streamIndex]; #if defined(GPDMA1) +#if defined(STM32WBA) + const uint32_t log2 = dataSizeInBytes == 4 ? 2 : (dataSizeInBytes == 2 ? 1 : 0); + if (!(streamRegister->CTR2 & DMA_MEMORY_TO_MEMORY) && (streamRegister->CTR2 & DMA_MEMORY_TO_PERIPH)) + MODIFY_REG(streamRegister->CTR1, DMA_CTR1_SDW_LOG2, log2 << DMA_CTR1_SDW_LOG2_Pos); + else if (!(streamRegister->CTR2 & DMA_MEMORY_TO_MEMORY)) + MODIFY_REG(streamRegister->CTR1, DMA_CTR1_DDW_LOG2, log2 << DMA_CTR1_DDW_LOG2_Pos); +#else dataSizeInBytes &= 0x03; uint32_t mask{ 0 }; if (!(streamRegister->CTR2 & DMA_MEMORY_TO_MEMORY) && (streamRegister->CTR2 & DMA_MEMORY_TO_PERIPH)) @@ -558,8 +575,11 @@ namespace hal mask = (dataSizeInBytes >> 1) << DMA_CTR1_DDW_LOG2_Pos; streamRegister->CTR1 = (streamRegister->CTR1 & ~mask) | (streamRegister->CTR1 | mask); +#endif #elif defined(DMA_SxCR_MSIZE) streamRegister->CR = (streamRegister->CR & ~DMA_SxCR_MSIZE) | ((dataSizeInBytes & 1) == 0 ? DMA_SxCR_MSIZE_0 : 0) | (dataSizeInBytes > 2 ? DMA_SxCR_MSIZE_1 : 0); +#elif defined(STM32WB) + streamRegister->CCR = (streamRegister->CCR & ~DMA_CCR_MSIZE) | (dataSizeInBytes == 2 ? DMA_CCR_MSIZE_0 : 0) | (dataSizeInBytes == 4 ? DMA_CCR_MSIZE_1 : 0); #else streamRegister->CCR = (streamRegister->CCR & ~DMA_CCR_MSIZE) | ((dataSizeInBytes & 1) == 0 ? DMA_CCR_MSIZE_0 : 0) | (dataSizeInBytes > 2 ? DMA_CCR_MSIZE_1 : 0); #endif diff --git a/hal_st/stm32fxxx/FlashCoordinatedWithWirelessStack.cpp b/hal_st/stm32fxxx/FlashCoordinatedWithWirelessStack.cpp index 578bd02e..04a0964d 100644 --- a/hal_st/stm32fxxx/FlashCoordinatedWithWirelessStack.cpp +++ b/hal_st/stm32fxxx/FlashCoordinatedWithWirelessStack.cpp @@ -208,11 +208,23 @@ namespace hal { if (__HAL_FLASH_GET_FLAG(FLASH_FLAG_ECCD)) { - __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_ECCD); const uint32_t errorAddress = READ_BIT(FLASH->ECCR, FLASH_ECCR_ADDR_ECC); + __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_ECCD); + + if (pFlash.Lock == HAL_LOCKED || __HAL_FLASH_GET_FLAG(FLASH_FLAG_BSY)) + return; + + // An unlocked FLASH_CR belongs to an interrupted WriteBuffer/EraseSectors, which must find it still unlocked + const bool wasLocked = READ_BIT(FLASH->CR, FLASH_CR_LOCK) != 0; + if (wasLocked && HAL_FLASH_Unlock() != HAL_OK) + return; + uint32_t pageError = 0; - FLASH_EraseInitTypeDef eraseInitStruct{ .TypeErase = FLASH_TYPEERASE_PAGES, .Page = flash.SectorOfAddress(errorAddress), .NbPages = 1 }; + FLASH_EraseInitTypeDef eraseInitStruct{ .TypeErase = FLASH_TYPEERASE_PAGES, .Page = (errorAddress * 8U) / FLASH_PAGE_SIZE, .NbPages = 1 }; HAL_FLASHEx_Erase(&eraseInitStruct, &pageError); + + if (wasLocked) + HAL_FLASH_Lock(); } } diff --git a/hal_st/stm32fxxx/FlashInternalStm.cpp b/hal_st/stm32fxxx/FlashInternalStm.cpp index db113d00..54b82d32 100644 --- a/hal_st/stm32fxxx/FlashInternalStm.cpp +++ b/hal_st/stm32fxxx/FlashInternalStm.cpp @@ -89,7 +89,10 @@ namespace hal { HAL_FLASH_Unlock(); -#if defined(STM32WB) || defined(STM32G4) || defined(STM32G0) || defined(STM32WBA) +#if defined(STM32WB) || defined(STM32WBA) + const auto regionOffset = reinterpret_cast(flashMemory.begin()) - FLASH_BASE; + detail::ErasePages((regionOffset + AddressOfSector(beginIndex)) / FLASH_PAGE_SIZE, (regionOffset + AddressOfSector(endIndex)) / FLASH_PAGE_SIZE); +#elif defined(STM32G4) || defined(STM32G0) uint32_t pageError = 0; FLASH_EraseInitTypeDef eraseInitStruct; diff --git a/hal_st/stm32fxxx/FlashInternalStmDetail.hpp b/hal_st/stm32fxxx/FlashInternalStmDetail.hpp index f449ac54..1e6d11a8 100644 --- a/hal_st/stm32fxxx/FlashInternalStmDetail.hpp +++ b/hal_st/stm32fxxx/FlashInternalStmDetail.hpp @@ -2,7 +2,9 @@ #define HAL_FLASH_INTERNAL_STM_DETAIL_HPP #include DEVICE_HEADER +#include "infra/util/ReallyAssert.hpp" #include "services/flash/FlashAlign.hpp" +#include #include namespace hal @@ -53,6 +55,36 @@ namespace hal chunk = flashAlign.Next(); } } + +#if defined(STM32WB) || defined(STM32WBA) + inline void ErasePages(uint32_t page, uint32_t endPage) + { +#if defined(FLASH_DBANK_SUPPORT) + const uint32_t pagesPerBank = FLASH_PAGE_NB; + const bool swapped = READ_BIT(FLASH->OPTR, FLASH_OPTR_SWAP_BANK) != 0; +#endif + + while (page != endPage) + { + FLASH_EraseInitTypeDef eraseInitStruct{}; + eraseInitStruct.TypeErase = FLASH_TYPEERASE_PAGES; +#if defined(FLASH_DBANK_SUPPORT) + const uint32_t bankEndPage = std::min(endPage, (page / pagesPerBank + 1) * pagesPerBank); + eraseInitStruct.Banks = (page >= pagesPerBank) == swapped ? FLASH_BANK_1 : FLASH_BANK_2; + eraseInitStruct.Page = page % pagesPerBank; +#else + const uint32_t bankEndPage = endPage; + eraseInitStruct.Page = page; +#endif + eraseInitStruct.NbPages = bankEndPage - page; + + uint32_t pageError = 0; + auto result = HAL_FLASHEx_Erase(&eraseInitStruct, &pageError); + really_assert(result == HAL_OK); + page = bankEndPage; + } + } +#endif } } diff --git a/hal_st/stm32fxxx/GeneratePeripheralTableStructure.xsl b/hal_st/stm32fxxx/GeneratePeripheralTableStructure.xsl index 2bca896a..4074a11f 100644 --- a/hal_st/stm32fxxx/GeneratePeripheralTableStructure.xsl +++ b/hal_st/stm32fxxx/GeneratePeripheralTableStructure.xsl @@ -68,15 +68,15 @@ - + - + 1 - - + + diff --git a/hal_st/stm32fxxx/I2cStm.cpp b/hal_st/stm32fxxx/I2cStm.cpp index 49d3b6c1..ea531eb8 100644 --- a/hal_st/stm32fxxx/I2cStm.cpp +++ b/hal_st/stm32fxxx/I2cStm.cpp @@ -3,6 +3,12 @@ #include "infra/event/EventDispatcher.hpp" #include "infra/util/ReallyAssert.hpp" +#if defined(STM32WB) +#include "stm32wbxx_ll_i2c.h" +#elif defined(STM32WBA) +#include "stm32wbaxx_ll_i2c.h" +#endif + namespace hal { I2cStm::I2cStm(uint8_t oneBasedI2cIndex, hal::GpioPinStm& scl, hal::GpioPinStm& sda, const Config& config) @@ -36,6 +42,9 @@ namespace hal i2cHandle.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE; i2cHandle.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE; HAL_I2C_Init(&i2cHandle); +#if defined(STM32WB) || defined(STM32WBA) + LL_I2C_DisableOwnAddress1(i2cHandle.Instance); +#endif #if defined(STM32F427xx) || defined(STM32F437xx) || defined(STM32F429xx) || defined(STM32F439xx) || \ defined(STM32F401xC) || defined(STM32F401xE) || defined(STM32F411xE) || defined(STM32F446xx) || \ @@ -47,6 +56,9 @@ namespace hal I2cStm::~I2cStm() { +#if defined(STM32WB) || defined(STM32WBA) + HAL_I2C_DeInit(&i2cHandle); +#endif DisableClockI2c(instance); } @@ -62,6 +74,9 @@ namespace hal this->address = address; #endif sent = 0; +#if defined(STM32WB) || defined(STM32WBA) + received = 0; +#endif __DMB(); @@ -90,6 +105,9 @@ namespace hal this->address = address; #endif received = 0; +#if defined(STM32WB) || defined(STM32WBA) + sent = 0; +#endif __DMB(); @@ -129,6 +147,33 @@ namespace hal #if defined(I2C_ISR_TXE) if ((peripheralI2c[instance]->ISR & I2C_ISR_NACKF) != 0) { +#if defined(STM32WB) || defined(STM32WBA) + const bool txPending = (peripheralI2c[instance]->ISR & I2C_ISR_TXE) == 0; + peripheralI2c[instance]->CR1 &= ~(I2C_CR1_TXIE | I2C_CR1_RXIE | I2C_CR1_TCIE | I2C_CR1_NACKIE | I2C_CR1_ERRIE); + __HAL_I2C_CLEAR_FLAG(&i2cHandle, I2C_FLAG_AF); + __HAL_I2C_CLEAR_FLAG(&i2cHandle, I2C_FLAG_TXE); + continuingPrevious = false; + + infra::EventDispatcher::Instance().Schedule([this, txPending]() + { + const uint32_t shifted = (txPending && sent != 0) ? sent - 1 : sent; + if (onSent != nullptr) + { + if (shifted == 0) + DeviceNotFound(); + sendData.clear(); + onSent(hal::Result::partialComplete, shifted == 0 ? 0 : shifted - 1); + } + else if (onReceived != nullptr) + { + if (received == 0) + DeviceNotFound(); + receiveData.clear(); + onReceived(hal::Result::partialComplete); + } + }); + return; +#else peripheralI2c[instance]->CR1 &= ~(I2C_CR1_TXIE | I2C_CR1_RXIE | I2C_CR1_TCIE | I2C_CR1_NACKIE | I2C_CR1_ERRIE); infra::EventDispatcher::Instance().Schedule([this]() @@ -146,6 +191,7 @@ namespace hal onReceived(hal::Result::partialComplete); } }); +#endif } if ((peripheralI2c[instance]->ISR & I2C_ISR_TXIS) != 0) @@ -166,10 +212,25 @@ namespace hal if (nextAction == Action::stop) peripheralI2c[instance]->CR2 = I2C_CR2_STOP; continuingPrevious = nextAction == Action::continueSession; +#if defined(STM32WB) || defined(STM32WBA) + if (onSent != nullptr) + infra::EventDispatcher::Instance().Schedule([this]() + { + if (onSent != nullptr) + onSent(hal::Result::complete, sent); + }); + else + infra::EventDispatcher::Instance().Schedule([this]() + { + if (onReceived != nullptr) + onReceived(hal::Result::complete); + }); +#else infra::EventDispatcher::Instance().Schedule([this]() { onSent(hal::Result::complete, sent); }); +#endif } else if (!receiveData.empty()) peripheralI2c[instance]->CR2 = (std::min(receiveData.size(), 255) << 16) | (receiveData.size() > 255 || nextAction == Action::continueSession ? I2C_CR2_RELOAD : 0); @@ -186,6 +247,20 @@ namespace hal if (nextAction == Action::stop) peripheralI2c[instance]->CR2 = I2C_CR2_STOP; continuingPrevious = nextAction == Action::continueSession; +#if defined(STM32WB) || defined(STM32WBA) + if (onSent != nullptr) + infra::EventDispatcher::Instance().Schedule([this]() + { + if (onSent != nullptr) + onSent(hal::Result::complete, sent); + }); + else + infra::EventDispatcher::Instance().Schedule([this]() + { + if (onReceived != nullptr) + onReceived(hal::Result::complete); + }); +#else if (onSent != nullptr) infra::EventDispatcher::Instance().Schedule([this]() { @@ -196,6 +271,7 @@ namespace hal { onReceived(hal::Result::complete); }); +#endif } #else auto sr1 = peripheralI2c[instance]->SR1; @@ -309,6 +385,45 @@ namespace hal void I2cStm::ErrorInterrupt() { #if defined(I2C_ISR_BERR) +#if defined(STM32WB) || defined(STM32WBA) + const bool active = (peripheralI2c[instance]->CR1 & I2C_CR1_TCIE) != 0; + const bool busError = (peripheralI2c[instance]->ISR & I2C_ISR_BERR) != 0; + const bool arbitrationLost = (peripheralI2c[instance]->ISR & I2C_ISR_ARLO) != 0; + + if (busError) + peripheralI2c[instance]->ICR |= I2C_ISR_BERR; + + if (arbitrationLost) + peripheralI2c[instance]->ICR |= I2C_ISR_ARLO; + + really_assert( + (peripheralI2c[instance]->ISR & I2C_ISR_OVR) == 0 && (peripheralI2c[instance]->ISR & I2C_ISR_PECERR) == 0 && (peripheralI2c[instance]->ISR & I2C_ISR_TIMEOUT) == 0 && (peripheralI2c[instance]->ISR & I2C_ISR_ALERT) == 0); + + if (busError || arbitrationLost) + { + peripheralI2c[instance]->CR1 &= ~(I2C_CR1_TXIE | I2C_CR1_RXIE | I2C_CR1_TCIE | I2C_CR1_NACKIE | I2C_CR1_ERRIE); + __HAL_I2C_CLEAR_FLAG(&i2cHandle, I2C_FLAG_TXE); + continuingPrevious = false; + + __HAL_I2C_DISABLE(&i2cHandle); + // Each APB access lasts at least one APB clock, so PE stays low for >= 3 APB cycles + (void)peripheralI2c[instance]->CR1; + (void)peripheralI2c[instance]->CR1; + (void)peripheralI2c[instance]->CR1; + __HAL_I2C_ENABLE(&i2cHandle); + + if (busError) + BusError(); + + if (arbitrationLost) + ArbitrationLost(); + + if (active && onSent != nullptr) + onSent(hal::Result::busError, sent); + else if (active && onReceived != nullptr) + onReceived(hal::Result::busError); + } +#else if ((peripheralI2c[instance]->ISR & I2C_ISR_BERR) != 0) { peripheralI2c[instance]->ICR |= I2C_ISR_BERR; @@ -323,6 +438,7 @@ namespace hal really_assert( (peripheralI2c[instance]->ISR & I2C_ISR_OVR) == 0 && (peripheralI2c[instance]->ISR & I2C_ISR_PECERR) == 0 && (peripheralI2c[instance]->ISR & I2C_ISR_TIMEOUT) == 0 && (peripheralI2c[instance]->ISR & I2C_ISR_ALERT) == 0); +#endif #else if ((peripheralI2c[instance]->SR1 & I2C_SR1_AF) != 0) { diff --git a/hal_st/stm32fxxx/I2cStm.hpp b/hal_st/stm32fxxx/I2cStm.hpp index 726e640e..5f73ac28 100644 --- a/hal_st/stm32fxxx/I2cStm.hpp +++ b/hal_st/stm32fxxx/I2cStm.hpp @@ -2,8 +2,8 @@ #define HAL_I2C_STM_HPP #include DEVICE_HEADER -#include "hal/interfaces/I2c.hpp" #include "hal/cortex_m/InterruptCortex.hpp" +#include "hal/interfaces/I2c.hpp" #include "hal_st/stm32fxxx/GpioStm.hpp" #include "infra/util/AutoResetFunction.hpp" @@ -19,8 +19,12 @@ namespace hal {} #if defined(I2C_TIMINGR_PRESC) +#if defined(STM32WB) || defined(STM32WBA) + uint32_t timing = 0x70b03d3d; +#else uint32_t timing = 0x00304d4d; #endif +#endif #if defined(I2C_CCR_CCR) uint32_t clockSpeed = 400000; #endif @@ -52,7 +56,7 @@ namespace hal cortex::ImmediateInterruptHandler evInterruptHandler; cortex::DispatchedInterruptHandler erInterruptHandler; - I2C_HandleTypeDef i2cHandle; + I2C_HandleTypeDef i2cHandle{}; Action nextAction; #if defined(STM32F2) || defined(STM32F4) diff --git a/hal_st/stm32fxxx/PeripheralTableWbaxx.xml b/hal_st/stm32fxxx/PeripheralTableWbaxx.xml index e686e5a9..a89e8875 100644 --- a/hal_st/stm32fxxx/PeripheralTableWbaxx.xml +++ b/hal_st/stm32fxxx/PeripheralTableWbaxx.xml @@ -28,4 +28,8 @@ + + + + diff --git a/hal_st/stm32fxxx/QuadSpiStm.cpp b/hal_st/stm32fxxx/QuadSpiStm.cpp index f6bbc08e..0041d44f 100644 --- a/hal_st/stm32fxxx/QuadSpiStm.cpp +++ b/hal_st/stm32fxxx/QuadSpiStm.cpp @@ -19,7 +19,7 @@ namespace hal handle.Instance = QUADSPI; handle.Init.ClockPrescaler = config.prescaler; - handle.Init.FifoThreshold = 32; + handle.Init.FifoThreshold = (QUADSPI_CR_FTHRES >> QUADSPI_CR_FTHRES_Pos) + 1; handle.Init.SampleShifting = QSPI_SAMPLE_SHIFTING_HALFCYCLE; handle.Init.FlashSize = config.flashSizeLog2 - 1; handle.Init.ChipSelectHighTime = QSPI_CS_HIGH_TIME_2_CYCLE; diff --git a/hal_st/stm32fxxx/QuadSpiStmDma.cpp b/hal_st/stm32fxxx/QuadSpiStmDma.cpp index c5c53266..e44dd190 100644 --- a/hal_st/stm32fxxx/QuadSpiStmDma.cpp +++ b/hal_st/stm32fxxx/QuadSpiStmDma.cpp @@ -22,7 +22,11 @@ namespace hal { EnableClockQuadSpi(0); +#if defined(STM32WB) + QUADSPI->CR = (config.prescaler << 24) | QUADSPI_CR_APMS | QSPI_SAMPLE_SHIFTING_HALFCYCLE | QUADSPI_CR_EN; +#else QUADSPI->CR = (config.prescaler << 24) | QUADSPI_CR_APMS | QSPI_SAMPLE_SHIFTING_HALFCYCLE | (32 << 8) | QUADSPI_CR_EN; +#endif QUADSPI->DCR = ((config.flashSizeLog2 - 1) << 16) | QSPI_CS_HIGH_TIME_2_CYCLE; } @@ -154,6 +158,15 @@ namespace hal void QuadSpiStmDma::OnDmaTransferDone() { +#if defined(STM32WB) + // A write's DMA completes when the last byte enters the FIFO, not when it has left the pins + if (READ_BIT(QUADSPI->CCR, QUADSPI_CCR_FMODE) == 0) + { + MODIFY_REG(QUADSPI->CR, QUADSPI_CR_DMAEN, QUADSPI_CR_TCIE); + return; + } +#endif + QUADSPI->FCR |= QUADSPI_FCR_CTCF; QUADSPI->CR &= ~QUADSPI_CR_DMAEN; onDone(); diff --git a/hal_st/stm32fxxx/RandomDataGeneratorStm.cpp b/hal_st/stm32fxxx/RandomDataGeneratorStm.cpp index 758ec804..a00a5b6b 100644 --- a/hal_st/stm32fxxx/RandomDataGeneratorStm.cpp +++ b/hal_st/stm32fxxx/RandomDataGeneratorStm.cpp @@ -7,10 +7,12 @@ namespace hal { RandomDataGeneratorStm::RandomDataGeneratorStm() { -#if defined(STM32F7) - Register(RNG_IRQn); -#else +#if defined(STM32F415xx) || defined(STM32F417xx) || defined(STM32F427xx) || defined(STM32F429xx) || defined(STM32F437xx) || defined(STM32F439xx) || defined(STM32F469xx) || defined(STM32F479xx) Register(HASH_RNG_IRQn); +#elif defined(STM32G0) + Register(AES_RNG_IRQn); +#else + Register(RNG_IRQn); #endif __HAL_RCC_RNG_CLK_ENABLE(); RNG->CR |= RNG_CR_RNGEN; diff --git a/hal_st/stm32fxxx/RandomDataGeneratorStm.hpp b/hal_st/stm32fxxx/RandomDataGeneratorStm.hpp index 89ad4666..78d72c59 100644 --- a/hal_st/stm32fxxx/RandomDataGeneratorStm.hpp +++ b/hal_st/stm32fxxx/RandomDataGeneratorStm.hpp @@ -1,8 +1,9 @@ #ifndef HAL_RANDOM_DATA_GENERATOR_STM_HPP #define HAL_RANDOM_DATA_GENERATOR_STM_HPP -#include "hal/interfaces/RandomDataGenerator.hpp" +#include "generated/stm32fxxx/PeripheralTable.hpp" #include "hal/cortex_m/InterruptCortex.hpp" +#include "hal/interfaces/RandomDataGenerator.hpp" #if defined(HAS_PERIPHERAL_RNG) diff --git a/hal_st/stm32fxxx/SpiMasterStmDma.cpp b/hal_st/stm32fxxx/SpiMasterStmDma.cpp index 2b0c1312..070dcc73 100644 --- a/hal_st/stm32fxxx/SpiMasterStmDma.cpp +++ b/hal_st/stm32fxxx/SpiMasterStmDma.cpp @@ -169,7 +169,7 @@ namespace hal assert(dataSizeInBits >= 4 && dataSizeInBits <= 16); peripheralSpi[spiInstance]->CR2 = (peripheralSpi[spiInstance]->CR2 & ~SPI_CR2_DS) | ((dataSizeInBits - 1) << POSITION_VAL(SPI_CR2_DS)) | (dataSizeInBits <= 8 ? SPI_CR2_FRXTH : 0); #elif defined(SPI_CFG1_DSIZE) - assert(dataSizeInBits >= 4 || dataSizeInBits <= 32); + assert(dataSizeInBits >= 4 && dataSizeInBits <= 32); peripheralSpi[spiInstance]->CFG1 = (peripheralSpi[spiInstance]->CFG1 & ~SPI_CFG1_DSIZE) | ((dataSizeInBits - 1) << POSITION_VAL(SPI_CFG1_DSIZE)); #else assert(dataSizeInBits == 8 || dataSizeInBits == 16); diff --git a/hal_st/stm32fxxx/SpiSlaveStmDma.cpp b/hal_st/stm32fxxx/SpiSlaveStmDma.cpp index 6bbaf579..b5f988f5 100644 --- a/hal_st/stm32fxxx/SpiSlaveStmDma.cpp +++ b/hal_st/stm32fxxx/SpiSlaveStmDma.cpp @@ -2,6 +2,12 @@ #include "generated/stm32fxxx/PeripheralTable.hpp" #include "infra/util/BitLogic.hpp" +#if defined(STM32WB) +#include "stm32wbxx_ll_spi.h" +#elif defined(STM32WBA) +#include "stm32wbaxx_ll_spi.h" +#endif + namespace hal { SpiSlaveStmDma::SpiSlaveStmDma(hal::DmaStm::TransmitStream& transmitStream, hal::DmaStm::ReceiveStream& receiveStream, uint8_t oneBasedSpiIndex, GpioPinStm& clock, GpioPinStm& miso, GpioPinStm& mosi, GpioPinStm& slaveSelect) @@ -68,9 +74,15 @@ namespace hal receiveDone = false; sendDone = false; -#ifdef STM32H5 +#if defined(STM32H5) || defined(STM32WB) || defined(STM32WBA) DisableSpi(); #endif +#if defined(STM32WB) + // SPI v2 keeps RXFIFO data while SPE=0, so frames clocked after the previous transfer would lead this one + while (LL_SPI_GetRxFIFOLevel(peripheralSpi[spiInstance]) != LL_SPI_RX_FIFO_EMPTY) + LL_SPI_ReceiveData8(peripheralSpi[spiInstance]); + LL_SPI_ClearFlag_OVR(peripheralSpi[spiInstance]); +#endif if (!sendData.empty() && !receiveData.empty()) { @@ -91,7 +103,7 @@ namespace hal else std::abort(); -#ifdef STM32H5 +#if defined(STM32H5) || defined(STM32WB) || defined(STM32WBA) EnableSpi(); #endif } @@ -114,7 +126,7 @@ namespace hal void SpiSlaveStmDma::TransferDone() { -#ifdef STM32H5 +#if defined(STM32H5) || defined(STM32WB) || defined(STM32WBA) DisableSpi(); #endif onDone(); @@ -165,7 +177,11 @@ namespace hal void SpiSlaveStmDma::DisableSpi() { +#if defined(STM32WB) || defined(STM32WBA) + LL_SPI_Disable(peripheralSpi[spiInstance]); +#else peripheralSpi[spiInstance]->CR1 |= SPI_CR1_SPE; +#endif } void SpiSlaveStmDma::EnableDma() diff --git a/hal_st/stm32fxxx/TimerPwmStm.cpp b/hal_st/stm32fxxx/TimerPwmStm.cpp index fe170d76..4fbf64aa 100644 --- a/hal_st/stm32fxxx/TimerPwmStm.cpp +++ b/hal_st/stm32fxxx/TimerPwmStm.cpp @@ -65,7 +65,16 @@ namespace hal { TimerPwmBaseStm::TimerPwmBaseStm(uint8_t oneBasedIndex, TimerBaseStm::Timing timing) : timer(oneBasedIndex, timing, { TimerBaseStm::CounterMode::up }) - {} + { + // Neither the HAL de-inits nor gating the clock reset BDTR, so a break input left + // enabled by an earlier user would keep MOE cleared. + if (IS_TIM_BREAK_INSTANCE(timer.Handle().Instance)) + { + TIM_BreakDeadTimeConfigTypeDef breakDeadTime{}; + auto result = HAL_TIMEx_ConfigBreakDeadTime(&timer.Handle(), &breakDeadTime); + really_assert(result == HAL_OK); + } + } PwmChannelGpio& TimerPwmBaseStm::Channel(uint8_t channelOneBasedIndex) { diff --git a/hal_st/stm32fxxx/TimerStm.cpp b/hal_st/stm32fxxx/TimerStm.cpp index 55632abc..892d3814 100644 --- a/hal_st/stm32fxxx/TimerStm.cpp +++ b/hal_st/stm32fxxx/TimerStm.cpp @@ -166,7 +166,7 @@ namespace hal handle.Instance = peripheralTimer[timerIndex]; handle.Init.Prescaler = timing.prescaler; handle.Init.Period = timing.period; - handle.Init.CounterMode = infra::enum_cast(config.counterMode); + handle.Init.CounterMode = TIM_COUNTERMODE_UP; handle.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; handle.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; HAL_TIM_Base_Init(&handle); @@ -174,6 +174,12 @@ namespace hal clockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; HAL_TIM_ConfigClockSource(&handle, &clockSourceConfig); + // CR1.DIR ignores writes while centre-aligned or in encoder mode, either of which an + // earlier user may have left, so the direction is only written once the two calls + // above have cleared CMS and SMS. + handle.Init.CounterMode = infra::enum_cast(config.counterMode); + HAL_TIM_Base_Init(&handle); + if (config.trigger) ConfigureTrigger(); } @@ -227,6 +233,9 @@ namespace hal { this->onIrq = onIrq; this->type = type; + // Some HAL versions leave UIF set from the UG of HAL_TIM_Base_Init, which would fire + // a callback as soon as the interrupt is enabled. + __HAL_TIM_CLEAR_FLAG(&handle, TIM_FLAG_UPDATE); auto result = HAL_TIM_Base_Start_IT(&handle); assert(result == HAL_OK); } diff --git a/hal_st/synchronous_stm32fxxx/SynchronousFlashInternalStm.cpp b/hal_st/synchronous_stm32fxxx/SynchronousFlashInternalStm.cpp index 4b2adadf..11297787 100644 --- a/hal_st/synchronous_stm32fxxx/SynchronousFlashInternalStm.cpp +++ b/hal_st/synchronous_stm32fxxx/SynchronousFlashInternalStm.cpp @@ -14,7 +14,7 @@ namespace #endif }; -#if defined(FLASH_DBANK_SUPPORT) +#if defined(STM32G0) && defined(FLASH_DBANK_SUPPORT) uint32_t GetBank(uint32_t sectorIndex) { return sectorIndex < FLASH_PAGE_NB ? FLASH_BANK_1 : FLASH_BANK_2; @@ -72,7 +72,10 @@ namespace hal { HAL_FLASH_Unlock(); -#if defined(STM32WB) || defined(STM32G4) || defined(STM32G0) || defined(STM32WBA) +#if defined(STM32WB) || defined(STM32WBA) + const auto regionOffset = reinterpret_cast(flashMemory.begin()) - FLASH_BASE; + detail::ErasePages((regionOffset + AddressOfSector(beginIndex)) / FLASH_PAGE_SIZE, (regionOffset + AddressOfSector(endIndex)) / FLASH_PAGE_SIZE); +#elif defined(STM32G4) || defined(STM32G0) auto erase = [](uint32_t beginIndex, uint32_t nbPages, uint32_t bank) { diff --git a/hal_st/synchronous_stm32fxxx/SynchronousGpioStm.cpp b/hal_st/synchronous_stm32fxxx/SynchronousGpioStm.cpp index 8d45bb9a..6d35dac0 100644 --- a/hal_st/synchronous_stm32fxxx/SynchronousGpioStm.cpp +++ b/hal_st/synchronous_stm32fxxx/SynchronousGpioStm.cpp @@ -1,4 +1,5 @@ #include "hal_st/synchronous_stm32fxxx/SynchronousGpioStm.hpp" +#include "infra/util/ReallyAssert.hpp" namespace hal { @@ -10,21 +11,33 @@ namespace hal GPIOC, #if defined(GPIOD) GPIOD, +#elif defined(STM32WB) || defined(STM32WBA) + nullptr, #endif #if defined(GPIOE) GPIOE, +#elif defined(STM32WB) || defined(STM32WBA) + nullptr, #endif #if defined(GPIOF) GPIOF, +#elif defined(STM32WB) || defined(STM32WBA) + nullptr, #endif #if defined(GPIOG) GPIOG, +#elif defined(STM32WB) || defined(STM32WBA) + nullptr, #endif #if defined(GPIOH) GPIOH, +#elif defined(STM32WB) || defined(STM32WBA) + nullptr, #endif #if defined(GPIOI) GPIOI, +#elif defined(STM32WB) || defined(STM32WBA) + nullptr, #endif #if defined(GPIOJ) GPIOJ, @@ -83,6 +96,9 @@ namespace hal : port(port) , index(index) { +#if defined(STM32WB) || defined(STM32WBA) + really_assert(portToGPIOPort[static_cast(port)] != nullptr); +#endif GPIO_InitTypeDef GPIO_InitStructure = {}; GPIO_InitStructure.Pin = pinToGPIOPin[index]; GPIO_InitStructure.Mode = driveToOutputMode[static_cast(drive)]; @@ -118,6 +134,9 @@ namespace hal void SmallPeripheralPinStm::ConfigPeripheralPin(uint8_t af, Drive drive, Speed speed, WeakPull weakPull) { +#if defined(STM32WB) || defined(STM32WBA) + really_assert(portToGPIOPort[static_cast(port)] != nullptr); +#endif GPIO_InitTypeDef GPIO_InitStructure = {}; GPIO_InitStructure.Pin = pinToGPIOPin[index]; GPIO_InitStructure.Mode = driveToAFMode[static_cast(drive)]; diff --git a/hal_st/synchronous_stm32fxxx/SynchronousHardwareSemaphoreStm.cpp b/hal_st/synchronous_stm32fxxx/SynchronousHardwareSemaphoreStm.cpp index d0e4212a..d8d71d41 100644 --- a/hal_st/synchronous_stm32fxxx/SynchronousHardwareSemaphoreStm.cpp +++ b/hal_st/synchronous_stm32fxxx/SynchronousHardwareSemaphoreStm.cpp @@ -1,5 +1,6 @@ #include "hal_st/synchronous_stm32fxxx/SynchronousHardwareSemaphoreStm.hpp" #include "stm32wbxx.h" +#include "stm32wbxx_ll_hsem.h" namespace hal { @@ -15,23 +16,19 @@ namespace hal void SynchronousHardwareSemaphoreMasterStm::WaitLock(hal::Semaphore semaphore) const { - HSEM->C1IER |= 1 << static_cast(semaphore); - - while (!IsLockedByCurrentCore(semaphore)) - {} + while (HAL_HSEM_FastTake(static_cast(semaphore)) != HAL_OK) + { + } } void SynchronousHardwareSemaphoreMasterStm::Release(hal::Semaphore semaphore) const { - uint32_t mask = 1 << static_cast(semaphore); - HSEM->C1ICR = mask; - HSEM->R[static_cast(semaphore)] = HSEM_CR_COREID_CURRENT; - HSEM->C1IER &= ~mask; + HAL_HSEM_Release(static_cast(semaphore), 0); } bool SynchronousHardwareSemaphoreMasterStm::IsLockedByCurrentCore(hal::Semaphore semaphore) const { - return HSEM->RLR[static_cast(semaphore)] == HSEM_R_LOCK | HSEM_CR_COREID_CURRENT; + return LL_HSEM_IsSemaphoreLocked(HSEM, static_cast(semaphore)) != 0 && LL_HSEM_GetCoreId(HSEM, static_cast(semaphore)) == LL_HSEM_COREID; } SynchronousHardwareSemaphoreStm::SynchronousHardwareSemaphoreStm(SynchronousHardwareSemaphoreMasterStm& synchronousHardwareSemaphoreMaster, Semaphore semaphore) diff --git a/hal_st/synchronous_stm32fxxx/SynchronousSynchronizedRandomDataGeneratorStm.cpp b/hal_st/synchronous_stm32fxxx/SynchronousSynchronizedRandomDataGeneratorStm.cpp index 21bf5e0d..ab52ddc6 100644 --- a/hal_st/synchronous_stm32fxxx/SynchronousSynchronizedRandomDataGeneratorStm.cpp +++ b/hal_st/synchronous_stm32fxxx/SynchronousSynchronizedRandomDataGeneratorStm.cpp @@ -28,11 +28,12 @@ namespace hal // HSI48 should already be enabled when the clock configuration is locked. Don't manage its lifecycle. really_assert(LL_RCC_HSI48_IsReady()); } - else + else if (!LL_RCC_HSI48_IsReady()) { LL_RCC_HSI48_Enable(); while (!LL_RCC_HSI48_IsReady()) - {} + { + } disableHsi48OnFinalization = true; } diff --git a/validation/PROTOCOL.md b/validation/PROTOCOL.md index 99435e0f..3c6422fc 100644 --- a/validation/PROTOCOL.md +++ b/validation/PROTOCOL.md @@ -11,34 +11,46 @@ The terminal is a `hal::UartStmDuplexDma` on USART1, the ST-LINK virtual COM por Aliases name the pins by peripheral function: -| Alias | NUCLEO-WB55RG | NUCLEO-WBA55CG | Function | -|-----------------------------------------------|-------------------------|-------------------------|------------------------------------------------------| -| `terminaltx` `terminalrx` | PB6 PB7 | PB12 PA8 | terminal USART1 TX / RX | -| `ain1` `ain2` `ain3` `ain4` `ain5` `ain6` | PC0 PC1 PC2 PC3 PA0 PA1 | - | ADC1 inputs IN1-IN6 | -| `ain2` `ain3` `ain4` `ain7` `ain8` `ain9` | - | PA7 PA6 PA5 PA2 PA1 PA0 | ADC4 inputs | -| `ain10` | - | PB9 | ADC4 input IN10 | -| `tim1ch1` `tim1ch2` `tim1ch3` `tim1ch4` | PA8 PA9 PA10 PA11 | PA11 PA12 PB4 PB3 | TIM1 channels 1-4 | -| `tim1ch1n` `tim1ch2n` `tim1ch3n` | PA7 PB8 PB9 | PB2 PB1 PB0 | TIM1 complementary channels 1N-3N | -| `tim1bkin` | PB12 | PA2 | TIM1 break input | -| `tim2ch1` `tim2ch2` `tim2ch3` `tim2ch4` | PA15 PA1 PA2 PA3 | - | TIM2 channels 1-4 (32-bit counter) | -| `tim2ch1` `tim2ch3` `tim2ch4` | - | PA5 PA7 PA6 | TIM2 channels 1, 3, 4 (CH2 is the terminal RX pin) | -| `tim3ch1` `tim3ch2` `tim3ch3` `tim3ch4` | - | PA10 PA1 PB14 PB9 | TIM3 channels 1-4 | -| `tim16ch1` `tim17ch1` | PA6 PB9 | PB9 PA1 | TIM16 / TIM17 channel 1 | -| `tim17ch1n` | - | PB3 | TIM17 complementary channel 1N | -| `qei1a` `qei1b` | PA8 PA9 | PA11 PA12 | TIM1 encoder phase A / B | -| `qei1idx` | - | PA15 | index input of the default encoder | -| `qei2a` `qei2b` `qei2idx` | PA15 PA1 PC6 | - | TIM2 encoder phase A / B, index input | -| `qei3a` `qei3b` | - | PA10 PA1 | TIM3 encoder phase A / B | -| `lptim1in1` `lptim1in2` | PC0 PC2 | - | LPTIM1 encoder inputs | -| `spi1clk` `spi1miso` `spi1mosi` `spi1cs` | PA5 PA6 PA7 PA4 | PB4 PB3 PA15 PA12 | SPI1 (Arduino D13/D12/D11/D10) | -| `lpuart1tx` `lpuart1rx` | PA2 PA3 | PB5 PA10 | LPUART1 TX / RX (Arduino D1/D0) | -| `lpuart1rts` `lpuart1cts` | PB12 PA6 | PB9 PB15 | LPUART1 RTS / CTS | -| `usart2tx` `usart2rx` `usart2rts` `usart2cts` | - | PB0 PA11 PB1 PB2 | USART2 | -| `led0` `led1` | PB0 PB1 | PB4 PA9 | user LEDs (green LD2, red LD3 / blue LD1, green LD2) | -| `gpio0` | PC6 | PB14 | general-purpose test pin, watchdog warning toggle | -| `gpio1` `gpio2` | PC10 PC12 | PA5 PA0 | general-purpose test pins | -| `gpio3` `gpio4` | PC13 PE4 | - | general-purpose test pins | -| `sw1` `sw2` `sw3` | PC4 PD0 PD1 | PC13 PB6 PB7 | user buttons (input only, pulled up) | +| Alias | NUCLEO-WB55RG | NUCLEO-WBA55CG | Function | +|-----------------------------------------------|-------------------------|-------------------------|------------------------------------------------------------------| +| `terminaltx` `terminalrx` | PB6 PB7 | PB12 PA8 | terminal USART1 TX / RX | +| `ain1` `ain2` `ain3` `ain4` `ain5` `ain6` | PC0 PC1 PC2 PC3 PA0 PA1 | - | ADC1 inputs IN1-IN6 | +| `ain2` `ain3` `ain4` `ain7` `ain8` `ain9` | - | PA7 PA6 PA5 PA2 PA1 PA0 | ADC4 inputs | +| `ain10` | - | PB9 | ADC4 input IN10 | +| `tim1ch1` `tim1ch2` `tim1ch3` `tim1ch4` | PA8 PA9 PA10 PA11 | PA11 PA12 PB4 PB3 | TIM1 channels 1-4 | +| `tim1ch1n` `tim1ch2n` `tim1ch3n` | PA7 PB8 PB9 | PB2 PB1 PB0 | TIM1 complementary channels 1N-3N | +| `tim1bkin` | PB12 | PA2 | TIM1 break input | +| `tim2ch1` `tim2ch2` `tim2ch3` `tim2ch4` | PA15 PA1 PA2 PA3 | - | TIM2 channels 1-4 (32-bit counter) | +| `tim2ch1` `tim2ch3` `tim2ch4` | - | PA5 PA7 PA6 | TIM2 channels 1, 3, 4 (CH2 is the terminal RX pin) | +| `tim3ch1` `tim3ch2` `tim3ch3` `tim3ch4` | - | PA10 PA1 PB14 PB9 | TIM3 channels 1-4 | +| `tim16ch1` `tim17ch1` | PA6 PB9 | PB9 PA1 | TIM16 / TIM17 channel 1 | +| `tim16ch1n` | - | PB8 | TIM16 complementary channel 1N (red LD3 on the pin) | +| `tim17ch1n` | - | PB3 | TIM17 complementary channel 1N | +| `qei1a` `qei1b` | PA8 PA9 | PA11 PA12 | TIM1 encoder phase A / B | +| `qei1idx` | - | PA15 | index input of the default encoder | +| `qei2a` `qei2b` `qei2idx` | PA15 PA1 PC6 | - | TIM2 encoder phase A / B, index input | +| `qei3a` `qei3b` | - | PA10 PA1 | TIM3 encoder phase A / B | +| `lptim1in1` `lptim1in2` | PC0 PC2 | PA0 PB3 | LPTIM1 encoder inputs | +| `lptim2in1` `lptim2in2` | - | PB9 PB0 | LPTIM2 encoder inputs | +| `lptim1ch2` | - | PA15 | LPTIM1 channel 2 (CH1, PB11, is not bonded out) | +| `lptim2ch1` `lptim2ch2` | - | PA11 PA1 | LPTIM2 channels 1-2 | +| `spi1clk` `spi1miso` `spi1mosi` `spi1cs` | PA5 PA6 PA7 PA4 | PB4 PB3 PA15 PA12 | SPI1 (Arduino D13/D12/D11/D10) | +| `spi1nss` | PA4 | PA12 | SPI1 hardware NSS (the pin of `spi1cs`) | +| `spi2clk` `spi2miso` `spi2mosi` `spi2nss` | PB13 PB14 PB15 PB12 | - | SPI2 | +| `spi3clk` `spi3miso` `spi3mosi` `spi3nss` | - | PA0 PB9 PB8 PA5 | SPI3 (8- and 16-bit frames only) | +| `i2c1scl` `i2c1sda` | PB8 PB9 | PB2 PB1 | I2C1 SCL / SDA (Arduino D15/D14) | +| `i2c3scl` `i2c3sda` | PC0 PC1 | PA6 PA7 | I2C3 SCL / SDA | +| `qspiclk` `qspincs` | PA3 PA2 | - | QUADSPI clock / chip select | +| `qspiio0` `qspiio1` `qspiio2` `qspiio3` | PB9 PB8 PA7 PA6 | - | QUADSPI IO0-IO3 | +| `lpuart1tx` `lpuart1rx` | PA2 PA3 | PB5 PA10 | LPUART1 TX / RX (Arduino D1/D0) | +| `lpuart1rts` `lpuart1cts` | PB12 PA6 | PB9 PB15 | LPUART1 RTS / CTS | +| `usart2tx` `usart2rx` `usart2rts` `usart2cts` | - | PB0 PA11 PB1 PB2 | USART2 | +| `mco` | PA8 | - | clock output MCO (`clock.mco`) | +| `led0` `led1` | PB0 PB1 | PB4 PA9 | user LEDs (green LD2, red LD3 / blue LD1, green LD2 = debug LED) | +| `gpio0` | PC6 | PB14 | general-purpose test pin, watchdog warning toggle | +| `gpio1` `gpio2` | PC10 PC12 | PA5 PA0 | general-purpose test pins | +| `gpio3` `gpio4` | PC13 PE4 | - | general-purpose test pins | +| `sw1` `sw2` `sw3` | PC4 PD0 PD1 | PC13 PB6 PB7 | user buttons (input only, pulled up) | - Wherever a pin is expected, an alias from this table may be used instead of `P`; several aliases may name the same pin, and no alias carries a default pull. - Per-instance default pins apply only when a command gets no pins at all: LPUART1 `lpuart1tx`/`lpuart1rx`; the default encoder (WB55 TIM2 `qei2a`/`qei2b`/`qei2idx`, WBA55 TIM1 `qei1a`/`qei1b`/`qei1idx`). Other UART and encoder instances need their pins; SPI and the ADC always do, PWM needs `channels` or `pins`. @@ -50,14 +62,22 @@ Aliases name the pins by peripheral function: The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix, number, hex and list syntax, open/close semantics) is specified in EMIL's [hardware-in-the-loop terminal documentation](https://github.com/embedded-pro/embedded-infra-lib/blob/main/docs/Hil.md). hal-st adds: - After reset the firmware prints `EVT boot board= family= sysclk= reset=` once; `` is `iwdg`, `wwdg`, `sw`, `lpwr`, `obl`, `bor`, `pin` or `unknown` (the RCC reset flags in that priority order; a reset through NRST also sets `pin`, so `pin` is checked last). -- Pins are written as `P`, for example `PA15`, `PB3`, `PH3`: ports A-E and H on STM32WB55 and A-C and H on STM32WBA55, index 0-15; a pin the package does not bond out returns `ERR pin`. -- Instance numbers are the STM32 peripheral numbers: USART 1-2 and LPUART 1 (selected with `lp=1`), SPI 1-3, TIM 1-17 (PWM and encoder) and LPTIM 1 (encoder, `lp=1`), ADC 1 (WB55) or 4 (WBA55), watchdog 0 (the WWDG). A number the running MCU lacks, including 0 where the peripherals start at 1, returns `ERR range`. -- The terminal UART and its pins, the debug LED (WB55 PB5, the blue LD1; WBA55 PB8, the red LD3; blinking while the firmware runs), the SWD pins PA13/PA14, the LSE crystal pins PC14/PC15 and BOOT0 (PH3) - are reserved and cannot be opened (`ERR busy`); any other pin, aliased or not, can be reconfigured freely. A pin held by another open instance returns `ERR busy`; a pin the hal-st pinout table does - not offer for the requested function and instance returns `ERR pin`. -- A timer serves one group at a time: a timer held by an open PWM, encoder or timer-triggered ADC returns `ERR busy` to the other groups. -- RAM limits how many instances are open at the same time: 1 PWM timer, 1 UART besides the terminal, 1 SPI, 1 ADC, 1 encoder, 1 watchdog and 8 GPIO pins; one more returns `ERR busy`. -- Argument errors (`usage`, `range`, `pin`, `unsupported`) are reported before `ERR busy`. +- Pins are written as `P`, for example `PA15`, `PB3`, `PH3`: ports A-E and H on STM32WB55 and A-C and H on STM32WBA55, index 0-15; a pin the package does not bond out returns `ERR pin` (on the STM32WBA55 UFQFPN48 also PA3, PB10, PB11 and PB13, its SMPS and VDD11 pads). +- Instance numbers are the STM32 peripheral numbers: USART 1-2 and LPUART 1 (selected with `lp=1`), I2C 1-3, SPI 1-3, TIM 1-17 (PWM, encoder, timer and timer PWM), LPTIM 1-2 (low-power timer, LPTIM PWM, and the encoder with `lp=1`), QUADSPI 1, ADC 1 (WB55) or 4 (WBA55), watchdog 0 (the WWDG). + A number the running MCU lacks, including 0 where the peripherals start at 1, returns `ERR range`: both MCUs have I2C1 and I2C3 (not I2C2) and LPTIM1 and LPTIM2; the encoder with `lp=1` takes LPTIM1 on the WB55 and LPTIM1 or LPTIM2 on the WBA55; QUADSPI exists on the WB55 only (the `qspi` commands are unsupported on the WBA55, see below). +- The terminal UART and its pins, the debug LED, the SWD pins PA13/PA14, the LSE crystal pins PC14/PC15 and BOOT0 (PH3) are reserved and cannot be opened (`ERR busy`); any other pin, aliased or not, can be reconfigured freely. + The debug LED blinks while the firmware runs: WB55 PB5, the blue LD1; WBA55 PA9, the green LD2 (alias `led1`), which is not connected on a stock NUCLEO-WBA55CG (SB28 open), so it stays dark unless SB28 is closed. + A pin held by another open instance returns `ERR busy`; a pin the hal-st pinout table does not offer for the requested function and instance returns `ERR pin`. +- A timer serves one group at a time: a timer held by an open PWM, encoder, timer-triggered ADC, timer (`tim`) or timer PWM (`tpwm`), by a running `ain.burst` or `dma.wave` (both TIM2), or by the scaffold timer TIM17 while `lpm.enter` or `hsem.lock hold=` uses it, returns `ERR busy` to the other groups. +- One peripheral instance serves one group at a time: an I2C instance is shared by `i2c`, `i2cs` and `eeprom`, an SPI instance by `spi` and `spis`, the ADC by `adc` and `ain`, an LPTIM by `qei` (`lp=1`), `lptim` and `lptpwm`, and the HSEM interrupt (claimed as HSEM 0, the semaphore the RNG driver waits on) by `flash ... variant=coord`, `hsem.lock` and `rng ... variant=hsem` (WB55). + So are the DMA channels some groups share: WB55 DMA1 channel 7 (`adc`, `ain.burst`); WBA55 GPDMA1 channel 7 (`adc`, `ain.burst`, `spis` receive) and channel 8 (`spis` transmit, `dma.wave`). An instance or channel another group holds returns `ERR busy`. +- RAM limits how many instances are open at the same time: 1 PWM timer, 1 UART besides the terminal, 1 SPI master, 1 SPI slave, 1 I2C master, 1 I2C target, 1 attached EEPROM, 1 ADC, 1 encoder, 1 timer (`tim`), 1 timer PWM (`tpwm`), 1 LPTIM, 1 LPTIM PWM, 1 QUADSPI, 1 watchdog and 8 GPIO pins; one more returns `ERR busy`. +- Argument errors (`usage`, `range`, `pin`, `unsupported`) are reported before `ERR busy`, except for the arguments a command parses into a buffer an operation in flight still uses: the payload of `flash.write`, the payload and `rx` of `spis.arm`, the data arguments of `qspi.cmd` and `qspi.xfer` (see their sections). +- Line length: a command line holds at most 255 characters (`terminal.max_command_length` in the board files). Commands that move more data than fits take a payload the firmware generates and can answer with a CRC instead of the data: + - in place of a hex payload, `-` with `len=` (1 up to the command's capacity, `ERR range` above) `[pattern=inc|const|prbs]` (default `inc`) `[seed=<0..0xFFFFFFFF>]` (default 0); `len` with a hex payload, or `pattern`/`seed` without `len`, returns `ERR usage` + - `inc`: byte i is (seed + i) & 0xFF; `const`: every byte is seed & 0xFF; `prbs`: per byte, a 32-bit xorshift `x ^= x << 13; x ^= x >> 17; x ^= x << 5` then `x & 0xFF`, starting from `seed` (1 when `seed` is 0). `inc` with seed 0xFE gives `fe ff 00 01`, `prbs` with seed 1 gives `21 01 c5 4f d1 d0 1a b2` + - `out=hex|crc` (default `hex`) where a command returns data: `hex` returns at most 128 bytes (`ERR range` above); `crc` returns `len= crc=<8 lower-case hex digits>`, the CRC-32 of zlib (`zlib.crc32`; `inc` seed 0 with 256 bytes gives `29058c73`) + - durations in replies are `us=` microseconds ## General @@ -66,6 +86,16 @@ The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix - `reset` → no final line; the board resets and prints `EVT boot ...` with `reset=sw` - `delay ` → `OK` after the given time (lets the host synchronise with firmware timing) +## Clock (hal-st default clocks, `ConfigureDefaultClockNucleoWB55RG` and `ConfigureDefaultClockNucleoWBA55CG`) + +- `clock.info` → `OK sysclk= hclk= pclk1= pclk2= [pclk7=] hse=0|1 lse=0|1 hsi=0|1 [hsi48=0|1] pll=0|1 rngsel= [clk48=]` + - the frequencies come from `HAL_RCC_Get*Freq`, the flags are the oscillator ready flags (`LL_RCC_*_IsReady`; `pll` is PLL1 on STM32WBA55) + - `pclk7` on STM32WBA55 only; `hsi48` and `clk48` on STM32WB55 only + - `rngsel` is the RNG kernel clock selection: `clk48`, `lsi` or `lse` on STM32WB55, where `clk48` names the CLK48 source (`hsi48`, `pllsai1`, `pll` or `msi`); `lse`, `lsi`, `hsi` or `pll` (PLL1 Q) on STM32WBA55 +- `clock.mco [div=1|2|4|8|16]` → `OK` (STM32WB55): MCO source and divider (`LL_RCC_ConfigMCO`, default `div=1`); `off` stops the output. Validation scaffolding: `sgpio.af PA8 af=0` puts MCO on the pin +- `clock.hsi48 <0|1>` → `OK` (STM32WB55): switches HSI48 off or on and waits for its ready flag (`ERR timeout` after 10 ms). Validation scaffolding for the RNG tests; switch it back on afterwards. `clock.hsi48 0` answers `ERR busy` while an `rng` driver holds the RNG clock (a `variant=async` read in flight, or one left by its `ERR timeout` until the next `rng` command): it would lose its kernel clock +- `clock.mco` and `clock.hsi48` return `ERR unsupported` on STM32WBA55: its only MCO pin is the terminal RX (PA8), and it has no HSI48 + ## GPIO (`hal::GpioPinStm`) - `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` @@ -77,15 +107,31 @@ The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix - `gpio.count [clear=0|1]` → `OK count=` - `gpio.release ` → `OK`; the pin returns to a digital input with its configured pull +## Synchronous GPIO (`hal::SynchronousOutputPinStm`, `hal::SmallPeripheralPinStm`, `hal::MultiGpioPinStm` with `hal::MultiPeripheralPinStm`) + +- `sgpio.out <0|1> [od=0|1] [speed=low|medium|fast|high]` → `OK`: the first use of a pin builds a `SynchronousOutputPinStm` (push-pull, `speed=low` unless given), later uses set the level; an `od` or `speed` that differs from the pin's rebuilds it, an omitted one keeps its value. The pin has no pull: an open-drain high only releases it +- `sgpio.latch ` → `OK value=<0|1>`: the output latch (`GetOutputLatch`); `ERR notopen` for a pin `sgpio.out` does not hold +- `sgpio.af timer=<1-17> [ch=<1-4>]` → `OK af=`: a `SmallPeripheralPinStm` (push-pull, low speed, no pull) on the alternate function of channel `ch` (default 1) of TIM`timer` in the hal-st pinout table; a timer the MCU lacks returns `ERR range`, a pin without that channel `ERR pin` + - `sgpio.af af=<0-15>` muxes a raw alternate function instead (MCO is AF0 on PA8, alias `mco`); exactly one of `timer` and `af`, and `ch` only with `timer` (`ERR usage`) + - the group only muxes the pin: what drives the function (a `tpwm` channel with no pin of its own, `clock.mco`) is set up by its own group +- `sgpio.multi ,[,...] timer=<1-17> [ch=<1-4>]` → `OK`: a `MultiGpioPinStm` over 1-4 different pins (`ERR usage` otherwise) muxed together to channel `ch` (default 1) of TIM`timer` by a `MultiPeripheralPinStm`; a pin without that channel returns `ERR pin` +- `sgpio.release ` → `OK`: the pin returns to an input without pull; any pin of the `multi` set releases the whole set; `ERR notopen` for a pin the group does not hold +- The group holds up to 4 output pins, 4 alternate-function pins and one `multi` set (one more returns `ERR busy`); a pin serves one of them at a time, and a pin another group holds returns `ERR busy` +- `sgpio.out` checks the level, `od` and `speed` before the pin; `sgpio.af` and `sgpio.multi` check `timer`, `ch` and `af` (`usage`, then `range`) before the pins + ## PWM (`hal::PwmStm`, `sync=1` selects `hal::SynchronousPwmStm`) -- `pwm.open [channels=[,...]] [pins=[:][,...]] [freq=] [mode=edge|center] [prescaler=] [dead=|off] [inv=0|1] [invn=0|1] [idle=0|1] [idlen=0|1] [brk=] [brkpol=low|high] [brkauto=0|1] [sync=0|1]` → `OK pwmclk=` +- `pwm.open [channels=[,...]] [pins=[:][,...]] [freq=] [mode=edge|edgedown|center|centerup|centerboth] [prescaler=] [dead=|off] [inv=0|1] [invn=0|1] [idle=0|1] [idlen=0|1] [brk=] [brkpol=low|high] [brkauto=0|1] [sync=0|1] [preload=0|1] [brkfilter=<0-15>] [trgo=reset|enable|update|oc1|oc1ref|oc2ref|oc3ref|oc4ref]` → `OK pwmclk=` - `channels` or `pins` is required (`ERR usage`); 1 to 4 channels, each at most once (`ERR usage`), numbered 1-4 - a `pins` entry is the channel output, optionally followed by `:` and its complementary output; `-` leaves a position unused (`PA8:PA7` drives CH1 and CH1N, `PA8` or `PA8:-` CH1 only, `-:PA7` CH1N only); with `channels` the entries follow the channel order, without it each channel follows from its pin - `channels` without `pins` takes the first pin of the pinout table for each channel that is neither reserved nor missing from the package, and no complementary output - - defaults: `freq=10000 mode=edge prescaler=0 dead=off inv=0 invn=0 idle=0 idlen=0 brkpol=high brkauto=0 sync=0` + - defaults: `freq=10000 mode=edge prescaler=0 dead=off inv=0 invn=0 idle=0 idlen=0 brkpol=high brkauto=0 sync=0 preload=1`, no `brkfilter` and no `trgo` - `pwmclk` is the counter clock, the timer kernel clock divided by `prescaler + 1`; `prescaler` is 0-65535 - - `mode=center` is the centre-aligned counter (`ERR unsupported` on TIM16/TIM17) + - `mode` selects the counter (`hal::PwmStmBase::Alignment`): `edge` counts up; `edgedown` counts down and cannot reach 0 %: at duty 0 the output stays active for one counter tick per period, since PWM mode 1 is active while the counter is at or below the compare value; + `center`, `centerup` and `centerboth` count up and down and give the same waveform (they differ only in when the compare flags are set); every mode but `edge` needs a counter mode select, TIM1, TIM2 or (STM32WBA55) TIM3 (`ERR unsupported` on TIM16/TIM17) + - `preload=1` buffers period and compare values, so a `pwm.duty` or `pwm.freq` takes effect at the next update event; with `preload=0` it takes effect at once, and a write in the middle of a period cuts or stretches that period's pulse + - `brkfilter` is the break input filter (BDTR.BKF, 0 = none up to 15 = 8 samples at the timer kernel clock / 32, 256 kernel clocks; the reference manual lists every step); it needs `brk` (`ERR usage`) + - `trgo` selects the trigger output (TIMx_CR2.MMS) an `adc.open trgo=` sequence runs on: `reset`, `enable`, `update`, `oc1` (compare pulse of channel 1), `oc1ref`-`oc4ref` (the channel's reference signal); without `trgo` the trigger output is `reset`; it needs a master timer, TIM1, TIM2 or (STM32WBA55) TIM3 (`ERR unsupported` on TIM16/TIM17) - `dead` is the dead time inserted between a channel and its complementary output, at most 1000000 ns; it saturates at the largest dead time the DTG field encodes - `inv` and `invn` invert every channel output or complementary output, `idle` and `idlen` set their level while the outputs are disabled - `brk=` muxes the break input of the timer (`ERR pin` for another pin); `brkpol` is the active level and `brkauto=1` re-enables the outputs automatically after the break input releases @@ -95,57 +141,150 @@ The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix - `pwm.duty [duty2%] [duty3%] [duty4%]` → `OK`; one duty per opened channel in channel order, or a single duty for all of them, starts the outputs; duty accepts decimals (`12.5`, up to 4 digits), `0` and `100` - `pwm.freq ` → `OK` - `pwm.stop ` → `OK` -- A frequency whose period is under 2 counter ticks, or whose auto-reload does not fit the counter (16 bits, 32 bits on TIM2), returns `ERR range`, both in `pwm.open` and `pwm.freq`; with `ticks = pwmclk / hz` (rounded down) the auto-reload is `ticks - 1` edge aligned and `ticks / 2` centre aligned, where the counter runs up and down for a period of `2 x ARR` ticks +- A frequency whose period is under 2 counter ticks, or whose auto-reload does not fit the counter (16 bits, 32 bits on TIM2), returns `ERR range`, both in `pwm.open` and `pwm.freq`; with `ticks = pwmclk / hz` (rounded down) the auto-reload is `ticks - 1` for `edge` and `edgedown` and `ticks / 2` for the centre-aligned modes, where the counter runs up and down for a period of `2 x ARR` ticks - `pwm.close ` → `OK` -## UART (`hal::UartStm`, `dma=1` selects `hal::UartStmDma`, `duplex=1` selects `hal::UartStmDuplexDma`, `sync=1` selects `hal::SynchronousUartStm`) +## UART (`hal::UartStm`, `dma=1` selects `hal::UartStmDma`, `duplex=1` selects `hal::UartStmDuplexDma`, `sync=1` selects `hal::SynchronousUartStm`, `sendonly=1` selects `hal::SynchronousUartStmSendOnly`) -- `uart.open [lp=0|1] [tx=] [rx=] [rts=] [cts=] [baud=] [parity=none|even|odd] [flow=none|rts|cts|rtscts] [swap=0|1] [dma=0|1] [duplex=0|1] [sync=0|1]` → `OK` +- `uart.open [lp=0|1] [tx=] [rx=] [rts=] [cts=] [baud=] [parity=none|even|odd] [flow=none|rts|cts|rtscts] [swap=0|1] [dma=0|1] [duplex=0|1] [sync=0|1] [sendonly=0|1]` → `OK` - `lp=1` selects LPUART`` instead of USART``; USART1 is the terminal (`ERR busy`) - default 115200 8N1 (8 data bits, plus the parity bit when `parity` is not `none`; one stop bit) - `baud` is 300-12000000, at most 8000000 on STM32WB55 where the HAL asserts that limit (`ERR range` outside); a rate whose divider does not fit the baud-rate register of the instance at its kernel clock returns `ERR range` (USART: 16 to 65535 with 8× oversampling; LPUART: 0x300 to 0xFFFFF) - - without pins LPUART1 uses `lpuart1tx`/`lpuart1rx`; every other instance needs `tx` and `rx` - - `flow` needs the matching `rts`/`cts` pins; `rts` and `cts` alone are only offered by `sync=1` (`ERR unsupported` otherwise) - - `swap=1` exchanges the TX and RX functions of the two pins (`ERR unsupported` with `sync=1`) - - at most one of `dma`, `duplex` and `sync` (`ERR usage`) + - without pins LPUART1 uses `lpuart1tx`/`lpuart1rx`; every other instance needs `tx` and `rx` (`rx` is optional with `sendonly=1`) + - `flow` needs the matching `rts`/`cts` pins; `rts` and `cts` alone are only offered by `sync=1`, and `rts` alone by `sendonly=1` (`ERR unsupported` otherwise) + - `swap=1` exchanges the TX and RX functions of the two pins (`ERR unsupported` with `sync=1` or `sendonly=1`) + - at most one of `dma`, `duplex`, `sync` and `sendonly` (`ERR usage`) - `sync=1` supports only `parity=none` and not `lp=1` (`ERR unsupported`); `duplex=1` does not support `lp=1` (`ERR unsupported`) + - `sendonly=1` transmits only, polling the data register: `flow` is `none` or `rts` (`cts` and `rtscts` return `ERR unsupported`), `parity=none` only; a given (or default) `rx` pin is held but left unconfigured. With `lp=1` it uses the `SyncLpUart` constructors, which exist on STM32WB55 only: `lp=1 sendonly=1` returns `ERR unsupported` on STM32WBA55 + - with `sendonly=1`, `uart.send` answers once the last byte has left the data register (TXE), while its frame is still on the wire: a `uart.close` right after it may cut that frame; `uart.recv` returns no data - `uart.send ` → `OK` once the driver reports completion (up to 112 bytes; `ERR timeout` if the driver never completes) - `uart.recv [timeout=] [len=]` → `OK data=` with everything received since the last `uart.recv`, at most 256 bytes (waits up to `timeout`, default 1000, at most 10000, for `len` bytes when given, and returns what arrived even if fewer) - `uart.close ` → `OK` -## SPI master (`hal::SpiMasterStm`, `dma=1` selects `hal::SpiMasterStmDma`, `sync=1` selects `hal::SynchronousSpiMasterStm`) +## SPI master (`hal::SpiMasterStm`, `dma=1` selects `hal::SpiMasterStmDma`, `sync=1` selects `hal::SynchronousSpiMasterStm`, `bits` adds `hal::SpiDataSizeConfiguratorStm`) -- `spi.open clk= mosi= miso= [cs=] [baud=] [mode=0|1|2|3] [dma=0|1] [sync=0|1]` → `OK`; defaults `baud=1000000 mode=0` +- `spi.open clk= mosi= miso= [cs=] [nss=] [baud=] [mode=0|1|2|3] [dma=0|1] [sync=0|1] [bits=<4-16>] [lsb=0|1]` → `OK`; defaults `baud=1000000 mode=0 bits=8 lsb=0` - the SPI clock is the fastest `spiclk / 2^n` (n = 1-8) not above `baud`, where `spiclk` is the kernel clock of the instance; `baud` outside `spiclk/256 ... spiclk/2` returns `ERR range` - `cs` is a GPIO chip select (any free pin, driven by EMIL's `SpiMasterWithChipSelect` or `SynchronousSpiMasterWithChipSelect`): low during a transfer, released after it unless `continue=1` - `dma=1` with `sync=1` returns `ERR usage` + - `nss` is the hardware slave select of the driver (`slaveSelect`): the pin must offer the instance's NSS function (`ERR pin`) and excludes `cs` (`ERR usage`). The masters configure the NSS pin but initialise the SPI with software NSS, so the pin is never driven (a known gap) + - `lsb=1` sends and receives the least significant bit first + - `bits` other than 8 needs `dma=1` (`ERR unsupported`); every transfer then runs with frames of `bits` bits (`SpiDataSizeConfiguratorStm`, installed for the whole open) + - up to 8 bits each buffer byte is one frame (its low bits); from 9 bits on, each frame takes two buffer bytes, little endian, so `txHex` and `rx` need an even length. Received frames are right aligned, the bits above the frame zero + - SPI3 of the STM32WBA55 is a limited instance: `bits` 8 or 16 only (`ERR unsupported`) + - the instance is shared with `spis`: an instance the SPI slave holds returns `ERR busy` - `spi.xfer [rx=] [continue=0|1]` → `OK rx=`; with an empty `txHex` (`-`) it receives `rx` bytes; `rx` defaults to the length of `txHex`, the transfer lasts max(tx, `rx`) bytes with `txHex` zero-padded, and the first `rx` received bytes are returned (`rx=0` only transmits); at most 64 bytes - `spi.close ` → `OK` +## SPI slave (`hal::SpiSlaveStmDma`) + +- `spis.open clk= miso= mosi= nss=` → `OK`: all four pins are required (`ERR usage`), each must offer its SPI function of the instance (`ERR pin`); the slave runs mode 0, 8-bit frames, MSB first, with the hardware NSS input (low selects it) + - DMA: STM32WB55 DMA2 channels 1 (transmit) and 2 (receive); STM32WBA55 GPDMA1 channels 8 (transmit) and 7 (receive), shared with `adc`, `ain.burst` and `dma.wave`. The instance held by `spi`, a held channel or pin returns `ERR busy` +- `spis.arm [rx=] [len=] [pattern=inc|const|prbs] [seed=]` → `OK` once the transfer is handed to the driver (`SendAndReceive`) + - full duplex: `rx` left out or equal to the transmit length (another value returns `ERR usage`); send only: `rx=0`; receive only: `-` with `rx=`; nothing to send or receive returns `ERR usage`. Lengths 1-1024 (`ERR range`) + - `-` with `len=` sends a payload generated in firmware (see "Framing"); `pattern`/`seed` without `len`, or `len` with hex, return `ERR usage` + - a transfer already armed returns `ERR busy`, checked before the payload and `rx` (the armed DMA still reads the transmit buffer) + - while nothing is armed the slave is disabled: frames a master clocks then are dropped. Frames beyond the armed length are dropped as well; the transfer completes with the armed length + - receive only sends the DMA's dummy word on MISO (undefined content) +- `spis.result [wait=] [out=hex|crc]` → `OK done=1 rx=` (or `OK done=1 len= crc=` with `out=crc`) once the transfer is done; `OK done=0` at once when nothing is armed, or after `wait` ms (0-10000, default 1000) with the transfer still armed + - `out=hex` holds 128 bytes: a longer receive length returns `ERR range`; a send-only transfer answers `rx=` with no data + - the result stays readable until the next `spis.arm` or `spis.cancel` + - a second `spis.result` while one waits returns `ERR busy`; `spis.cancel` and `spis.close` answer a waiting `spis.result` with `OK done=0` first +- `spis.cancel ` → `OK cancelled=<0|1>`: stops the armed transfer (`CancelTransmission`); `cancelled=1` when it was still running +- `spis.close ` → `OK`: cancels the transfer, then releases the driver, its DMA channels and pins + ## ADC (`hal::AdcStm` with `hal::AdcDmaMultiChannelStmBase`) -- `adc.open pins=[,...] [sampling=] [timer=] [rate=]` → `OK` +- `adc.open pins=[,...] [sampling=] [timer=] [rate=] [trgo=]` → `OK` - `pins` is required (`ERR usage`), one conversion per pin in the given order, at most 8 (`ERR range`); a pin without an ADC channel returns `ERR pin` - `sampling` is the sampling time of every channel, in ADC clock cycles: WB55 `2.5`, `6.5`, `12.5`, `24.5`, `47.5`, `92.5`, `247.5`, `640.5` (default `2.5`); WBA55 `1.5`, `3.5`, `7.5`, `12.5`, `19.5`, `39.5`, `79.5`, `814.5` (default `3.5`); the value is handed to the driver's per-channel `samplingTime` - without `timer` each run converts the sequence once from a software trigger (DMA one-shot mode), and the firmware starts the next run from the event loop after the previous one completed - `timer=` triggers the conversions from the TRGO of timer `t` at `rate` runs per second (default 1000, 1-100000, `ERR range` outside) in DMA circular mode; the timers the driver can trigger from are TIM1 and TIM2 (`ERR unsupported` for others); `rate` without `timer` returns `ERR usage` + - `trgo=` converts the sequence once per trigger output of timer `t` while the `pwm` group drives it (open `pwm.open ... trgo=` first; `adc.open` never takes the timer), in DMA circular mode; nothing arrives before `pwm.duty` starts the timer + - `trgo` with `timer` or `rate` returns `ERR usage`, a timer the MCU lacks `ERR range` + - `ERR unsupported` when no group holds the timer, or when the ADC cannot trigger from its TRGO: TIM1 and TIM2 on STM32WB55, TIM2 only on STM32WBA55 (ADC4 reaches TIM1 through TRGO2, which PWM leaves at reset) + - `ERR busy` when another group (encoder, timer-triggered ADC, `ain.burst`, `dma.wave`, `tim`, `tpwm`) holds the timer + - while open the group holds the ADC and its DMA channel (channel 7 of DMA1 on STM32WB55, of GPDMA1 on STM32WBA55), which `ain` needs too (`ERR busy` both ways) - `adc.measure [n=]` → `OK samples=[,...]` (raw 12-bit codes); `n` is the number of sequence runs (default 1), each contributing one value per pin, at most 64 values; returns `ERR timeout` after 1000 ms - `adc.close ` → `OK` +## Analog input (`hal::AnalogToDigitalPinImplStm`, `hal::AnalogToDigitalInternalTemperatureStm`, `hal::AdcTriggeredByTimerWithDma`) + +Each command builds its drivers, holds the ADC (and for `ain.burst` TIM2 and the ADC's DMA channel, which `spis` receives on as well on STM32WBA55) until it answers and then releases them; while `adc` is open, another group holds one of them, or a command of this group still runs, it answers `ERR busy`. `` is the ADC of `adc.open` (1 on STM32WB55, 4 on STM32WBA55, `ERR range` otherwise). + +- `ain.read [sampling=]` → `OK code=`, or `OK code= mcelsius=` for `temp` + - one conversion of `pin` (an analog pin, `ERR pin` otherwise) or of the internal temperature sensor; `sampling` takes the values of `adc.open` (default the same); the STM32WBA55 driver samples every channel with its common sampling time (79.5 cycles) whatever `sampling` says + - `mcelsius` is `__LL_ADC_CALC_TEMPERATURE` with VDDA = 3300 mV and the factory calibration, in whole degrees (a multiple of 1000); the sensor needs a long sampling time (WB55 `640.5`, WBA55 `814.5`), the driver's default is shorter than its minimum + - `ERR timeout` after 1000 ms +- `ain.burst n=<1-256> rate=<10-100000> [repeat=1|2] [out=list|stats]` → `OK samples=,... us=` or `OK n= min= max= mean= us=` + - one `AdcTriggeredByTimerWithDma` over a 256-sample buffer converts `pin` on every TIM2 update at `rate` per second (TIM2 is the driver's timer); each command calls `Measure(n)` `repeat` times on that driver + - `n` and `rate` are required (`ERR usage`); `out=list` (default) lists the samples and allows `n` up to 64 (`ERR range` above), `out=stats` reports the number of samples, their minimum, maximum and mean (rounded); `us` is the time from `Measure(n)` to its result + - with `repeat=2` the first measurement is reported as `EVT ain index= run=1 ...` with the same fields, the second as the final line + - `ERR timeout` after `repeat` × (n / rate + 1 ms) + 1000 ms; the group stays busy until the measurement completes + +## DMA (`hal::CircularTransmitDmaChannel`) + +- `dma.wave rate=<1-1000000> pattern= [ms=<1-10000>]` → `OK` after `ms` (default 50) + - drives `pin` (an output) with `pattern`, 1 to 32 bytes, one bit per TIM2 update at `rate` per second, byte by byte and least significant bit first, repeated: TIM2 update requests make a `CircularTransmitDmaChannel` write a 32-bit set/reset word to the port's BSRR (32-bit memory and peripheral transfers) + - the channel is DMA2 channel 4 on STM32WB55 and GPDMA1 channel 8 on STM32WBA55; TIM2, the channel and the pin are held until the reply, so a group using any of them answers `ERR busy`, and so does `dma.wave` while they are in use + - `rate` and `pattern` are required (`ERR usage`); `pattern=-` or an odd number of hex digits is `ERR usage`, more than 32 bytes `ERR range` + ## Quadrature encoder (`hal::SynchronousQuadratureEncoderStm`, `lp=1` selects `hal::SynchronousQuadratureEncoderLpTimStm`) -- `qei.open [lp=0|1] [a=] [b=] [idx=] [res=] [offset=] [inva=0|1] [invb=0|1] [cap=a|b|ab] [filter=<0-15>] [vel=|off]` → `OK` +- `qei.open [lp=0|1] [a=] [b=] [idx=] [res=] [offset=] [inva=0|1] [invb=0|1] [cap=a|b|ab|rise|fall] [filter=<0-15>] [vel=|off]` → `OK` - defaults `res=4096 offset=0 inva=0 invb=0 cap=ab filter=0 vel=1000`; without pins the default encoder takes its default pins, other instances need `a` and `b` - `a` and `b` are channels 1 and 2 of the timer; the timer must offer encoder mode (TIM1, TIM2, TIM3; `ERR unsupported` for TIM16/TIM17) - `res` is the count at which the counter wraps (2 to 65536, up to 4294967295 on TIM2), `offset` the starting count (below `res`, `ERR range` otherwise) - - `cap=ab` counts both edges of both phases, `cap=a` and `cap=b` both edges of one phase + - `cap=ab` counts both edges of both phases, `cap=a` and `cap=b` both edges of one phase; `cap=rise` and `cap=fall` need `lp=1` (`ERR usage` otherwise) - `vel` is the speed sampling period in µs (1-1000000), `off` leaves speed at 0 - `idx` is a plain input read by `qei.index`; it never changes the count - - `lp=1` (LPTIM1, WB55 only, `ERR unsupported` elsewhere) takes `a`/`b` on the LPTIM inputs 1/2 (`lptim1in1`/`lptim1in2`), `res` up to 65536, `inva=1` as the mirrored-mounting reversal and `filter` 0, 2, 4 or 8 (consecutive samples); `cap`, `offset` and `invb` return `ERR unsupported` + - `lp=1` selects the LPTIM encoder: LPTIM1 on STM32WB55 (LPTIM2 has no encoder interface, `ERR range`), LPTIM1 and LPTIM2 on STM32WBA55. It takes `a`/`b` on the LPTIM inputs 1/2 (`lptimin1`/`lptimin2`), `res` up to 65536, `inva=1` as the mirrored-mounting reversal and `filter` 0, 2, 4 or 8 (consecutive samples) + - with `lp=1`, `cap=ab` (default) counts both edges of both inputs, `cap=rise` and `cap=fall` only the rising or falling edges of both inputs (two counts per quadrature cycle); `cap=a`, `cap=b`, `offset` and `invb` return `ERR unsupported` + - the LPTIM of an `lp=1` encoder is held against `lptim` and `lptpwm` (`ERR busy`) - `qei.read ` → `OK pos= dir= speed= res=` (`speed` is in counts per second, `res` is the driver's `Resolution()`) - `qei.index ` → `OK idx=<0|1>`, the level of the index input; `ERR unsupported` when the encoder was opened without `idx` - `qei.close ` → `OK` +## Timer (`hal::FreeRunningTimerStm`, `irq=immediate|dispatched` selects `hal::TimerWithInterruptStm`) + +- `tim.open [prescaler=<0-65535>] [period=] [irq=immediate|dispatched|none] [mode=up|down] [pin=]` → `OK timclk=` + - defaults `prescaler=0 period=999 irq=dispatched mode=up`; `period` is the auto-reload, 1-65535 (1-4294967295 on TIM2); `timclk` is the timer kernel clock + - one update per `period + 1` ticks of `timclk / (prescaler + 1)` + - `irq=none` builds a `FreeRunningTimerStm` without interrupt; `immediate` and `dispatched` build a `TimerWithInterruptStm` whose update callback runs in the interrupt or from the event loop. Dispatched callbacks coalesce while one is queued: above about 1 kHz `irqs` counts fewer than the updates + - with `irq=immediate|dispatched` an update rate `timclk / ((prescaler + 1) (period + 1))` (integer division) above 100 kHz returns `ERR range`: the interrupt runs on every update in both modes and would starve the event loop + - `pin` (any free bonded pin) is driven low and toggled by every update callback, so it runs at half the update rate; `pin` with `irq=none` returns `ERR usage` + - `mode=down` counts down from `period`; it needs `irq=none` and a timer with a counter mode select (TIM1, TIM2, TIM3), `ERR unsupported` otherwise +- `tim.start `, `tim.stop ` → `OK`; starting a running or stopping a stopped timer changes nothing +- `tim.count ` → `OK cnt= irqs=`: the counter register and the update callbacks since `tim.open` +- `tim.close ` → `OK` + +## Timer PWM (`hal::TimerPwmWithChannels`) + +- `tpwm.open pins=[,...] [prescaler=<0-65535>] [period=]` → `OK timclk=` + - N is the number of entries (1-4) and entry n is channel n; `-` leaves a channel unused (a `hal::DummyPinStm`: the channel is configured but drives no pin, and the pin stays free); at least one entry is a pin (`ERR usage`) + - defaults `prescaler=0 period=6399` (10 kHz at 64 MHz); the PWM frequency is `timclk / ((prescaler + 1) (period + 1))`, `period` 1-65535 (1-4294967295 on TIM2) + - a pin that is not channel n of the timer returns `ERR pin`; a channel the timer does not have (CH2-CH4 on TIM16/TIM17) `ERR unsupported` +- `tpwm.duty <0-100>` → `OK`: integer percent, compare value = `period` × duty / 100 (`PwmChannelGpio::SetDuty`, 32-bit arithmetic); the output is high while the counter is below the compare value, so 100 keeps one low counter tick per period +- `tpwm.pulse ` → `OK`: compare value `on` (0 to the counter maximum) and auto-reload `period` (1 to the counter maximum, `PwmChannelGpio::SetPulse`); the period applies to every channel +- `tpwm.start [ch=]`, `tpwm.stop [ch=]` → `OK`: one channel, or every channel through `TimerPwmBaseStm::Start`/`Stop`; starting a running channel changes nothing +- `tpwm.close ` → `OK`; every channel stops first + +## Low-power timer (`hal::FreeRunningLowPowerTimerStm`, `irq=immediate|dispatched` selects `hal::LowPowerTimerWithInterruptStm`) + +- `lptim.open [period=<1-65535>] [prescaler=1|2|4|8|16|32|64|128] [irq=immediate|dispatched|none] [rep=<0-255>] [pin=]` → `OK lptimclk=` + - LPTIM 1-2; defaults `period=999 prescaler=1 irq=dispatched rep=0`; `prescaler` is the clock divider, another number returns `ERR range` + - one update per `period + 1` ticks of `lptimclk / prescaler`, and with `rep` once every `rep + 1` periods; `lptimclk` is the LPTIM kernel clock (PCLK1 on STM32WB55; PCLK7 for LPTIM1 and PCLK1 for LPTIM2 on STM32WBA55) + - `rep` is the repetition counter of the STM32WBA LPTIM (`ERR unsupported` on STM32WB55, whose LPTIM has none) + - `irq` and `pin` as for `tim.open`; with `irq=immediate|dispatched` an update rate `lptimclk / (prescaler (period + 1))` (integer division, `rep` left out: the autoreload-match interrupt runs every period) above 100 kHz returns `ERR range` +- `lptim.start `, `lptim.stop `, `lptim.count ` → `OK cnt= irqs=`, `lptim.close ` as for `tim` + +## LPTIM PWM (`hal::LpTimerPwmWithChannels`, NUCLEO-WBA55CG only) + +- `lptpwm.open pins=[,] [prescaler=1|2|4|8|16|32|64|128] [period=<1-65535>]` → `OK lptimclk=` + - N is the number of entries (1-2), `-` as for `tpwm`; defaults `prescaler=1 period=6399`; the PWM frequency is `lptimclk / prescaler / (period + 1)` + - LPTIM1 channel 1 (PB11) is not bonded out on the UFQFPN48, so LPTIM1 runs as `pins=-,` +- `lptpwm.duty`, `lptpwm.pulse`, `lptpwm.start`, `lptpwm.stop` and `lptpwm.close` as for `tpwm`; the LPTIM takes compare and period writes only while it is enabled, so set them after `lptpwm.start` + ## Watchdog (`hal::WatchDogStm`, the window watchdog) - `wdt.start timeout= [feed=auto|manual] [pin=]` → `OK` @@ -154,9 +293,215 @@ The generic framing (`OK`/`ERR`/`EVT` lines, reasons, the deferred `\r\n` prefix - with `feed=auto` (default) the firmware refreshes on every early warning; the watchdog resets the board one counter tick after a warning that is not answered - `pin` is driven low and toggled in the early-warning interrupt, so its period can be measured; it stays claimed until reset - a started watchdog cannot be stopped: it runs until reset + - the coordinated flash driver (`flash ... variant=coord`, `flash.stack starting`, STM32WB55) borrows the WWDG to refresh it around the steps it runs with the interrupts masked: a running watchdog keeps running, an unstarted one is created for the borrow + - while an unstarted watchdog is borrowed, `wdt.start` returns `ERR busy` (after the argument checks) until the driver gives it back - `wdt.feed ` → `OK` - Early warning: `EVT wdt index=0 warning=`. After a watchdog reset the next `EVT boot` reports `reset=wwdg`. +## I2C master (`hal::I2cStm`) + +- `i2c.open scl= sda= [freq=] [timing=] [pull=none|up]` → `OK timing=<0x........> kernel=` + - checks in order: `scl` or `sda` missing, or `freq` together with `timing` → `ERR usage`; an index other than 1 and 3, `freq` outside 20000-400000 → `ERR range`; a pin without the `i2cScl`/`i2cSda` function of that instance → `ERR pin`; the instance held by `i2c`, `i2cs` or `eeprom.attach`, or a pin held → `ERR busy` + - neither `freq` nor `timing`: the driver's default `Config` (TIMINGR 0x70B03D3D on STM32WB/WBA: Standard mode at 63.8 kHz with a 64 MHz kernel clock, 99.2 kHz at 100 MHz); `freq` takes the TIMINGR below; `timing` is written as given (`HAL_I2C_Init` drops the reserved bits 24-27) + - `timing` in the reply is TIMINGR read back from the peripheral, `kernel` the I2C kernel clock + - `pull=up` turns on the MCU pull-ups of both pins after the driver configured them (validation scaffolding; the pinout table configures the I2C pins open drain without pull): enough for Standard mode on a short bus without resistors; Fast mode needs external pull-ups. Default `none` +- `freq` → TIMINGR (`I2cTiming`, validation/firmware/I2cTiming.cpp), in integer picoseconds (`tclk = 10^12 / kernel`, `target = 10^12 / freq`, both rounded down) + - Standard mode up to 100 kHz, Fast mode above: tLOW > 4700/1300 ns, tHIGH >= 4000/600 ns, rise 640/250 ns, fall 20/100 ns, tSU;DAT 250/100 ns, tVD;DAT 3450/900 ns; analog filter 50-260 ns, digital filter off + - per prescaler 0-15: the first SCLDEL with `(SCLDEL + 1) × tpresc >= rise + tSU;DAT` and the first SDADEL with `fall - 50 ns - 3 × tclk <= (SDADEL × (PRESC + 1) + 1) × tclk <= tVD;DAT - rise - 260 ns - 4 × tclk` (both bounds at least 0) + - then the minimal SCLL/SCLH meeting tLOW/tHIGH, with `sync = 50 ns + 2 × tclk` added to each phase, and the fewest `SCLL + SCLH + 2` clock units with `2 × sync + fall + units × tpresc >= target`, the extra units going to SCLL first + - SCLL and SCLH stay within 255; the prescaler with the shortest such period wins (ties: the smaller prescaler). With a zero rise time the bus never runs faster than `freq`; a slow rise makes it slower + - examples: + + | kernel | 20 kHz | 50 kHz | 100 kHz | 400 kHz | + |---------|------------|------------|------------|------------| + | 16 MHz | 0x20408186 | 0x00e097a2 | 0x00e04752 | 0x00500916 | + | 32 MHz | 0x80305659 | 0x2090656c | 0x10e04853 | 0x00b0162e | + | 64 MHz | 0x90509ca1 | 0x80604348 | 0x40b03943 | 0x10b0172f | + | 100 MHz | 0xd060aeb4 | 0x70b0777f | 0x50e04b57 | 0x20b11931 | + +- `i2c.write [next=stop|restart|continue] [len=] [pattern=] [seed=]` → `OK sent= result=` + - `addr` 0-0x7f, 0 being the general call; the data is hex or generated with `len` (1-1024, `pattern=inc|const|prbs` `seed=`, see "Line length" in General); `-` without `len` writes no data (an address probe, NBYTES=0) + - `next` maps to `hal::Action`: `stop` (default), `restart` (no STOP, the next transfer starts with a repeated START), `continue` (the next transfer continues the same transaction without START or address) + - `sent` is `numberOfBytesSent`, the acknowledged data bytes; `result` maps `Result::complete`, `partialComplete` (`nack`: the address or a data byte was not acknowledged) and `busError` (`buserror`: bus error or arbitration lost) + - `ERR timeout` after 1000 ms without completion; the group then answers `ERR busy` until `i2c.close` +- `i2c.read [next=stop|restart|continue] [out=hex|crc]` → `OK result=complete data=` or, with `out=crc`, `OK result=complete len= crc=`; `OK result=nack` or `OK result=buserror` without data + - `len` 1-1024, above 128 only with `out=crc`; `next` and the timeout as for `i2c.write` +- `i2c.close ` → `OK`; disables the interrupts and destroys the driver one event-loop turn later, so completions already queued run first; a held bus (`next=continue`) is released +- `EVT i2c index= hook=notfound|buserror|arblost` when the driver calls `DeviceNotFound`, `BusError` or `ArbitrationLost`, printed before the final line of the transfer + +## I2C target (validation scaffolding, LL on the other instance) + +Not a hal-st driver (hal-st has no I2C slave): the other end of the bus for the `i2c` tests, written with the LL I2C functions on the EV/ER interrupts of its instance (`hal::cortex::ImmediateInterruptHandler`). Writes use slave byte control: every received byte stops SCL before its acknowledge, so the target decides ACK or NACK per byte; reads load one byte per transmit request. + +- `i2cs.open scl= sda= [addr=<0x08-0x77>] [mode=regs|sink] [timing=]` → `OK addr=<0x..>` + - checks as for `i2c.open`; the instance must not be held by `i2c` or `eeprom.attach` (`ERR busy`) + - default `addr=0x42` (0x50-0x57 stay free for an EEPROM) + - `mode=regs` (default): a 256-byte register file; the first byte of a write sets the pointer, the following bytes are stored at pointer++ (wrapping at 256), reads return the registers from the pointer on; `mode=sink`: written bytes only update the counters and the CRC, reads return the pattern of `i2cs.cfg`, from its start in every read + - `timing` only sets the data setup and hold times (SCLDEL, SDADEL); default the 400 kHz `I2cTiming` value for the kernel clock +- `i2cs.cfg [nack=] [addrnack=0|1] [stretch=] [stretchat=] [fault=none|stop] [faultat=] [pattern=inc|const|prbs] [seed=]` → `OK` + - replaces the whole behaviour: options left out take their defaults `nack=0 addrnack=0 stretch=0 stretchat=0 fault=none faultat=1 pattern=inc seed=0` + - `nack=k` NACKs data byte k (1-based) of every write (0 = never); the NACKed byte is not counted or stored + - `addrnack=1` turns the own address off: the address is NACKed + - `stretch` (0-10000 us) holds SCL low at byte `stretchat`: 0 is the address phase (both directions), k >= 1 a written byte before its acknowledge, or a read byte before it is loaded; for read bytes after the first the byte in flight shortens the visible gap. The target busy-waits in its interrupt, which blocks the terminal meanwhile + - `fault=stop faultat=k` (k >= 1) applies to the next read once: when byte k is requested, the target drives SDA and SCL low as GPIO open-drain outputs, releases SCL, waits up to 1 ms for SCL to read high and releases SDA: a STOP in the middle of a byte, which the master reports as a bus error. The target then resets (PE off and on, own address kept) and restores the pins. Run it at 100 kHz +- `i2cs.status [clear=0|1]` → `OK rx= tx= crc= writes= reads= stops= nacked= errors= last=` + - since the open or the last `clear=1` (which clears after printing): received and transmitted data bytes, the CRC-32 of the received bytes, addressed writes and reads, STOP conditions, NACKed data bytes, bus errors, arbitration losses and overruns seen by the target, and the first 32 data bytes of the last write +- `i2cs.regs ` → `OK`; preloads the register file (offset + length at most 256) +- `i2cs.dump ` → `OK data=`; `len` 1-128, offset + length at most 256 +- `i2cs.close ` → `OK` + +## EEPROM (`services::HilEepromCommands` over the `I2cEepromStm` adapter) + +- `eeprom.attach scl= sda= [addr=<0x08-0x77>] [size=] [page=] [abytes=1|2] [freq=] [wcycle=]` → `OK` + - defaults for a 24LC256/AT24C256: `addr=0x50 size=32768 page=64 abytes=2 freq=400000 wcycle=10` + - `size` 1-65536, `page` 8-256 and a power of two, `abytes` the word address bytes (with `abytes=1` at most 256 bytes), `wcycle` 0-20 ms, `freq` as for `i2c.open` (`ERR range` outside); checks in the order of `i2c.open` + - the adapter builds its own `hal::I2cStm`, whose hooks only count (no `EVT i2c`); `ERR busy` when attached already or when the instance or a pin is held +- `eeprom.detach` → `OK`; `ERR busy` while an adapter transfer is in flight, `ERR notopen` when nothing is attached; after an error (below) it first completes the pending `eeprom.*` command (which prints nothing if it timed out already) and then detaches, so the group works again +- `eeprom.write
` → `OK`, `eeprom.read
` → `OK data=` (`len` 1-128), `eeprom.erase` → `OK`: EMIL's group (`HilEepromCommands`), `ERR range` beyond `size`, `ERR timeout` after 5 s. Without an attached EEPROM the size is 0: writes and reads answer `ERR range`, `eeprom.erase` `OK` at once +- the adapter: + - writes page by page: each page is one transfer of the word address and the data, ending with a STOP + - after each page it polls: it repeats only the word address (which starts no write cycle) from each completion until the chip acknowledges, at most `wcycle` ms; `wcycle=0` does not poll + - reads send the word address, then read after a repeated START + - `eeprom.erase` writes 0xFF pages over `size` (about 6.7 ms per 64-byte page at 400 kHz: attach with a `size` that fits EMIL's 5 s) + - a NACK or bus error outside the polling prints `EVT eeprom error= address=` and leaves the command pending (EMIL answers `ERR timeout`); `eeprom.detach` recovers + +## Random number generator (`hal::SynchronousRandomDataGeneratorStm`, `variant=async` selects `hal::RandomDataGeneratorStm`, `variant=hsem` selects `hal::SynchronousSynchronizedRandomDataGeneratorStm`) + +- `rng.read [variant=sync|async|hsem] [lock5=0|1]` → `OK data=`: `len` random bytes, 1-128; after a `variant=hsem` read the line ends with `hsi48=<0|1>`, the HSI48 ready flag (`LL_RCC_HSI48_IsReady`) once the driver is done + - every command builds the driver of its variant and destroys it afterwards; `variant` defaults to `sync` + - `variant=async` answers from the driver's completion (RNG interrupt), `ERR timeout` after 1 s + - `variant=hsem` exists on STM32WB55 only (`ERR unsupported` on STM32WBA55): the driver holds HSEM semaphore 0 during the read, through the firmware's one `SynchronousHardwareSemaphoreMasterStm` (shared with `hsem` and `flash`) + - unless this core holds semaphore 5 (clock configuration), the driver's `Hsi48Enabler` starts HSI48, the RNG kernel clock, for the read when it is off and stops it again afterwards + - `variant=hsem` answers `ERR busy` while this core holds semaphore 0 (`hsem.take 0`: nothing could free it while the driver blocks the event loop) or the coordinated flash driver exists (HSEM 0 of the sharing rule), and `ERR failed` while this core holds semaphore 5 (`lock5=1` or `hsem.take 5`, any process) and HSI48 is off + - on STM32WB55 `variant=sync` and `variant=async` answer `ERR failed` while HSI48, their kernel clock (CLK48), is off: only `Hsi48Enabler` starts it, and the plain drivers would abort on the clock error + - `lock5=1` (validation scaffolding, STM32WB55 only: `ERR unsupported` on STM32WBA55; only with `variant=hsem`: `ERR usage` otherwise) holds semaphore 5 (`HAL_HSEM_FastTake`, process 0) around the read, so `Hsi48Enabler` takes its locked branch; `ERR busy` when semaphore 5 is taken, `ERR failed` when HSI48 is off (the driver asserts that HSI48 runs while semaphore 5 is locked) +- `rng.stats [variant=sync|async|hsem]` → `OK n= ones= runs= chisq= crc= us=`: `len` bytes, 16-65536, generated by one driver in chunks of 256 bytes; `variant=async` answers `ERR timeout` after 5 s; `variant` follows the rules of `rng.read` + - the bytes form one bit stream, each byte most significant bit first: `ones` counts its set bits, `runs` its bit transitions plus one + - `chisq` is the chi-square of the byte histogram against the uniform distribution (255 degrees of freedom) times 1000, rounded down and at most 4294967295; `crc` is the CRC-32 of the bytes (8 lower-case hex digits, as `out=crc`); `us` is the generation time +- After an `ERR timeout` of `variant=async` the next `rng` command drops that driver and runs; until the timeout another `rng` command returns `ERR busy` + +## AES (`hal::SynchronousAes128EcbStm`) + +- `aes.enc [swap=none|half|byte|bit]` → `OK data=`: AES-128 ECB encryption of `data` under `key`; `aes.dec` takes the same arguments and decrypts + - `key` is 16 bytes (32 hex digits) and `data` 16-80 bytes in whole 16-byte blocks; any other length, an empty (`-`) or malformed hex returns `ERR usage` + - `swap` is the driver's data swapping (`DataSwapping`, AES_CR.DATATYPE) of every 32-bit word written to and read from the peripheral, default `byte`, which gives standard AES on the byte string; the key is not swapped + - `none`, `half` and `bit` give `T(AES(T(data)))`, where T reverses the bytes of every 32-bit word (`none`), swaps the two bytes of every 16-bit half-word (`half`) or reverses the bits of every byte (`bit`) + - every command builds the driver, sets the key, runs and destroys the driver + +## Public key accelerator (`hal::PkaStm` on `services::secp256r1`) + +- `pka.mul [k=] [x= y=]` → `OK x= y= us=`: the scalar multiplication k × (x, y) on the NIST P-256 curve; each result coordinate has 32 bytes (64 hex digits); `us` runs from the start of the operation to its completion + - `k` defaults to 1 and the point to the base point G of the curve; `x` and `y` come together (`ERR usage` otherwise) +- `pka.check x= y=` → `OK on=<0|1>`: whether (x, y) lies on the curve +- `pka.cmp a= b=` → `OK cmp=lt|eq|gt`: compares two numbers of the same length, 4-60 bytes: different lengths return `ERR usage`, a length outside 4-60 `ERR range` (60 bytes keep the line within 255 characters) +- Operands are big-endian hex. `mul` and `check` take 1-32 bytes and the firmware left-pads them with zeros to 32 bytes, as `PkaStm` itself pads by two words only. A missing operand, an empty (`-`), odd or non-hex one returns `ERR usage` and one above the size limit `ERR range`; every `usage` check of a command comes before its `range` checks +- The answer comes from the driver's completion (PKA interrupt, dispatched to the event loop), `ERR timeout` after 5 s. One operation runs at a time: until its timeout another `pka` command returns `ERR busy`; after an `ERR timeout` the next one starts a new operation +- The firmware builds one `PkaStm` on the first `pka` command and keeps it: its constructor waits for the PKA to initialise, on STM32WBA55 from the RNG kernel clock + +## Internal flash (`hal::FlashHomogeneousInternalStm`, `hal::FlashInternalStm`, their `hal::Synchronous*` twins, on STM32WB55 `hal::FlashCoordinatedWithWirelessStack`) + +- The commands work on a scratch region of the flash, absolute pages 64-127 on both boards; addresses are relative to the region: + - STM32WB55: 0x08040000-0x0807FFFF in 4 KB pages, below the 512 KB the linker script gives the firmware and below the secure area of the wireless stack + - STM32WBA55: 0x08080000-0x080FFFFF in 8 KB pages +- `variant=sync|async|coord` (default `sync`) selects the driver; each command builds its driver and destroys it when done: + - `sync`: the `Synchronous*InternalStm` classes + - `async`: the `hal::Flash` classes (completion scheduled on the event loop) + - `coord` (STM32WB55; `ERR unsupported` on STM32WBA55): `FlashCoordinatedWithWirelessStack` over the async driver +- `layout=homogeneous|table` (default `homogeneous`) selects the sectors: + - `homogeneous`: one sector per page (`Flash*HomogeneousInternalStm`) + - `table`: a sector-size table (`FlashInternalStm`/`SynchronousFlashInternalStm`) of single pages followed by the page pattern 1, 1, 2, 4 twice; over 64 pages that is 56 sectors, of which sectors 48-55 have 1, 1, 2, 4, 1, 1, 2, 4 pages +- `flash.info [variant=] [layout=]` → `OK base=<0x........> sectors= size= first= image= layout=` + - `image` is the first absolute page past the running image (`_sidata` plus the size of `.data`) + - `first` is the first sector `flash.erase` and `flash.write` accept, `min(image, sectors)`. Every sector starts at or past the page of its index, so even an erase that took the sector index for the absolute page (as `EraseSectors` did on STM32WB/WBA before its fix) cannot reach the running image + - when the image reaches page 64, every `flash.*` command returns `ERR unsupported` +- `flash.erase [variant=] [layout=]` → `OK us=`: erases sectors `first` up to `end` (exclusive) + - `first` below the accepted first sector, `first >= end` or `end` past the last sector return `ERR range` + - `us` is the duration (CYCCNT); with `variant=sync` it covers the blocking erase +- `flash.write
[len=] [pattern=inc|const|prbs] [seed=] [variant=] [layout=]` → `OK us=` + - the payload is the hex bytes or, with `-` and `len=1..512`, the firmware-generated pattern of the payload convention (`inc` default, `seed` default 0); an empty payload, `len` with hex, or `pattern`/`seed` without `len` return `ERR usage` + - a payload outside the region or starting before the first accepted sector returns `ERR range` + - a write that touches a flash word (64 bits on STM32WB55, 128 bits on STM32WBA55) that is not erased returns `ERR failed`: the drivers program whole words (padding with 0xFF), and programming a word twice between erases fails their assertion +- `flash.read
[out=hex|crc] [variant=] [layout=]` → `OK data=` or `OK len= crc=<8 hex digits>` + - anywhere in the region, `len` from 1 up to the region size; `out=hex` (default) up to 128 bytes, else `ERR range` +- `flash.stack [layout=]` → `OK` (STM32WB55) + - `starting` builds a coordinated driver of `layout` that stays until `stopped` or `fus`, and calls `WirelessStackStarting()`: its steps wait; `starting` while one is held returns `ERR busy` + - `stopped` and `fus` both call `FirmwareUpgradeServicesReady()`, the driver's only way out of `starting`, which runs a waiting step; the driver is destroyed once idle. Without a held driver they answer `OK` +- Coordinated steps: + - each step takes HSEM 7 (CPU2's flash semaphore; the tests hold it with `hsem.take 7 procid=

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

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

`: `core` is the COREID field (4 = CPU1, 8 = CPU2, 0 when free) +- `hsem.lock [hold=<2..65536 us>]` → `OK waited=`: constructs and destroys a `SynchronousHardwareSemaphoreStm` on `n` and reports how long the constructor waited (CYCCNT) + - with `hold` the firmware first takes `n` for process 1 and starts the scaffold timer (TIM17, 1 us ticks), whose interrupt releases it after `hold` us: the lock must wait that long + - a semaphore already held returns `ERR busy` (nothing could free it while the lock blocks the event loop); so do TIM17 held by another group (with `hold`) and the coordinated flash driver +- `hsem.mine ` → `OK mine=0|1` (`IsLockedByCurrentCore`, a query without side effect) + +## Backup RAM (`hal::BackupRamStm` as `hal::BackupRam`) + +- STM32WB55: RTC BKP0R-BKP19R (20 words); STM32WBA55: TAMP BKP0R-BKP15R (16 words). The words survive a system reset (`reset`, the watchdog), not a power cycle +- `bkp.info` → `OK words=` +- `bkp.write ` → `OK`: `value` a 32-bit number (decimal or `0x` hex); an index past the last word returns `ERR range` +- `bkp.read ` → `OK value=<8 hex digits>` +- `bkp.fill ` → `OK`: word i becomes `seed XOR (0x9E3779B9 × (i + 1))` (32 bits) +- `bkp.check ` → `OK mismatches=`: the words that differ from that fill + +## Low power (`hal::LowPowerModeStm`) + +- `lpm.enter [wake=] [edge=rising|falling] [marker=] [timeout=<1..10000 ms>]` → `OK woke=exti restored= us= sleeps=` + - defaults: `wake` STM32WB55 PC6 (`gpio0`), STM32WBA55 PB14 (`gpio0`); `marker` STM32WB55 PB0 (`led0`), STM32WBA55 PA2 (`tim1bkin`); `edge=rising`; `timeout=2000` + - the wake pin is an input with the pull against its edge (pull-down for `rising`) and an EXTI interrupt; the marker is an output, high, driven low while the core sleeps and high again right after it wakes + - inside the window the interrupts are masked (PRIMASK), every NVIC interrupt but the wake line's EXTI and TIM17's is disabled and the SysTick tick interrupt is off: WFI returns only on the wake edge or a TIM17 update + - the scaffold timer TIM17 counts 1 us ticks (CYCCNT stops while the core sleeps) and gives `us`, the time asleep; at `timeout` ms it ends the window with `ERR timeout` + - `sleeps` counts the `LowPowerModeStm::Enter` calls: WFI returns only on a TIM17 update (every millisecond) or the wake edge, so a core that sleeps makes about one call per elapsed millisecond plus one, a driver that returns without sleeping hundreds per millisecond + - `deep` maps to Sleep on STM32WB/WBA (`LowPowerModeStm::Stop`), so it never calls the clock-restore callback: `restored` counts its calls and is 0 + - the wake and marker pins must differ (`ERR usage`); a pin the package lacks returns `ERR pin`, a wake pin whose EXTI line serves a pin of another port `ERR unsupported`, a held pin or TIM17 held by another group `ERR busy` + - the terminal and every other interrupt (including the watchdog's early warning: a running watchdog is not fed) are blocked for the window: send nothing until the final line + +## QUADSPI (`hal::QuadSpiStm`, `variant=dma` selects `hal::QuadSpiStmDma`, `variant=spi` adds `hal::SingleSpeedQuadSpiStmDma`, NUCLEO-WB55RG only) + +- `qspi.open <1> [variant=poll|dma|spi] [prescaler=<0-255>] [size=<1-32>]` → `OK clk=` + - QUADSPI 1 is the only instance (`ERR range` otherwise); the pins come from the board profile: `qspiclk` PA3, `qspincs` PA2, `qspiio0`-`qspiio3` PB9, PB8, PA7, PA6 (all six held, `ERR busy` when one is taken) + - defaults `variant=poll prescaler=2 size=24`; `clk` is the QUADSPI kernel clock (HCLK4) divided by `prescaler + 1`; `size` is the flash size as log2 of bytes (DCR FSIZE + 1) + - `variant=poll` is the blocking `QuadSpiStm` (HAL); `variant=dma` is `QuadSpiStmDma` on DMA2 channel 3; `variant=spi` is `QuadSpiStmDma` with `SingleSpeedQuadSpiStmDma` on top, for `qspi.xfer` +- `qspi.cmd <1> [instr=<0-0xff>] [addr= [abytes=1-4]] [alt= [altbytes=1-4]] [dummy=<0-31>] [lines=1|4] [tx=|len= [pattern=] [seed=]|rx= [out=hex|crc]] [repeat=<1-8>]` → writes `OK flevel=`, reads `OK data=` (or `OK len= crc=` with `out=crc`) + - one command of `hal::QuadSpi` (`SendData` without `rx`, `ReceiveData` with it) on every variant; every phase is optional: without `instr`, `addr` and `alt` the command has no instruction, address or alternate-byte phase, and without `tx`/`len`/`rx` no data phase + - `addr` takes `abytes` bytes (default 3) and `alt` `altbytes` bytes (default 1), both sent most significant byte first; a value wider than its bytes returns `ERR range`, `abytes` without `addr` or `altbytes` without `alt` `ERR usage` + - `lines` (default 1) applies to every phase; 2 returns `ERR range` + - `tx` is the data in hex; `len` generates it (`pattern=inc|const|prbs` `seed=`, see "Line length" in General), 1-256 bytes; `rx` reads 1-256 bytes, above 128 only with `out=crc`; at most one of `tx`, `len` and `rx` + - `flevel` is the QUADSPI FIFO level read in the completion callback: 0 once the last byte has left the pins + - `repeat` issues the write again from each completion callback (writes only) and answers after the last one + - a read with a data phase only hangs the QUADSPI with BUSY set (STM32 QUADSPI erratum "cannot be used in indirect read mode when only data phase is activated"): `variant=dma` answers `ERR timeout`, `variant=poll` blocks the firmware; add `dummy=2` to such a read, the erratum's workaround, which drives no IO line either +- `qspi.poll <1> match= mask= [size=1-4] [instr=] [addr=] [abytes=] [alt=] [altbytes=] [dummy=] [lines=1|4]` → `OK` + - `hal::QuadSpi::PollStatus`: reads `size` status bytes (default 1) until the status masked with `mask` equals `match` + - `variant=dma`: `ERR timeout` after 2000 ms; the group then answers `ERR busy` until `qspi.close`, which stops the polling (`~QuadSpiStmDma` clears CR) + - `variant=poll` blocks the firmware until the status matches (`QuadSpiStm` waits with `HAL_MAX_DELAY`, a known gap) +- `qspi.xfer <1> [len= [pattern=] [seed=]] [rx=<1-128>] [repeat=<1-8>]` → writes `OK flevel=`, reads `OK rx=` + - `variant=spi` only (`ERR unsupported` otherwise): `SingleSpeedQuadSpiStmDma::SendAndReceive`, SPI mode 0 with MOSI on IO0, MISO on IO1 and NCS as chip select + - half duplex: exactly one of the data (`txhex` or `len`) and `rx`, else `ERR usage`; `repeat` as for `qspi.cmd` + - a read has a data phase only, so the erratum above applies to it +- `qspi.close <1>` → `OK`: also resets the QUADSPI (RCC), so one left BUSY by a timed-out command (the erratum above) is idle at the next `qspi.open` +- `qspi.cmd`, `qspi.poll` and `qspi.xfer` answer within 2000 ms or `ERR timeout`; a command that timed out keeps the group busy until `qspi.close` +- while a command is in flight, `qspi.cmd` returns `ERR busy` before checking its data arguments (`tx`, `len`, `pattern`, `seed`, `rx`, `out`, `repeat`) and `qspi.xfer` before its payload, `rx` and `repeat` (the command in flight uses the data buffer they fill); `qspi.xfer` reports `ERR unsupported` before that + ## Not available on these boards -hal-st has no comparator, CAN, EEPROM or Ethernet driver for STM32WB55/STM32WBA55, so `comp.open`, `comp.read`, `comp.irq`, `comp.count`, `comp.close`, `can.open`, `can.send`, `can.close`, `eeprom.write`, `eeprom.read`, `eeprom.erase`, `eth.open`, `eth.status` and `eth.close` return `ERR unsupported`. +hal-st has no comparator, CAN or Ethernet driver for STM32WB55/STM32WBA55, so `comp.open`, `comp.read`, `comp.irq`, `comp.count`, `comp.close`, `can.open`, `can.send`, `can.close`, `eth.open`, `eth.status` and `eth.close` return `ERR unsupported`. +The groups one MCU lacks return `ERR unsupported` on that MCU: + +- STM32WB55: `lptpwm.open`, `lptpwm.duty`, `lptpwm.pulse`, `lptpwm.start`, `lptpwm.stop`, `lptpwm.close` (`hal::LpTimerPwmStm` is not built for STM32WB). +- STM32WBA55: `hsem.take`, `hsem.release`, `hsem.status`, `hsem.lock`, `hsem.mine` (no hardware semaphore), `qspi.open`, `qspi.cmd`, `qspi.poll`, `qspi.xfer`, `qspi.close` (no QUADSPI), `flash.stack` (no wireless coprocessor to coordinate the flash with), `clock.mco` (the only MCO pin is the terminal RX) and `clock.hsi48` (no HSI48). + +The `eeprom` commands are served by an external 24Cxx EEPROM on the I2C bus (see the EEPROM section). diff --git a/validation/README.md b/validation/README.md index b30c50ff..4f8ccaec 100644 --- a/validation/README.md +++ b/validation/README.md @@ -7,7 +7,8 @@ The structure follows hal-ti's `validation/`; the firmware runs on EMIL's hardwa - `firmware/` - C++ firmware on hal-st and EMIL. It exposes the hal-st peripherals through a line-based terminal; the command set is specified in [PROTOCOL.md](PROTOCOL.md). - `host/` - Python package `hal_st_validation` and a pytest suite that talks to the firmware terminal over a serial port and to the AD3 through the WaveForms SDK. - The generic bench code (AD3 wrapper over the WaveForms SDK, signal analysis, `OK`/`ERR`/`EVT` terminal client, console, pytest plugin and fakes) lives in the separate [ad3-waveforms-bench](https://github.com/embedded-pro/ad3-waveforms-bench) repository; `hal_st_validation` only adds what is specific to hal-st. -- hal-st has no comparator, CAN, EEPROM or Ethernet driver for these MCUs, so those commands answer `ERR unsupported`; I2C, flash, RNG and AES have no command group in EMIL's terminal yet and are not validated here. +- hal-st has no comparator, CAN or Ethernet driver for these MCUs, so those commands answer `ERR unsupported`, as do the command groups one MCU lacks (PROTOCOL.md, "Not available on these boards"). + Peripherals EMIL's terminal has no command group for (I2C, timers, LPTIM, QUADSPI, flash, RNG, AES, PKA and others) get command groups of the validation firmware; the `eeprom` commands are EMIL's, served by an external 24Cxx EEPROM on the I2C bus. ## Why C++ and Python @@ -30,14 +31,22 @@ The structure follows hal-ti's `validation/`; the firmware runs on EMIL's hardwa - The Nucleo is powered from its ST-LINK USB port. The AD3 V+/V- supplies stay off unless `ad3.vplus`/`ad3.vminus` are set in the board file. - Wavegen outputs are refused outside `ad3.analog_limits` (0..3.3 V by default) so a wrong parameter cannot overdrive an analog input. - Run the tests with nothing else connected to the pins of the selected wiring sets: the tests drive them directly. -- Each board has fixed wiring bundles (tables below); wire one, run its tests, then switch: +- Each board has fixed wiring bundles (tables in [Wiring sets](#wiring-sets)); wire one, run its tests, then switch: - `bundle1` wires all 16 DIOs and W1/W2 on two ADC inputs, and runs nearly the whole suite. - - `bundle2` (NUCLEO-WB55RG only) moves DIO9/DIO10 to the LPTIM1 inputs PC0/PC2 for the LPTIM encoder tests; W2 and scope 2 are unplugged because PC2 is then driven by a DIO. + - `bundle2` of the NUCLEO-WB55RG moves DIO9/DIO10 to the LPTIM1 inputs PC0/PC2 for the LPTIM encoder tests; W2 and scope 2 are unplugged because PC2 is then driven by a DIO. + - `bundle2` of the NUCLEO-WBA55CG moves DIO8, DIO9, DIO11 and DIO14 to PA7, PA6, PB8 and PA0 for SPI3, TIM2 CH3/CH4, TIM16 CH1N and the LPTIM1 encoder, and carries the I2C and SPI loop options; W1, W2 and both scopes are unplugged (with `--with i2c` the scopes measure the I2C rise time). +- Optional wiring is fitted on top of a bundle and enabled with `--with ` (see [Optional wiring](#optional-wiring)): + - `loopback` - an SPI1 MOSI-MISO jumper (both boards, both bundles); + - `i2c` - I2C1 to I2C3 with external pull-ups and a 24Cxx EEPROM on a breadboard (NUCLEO-WB55RG `bundle1`, NUCLEO-WBA55CG `bundle2`); + - `spiloop` - SPI1 to SPI2 (NUCLEO-WB55RG `bundle1`) or SPI1 to SPI3 (NUCLEO-WBA55CG `bundle2`); not together with `loopback`. +- Fit an option only together with its `--with ` and remove it otherwise: its wiring loads pins that other tests drive. Start every new setup with the [bench bring-up](#bench-bring-up), which checks the wiring before the suite runs. - Each pin serves several tests: the TIM1 outputs are also SPI and encoder pins, the LPUART1 CTS pin is also the SPI1 MISO, and so on. The firmware frees every pin between tests, so a pin can change role from one test to the next. -- Tests whose connections are missing from the selected sets are skipped with the reason. +- Tests whose connections are missing from the selected sets are skipped with the reason, and so are tests on pins an enabled option loads ([Which tests skip, and how](#which-tests-skip-and-how)). - The terminal is USART1 on the ST-LINK virtual COM port (`/dev/ttyACM0`, `COMx`): PB6/PB7 on the NUCLEO-WB55RG, PB12/PA8 on the NUCLEO-WBA55CG, at 921600 baud. If the ST-LINK of a board cannot keep up with 921600 baud, change `terminalBaudRate` in `firmware/boards//BoardProfile.hpp` and `terminal.baud` in the board file together. -- The NUCLEO-WB55RG positions come from its user manual, UM2435 Rev 2 (board MB1355C: Table 10, Table 11, Fig. 8 and Fig. 24), cross-checked with the STM32CubeWB example readmes. A later board revision (MB1355D, user manual UM2819) may differ: check the solder bridges named in the table (SB1, SB5, SB8, SB11, SB14, SB15, SB41) before wiring. +- The NUCLEO-WB55RG positions come from its user manual, UM2435 Rev 2 (board MB1355C: Table 10, Table 11, Fig. 8 and Fig. 24), cross-checked with the STM32CubeWB example readmes. A later board revision (MB1355D, user manual UM2819) may differ: check the solder bridges named in the table (SB1, SB5, SB8, SB9, SB11, SB12, SB14, SB15, SB41) before wiring. +- The NUCLEO-WBA55CG user manual, UM3301, could not be consulted: its positions come from the STM32CubeWBA example readmes, Zephyr's Arduino connector map and modm-data's transcription of UM3301 Table 8, and its 3V3 and GND pins from a single source (the STM32-Sidewalk-SDK README). + Each position names its source; check the silkscreen and do the pre-power check of the bring-up before fitting the I2C option. - NUCLEO-WB55RG: the firmware runs on the Cortex-M4 alone and never starts the wireless coprocessor; the linker script keeps it below the flash and SRAM the wireless stack uses. ## Build and flash the firmware @@ -58,7 +67,7 @@ The artifacts are `build//validation/firmware/RelWithDebInfo/hal_st.vali STM32_Programmer_CLI -c port=SWD -w build/stm32wb55/validation/firmware/RelWithDebInfo/hal_st.validation_firmware.hex -v -rst ``` -After reset the firmware prints `EVT boot board=... family=... sysclk=... reset=...`, and the debug LED (WB55 blue LD1, WBA55 red LD3) blinks. +After reset the firmware prints `EVT boot board=... family=... sysclk=... reset=...`, and the debug LED blinks: the blue LD1 on the NUCLEO-WB55RG, the green LD2 on the NUCLEO-WBA55CG, which is not connected on a stock board (SB28 open) and stays dark unless SB28 is closed. ## Install the host package @@ -95,6 +104,7 @@ Hardware tests need `--port`; without it they are skipped. Select the board file pytest validation/host/tests/hil --board nucleo_wb55rg --port /dev/ttyACM0 --wiring-set bundle1 --depth quick pytest validation/host/tests/hil --board nucleo_wb55rg --port /dev/ttyACM0 --wiring-set bundle1 --depth full pytest validation/host/tests/hil/test_qei.py --board nucleo_wb55rg --port /dev/ttyACM0 --wiring-set bundle2 +pytest validation/host/tests/hil/test_qei.py validation/host/tests/hil/test_timer_pwm.py --board nucleo_wba55cg --port /dev/ttyACM0 --wiring-set bundle2 pytest validation/host/tests/hil --board nucleo_wba55cg --port /dev/ttyACM0 --wiring-set bundle1 pytest validation/host/tests/hil --board nucleo_wb55rg --port /dev/ttyACM0 --no-ad3 ``` @@ -104,10 +114,11 @@ Run with `--depth quick` first; once it passes, run it again with `--depth full` Options from `tests/conftest.py`: - `--board` - board file name in `host/boards/` or a path to a YAML file (default `nucleo_wb55rg`, or `HAL_ST_BOARD`). +- `--board-extra ` - YAML deep-merged over the board file, repeatable (or `HAL_ST_BOARD_EXTRA`, a list separated by `os.pathsep`): mappings merge, lists append, a scalar replaces, and a key ending in `!` replaces the value of the key without it (`limit_pins!: [...]`). - `--port`, `--baud` - firmware terminal serial port (or `HAL_ST_PORT`) and baud rate (default from the board file, 921600). - `--command-timeout` - seconds to wait for a reply (or `HAL_ST_COMMAND_TIMEOUT`, default from the board file); raise it for slow links such as port-bridge. - `--wiring-set a,b` - active wiring sets (or `HAL_ST_WIRING`). -- `--with ` - enable optional wiring (`loopback` for the SPI MOSI-MISO jumper), repeatable. +- `--with ` - enable optional wiring, repeatable. The tag must be an option of a selected `--wiring-set`, and options that exclude each other cannot be enabled together; otherwise the run stops with a usage error. - `--depth quick|full` - `quick` (default, or `HAL_ST_DEPTH`) runs a pairwise subset of every parameter matrix: every pair of values of any two parameters appears in at least one test. `full` runs the complete cartesian products. - `--set path=value` - override a test parameter, value parsed as YAML: `--set pwm.waveform.freq=[20000] --set uart.transfer.baud=[921600]`. - `--run-known-gaps` - also run the tests of known driver gaps that abort or hang the firmware (see [Known driver gaps](#known-driver-gaps)); they are skipped by default. @@ -116,12 +127,14 @@ Options from the `ad3_waveforms_bench` pytest plugin (loaded automatically once - `--ad3-serial` - pick an AD3 by serial number (or `AD3_SERIAL`); `--ad3-remote host[:port]` uses an AD3 on another machine (or `AD3_REMOTE`); `--no-ad3` skips every test that needs it. - `--fake` - run the HIL plumbing against the in-memory fakes (`FakeDwfApi` for the AD3, `hal_st_validation.fake_firmware` for the terminal); only useful when changing the test code. - - The fake firmware validates arguments in the firmware's order and models pins, instances, timer sharing, EXTI line ownership, ADC triggers and the watchdog. + - The fake firmware validates arguments in the firmware's order and models pins, instances, timer sharing, the peripherals and DMA channels groups share, EXTI line ownership, ADC triggers and the watchdog; the groups of `fakes/*.py` add the newer command groups. - It models no measured signal, so most tests that read the AD3 fail under `--fake --wiring-set ...`; `--fake --no-ad3` passes completely. Tests that need no AD3 (system, argument errors, limits, instance and timer sharing, watchdog behaviour) run with any wiring set. +Optional wiring loads pins: a test that resolves an AD3 channel (`need.dio`, `need.wavegen`, `need.scope`) for a pin an enabled option loads skips ("pin X loaded by --with T") unless it is marked `@pytest.mark.uses_option("T")` or `@pytest.mark.requires_option("T")`. +Pins an option ties to a channel with a jumper resolve only for such tests. `requires_option` skips the test without the option, `conflicts_option` skips it with the option. Tests that reset the board on purpose (watchdog, UART swap) are marked `resets_board`; any other unexpected `EVT boot` fails the test that caused it. -Every instance a test opened is closed afterwards and the AD3 outputs are released, so tests are independent (the firmware keeps at most one PWM timer, UART, SPI, ADC, encoder and watchdog open at a time). +Every instance a test opened is closed afterwards and the AD3 outputs are released, so tests are independent (the firmware keeps at most one instance of each group open at a time, see the Framing section of PROTOCOL.md). Use `-k`, `-m "not slow"` and `--junitxml report.xml` as usual. ## Known driver gaps @@ -140,6 +153,19 @@ test: PH3 (BOOT0) is the only port H pin on both boards and is reserved. Fix the driver, then remove its entry from both board files so the tests guard the fix. +### Known gaps needing a decision + +These behaviours are not fixed here because a fix changes an API or behaviour other users rely on; the tests assert the current behaviour or mark it as a known gap until a decision is made. + +- `SpiMasterStm`, `SpiMasterStmDma`, `SynchronousSpiMasterStm` mux `slaveSelect` to NSS but initialise `SPI_NSS_SOFT` and keep SPE on from construction (`SpiMasterStm.cpp:24,56`, `SpiMasterStmDma.cpp:38`, `SynchronousSpiMasterStm.cpp:23`): hardware NSS framing needs SPE toggled per transfer in three drivers; `test_spi_ext.py::test_hardware_nss*` is a known gap. +- `SpiDataSizeConfiguratorStm` with 9- to 16-bit frames makes `SpiMasterStmDma` move 16-bit DMA words (`SpiMasterStmDma.cpp:109-129`), so each frame takes two buffer bytes, little endian; the tests assert that model (`spiwords.spi_frames`) with even lengths. + An odd byte count has no defined result: the last byte is dropped on the STM32WB55, and the GPDMA of the STM32WBA55 gets a count that is not a multiple of its 16-bit data width. +- `QuadSpiStm` waits with `HAL_MAX_DELAY` (`QuadSpiStm.cpp:52,73,97`): a `PollStatus` that never matches hangs the firmware; `test_qspi.py::test_poll_timeout[variant=poll]` is a hanging known gap. +- `AnalogToDigitalPinImplStm` and `AnalogToDigitalInternalTemperatureStm` ignore `numberOfSamples` (`AnalogToDigitalPinStm.cpp:90-165`) and always take one sample; the tests assert one. +- The default sampling time of `AnalogToDigitalInternalTemperatureStm` (`AnalogToDigitalPinStm.hpp:21-27`) is below the temperature sensor's minimum; the tests pass a long `sampling=` and check only that the default delivers a code. +- `TransmitDmaBridgeChannel` and `ReceiveDmaBridgeChannel` (`DmaStm.cpp:1176-1203`) never start on DMA v1 (WB55), the transmit bridge copies destination to source on GPDMA, and the memory side keeps byte width and increment; there is no in-tree user and no command, so they stay untested. +- `LpTimerPwmStm` writes CCR = ARR x duty / 100 with the output polarity high, which may give a duty of 100 - d on the LPTIM; the tests assert d, and if the bench measures 100 - d the test becomes a known gap. + ## Windows host and Docker (bridge mode) Use this when the build and the tests run in the hal-st devcontainer (Docker) but the AD3, the Nucleo's virtual COM port and its ST-LINK are plugged into a Windows PC. Docker cannot reach those USB devices, so Windows shares them over TCP and the container uses them through `host.docker.internal` (the devcontainer maps that name to the host): @@ -206,99 +232,302 @@ pytest validation/host/tests/hil --board nucleo_wb55rg --wiring-set bundle1 --de ## Wiring sets -The tables follow the `wiring_sets` of the board files (keep both in step); the notes list every function a pin serves. Scope inputs are single ended: connect the `-` input of each used scope channel to GND. +The tables follow the `wiring_sets` of the board files (keep both in step); the notes list every function a pin serves, and every header position names its source. Scope inputs are single ended: connect the `-` input of each used scope channel to GND. + +### Physical rules + +- One dupont housing per header pin. AD3 leads (female) sit on male morpho pins; jumper ends go to the free female Arduino sockets (male-ended wire) or to free morpho pins (female-ended wire). +- Where the only header position of a pin already carries an AD3 lead (NUCLEO-WB55RG PB12 at CN10-16, NUCLEO-WBA55CG PB8 at CN4-38), the net goes through a breadboard row: one male-female wire from the header pin to the row, the AD3 lead on a male pin in that row, and the jumper into the same row. +- The I2C option is built on a breadboard: an SCL row, an SDA row, a 3V3 rail and a GND rail. Each rail is fed by one wire from one named header pin; the pull-ups, the EEPROM VCC and GND and (NUCLEO-WBA55CG) the AD3 ground share the rail, never a header pin. +- Fit option wiring only together with its `--with `, and remove it otherwise. `test_wiring.py` fails when it finds the jumpers of an option that is not enabled (`pass --with or remove the wiring`). + +### Parts for the I2C option + +- EEPROM: Microchip 24LC256 or AT24C256 (DIP-8, 3.3 V, 32 KiB, 64-byte pages, 16-bit word address, 400 kHz, 5 ms write cycle). Pins 1-3 (A0-A2) to the GND rail (address 0x50), 4 (VSS) to the GND rail, 5 (SDA) to the SDA row, 6 (SCL) to the SCL row, 7 (WP) to the GND rail (writes enabled), 8 (VCC) to the 3V3 rail; 100 nF from VCC to GND at the part. +- Two 4.7 kOhm resistors (SCL row to the 3V3 rail, SDA row to the 3V3 rail), and two 2.2 kOhm as a fallback. +- A half-size breadboard, 8 male-male and 8 male-female dupont wires, a strip of 2.54 mm male pin headers (to put AD3 leads and scope probes into breadboard rows) and a multimeter. +- Rise time: a NUCLEO-WB55RG bus line (one AD3 DIO, two MCU pins, the EEPROM and the breadboard, about 50 pF) rises in about 0.85 x 4.7 kOhm x 50 pF = 200 ns, under the 300 ns of Fast mode. The NUCLEO-WBA55CG `bundle2` lines carry two AD3 DIOs and a scope probe each (80-100 pF, 320-400 ns): `test_rise_time` measures them; when it fails, fit the 2.2 kOhm resistors (150-190 ns). +- The board file's `tests.eeprom` holds `addr`, `size`, `page`, `abytes`, `wcycle_ms` and `erase_size`, so another part is a YAML change (a 24C02: `size: 256`, `page: 8`, `abytes: 1`). + +### Bench bring-up + +1. With the board unplugged, wire the bundle and the options you will enable; keep the terminal, SWD and BOOT0 pins free. +2. For `--with i2c`, build the breadboard without the EEPROM, plug the board in and measure 3.3 V between the 3V3 and GND rails (multimeter, or AD3 scope 1 with its leads on the rails). Unplug, insert the EEPROM, plug in again. +3. Flash the firmware and run the wiring self-check first, with the sets and options of the run that follows: + + ```bash + pytest validation/host/tests/hil/test_wiring.py --board nucleo_wb55rg --port /dev/ttyACM0 --wiring-set bundle1 --with i2c --with spiloop + pytest validation/host/tests/hil/test_wiring.py --board nucleo_wba55cg --port /dev/ttyACM0 --wiring-set bundle2 --with i2c + ``` + + With the AD3 outputs and pulls off it uses GPIO commands only: each jumper of an enabled option conducts both ways, its pull-ups sit on a live 3V3 rail, the jumpers of offered options that are not enabled are absent, and the pins of `tests.wiring.undriven` follow both MCU pulls (NUCLEO-WBA55CG: no solder bridge connects an ST-LINK line to PA0, PB9, PA10, PB5 or PB15). +4. Run the suite with the same `--wiring-set` and `--with`, `--depth quick` first: + + ```bash + pytest validation/host/tests/hil --board nucleo_wb55rg --port /dev/ttyACM0 --wiring-set bundle1 --with i2c --with spiloop --depth quick + pytest validation/host/tests/hil --board nucleo_wba55cg --port /dev/ttyACM0 --wiring-set bundle2 --with i2c --with spiloop --depth quick + ``` ### NUCLEO-WB55RG wiring -`CN7-n`/`CN10-n` are the male ST morpho pins and the easiest to reach with AD3 flywires; `Dn`/`An` are the same nets on the female Arduino sockets. "via SBn" names a solder bridge that is closed on a stock board. PB3 (SWO) is on no header of a stock board, so TIM2 CH2 and the TIM2 encoder B use PA1. - -| Wiring set | AD3 | Pin | Nucleo header | Note | -|------------|-----------------|-------------------|----------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------| -| both | GND | GND | CN10-9 and CN10-20 | common ground, at least two leads | -| `bundle1` | DIO0 | PA8 (tim1ch1) | CN10-25 (D6) | TIM1 CH1, encoder TIM1 A, GPIO | -| `bundle1` | DIO1 | PA7 (tim1ch1n) | CN10-15 via SB1 (D11) | TIM1 CH1N, SPI1 MOSI, GPIO | -| `bundle1` | DIO2 | PA9 (tim1ch2) | CN10-19 via SB8 (D9) | TIM1 CH2, encoder TIM1 B, GPIO | -| `bundle1` | DIO3 | PB8 (tim1ch2n) | CN10-3 (D15) | TIM1 CH2N, GPIO | -| `bundle1` | DIO4 | PA10 (tim1ch3) | CN10-31 via SB11 (D3) | TIM1 CH3, TIM17 break, GPIO | -| `bundle1` | DIO5 | PB9 (tim1ch3n) | CN10-5 (D14) | TIM1 CH3N, TIM17 CH1, GPIO; CN10-6 next to it is the terminal RX (PB7) | -| `bundle1` | DIO6 | PA5 (spi1clk) | CN10-11 (D13) | SPI1 SCK, GPIO | -| `bundle1` | DIO7 | PA6 (spi1miso) | CN10-13 (D12) | SPI1 MISO, LPUART1 CTS, TIM16 CH1, EXTI line 6 partner of PC6, GPIO | -| `bundle1` | DIO8 | PA4 (spi1cs) | CN10-17 via SB5 (D10 via SB41) | SPI1 chip select, GPIO | -| `bundle1` | DIO9 | PA15 (qei2a) | CN10-27 (D5) | TIM2 CH1, encoder TIM2 A, GPIO | -| `bundle1` | DIO10 | PA1 (qei2b) | CN7-32 via SB14 (A2) | TIM2 CH2, encoder TIM2 B, GPIO | -| `bundle1` | DIO11 | PC6 (gpio0) | CN10-33 (D2) | GPIO/EXTI, encoder TIM1 and TIM2 index, watchdog warning toggle; CN10-34 next to it is the terminal TX (PB6) | -| `bundle1` | DIO12 | PA2 (lpuart1tx) | CN10-35 via SB15 (D1) | LPUART1 TX, TIM2 CH3, GPIO | -| `bundle1` | DIO13 | PA3 (lpuart1rx) | CN10-37 (D0) | LPUART1 RX, TIM2 CH4, GPIO | -| `bundle1` | DIO14 | PB12 (lpuart1rts) | CN10-16 | LPUART1 RTS, TIM1 break, GPIO | -| `bundle1` | DIO15 | PB0 (led0) | CN10-22 | green LED2 output, GPIO; LED2 and its 680 ohm resistor load the pin | -| `bundle1` | W1 | PC3 (ain4) | CN7-36 (A4) | ADC1 IN4 | -| `bundle1` | W2 | PC2 (ain3) | CN7-38 (A5) | ADC1 IN3 | -| `bundle1` | Scope 1+ | PC3 (ain4) | A4 (CN8-5) | same net as W1 | -| `bundle1` | Scope 2+ | PC2 (ain3) | A5 (CN8-6) | same net as W2 | -| `bundle1` | Scope 1-, 2- | GND | CN7-19, CN7-20 (or CN7-8) | not CN7-22: Fig. 24 calls it GND, the schematic leaves it unconnected | -| `bundle1` | - | - | CN10-15 to CN10-13 | `--with loopback`: jumper PA7 (SPI1 MOSI) to PA6 (SPI1 MISO), or D11 to D12; the SPI tests then leave DIO7 an input and check the read-back | -| `bundle1` | - | - | - | D10 and CN10-17 carry PA4 with the default solder bridges (SB41 and SB5 closed, SB42 and SB6 open; PB10 otherwise) | -| `bundle1` | - | - | CN10-6, CN10-34, CN7-7, CN7-13, CN7-15 | keep free: terminal RX/TX to the ST-LINK (PB7, PB6), BOOT0 (PH3) and SWD (PA13, PA14) | -| `bundle2` | everything else | as `bundle1` | | including GND, the loopback option and the D10 note | -| `bundle2` | DIO9 | PC0 (lptim1in1) | CN7-28 (A0) | LPTIM1 IN1, encoder LPTIM1 A, ADC1 IN1 | -| `bundle2` | DIO10 | PC2 (lptim1in2) | CN7-38 (A5) | LPTIM1 IN2, encoder LPTIM1 B, ADC1 IN3; W2 and scope 2 unplugged | +`CN7-n`/`CN10-n` are the male ST morpho pins and the easiest to reach with AD3 flywires; `Dn`/`An` are the same nets on the female Arduino sockets. +"via SBn" names a solder bridge that is closed on a stock board. PB3 (SWO) is on no header of a stock board, so TIM2 CH2 and the TIM2 encoder B use PA1. Positions are from UM2435 Rev 2: Table 10 (Arduino), Table 11 (morpho), Fig. 8 (solder bridges) and Fig. 24. + +| Wiring set | AD3 | Pin | Nucleo header | Note | +|------------|-----------------|-------------------|----------------------------------------|--------------------------------------------------------------------------------------------------------------------| +| both | GND | GND | CN10-9 and CN10-20 | common ground, at least two leads | +| `bundle1` | DIO0 | PA8 (tim1ch1) | CN10-25 (D6) | TIM1 CH1, encoder TIM1 A, MCO, GPIO | +| `bundle1` | DIO1 | PA7 (tim1ch1n) | CN10-15 via SB1 (D11) | TIM1 CH1N, SPI1 MOSI, I2C3 SCL, QUADSPI IO2, GPIO | +| `bundle1` | DIO2 | PA9 (tim1ch2) | CN10-19 via SB8 (D9) | TIM1 CH2, encoder TIM1 B, I2C1 SCL, GPIO | +| `bundle1` | DIO3 | PB8 (tim1ch2n) | CN10-3 (D15) | TIM1 CH2N, I2C1 SCL, QUADSPI IO1, GPIO | +| `bundle1` | DIO4 | PA10 (tim1ch3) | CN10-31 via SB11 (D3) | TIM1 CH3, TIM17 break, I2C1 SDA, GPIO | +| `bundle1` | DIO5 | PB9 (tim1ch3n) | CN10-5 (D14) | TIM1 CH3N, TIM17 CH1, I2C1 SDA, QUADSPI IO0, GPIO; CN10-6 next to it is the terminal RX (PB7) | +| `bundle1` | DIO6 | PA5 (spi1clk) | CN10-11 (D13) | SPI1 SCK, GPIO | +| `bundle1` | DIO7 | PA6 (spi1miso) | CN10-13 (D12) | SPI1 MISO, LPUART1 CTS, TIM16 CH1, QUADSPI IO3, EXTI line 6 partner of PC6, GPIO | +| `bundle1` | DIO8 | PA4 (spi1cs) | CN10-17 via SB5 (D10 via SB41) | SPI1 chip select and NSS, GPIO | +| `bundle1` | DIO9 | PA15 (qei2a) | CN10-27 (D5) | TIM2 CH1, encoder TIM2 A, GPIO | +| `bundle1` | DIO10 | PA1 (qei2b) | CN7-32 via SB14 (A2) | TIM2 CH2, encoder TIM2 B, GPIO | +| `bundle1` | DIO11 | PC6 (gpio0) | CN10-33 (D2) | GPIO/EXTI, encoder TIM1 and TIM2 index, watchdog warning toggle; CN10-34 next to it is the terminal TX (PB6) | +| `bundle1` | DIO12 | PA2 (lpuart1tx) | CN10-35 via SB15 (D1) | LPUART1 TX, TIM2 CH3, QUADSPI NCS, GPIO | +| `bundle1` | DIO13 | PA3 (lpuart1rx) | CN10-37 (D0) | LPUART1 RX, TIM2 CH4, QUADSPI CLK, GPIO | +| `bundle1` | DIO14 | PB12 (lpuart1rts) | CN10-16 | LPUART1 RTS, TIM1 break, SPI2 NSS, GPIO; with `--with spiloop` the lead sits in the PB12 breadboard row | +| `bundle1` | DIO15 | PB0 (led0) | CN10-22 | green LED2 output, GPIO; LED2 and its 680 ohm resistor load the pin | +| `bundle1` | W1 | PC3 (ain4) | CN7-36 (A4) | ADC1 IN4 | +| `bundle1` | W2 | PC2 (ain3) | CN7-38 (A5) | ADC1 IN3 | +| `bundle1` | Scope 1+ | PC3 (ain4) | A4 (CN8-5) | same net as W1 | +| `bundle1` | Scope 2+ | PC2 (ain3) | A5 (CN8-6) | same net as W2 | +| `bundle1` | Scope 1-, 2- | GND | CN7-19, CN7-20 (or CN7-8) | not CN7-22: Fig. 24 calls it GND, the schematic leaves it unconnected | +| `bundle1` | - | - | - | D10 and CN10-17 carry PA4 with the default solder bridges (SB41 and SB5 closed, SB42 and SB6 open; PB10 otherwise) | +| `bundle1` | - | - | CN10-6, CN10-34, CN7-7, CN7-13, CN7-15 | keep free: terminal RX/TX to the ST-LINK (PB7, PB6), BOOT0 (PH3) and SWD (PA13, PA14) | +| `bundle2` | everything else | as `bundle1` | | including GND, the loopback option and the D10 note | +| `bundle2` | DIO9 | PC0 (lptim1in1) | CN7-28 (A0) | LPTIM1 IN1, encoder LPTIM1 A, ADC1 IN1 | +| `bundle2` | DIO10 | PC2 (lptim1in2) | CN7-38 (A5) | LPTIM1 IN2, encoder LPTIM1 B, ADC1 IN3; W2 and scope 2 unplugged | ### NUCLEO-WBA55CG wiring -| Wiring set | AD3 | Pin | Note | -|------------|----------|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------| -| `bundle1` | DIO0 | PB4 (spi1clk) | SPI1 SCK, TIM1 CH3, blue LED (led0), GPIO (D13) | -| `bundle1` | DIO1 | PB3 (spi1miso) | SPI1 MISO, TIM1 CH4, TIM17 CH1N, GPIO (D12) | -| `bundle1` | DIO2 | PA15 (spi1mosi) | SPI1 MOSI, encoder TIM1 index, TIM17 break, GPIO (D11) | -| `bundle1` | DIO3 | PA12 (spi1cs) | SPI1 chip select, TIM1 CH2, encoder TIM1 B, GPIO (D10) | -| `bundle1` | DIO4 | PA11 (tim1ch1) | TIM1 CH1, encoder TIM1 A, USART2 RX, GPIO (D4) | -| `bundle1` | DIO5 | PB2 (tim1ch1n) | TIM1 CH1N, USART2 CTS, GPIO (D15) | -| `bundle1` | DIO6 | PB1 (tim1ch2n) | TIM1 CH2N, USART2 RTS, GPIO (D14) | -| `bundle1` | DIO7 | PB0 (tim1ch3n) | TIM1 CH3N, USART2 TX, GPIO (D6) | -| `bundle1` | DIO8 | PB5 (lpuart1tx) | LPUART1 TX, GPIO (D1) | -| `bundle1` | DIO9 | PA10 (lpuart1rx) | LPUART1 RX, TIM3 CH1, encoder TIM3 A, GPIO (D0) | -| `bundle1` | DIO10 | PB9 (lpuart1rts) | LPUART1 RTS, TIM3 CH4, TIM16 CH1, GPIO (D7) | -| `bundle1` | DIO11 | PB15 (lpuart1cts) | LPUART1 CTS, TIM16 break, GPIO (D8) | -| `bundle1` | DIO12 | PA1 (tim3ch2) | TIM3 CH2, encoder TIM3 B, TIM17 CH1, GPIO (A3) | -| `bundle1` | DIO13 | PB14 (gpio0) | GPIO/EXTI, TIM3 CH3, encoder TIM3 index, watchdog warning toggle (D5) | -| `bundle1` | DIO14 | PA2 (tim1bkin) | TIM1 break input, GPIO (A2) | -| `bundle1` | DIO15 | PA5 (tim2ch1) | TIM2 CH1, GPIO (A4) | -| `bundle1` | W1 | PA7 (ain2) | ADC4 IN2 (A0) | -| `bundle1` | W2 | PA6 (ain3) | ADC4 IN3 (A1) | -| `bundle1` | Scope 1+ | PA7 (ain2) | | -| `bundle1` | Scope 2+ | PA6 (ain3) | | -| `bundle1` | - | - | `--with loopback`: jumper PA15 (SPI1 MOSI, D11) to PB3 (SPI1 MISO, D12); the SPI tests then leave DIO1 an input and check the read-back | -| `bundle1` | - | - | PB4 (D13) also drives the blue LED LD1, which loads the SPI1 clock line | +The board manual, UM3301, could not be consulted, so every position names its source: + +- Arduino labels (Dn, An) are from Zephyr's `boards/st/nucleo_wba55cg/arduino_r3_connector.dtsi`. +- Morpho positions (CN3, CN4) and Arduino socket positions (CN6, CN7, CN8) are from the STM32CubeWBA v1.10.0 example readmes of `Projects/NUCLEO-WBA55CG`; "readmes (n)" is the number of readmes that give the position. +- CN4-35 and CN4-37 are from modm-data's transcription of UM3301 Table 8; 3V3 (CN5-4) and GND (CN5-6) are from the STM32-Sidewalk-SDK README (`NUCLEO-WBAxx`: `VDD 3V3 | CN5, pin 4`, `GND | CN5, pin 6`), a single source. +- The A1 and A4 sockets are named by their label only; DIO11 and DIO13 have no sourced morpho position and use a male pin in their Arduino socket. Every other AD3 lead sits on a morpho pin, so the Arduino sockets stay free for the option jumpers. + +Check before wiring (modm-data, UM3301 Table 8): on MB1801, SB7/SB8 connect PA10/PB5 to the ST-LINK VCP2; SB25 with MB1803 SB8 on and SB9 off routes STLINK_RTS to PA0 (with MB1803 SB32 on and SB31 off to PB15); SB23/SB25 route STLINK_CTS to PB9. +None is the stock setting; `test_wiring.py::test_no_foreign_drivers` checks that PA0, PB9, PA10, PB5 and PB15 follow the MCU pulls with the AD3 released. + +Board loads: PB4 drives the blue LD1, which loads the SPI1 clock line. PB8 carries the red LD3 (active low, Zephyr `nucleo_wba55cg.dts`): the LED and its resistor pull PB8 towards 3V3, so PB8 stays out of the pull-level and idle assertions and the `bundle2` TIM16 entry has no break input. +PA9 is the green LD2, the firmware's debug LED, which stays dark unless SB28 is closed (open on a stock board). + +| Wiring set | AD3 | Pin | Nucleo header | Source | Note | +|------------|-----------------|------------------------------|-----------------------------------|---------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| both | GND | GND | CN5-6 | Sidewalk SDK README | common ground; CN5-6 is the only sourced GND pin, so scope 1-/2- share it through a breadboard GND rail fed from CN5-6 (with `--with i2c`, the rail of the option) | +| `bundle1` | DIO0 | PB4 (spi1clk) | CN4-6 (D13) | readmes (15) | SPI1 SCK, TIM1 CH3, blue LED LD1 (led0), GPIO | +| `bundle1` | DIO1 | PB3 (spi1miso) | CN4-10 (D12) | readmes (13) | SPI1 MISO, I2C1 SDA, TIM1 CH4, TIM17 CH1N, GPIO | +| `bundle1` | DIO2 | PA15 (spi1mosi) | CN4-15 (D11) | readmes (12) | SPI1 MOSI, I2C1 SCL, LPTIM1 CH2, encoder TIM1 index, TIM17 break, GPIO | +| `bundle1` | DIO3 | PA12 (spi1cs) | CN4-17 (D10) | readmes (10) | SPI1 chip select and NSS, TIM1 CH2, encoder TIM1 B, GPIO | +| `bundle1` | DIO4 | PA11 (tim1ch1) | CN4-24 (D4) | readmes (7) | TIM1 CH1, encoder TIM1 A, USART2 RX, LPTIM2 CH1, GPIO | +| `bundle1` | DIO5 | PB2 (tim1ch1n) | CN4-3 (D15) | readmes (31) | TIM1 CH1N, USART2 CTS, I2C1 SCL, I2C3 SCL, GPIO | +| `bundle1` | DIO6 | PB1 (tim1ch2n) | CN4-5 (D14) | readmes (31) | TIM1 CH2N, USART2 RTS, I2C1 SDA, I2C3 SDA, GPIO | +| `bundle1` | DIO7 | PB0 (tim1ch3n) | CN4-25 (D6) | readmes (1) | TIM1 CH3N, USART2 TX, encoder LPTIM2 B (LPTIM2 IN2), GPIO | +| `bundle1` | DIO8 | PB5 (lpuart1tx) | CN4-35 (D1) | modm-data, UM3301 Table 8 | LPUART1 TX, GPIO | +| `bundle1` | DIO9 | PA10 (lpuart1rx) | CN4-37 (D0) | modm-data, UM3301 Table 8 | LPUART1 RX, TIM3 CH1, encoder TIM3 A, GPIO | +| `bundle1` | DIO10 | PB9 (lpuart1rts) | CN4-23 (D7) | readmes (4) | LPUART1 RTS, TIM3 CH4, TIM16 CH1, encoder LPTIM2 A (LPTIM2 IN1), GPIO | +| `bundle1` | DIO11 | PB15 (lpuart1cts) | male pin in the D8 socket | Zephyr label | LPUART1 CTS, TIM16 break, GPIO | +| `bundle1` | DIO12 | PA1 (tim3ch2) | CN3-34 (A3) | readmes (1) | TIM3 CH2, encoder TIM3 B, TIM17 CH1, LPTIM2 CH2, GPIO | +| `bundle1` | DIO13 | PB14 (gpio0) | male pin in the D5 socket | Zephyr label | GPIO/EXTI, TIM3 CH3, encoder TIM3 index, watchdog warning toggle | +| `bundle1` | DIO14 | PA2 (tim1bkin) | CN3-32 (A2) | readmes (8) | TIM1 break input, GPIO | +| `bundle1` | DIO15 | PA5 (tim2ch1) | CN3-36 (A4) | readmes (7) | TIM2 CH1, GPIO | +| `bundle1` | W1 | PA7 (ain2) | CN3-28 (A0) | readmes (23) | ADC4 IN2 | +| `bundle1` | W2 | PA6 (ain3) | CN3-30 (A1) | readmes (10) | ADC4 IN3 | +| `bundle1` | Scope 1+ | PA7 (ain2) | male pin in the A0 socket (CN7-1) | readmes (12) | same net as W1 | +| `bundle1` | Scope 2+ | PA6 (ain3) | male pin in the A1 socket | Zephyr label | same net as W2 | +| `bundle1` | Scope 1-, 2- | GND | GND rail fed from CN5-6 | Sidewalk SDK README | see GND | +| `bundle2` | everything else | as `bundle1` | | | including GND and the loopback option; W1, W2 and the scopes of `bundle1` unplugged. DIO1 (PB3) is also the encoder LPTIM1 B (LPTIM1 IN2), DIO10 (PB9) the SPI3 MISO and DIO15 (PA5) the SPI3 NSS | +| `bundle2` | DIO8 | PA7 (i2c3sda) | CN3-28 (A0) | readmes (23) | I2C3 SDA, TIM2 CH3, GPIO | +| `bundle2` | DIO9 | PA6 (i2c3scl) | CN3-30 (A1) | readmes (10) | I2C3 SCL, TIM2 CH4, GPIO | +| `bundle2` | DIO11 | PB8 (spi3mosi) | CN4-38 | readmes (12) | SPI3 MOSI, TIM16 CH1N, GPIO; the red LD3 loads the pin; with `--with spiloop` the lead sits in the PB8 breadboard row | +| `bundle2` | DIO14 | PA0 (spi3clk) | CN3-38 (A5) | readmes (3) | SPI3 SCK, encoder LPTIM1 A (LPTIM1 IN1), GPIO | +| `bundle2` | Scope 1+, 2+ | PB2 (i2c1scl), PB1 (i2c1sda) | male pins in the SCL and SDA rows | - | only with `--with i2c` (rise time); 1- and 2- on the GND rail | + +### Optional wiring + +The options a set offers are in its `options` (board file); `--with ` enables one, and a tag no selected set offers, or two options that exclude each other, stop the run with a usage error. +The table lists the pins each option loads (tests on them skip unless they use the option) and pulls up; the tables after it give, per net, the AD3 lead already on it, where the jumper or breadboard wire goes, and the source of each position. + +| Board | Option | Sets | Loads | Pull-ups | Excludes | +|----------------|------------|----------------------|------------------------------------------|--------------------|------------| +| NUCLEO-WB55RG | `loopback` | `bundle1`, `bundle2` | PA6, PA7 | - | `spiloop` | +| NUCLEO-WB55RG | `i2c` | `bundle1` | PB8, PB9, PC0, PC1 | PB8, PB9, PC0, PC1 | - | +| NUCLEO-WB55RG | `spiloop` | `bundle1` | PA4-PA7, PB12-PB15 | - | `loopback` | +| NUCLEO-WBA55CG | `loopback` | `bundle1`, `bundle2` | PA15, PB3 | - | `spiloop` | +| NUCLEO-WBA55CG | `i2c` | `bundle2` | PB2, PB1, PA6, PA7 | PB2, PB1, PA6, PA7 | - | +| NUCLEO-WBA55CG | `spiloop` | `bundle2` | PB4, PA0, PB3, PB9, PA15, PB8, PA12, PA5 | - | `loopback` | + +#### NUCLEO-WB55RG `--with loopback` (`bundle1`, `bundle2`) + +One male-male wire from D11 to D12; the SPI tests then leave DIO7 an input and check the read-back. + +| Net | AD3 lead | Jumper or breadboard end | Source | +|-----------------|-----------------|--------------------------|-----------------| +| PA7 (SPI1 MOSI) | DIO1 at CN10-15 | D11 socket (CN5-4) | UM2435 Table 10 | +| PA6 (SPI1 MISO) | DIO7 at CN10-13 | D12 socket (CN5-5) | UM2435 Table 10 | + +#### NUCLEO-WB55RG `--with i2c` (`bundle1`) + +I2C1 to I2C3 with the EEPROM, ST's one-board pairing PB8-PC0 and PB9-PC1 (STM32CubeWB `Projects/P-NUCLEO-WB55.Nucleo/Examples_LL/I2C/I2C_OneBoard_Communication_IT/readme.txt`). `bundle2` cannot take it: its DIO9 sits on PC0. + +| Net | AD3 lead | Jumper or breadboard end | Source | +|-------------------------|----------------------------|----------------------------------------------------------------|--------------------------------------------------------------------------------------------| +| SCL row: PB8 (I2C1 SCL) | DIO3 at CN10-3 (unchanged) | D15 socket (CN5-10) to the SCL row | UM2435 Table 10; CN10-3 Table 11 | +| SCL row: PC0 (I2C3 SCL) | - | A0 socket (CN8-1) to the SCL row | UM2435 Table 10 | +| SDA row: PB9 (I2C1 SDA) | DIO5 at CN10-5 (unchanged) | D14 socket (CN5-9) to the SDA row | UM2435 Table 10; CN10-5 Table 11 | +| SDA row: PC1 (I2C3 SDA) | - | A1 socket (CN8-2) to the SDA row | UM2435 Table 10 | +| 3V3 rail | - | CN6-4 (3V3, female) to the rail | UM2435 Table 10; 7.5.2 "3V3 on CN6 pin 4 or CN7 pin 16 can be used as power supply output" | +| GND rail | - | CN6-6 (GND, female) to the rail; CN6-7 spare | UM2435 Table 10 (CN6 pins 6 and 7) | +| Pull-ups | - | 4.7 kOhm from the SCL row and from the SDA row to the 3V3 rail | [parts](#parts-for-the-i2c-option) | +| EEPROM | - | as in the parts list | [parts](#parts-for-the-i2c-option) | + +The AD3 observes SCL on DIO3 and SDA on DIO5. Both scope channels stay on the ADC inputs, so the rise time is measured on the NUCLEO-WBA55CG, whose bus has the same pull-up network. +Without the option the standalone I2C tests run on `bundle1` with the MCU pull-ups (`pull=up`): I2C1 on PB8/PB9 (DIO3/DIO5) and on PA9/PA10 (DIO2/DIO4), and I2C3 with SCL on PA7 (DIO1) and SDA on PB4 (CN10-4, UM2435 Table 11; no DIO, so SCL timing and the address NACK only). + +#### NUCLEO-WB55RG `--with spiloop` (`bundle1`) + +SPI1 to SPI2, ST's one-board pairing (STM32CubeWB `Projects/P-NUCLEO-WB55.Nucleo/Examples_LL/SPI/SPI_OneBoard_HalfDuplex_IT_Init/readme.txt`). SPI2 is observed through DIO6, DIO7, DIO1 and DIO14. + +| Net | AD3 lead | Jumper or breadboard end | Source | +|-------------------|--------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------|--------------------------------------| +| SCK: PA5 to PB13 | DIO6 at CN10-11 | D13 socket (CN5-6) to CN10-30 (PB13, via SB12) | UM2435 Table 10; Table 11 and Fig. 8 | +| MISO: PA6 to PB14 | DIO7 at CN10-13 | D12 socket (CN5-5) to CN10-28 (PB14) | UM2435 Table 10; Table 11 | +| MOSI: PA7 to PB15 | DIO1 at CN10-15 | D11 socket (CN5-4) to CN10-26 (PB15, via SB9) | UM2435 Table 10; Table 11 and Fig. 8 | +| NSS: PA4 to PB12 | DIO8 at CN10-17; DIO14 moved from CN10-16 to a male pin in the row | D10 socket (CN5-3, PA4 via SB41) to a breadboard row; CN10-16 (PB12, its only position) to the same row with a male-female wire | UM2435 Table 10; Table 11 and Fig. 8 | + +#### NUCLEO-WBA55CG `--with loopback` (`bundle1`, `bundle2`) + +One male-male wire from D11 to D12; the SPI tests then leave DIO1 an input and check the read-back. + +| Net | AD3 lead | Jumper or breadboard end | Source | +|------------------|----------------|--------------------------|----------------------------| +| PA15 (SPI1 MOSI) | DIO2 at CN4-15 | D11 socket (CN6-4) | Zephyr label; readmes (14) | +| PB3 (SPI1 MISO) | DIO1 at CN4-10 | D12 socket (CN6-5) | Zephyr label; readmes (11) | + +#### NUCLEO-WBA55CG `--with i2c` (`bundle2`) + +I2C1 (PB2/PB1) to I2C3 (PA6/PA7) with the EEPROM, ST's pairing (STM32CubeWBA `Projects/NUCLEO-WBA55CG/Examples_MIX/I2C/I2C_OneBoard_ComSlave7_10bits_IT/README.md`). `bundle1` cannot take it: its W1/W2 sit on PA7/PA6. + +| Net | AD3 lead | Jumper or breadboard end | Source | +|-------------------------|-------------------------------------------------------------------------------------|------------------------------------|-----------------------------------------------| +| SCL row: PB2 (I2C1 SCL) | DIO5 at CN4-3 | D15 socket (CN6-10) to the SCL row | Zephyr label; readmes (2) | +| SCL row: PA6 (I2C3 SCL) | DIO9 at CN3-30 | A1 socket to the SCL row | Zephyr label only | +| SDA row: PB1 (I2C1 SDA) | DIO6 at CN4-5 | D14 socket (CN6-9) to the SDA row | Zephyr label; readmes (2) | +| SDA row: PA7 (I2C3 SDA) | DIO8 at CN3-28 | A0 socket (CN7-1) to the SDA row | Zephyr label; readmes (12) | +| 3V3 rail | - | CN5-4 to the rail | Sidewalk SDK README | +| GND rail | AD3 GND moves from CN5-6 to the rail | CN5-6 to the rail | Sidewalk SDK README | +| Scopes (rise time) | scope 1+ on a male pin in the SCL row, 2+ in the SDA row, 1- and 2- on the GND rail | - | board file scope entries with `requires: i2c` | +| Pull-ups, EEPROM | - | as on the NUCLEO-WB55RG | [parts](#parts-for-the-i2c-option) | + +Without the option the standalone I2C tests run on `bundle1` with the MCU pull-ups (`pull=up`): I2C1 and I2C3 on PB2/PB1 (DIO5/DIO6) and I2C1 on PA15/PB3 (DIO2/DIO1). + +#### NUCLEO-WBA55CG `--with spiloop` (`bundle2`) + +SPI1 to SPI3; SPI3 is observed through DIO14, DIO10, DIO11 and DIO15. The PB8 end is the one the self-check drives: its LD3 load would spoil a pull-level read. + +| Net | AD3 leads | Jumper or breadboard end | Source | +|-------------------|------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------|----------------------------------------------------| +| SCK: PB4 to PA0 | DIO0 at CN4-6; DIO14 at CN3-38 | D13 socket (CN6-6) to the A5 socket (CN7-6) | Zephyr labels; readmes (12) and (9) | +| MISO: PB3 to PB9 | DIO1 at CN4-10; DIO10 at CN4-23 | D12 socket (CN6-5) to the D7 socket (CN8-8) | Zephyr labels; readmes (11) and (8) | +| MOSI: PA15 to PB8 | DIO2 at CN4-15; DIO11 moved from CN4-38 to a male pin in the row | D11 socket (CN6-4) to a breadboard row; CN4-38 (PB8, its only position) to the same row with a male-female wire | Zephyr label; readmes (14) and (12) | +| NSS: PA12 to PA5 | DIO3 at CN4-17; DIO15 at CN3-36 | D10 socket (CN6-3) to the A4 socket | Zephyr labels; CN6-3 readmes (1); A4 by label only | + +### Which tests skip, and how + +- Load gate: a test that resolves an AD3 channel (`need.dio`, `need.wavegen`, `need.scope`, `optional_dio`, `optional_scope`) for a pin an enabled option loads skips with "pin X loaded by --with T", unless it is marked `uses_option("T")` or `requires_option("T")`. Pins an option ties to a channel with a jumper resolve only for such tests. +- Without `--with i2c`: the I2C target, EEPROM, Fast-mode, rise-time, data-NACK, RELOAD, clock-stretch, bus-error and general-call tests skip (`requires_option("i2c")`); default timing, 100 kHz, the address NACK and arbitration loss run on `bundle1` with `pull=up`. +- With `--with i2c`: on the NUCLEO-WB55RG the PWM tests of TIM1 CH2N (PB8), TIM1 CH3N and TIM17 CH1 (PB9), the GPIO loop tests on PB8/PB9 and every QUADSPI test that uses IO0/IO1 skip; on the NUCLEO-WBA55CG `bundle2` the tests of TIM1 CH1N/CH2N and USART2 CTS/RTS (PB2/PB1) and of TIM2 CH3/CH4 (PA7/PA6) skip. +- Without `--with spiloop`: the SPI2 (NUCLEO-WB55RG) instance tests and the SPI1 to SPI2/SPI3 loops skip. With it, tests on its loaded pins skip unless they use the option, and `--with loopback` cannot be enabled as well. +- With `--with loopback`: tests on PA6/PA7 (NUCLEO-WB55RG) or PA15/PB3 (NUCLEO-WBA55CG) skip unless marked `uses_option("loopback")`: QUADSPI, the SPI slave cases with the AD3 as master, PWM TIM1 CH1N and TIM16 CH1 (NUCLEO-WB55RG), TIM17 break and the encoder index (NUCLEO-WBA55CG). +- NUCLEO-WBA55CG `bundle2`: the ADC, LPUART1 and TIM1/TIM16 break tests skip (no DIO, wavegen or scope on their pins). +- `--with` a tag that no selected wiring set offers, or without `--wiring-set`: the run stops with a usage error. ## What is tested +- `test_wiring.py` - the bench wiring, with GPIO commands only and the AD3 outputs and pulls off: continuity of the jumpers of every enabled option, its external pull-ups, the jumpers of offered options that are not enabled (`pass --with or remove the wiring`), and that the pins of `tests.wiring.undriven` follow both MCU pulls. Run it first after wiring the board; it skips with `--fake`. - `test_system.py` - `ping`, `info`, and the `board.pins` alias table against the board file in both directions, every alias accepted as a pin, reserved terminal/SWD/LSE/BOOT0 pins and the debug LED, unbonded pins, pin syntax, the terminal UART, error reasons (`usage`, `busy`, `notopen`, `range`, `unsupported`), missing instances (including 0), `delay`, `reset` and the `EVT boot` cause. +- `test_uid.py` - the 96-bit unique device ID of `info`: twelve bytes, not blank memory, the lot number in printable ASCII, the same after a reset. +- `test_clock.py` - `clock.info` against the board file: bus clocks, oscillator ready flags, the RNG kernel clock and (NUCLEO-WB55RG) the CLK48 source. On the NUCLEO-WB55RG also the clocks on the MCO pin (PA8, DIO0): SYSCLK/16 and HSI16/16 within 1 %, HSE/16 within 0.1 %, the LSE within 100 ppm (least-squares fit of the edge times), MCO off, and `clock.hsi48` stopping and restarting HSI48. - `test_gpio.py` - output levels with every drive (`hal::Speed`), inputs following the AD3 with every pull, pull-only idle levels, open drain, the user LED, eight pins at a time, pins held by other groups, interrupt counts for edge x handler type x pulse count x frequency against exact AD3 pulse trains, one EXTI line per port at a time, `gpio.pulse` timing. +- `test_sgpio.py` - for `SynchronousOutputPinStm`: levels with every speed, the open-drain low level and the output latch, rebuilding on a changed `od` and the input left by `sgpio.release`. + - For `SmallPeripheralPinStm` and `MultiGpioPinStm`/`MultiPeripheralPinStm`: a timer channel muxed to one pin, or to two or three pins at once, while `tpwm` runs that channel with no pin of its own (frequency and duty on every muxed pin); also the slots, pins shared with `gpio` and argument errors. - `test_pwm.py` - for `PwmStm` and `SynchronousPwmStm`: - frequency x duty x edge/centre alignment x prescaler, with `ERR range` exactly where the period does not fit the counter (16 or 32 bits); - 1-4 channels, complementary outputs and complementary-only outputs with dead time and inversion (shoot-through check), the dead-time limit; - idle levels, the break input with both polarities and automatic re-enable; - frequency changes and stop, the features each timer offers (`unsupported` elsewhere), argument errors, one timer at a time, and that a re-open forgets dead time and break. +- `test_pwm_ext.py` - the PWM options of D.10: frequency, duty and alignment of every counter mode (rising edges shared counting up, falling edges counting down, pulse centres centre aligned), the one-tick pulse `edgedown` keeps at 0 %, + compare preload (duty 80 % / 20 % written while the logic analyzer records: cut pulses only without preload), the break filter against AD3 pulses shorter and longer than it, `adc.open trgo=` paced by the PWM's trigger output (`update`, `oc1ref`), the timers that offer neither, and argument errors. - `test_uart.py` - every baud rate x parity x driver variant (interrupt, DMA, duplex DMA, synchronous) on LPUART1 and USART2, both directions against the AD3 UART, frames decoded from the firmware TX line and the bit rate measured on it, large payloads, full-duplex streaming, RTS/CTS flow control per variant, send timeout with CTS held, re-open with other settings, TX/RX swap, the baud limits of each instance, argument errors and default pins. -- `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_uart_sendonly.py` - for `SynchronousUartStmSendOnly` (NUCLEO-WB55RG LPUART1 through the `SyncLpUart` constructors, NUCLEO-WBA55CG USART2): frames against the AD3 UART at several baud rates, no received data, `flow=rts` driving RTS while CTS is ignored, the last frame that a `uart.close` right after `uart.send` may cut, optional `rx`, default pins and argument errors. +- `test_spi.py` - SPI modes 0-3 x baud x driver variant (interrupt, DMA, synchronous) x GPIO chip select, decoded from the logic analyzer (MOSI, MISO, clock polarity and rate, chip-select release), continued sessions, receive-only first transfers, the largest transfer, the baud limits and argument errors, on SPI1 and on SPI2 (NUCLEO-WB55RG, `--with spiloop`) or SPI3 (NUCLEO-WBA55CG, `bundle2`). +- `test_spi_ext.py` - the SPI master options: the bit order (`lsb=1`) on every driver, frame sizes of 4 to 16 bits (`bits=`, `SpiDataSizeConfiguratorStm` on the DMA driver) decoded word by word, the 8/16-bit limit of the WBA55 SPI3, an 8-bit master reopened after a 16-bit one, the `nss`/`cs` checks, and the hardware slave select (a known gap). +- `test_spi_slave.py` - `SpiSlaveStmDma` clocked by the AD3 SPI master: full duplex, send-only and receive-only transfers of 1-1024 bytes at several clock rates (CRC beyond 128 bytes), clocks while nothing is armed, a master clocking more or less than armed, `spis.result` waiting and busy, cancel and re-open, argument errors and the instance shared with `spi`; + with `--with spiloop` the board's own SPI master (every driver) against the slave on the other instance, both ways. - `test_adc.py` - wavegen DC levels against raw 12-bit codes (checked against the scope when it is wired), sequences of up to 8 conversions, every sampling time, timer-triggered rates and the measure timeout, timer sharing with PWM and the encoder, unsupported trigger timers, argument errors and limits, and ADC pins against GPIO. -- `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_ain.py` - single conversions against wavegen levels and every listed sampling time, the internal temperature sensor in its room-temperature range, TIM2-paced bursts that give exactly n samples in n / rate (B.12), also twice on one driver, burst values and a wavegen ramp followed at its slope, the ADC, TIM2 and DMA channel shared with `adc` and `pwm`, argument errors. +- `test_dma.py` - `dma.wave`: 8- to 64-bit patterns at 1 kHz to 1 MHz decoded from the logic analyzer (32-bit DMA transfers to BSRR on both MCUs, B.4), TIM2 and the pin shared with `pwm` and `gpio`, argument errors. +- `test_qei.py` - position counts for frequency x cycles x direction x decoding x phase inversion, physically inverted phases restored by `inva`/`invb`, offset and rollover, speed, the index input through `qei.index`, default pins, the resolution limits, argument errors, one encoder at a time and timer sharing. + - The LPTIM encoders with `cap=ab|rise|fall`: NUCLEO-WB55RG LPTIM1 in bundle2; NUCLEO-WBA55CG LPTIM2 in bundle1 and LPTIM1 in bundle2. +- `test_timer.py` - for `FreeRunningTimerStm` and `TimerWithInterruptStm` on every timer: the update rate on the marker pin (gpio0) with immediate and dispatched callbacks (dispatched at most 1 kHz), interrupt counts against the elapsed time, the free-running counter up and down (`ERR unsupported` down on TIM16/TIM17). + - Also stop and restart, the marker pin claimed while open, argument errors, one timer at a time and the timer shared with pwm, tpwm, the encoder and the timer-triggered ADC. +- `test_timer_pwm.py` - for `TimerPwmWithChannels`: frequency and duty 0/50/100 % of every wired channel (100 % keeps one low counter tick), a duty per channel, `SetPulse` changing the period of every channel, an unused `-` channel whose pin stays free, starting and stopping one channel or all, argument errors and the timer shared with pwm and tim (NUCLEO-WBA55CG TIM2 with CH3/CH4 in bundle2). +- `test_lptim.py` - for `FreeRunningLowPowerTimerStm` and `LowPowerTimerWithInterruptStm` on LPTIM1 and LPTIM2: the update rate on the marker pin (gpio0) with immediate and dispatched callbacks, every prescaler and the WBA55 repetition counter. + - Also interrupt counts against the elapsed time, the free-running counter, stop, the kernel clock, argument errors and the LPTIM shared with the LPTIM encoder and `lptpwm`. +- `test_lptim_pwm.py` (NUCLEO-WBA55CG) - for `LpTimerPwmWithChannels`: frequency and duty of LPTIM1 CH2 and LPTIM2 CH1/CH2, `SetPulse`, stop and restart, argument errors and one LPTIM PWM at a time. It asserts the requested duty; a bench that measures 1 - duty is recorded as a known gap (DESIGN R14). - `test_watchdog.py` - the window watchdog with timeouts across its range x automatic or manual feeding (warnings, no reset while fed, `reset=wwdg` otherwise), the early-warning period measured on the `pin=` toggle, manual feeding, a single watchdog at a time, the timeout limits and argument errors. -- `test_unsupported.py` - every comparator, CAN, EEPROM and Ethernet command answers `ERR unsupported`. +- `test_i2c.py` - `I2cStm` on both instances. + Without wiring, each instance on its own pins with the MCU pull-ups (`pull=up`): the default and computed TIMINGR (reply and bus timing against the Standard mode minima), address NACK and its recovery in both directions, the zero-length probe, arbitration loss with SDA held before `i2c.open` and pulled low by the AD3 inside the second address bit, and argument errors. + With `--with i2c` (the master against the `i2cs` LL target on the other instance, 4.7 kOhm pull-ups): Standard and Fast mode timing and the rise time, data NACK at every position, the address NACK after the other direction, writes and reads of 1-1024 bytes across the NBYTES/RELOAD boundaries, repeated START, continued sessions in both directions, + clock stretching, a bus error from a misplaced STOP, the general call, close while the bus is held and both instance directions. +- `test_eeprom.py` - EMIL's `eeprom.*` over the `I2cEepromStm` adapter on the external 24Cxx (`--with i2c`): the address probe, attach/detach, writes inside and across pages, reads across pages, write-read-write-read, erase, data across a reset, the size limit, + ACK polling on the logic analyzer, recovery from an absent chip, detach while busy, and 1-byte word addresses against the `i2cs` register file. +- `test_rng.py` - for `SynchronousRandomDataGeneratorStm`, `RandomDataGeneratorStm` and (NUCLEO-WB55RG) `SynchronousSynchronizedRandomDataGeneratorStm`: exact lengths, two reads that differ, 64 KiB per variant through the monobit, byte chi-square and runs tests of `rngstats`, variants taking turns on the RNG. + - On the NUCLEO-WB55RG the synchronized variant keeps HSI48 running when it was on and switches it off again when it was off, takes its locked branch with semaphore 5 held (`lock5=1`), and leaves HSEM semaphores 0 and 5 free (`hsem.status`, when that group is built in). +- `test_aes.py` - for `SynchronousAes128EcbStm`: the FIPS-197 C.1 and SP 800-38A F.1.1 known answers in both directions, block by block and four blocks at once, one to five blocks per command, a key change between commands, and every data swapping mode (`swap=none|half|byte|bit`) against the model of `crypto_ref`; argument errors. +- `test_pka.py` - for `PkaStm` on secp256r1: G, 2G and 3G, the default operands, a short scalar padded by the firmware, (n - 1)G = -G, the NIST CAVP ECC CDH vector, a given base point, points on and off the curve, comparisons of 4, 32 and 60-byte numbers, the duration of one multiplication and argument errors; every result against the P-256 arithmetic of `crypto_ref`. +- `test_qspi.py` (NUCLEO-WB55RG) - for `QuadSpiStm` (`variant=poll`), `QuadSpiStmDma` (`variant=dma`) and `SingleSpeedQuadSpiStmDma` (`variant=spi`): instruction, address, alternate bytes and data of writes decoded on the logic analyzer on 1 and on 4 lines, and SPI mode 0 transfers. + - Reads: 4-line reads of static AD3 levels, a status poll that matches and one that times out (`variant=dma`; `variant=poll` hangs, a known gap). + - Also the B.15 fix: writes answer the FIFO level of their completion callback (`flevel=0`) and two writes issued from one completion callback both reach the bus; the clock, the CRC of 256-byte reads, argument errors and the pins held while open. + - The AD3 never drives a line the QUADSPI drives: it decodes writes and holds only lines the QUADSPI receives on, with its weakest drive. Every case skips while `--with loopback`, `--with spiloop` or `--with i2c` loads a QUADSPI pin. +- `test_backup_ram.py` - `hal::BackupRamStm` as `hal::BackupRam` (RTC BKP0R-BKP19R on the NUCLEO-WB55RG, TAMP BKP0R-BKP15R on the NUCLEO-WBA55CG): the word count, every word written and read back with four patterns, fill and check, the words kept through a reset, argument errors. +- `test_flash.py` - the internal flash drivers (`FlashHomogeneousInternalStm`, `FlashInternalStm`, their synchronous twins and, on the NUCLEO-WB55RG, `FlashCoordinatedWithWirelessStack`) over a scratch region of 64 pages, with one sector per page and with a sector-size table of 1-, 2- and 4-page sectors: + - the geometry and the first sector the firmware lets the tests erase (an erase that took the sector index for the absolute page could not reach the running image from there on); + - every variant and layout erases exactly the pages of the sector it is given (the marker in the next sector stays, the previous sector is unchanged); + - aligned, unaligned, odd-length, page-crossing and generated 300-byte writes on distinct flash words, read back exactly through every variant, with the bytes around them still erased; a programmed word refused with `ERR failed`; the page erase time; + - NUCLEO-WB55RG: a coordinated write and erase wait for HSEM 7 while it is held; `flash.stack starting` holds a write (its `ERR timeout` goes out, the flash stays erased) until `flash.stack fus` completes it with `EVT flash`; + - NUCLEO-WB55RG: the coordinated driver borrows the watchdog (`wdt.start` busy) and excludes `hsem.lock`, and a coordinated erase under a running watchdog does not reset the board. +- `test_hsem.py` - NUCLEO-WB55RG hardware semaphores: + - two-step take and release per process with the lock state read from R, another process refused, `hold=` releasing on time; + - `SynchronousHardwareSemaphoreStm` waiting until the scaffold timer frees a semaphore held by process 1 (and returning at once on a free one), a held semaphore refused instead of a lock that would block for good; + - `IsLockedByCurrentCore` as a query that takes no lock, argument errors. +- `test_low_power.py` - `hal::LowPowerModeStm` in `sleep` and `deep` (Sleep on these MCUs: the clock-restore callback is never called): + - with only the wake line and TIM17 enabled, the marker pin stays low until the AD3 edge on the wake pin (both edges) and rises within 50 us of it, and `us` covers the time asleep; + - without an edge the TIM17 timeout ends the window and the tick runs again; pins released; argument errors. +- `test_unsupported.py` - every comparator, CAN and Ethernet command, and the commands of the groups the running MCU lacks (PROTOCOL.md, "Not available on these boards"), answer `ERR unsupported`. ## Customising Each board file (`host/boards/.yaml`) holds: - `terminal`, `clocks` (the kernel clocks the expectations use), `pins` (the PROTOCOL.md alias table, compared with `board.pins`; only the generic names of `hal_st_validation.protocol` are accepted) and `ad3` (supplies, analog limits). -- `wiring_sets` - per set, `dio`, `wavegen` and `scope` maps from AD3 channel to pin or alias. An entry is either a pin or a mapping with `pin`, `jumpered` (pins tied to `pin` with a wire), `role` (a name tests can look up), `note` and `requires` (only used with `--with `); `jumpers` and `options` document extra wiring. +- `wiring_sets` - per set, `dio`, `wavegen` and `scope` maps from AD3 channel to pin or alias. An entry is either a pin or a mapping with `pin`, `jumpered` (pins tied to `pin` with a wire), `role` (a name tests can look up), `note` and `requires` (only used with `--with `, which the set must offer); `jumpers` documents extra wiring. +- `wiring_sets..options.` - the optional wiring a set offers: a description, or a mapping with `description`, `jumpered` (key pin -> pins the wiring ties to it; the self-check drives the key), `loads` (pins the wiring loads), `pullups` (pins it pulls up to 3V3) and `excludes` (options that cannot be fitted at the same time). - `known_gaps` - driver gaps with the test ids (wildcards `*` and `?`) they affect, the reason and whether they hang the firmware. - `tests` - the parameters of every test module: parameter matrices, pins and instances, levels and tolerances. - `@pytest.mark.matrix("pwm.waveform")` turns every key of that mapping into one test parameter of the same name; `@pytest.mark.board_params("argname", "section.key")` adds one parameter from a list. - All parameters of a test form one matrix: `--depth full` runs its product, `--depth quick` a pairwise subset (`hal_st_validation.pairwise`); `@pytest.mark.constraint(valid=...)` removes combinations a driver cannot take (for example parity with the synchronous UART). - Extending a sweep or moving a peripheral to other pins is a YAML change. + - `tests.wiring.undriven` lists pins nothing on the board may drive (solder bridges to ST-LINK lines); `test_wiring.py` checks them. + - `@pytest.mark.wiring_options("tag")` runs a test once per enabled option (`enabled=False`: per offered option that is not enabled), each case marked `uses_option`. To validate another board, add a board profile under `firmware/boards//` and the MCU to `emil_build_for` in `firmware/CMakeLists.txt`, copy a board file, adapt the pins, wiring sets and parameters, and pass `--board path/to/board.yaml`. @@ -316,7 +545,20 @@ The console forwards commands, prints final lines and events, and keeps a histor In `hal_st_validation` (hal-st specific): -- `firmware.py` - typed API with one group per PROTOCOL.md section (`fw.system`, `fw.gpio`, `fw.pwm`, `fw.uart`, `fw.spi`, `fw.adc`, `fw.qei`, `fw.wdt`); keyword arguments map 1:1 to protocol options (`continue_` for `continue`). +- `firmware.py` - typed API with one group per PROTOCOL.md section (`fw.system`, `fw.gpio`, `fw.pwm`, `fw.uart`, `fw.spi`, `fw.adc`, `fw.qei`, `fw.wdt`); keyword arguments map 1:1 to protocol options (`continue_` for `continue`). The groups of `groups/*.py` are attached under the names their `GROUPS` mapping exports. +- `groups/` - `Group` (`groups/base.py`: `_cmd`, `begin` for commands whose final line comes later, pin resolution) and one module per area exporting `GROUPS = {"name": GroupClass}`. +- `fakes/` - `FakeGroup` and the argument helpers (`fakes/base.py`); one module per area with the fake firmware's model of its command groups, found automatically. +- `patterns.py` - the payloads the firmware generates (`len=`, `pattern=inc|const|prbs`, `seed=`) and their CRC-32 (`crc=`). +- `i2c.py` - I2C bus helpers: decoding SCL/SDA captures (`i2c_decode`), SCL timing and rise times, `expected_timing` (the host twin of the firmware's TIMINGR computation), and AD3 captures and SDA pulses started by a START condition; `groups/i2c.py` - the `fw.i2c`, `fw.i2cs` and `fw.eeprom` wrappers; `fakes/i2c.py` - their fake groups with one shared bus, the target and a 24LC256. +- `spiwords.py` - SPI words of 4-16 bits: a logic-capture decoder without the bench decoder's 8-bit mask (`spi_decode_words`) and the frame model of `SpiMasterStmDma` with a data size configurator (`spi_frames`, `spi_bytes`); `groups/spis.py` - the `fw.spis` wrapper; `fakes/spis.py` - its fake group and the SPI loop of the fake `spi.xfer` (`FakeFirmware.spi_loop`). +- `groups/timers.py` - `fw.tim`, `fw.tpwm`, `fw.lptim`, `fw.lptpwm` and their expected waveforms (`timer_update_rate`, `lptim_update_rate`, `pwm_duty_fraction`); `fakes/timers.py` is their fake firmware model. +- `groups/analog.py` - `fw.ain` (`read`, `burst` with one `BurstRun` per measurement) and `fw.dma` (`wave`), with the expectations of the TIM2 pacing (`trigger_timing`, `trigger_rate`, `burst_seconds`) and the wave bit order (`wave_bits`); `fakes/analog.py` models both groups and their deferred replies. +- `groups/io.py` - `fw.sgpio`, `fw.clock` (`ClockInfo`; `clock.mco` and `clock.hsi48` register their undo with `close_all`), `edge_fit_frequency` and `parse_uid`; `fakes/io.py` is their fake firmware model, with a unique device ID of the real layout. +- `crypto_ref.py` - pure-Python AES-128 with the data swapping model of the STM32 AES peripheral, and affine P-256 arithmetic, with the FIPS-197, SP 800-38A and CAVP ECC CDH vectors; `rngstats.py` - the `rng.stats` counts and the monobit, chi-square and runs bounds. +- `groups/crypto.py` - `fw.rng`, `fw.aes`, `fw.pka`; `fakes/crypto.py` is their fake firmware model (a deterministic RNG, AES and PKA through `crypto_ref`). +- `groups/system_ext.py` - `fw.flash`, `fw.hsem`, `fw.bkp`, `fw.lpm` (`flash.stack starting` and `hsem.take` without `hold` register their undo with `close_all`), the sector table of `layout=table`, `flash_words` and `bkp_fill_value`. +- `fakes/system_ext.py` - their fake firmware model: a flash array with page erase, HSEM state on the fake clock, backup words that survive `reset`, an `lpm.enter` that wakes at once, and the `wdt.start` refusal while the coordinated flash borrows the watchdog. +- `groups/qspi.py` - `fw.qspi` and the decoding of QUADSPI captures (`qspi_samples`, `qspi_bytes`, `qspi_frame`); `fakes/qspi.py` is its fake firmware model (STM32WB55). - `protocol.py` - the hal-st part of the protocol: error reasons, `P` pins (ports A-K, index 0-15) and the generic alias names (`normalize_pin`, `parse_pin_map`). - `config.py` - board file loading, wiring-set merging, parameter matrices, overrides and known gaps; `expect.py` - expected STM32 values (PWM quantisation and range, SPI prescaler, UART baud-rate register limits, WWDG prescaler and period, ADC codes, encoder counts). - `pairwise.py` - the full product and the deterministic pairwise generator behind `--depth`. @@ -327,4 +569,12 @@ In the firmware (`firmware/`): - `Main.cpp` composes the console, the pin pool and every command group; `Console` is the terminal on USART1; `boards//BoardProfile.hpp` holds the aliases, reserved pins, default pins, DMA request lines, clocks and ADC tables of each board. - `PinFactoryStm` builds `hal::GpioPinStm`s over the generated pinout tables (bonded pins only, analog sharing, one EXTI line per port); `BoardInfoStm` reports the board, clock and reset cause; `TimerAllocation` keeps PWM, encoder and timer-triggered ADC off each other's timer. +- `ResourceAllocation` keeps the groups that share an I2C, SPI, ADC or LPTIM instance, a DMA channel or an HSEM semaphore off each other, with the owner ids of `Owners.hpp`; `Payload` generates the `len=`/`pattern=` payloads and the `out=crc` CRCs; `ChannelPins`, `Stopwatch` (microseconds from the cycle counter) and `HsemMaster` (the one HSEM master of the STM32WB55) serve the newer groups. - One factory per command group (`UartFactory`, `SpiFactory`, `AdcFactory`, `PwmFactory`, `QeiFactory` with the `qei.index` command, `WatchDogFactory`) parses the hal-st options and builds the driver; `UnsupportedGroups` answers the rest. + `AnalogInputGroup` (`ain.read`, `ain.burst`) and `DmaGroup` (`dma.wave`) build their drivers per command and claim the ADC, TIM2 and the DMA channels through `TimerAllocation` and `ResourceAllocation`. +- `I2cGroup` (`i2c.*` over `I2cStmForHil`, an `I2cStm` whose hooks report `EVT i2c`), `I2cTiming` (TIMINGR from kernel clock and bus frequency), `I2cTarget` (`i2cs.*`, the LL I2C target scaffold), `I2cEepromStm` (a `hal::Eeprom` over `hal::I2cMaster` with page writes and ACK polling) and `EepromGroup` (`eeprom.attach`/`eeprom.detach`, the factory of EMIL's `eeprom` group). +- `SpiSlaveGroup` (`spis.*`: `SpiSlaveFactory` builds `SpiSlaveStmDma` on the board's slave DMA channels, `SpiSlaveCommands` arms one transfer at a time and answers `spis.result` from its own timer); `SpiFactory` also takes `bits`, `lsb` and `nss` and holds the SPI instance in `ResourceAllocation`. +- `TimerGroup`, `TimerPwmGroup`, `LpTimerGroup` and `LpTimerPwmGroup` serve `tim.*`, `tpwm.*`, `lptim.*` and `lptpwm.*`, each a factory and a single-instance group; `QeiFactory` also builds the LPTIM encoder with `cap=rise|fall` and holds its LPTIM in `ResourceAllocation`. +- `SyncGpioGroup` serves `sgpio.*` through `SyncGpioDriver`, the only translation unit that includes `SynchronousGpioStm.hpp`; `ClockGroup` serves `clock.*`; `UartFactory` also builds `SynchronousUartStmSendOnly` (`sendonly=1`). +- `RngGroup`, `AesGroup` and `PkaGroup` are stateless command groups: `rng` and `aes` build their driver per command; `pka` builds one `PkaStm` on first use and keeps it. +- `FlashGroup`, `HsemGroup` (STM32WB), `BackupRamGroup` and `LowPowerGroup` serve `flash.*`, `hsem.*`, `bkp.*` and `lpm.*`; `WatchDogFactory` lends the WWDG to the coordinated flash driver (`Borrow`/`Return`). diff --git a/validation/firmware/AdcFactory.cpp b/validation/firmware/AdcFactory.cpp index 46f221ba..9c47c11a 100644 --- a/validation/firmware/AdcFactory.cpp +++ b/validation/firmware/AdcFactory.cpp @@ -15,12 +15,14 @@ namespace validation { using services::HilStatus; - constexpr std::array openKeys{ { "pins", "sampling", "timer", "rate" } }; + constexpr std::array openKeys{ { "pins", "sampling", "timer", "rate", "trgo" } }; constexpr uint32_t defaultRate = 1000; constexpr uint32_t maximumRate = 100000; constexpr uint64_t minimumTicks = 2; + static_assert(board::adcDma.dma == 1); + // A Stop from the event loop must not interleave with the DMA interrupt, which disables the ADC itself in one-shot mode and stops a timer-triggered sequence from EMIL's last-run callback class InterruptsDisabled { @@ -53,19 +55,37 @@ namespace validation return a > b ? a - b : b - a; } - // The smallest prescaler that fits is at most 1 / maximumTicks off the best divider; searching every prescaler for that one blocks the event loop for tens of milliseconds - hal::TimerBaseStm::Timing TriggerTiming(uint8_t timer, uint32_t rate) + // PwmStm drives TRGO only, so a timer the ADC reaches through TRGO2 alone (TIM1 on the STM32WBA ADC4) cannot pace it + std::optional PwmTriggerSource(uint8_t timer) { - const uint64_t clock = TimerClock(timer); - const uint64_t maximumTicks = IS_TIM_32B_COUNTER_INSTANCE(hal::peripheralTimer[timer - 1]) ? uint64_t{ 1 } << 32 : uint64_t{ 1 } << 16; + switch (timer) + { +#if defined(ADC_EXTERNALTRIG_T1_TRGO) + case 1: + return ADC_EXTERNALTRIG_T1_TRGO; +#endif +#if defined(ADC_EXTERNALTRIG_T2_TRGO) + case 2: + return ADC_EXTERNALTRIG_T2_TRGO; +#endif + default: + return std::nullopt; + } + } + } + + // The smallest prescaler that fits is at most 1 / maximumTicks off the best divider; searching every prescaler for that one blocks the event loop for tens of milliseconds + hal::TimerBaseStm::Timing TriggerTiming(uint8_t timer, uint32_t rate) + { + const uint64_t clock = TimerClock(timer); + const uint64_t maximumTicks = IS_TIM_32B_COUNTER_INSTANCE(hal::peripheralTimer[timer - 1]) ? uint64_t{ 1 } << 32 : uint64_t{ 1 } << 16; - const uint64_t prescale = clock / (rate * (maximumTicks + 1)) + 1; - const uint64_t floorTicks = std::max(clock / (prescale * rate), minimumTicks); - const uint64_t ceilTicks = std::min(floorTicks + 1, maximumTicks); - const bool roundUp = Distance(clock, prescale * ceilTicks * rate) * floorTicks < Distance(clock, prescale * floorTicks * rate) * ceilTicks; + const uint64_t prescale = clock / (rate * (maximumTicks + 1)) + 1; + const uint64_t floorTicks = std::max(clock / (prescale * rate), minimumTicks); + const uint64_t ceilTicks = std::min(floorTicks + 1, maximumTicks); + const bool roundUp = Distance(clock, prescale * ceilTicks * rate) * floorTicks < Distance(clock, prescale * floorTicks * rate) * ceilTicks; - return { static_cast(prescale - 1), static_cast((roundUp ? ceilTicks : floorTicks) - 1) }; - } + return { static_cast(prescale - 1), static_cast((roundUp ? ceilTicks : floorTicks) - 1) }; } template @@ -108,6 +128,34 @@ namespace validation return measuring; } + AdcFactoryStm::PwmTriggeredSequence::PwmTriggeredSequence(infra::MemoryRange samples, infra::MemoryRange inputs, hal::AdcStm& converter, hal::DmaStm::ReceiveStream& receiveStream, ChannelConfigs configs, uint32_t triggerSource) + : Sequence(samples, inputs, converter, receiveStream, configs, hal::AdcDmaMultiChannelStmBase::Unlimited{}) + { + HAL_ADC_Stop(&converter.Handle()); + + LL_ADC_REG_SetTriggerSource(converter.Handle().Instance, triggerSource); +#if defined(ADC_CFGR1_EXTEN) || defined(ADC_CFGR_EXTEN) + LL_ADC_REG_SetTriggerEdge(converter.Handle().Instance, ADC_EXTERNALTRIGCONVEDGE_RISING); +#endif + } + + void AdcFactoryStm::PwmTriggeredSequence::Measure(const infra::Function& onDone) + { + measuring = true; + Sequence::Measure(onDone); + } + + void AdcFactoryStm::PwmTriggeredSequence::Stop() + { + Sequence::Stop(); + measuring = false; + } + + bool AdcFactoryStm::PwmTriggeredSequence::Measuring() const + { + return measuring; + } + void AdcFactoryStm::RepeatedConversion::Attach(Sequence& sequence, Samples samples) { this->sequence = &sequence; @@ -160,10 +208,11 @@ namespace validation Convert(); } - AdcFactoryStm::AdcFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma, TimerAllocation& timers) + AdcFactoryStm::AdcFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma, TimerAllocation& timers, ResourceAllocation& resources) : naming(naming) , dma(dma) , timers(timers) + , resources(resources) {} std::size_t AdcFactoryStm::KeyPositionals() const @@ -203,8 +252,15 @@ namespace validation for (std::size_t i = 0; i != request.count && status == HilStatus::done; ++i) status = pins.ClaimAnalog(request.pins[i], claimed[i]); + if (status == HilStatus::done) + status = ClaimResources(); + if (status == HilStatus::done && request.timer) + { status = timers.Claim(*request.timer, services::HilOwners::adc); + if (status != HilStatus::done) + ReleaseResources(); + } if (status != HilStatus::done) return status; @@ -221,6 +277,9 @@ namespace validation if (auto triggered = std::get_if(&sequence); triggered != nullptr && triggered->Measuring()) triggered->Stop(); + if (auto triggered = std::get_if(&sequence); triggered != nullptr && triggered->Measuring()) + triggered->Stop(); + sequence.emplace(); analogPins.clear(); stream.reset(); @@ -229,6 +288,7 @@ namespace validation if (timer) timers.Release(*timer, services::HilOwners::adc); timer.reset(); + ReleaseResources(); onClosed(); } @@ -250,9 +310,18 @@ namespace validation request.timer = static_cast(number); } arguments.Number("rate", request.rate, 1, maximumRate, status); + if (arguments.Has("trgo")) + { + uint32_t number = 0; + arguments.Number("trgo", number, 0, static_cast(hal::peripheralTimer.size()), status); + request.triggerTimer = static_cast(number); + } if (status != HilStatus::done) return status; + if (request.triggerTimer && (request.timer || arguments.Has("rate"))) + return HilStatus::usage; + status = ParsePins(arguments, request); if (status != HilStatus::done) return status; @@ -263,9 +332,45 @@ namespace validation if (request.timer && !IsTriggerTimer(*request.timer)) return HilStatus::unsupported; + if (request.triggerTimer && !TimerExists(*request.triggerTimer)) + return HilStatus::range; + + if (request.triggerTimer) + return EvaluateTrigger(*request.triggerTimer); + + return HilStatus::done; + } + + HilStatus AdcFactoryStm::EvaluateTrigger(uint8_t timer) const + { + const auto owner = timers.Owner(timer); + if (!PwmTriggerSource(timer) || !owner) + return HilStatus::unsupported; + + if (*owner != services::HilOwners::pwm) + return HilStatus::busy; + return HilStatus::done; } + HilStatus AdcFactoryStm::ClaimResources() + { + HilStatus status = resources.Claim(Resource::adc, board::adc, services::HilOwners::adc); + if (status == HilStatus::done) + status = resources.Claim(Resource::dma1, board::adcDma.channel, services::HilOwners::adc); + + if (status != HilStatus::done) + ReleaseResources(); + + return status; + } + + void AdcFactoryStm::ReleaseResources() + { + resources.Release(Resource::adc, board::adc, services::HilOwners::adc); + resources.Release(Resource::dma1, board::adcDma.channel, services::HilOwners::adc); + } + HilStatus AdcFactoryStm::ParsePins(const services::HilArguments& arguments, Request& request) const { const auto list = arguments.Key("pins"); @@ -296,7 +401,7 @@ namespace validation void AdcFactoryStm::Construct(const Request& request, const std::array& claimed, services::HilAdcHandle& handle) { auto& converter = adc.emplace(board::adc); - auto& receiveStream = stream.emplace(dma, hal::DmaChannelId(1, board::adcDmaChannel, board::adcDmaRequest)); + auto& receiveStream = stream.emplace(dma, hal::DmaChannelId(board::adcDma.dma, board::adcDma.channel, board::adcDmaRequest)); for (std::size_t i = 0; i != request.count; ++i) { @@ -314,6 +419,12 @@ namespace validation return; } + if (request.triggerTimer) + { + handle.adc = &sequence.emplace(samples, infra::MakeRange(analogPins), converter, receiveStream, channels, *PwmTriggerSource(*request.triggerTimer)); + return; + } + repeated.Attach(sequence.emplace(samples, infra::MakeRange(analogPins), converter, receiveStream, channels, hal::AdcDmaMultiChannelStmBase::OneShot{}), samples); handle.adc = &repeated; } diff --git a/validation/firmware/AdcFactory.hpp b/validation/firmware/AdcFactory.hpp index a4342d93..be643ac7 100644 --- a/validation/firmware/AdcFactory.hpp +++ b/validation/firmware/AdcFactory.hpp @@ -12,6 +12,7 @@ #include "infra/util/MemoryRange.hpp" #include "services/hil/commands/HilAdcCommands.hpp" #include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/ResourceAllocation.hpp" #include "validation/firmware/TimerAllocation.hpp" #include #include @@ -22,13 +23,15 @@ namespace validation { + hal::TimerBaseStm::Timing TriggerTiming(uint8_t timer, uint32_t rate); + class AdcFactoryStm : public services::HilAdcFactory { public: static constexpr std::size_t slots = 1; - AdcFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma, TimerAllocation& timers); + AdcFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma, TimerAllocation& timers, ResourceAllocation& resources); std::size_t KeyPositionals() const override; services::HilStatus ParseKey(const services::HilArguments& arguments, uint16_t& key) const override; @@ -49,6 +52,7 @@ namespace validation uint32_t samplingTime = 0; std::optional timer; uint32_t rate = 0; + std::optional triggerTimer; }; class Sequence @@ -77,6 +81,21 @@ namespace validation std::atomic measuring{ false }; }; + class PwmTriggeredSequence + : public Sequence + { + public: + PwmTriggeredSequence(infra::MemoryRange samples, infra::MemoryRange inputs, hal::AdcStm& converter, hal::DmaStm::ReceiveStream& receiveStream, ChannelConfigs configs, uint32_t triggerSource); + + void Measure(const infra::Function& onDone) override; + void Stop() override; + + bool Measuring() const; + + private: + std::atomic measuring{ false }; + }; + class RepeatedConversion : public hal::AdcMultiChannel { @@ -101,18 +120,22 @@ namespace validation services::HilStatus Parse(const services::HilArguments& arguments, Request& request) const; services::HilStatus ParsePins(const services::HilArguments& arguments, Request& request) const; + services::HilStatus EvaluateTrigger(uint8_t timer) const; + services::HilStatus ClaimResources(); + void ReleaseResources(); void Construct(const Request& request, const std::array& claimed, services::HilAdcHandle& handle); private: const services::HilPinNaming& naming; hal::DmaStm& dma; TimerAllocation& timers; + ResourceAllocation& resources; std::optional adc; std::optional stream; infra::BoundedVector::WithMaxSize analogPins; std::array buffer{}; std::array configs{}; - std::variant sequence; + std::variant sequence; RepeatedConversion repeated; std::optional timer; }; diff --git a/validation/firmware/AesGroup.cpp b/validation/firmware/AesGroup.cpp new file mode 100644 index 00000000..f2414231 --- /dev/null +++ b/validation/firmware/AesGroup.cpp @@ -0,0 +1,88 @@ +#include "validation/firmware/AesGroup.hpp" +#include "validation/firmware/Payload.hpp" + +namespace validation +{ + namespace + { + using services::HilChoice; + using services::HilStatus; + using DataSwapping = hal::detail::DataSwapping; + + constexpr std::size_t blockSize = 16; + + constexpr std::array, 4> swappings{ { + { "none", DataSwapping::disabled }, + { "half", DataSwapping::halfWord }, + { "byte", DataSwapping::byte }, + { "bit", DataSwapping::bit }, + } }; + + HilStatus ParseBlocks(infra::BoundedConstString text, infra::ByteRange storage, std::size_t& size) + { + if (services::HilArguments::ParseHex(text, storage, size) != HilStatus::done || size == 0 || size % blockSize != 0) + return HilStatus::usage; + + return HilStatus::done; + } + } + + AesCommands::AesCommands(services::HilContext& context) + : services::TerminalCommands(context.terminal) + , context(context) + , commands{ { + services::HilBind("aes.enc", " [swap=none|half|byte|bit]", *this, context.response), + services::HilBind("aes.dec", " [swap=none|half|byte|bit]", *this, context.response), + } } + {} + + infra::MemoryRange AesCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus AesCommands::Encrypt(const services::HilArguments& arguments) + { + return Run(arguments, false); + } + + HilStatus AesCommands::Decrypt(const services::HilArguments& arguments) + { + return Run(arguments, true); + } + + HilStatus AesCommands::Run(const services::HilArguments& arguments, bool decrypt) + { + if (!arguments.Shape(2, 2, { "swap" })) + return HilStatus::usage; + + std::size_t keySize = 0; + std::size_t size = 0; + if (ParseBlocks(arguments.Positional(0), infra::MakeRange(key), keySize) != HilStatus::done || keySize != key.size()) + return HilStatus::usage; + + if (ParseBlocks(arguments.Positional(1), infra::MakeRange(input), size) != HilStatus::done) + return HilStatus::usage; + + HilStatus status = HilStatus::done; + hal::SynchronousAes128EcbStm::Config config; + arguments.Select("swap", config.dataSwapping, swappings, status); + if (status != HilStatus::done) + return status; + + auto data = infra::Head(infra::MakeRange(input), size); + auto result = infra::Head(infra::MakeRange(output), size); + + hal::SynchronousAes128EcbStm aes{ config }; + aes.SetKey(key); + + if (decrypt) + aes.Decrypt(data, result); + else + aes.Encrypt(data, result); + + auto line = context.response.Ok(); + PrintData(line, result, Output::hex); + return HilStatus::done; + } +} diff --git a/validation/firmware/AesGroup.hpp b/validation/firmware/AesGroup.hpp new file mode 100644 index 00000000..8fafba8e --- /dev/null +++ b/validation/firmware/AesGroup.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include "hal_st/synchronous_stm32fxxx/SynchronousAesStm.hpp" +#include "services/hil/HilCommand.hpp" +#include +#include + +namespace validation +{ + class AesCommands + : public services::TerminalCommands + { + public: + explicit AesCommands(services::HilContext& context); + + infra::MemoryRange Commands() override; + + private: + services::HilStatus Encrypt(const services::HilArguments& arguments); + services::HilStatus Decrypt(const services::HilArguments& arguments); + services::HilStatus Run(const services::HilArguments& arguments, bool decrypt); + + private: + services::HilContext& context; + std::array key{}; + alignas(uint32_t) std::array input{}; + alignas(uint32_t) std::array output{}; + std::array commands; + }; +} diff --git a/validation/firmware/AnalogInputGroup.cpp b/validation/firmware/AnalogInputGroup.cpp new file mode 100644 index 00000000..85e9735a --- /dev/null +++ b/validation/firmware/AnalogInputGroup.cpp @@ -0,0 +1,287 @@ +#include "validation/firmware/AnalogInputGroup.hpp" +#include "BoardProfile.hpp" +#include "infra/event/EventDispatcher.hpp" +#include "validation/firmware/AdcFactory.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include +#include +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + using services::HilChoice; + using services::HilStatus; + + // AdcTriggeredByTimerWithDma paces its conversions with TIM2 whatever the caller wants + constexpr uint8_t burstTimer = 2; + constexpr uint32_t vddaMillivolts = 3300; + constexpr uint32_t maximumIndex = std::numeric_limits::max(); + constexpr uint32_t minimumRate = 10; + constexpr uint32_t maximumRate = 100000; + constexpr uint32_t maximumRepeat = 2; + constexpr std::size_t maximumListed = 64; + constexpr infra::Duration readTimeout = std::chrono::milliseconds(1000); + constexpr uint32_t burstMarginMs = 1000; + + constexpr std::array burstKeys{ { "n", "rate", "repeat", "out" } }; + + static_assert(board::adcDma.dma == 1); + } + + AnalogInputCommands::AnalogInputCommands(services::HilContext& context, const services::HilPinNaming& naming, hal::DmaStm& dma, TimerAllocation& timers, ResourceAllocation& resources) + : services::TerminalCommands(context.terminal) + , context(context) + , naming(naming) + , dma(dma) + , timers(timers) + , resources(resources) + , pins(context.pins, owners::analogInput) + , pending(context.response) + , commands{ { + services::HilBind("ain.read", " [sampling=]", *this, context.response), + services::HilBind("ain.burst", " n= rate= [repeat=] [out=]", *this, context.response), + } } + {} + + infra::MemoryRange AnalogInputCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus AnalogInputCommands::Read(const services::HilArguments& arguments) + { + if (!arguments.Shape(2, 2, { "sampling" })) + return HilStatus::usage; + + uint32_t index = 0; + uint32_t samplingTime = board::adcDefaultSamplingTime; + HilStatus status = HilStatus::done; + arguments.NumberAt(0, index, 0, maximumIndex, status); + arguments.Select("sampling", samplingTime, board::adcSamplingTimes, status); + if (status != HilStatus::done) + return status; + + if (index != board::adc) + return HilStatus::range; + + const bool internal = arguments.Positional(1) == "temp"; + std::optional pin; + if (!internal) + { + status = ParseAnalogPin(arguments.Positional(1), pin.emplace()); + if (status != HilStatus::done) + return status; + } + + if (pending.Busy()) + return HilStatus::busy; + + status = Claim(pin, false); + if (status != HilStatus::done) + return status; + + hal::detail::AdcStmChannelConfig config; + config.samplingTime = samplingTime; + temperature = internal; + + auto& converter = adc.emplace(board::adc); + operation = pending.Start(readTimeout); + + const auto onDone = [this](infra::MemoryRange samples) + { + code = samples.front(); + + // The driver still runs the callback that delivered the sample + infra::EventDispatcher::Instance().Schedule([this]() + { + FinishRead(); + }); + }; + + if (internal) + reader.emplace(converter, config).Measure(1, onDone); + else + reader.emplace(PinOrDummy(claimedPin), converter, config).Measure(1, onDone); + + return HilStatus::done; + } + + HilStatus AnalogInputCommands::Burst(const services::HilArguments& arguments) + { + if (!arguments.Shape(2, 2, infra::MakeRange(burstKeys)) || !arguments.Has("n") || !arguments.Has("rate")) + return HilStatus::usage; + + static constexpr std::array, 2> outputs{ { + { "list", Output::list }, + { "stats", Output::stats }, + } }; + + BurstRequest parsed; + uint32_t index = 0; + HilStatus status = HilStatus::done; + arguments.NumberAt(0, index, 0, maximumIndex, status); + arguments.Number("n", parsed.samples, 1, bufferSize, status); + arguments.Number("rate", parsed.rate, minimumRate, maximumRate, status); + arguments.Number("repeat", parsed.repeat, 1, maximumRepeat, status); + arguments.Select("out", parsed.output, outputs, status); + if (status != HilStatus::done) + return status; + + if (index != board::adc || (parsed.output == Output::list && parsed.samples > maximumListed)) + return HilStatus::range; + + status = ParseAnalogPin(arguments.Positional(1), parsed.pin); + if (status != HilStatus::done) + return status; + + if (pending.Busy()) + return HilStatus::busy; + + status = Claim(parsed.pin, true); + if (status != HilStatus::done) + return status; + + request = parsed; + completedRuns = 0; + + auto& converter = adc.emplace(board::adc); + auto& receiveStream = stream.emplace(dma, hal::DmaChannelId(board::adcDma.dma, board::adcDma.channel, board::adcDmaRequest)); + burst.emplace(infra::MakeRange(buffer), converter, receiveStream, TriggerTiming(burstTimer, request.rate), PinOrDummy(claimedPin)); + + const auto runMs = request.samples * 1000 / request.rate + 1; + operation = pending.Start(std::chrono::milliseconds(request.repeat * runMs + burstMarginMs)); + StartRun(); + return HilStatus::done; + } + + HilStatus AnalogInputCommands::ParseAnalogPin(infra::BoundedConstString text, HilPinId& pin) const + { + const auto parsed = services::HilArguments::ParsePin(text, naming); + if (!parsed || !SupportsAnalog(*parsed)) + return HilStatus::pin; + + pin = *parsed; + return HilStatus::done; + } + + HilStatus AnalogInputCommands::Claim(const std::optional& pin, bool timed) + { + HilStatus status = resources.Claim(Resource::adc, board::adc, owners::analogInput); + if (status == HilStatus::done && timed) + status = resources.Claim(Resource::dma1, board::adcDma.channel, owners::analogInput); + if (status == HilStatus::done && timed) + status = timers.Claim(burstTimer, owners::analogInput); + + claimedPin = nullptr; + if (status == HilStatus::done && pin) + status = pins.ClaimAnalog(*pin, claimedPin); + + if (status != HilStatus::done) + Release(); + + return status; + } + + void AnalogInputCommands::Release() + { + pins.Release(); + claimedPin = nullptr; + timers.Release(burstTimer, owners::analogInput); + resources.Release(Resource::dma1, board::adcDma.channel, owners::analogInput); + resources.Release(Resource::adc, board::adc, owners::analogInput); + } + + void AnalogInputCommands::FinishRead() + { + reader.emplace(); + adc.reset(); + Release(); + + if (!pending.Complete(operation)) + return; + + auto line = context.response.Ok(); + line << " code=" << static_cast(code); + + if (temperature) + { + const int32_t celsius = __LL_ADC_CALC_TEMPERATURE(vddaMillivolts, code, LL_ADC_RESOLUTION_12B); + line << " mcelsius=" << (celsius < 0 ? "-" : "") << static_cast(celsius < 0 ? -celsius : celsius) * 1000; + } + } + + void AnalogInputCommands::StartRun() + { + stopwatch.Start(); + burst->Measure(request.samples, [this](infra::MemoryRange samples) + { + RunDone(samples); + }); + } + + void AnalogInputCommands::RunDone(infra::MemoryRange samples) + { + const auto microseconds = stopwatch.ElapsedUs(); + + if (++completedRuns < request.repeat) + { + { + auto line = context.response.Event("ain"); + line << " index=" << static_cast(board::adc) << " run=" << completedRuns; + PrintRun(line, samples, microseconds); + } + + StartRun(); + return; + } + + lastSamples = samples; + lastMicroseconds = microseconds; + + // The driver still runs the callback that delivered the samples + infra::EventDispatcher::Instance().Schedule([this]() + { + FinishBurst(); + }); + } + + void AnalogInputCommands::FinishBurst() + { + burst.reset(); + stream.reset(); + adc.reset(); + Release(); + + if (!pending.Complete(operation)) + return; + + auto line = context.response.Ok(); + PrintRun(line, lastSamples, lastMicroseconds); + } + + void AnalogInputCommands::PrintRun(services::HilResponse::Line& line, infra::MemoryRange samples, uint32_t microseconds) const + { + if (request.output == Output::list) + { + line << " samples="; + for (std::size_t i = 0; i != samples.size(); ++i) + line << (i == 0 ? "" : ",") << static_cast(samples[i]); + } + else + { + uint32_t sum = 0; + for (auto sample : samples) + sum += sample; + + const auto count = static_cast(samples.size()); + const auto [minimum, maximum] = std::ranges::minmax(samples); + line << " n=" << count << " min=" << static_cast(minimum) << " max=" << static_cast(maximum) << " mean=" << (sum + count / 2) / count; + } + + line << " us=" << microseconds; + } +} diff --git a/validation/firmware/AnalogInputGroup.hpp b/validation/firmware/AnalogInputGroup.hpp new file mode 100644 index 00000000..8a7a8edd --- /dev/null +++ b/validation/firmware/AnalogInputGroup.hpp @@ -0,0 +1,83 @@ +#pragma once + +#include "hal_st/stm32fxxx/AdcDmaStm.hpp" +#include "hal_st/stm32fxxx/AnalogToDigitalPinStm.hpp" +#include "hal_st/stm32fxxx/DmaStm.hpp" +#include "infra/util/MemoryRange.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilPendingOperation.hpp" +#include "services/util/Terminal.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include "validation/firmware/Stopwatch.hpp" +#include "validation/firmware/TimerAllocation.hpp" +#include +#include +#include +#include +#include + +namespace validation +{ + class AnalogInputCommands + : public services::TerminalCommands + { + public: + AnalogInputCommands(services::HilContext& context, const services::HilPinNaming& naming, hal::DmaStm& dma, TimerAllocation& timers, ResourceAllocation& resources); + + infra::MemoryRange Commands() override; + + private: + static constexpr std::size_t bufferSize = 256; + + enum class Output : uint8_t + { + list, + stats, + }; + + struct BurstRequest + { + HilPinId pin; + uint32_t samples = 0; + uint32_t rate = 0; + uint32_t repeat = 1; + Output output = Output::list; + }; + + services::HilStatus Read(const services::HilArguments& arguments); + services::HilStatus Burst(const services::HilArguments& arguments); + services::HilStatus ParseAnalogPin(infra::BoundedConstString text, HilPinId& pin) const; + services::HilStatus Claim(const std::optional& pin, bool burst); + void Release(); + void FinishRead(); + void StartRun(); + void RunDone(infra::MemoryRange samples); + void FinishBurst(); + void PrintRun(services::HilResponse::Line& line, infra::MemoryRange samples, uint32_t microseconds) const; + + private: + services::HilContext& context; + const services::HilPinNaming& naming; + hal::DmaStm& dma; + TimerAllocation& timers; + ResourceAllocation& resources; + services::HilPinOwner pins; + services::HilPendingOperation pending; + hal::GpioPin* claimedPin = nullptr; + std::optional adc; + std::variant reader; + std::optional stream; + std::optional burst; + std::array buffer{}; + BurstRequest request; + uint32_t operation = 0; + uint16_t code = 0; + bool temperature = false; + uint32_t completedRuns = 0; + infra::MemoryRange lastSamples; + uint32_t lastMicroseconds = 0; + Stopwatch stopwatch; + std::array commands; + }; +} diff --git a/validation/firmware/BackupRamGroup.cpp b/validation/firmware/BackupRamGroup.cpp new file mode 100644 index 00000000..2b546af8 --- /dev/null +++ b/validation/firmware/BackupRamGroup.cpp @@ -0,0 +1,138 @@ +#include "validation/firmware/BackupRamGroup.hpp" +#include "hal_st/stm32fxxx/BackupRamStm.hpp" +#include "infra/util/Endian.hpp" +#include + +namespace validation +{ + namespace + { + using services::HilStatus; + + constexpr uint32_t fillStep = 0x9e3779b9; + + hal::BackupRam& Instance() + { + static hal::BackupRamStm backupRam; + return backupRam; + } + + uint32_t FillValue(uint32_t seed, uint32_t index) + { + return seed ^ (fillStep * (index + 1)); + } + + void PrintValue(services::HilResponse::Line& line, uint32_t value) + { + infra::BigEndian bytes{ value }; + line << " value="; + line.Hex(infra::MakeByteRange(bytes)); + } + } + + BackupRamCommands::BackupRamCommands(services::HilContext& context) + : services::TerminalCommands(context.terminal) + , context(context) + , backupRam(Instance()) + , commands{ { + services::HilBind("bkp.info", "", *this, context.response), + services::HilBind("bkp.write", " ", *this, context.response), + services::HilBind("bkp.read", "", *this, context.response), + services::HilBind("bkp.fill", "", *this, context.response), + services::HilBind("bkp.check", "", *this, context.response), + } } + {} + + infra::MemoryRange BackupRamCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus BackupRamCommands::Info(const services::HilArguments& arguments) + { + if (!arguments.Shape(0, 0, {})) + return HilStatus::usage; + + context.response.Ok() << " words=" << static_cast(backupRam.Get().size()); + return HilStatus::done; + } + + HilStatus BackupRamCommands::Write(const services::HilArguments& arguments) + { + if (!arguments.Shape(2, 2, {})) + return HilStatus::usage; + + uint32_t index = 0; + uint32_t value = 0; + auto status = ParseIndex(arguments, index); + arguments.NumberAt(1, value, 0, std::numeric_limits::max(), status); + if (status != HilStatus::done) + return status; + + backupRam.Get()[index] = value; + context.response.Ok(); + return HilStatus::done; + } + + HilStatus BackupRamCommands::Read(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + uint32_t index = 0; + auto status = ParseIndex(arguments, index); + if (status != HilStatus::done) + return status; + + auto line = context.response.Ok(); + PrintValue(line, backupRam.Get()[index]); + return HilStatus::done; + } + + HilStatus BackupRamCommands::Fill(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + uint32_t seed = 0; + auto status = HilStatus::done; + arguments.NumberAt(0, seed, 0, std::numeric_limits::max(), status); + if (status != HilStatus::done) + return status; + + auto words = backupRam.Get(); + for (uint32_t index = 0; index != words.size(); ++index) + words[index] = FillValue(seed, index); + + context.response.Ok(); + return HilStatus::done; + } + + HilStatus BackupRamCommands::Check(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + uint32_t seed = 0; + auto status = HilStatus::done; + arguments.NumberAt(0, seed, 0, std::numeric_limits::max(), status); + if (status != HilStatus::done) + return status; + + auto words = backupRam.Get(); + uint32_t mismatches = 0; + for (uint32_t index = 0; index != words.size(); ++index) + if (words[index] != FillValue(seed, index)) + ++mismatches; + + context.response.Ok() << " mismatches=" << mismatches; + return HilStatus::done; + } + + HilStatus BackupRamCommands::ParseIndex(const services::HilArguments& arguments, uint32_t& index) const + { + auto status = HilStatus::done; + arguments.NumberAt(0, index, 0, static_cast(backupRam.Get().size()) - 1, status); + return status; + } +} diff --git a/validation/firmware/BackupRamGroup.hpp b/validation/firmware/BackupRamGroup.hpp new file mode 100644 index 00000000..f5ce1d4f --- /dev/null +++ b/validation/firmware/BackupRamGroup.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include "hal/interfaces/BackupRam.hpp" +#include "services/hil/HilCommand.hpp" +#include +#include + +namespace validation +{ + class BackupRamCommands + : public services::TerminalCommands + { + public: + explicit BackupRamCommands(services::HilContext& context); + + infra::MemoryRange Commands() override; + + private: + services::HilStatus Info(const services::HilArguments& arguments); + services::HilStatus Write(const services::HilArguments& arguments); + services::HilStatus Read(const services::HilArguments& arguments); + services::HilStatus Fill(const services::HilArguments& arguments); + services::HilStatus Check(const services::HilArguments& arguments); + + services::HilStatus ParseIndex(const services::HilArguments& arguments, uint32_t& index) const; + + private: + services::HilContext& context; + hal::BackupRam& backupRam; + std::array commands; + }; +} diff --git a/validation/firmware/BoardTypes.hpp b/validation/firmware/BoardTypes.hpp index 1ca5f240..dd436105 100644 --- a/validation/firmware/BoardTypes.hpp +++ b/validation/firmware/BoardTypes.hpp @@ -2,6 +2,7 @@ #include "hal_st/stm32fxxx/GpioStm.hpp" #include "services/hil/HilPinId.hpp" +#include #include namespace validation @@ -40,4 +41,36 @@ namespace validation uint8_t transmit; uint8_t receive; }; + + struct DmaChannel + { + uint8_t dma; + uint8_t channel; + }; + + struct DmaPair + { + DmaChannel transmit; + DmaChannel receive; + }; + + struct I2cPins + { + uint8_t index; + HilPinId scl; + HilPinId sda; + }; + + struct QuadSpiPins + { + HilPinId clk; + HilPinId ncs; + std::array io; + }; + + struct LowPowerPins + { + HilPinId wake; + HilPinId marker; + }; } diff --git a/validation/firmware/CMakeLists.txt b/validation/firmware/CMakeLists.txt index a87a4d12..d4c33879 100644 --- a/validation/firmware/CMakeLists.txt +++ b/validation/firmware/CMakeLists.txt @@ -23,24 +23,79 @@ target_link_libraries(hal_st.validation_firmware PRIVATE target_sources(hal_st.validation_firmware PRIVATE AdcFactory.cpp AdcFactory.hpp + AesGroup.cpp + AesGroup.hpp + AnalogInputGroup.cpp + AnalogInputGroup.hpp + BackupRamGroup.cpp + BackupRamGroup.hpp BoardInfoStm.cpp BoardInfoStm.hpp BoardTypes.hpp + ChannelPins.hpp + ClockGroup.cpp + ClockGroup.hpp Console.cpp Console.hpp + DmaGroup.cpp + DmaGroup.hpp + EepromGroup.cpp + EepromGroup.hpp + FlashGroup.cpp + FlashGroup.hpp + HsemGroup.cpp + HsemGroup.hpp + HsemMaster.cpp + HsemMaster.hpp + I2cEepromStm.cpp + I2cEepromStm.hpp + I2cGroup.cpp + I2cGroup.hpp + I2cTarget.cpp + I2cTarget.hpp + I2cTiming.cpp + I2cTiming.hpp + LowPowerGroup.cpp + LowPowerGroup.hpp + LpTimerGroup.cpp + LpTimerGroup.hpp + LpTimerPwmGroup.cpp + LpTimerPwmGroup.hpp Main.cpp + Owners.hpp + Payload.cpp + Payload.hpp PeripheralClocks.cpp PeripheralClocks.hpp PinFactoryStm.cpp PinFactoryStm.hpp + PkaGroup.cpp + PkaGroup.hpp PwmFactory.cpp PwmFactory.hpp QeiFactory.cpp QeiFactory.hpp + QuadSpiGroup.cpp + QuadSpiGroup.hpp + ResourceAllocation.cpp + ResourceAllocation.hpp + RngGroup.cpp + RngGroup.hpp SpiFactory.cpp SpiFactory.hpp + SpiSlaveGroup.cpp + SpiSlaveGroup.hpp + Stopwatch.hpp + SyncGpioDriver.cpp + SyncGpioDriver.hpp + SyncGpioGroup.cpp + SyncGpioGroup.hpp TimerAllocation.cpp TimerAllocation.hpp + TimerGroup.cpp + TimerGroup.hpp + TimerPwmGroup.cpp + TimerPwmGroup.hpp UartFactory.cpp UartFactory.hpp UnsupportedGroups.cpp diff --git a/validation/firmware/ChannelPins.hpp b/validation/firmware/ChannelPins.hpp new file mode 100644 index 00000000..41708c54 --- /dev/null +++ b/validation/firmware/ChannelPins.hpp @@ -0,0 +1,102 @@ +#pragma once + +#include "hal_st/stm32fxxx/GpioStm.hpp" +#include "infra/util/MemoryRange.hpp" +#include "infra/util/ReallyAssert.hpp" +#include +#include +#include +#include +#include + +namespace validation +{ + // TimerPwmWithChannels and LpTimerPwmWithChannels index one contiguous MemoryRange by channel, so an + // unused channel needs a DummyPinStm in its slot rather than a separate object + template + class ChannelPins + { + static_assert(sizeof(hal::DummyPinStm) == sizeof(hal::GpioPinStm) && alignof(hal::DummyPinStm) == alignof(hal::GpioPinStm)); + + public: + ChannelPins() = default; + ChannelPins(const ChannelPins& other) = delete; + ChannelPins& operator=(const ChannelPins& other) = delete; + ~ChannelPins(); + + void Emplace(std::size_t position, hal::Port port, uint8_t index); + void EmplaceUnused(std::size_t position); + infra::MemoryRange Range(std::size_t count); + + private: + enum class Kind : uint8_t + { + none, + pin, + unused, + }; + + void* Slot(std::size_t position); + void Destroy(std::size_t position); + + private: + alignas(hal::GpioPinStm) std::array storage; + std::array kinds{}; + }; + + //// Implementation //// + + template + ChannelPins::~ChannelPins() + { + for (std::size_t position = 0; position != N; ++position) + Destroy(position); + } + + template + void ChannelPins::Emplace(std::size_t position, hal::Port port, uint8_t index) + { + auto slot = Slot(position); + Destroy(position); + new (slot) hal::GpioPinStm(port, index); + kinds[position] = Kind::pin; + } + + template + void ChannelPins::EmplaceUnused(std::size_t position) + { + auto slot = Slot(position); + Destroy(position); + new (slot) hal::DummyPinStm(); + kinds[position] = Kind::unused; + } + + template + infra::MemoryRange ChannelPins::Range(std::size_t count) + { + really_assert(count <= N); + for (std::size_t position = 0; position != count; ++position) + really_assert(kinds[position] != Kind::none); + + auto first = std::launder(reinterpret_cast(storage.data())); + return infra::MemoryRange(first, first + count); + } + + template + void* ChannelPins::Slot(std::size_t position) + { + really_assert(position < N); + return storage.data() + position * sizeof(hal::GpioPinStm); + } + + template + void ChannelPins::Destroy(std::size_t position) + { + if (kinds[position] == Kind::pin) + std::destroy_at(std::launder(static_cast(Slot(position)))); + else if (kinds[position] == Kind::unused) + std::destroy_at(std::launder(static_cast(Slot(position)))); + + kinds[position] = Kind::none; + } +} diff --git a/validation/firmware/ClockGroup.cpp b/validation/firmware/ClockGroup.cpp new file mode 100644 index 00000000..17414d90 --- /dev/null +++ b/validation/firmware/ClockGroup.cpp @@ -0,0 +1,154 @@ +#include "validation/firmware/ClockGroup.hpp" +#include "validation/firmware/Stopwatch.hpp" +#include +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + using services::HilChoice; + using services::HilStatus; + +#if defined(STM32WB) + constexpr std::array, 3> rngSources{ { + { "clk48", RCC_RNGCLKSOURCE_CLK48 }, + { "lsi", RCC_RNGCLKSOURCE_LSI }, + { "lse", RCC_RNGCLKSOURCE_LSE }, + } }; + + constexpr std::array, 4> clk48Sources{ { + { "hsi48", LL_RCC_CLK48_CLKSOURCE_HSI48 }, + { "pllsai1", LL_RCC_CLK48_CLKSOURCE_PLLSAI1 }, + { "pll", LL_RCC_CLK48_CLKSOURCE_PLL }, + { "msi", LL_RCC_CLK48_CLKSOURCE_MSI }, + } }; + + constexpr std::array, 6> mcoSources{ { + { "sysclk", LL_RCC_MCO1SOURCE_SYSCLK }, + { "hse", LL_RCC_MCO1SOURCE_HSE }, + { "hsi", LL_RCC_MCO1SOURCE_HSI }, + { "lse", LL_RCC_MCO1SOURCE_LSE }, + { "hsi48", LL_RCC_MCO1SOURCE_HSI48 }, + { "off", LL_RCC_MCO1SOURCE_NOCLOCK }, + } }; + + constexpr std::array, 5> mcoDividers{ { + { "1", LL_RCC_MCO1_DIV_1 }, + { "2", LL_RCC_MCO1_DIV_2 }, + { "4", LL_RCC_MCO1_DIV_4 }, + { "8", LL_RCC_MCO1_DIV_8 }, + { "16", LL_RCC_MCO1_DIV_16 }, + } }; + + constexpr uint32_t hsi48TimeoutUs = 10000; +#else + constexpr std::array, 4> rngSources{ { + { "lse", RCC_RNGCLKSOURCE_LSE }, + { "lsi", RCC_RNGCLKSOURCE_LSI }, + { "hsi", RCC_RNGCLKSOURCE_HSI }, + { "pll", RCC_RNGCLKSOURCE_PLL1Q }, + } }; +#endif + + template + const char* NameOf(const std::array, N>& choices, uint32_t value) + { + for (const auto& choice : choices) + if (choice.value == value) + return choice.name; + + return "unknown"; + } + } + + ClockCommands::ClockCommands(services::HilContext& context) + : services::TerminalCommands(context.terminal) + , context(context) + , commands{ { + services::HilBind("clock.info", "", *this, context.response), +#if defined(STM32WB) + services::HilBind("clock.mco", " [div=1|2|4|8|16]", *this, context.response), + services::HilBind("clock.hsi48", "<0|1>", *this, context.response), +#endif + } } + {} + + infra::MemoryRange ClockCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus ClockCommands::Info(const services::HilArguments& arguments) + { + if (!arguments.Shape(0, 0, {})) + return HilStatus::usage; + + auto line = context.response.Ok(); + line << " sysclk=" << HAL_RCC_GetSysClockFreq() << " hclk=" << HAL_RCC_GetHCLKFreq() << " pclk1=" << HAL_RCC_GetPCLK1Freq() << " pclk2=" << HAL_RCC_GetPCLK2Freq(); +#if defined(STM32WBA) + line << " pclk7=" << HAL_RCC_GetPCLK7Freq(); +#endif + line << " hse=" << LL_RCC_HSE_IsReady() << " lse=" << LL_RCC_LSE_IsReady() << " hsi=" << LL_RCC_HSI_IsReady(); +#if defined(STM32WB) + line << " hsi48=" << LL_RCC_HSI48_IsReady() << " pll=" << LL_RCC_PLL_IsReady(); +#else + line << " pll=" << LL_RCC_PLL1_IsReady(); +#endif + line << " rngsel=" << NameOf(rngSources, __HAL_RCC_GET_RNG_SOURCE()); +#if defined(STM32WB) + line << " clk48=" << NameOf(clk48Sources, LL_RCC_GetCLK48ClockSource(LL_RCC_CLK48_CLKSOURCE)); +#endif + return HilStatus::done; + } + +#if defined(STM32WB) + HilStatus ClockCommands::Mco(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, { "div" })) + return HilStatus::usage; + + uint32_t source = LL_RCC_MCO1SOURCE_NOCLOCK; + uint32_t divider = LL_RCC_MCO1_DIV_1; + HilStatus status = HilStatus::done; + arguments.SelectAt(0, source, mcoSources, status); + arguments.Select("div", divider, mcoDividers, status); + if (status != HilStatus::done) + return status; + + LL_RCC_ConfigMCO(source, divider); + context.response.Ok(); + return HilStatus::done; + } + + HilStatus ClockCommands::Hsi48(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + uint32_t on = 0; + HilStatus status = HilStatus::done; + arguments.NumberAt(0, on, 0, 1, status); + if (status != HilStatus::done) + return status; + + // A running rng driver would lose its kernel clock (CLK48 from HSI48) and abort on the clock error + if (on == 0 && __HAL_RCC_RNG_IS_CLK_ENABLED() != 0) + return HilStatus::busy; + + if (on != 0) + LL_RCC_HSI48_Enable(); + else + LL_RCC_HSI48_Disable(); + + Stopwatch stopwatch; + stopwatch.Start(); + while (LL_RCC_HSI48_IsReady() != on) + if (stopwatch.ElapsedUs() > hsi48TimeoutUs) + return HilStatus::timeout; + + context.response.Ok(); + return HilStatus::done; + } +#endif +} diff --git a/validation/firmware/ClockGroup.hpp b/validation/firmware/ClockGroup.hpp new file mode 100644 index 00000000..1905577d --- /dev/null +++ b/validation/firmware/ClockGroup.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include "services/hil/HilCommand.hpp" +#include + +namespace validation +{ + class ClockCommands + : public services::TerminalCommands + { + public: + explicit ClockCommands(services::HilContext& context); + + infra::MemoryRange Commands() override; + + private: + services::HilStatus Info(const services::HilArguments& arguments); +#if defined(STM32WB) + services::HilStatus Mco(const services::HilArguments& arguments); + services::HilStatus Hsi48(const services::HilArguments& arguments); +#endif + + private: + services::HilContext& context; +#if defined(STM32WB) + std::array commands; +#else + std::array commands; +#endif + }; +} diff --git a/validation/firmware/DmaGroup.cpp b/validation/firmware/DmaGroup.cpp new file mode 100644 index 00000000..97dbb2ad --- /dev/null +++ b/validation/firmware/DmaGroup.cpp @@ -0,0 +1,177 @@ +#include "validation/firmware/DmaGroup.hpp" +#include "BoardProfile.hpp" +#include "validation/firmware/AdcFactory.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + using services::HilStatus; + + // board::dmaGroupDma serves the TIM2 update request + constexpr uint8_t waveTimer = 2; + constexpr uint32_t maximumRate = 1000000; + constexpr uint32_t defaultDurationMs = 50; + constexpr uint32_t maximumDurationMs = 10000; + + constexpr std::array waveKeys{ { "rate", "pattern", "ms" } }; + + constexpr Resource dmaResource = board::dmaGroupDma.dma == 1 ? Resource::dma1 : Resource::dma2; + + GPIO_TypeDef* PortRegisters(hal::Port port) + { + const std::array ports{ +#if defined(GPIOA) + GPIOA, +#endif +#if defined(GPIOB) + GPIOB, +#endif +#if defined(GPIOC) + GPIOC, +#endif +#if defined(GPIOD) + GPIOD, +#endif +#if defined(GPIOE) + GPIOE, +#endif +#if defined(GPIOF) + GPIOF, +#endif +#if defined(GPIOG) + GPIOG, +#endif +#if defined(GPIOH) + GPIOH, +#endif + }; + + return ports[static_cast(port)]; + } + } + + DmaCommands::DmaCommands(services::HilContext& context, const services::HilPinNaming& naming, hal::DmaStm& dma, TimerAllocation& timers, ResourceAllocation& resources) + : services::TerminalCommands(context.terminal) + , context(context) + , naming(naming) + , dma(dma) + , timers(timers) + , resources(resources) + , pins(context.pins, owners::dma) + , commands{ { + services::HilBind("dma.wave", " rate= pattern= [ms=]", *this, context.response), + } } + {} + + infra::MemoryRange DmaCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus DmaCommands::Wave(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, infra::MakeRange(waveKeys)) || !arguments.Has("rate") || !arguments.Has("pattern")) + return HilStatus::usage; + + uint32_t rate = 0; + uint32_t durationMs = defaultDurationMs; + std::array pattern{}; + std::size_t patternSize = 0; + HilPinId pin; + HilStatus status = HilStatus::done; + arguments.Number("rate", rate, 1, maximumRate, status); + if (status == HilStatus::done) + status = services::HilArguments::ParseHex(*arguments.Key("pattern"), infra::MakeRange(pattern), patternSize); + if (status == HilStatus::done && patternSize == 0) + status = HilStatus::usage; + arguments.Number("ms", durationMs, 1, maximumDurationMs, status); + arguments.PinAt(0, naming, pin, status); + if (status != HilStatus::done) + return status; + + if (!IsBonded(pin)) + return HilStatus::pin; + + if (channel) + return HilStatus::busy; + + status = Claim(pin); + if (status != HilStatus::done) + return status; + + claimedPin->Config(hal::PinConfigType::output, false); + const auto count = FillWords(pin, infra::Head(infra::MakeRange(pattern), patternSize)); + + auto& updates = timer.emplace(waveTimer, TriggerTiming(waveTimer, rate), hal::TimerBaseStm::Config{ hal::TimerBaseStm::CounterMode::up }); + auto& transmitStream = stream.emplace(dma, hal::DmaChannelId(board::dmaGroupDma.dma, board::dmaGroupDma.channel, board::dmaGroupDmaRequest)); + auto& circular = channel.emplace(transmitStream, &PortRegisters(PortOf(pin))->BSRR, sizeof(uint32_t), []() {}, []() {}); + circular.StartTransmit(infra::MemoryRange(words.data(), words.data() + count)); + __HAL_TIM_ENABLE_DMA(&updates.Handle(), TIM_DMA_UPDATE); + updates.Start(); + + duration.Start(std::chrono::milliseconds(durationMs), [this]() + { + Finish(); + }); + + return HilStatus::done; + } + + HilStatus DmaCommands::Claim(HilPinId pin) + { + HilStatus status = pins.Claim(pin, services::HilPinPool::Use::exclusive, claimedPin); + if (status == HilStatus::done) + status = timers.Claim(waveTimer, owners::dma); + if (status == HilStatus::done) + status = resources.Claim(dmaResource, board::dmaGroupDma.channel, owners::dma); + + if (status != HilStatus::done) + Release(); + + return status; + } + + void DmaCommands::Release() + { + pins.Release(); + claimedPin = nullptr; + timers.Release(waveTimer, owners::dma); + resources.Release(dmaResource, board::dmaGroupDma.channel, owners::dma); + } + + // The circular channel always enables its half and full transfer interrupts: repeating the pattern over the whole buffer keeps them rare at high rates + std::size_t DmaCommands::FillWords(HilPinId pin, infra::ConstByteRange pattern) + { + const auto bits = pattern.size() * 8; + const auto count = bits * (words.size() / bits); + const uint32_t set = 1u << pin.index; + const uint32_t reset = set << 16; + + for (std::size_t i = 0; i != count; ++i) + { + const auto bit = i % bits; + words[i] = (pattern[bit / 8] & (1u << (bit % 8))) != 0 ? set : reset; + } + + return count; + } + + void DmaCommands::Finish() + { + timer->Stop(); + channel->StopTransfer(); + __HAL_TIM_DISABLE_DMA(&timer->Handle(), TIM_DMA_UPDATE); + + channel.reset(); + stream.reset(); + timer.reset(); + Release(); + + context.response.Ok(); + } +} diff --git a/validation/firmware/DmaGroup.hpp b/validation/firmware/DmaGroup.hpp new file mode 100644 index 00000000..d7019517 --- /dev/null +++ b/validation/firmware/DmaGroup.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include "hal_st/stm32fxxx/DmaStm.hpp" +#include "hal_st/stm32fxxx/TimerStm.hpp" +#include "infra/timer/Timer.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/util/Terminal.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include "validation/firmware/TimerAllocation.hpp" +#include +#include +#include +#include + +namespace validation +{ + class DmaCommands + : public services::TerminalCommands + { + public: + DmaCommands(services::HilContext& context, const services::HilPinNaming& naming, hal::DmaStm& dma, TimerAllocation& timers, ResourceAllocation& resources); + + infra::MemoryRange Commands() override; + + private: + static constexpr std::size_t maximumPatternBytes = 32; + static constexpr std::size_t maximumWords = maximumPatternBytes * 8; + + services::HilStatus Wave(const services::HilArguments& arguments); + services::HilStatus Claim(HilPinId pin); + void Release(); + std::size_t FillWords(HilPinId pin, infra::ConstByteRange pattern); + void Finish(); + + private: + services::HilContext& context; + const services::HilPinNaming& naming; + hal::DmaStm& dma; + TimerAllocation& timers; + ResourceAllocation& resources; + services::HilPinOwner pins; + hal::GpioPin* claimedPin = nullptr; + std::optional timer; + std::optional stream; + std::optional channel; + std::array words{}; + infra::TimerSingleShot duration; + std::array commands; + }; +} diff --git a/validation/firmware/EepromGroup.cpp b/validation/firmware/EepromGroup.cpp new file mode 100644 index 00000000..49298814 --- /dev/null +++ b/validation/firmware/EepromGroup.cpp @@ -0,0 +1,209 @@ +#include "validation/firmware/EepromGroup.hpp" +#include "infra/event/EventDispatcher.hpp" +#include "validation/firmware/I2cTiming.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include + +namespace validation +{ + namespace + { + using services::HilStatus; + + constexpr uint32_t instances = 4; + constexpr uint32_t minimumAddress = 0x08; + constexpr uint32_t maximumAddress = 0x77; + constexpr uint32_t maximumSize = 65536; + constexpr uint32_t oneByteAddressSpace = 256; + constexpr uint32_t minimumPage = 8; + constexpr uint32_t maximumWriteCycleMs = 20; + constexpr uint32_t defaultBus = 400000; + + constexpr std::array attachKeys{ { "scl", "sda", "addr", "size", "page", "abytes", "freq", "wcycle" } }; + + bool PowerOfTwo(uint32_t value) + { + return value != 0 && (value & (value - 1)) == 0; + } + } + + uint32_t EepromGroup::DetachedEeprom::Size() const + { + return 0; + } + + void EepromGroup::DetachedEeprom::WriteBuffer(infra::ConstByteRange, uint32_t, infra::Function onDone) + { + onDone(); + } + + void EepromGroup::DetachedEeprom::ReadBuffer(infra::ByteRange, uint32_t, infra::Function onDone) + { + onDone(); + } + + void EepromGroup::DetachedEeprom::Erase(infra::Function onDone) + { + onDone(); + } + + EepromGroup::EepromGroup(services::HilContext& context, const services::HilPinNaming& naming, ResourceAllocation& resources) + : services::TerminalCommands(context.terminal) + , context(context) + , naming(naming) + , resources(resources) + , pins(context.pins, owners::eeprom) + , commands{ { + services::HilBind("eeprom.attach", " scl= sda= [addr=] [size=] [page=] [abytes=1|2] [freq=] [wcycle=]", *this, context.response), + services::HilBind("eeprom.detach", "", *this, context.response), + } } + {} + + infra::MemoryRange EepromGroup::Commands() + { + return infra::MakeRange(commands); + } + + hal::Eeprom& EepromGroup::Instance() + { + if (adapter && !detaching) + return *adapter; + + return detached; + } + + HilStatus EepromGroup::Attach(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, infra::MakeRange(attachKeys))) + return HilStatus::usage; + + uint8_t requested = 0; + Request request; + HilStatus status = Evaluate(arguments, requested, request); + if (status != HilStatus::done) + return status; + + if (adapter) + return HilStatus::busy; + + I2cBusPins claimed; + status = ClaimI2cBus(requested, request.bus, pins, resources, claimed); + if (status != HilStatus::done) + { + pins.Release(); + return status; + } + + hal::I2cStm::Config config; + config.timing = request.timing; + + index = requested; + auto& master = driver.emplace(requested, PinOrDummy(claimed.scl), PinOrDummy(claimed.sda), config, nullptr); + adapter.emplace(master, request.config, [this](I2cEepromError error, uint32_t address) + { + Error(error, address); + }); + + context.response.Ok(); + return HilStatus::done; + } + + HilStatus EepromGroup::Detach(const services::HilArguments& arguments) + { + if (!arguments.Shape(0, 0, {})) + return HilStatus::usage; + + if (!adapter) + return HilStatus::notOpen; + + if (detaching || adapter->Busy()) + return HilStatus::busy; + + if (adapter->Failed()) + adapter->Recover(); + + detaching = true; + driver->DisableInterrupts(); + + // I2cStm schedules its completions and its dispatched error handler with its own address: let those already + // queued run against a live driver + infra::EventDispatcher::Instance().Schedule([this]() + { + Destroy(); + context.response.Ok(); + }); + + return HilStatus::done; + } + + HilStatus EepromGroup::Evaluate(const services::HilArguments& arguments, uint8_t& index, Request& request) const + { + uint32_t requested = 0; + HilStatus status = HilStatus::done; + arguments.NumberAt(0, requested, 0, instances - 1, status); + if (status != HilStatus::done) + return status; + + if (!arguments.Has("scl") || !arguments.Has("sda")) + return HilStatus::usage; + + uint32_t address = request.config.address; + uint32_t size = request.config.size; + uint32_t page = request.config.page; + uint32_t addressBytes = request.config.addressBytes; + uint32_t writeCycleMs = request.config.writeCycleMs; + uint32_t frequency = defaultBus; + constexpr auto any = std::numeric_limits::max(); + arguments.Number("addr", address, 0, any, status); + arguments.Number("size", size, 0, any, status); + arguments.Number("page", page, 0, any, status); + arguments.Number("abytes", addressBytes, 0, any, status); + arguments.Number("wcycle", writeCycleMs, 0, any, status); + arguments.Number("freq", frequency, 0, any, status); + if (status != HilStatus::done) + return status; + + index = static_cast(requested); + if (!I2cExists(index) || address < minimumAddress || address > maximumAddress || size == 0 || size > maximumSize) + return HilStatus::range; + + if (page < minimumPage || page > I2cEepromStm::maximumPage || !PowerOfTwo(page) || addressBytes < 1 || addressBytes > I2cEepromStm::maximumAddressBytes || writeCycleMs > maximumWriteCycleMs) + return HilStatus::range; + + if (addressBytes == 1 && size > oneByteAddressSpace) + return HilStatus::range; + + auto timing = I2cTiming(I2cKernelClock(index), frequency); + if (!timing) + return HilStatus::range; + + request.timing = *timing; + request.config.address = static_cast(address); + request.config.size = size; + request.config.page = page; + request.config.addressBytes = static_cast(addressBytes); + request.config.writeCycleMs = writeCycleMs; + + status = ParseI2cBus(arguments, naming, request.bus); + if (status != HilStatus::done) + return status; + + return CheckI2cBus(index, request.bus); + } + + void EepromGroup::Error(I2cEepromError error, uint32_t address) + { + context.response.Event("eeprom") << " error=" << (error == I2cEepromError::busError ? "buserror" : "nack") << " address=" << address; + } + + void EepromGroup::Destroy() + { + adapter.reset(); + driver.reset(); + resources.Release(Resource::i2c, index, owners::eeprom); + pins.Release(); + detaching = false; + } +} diff --git a/validation/firmware/EepromGroup.hpp b/validation/firmware/EepromGroup.hpp new file mode 100644 index 00000000..30f5cdb3 --- /dev/null +++ b/validation/firmware/EepromGroup.hpp @@ -0,0 +1,62 @@ +#pragma once + +#include "hal/interfaces/Eeprom.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilEepromCommands.hpp" +#include "services/util/Terminal.hpp" +#include "validation/firmware/I2cEepromStm.hpp" +#include "validation/firmware/I2cGroup.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include +#include +#include + +namespace validation +{ + class EepromGroup + : public services::TerminalCommands + , public services::HilEepromFactory + { + public: + EepromGroup(services::HilContext& context, const services::HilPinNaming& naming, ResourceAllocation& resources); + + infra::MemoryRange Commands() override; + hal::Eeprom& Instance() override; + + private: + class DetachedEeprom + : public hal::Eeprom + { + public: + uint32_t Size() const override; + void WriteBuffer(infra::ConstByteRange buffer, uint32_t address, infra::Function onDone) override; + void ReadBuffer(infra::ByteRange buffer, uint32_t address, infra::Function onDone) override; + void Erase(infra::Function onDone) override; + }; + + struct Request + { + I2cBus bus; + I2cEepromStm::Config config; + uint32_t timing = 0; + }; + + services::HilStatus Attach(const services::HilArguments& arguments); + services::HilStatus Detach(const services::HilArguments& arguments); + services::HilStatus Evaluate(const services::HilArguments& arguments, uint8_t& index, Request& request) const; + void Error(I2cEepromError error, uint32_t address); + void Destroy(); + + private: + services::HilContext& context; + const services::HilPinNaming& naming; + ResourceAllocation& resources; + services::HilPinOwner pins; + std::optional driver; + std::optional adapter; + DetachedEeprom detached; + uint8_t index = 0; + bool detaching = false; + std::array commands; + }; +} diff --git a/validation/firmware/FlashGroup.cpp b/validation/firmware/FlashGroup.cpp new file mode 100644 index 00000000..bdd523f3 --- /dev/null +++ b/validation/firmware/FlashGroup.cpp @@ -0,0 +1,620 @@ +#include "validation/firmware/FlashGroup.hpp" +#include "BoardProfile.hpp" +#include "infra/event/EventDispatcher.hpp" +#include "infra/util/Endian.hpp" +#include +#include +#include +#include +#include DEVICE_HEADER +#if defined(STM32WB) +#include "validation/firmware/HsemMaster.hpp" +#endif + +extern "C" +{ + extern const uint8_t _sidata[]; + extern const uint8_t _sdata[]; + extern const uint8_t _edata[]; +} + +namespace validation +{ + namespace + { + using services::HilChoice; + using services::HilStatus; + using Variant = FlashCommands::Variant; + using Layout = FlashCommands::Layout; + + constexpr std::array, 3> variants{ { + { "sync", Variant::sync }, + { "async", Variant::async }, + { "coord", Variant::coord }, + } }; + + constexpr std::array, 2> layouts{ { + { "homogeneous", Layout::homogeneous }, + { "table", Layout::table }, + } }; + + constexpr std::array driverKeys{ { "variant", "layout" } }; + constexpr std::array writeKeys{ { "len", "pattern", "seed", "variant", "layout" } }; + constexpr std::array readKeys{ { "out", "variant", "layout" } }; + +#if defined(STM32WBA) + constexpr uint32_t flashWord = 16; +#else + constexpr uint32_t flashWord = 8; +#endif + constexpr std::array tablePattern{ { 1, 1, 2, 4 } }; + constexpr uint32_t tablePatternPages = 8; + constexpr uint32_t tablePatternRepeats = 2; + constexpr std::size_t readChunk = 64; + constexpr std::chrono::seconds eraseTimeout{ 10 }; + constexpr std::chrono::seconds transferTimeout{ 2 }; + +#if defined(STM32WB) + enum class StackState : uint8_t + { + stopped, + starting, + fus, + }; + + constexpr std::array, 3> stackStates{ { + { "stopped", StackState::stopped }, + { "starting", StackState::starting }, + { "fus", StackState::fus }, + } }; + + constexpr std::array stackKeys{ { "layout" } }; + + // hsem.lock holds HSEM 0 as owners::scaffold; the coordinated flash needs an owner of its own to exclude it + constexpr services::HilOwner coordinatedOwner = services::HilOwners::last; +#endif + + uint32_t Address(const uint8_t* pointer) + { + return static_cast(reinterpret_cast(pointer)); + } + + void PrintCrc(services::HilResponse::Line& line, uint32_t length, uint32_t value) + { + infra::BigEndian crc{ value }; + line << " len=" << length << " crc="; + line.Hex(infra::MakeByteRange(crc)); + } + } + + FlashCommands::FlashCommands(services::HilContext& context, WatchDogFactoryStm& watchDogFactory, ResourceAllocation& resources) + : services::TerminalCommands(context.terminal) + , context(context) + , watchDogFactory(watchDogFactory) + , resources(resources) + , pending(context.response) + , commands{ { + services::HilBind("flash.info", "[variant=sync|async|coord] [layout=homogeneous|table]", *this, context.response), + services::HilBind("flash.erase", " [variant=] [layout=]", *this, context.response), + services::HilBind("flash.write", "

[len=] [pattern=] [seed=] [variant=] [layout=]", *this, context.response), + services::HilBind("flash.read", "
[out=hex|crc] [variant=] [layout=]", *this, context.response), +#if defined(STM32WB) + services::HilBind("flash.stack", " [layout=]", *this, context.response), +#endif + } } + { + static_assert(board::flashScratchEndPage - board::flashScratchFirstPage <= std::tuple_size_v); + + uint32_t endPage = board::flashScratchEndPage; +#if defined(STM32WB) + FLASH_OBProgramInitTypeDef options{}; + HAL_FLASHEx_OBGetConfig(&options); + if (options.SecureMode == SYSTEM_IN_SECURE_MODE) + endPage = std::min(endPage, (options.SecureFlashStartAddr - FLASH_BASE) / FLASH_PAGE_SIZE); +#endif + + const auto imageEnd = Address(_sidata) + (Address(_edata) - Address(_sdata)); + imageEndPage = (imageEnd - FLASH_BASE + FLASH_PAGE_SIZE - 1) / FLASH_PAGE_SIZE; + usable = imageEndPage <= board::flashScratchFirstPage && endPage > board::flashScratchFirstPage; + + const auto pages = usable ? endPage - board::flashScratchFirstPage : 0; + const auto begin = reinterpret_cast(FLASH_BASE + board::flashScratchFirstPage * FLASH_PAGE_SIZE); + region = infra::ConstByteRange(begin, begin + pages * FLASH_PAGE_SIZE); + + // Single pages first: an unfixed erase (B.5) takes the sector index for an absolute page, so the multi-page + // sectors need indices past the image end page to be accepted (see LayoutGeometry) + const auto repeats = std::min(tablePatternRepeats, pages / tablePatternPages); + for (uint32_t page = 0; page != pages - repeats * tablePatternPages; ++page) + sectorSizes[tableSectors++] = FLASH_PAGE_SIZE; + for (uint32_t repeat = 0; repeat != repeats; ++repeat) + for (auto size : tablePattern) + sectorSizes[tableSectors++] = size * FLASH_PAGE_SIZE; + } + + infra::MemoryRange FlashCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus FlashCommands::Info(const services::HilArguments& arguments) + { + if (!arguments.Shape(0, 0, infra::MakeRange(driverKeys))) + return HilStatus::usage; + + Request candidate; + auto status = ParseDriver(arguments, candidate); + if (status == HilStatus::done) + status = CheckDriver(candidate); + if (status != HilStatus::done) + return status; + + const auto geometry = LayoutGeometry(candidate.layout); + infra::BigEndian base{ Address(region.begin()) }; + auto line = context.response.Ok(); + line << " base=0x"; + line.Hex(infra::MakeByteRange(base)); + line << " sectors=" << geometry.sectors << " size=" << RegionSize() << " first=" << geometry.first << " image=" << imageEndPage << " layout=" << (candidate.layout == Layout::table ? "table" : "homogeneous"); + return HilStatus::done; + } + + HilStatus FlashCommands::Erase(const services::HilArguments& arguments) + { + if (!arguments.Shape(2, 2, infra::MakeRange(driverKeys))) + return HilStatus::usage; + + Request candidate; + candidate.job = Job::erase; + auto status = HilStatus::done; + arguments.NumberAt(0, candidate.begin, 0, std::numeric_limits::max(), status); + arguments.NumberAt(1, candidate.end, 0, std::numeric_limits::max(), status); + if (status == HilStatus::done) + status = ParseDriver(arguments, candidate); + if (status != HilStatus::done) + return status; + + if (!usable) + return HilStatus::unsupported; + + const auto geometry = LayoutGeometry(candidate.layout); + if (candidate.begin < geometry.first || candidate.begin >= candidate.end || candidate.end > geometry.sectors) + return HilStatus::range; + + status = CheckDriver(candidate); + if (status == HilStatus::done) + status = Reserve(candidate); + if (status != HilStatus::done) + return status; + + return Run(candidate); + } + + HilStatus FlashCommands::Write(const services::HilArguments& arguments) + { + if (!arguments.Shape(2, 2, infra::MakeRange(writeKeys))) + return HilStatus::usage; + + Request candidate; + candidate.job = Job::write; + auto status = HilStatus::done; + arguments.NumberAt(0, candidate.address, 0, std::numeric_limits::max(), status); + if (status == HilStatus::done) + status = ParseDriver(arguments, candidate); + if (status != HilStatus::done) + return status; + + // An operation in flight may still program from the payload buffer + if (pending.Busy()) + return HilStatus::busy; + + status = ParsePayload(arguments, 1, infra::MakeRange(buffer), payload); + if (status == HilStatus::done && payload.empty()) + status = HilStatus::usage; + if (status != HilStatus::done) + return status; + + if (!usable) + return HilStatus::unsupported; + + candidate.length = static_cast(payload.size()); + const auto geometry = LayoutGeometry(candidate.layout); + if (candidate.address < AddressOfSector(candidate.layout, geometry.first) || candidate.address > RegionSize() || candidate.length > RegionSize() - candidate.address) + return HilStatus::range; + + status = CheckDriver(candidate); + if (status == HilStatus::done) + status = Reserve(candidate); + if (status != HilStatus::done) + return status; + + // Programming a flash word that is not erased fails the driver's assertion + if (!Erased(candidate.address, candidate.length)) + return HilStatus::failed; + + return Run(candidate); + } + + HilStatus FlashCommands::Read(const services::HilArguments& arguments) + { + if (!arguments.Shape(2, 2, infra::MakeRange(readKeys))) + return HilStatus::usage; + + Request candidate; + auto status = HilStatus::done; + arguments.NumberAt(0, candidate.address, 0, std::numeric_limits::max(), status); + arguments.NumberAt(1, candidate.length, 1, std::numeric_limits::max(), status); + if (status == HilStatus::done) + status = ParseOutput(arguments, candidate.output); + if (status == HilStatus::done) + status = ParseDriver(arguments, candidate); + if (status != HilStatus::done) + return status; + + if (!usable) + return HilStatus::unsupported; + + if (candidate.address > RegionSize() || candidate.length > RegionSize() - candidate.address || CheckOutput(candidate.length, candidate.output) != HilStatus::done) + return HilStatus::range; + + status = CheckDriver(candidate); + if (status == HilStatus::done) + status = Reserve(candidate); + if (status != HilStatus::done) + return status; + + return Run(candidate); + } + +#if defined(STM32WB) + HilStatus FlashCommands::Stack(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, infra::MakeRange(stackKeys))) + return HilStatus::usage; + + auto state = StackState::stopped; + Request candidate; + candidate.variant = Variant::coord; + auto status = HilStatus::done; + arguments.SelectAt(0, state, stackStates, status); + arguments.Select("layout", candidate.layout, layouts, status); + if (status != HilStatus::done) + return status; + + if (!usable) + return HilStatus::unsupported; + + if (state != StackState::starting) + { + if (stackHeld) + { + // The driver has no way out of `starting` but FirmwareUpgradeServicesReady, which also runs the held step + stackHeld = false; + coordinated->FirmwareUpgradeServicesReady(); + if (operation == 0) + ReleaseAsynchronous(); + } + + context.response.Ok(); + return HilStatus::done; + } + + if (stackHeld) + return HilStatus::busy; + + status = Reserve(candidate); + if (status != HilStatus::done) + return status; + + request = candidate; + status = ConstructAsynchronous(); + if (status != HilStatus::done) + return status; + + coordinated->WirelessStackStarting(); + stackHeld = true; + context.response.Ok(); + return HilStatus::done; + } +#endif + + HilStatus FlashCommands::ParseDriver(const services::HilArguments& arguments, Request& candidate) const + { + auto status = HilStatus::done; + arguments.Select("variant", candidate.variant, variants, status); + arguments.Select("layout", candidate.layout, layouts, status); + return status; + } + + HilStatus FlashCommands::CheckDriver(const Request& candidate) const + { + if (!usable) + return HilStatus::unsupported; + +#if !defined(STM32WB) + if (candidate.variant == Variant::coord) + return HilStatus::unsupported; +#endif + + return HilStatus::done; + } + + FlashCommands::Geometry FlashCommands::LayoutGeometry(Layout layout) const + { + const auto sectors = layout == Layout::table ? tableSectors : RegionSize() / FLASH_PAGE_SIZE; + + // An unfixed erase (B.5) takes the sector index for the absolute page; every sector starts at or past the + // page of its index, so sectors from the image end page on cannot reach the running image + return { sectors, std::min(imageEndPage, sectors) }; + } + + uint32_t FlashCommands::AddressOfSector(Layout layout, uint32_t sector) const + { + if (layout == Layout::homogeneous) + return sector * FLASH_PAGE_SIZE; + + uint32_t address = 0; + for (uint32_t index = 0; index != sector; ++index) + address += sectorSizes[index]; + + return address; + } + + uint32_t FlashCommands::RegionSize() const + { + return static_cast(region.size()); + } + + bool FlashCommands::Erased(uint32_t start, uint32_t length) const + { + const auto first = start / flashWord * flashWord; + const auto last = std::min((start + length + flashWord - 1) / flashWord * flashWord, RegionSize()); + + return std::all_of(region.begin() + first, region.begin() + last, [](uint8_t byte) + { + return byte == 0xff; + }); + } + + HilStatus FlashCommands::Reserve(const Request& candidate) + { + if (candidate.job == Job::read && candidate.variant == Variant::sync) + return HilStatus::done; + +#if defined(STM32WB) + if (stackHeld) + return !pending.Busy() && candidate.variant == Variant::coord && candidate.layout == activeLayout ? HilStatus::done : HilStatus::busy; +#endif + + // A coordinated step waiting for HSEM 7 runs once the semaphore is released, also after its ERR timeout + return pending.Busy() ? HilStatus::busy : HilStatus::done; + } + + HilStatus FlashCommands::Run(const Request& candidate) + { + // A synchronous read may run while an operation is held, which keeps its own request + if (candidate.variant == Variant::sync) + { + RunSynchronous(candidate); + return HilStatus::done; + } + + request = candidate; + auto status = HilStatus::done; +#if defined(STM32WB) + if (!stackHeld) + status = ConstructAsynchronous(); +#else + status = ConstructAsynchronous(); +#endif + if (status != HilStatus::done) + return status; + + StartAsynchronous(); + return HilStatus::done; + } + + void FlashCommands::RunSynchronous(const Request& synchronous) + { + if (synchronous.layout == Layout::table) + { + hal::SynchronousFlashInternalStm flash{ infra::MemoryRange(sectorSizes.data(), sectorSizes.data() + tableSectors), region }; + RunSynchronous(flash, synchronous); + } + else + { + hal::SynchronousFlashHomogeneousInternalStm flash{ RegionSize() / FLASH_PAGE_SIZE, FLASH_PAGE_SIZE, region }; + RunSynchronous(flash, synchronous); + } + } + + void FlashCommands::RunSynchronous(hal::SynchronousFlash& flash, const Request& synchronous) + { + if (synchronous.job == Job::read) + { + ReadSynchronous(flash, synchronous); + return; + } + + stopwatch.Start(); + if (synchronous.job == Job::erase) + flash.EraseSectors(synchronous.begin, synchronous.end); + else + flash.WriteBuffer(payload, synchronous.address); + + context.response.Ok() << " us=" << stopwatch.ElapsedUs(); + } + + void FlashCommands::ReadSynchronous(hal::SynchronousFlash& flash, const Request& synchronous) + { + // A held coordinated write still programs from `buffer`, so a synchronous read streams through a small chunk + std::array chunk; + infra::Crc32 checksum; + auto line = context.response.Ok(); + + if (synchronous.output == Output::hex) + line << " data="; + + for (uint32_t offset = 0; offset != synchronous.length;) + { + auto part = infra::Head(infra::MakeRange(chunk), std::min(synchronous.length - offset, chunk.size())); + flash.ReadBuffer(part, synchronous.address + offset); + offset += static_cast(part.size()); + + if (synchronous.output == Output::hex) + line.Hex(part); + else + checksum.Update(part); + } + + if (synchronous.output == Output::crc) + PrintCrc(line, synchronous.length, checksum.Result()); + } + + HilStatus FlashCommands::ConstructAsynchronous() + { + ReleaseAsynchronous(); + +#if defined(STM32WB) + if (request.variant == Variant::coord) + { + auto status = resources.Claim(Resource::hsem, 0, coordinatedOwner); + if (status != HilStatus::done) + return status; + + HsemMaster(); + } +#endif + + activeLayout = request.layout; + + hal::FlashInternalStmBase* base = nullptr; + if (request.layout == Layout::table) + base = &asyncFlash.emplace(infra::MemoryRange(sectorSizes.data(), sectorSizes.data() + tableSectors), region); + else + base = &asyncFlash.emplace(RegionSize() / FLASH_PAGE_SIZE, FLASH_PAGE_SIZE, region); + + activeFlash = base; +#if defined(STM32WB) + if (request.variant == Variant::coord) + activeFlash = &coordinated.emplace(*base, watchDogFactory.Borrow(), hal::FlashCoordinatedWithWirelessStack::WirelessStack::stopped); +#endif + + return HilStatus::done; + } + + void FlashCommands::StartAsynchronous() + { + const auto timeout = request.job == Job::erase ? eraseTimeout : transferTimeout; + operation = pending.Start(timeout); + stopwatch.Start(); + + switch (request.job) + { + case Job::erase: + activeFlash->EraseSectors(request.begin, request.end, [this, doneOperation = operation]() + { + Done(doneOperation); + }); + break; + case Job::write: + activeFlash->WriteBuffer(payload, request.address, [this, doneOperation = operation]() + { + Done(doneOperation); + }); + break; + case Job::read: + crc.Reset(); + address = request.address; + remaining = request.length; + ReadNextChunk(); + break; + } + } + + void FlashCommands::ReadNextChunk() + { + auto chunk = infra::Head(infra::MakeRange(buffer), std::min(remaining, buffer.size())); + activeFlash->ReadBuffer(chunk, address, [this, chunkOperation = operation]() + { + ChunkRead(chunkOperation); + }); + } + + void FlashCommands::ChunkRead(uint32_t chunkOperation) + { + if (chunkOperation != operation) + return; + + const auto size = static_cast(std::min(remaining, buffer.size())); + if (request.output == Output::crc) + crc.Update(infra::Head(infra::MakeRange(buffer), size)); + + address += size; + remaining -= size; + + if (remaining != 0) + ReadNextChunk(); + else + Done(chunkOperation); + } + + void FlashCommands::Done(uint32_t doneOperation) + { + if (doneOperation != operation) + return; + + operation = 0; + elapsed = stopwatch.ElapsedUs(); + + // The driver calls this from its own scheduled callback; destroy it one event-loop turn later + infra::EventDispatcher::Instance().Schedule([this]() + { +#if defined(STM32WB) + if (stackHeld) + return; +#endif + if (operation == 0) + ReleaseAsynchronous(); + }); + + if (pending.Complete(doneOperation)) + { + Finish(); + return; + } + + const char* job = "read"; + if (request.job == Job::erase) + job = "erase"; + else if (request.job == Job::write) + job = "write"; + + context.response.Event("flash") << " op=" << job << " us=" << elapsed; + } + + void FlashCommands::Finish() + { + auto line = context.response.Ok(); + + if (request.job != Job::read) + line << " us=" << elapsed; + else if (request.output == Output::hex) + PrintData(line, infra::Head(infra::MakeRange(buffer), request.length), Output::hex); + else + PrintCrc(line, request.length, crc.Result()); + } + + void FlashCommands::ReleaseAsynchronous() + { +#if defined(STM32WB) + if (coordinated) + { + coordinated.reset(); + watchDogFactory.Return(); + resources.Release(Resource::hsem, 0, coordinatedOwner); + } +#endif + + asyncFlash.emplace(); + activeFlash = nullptr; + } +} diff --git a/validation/firmware/FlashGroup.hpp b/validation/firmware/FlashGroup.hpp new file mode 100644 index 00000000..714b3434 --- /dev/null +++ b/validation/firmware/FlashGroup.hpp @@ -0,0 +1,131 @@ +#pragma once + +#include "hal_st/stm32fxxx/FlashInternalStm.hpp" +#include "hal_st/synchronous_stm32fxxx/SynchronousFlashInternalStm.hpp" +#include "infra/util/Crc.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilPendingOperation.hpp" +#include "validation/firmware/Payload.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include "validation/firmware/Stopwatch.hpp" +#include "validation/firmware/WatchDogFactory.hpp" +#include +#include +#include +#include +#if defined(STM32WB) +#include "hal_st/stm32fxxx/FlashCoordinatedWithWirelessStack.hpp" +#endif + +namespace validation +{ + class FlashCommands + : public services::TerminalCommands + { + public: + enum class Variant : uint8_t + { + sync, + async, + coord, + }; + + enum class Layout : uint8_t + { + homogeneous, + table, + }; + + FlashCommands(services::HilContext& context, WatchDogFactoryStm& watchDogFactory, ResourceAllocation& resources); + + infra::MemoryRange Commands() override; + + private: + enum class Job : uint8_t + { + erase, + write, + read, + }; + + struct Request + { + Job job = Job::read; + Variant variant = Variant::sync; + Layout layout = Layout::homogeneous; + uint32_t begin = 0; + uint32_t end = 0; + uint32_t address = 0; + uint32_t length = 0; + Output output = Output::hex; + }; + + struct Geometry + { + uint32_t sectors = 0; + uint32_t first = 0; + }; + + services::HilStatus Info(const services::HilArguments& arguments); + services::HilStatus Erase(const services::HilArguments& arguments); + services::HilStatus Write(const services::HilArguments& arguments); + services::HilStatus Read(const services::HilArguments& arguments); +#if defined(STM32WB) + services::HilStatus Stack(const services::HilArguments& arguments); +#endif + + services::HilStatus ParseDriver(const services::HilArguments& arguments, Request& request) const; + services::HilStatus CheckDriver(const Request& request) const; + Geometry LayoutGeometry(Layout layout) const; + uint32_t AddressOfSector(Layout layout, uint32_t sector) const; + uint32_t RegionSize() const; + bool Erased(uint32_t address, uint32_t length) const; + services::HilStatus Reserve(const Request& request); + services::HilStatus Run(const Request& request); + + void RunSynchronous(const Request& synchronous); + void RunSynchronous(hal::SynchronousFlash& flash, const Request& synchronous); + void ReadSynchronous(hal::SynchronousFlash& flash, const Request& synchronous); + services::HilStatus ConstructAsynchronous(); + void StartAsynchronous(); + void ReadNextChunk(); + void ChunkRead(uint32_t chunkOperation); + void Done(uint32_t doneOperation); + void Finish(); + void ReleaseAsynchronous(); + + private: + services::HilContext& context; + WatchDogFactoryStm& watchDogFactory; + ResourceAllocation& resources; + services::HilPendingOperation pending; + infra::ConstByteRange region; + uint32_t imageEndPage = 0; + bool usable = false; + std::array sectorSizes{}; + uint32_t tableSectors = 0; + + std::variant asyncFlash; +#if defined(STM32WB) + std::optional coordinated; + bool stackHeld = false; +#endif + hal::Flash* activeFlash = nullptr; + Layout activeLayout = Layout::homogeneous; + + Request request; + Stopwatch stopwatch; + std::array buffer{}; + infra::ByteRange payload; + infra::Crc32 crc; + uint32_t address = 0; + uint32_t remaining = 0; + uint32_t elapsed = 0; + uint32_t operation = 0; +#if defined(STM32WB) + std::array commands; +#else + std::array commands; +#endif + }; +} diff --git a/validation/firmware/HsemGroup.cpp b/validation/firmware/HsemGroup.cpp new file mode 100644 index 00000000..fb47d7dc --- /dev/null +++ b/validation/firmware/HsemGroup.cpp @@ -0,0 +1,210 @@ +#include "validation/firmware/HsemGroup.hpp" + +#if defined(STM32WB) + +#include "BoardProfile.hpp" +#include "hal_st/stm32fxxx/TimerStm.hpp" +#include "hal_st/synchronous_stm32fxxx/SynchronousHardwareSemaphoreStm.hpp" +#include "stm32wbxx_ll_hsem.h" +#include "validation/firmware/HsemMaster.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include +#include +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + using services::HilStatus; + + constexpr uint32_t maximumHoldMs = 60000; + constexpr uint32_t minimumHoldUs = 2; + constexpr uint32_t maximumHoldUs = 65536; + constexpr uint32_t ticksPerSecond = 1000000; + constexpr uint32_t lockHoldProcess = 1; + } + + HsemCommands::HsemCommands(services::HilContext& context, TimerAllocation& timers, ResourceAllocation& resources) + : services::TerminalCommands(context.terminal) + , context(context) + , timers(timers) + , resources(resources) + , commands{ { + services::HilBind("hsem.take", " procid=<1..255> [hold=]", *this, context.response), + services::HilBind("hsem.release", " procid=<1..255>", *this, context.response), + services::HilBind("hsem.status", "", *this, context.response), + services::HilBind("hsem.lock", " [hold=]", *this, context.response), + services::HilBind("hsem.mine", "", *this, context.response), + } } + {} + + infra::MemoryRange HsemCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus HsemCommands::Take(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, { "procid", "hold" })) + return HilStatus::usage; + + uint32_t semaphore = 0; + uint32_t process = 0; + uint32_t hold = 0; + auto status = ParseProcess(arguments, semaphore, process); + arguments.Number("hold", hold, 1, maximumHoldMs, status); + if (status != HilStatus::done) + return status; + + HsemMaster(); + if (hold != 0 && holdTimer.Armed()) + return HilStatus::busy; + + if (HAL_HSEM_Take(semaphore, process) != HAL_OK) + return HilStatus::busy; + + if (hold != 0) + { + heldSemaphore = semaphore; + heldProcess = process; + holdTimer.Start(std::chrono::milliseconds(hold), [this]() + { + HAL_HSEM_Release(heldSemaphore, heldProcess); + }); + } + + context.response.Ok(); + return HilStatus::done; + } + + HilStatus HsemCommands::Release(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, { "procid" })) + return HilStatus::usage; + + uint32_t semaphore = 0; + uint32_t process = 0; + auto status = ParseProcess(arguments, semaphore, process); + if (status != HilStatus::done) + return status; + + HsemMaster(); + HAL_HSEM_Release(semaphore, process); + if (holdTimer.Armed() && heldSemaphore == semaphore && heldProcess == process) + holdTimer.Cancel(); + + context.response.Ok(); + return HilStatus::done; + } + + HilStatus HsemCommands::Status(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + uint32_t semaphore = 0; + auto status = HilStatus::done; + arguments.NumberAt(0, semaphore, 0, HSEM_SEMID_MAX, status); + if (status != HilStatus::done) + return status; + + HsemMaster(); + context.response.Ok() << " locked=" << LL_HSEM_IsSemaphoreLocked(HSEM, semaphore) << " core=" << (LL_HSEM_GetCoreId(HSEM, semaphore) >> HSEM_R_COREID_Pos) << " procid=" << LL_HSEM_GetProcessId(HSEM, semaphore); + return HilStatus::done; + } + + HilStatus HsemCommands::Lock(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, { "hold" })) + return HilStatus::usage; + + uint32_t semaphore = 0; + uint32_t hold = 0; + auto status = HilStatus::done; + arguments.NumberAt(0, semaphore, 0, HSEM_SEMID_MAX, status); + arguments.Number("hold", hold, minimumHoldUs, maximumHoldUs, status); + if (status != HilStatus::done) + return status; + + HsemMaster(); + status = resources.Claim(Resource::hsem, 0, owners::scaffold); + if (status == HilStatus::done && hold != 0) + { + status = timers.Claim(board::scaffoldTimer, owners::scaffold); + if (status != HilStatus::done) + resources.Release(Resource::hsem, 0, owners::scaffold); + } + if (status != HilStatus::done) + return status; + + // The wait blocks the event loop: a semaphore held by anything but the scaffold timer would never be freed + uint32_t waited = 0; + if (LL_HSEM_IsSemaphoreLocked(HSEM, semaphore) != 0) + status = HilStatus::busy; + else + waited = Wait(semaphore, hold); + + if (hold != 0) + timers.Release(board::scaffoldTimer, owners::scaffold); + resources.Release(Resource::hsem, 0, owners::scaffold); + if (status != HilStatus::done) + return status; + + context.response.Ok() << " waited=" << waited; + return HilStatus::done; + } + + HilStatus HsemCommands::Mine(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + uint32_t semaphore = 0; + auto status = HilStatus::done; + arguments.NumberAt(0, semaphore, 0, HSEM_SEMID_MAX, status); + if (status != HilStatus::done) + return status; + + const bool mine = HsemMaster().IsLockedByCurrentCore(static_cast(semaphore)); + context.response.Ok() << " mine=" << static_cast(mine ? 1 : 0); + return HilStatus::done; + } + + HilStatus HsemCommands::ParseProcess(const services::HilArguments& arguments, uint32_t& semaphore, uint32_t& process) const + { + if (!arguments.Has("procid")) + return HilStatus::usage; + + auto status = HilStatus::done; + arguments.NumberAt(0, semaphore, 0, HSEM_SEMID_MAX, status); + arguments.Number("procid", process, 1, HSEM_PROCESSID_MAX, status); + return status; + } + + uint32_t HsemCommands::Wait(uint32_t semaphore, uint32_t holdUs) + { + std::optional releaser; + + if (holdUs != 0) + { + HAL_HSEM_Take(semaphore, lockHoldProcess); + + auto& timer = releaser.emplace(board::scaffoldTimer, hal::TimerBaseStm::Timing{ TimerClock(board::scaffoldTimer) / ticksPerSecond - 1, holdUs - 1 }); + __HAL_TIM_CLEAR_FLAG(&timer.Handle(), TIM_FLAG_UPDATE); + timer.Start([&timer, semaphore]() + { + HAL_HSEM_Release(semaphore, lockHoldProcess); + timer.Stop(); + }, + hal::InterruptType::immediate); + } + + stopwatch.Start(); + hal::SynchronousHardwareSemaphoreStm lock{ HsemMaster(), static_cast(semaphore) }; + return stopwatch.ElapsedUs(); + } +} + +#endif diff --git a/validation/firmware/HsemGroup.hpp b/validation/firmware/HsemGroup.hpp new file mode 100644 index 00000000..598604cd --- /dev/null +++ b/validation/firmware/HsemGroup.hpp @@ -0,0 +1,45 @@ +#pragma once + +#if defined(STM32WB) + +#include "infra/timer/Timer.hpp" +#include "services/hil/HilCommand.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include "validation/firmware/Stopwatch.hpp" +#include "validation/firmware/TimerAllocation.hpp" +#include +#include + +namespace validation +{ + class HsemCommands + : public services::TerminalCommands + { + public: + HsemCommands(services::HilContext& context, TimerAllocation& timers, ResourceAllocation& resources); + + infra::MemoryRange Commands() override; + + private: + services::HilStatus Take(const services::HilArguments& arguments); + services::HilStatus Release(const services::HilArguments& arguments); + services::HilStatus Status(const services::HilArguments& arguments); + services::HilStatus Lock(const services::HilArguments& arguments); + services::HilStatus Mine(const services::HilArguments& arguments); + + services::HilStatus ParseProcess(const services::HilArguments& arguments, uint32_t& semaphore, uint32_t& process) const; + uint32_t Wait(uint32_t semaphore, uint32_t holdUs); + + private: + services::HilContext& context; + TimerAllocation& timers; + ResourceAllocation& resources; + infra::TimerSingleShot holdTimer; + uint32_t heldSemaphore = 0; + uint32_t heldProcess = 0; + Stopwatch stopwatch; + std::array commands; + }; +} + +#endif diff --git a/validation/firmware/HsemMaster.cpp b/validation/firmware/HsemMaster.cpp new file mode 100644 index 00000000..b8c49410 --- /dev/null +++ b/validation/firmware/HsemMaster.cpp @@ -0,0 +1,25 @@ +#include "validation/firmware/HsemMaster.hpp" + +#if defined(STM32WB) + +#include "infra/util/StaticStorage.hpp" + +namespace validation +{ + hal::SynchronousHardwareSemaphoreMasterStm& HsemMaster() + { + // Never destroyed: the destructor gates the HSEM clock, which FlashCoordinatedWithWirelessStack and HAL_HSEM_* need + static infra::StaticStorage storage; + static bool constructed = false; + + if (!constructed) + { + storage.Construct(); + constructed = true; + } + + return *storage; + } +} + +#endif diff --git a/validation/firmware/HsemMaster.hpp b/validation/firmware/HsemMaster.hpp new file mode 100644 index 00000000..6923dc65 --- /dev/null +++ b/validation/firmware/HsemMaster.hpp @@ -0,0 +1,12 @@ +#pragma once + +#if defined(STM32WB) + +#include "hal_st/synchronous_stm32fxxx/SynchronousHardwareSemaphoreStm.hpp" + +namespace validation +{ + hal::SynchronousHardwareSemaphoreMasterStm& HsemMaster(); +} + +#endif diff --git a/validation/firmware/I2cEepromStm.cpp b/validation/firmware/I2cEepromStm.cpp new file mode 100644 index 00000000..297db4e7 --- /dev/null +++ b/validation/firmware/I2cEepromStm.cpp @@ -0,0 +1,213 @@ +#include "validation/firmware/I2cEepromStm.hpp" +#include +#include + +namespace validation +{ + I2cEepromStm::I2cEepromStm(hal::I2cMaster& master, const Config& config, const infra::Function& onError) + : master(master) + , config(config) + , onError(onError) + {} + + uint32_t I2cEepromStm::Size() const + { + return config.size; + } + + void I2cEepromStm::WriteBuffer(infra::ConstByteRange buffer, uint32_t address, infra::Function onDone) + { + source = buffer; + erasing = false; + StartWrite(address, static_cast(buffer.size()), onDone); + } + + void I2cEepromStm::ReadBuffer(infra::ByteRange buffer, uint32_t address, infra::Function onDone) + { + this->onDone = onDone; + this->address = address; + destination = buffer; + + if (buffer.empty()) + { + Done(); + return; + } + + const auto size = PrepareAddress(address); + transferring = true; + master.SendData(hal::I2cAddress(config.address), Frame(size), hal::Action::repeatedStart, [this](hal::Result result, uint32_t) + { + AddressSent(result); + }); + } + + void I2cEepromStm::Erase(infra::Function onDone) + { + source = infra::ConstByteRange(); + erasing = true; + StartWrite(0, config.size, onDone); + } + + bool I2cEepromStm::Busy() const + { + return transferring; + } + + bool I2cEepromStm::Failed() const + { + return failed; + } + + void I2cEepromStm::Recover() + { + failed = false; + + if (onDone != nullptr) + onDone(); + } + + void I2cEepromStm::StartWrite(uint32_t address, uint32_t size, infra::Function onDone) + { + this->onDone = onDone; + this->address = address; + remaining = size; + + if (remaining == 0) + Done(); + else + WritePage(); + } + + void I2cEepromStm::WritePage() + { + chunk = std::min(remaining, config.page - address % config.page); + + const auto header = PrepareAddress(address); + if (erasing) + std::fill_n(frame.begin() + header, chunk, 0xff); + else + std::copy_n(source.begin(), chunk, frame.begin() + header); + + transferring = true; + master.SendData(hal::I2cAddress(config.address), Frame(header + chunk), hal::Action::stop, [this](hal::Result result, uint32_t) + { + PageWritten(result); + }); + } + + void I2cEepromStm::PageWritten(hal::Result result) + { + transferring = false; + + if (result != hal::Result::complete) + Fail(result); + else if (config.writeCycleMs == 0) + NextPage(); + else + { + expired = false; + deadline.Start(std::chrono::milliseconds(config.writeCycleMs), [this]() + { + expired = true; + }); + Poll(); + } + } + + void I2cEepromStm::Poll() + { + // Only the word address: a 24Cxx acknowledges it once its write cycle ends, and it starts no new write + transferring = true; + master.SendData(hal::I2cAddress(config.address), Frame(config.addressBytes), hal::Action::stop, [this](hal::Result result, uint32_t) + { + Polled(result); + }); + } + + void I2cEepromStm::Polled(hal::Result result) + { + transferring = false; + + if (result == hal::Result::partialComplete && !expired) + { + Poll(); + return; + } + + deadline.Cancel(); + + if (result == hal::Result::complete) + NextPage(); + else + Fail(result); + } + + void I2cEepromStm::NextPage() + { + address += chunk; + remaining -= chunk; + + if (!erasing) + source = infra::DiscardHead(source, chunk); + + if (remaining == 0) + Done(); + else + WritePage(); + } + + void I2cEepromStm::AddressSent(hal::Result result) + { + if (result != hal::Result::complete) + { + transferring = false; + Fail(result); + return; + } + + master.ReceiveData(hal::I2cAddress(config.address), destination, hal::Action::stop, [this](hal::Result result) + { + DataReceived(result); + }); + } + + void I2cEepromStm::DataReceived(hal::Result result) + { + transferring = false; + + if (result == hal::Result::complete) + Done(); + else + Fail(result); + } + + void I2cEepromStm::Fail(hal::Result result) + { + failed = true; + onError(result == hal::Result::busError ? I2cEepromError::busError : I2cEepromError::nack, address); + } + + void I2cEepromStm::Done() + { + onDone(); + } + + uint32_t I2cEepromStm::PrepareAddress(uint32_t address) + { + if (config.addressBytes == 1) + { + frame[0] = static_cast(address); + return 1; + } + + frame[0] = static_cast(address >> 8); + frame[1] = static_cast(address); + return 2; + } + + infra::ConstByteRange I2cEepromStm::Frame(uint32_t size) const + { + return infra::Head(infra::MakeRange(frame), size); + } +} diff --git a/validation/firmware/I2cEepromStm.hpp b/validation/firmware/I2cEepromStm.hpp new file mode 100644 index 00000000..f62a42bc --- /dev/null +++ b/validation/firmware/I2cEepromStm.hpp @@ -0,0 +1,79 @@ +#pragma once + +#include "hal/interfaces/Eeprom.hpp" +#include "hal/interfaces/I2c.hpp" +#include "infra/timer/Timer.hpp" +#include "infra/util/AutoResetFunction.hpp" +#include "infra/util/Function.hpp" +#include +#include + +namespace validation +{ + enum class I2cEepromError : uint8_t + { + nack, + busError, + }; + + class I2cEepromStm + : public hal::Eeprom + { + public: + static constexpr uint32_t maximumPage = 256; + static constexpr uint32_t maximumAddressBytes = 2; + + struct Config + { + uint8_t address = 0x50; + uint32_t size = 32768; + uint32_t page = 64; + uint8_t addressBytes = 2; + uint32_t writeCycleMs = 10; + }; + + I2cEepromStm(hal::I2cMaster& master, const Config& config, const infra::Function& onError); + + uint32_t Size() const override; + void WriteBuffer(infra::ConstByteRange buffer, uint32_t address, infra::Function onDone) override; + void ReadBuffer(infra::ByteRange buffer, uint32_t address, infra::Function onDone) override; + void Erase(infra::Function onDone) override; + + bool Busy() const; + bool Failed() const; + void Recover(); + + private: + void StartWrite(uint32_t address, uint32_t size, infra::Function onDone); + void WritePage(); + void PageWritten(hal::Result result); + void Poll(); + void Polled(hal::Result result); + void NextPage(); + void AddressSent(hal::Result result); + void DataReceived(hal::Result result); + void Fail(hal::Result result); + void Done(); + uint32_t PrepareAddress(uint32_t address); + infra::ConstByteRange Frame(uint32_t size) const; + + private: + hal::I2cMaster& master; + Config config; + infra::Function onError; + infra::AutoResetFunction onDone; + + infra::ConstByteRange source; + infra::ByteRange destination; + bool erasing = false; + uint32_t address = 0; + uint32_t remaining = 0; + uint32_t chunk = 0; + + bool transferring = false; + bool failed = false; + bool expired = false; + infra::TimerSingleShot deadline; + std::array frame{}; + }; +} diff --git a/validation/firmware/I2cGroup.cpp b/validation/firmware/I2cGroup.cpp new file mode 100644 index 00000000..41868c92 --- /dev/null +++ b/validation/firmware/I2cGroup.cpp @@ -0,0 +1,436 @@ +#include "validation/firmware/I2cGroup.hpp" +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include "infra/event/EventDispatcher.hpp" +#include "infra/util/Endian.hpp" +#include "validation/firmware/I2cTiming.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include + +#if defined(STM32WB) +#include "stm32wbxx_ll_gpio.h" +#include "stm32wbxx_ll_i2c.h" +#elif defined(STM32WBA) +#include "stm32wbaxx_ll_gpio.h" +#include "stm32wbaxx_ll_i2c.h" +#endif + +namespace validation +{ + namespace + { + using services::HilChoice; + using services::HilStatus; + + constexpr uint8_t instances = 4; + constexpr uint32_t maximumAddress = 0x7f; + constexpr infra::Duration transferTimeout = std::chrono::milliseconds(1000); + + constexpr std::array openKeys{ { "scl", "sda", "freq", "timing", "pull" } }; + constexpr std::array writeKeys{ { "next", "len", "pattern", "seed" } }; + constexpr std::array readKeys{ { "next", "out" } }; + + constexpr std::array, 3> nextChoices{ { + { "stop", hal::Action::stop }, + { "restart", hal::Action::repeatedStart }, + { "continue", hal::Action::continueSession }, + } }; + + constexpr std::array, 2> pullChoices{ { + { "none", false }, + { "up", true }, + } }; + + const char* ToString(hal::Result result) + { + switch (result) + { + case hal::Result::complete: + return "complete"; + case hal::Result::partialComplete: + return "nack"; + default: + return "buserror"; + } + } + + const char* ToString(I2cHook hook) + { + switch (hook) + { + case I2cHook::notFound: + return "notfound"; + case I2cHook::busError: + return "buserror"; + default: + return "arblost"; + } + } + } + + GPIO_TypeDef* GpioRegisters(HilPinId pin) + { + const std::array ports{ +#if defined(GPIOA) + GPIOA, +#endif +#if defined(GPIOB) + GPIOB, +#endif +#if defined(GPIOC) + GPIOC, +#endif +#if defined(GPIOD) + GPIOD, +#endif +#if defined(GPIOE) + GPIOE, +#endif +#if defined(GPIOH) + GPIOH, +#endif + }; + + return ports[pin.port]; + } + + uint32_t GpioMask(HilPinId pin) + { + return uint32_t{ 1 } << pin.index; + } + + void PrintHex32(services::HilResponse::Line& line, uint32_t value) + { + infra::BigEndian bigEndian{ value }; + line.Hex(infra::MakeByteRange(bigEndian)); + } + + HilStatus ParseI2cBus(const services::HilArguments& arguments, const services::HilPinNaming& naming, I2cBus& bus) + { + HilStatus status = HilStatus::done; + arguments.Pin("scl", naming, bus.scl, status); + arguments.Pin("sda", naming, bus.sda, status); + return status; + } + + HilStatus CheckI2cBus(uint8_t index, const I2cBus& bus) + { + if (!SupportsFunction(*bus.scl, hal::PinConfigTypeStm::i2cScl, index) || !SupportsFunction(*bus.sda, hal::PinConfigTypeStm::i2cSda, index)) + return HilStatus::pin; + + return HilStatus::done; + } + + HilStatus ClaimI2cBus(uint8_t index, const I2cBus& bus, services::HilPinOwner& pins, ResourceAllocation& resources, I2cBusPins& claimed) + { + HilStatus status = resources.Claim(Resource::i2c, index, pins.Id()); + if (status != HilStatus::done) + return status; + + status = pins.ClaimFunction(*bus.scl, Function(hal::PinConfigTypeStm::i2cScl), index, claimed.scl); + if (status == HilStatus::done) + status = pins.ClaimFunction(*bus.sda, Function(hal::PinConfigTypeStm::i2cSda), index, claimed.sda); + if (status != HilStatus::done) + resources.Release(Resource::i2c, index, pins.Id()); + + return status; + } + + I2cStmForHil::I2cStmForHil(uint8_t oneBasedIndex, hal::GpioPinStm& scl, hal::GpioPinStm& sda, const Config& config, const infra::Function& onHook) + : hal::I2cStm(oneBasedIndex, scl, sda, config) + , peripheral(hal::peripheralI2c[oneBasedIndex - 1]) + , onHook(onHook) + {} + + uint32_t I2cStmForHil::Timing() const + { + return (LL_I2C_GetTimingPrescaler(peripheral) << I2C_TIMINGR_PRESC_Pos) | (LL_I2C_GetDataSetupTime(peripheral) << I2C_TIMINGR_SCLDEL_Pos) | (LL_I2C_GetDataHoldTime(peripheral) << I2C_TIMINGR_SDADEL_Pos) | (LL_I2C_GetClockHighPeriod(peripheral) << I2C_TIMINGR_SCLH_Pos) | (LL_I2C_GetClockLowPeriod(peripheral) << I2C_TIMINGR_SCLL_Pos); + } + + uint32_t I2cStmForHil::Count(I2cHook hook) const + { + return counts[static_cast(hook)]; + } + + void I2cStmForHil::DisableInterrupts() + { + LL_I2C_DisableIT_TX(peripheral); + LL_I2C_DisableIT_RX(peripheral); + LL_I2C_DisableIT_TC(peripheral); + LL_I2C_DisableIT_NACK(peripheral); + LL_I2C_DisableIT_ERR(peripheral); + } + + void I2cStmForHil::DeviceNotFound() + { + Report(I2cHook::notFound); + } + + void I2cStmForHil::BusError() + { + Report(I2cHook::busError); + } + + void I2cStmForHil::ArbitrationLost() + { + Report(I2cHook::arbitrationLost); + } + + void I2cStmForHil::Report(I2cHook hook) + { + ++counts[static_cast(hook)]; + + if (onHook != nullptr) + onHook(hook); + } + + I2cFactoryStm::I2cFactoryStm(const services::HilPinNaming& naming, ResourceAllocation& resources) + : naming(naming) + , resources(resources) + {} + + uint8_t I2cFactoryStm::Instances() const + { + return instances; + } + + infra::MemoryRange I2cFactoryStm::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus I2cFactoryStm::Prepare(uint8_t index, const services::HilArguments& arguments) + { + Request request; + return Evaluate(index, arguments, request); + } + + void I2cFactoryStm::Close(uint8_t, const infra::Function& onClosed) + { + this->onClosed = onClosed; + + if (driver) + driver->DisableInterrupts(); + + // I2cStm schedules its completions and its dispatched error handler with its own address: let those already + // queued run against a live driver + infra::EventDispatcher::Instance().Schedule([this]() + { + Destroy(); + }); + } + + HilStatus I2cFactoryStm::Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins, const infra::Function& onHook) + { + Request request; + HilStatus status = Evaluate(index, arguments, request); + if (status != HilStatus::done) + return status; + + I2cBusPins claimed; + status = ClaimI2cBus(index, request.bus, pins, resources, claimed); + if (status != HilStatus::done) + return status; + + hal::I2cStm::Config config; + if (request.timing) + config.timing = *request.timing; + + this->index = index; + driver.emplace(index, PinOrDummy(claimed.scl), PinOrDummy(claimed.sda), config, onHook); + + // The pinout table configures the I2C pins open drain without pull; this lets Standard mode run without external resistors + if (request.pullUp) + for (auto pin : { *request.bus.scl, *request.bus.sda }) + LL_GPIO_SetPinPull(GpioRegisters(pin), GpioMask(pin), LL_GPIO_PULL_UP); + + return HilStatus::done; + } + + hal::I2cMaster& I2cFactoryStm::Master() + { + return *driver; + } + + uint32_t I2cFactoryStm::Timing() const + { + return driver->Timing(); + } + + uint32_t I2cFactoryStm::KernelClock() const + { + return I2cKernelClock(index); + } + + HilStatus I2cFactoryStm::Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const + { + if (!arguments.Has("scl") || !arguments.Has("sda") || (arguments.Has("freq") && arguments.Has("timing"))) + return HilStatus::usage; + + uint32_t frequency = 0; + uint32_t timing = 0; + HilStatus status = HilStatus::done; + arguments.Select("pull", request.pullUp, pullChoices, status); + arguments.Number("timing", timing, 0, std::numeric_limits::max(), status); + arguments.Number("freq", frequency, 0, std::numeric_limits::max(), status); + if (status != HilStatus::done) + return status; + + if (!I2cExists(index)) + return HilStatus::range; + + if (arguments.Has("timing")) + request.timing = timing; + else if (arguments.Has("freq")) + { + request.timing = I2cTiming(I2cKernelClock(index), frequency); + if (!request.timing) + return HilStatus::range; + } + + status = ParseI2cBus(arguments, naming, request.bus); + if (status != HilStatus::done) + return status; + + return CheckI2cBus(index, request.bus); + } + + void I2cFactoryStm::Destroy() + { + driver.reset(); + resources.Release(Resource::i2c, index, owners::i2c); + onClosed(); + } + + I2cCommands::I2cCommands(services::HilContext& context, I2cFactoryStm& factory) + : services::HilSingleInstanceGroup(context, factory, owners::i2c) + , factory(factory) + , pending(context.response) + , commands{ { + OpenCommand("i2c.open", " scl= sda= [freq=|timing=] [pull=none|up]"), + services::HilBind("i2c.write", " [next=] [len=] [pattern=] [seed=]", *this, context.response), + services::HilBind("i2c.read", " [next=] [out=hex|crc]", *this, context.response), + CloseCommand("i2c.close", ""), + } } + {} + + infra::MemoryRange I2cCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus I2cCommands::OpenInstance(uint8_t index, const services::HilArguments& arguments) + { + return factory.Open(index, arguments, Pins(), [this](I2cHook hook) + { + Hook(hook); + }); + } + + void I2cCommands::Opened(services::HilResponse::Line& line) const + { + line << " timing=0x"; + PrintHex32(line, factory.Timing()); + line << " kernel=" << factory.KernelClock(); + } + + void I2cCommands::CloseInstance() + { + pending.Cancel(); + } + + HilStatus I2cCommands::Write(const services::HilArguments& arguments) + { + if (!arguments.Shape(3, 3, infra::MakeRange(writeKeys))) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done) + return status; + + uint32_t address = 0; + hal::Action action = hal::Action::stop; + arguments.NumberAt(1, address, 0, maximumAddress, status); + arguments.Select("next", action, nextChoices, status); + if (status != HilStatus::done) + return status; + + infra::ByteRange payload; + status = ParsePayload(arguments, 2, infra::MakeRange(buffer), payload); + if (status != HilStatus::done) + return status; + + if (pending.Busy()) + return HilStatus::busy; + + const auto operation = pending.Start(transferTimeout); + factory.Master().SendData(hal::I2cAddress(static_cast(address)), payload, action, [this, operation](hal::Result result, uint32_t sent) + { + Sent(operation, result, sent); + }); + + return HilStatus::done; + } + + HilStatus I2cCommands::Read(const services::HilArguments& arguments) + { + if (!arguments.Shape(3, 3, infra::MakeRange(readKeys))) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done) + return status; + + uint32_t address = 0; + uint32_t length = 0; + hal::Action action = hal::Action::stop; + Output requested = Output::hex; + arguments.NumberAt(1, address, 0, maximumAddress, status); + arguments.NumberAt(2, length, 1, static_cast(buffer.size()), status); + arguments.Select("next", action, nextChoices, status); + if (status == HilStatus::done) + status = ParseOutput(arguments, requested); + if (status == HilStatus::done) + status = CheckOutput(length, requested); + if (status != HilStatus::done) + return status; + + if (pending.Busy()) + return HilStatus::busy; + + output = requested; + received = infra::Head(infra::MakeRange(buffer), length); + const auto operation = pending.Start(transferTimeout); + factory.Master().ReceiveData(hal::I2cAddress(static_cast(address)), received, action, [this, operation](hal::Result result) + { + Received(operation, result); + }); + + return HilStatus::done; + } + + void I2cCommands::Hook(I2cHook hook) + { + if (Instance().Occupied()) + Context().response.Event("i2c") << " index=" << static_cast(Instance().Index()) << " hook=" << ToString(hook); + } + + void I2cCommands::Sent(uint32_t operation, hal::Result result, uint32_t sent) + { + if (pending.Complete(operation)) + Context().response.Ok() << " sent=" << sent << " result=" << ToString(result); + } + + void I2cCommands::Received(uint32_t operation, hal::Result result) + { + if (!pending.Complete(operation)) + return; + + auto line = Context().response.Ok(); + line << " result=" << ToString(result); + if (result == hal::Result::complete) + PrintData(line, received, output); + } +} diff --git a/validation/firmware/I2cGroup.hpp b/validation/firmware/I2cGroup.hpp new file mode 100644 index 00000000..a0d80211 --- /dev/null +++ b/validation/firmware/I2cGroup.hpp @@ -0,0 +1,134 @@ +#pragma once + +#include "hal_st/stm32fxxx/I2cStm.hpp" +#include "infra/util/AutoResetFunction.hpp" +#include "infra/util/Function.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilPendingOperation.hpp" +#include "services/hil/commands/HilSingleInstanceGroup.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/Payload.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include +#include +#include +#include DEVICE_HEADER + +namespace validation +{ + enum class I2cHook : uint8_t + { + notFound, + busError, + arbitrationLost, + }; + + struct I2cBus + { + std::optional scl; + std::optional sda; + }; + + struct I2cBusPins + { + hal::GpioPin* scl = nullptr; + hal::GpioPin* sda = nullptr; + }; + + GPIO_TypeDef* GpioRegisters(HilPinId pin); + uint32_t GpioMask(HilPinId pin); + void PrintHex32(services::HilResponse::Line& line, uint32_t value); + + services::HilStatus ParseI2cBus(const services::HilArguments& arguments, const services::HilPinNaming& naming, I2cBus& bus); + services::HilStatus CheckI2cBus(uint8_t index, const I2cBus& bus); + services::HilStatus ClaimI2cBus(uint8_t index, const I2cBus& bus, services::HilPinOwner& pins, ResourceAllocation& resources, I2cBusPins& claimed); + + class I2cStmForHil + : public hal::I2cStm + { + public: + I2cStmForHil(uint8_t oneBasedIndex, hal::GpioPinStm& scl, hal::GpioPinStm& sda, const Config& config, const infra::Function& onHook); + + uint32_t Timing() const; + uint32_t Count(I2cHook hook) const; + void DisableInterrupts(); + + protected: + void DeviceNotFound() override; + void BusError() override; + void ArbitrationLost() override; + + private: + void Report(I2cHook hook); + + private: + I2C_TypeDef* peripheral; + infra::Function onHook; + std::array counts{}; + }; + + class I2cFactoryStm + : public services::HilInstanceFactory + { + public: + I2cFactoryStm(const services::HilPinNaming& naming, ResourceAllocation& resources); + + uint8_t Instances() const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + void Close(uint8_t index, const infra::Function& onClosed) override; + + services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins, const infra::Function& onHook); + hal::I2cMaster& Master(); + uint32_t Timing() const; + uint32_t KernelClock() const; + + private: + struct Request + { + I2cBus bus; + std::optional timing; + bool pullUp = false; + }; + + services::HilStatus Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const; + void Destroy(); + + private: + const services::HilPinNaming& naming; + ResourceAllocation& resources; + std::optional driver; + uint8_t index = 0; + infra::AutoResetFunction onClosed; + }; + + class I2cCommands + : public services::HilSingleInstanceGroup + { + public: + I2cCommands(services::HilContext& context, I2cFactoryStm& factory); + + infra::MemoryRange Commands() override; + + protected: + services::HilStatus OpenInstance(uint8_t index, const services::HilArguments& arguments) override; + void Opened(services::HilResponse::Line& line) const override; + void CloseInstance() override; + + private: + services::HilStatus Write(const services::HilArguments& arguments); + services::HilStatus Read(const services::HilArguments& arguments); + + void Hook(I2cHook hook); + void Sent(uint32_t operation, hal::Result result, uint32_t sent); + void Received(uint32_t operation, hal::Result result); + + private: + I2cFactoryStm& factory; + services::HilPendingOperation pending; + infra::ByteRange received; + Output output = Output::hex; + std::array buffer{}; + std::array commands; + }; +} diff --git a/validation/firmware/I2cTarget.cpp b/validation/firmware/I2cTarget.cpp new file mode 100644 index 00000000..e71b0e62 --- /dev/null +++ b/validation/firmware/I2cTarget.cpp @@ -0,0 +1,573 @@ +#include "validation/firmware/I2cTarget.hpp" +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include "validation/firmware/I2cTiming.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include + +#if defined(STM32WB) +#include "stm32wbxx_ll_gpio.h" +#include "stm32wbxx_ll_i2c.h" +#elif defined(STM32WBA) +#include "stm32wbaxx_ll_gpio.h" +#include "stm32wbaxx_ll_i2c.h" +#endif + +namespace validation +{ + namespace + { + using services::HilChoice; + using services::HilStatus; + + constexpr uint8_t instances = 4; + constexpr uint32_t minimumAddress = 0x08; + constexpr uint32_t maximumAddress = 0x77; + constexpr uint32_t defaultBus = 400000; + constexpr uint32_t maximumStretchUs = 10000; + constexpr uint32_t maximumPosition = 0xffff; + constexpr uint32_t faultTimeoutUs = 1000; + constexpr uint32_t maximumDump = 128; + + constexpr std::array openKeys{ { "scl", "sda", "addr", "mode", "timing" } }; + constexpr std::array configureKeys{ { "nack", "addrnack", "stretch", "stretchat", "fault", "faultat", "pattern", "seed" } }; + + constexpr std::array, 2> modeChoices{ { + { "regs", false }, + { "sink", true }, + } }; + + constexpr std::array, 2> faultChoices{ { + { "none", false }, + { "stop", true }, + } }; + } + + I2cTarget::I2cTarget(uint8_t oneBasedIndex, hal::GpioPinStm& scl, hal::GpioPinStm& sda, HilPinId sclId, HilPinId sdaId, const Config& config) + : instance(oneBasedIndex - 1) + , peripheral(hal::peripheralI2c[instance]) + , sclId(sclId) + , sdaId(sdaId) + , scl(scl, hal::PinConfigTypeStm::i2cScl, oneBasedIndex) + , sda(sda, hal::PinConfigTypeStm::i2cSda, oneBasedIndex) + , sink(config.sink) + , eventHandler(hal::peripheralI2cEvIrq[instance], [this]() + { + EventInterrupt(); + }) + , errorHandler(hal::peripheralI2cErIrq[instance], [this]() + { + ErrorInterrupt(); + }) + { + hal::EnableClockI2c(instance); + + handle.Instance = peripheral; + handle.Init.Timing = config.timing; + handle.Init.OwnAddress1 = static_cast(config.address) << 1; + handle.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; + handle.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE; + handle.Init.OwnAddress2 = 0; + handle.Init.OwnAddress2Masks = I2C_OA2_NOMASK; + handle.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE; + handle.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE; + HAL_I2C_Init(&handle); + + LL_I2C_Disable(peripheral); + LL_I2C_EnableSlaveByteControl(peripheral); + LL_I2C_Enable(peripheral); + + LL_I2C_EnableIT_ADDR(peripheral); + LL_I2C_EnableIT_TC(peripheral); + LL_I2C_EnableIT_NACK(peripheral); + LL_I2C_EnableIT_STOP(peripheral); + LL_I2C_EnableIT_ERR(peripheral); + } + + I2cTarget::~I2cTarget() + { + LL_I2C_DisableIT_ADDR(peripheral); + LL_I2C_DisableIT_TC(peripheral); + LL_I2C_DisableIT_NACK(peripheral); + LL_I2C_DisableIT_STOP(peripheral); + LL_I2C_DisableIT_ERR(peripheral); + LL_I2C_DisableIT_TX(peripheral); + + HAL_I2C_DeInit(&handle); + hal::DisableClockI2c(instance); + } + + void I2cTarget::Configure(const I2cTargetSettings& settings) + { + this->settings = settings; + faultArmed = settings.fault; + + if (settings.addressNack) + LL_I2C_DisableOwnAddress1(peripheral); + else + LL_I2C_EnableOwnAddress1(peripheral); + } + + void I2cTarget::PrintStatus(services::HilResponse::Line& line) const + { + line << " rx=" << received.load() << " tx=" << transmitted.load() << " crc="; + PrintHex32(line, crc.Result()); + line << " writes=" << writes.load() << " reads=" << reads.load() << " stops=" << stops.load() << " nacked=" << nacked.load() << " errors=" << errors.load() << " last="; + line.Hex(infra::Head(infra::MakeRange(last), std::min(lastSize.load(), last.size()))); + } + + void I2cTarget::ClearStatus() + { + received = 0; + transmitted = 0; + writes = 0; + reads = 0; + stops = 0; + nacked = 0; + errors = 0; + crc.Reset(); + lastSize = 0; + } + + void I2cTarget::Preload(std::size_t offset, infra::ConstByteRange data) + { + std::copy(data.begin(), data.end(), registers.begin() + offset); + } + + infra::ConstByteRange I2cTarget::Registers(std::size_t offset, std::size_t size) const + { + return infra::Head(infra::DiscardHead(infra::MakeRange(registers), offset), size); + } + + void I2cTarget::EventInterrupt() + { + if (LL_I2C_IsActiveFlag_NACK(peripheral)) + MasterNacked(); + + if (LL_I2C_IsActiveFlag_STOP(peripheral)) + Stopped(); + + if (LL_I2C_IsActiveFlag_ADDR(peripheral)) + Addressed(); + + if (LL_I2C_IsActiveFlag_TCR(peripheral)) + ByteReceived(); + + if (LL_I2C_IsEnabledIT_TX(peripheral) && LL_I2C_IsActiveFlag_TXIS(peripheral)) + LoadByte(); + } + + void I2cTarget::ErrorInterrupt() + { + if (LL_I2C_IsActiveFlag_BERR(peripheral)) + { + LL_I2C_ClearFlag_BERR(peripheral); + ++errors; + } + + if (LL_I2C_IsActiveFlag_ARLO(peripheral)) + { + LL_I2C_ClearFlag_ARLO(peripheral); + ++errors; + } + + if (LL_I2C_IsActiveFlag_OVR(peripheral)) + { + LL_I2C_ClearFlag_OVR(peripheral); + ++errors; + } + } + + void I2cTarget::Addressed() + { + position = 0; + + if (settings.stretchUs != 0 && settings.stretchAt == 0) + Hold(settings.stretchUs); + + if (LL_I2C_GetTransferDirection(peripheral) == LL_I2C_DIRECTION_WRITE) + { + ++writes; + lastSize = 0; + LL_I2C_DisableIT_TX(peripheral); + LL_I2C_EnableSlaveByteControl(peripheral); + LL_I2C_EnableReloadMode(peripheral); + LL_I2C_SetTransferSize(peripheral, 1); + } + else + { + ++reads; + patternState = settings.seed; + LL_I2C_DisableReloadMode(peripheral); + LL_I2C_DisableSlaveByteControl(peripheral); + LL_I2C_ClearFlag_TXE(peripheral); + LL_I2C_EnableIT_TX(peripheral); + } + + LL_I2C_ClearFlag_ADDR(peripheral); + } + + void I2cTarget::ByteReceived() + { + const uint8_t byte = LL_I2C_ReceiveData8(peripheral); + ++position; + + if (position == settings.nack) + { + ++nacked; + LL_I2C_AcknowledgeNextData(peripheral, LL_I2C_NACK); + } + else + { + ++received; + crc.Update(byte); + + const auto size = lastSize.load(); + if (size < last.size()) + { + last[size] = byte; + lastSize = size + 1; + } + + if (!sink && position == 1) + pointer = byte; + else if (!sink) + registers[pointer++] = byte; + } + + if (settings.stretchUs != 0 && settings.stretchAt == position) + Hold(settings.stretchUs); + + LL_I2C_SetTransferSize(peripheral, 1); + } + + void I2cTarget::LoadByte() + { + ++position; + + if (faultArmed && position == settings.faultAt) + { + Fault(); + return; + } + + if (settings.stretchUs != 0 && settings.stretchAt == position) + Hold(settings.stretchUs); + + LL_I2C_TransmitData8(peripheral, sink ? NextPattern() : registers[pointer++]); + ++transmitted; + } + + void I2cTarget::MasterNacked() + { + const bool unsent = !LL_I2C_IsActiveFlag_TXE(peripheral); + + LL_I2C_ClearFlag_NACK(peripheral); + LL_I2C_ClearFlag_TXE(peripheral); + LL_I2C_DisableIT_TX(peripheral); + + // The byte loaded for the next TXIS never left TXDR + if (unsent) + { + --transmitted; + if (!sink) + --pointer; + } + } + + void I2cTarget::Stopped() + { + LL_I2C_ClearFlag_STOP(peripheral); + LL_I2C_DisableIT_TX(peripheral); + LL_I2C_ClearFlag_TXE(peripheral); + ++stops; + } + + void I2cTarget::Fault() + { + auto* sclPort = GpioRegisters(sclId); + auto* sdaPort = GpioRegisters(sdaId); + const auto sclMask = GpioMask(sclId); + const auto sdaMask = GpioMask(sdaId); + + faultArmed = false; + + LL_GPIO_ResetOutputPin(sdaPort, sdaMask); + LL_GPIO_ResetOutputPin(sclPort, sclMask); + LL_GPIO_SetPinMode(sdaPort, sdaMask, LL_GPIO_MODE_OUTPUT); + LL_GPIO_SetPinMode(sclPort, sclMask, LL_GPIO_MODE_OUTPUT); + + // SDA must rise while SCL is high to form the misplaced STOP; the master may still hold SCL low + LL_GPIO_SetOutputPin(sclPort, sclMask); + stopwatch.Start(); + while (!LL_GPIO_IsInputPinSet(sclPort, sclMask) && stopwatch.ElapsedUs() < faultTimeoutUs) + { + } + LL_GPIO_SetOutputPin(sdaPort, sdaMask); + + LL_I2C_Disable(peripheral); + while (LL_I2C_IsEnabled(peripheral)) + { + } + LL_I2C_ClearFlag_ADDR(peripheral); + LL_I2C_ClearFlag_NACK(peripheral); + LL_I2C_ClearFlag_STOP(peripheral); + LL_I2C_ClearFlag_BERR(peripheral); + LL_I2C_ClearFlag_ARLO(peripheral); + LL_I2C_ClearFlag_OVR(peripheral); + LL_I2C_DisableIT_TX(peripheral); + LL_I2C_Enable(peripheral); + + LL_GPIO_SetPinMode(sclPort, sclMask, LL_GPIO_MODE_ALTERNATE); + LL_GPIO_SetPinMode(sdaPort, sdaMask, LL_GPIO_MODE_ALTERNATE); + } + + void I2cTarget::Hold(uint32_t us) + { + stopwatch.Start(); + while (stopwatch.ElapsedUs() < us) + { + } + } + + uint8_t I2cTarget::NextPattern() + { + switch (settings.pattern) + { + case Pattern::inc: + return static_cast(settings.seed + position - 1); + case Pattern::constant: + return static_cast(settings.seed); + default: + if (patternState == 0) + patternState = 1; + patternState ^= patternState << 13; + patternState ^= patternState >> 17; + patternState ^= patternState << 5; + return static_cast(patternState); + } + } + + I2cTargetFactory::I2cTargetFactory(const services::HilPinNaming& naming, ResourceAllocation& resources) + : naming(naming) + , resources(resources) + {} + + uint8_t I2cTargetFactory::Instances() const + { + return instances; + } + + infra::MemoryRange I2cTargetFactory::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus I2cTargetFactory::Prepare(uint8_t index, const services::HilArguments& arguments) + { + Request request; + return Evaluate(index, arguments, request); + } + + void I2cTargetFactory::Close(uint8_t, const infra::Function& onClosed) + { + target.reset(); + resources.Release(Resource::i2c, index, owners::i2cTarget); + onClosed(); + } + + HilStatus I2cTargetFactory::Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) + { + Request request; + HilStatus status = Evaluate(index, arguments, request); + if (status != HilStatus::done) + return status; + + I2cBusPins claimed; + status = ClaimI2cBus(index, request.bus, pins, resources, claimed); + if (status != HilStatus::done) + return status; + + this->index = index; + address = request.config.address; + target.emplace(index, PinOrDummy(claimed.scl), PinOrDummy(claimed.sda), *request.bus.scl, *request.bus.sda, request.config); + return HilStatus::done; + } + + I2cTarget& I2cTargetFactory::Target() + { + return *target; + } + + uint8_t I2cTargetFactory::Address() const + { + return address; + } + + HilStatus I2cTargetFactory::Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const + { + if (!arguments.Has("scl") || !arguments.Has("sda")) + return HilStatus::usage; + + uint32_t address = request.config.address; + uint32_t timing = 0; + HilStatus status = HilStatus::done; + arguments.Select("mode", request.config.sink, modeChoices, status); + arguments.Number("timing", timing, 0, std::numeric_limits::max(), status); + arguments.Number("addr", address, minimumAddress, maximumAddress, status); + if (status != HilStatus::done) + return status; + + if (!I2cExists(index)) + return HilStatus::range; + + request.config.address = static_cast(address); + if (arguments.Has("timing")) + request.config.timing = timing; + else + { + auto standard = I2cTiming(I2cKernelClock(index), defaultBus); + if (!standard) + return HilStatus::range; + + request.config.timing = *standard; + } + + status = ParseI2cBus(arguments, naming, request.bus); + if (status != HilStatus::done) + return status; + + return CheckI2cBus(index, request.bus); + } + + I2cTargetCommands::I2cTargetCommands(services::HilContext& context, I2cTargetFactory& factory) + : services::HilSingleInstanceGroup(context, factory, owners::i2cTarget) + , factory(factory) + , commands{ { + OpenCommand("i2cs.open", " scl= sda= [addr=] [mode=regs|sink] [timing=]"), + services::HilBind("i2cs.cfg", " [nack=] [addrnack=] [stretch=] [stretchat=] [fault=none|stop] [faultat=] [pattern=] [seed=]", *this, context.response), + services::HilBind("i2cs.status", " [clear=0|1]", *this, context.response), + services::HilBind("i2cs.regs", " ", *this, context.response), + services::HilBind("i2cs.dump", " ", *this, context.response), + CloseCommand("i2cs.close", ""), + } } + {} + + infra::MemoryRange I2cTargetCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus I2cTargetCommands::OpenInstance(uint8_t index, const services::HilArguments& arguments) + { + return factory.Open(index, arguments, Pins()); + } + + void I2cTargetCommands::Opened(services::HilResponse::Line& line) const + { + const uint8_t address = factory.Address(); + line << " addr=0x"; + line.Hex(infra::MakeByteRange(address)); + } + + void I2cTargetCommands::CloseInstance() + {} + + HilStatus I2cTargetCommands::Configure(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, infra::MakeRange(configureKeys))) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done) + return status; + + I2cTargetSettings settings; + arguments.Number("nack", settings.nack, 0, maximumPosition, status); + arguments.Flag("addrnack", settings.addressNack, status); + arguments.Number("stretch", settings.stretchUs, 0, maximumStretchUs, status); + arguments.Number("stretchat", settings.stretchAt, 0, maximumPosition, status); + arguments.Select("fault", settings.fault, faultChoices, status); + arguments.Number("faultat", settings.faultAt, 1, maximumPosition, status); + arguments.Select("pattern", settings.pattern, patternChoices, status); + arguments.Number("seed", settings.seed, 0, std::numeric_limits::max(), status); + if (status != HilStatus::done) + return status; + + factory.Target().Configure(settings); + Context().response.Ok(); + return HilStatus::done; + } + + HilStatus I2cTargetCommands::Status(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, { "clear" })) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + bool clear = false; + arguments.Flag("clear", clear, status); + if (status != HilStatus::done) + return status; + + { + auto line = Context().response.Ok(); + factory.Target().PrintStatus(line); + } + + if (clear) + factory.Target().ClearStatus(); + + return HilStatus::done; + } + + HilStatus I2cTargetCommands::Preload(const services::HilArguments& arguments) + { + if (!arguments.Shape(3, 3, {})) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + uint32_t offset = 0; + arguments.NumberAt(1, offset, 0, I2cTarget::registerCount - 1, status); + if (status != HilStatus::done) + return status; + + std::array data; + std::size_t size = 0; + status = services::HilArguments::ParseHex(arguments.Positional(2), infra::MakeRange(data), size); + if (status != HilStatus::done) + return status; + + if (size == 0) + return HilStatus::usage; + + if (offset + size > I2cTarget::registerCount) + return HilStatus::range; + + factory.Target().Preload(offset, infra::Head(infra::MakeRange(data), size)); + Context().response.Ok(); + return HilStatus::done; + } + + HilStatus I2cTargetCommands::Dump(const services::HilArguments& arguments) + { + if (!arguments.Shape(3, 3, {})) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + uint32_t offset = 0; + uint32_t length = 0; + arguments.NumberAt(1, offset, 0, I2cTarget::registerCount - 1, status); + arguments.NumberAt(2, length, 1, maximumDump, status); + if (status != HilStatus::done) + return status; + + if (offset + length > I2cTarget::registerCount) + return HilStatus::range; + + (Context().response.Ok() << " data=").Hex(factory.Target().Registers(offset, length)); + return HilStatus::done; + } +} diff --git a/validation/firmware/I2cTarget.hpp b/validation/firmware/I2cTarget.hpp new file mode 100644 index 00000000..db92ddcc --- /dev/null +++ b/validation/firmware/I2cTarget.hpp @@ -0,0 +1,160 @@ +#pragma once + +#include "hal/cortex_m/InterruptCortex.hpp" +#include "hal_st/stm32fxxx/GpioStm.hpp" +#include "infra/util/AutoResetFunction.hpp" +#include "infra/util/Crc.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilSingleInstanceGroup.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/I2cGroup.hpp" +#include "validation/firmware/Payload.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include "validation/firmware/Stopwatch.hpp" +#include +#include +#include +#include +#include DEVICE_HEADER + +namespace validation +{ + struct I2cTargetSettings + { + uint32_t nack = 0; + bool addressNack = false; + uint32_t stretchUs = 0; + uint32_t stretchAt = 0; + bool fault = false; + uint32_t faultAt = 1; + Pattern pattern = Pattern::inc; + uint32_t seed = 0; + }; + + // Validation scaffolding: hal-st has no I2C slave driver, so the other end of the bus is driven with LL + class I2cTarget + { + public: + static constexpr std::size_t registerCount = 256; + + struct Config + { + uint8_t address; + bool sink; + uint32_t timing; + }; + + I2cTarget(uint8_t oneBasedIndex, hal::GpioPinStm& scl, hal::GpioPinStm& sda, HilPinId sclId, HilPinId sdaId, const Config& config); + I2cTarget(const I2cTarget& other) = delete; + I2cTarget& operator=(const I2cTarget& other) = delete; + ~I2cTarget(); + + void Configure(const I2cTargetSettings& settings); + void PrintStatus(services::HilResponse::Line& line) const; + void ClearStatus(); + void Preload(std::size_t offset, infra::ConstByteRange data); + infra::ConstByteRange Registers(std::size_t offset, std::size_t size) const; + + private: + void EventInterrupt(); + void ErrorInterrupt(); + + void Addressed(); + void ByteReceived(); + void LoadByte(); + void MasterNacked(); + void Stopped(); + void Fault(); + void Hold(uint32_t us); + uint8_t NextPattern(); + + private: + uint8_t instance; + I2C_TypeDef* peripheral; + HilPinId sclId; + HilPinId sdaId; + hal::PeripheralPinStm scl; + hal::PeripheralPinStm sda; + I2C_HandleTypeDef handle{}; + bool sink; + + I2cTargetSettings settings; + bool faultArmed = false; + Stopwatch stopwatch; + + std::array registers{}; + uint8_t pointer = 0; + uint32_t position = 0; + uint32_t patternState = 0; + + std::atomic received{ 0 }; + std::atomic transmitted{ 0 }; + std::atomic writes{ 0 }; + std::atomic reads{ 0 }; + std::atomic stops{ 0 }; + std::atomic nacked{ 0 }; + std::atomic errors{ 0 }; + infra::Crc32 crc; + std::array last{}; + std::atomic lastSize{ 0 }; + + hal::cortex::ImmediateInterruptHandler eventHandler; + hal::cortex::ImmediateInterruptHandler errorHandler; + }; + + class I2cTargetFactory + : public services::HilInstanceFactory + { + public: + I2cTargetFactory(const services::HilPinNaming& naming, ResourceAllocation& resources); + + uint8_t Instances() const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + void Close(uint8_t index, const infra::Function& onClosed) override; + + services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins); + I2cTarget& Target(); + uint8_t Address() const; + + private: + struct Request + { + I2cBus bus; + I2cTarget::Config config{ 0x42, false, 0 }; + }; + + services::HilStatus Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const; + + private: + const services::HilPinNaming& naming; + ResourceAllocation& resources; + std::optional target; + uint8_t index = 0; + uint8_t address = 0; + }; + + class I2cTargetCommands + : public services::HilSingleInstanceGroup + { + public: + I2cTargetCommands(services::HilContext& context, I2cTargetFactory& factory); + + infra::MemoryRange Commands() override; + + protected: + services::HilStatus OpenInstance(uint8_t index, const services::HilArguments& arguments) override; + void Opened(services::HilResponse::Line& line) const override; + void CloseInstance() override; + + private: + services::HilStatus Configure(const services::HilArguments& arguments); + services::HilStatus Status(const services::HilArguments& arguments); + services::HilStatus Preload(const services::HilArguments& arguments); + services::HilStatus Dump(const services::HilArguments& arguments); + + private: + I2cTargetFactory& factory; + std::array commands; + }; +} diff --git a/validation/firmware/I2cTiming.cpp b/validation/firmware/I2cTiming.cpp new file mode 100644 index 00000000..ba8f162e --- /dev/null +++ b/validation/firmware/I2cTiming.cpp @@ -0,0 +1,109 @@ +#include "validation/firmware/I2cTiming.hpp" +#include + +namespace validation +{ + namespace + { + struct Mode + { + int64_t hddatMin; + int64_t vddatMax; + int64_t sudatMin; + int64_t lsclMin; + int64_t hsclMin; + int64_t rise; + int64_t fall; + }; + + struct Candidate + { + int64_t period; + uint32_t prescaler; + uint32_t scldel; + uint32_t sdadel; + uint32_t sclh; + uint32_t scll; + }; + + constexpr int64_t picosecondsPerSecond = 1'000'000'000'000; + constexpr Mode standardMode{ 0, 3'450'000, 250'000, 4'700'000, 4'000'000, 640'000, 20'000 }; + constexpr Mode fastMode{ 0, 900'000, 100'000, 1'300'000, 600'000, 250'000, 100'000 }; + constexpr int64_t analogFilterMin = 50'000; + constexpr int64_t analogFilterMax = 260'000; + constexpr uint32_t fieldMax = 15; + constexpr int64_t periodMax = 255; + constexpr uint32_t standardModeMaximumBus = 100'000; + + int64_t FloorDivide(int64_t numerator, int64_t denominator) + { + const auto quotient = numerator / denominator; + return quotient * denominator > numerator ? quotient - 1 : quotient; + } + + int64_t CeilDivide(int64_t numerator, int64_t denominator) + { + return -FloorDivide(-numerator, denominator); + } + + std::optional Evaluate(const Mode& mode, int64_t clock, int64_t target, uint32_t prescaler) + { + const int64_t prescaled = (prescaler + 1) * clock; + const int64_t sdadelMin = std::max(mode.fall + mode.hddatMin - analogFilterMin - 3 * clock, 0); + const int64_t sdadelMax = std::max(mode.vddatMax - mode.rise - analogFilterMax - 4 * clock, 0); + const int64_t scldelMin = mode.rise + mode.sudatMin; + + uint32_t scldel = 0; + while (scldel <= fieldMax && (scldel + 1) * prescaled < scldelMin) + ++scldel; + if (scldel > fieldMax) + return std::nullopt; + + uint32_t sdadel = 0; + for (; sdadel <= fieldMax; ++sdadel) + { + const int64_t delay = (sdadel * (prescaler + 1) + 1) * clock; + if (delay >= sdadelMin && delay <= sdadelMax) + break; + } + if (sdadel > fieldMax) + return std::nullopt; + + const int64_t sync = analogFilterMin + 2 * clock; + const int64_t scllMin = std::max(0, FloorDivide(mode.lsclMin - sync, prescaled)); + const int64_t sclhMin = std::max(0, CeilDivide(mode.hsclMin - sync, prescaled) - 1); + const int64_t fixed = 2 * sync + mode.fall; + const int64_t units = std::max(scllMin + sclhMin + 2, CeilDivide(target - fixed, prescaled)); + const int64_t extra = units - (scllMin + sclhMin + 2); + const int64_t scll = scllMin + (extra + 1) / 2; + const int64_t sclh = sclhMin + extra / 2; + if (scll > periodMax || sclh > periodMax) + return std::nullopt; + + return Candidate{ fixed + units * prescaled, prescaler, scldel, sdadel, static_cast(sclh), static_cast(scll) }; + } + } + + std::optional I2cTiming(uint32_t kernelHz, uint32_t busHz) + { + if (kernelHz == 0 || busHz < i2cMinimumBus || busHz > i2cMaximumBus) + return std::nullopt; + + const Mode& mode = busHz <= standardModeMaximumBus ? standardMode : fastMode; + const int64_t clock = picosecondsPerSecond / kernelHz; + const int64_t target = picosecondsPerSecond / busHz; + + std::optional best; + for (uint32_t prescaler = 0; prescaler <= fieldMax; ++prescaler) + { + auto candidate = Evaluate(mode, clock, target, prescaler); + if (candidate && (!best || candidate->period < best->period)) + best = candidate; + } + + if (!best) + return std::nullopt; + + return (best->prescaler << 28) | (best->scldel << 20) | (best->sdadel << 16) | (best->sclh << 8) | best->scll; + } +} diff --git a/validation/firmware/I2cTiming.hpp b/validation/firmware/I2cTiming.hpp new file mode 100644 index 00000000..b448dde1 --- /dev/null +++ b/validation/firmware/I2cTiming.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include +#include + +namespace validation +{ + inline constexpr uint32_t i2cMinimumBus = 20000; + inline constexpr uint32_t i2cMaximumBus = 400000; + + std::optional I2cTiming(uint32_t kernelHz, uint32_t busHz); +} diff --git a/validation/firmware/LowPowerGroup.cpp b/validation/firmware/LowPowerGroup.cpp new file mode 100644 index 00000000..9e16822c --- /dev/null +++ b/validation/firmware/LowPowerGroup.cpp @@ -0,0 +1,240 @@ +#include "validation/firmware/LowPowerGroup.hpp" +#include "BoardProfile.hpp" +#include "hal_st/stm32fxxx/TimerStm.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + using services::HilChoice; + using services::HilStatus; + + constexpr std::array enterKeys{ { "wake", "edge", "marker", "timeout" } }; + + constexpr std::array, 2> modes{ { + { "sleep", hal::PowerMode::sleep }, + { "deep", hal::PowerMode::deepSleep }, + } }; + + constexpr std::array, 2> edges{ { + { "rising", true }, + { "falling", false }, + } }; + + constexpr uint32_t maximumTimeoutMs = 10000; + constexpr uint32_t ticksPerSecond = 1000000; + constexpr uint32_t ticksPerUpdate = 1000; + constexpr std::size_t nvicWords = sizeof(NVIC_Type::ISER) / sizeof(uint32_t); + + static_assert(board::scaffoldTimer == 17); +#if defined(STM32WB) + constexpr IRQn_Type scaffoldIrq = TIM1_TRG_COM_TIM17_IRQn; +#else + constexpr IRQn_Type scaffoldIrq = TIM17_IRQn; +#endif + + IRQn_Type ExtiIrq(uint8_t line) + { +#if defined(STM32WB) + if (line >= 10) + return EXTI15_10_IRQn; + if (line >= 5) + return EXTI9_5_IRQn; +#endif + return static_cast(EXTI0_IRQn + line); + } + + bool ExtiPending(uint32_t mask) + { + return __HAL_GPIO_EXTI_GET_IT(mask) != 0; + } + } + + LowPowerCommands::LowPowerCommands(services::HilContext& context, TimerAllocation& timers) + : services::TerminalCommands(context.terminal) + , context(context) + , timers(timers) + , pins(context.pins, owners::lowPower) + , lowPower([this]() + { + ++restored; + }) + , commands{ { + services::HilBind("lpm.enter", " [wake=] [edge=rising|falling] [marker=] [timeout=]", *this, context.response), + } } + {} + + infra::MemoryRange LowPowerCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus LowPowerCommands::Enter(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, infra::MakeRange(enterKeys))) + return HilStatus::usage; + + Request request; + auto status = Parse(arguments, request); + if (status != HilStatus::done) + return status; + + hal::GpioPin* wake = nullptr; + hal::GpioPin* marker = nullptr; + status = Claim(request, wake, marker); + if (status != HilStatus::done) + return status; + + restored = 0; + const auto outcome = Sleep(request, *marker); + + wake->DisableInterrupt(); + wake->ResetConfig(); + marker->ResetConfig(); + pins.Release(); + timers.Release(board::scaffoldTimer, owners::lowPower); + + if (!outcome.woke) + return HilStatus::timeout; + + context.response.Ok() << " woke=exti restored=" << restored.load() << " us=" << outcome.us << " sleeps=" << outcome.sleeps; + return HilStatus::done; + } + + HilStatus LowPowerCommands::Parse(const services::HilArguments& arguments, Request& request) const + { + std::optional wake; + std::optional marker; + auto status = HilStatus::done; + arguments.SelectAt(0, request.mode, modes, status); + arguments.Select("edge", request.rising, edges, status); + arguments.Number("timeout", request.timeoutMs, 1, maximumTimeoutMs, status); + arguments.Pin("wake", context.naming, wake, status); + arguments.Pin("marker", context.naming, marker, status); + if (status != HilStatus::done) + return status; + + request.wake = wake.value_or(board::lowPowerDefaults.wake); + request.marker = marker.value_or(board::lowPowerDefaults.marker); + if (request.wake == request.marker) + return HilStatus::usage; + + if (!IsBonded(request.wake) || !IsBonded(request.marker)) + return HilStatus::pin; + + if (!context.pins.Factory().SupportsInterrupt(request.wake)) + return HilStatus::unsupported; + + return HilStatus::done; + } + + HilStatus LowPowerCommands::Claim(const Request& request, hal::GpioPin*& wake, hal::GpioPin*& marker) + { + auto status = timers.Claim(board::scaffoldTimer, owners::lowPower); + if (status != HilStatus::done) + return status; + + services::HilPinOptions wakeOptions; + wakeOptions.pull = request.rising ? services::HilPull::down : services::HilPull::up; + status = pins.Claim(request.wake, services::HilPinPool::Use::exclusive, wake, wakeOptions); + if (status == HilStatus::done) + status = pins.Claim(request.marker, services::HilPinPool::Use::exclusive, marker); + if (status != HilStatus::done) + { + pins.Release(); + timers.Release(board::scaffoldTimer, owners::lowPower); + return status; + } + + woke = false; + wake->Config(hal::PinConfigType::input); + marker->Config(hal::PinConfigType::output, true); + wake->EnableInterrupt([this]() + { + woke = true; + }, + request.rising ? hal::InterruptTrigger::risingEdge : hal::InterruptTrigger::fallingEdge, hal::InterruptType::immediate); + return HilStatus::done; + } + + LowPowerCommands::Outcome LowPowerCommands::Sleep(const Request& request, hal::GpioPin& marker) + { + // CYCCNT stops while the core sleeps: the scaffold timer counts the window in microseconds instead + hal::TimerWithInterruptStm timer{ board::scaffoldTimer, { TimerClock(board::scaffoldTimer) / ticksPerSecond - 1, ticksPerUpdate - 1 } }; + auto& handle = timer.Handle(); + const uint32_t wakeMask = 1u << request.wake.index; + const auto wakeIrq = ExtiIrq(request.wake.index); + uint32_t updates = 0; + uint32_t sleeps = 0; + + __HAL_TIM_CLEAR_FLAG(&handle, TIM_FLAG_UPDATE); + timer.Start([]() {}, hal::InterruptType::immediate); + + // With PRIMASK set WFI still returns on any enabled interrupt that becomes pending, so only the wake line and + // the scaffold timer stay enabled; their handlers run once PRIMASK is restored + const auto primask = __get_PRIMASK(); + __disable_irq(); + + std::array enabled; + for (std::size_t word = 0; word != enabled.size(); ++word) + { + enabled[word] = NVIC->ISER[word]; + for (uint32_t bit = 0; bit != 32; ++bit) + { + const auto irq = static_cast(word * 32 + bit); + if ((enabled[word] & (1u << bit)) != 0 && irq != wakeIrq && irq != scaffoldIrq) + HAL_NVIC_DisableIRQ(irq); + } + } + + const bool tick = (SysTick->CTRL & SysTick_CTRL_TICKINT_Msk) != 0; + HAL_SuspendTick(); + // A tick that pended before TICKINT was cleared would end every WFI at once + SCB->ICSR = SCB_ICSR_PENDSTCLR_Msk; + + __HAL_TIM_SET_COUNTER(&handle, 0); + __HAL_TIM_CLEAR_FLAG(&handle, TIM_FLAG_UPDATE); + HAL_NVIC_ClearPendingIRQ(scaffoldIrq); + marker.Set(false); + + while (!woke && !ExtiPending(wakeMask) && updates != request.timeoutMs) + { + lowPower.Enter(request.mode); + ++sleeps; + + if (__HAL_TIM_GET_FLAG(&handle, TIM_FLAG_UPDATE) != 0) + { + __HAL_TIM_CLEAR_FLAG(&handle, TIM_FLAG_UPDATE); + HAL_NVIC_ClearPendingIRQ(scaffoldIrq); + ++updates; + } + } + + marker.Set(true); + + const uint32_t counter = __HAL_TIM_GET_COUNTER(&handle); + const bool wrapped = __HAL_TIM_GET_FLAG(&handle, TIM_FLAG_UPDATE) != 0 && counter < ticksPerUpdate / 2; + Outcome outcome{ woke || ExtiPending(wakeMask), (updates + (wrapped ? 1 : 0)) * ticksPerUpdate + counter, sleeps }; + + timer.Stop(); + __HAL_TIM_CLEAR_FLAG(&handle, TIM_FLAG_UPDATE); + HAL_NVIC_ClearPendingIRQ(scaffoldIrq); + + if (tick) + HAL_ResumeTick(); + + for (std::size_t word = 0; word != enabled.size(); ++word) + for (uint32_t bit = 0; bit != 32; ++bit) + if ((enabled[word] & (1u << bit)) != 0) + HAL_NVIC_EnableIRQ(static_cast(word * 32 + bit)); + + __set_PRIMASK(primask); + return outcome; + } +} diff --git a/validation/firmware/LowPowerGroup.hpp b/validation/firmware/LowPowerGroup.hpp new file mode 100644 index 00000000..da9d05f9 --- /dev/null +++ b/validation/firmware/LowPowerGroup.hpp @@ -0,0 +1,53 @@ +#pragma once + +#include "hal/interfaces/LowPowerMode.hpp" +#include "hal_st/stm32fxxx/LowPowerModeStm.hpp" +#include "services/hil/HilCommand.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/TimerAllocation.hpp" +#include +#include +#include + +namespace validation +{ + class LowPowerCommands + : public services::TerminalCommands + { + public: + LowPowerCommands(services::HilContext& context, TimerAllocation& timers); + + infra::MemoryRange Commands() override; + + private: + struct Request + { + hal::PowerMode mode = hal::PowerMode::sleep; + HilPinId wake; + HilPinId marker; + bool rising = true; + uint32_t timeoutMs = 2000; + }; + + struct Outcome + { + bool woke = false; + uint32_t us = 0; + uint32_t sleeps = 0; + }; + + services::HilStatus Enter(const services::HilArguments& arguments); + services::HilStatus Parse(const services::HilArguments& arguments, Request& request) const; + services::HilStatus Claim(const Request& request, hal::GpioPin*& wake, hal::GpioPin*& marker); + Outcome Sleep(const Request& request, hal::GpioPin& marker); + + private: + services::HilContext& context; + TimerAllocation& timers; + services::HilPinOwner pins; + hal::LowPowerModeStm lowPower; + std::atomic restored{ 0 }; + std::atomic woke{ false }; + std::array commands; + }; +} diff --git a/validation/firmware/LpTimerGroup.cpp b/validation/firmware/LpTimerGroup.cpp new file mode 100644 index 00000000..942c6872 --- /dev/null +++ b/validation/firmware/LpTimerGroup.cpp @@ -0,0 +1,213 @@ +#include "validation/firmware/LpTimerGroup.hpp" +#include "infra/event/EventDispatcher.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include +#include DEVICE_HEADER + +#if defined(HAS_PERIPHERAL_LPTIMER) + +namespace validation +{ + namespace + { + using services::HilStatus; + + constexpr uint32_t maximumPeriod = 0xffff; + constexpr uint32_t maximumPrescaler = 128; + constexpr uint32_t maximumRepetition = 0xff; + + constexpr std::array openKeys{ { "period", "prescaler", "irq", "rep", "pin" } }; + + constexpr std::array prescalers{ { + LPTIM_PRESCALER_DIV1, + LPTIM_PRESCALER_DIV2, + LPTIM_PRESCALER_DIV4, + LPTIM_PRESCALER_DIV8, + LPTIM_PRESCALER_DIV16, + LPTIM_PRESCALER_DIV32, + LPTIM_PRESCALER_DIV64, + LPTIM_PRESCALER_DIV128, + } }; + + constexpr TimerCounterGroupNames names{ "lptim.open", "lptim.start", "lptim.stop", "lptim.count", "lptim.close", " lptimclk=" }; + } + + std::optional LpTimerPrescaler(uint32_t divider) + { + for (std::size_t shift = 0; shift != prescalers.size(); ++shift) + if (divider == (uint32_t{ 1 } << shift)) + return prescalers[shift]; + + return std::nullopt; + } + + LpTimerFactoryStm::LpTimerFactoryStm(const services::HilPinNaming& naming, ResourceAllocation& resources) + : naming(naming) + , resources(resources) + {} + + uint8_t LpTimerFactoryStm::Instances() const + { + return 3; + } + + infra::MemoryRange LpTimerFactoryStm::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus LpTimerFactoryStm::Prepare(uint8_t index, const services::HilArguments& arguments) + { + Request request; + return Evaluate(index, arguments, request); + } + + HilStatus LpTimerFactoryStm::Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) + { + Request request; + HilStatus status = Evaluate(index, arguments, request); + if (status != HilStatus::done) + return status; + + status = resources.Claim(Resource::lpTimer, index, owners::lpTimer); + if (status != HilStatus::done) + return status; + + status = marker.Claim(pins, request.pin); + if (status != HilStatus::done) + { + resources.Release(Resource::lpTimer, index, owners::lpTimer); + return status; + } + + Construct(index, request); + return HilStatus::done; + } + + void LpTimerFactoryStm::Close(uint8_t, const infra::Function& onClosed) + { + this->onClosed = onClosed; + Stop(); + + // A dispatched update may already be queued with a pointer to the driver + infra::EventDispatcher::Instance().Schedule([this]() + { + Destroy(); + }); + } + + uint32_t LpTimerFactoryStm::Clock() const + { + return clock; + } + + void LpTimerFactoryStm::Start() + { + if (running) + return; + + running = true; + if (auto freeRunning = std::get_if(&driver)) + freeRunning->Start(); + else if (auto withInterrupt = std::get_if(&driver)) + withInterrupt->Start([this]() + { + marker.Update(); + }, + irq == TimerIrq::immediate ? hal::InterruptType::immediate : hal::InterruptType::dispatched); + } + + void LpTimerFactoryStm::Stop() + { + if (!running) + return; + + running = false; + services::HilWithDriver(driver, [](auto& alternative) + { + alternative.Stop(); + }); + } + + uint32_t LpTimerFactoryStm::Counter() + { + uint32_t counter = 0; + services::HilWithDriver(driver, [&counter](auto& alternative) + { + counter = HAL_LPTIM_ReadCounter(&alternative.Handle()); + }); + + return counter; + } + + uint32_t LpTimerFactoryStm::Interrupts() const + { + return marker.Updates(); + } + + HilStatus LpTimerFactoryStm::Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const + { + if (!LpTimerExists(index)) + return HilStatus::range; + + HilStatus status = HilStatus::done; + arguments.Number("period", request.period, 1, maximumPeriod, status); + arguments.Number("prescaler", request.prescaler, 1, maximumPrescaler, status); + arguments.Number("rep", request.repetition, 0, maximumRepetition, status); + arguments.Select("irq", request.irq, timerIrqChoices, status); + arguments.Pin("pin", naming, request.pin, status); + if (status != HilStatus::done) + return status; + + if (!LpTimerPrescaler(request.prescaler)) + return HilStatus::range; + + if (request.pin && request.irq == TimerIrq::none) + return HilStatus::usage; + + if (request.pin && !IsBonded(*request.pin)) + return HilStatus::pin; + +#if defined(STM32WB) + if (arguments.Has("rep")) + return HilStatus::unsupported; +#endif + + // ARRM interrupts on every period, whatever the repetition counter + if (request.irq != TimerIrq::none && LpTimerClock(index) / (request.prescaler * (request.period + 1)) > maximumUpdateInterruptRate) + return HilStatus::range; + + return HilStatus::done; + } + + void LpTimerFactoryStm::Construct(uint8_t index, const Request& request) + { + this->index = index; + clock = LpTimerClock(index); + irq = request.irq; + running = false; + + const hal::LowPowerTimerBaseStm::Timing timing{ request.period, request.repetition, *LpTimerPrescaler(request.prescaler) }; + if (request.irq == TimerIrq::none) + driver.emplace(index, timing); + else + driver.emplace(index, timing); + } + + void LpTimerFactoryStm::Destroy() + { + driver.emplace(); + marker.Release(); + resources.Release(Resource::lpTimer, index, owners::lpTimer); + onClosed(); + } + + LpTimerGroup::LpTimerGroup(services::HilContext& context, LpTimerFactoryStm& factory) + : TimerCounterGroup(context, factory, owners::lpTimer, names) + {} +} + +#endif diff --git a/validation/firmware/LpTimerGroup.hpp b/validation/firmware/LpTimerGroup.hpp new file mode 100644 index 00000000..d421f909 --- /dev/null +++ b/validation/firmware/LpTimerGroup.hpp @@ -0,0 +1,72 @@ +#pragma once + +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include "hal_st/stm32fxxx/LpTimerStm.hpp" +#include "infra/util/AutoResetFunction.hpp" +#include "services/hil/HilCommand.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include "validation/firmware/TimerGroup.hpp" +#include +#include +#include + +#if defined(HAS_PERIPHERAL_LPTIMER) + +namespace validation +{ + std::optional LpTimerPrescaler(uint32_t divider); + + class LpTimerFactoryStm + : public TimerCounterFactory + { + public: + LpTimerFactoryStm(const services::HilPinNaming& naming, ResourceAllocation& resources); + + uint8_t Instances() const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + void Close(uint8_t index, const infra::Function& onClosed) override; + + services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) override; + uint32_t Clock() const override; + void Start() override; + void Stop() override; + uint32_t Counter() override; + uint32_t Interrupts() const override; + + private: + struct Request + { + uint32_t period = 999; + uint32_t prescaler = 1; + uint32_t repetition = 0; + TimerIrq irq = TimerIrq::dispatched; + std::optional pin; + }; + + services::HilStatus Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const; + void Construct(uint8_t index, const Request& request); + void Destroy(); + + private: + const services::HilPinNaming& naming; + ResourceAllocation& resources; + std::variant driver; + TimerMarker marker; + uint8_t index = 0; + uint32_t clock = 0; + TimerIrq irq = TimerIrq::none; + bool running = false; + infra::AutoResetFunction onClosed; + }; + + class LpTimerGroup + : public TimerCounterGroup + { + public: + LpTimerGroup(services::HilContext& context, LpTimerFactoryStm& factory); + }; +} + +#endif diff --git a/validation/firmware/LpTimerPwmGroup.cpp b/validation/firmware/LpTimerPwmGroup.cpp new file mode 100644 index 00000000..1f8161a6 --- /dev/null +++ b/validation/firmware/LpTimerPwmGroup.cpp @@ -0,0 +1,194 @@ +#include "validation/firmware/LpTimerPwmGroup.hpp" +#include "validation/firmware/LpTimerGroup.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include DEVICE_HEADER + +#if defined(HAS_PERIPHERAL_LPTIMER) && !defined(STM32WB) + +namespace validation +{ + namespace + { + using services::HilStatus; + + constexpr uint32_t maximumCount = 0xffff; + constexpr uint32_t maximumPrescaler = 128; + + constexpr std::array openKeys{ { "pins", "prescaler", "period" } }; + + constexpr std::array channelFunctions{ { + hal::PinConfigTypeStm::lpTimerChannel1, + hal::PinConfigTypeStm::lpTimerChannel2, + } }; + + constexpr std::array lpTimerChannels{ { + LPTIM_CHANNEL_1, + LPTIM_CHANNEL_2, + } }; + + constexpr PwmChannelGroupNames names{ "lptpwm.open", "lptpwm.duty", "lptpwm.pulse", "lptpwm.start", "lptpwm.stop", "lptpwm.close", " lptimclk=" }; + } + + LpTimerPwmFactoryStm::LpTimerPwmFactoryStm(const services::HilPinNaming& naming, ResourceAllocation& resources) + : naming(naming) + , resources(resources) + {} + + uint8_t LpTimerPwmFactoryStm::Instances() const + { + return 3; + } + + infra::MemoryRange LpTimerPwmFactoryStm::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus LpTimerPwmFactoryStm::Prepare(uint8_t index, const services::HilArguments& arguments) + { + Request request; + return Evaluate(index, arguments, request); + } + + HilStatus LpTimerPwmFactoryStm::Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) + { + Request request; + HilStatus status = Evaluate(index, arguments, request); + if (status != HilStatus::done) + return status; + + status = resources.Claim(Resource::lpTimer, index, owners::lpTimerPwm); + if (status != HilStatus::done) + return status; + + status = Claim(index, request, pins); + if (status != HilStatus::done) + { + resources.Release(Resource::lpTimer, index, owners::lpTimerPwm); + return status; + } + + Construct(index, request); + return HilStatus::done; + } + + void LpTimerPwmFactoryStm::Close(uint8_t index, const infra::Function& onClosed) + { + run.Stop(std::nullopt); + driver.emplace(); + base = nullptr; + resources.Release(Resource::lpTimer, index, owners::lpTimerPwm); + onClosed(); + } + + uint32_t LpTimerPwmFactoryStm::Clock() const + { + return clock; + } + + std::size_t LpTimerPwmFactoryStm::Channels() const + { + return channels; + } + + uint32_t LpTimerPwmFactoryStm::MaximumCount() const + { + return maximumCount; + } + + void LpTimerPwmFactoryStm::SetDuty(uint8_t channel, uint8_t percent) + { + base->Channel(channel).SetDuty(percent); + } + + void LpTimerPwmFactoryStm::SetPulse(uint8_t channel, uint32_t pulseOn, uint32_t period) + { + base->Channel(channel).SetPulse(pulseOn, period); + } + + void LpTimerPwmFactoryStm::Start(std::optional channel) + { + run.Start(channel); + } + + void LpTimerPwmFactoryStm::Stop(std::optional channel) + { + run.Stop(channel); + } + + HilStatus LpTimerPwmFactoryStm::Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const + { + if (!LpTimerExists(index)) + return HilStatus::range; + + HilStatus status = HilStatus::done; + arguments.Number("prescaler", request.prescaler, 1, maximumPrescaler, status); + arguments.Number("period", request.period, 1, maximumCount, status); + if (status != HilStatus::done) + return status; + + if (!LpTimerPrescaler(request.prescaler)) + return HilStatus::range; + + status = ParseChannelPins(arguments, naming, infra::MakeRange(request.pins), request.channels); + if (status != HilStatus::done) + return status; + + for (std::size_t position = 0; position != request.channels; ++position) + if (request.pins[position] && !SupportsFunction(*request.pins[position], channelFunctions[position], index)) + return HilStatus::pin; + + for (std::size_t position = 0; position != request.channels; ++position) + if (!IS_LPTIM_CCX_INSTANCE(hal::peripheralLpTimer[index - 1], lpTimerChannels[position])) + return HilStatus::unsupported; + + return HilStatus::done; + } + + HilStatus LpTimerPwmFactoryStm::Claim(uint8_t index, const Request& request, services::HilPinOwner& pins) const + { + HilStatus status = HilStatus::done; + + for (std::size_t position = 0; position != request.channels && status == HilStatus::done; ++position) + { + hal::GpioPin* claimed = nullptr; + status = pins.ClaimFunction(request.pins[position], Function(channelFunctions[position]), index, claimed); + } + + return status; + } + + void LpTimerPwmFactoryStm::Construct(uint8_t index, const Request& request) + { + clock = LpTimerClock(index); + channels = request.channels; + + for (std::size_t position = 0; position != request.channels; ++position) + { + if (request.pins[position]) + channelPins.Emplace(position, PortOf(*request.pins[position]), request.pins[position]->index); + else + channelPins.EmplaceUnused(position); + } + + hal::LowPowerTimerBaseStm::Timing timing{}; + timing.period = request.period; + timing.prescaler = *LpTimerPrescaler(request.prescaler); + const auto pins = channelPins.Range(request.channels); + + if (request.channels == 1) + base = &driver.emplace>(index, timing, pins); + else + base = &driver.emplace>(index, timing, pins); + + run.Attach(*base, request.channels); + } + + LpTimerPwmGroup::LpTimerPwmGroup(services::HilContext& context, LpTimerPwmFactoryStm& factory) + : PwmChannelGroup(context, factory, owners::lpTimerPwm, names) + {} +} + +#endif diff --git a/validation/firmware/LpTimerPwmGroup.hpp b/validation/firmware/LpTimerPwmGroup.hpp new file mode 100644 index 00000000..87e52ae7 --- /dev/null +++ b/validation/firmware/LpTimerPwmGroup.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include "hal_st/stm32fxxx/LpTimerPwmStm.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/ChannelPins.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include "validation/firmware/TimerPwmGroup.hpp" +#include +#include +#include +#include +#include + +#if defined(HAS_PERIPHERAL_LPTIMER) && !defined(STM32WB) + +namespace validation +{ + class LpTimerPwmFactoryStm + : public PwmChannelFactory + { + public: + static constexpr std::size_t maximumChannels = 2; + + LpTimerPwmFactoryStm(const services::HilPinNaming& naming, ResourceAllocation& resources); + + uint8_t Instances() const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + void Close(uint8_t index, const infra::Function& onClosed) override; + + services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) override; + uint32_t Clock() const override; + std::size_t Channels() const override; + uint32_t MaximumCount() const override; + void SetDuty(uint8_t channel, uint8_t percent) override; + void SetPulse(uint8_t channel, uint32_t pulseOn, uint32_t period) override; + void Start(std::optional channel) override; + void Stop(std::optional channel) override; + + private: + struct Request + { + uint32_t prescaler = 1; + uint32_t period = 6399; + std::size_t channels = 0; + std::array, maximumChannels> pins; + }; + + services::HilStatus Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const; + services::HilStatus Claim(uint8_t index, const Request& request, services::HilPinOwner& pins) const; + void Construct(uint8_t index, const Request& request); + + private: + const services::HilPinNaming& naming; + ResourceAllocation& resources; + ChannelPins channelPins; + std::variant, hal::LpTimerPwmWithChannels<2>> driver; + hal::LpTimerPwmBaseStm* base = nullptr; + PwmChannelRun run; + uint32_t clock = 0; + std::size_t channels = 0; + }; + + class LpTimerPwmGroup + : public PwmChannelGroup + { + public: + LpTimerPwmGroup(services::HilContext& context, LpTimerPwmFactoryStm& factory); + }; +} + +#endif diff --git a/validation/firmware/Main.cpp b/validation/firmware/Main.cpp index da467065..494d6470 100644 --- a/validation/firmware/Main.cpp +++ b/validation/firmware/Main.cpp @@ -6,6 +6,7 @@ #include "services/hil/HilPinPool.hpp" #include "services/hil/HilSystemCommands.hpp" #include "services/hil/commands/HilAdcCommands.hpp" +#include "services/hil/commands/HilEepromCommands.hpp" #include "services/hil/commands/HilGpioCommands.hpp" #include "services/hil/commands/HilPwmCommands.hpp" #include "services/hil/commands/HilQeiCommands.hpp" @@ -14,13 +15,34 @@ #include "services/hil/commands/HilWatchDogCommands.hpp" #include "services/peripheral/DebugLed.hpp" #include "validation/firmware/AdcFactory.hpp" +#include "validation/firmware/AesGroup.hpp" +#include "validation/firmware/AnalogInputGroup.hpp" +#include "validation/firmware/BackupRamGroup.hpp" #include "validation/firmware/BoardInfoStm.hpp" +#include "validation/firmware/ClockGroup.hpp" #include "validation/firmware/Console.hpp" +#include "validation/firmware/DmaGroup.hpp" +#include "validation/firmware/EepromGroup.hpp" +#include "validation/firmware/FlashGroup.hpp" +#include "validation/firmware/HsemGroup.hpp" +#include "validation/firmware/I2cGroup.hpp" +#include "validation/firmware/I2cTarget.hpp" +#include "validation/firmware/LowPowerGroup.hpp" +#include "validation/firmware/LpTimerGroup.hpp" +#include "validation/firmware/LpTimerPwmGroup.hpp" #include "validation/firmware/PinFactoryStm.hpp" +#include "validation/firmware/PkaGroup.hpp" #include "validation/firmware/PwmFactory.hpp" #include "validation/firmware/QeiFactory.hpp" +#include "validation/firmware/QuadSpiGroup.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include "validation/firmware/RngGroup.hpp" #include "validation/firmware/SpiFactory.hpp" +#include "validation/firmware/SpiSlaveGroup.hpp" +#include "validation/firmware/SyncGpioGroup.hpp" #include "validation/firmware/TimerAllocation.hpp" +#include "validation/firmware/TimerGroup.hpp" +#include "validation/firmware/TimerPwmGroup.hpp" #include "validation/firmware/UartFactory.hpp" #include "validation/firmware/UnsupportedGroups.hpp" #include "validation/firmware/WatchDogFactory.hpp" @@ -43,6 +65,7 @@ int main() static services::HilPinNamingDefault naming{ validation::board::portLetters, validation::board::maximumPinIndex, infra::MakeRange(validation::board::aliases) }; static services::HilContext context{ console.response, pins, naming, console.terminal }; static validation::TimerAllocation timers; + static validation::ResourceAllocation resources; static hal::cortex::Reset reset; static services::HilSystemCommands system{ context, boardInfo, reset }; @@ -54,19 +77,64 @@ int main() static validation::UartFactoryStm uartFactory{ naming, console.dma }; static services::HilUartCommands::WithCapacity<256, 112> uart{ context, uartFactory }; - static validation::SpiFactoryStm spiFactory{ naming, console.dma }; + static validation::SpiFactoryStm spiFactory{ naming, console.dma, resources }; static services::HilSpiCommands::WithCapacity<64> spi{ context, spiFactory }; - static validation::AdcFactoryStm adcFactory{ naming, console.dma, timers }; + static validation::AdcFactoryStm adcFactory{ naming, console.dma, timers, resources }; static services::HilAdcCommands::WithCapacity adc{ context, adcFactory }; - static validation::QeiFactoryStm qeiFactory{ naming, timers }; + static validation::QeiFactoryStm qeiFactory{ naming, timers, resources }; static services::HilQeiCommands qei{ context, qeiFactory }; static validation::QeiExtensionCommands qeiExtension{ context, qeiFactory }; static validation::WatchDogFactoryStm watchDogFactory{ naming, pins }; static services::HilWatchDogCommands watchDog{ context, watchDogFactory }; + static validation::I2cFactoryStm i2cFactory{ naming, resources }; + static validation::I2cCommands i2c{ context, i2cFactory }; + static validation::I2cTargetFactory i2cTargetFactory{ naming, resources }; + static validation::I2cTargetCommands i2cTarget{ context, i2cTargetFactory }; + static validation::EepromGroup eepromGroup{ context, naming, resources }; + static services::HilEepromCommands::WithCapacity<128> eeprom{ context, eepromGroup }; + + static validation::SpiSlaveFactory spiSlaveFactory{ naming, console.dma, resources }; + static validation::SpiSlaveCommands spiSlave{ context, spiSlaveFactory }; + + static validation::TimerFactoryStm timerFactory{ naming, timers }; + static validation::TimerGroup timer{ context, timerFactory }; + static validation::TimerPwmFactoryStm timerPwmFactory{ naming, timers }; + static validation::TimerPwmGroup timerPwm{ context, timerPwmFactory }; +#if defined(HAS_PERIPHERAL_LPTIMER) + static validation::LpTimerFactoryStm lpTimerFactory{ naming, resources }; + static validation::LpTimerGroup lpTimer{ context, lpTimerFactory }; +#endif +#if defined(HAS_PERIPHERAL_LPTIMER) && !defined(STM32WB) + static validation::LpTimerPwmFactoryStm lpTimerPwmFactory{ naming, resources }; + static validation::LpTimerPwmGroup lpTimerPwm{ context, lpTimerPwmFactory }; +#endif + + static validation::AnalogInputCommands analogInput{ context, naming, console.dma, timers, resources }; + static validation::DmaCommands dmaWave{ context, naming, console.dma, timers, resources }; + + static validation::SyncGpioCommands syncGpio{ context }; + static validation::ClockCommands clocks{ context }; + + static validation::RngCommands rng{ context, resources }; + static validation::AesCommands aes{ context }; + static validation::PkaCommands pka{ context }; + + static validation::FlashCommands flash{ context, watchDogFactory, resources }; +#if defined(STM32WB) + static validation::HsemCommands hsem{ context, timers, resources }; +#endif + static validation::BackupRamCommands backupRam{ context }; + static validation::LowPowerCommands lowPower{ context, timers }; + +#if defined(HAS_PERIPHERAL_QUADSPI) + static validation::QuadSpiFactoryStm quadSpiFactory{ console.dma, resources }; + static validation::QuadSpiGroup quadSpi{ context, quadSpiFactory }; +#endif + validation::CreateUnsupportedGroups(context); system.PrintBoot(); diff --git a/validation/firmware/Owners.hpp b/validation/firmware/Owners.hpp new file mode 100644 index 00000000..e2cbbf10 --- /dev/null +++ b/validation/firmware/Owners.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "services/hil/HilPinPool.hpp" + +namespace validation::owners +{ + // 17 is the watchdog warning pin (WatchDogFactory.cpp); EMIL owns the ids below extension + inline constexpr services::HilOwner i2c = 16; + inline constexpr services::HilOwner i2cTarget = 18; + inline constexpr services::HilOwner eeprom = 19; + inline constexpr services::HilOwner spiSlave = 20; + inline constexpr services::HilOwner timer = 21; + inline constexpr services::HilOwner timerPwm = 22; + inline constexpr services::HilOwner lpTimer = 23; + inline constexpr services::HilOwner lpTimerPwm = 24; + inline constexpr services::HilOwner quadSpi = 25; + inline constexpr services::HilOwner syncGpio = 26; + inline constexpr services::HilOwner analogInput = 27; + inline constexpr services::HilOwner lowPower = 28; + inline constexpr services::HilOwner dma = 29; + inline constexpr services::HilOwner scaffold = 30; + + static_assert(i2c == services::HilOwners::extension && scaffold < services::HilOwners::last); +} diff --git a/validation/firmware/Payload.cpp b/validation/firmware/Payload.cpp new file mode 100644 index 00000000..880ad572 --- /dev/null +++ b/validation/firmware/Payload.cpp @@ -0,0 +1,120 @@ +#include "validation/firmware/Payload.hpp" +#include "infra/util/Crc.hpp" +#include "infra/util/Endian.hpp" +#include +#include + +namespace validation +{ + namespace + { + using services::HilStatus; + + constexpr std::array, 2> outputChoices{ { + { "hex", Output::hex }, + { "crc", Output::crc }, + } }; + + uint8_t NextPrbs(uint32_t& state) + { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + return static_cast(state); + } + } + + HilStatus ParsePayload(const services::HilArguments& arguments, std::size_t positional, infra::ByteRange storage, infra::ByteRange& payload) + { + auto text = arguments.Positional(positional); + auto generated = arguments.Has("len"); + auto patterned = arguments.Has("pattern") || arguments.Has("seed"); + payload = infra::Head(storage, 0); + + if (text != "-") + { + if (generated || patterned) + return HilStatus::usage; + + std::size_t size = 0; + auto status = services::HilArguments::ParseHex(text, storage, size); + if (status == HilStatus::done) + payload = infra::Head(storage, size); + + return status; + } + + if (!generated) + return patterned ? HilStatus::usage : HilStatus::done; + + auto status = HilStatus::done; + auto pattern = Pattern::inc; + uint32_t seed = 0; + uint32_t length = 0; + arguments.Select("pattern", pattern, patternChoices, status); + arguments.Number("seed", seed, 0, std::numeric_limits::max(), status); + arguments.Number("len", length, 1, static_cast(storage.size()), status); + + if (status != HilStatus::done) + return status; + + payload = infra::Head(storage, length); + Generate(payload, pattern, seed); + return HilStatus::done; + } + + void Generate(infra::ByteRange data, Pattern pattern, uint32_t seed) + { + switch (pattern) + { + case Pattern::inc: + for (std::size_t i = 0; i != data.size(); ++i) + data[i] = static_cast(seed + i); + break; + case Pattern::constant: + std::fill(data.begin(), data.end(), static_cast(seed)); + break; + case Pattern::prbs: + { + uint32_t state = seed != 0 ? seed : 1; + for (auto& byte : data) + byte = NextPrbs(state); + break; + } + } + } + + uint32_t Crc32(infra::ConstByteRange data) + { + infra::Crc32 crc; + crc.Update(data); + return crc.Result(); + } + + HilStatus ParseOutput(const services::HilArguments& arguments, Output& output) + { + auto status = HilStatus::done; + output = Output::hex; + arguments.Select("out", output, outputChoices, status); + return status; + } + + HilStatus CheckOutput(std::size_t size, Output output) + { + return output == Output::hex && size > maximumHexOutput ? HilStatus::range : HilStatus::done; + } + + void PrintData(services::HilResponse::Line& line, infra::ConstByteRange data, Output output) + { + if (output == Output::hex) + { + line << " data="; + line.Hex(data); + return; + } + + infra::BigEndian crc{ Crc32(data) }; + line << " len=" << static_cast(data.size()) << " crc="; + line.Hex(infra::MakeByteRange(crc)); + } +} diff --git a/validation/firmware/Payload.hpp b/validation/firmware/Payload.hpp new file mode 100644 index 00000000..ae056b15 --- /dev/null +++ b/validation/firmware/Payload.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include "infra/util/ByteRange.hpp" +#include "services/hil/HilArguments.hpp" +#include "services/hil/HilResponse.hpp" +#include "services/hil/HilStatus.hpp" +#include +#include +#include + +namespace validation +{ + enum class Pattern : uint8_t + { + inc, + constant, + prbs, + }; + + enum class Output : uint8_t + { + hex, + crc, + }; + + inline constexpr std::array payloadKeys{ { "len", "pattern", "seed" } }; + inline constexpr std::array, 3> patternChoices{ { + { "inc", Pattern::inc }, + { "const", Pattern::constant }, + { "prbs", Pattern::prbs }, + } }; + inline constexpr std::size_t maximumHexOutput = 128; + + services::HilStatus ParsePayload(const services::HilArguments& arguments, std::size_t positional, infra::ByteRange storage, infra::ByteRange& payload); + void Generate(infra::ByteRange data, Pattern pattern, uint32_t seed); + uint32_t Crc32(infra::ConstByteRange data); + + services::HilStatus ParseOutput(const services::HilArguments& arguments, Output& output); + services::HilStatus CheckOutput(std::size_t size, Output output); + void PrintData(services::HilResponse::Line& line, infra::ConstByteRange data, Output output); +} diff --git a/validation/firmware/PeripheralClocks.cpp b/validation/firmware/PeripheralClocks.cpp index 057abce5..142aea64 100644 --- a/validation/firmware/PeripheralClocks.cpp +++ b/validation/firmware/PeripheralClocks.cpp @@ -39,4 +39,58 @@ namespace validation return hclk / peripheralClock == 1 ? peripheralClock : peripheralClock * 2; } + + bool I2cExists(uint8_t index) + { + return index >= 1 && index <= hal::peripheralI2c.size() && hal::peripheralI2c[index - 1] != nullptr; + } + + uint32_t I2cKernelClock(uint8_t index) + { + switch (index) + { + case 1: + return HAL_RCCEx_GetPeriphCLKFreq(RCC_PERIPHCLK_I2C1); + case 3: + return HAL_RCCEx_GetPeriphCLKFreq(RCC_PERIPHCLK_I2C3); + default: + return 0; + } + } + + bool SpiExists(uint8_t index) + { + return index >= 1 && index <= hal::peripheralSpi.size() && hal::peripheralSpi[index - 1] != nullptr; + } + + bool SpiLimited(uint8_t index) + { +#if defined(IS_SPI_LIMITED_INSTANCE) + return SpiExists(index) && IS_SPI_LIMITED_INSTANCE(hal::peripheralSpi[index - 1]); +#else + return false; +#endif + } + + bool LpTimerExists(uint8_t index) + { +#if defined(HAS_PERIPHERAL_LPTIMER) + return index >= 1 && index <= hal::peripheralLpTimer.size() && hal::peripheralLpTimer[index - 1] != nullptr; +#else + return false; +#endif + } + + uint32_t LpTimerClock(uint8_t index) + { + switch (index) + { + case 1: + return HAL_RCCEx_GetPeriphCLKFreq(RCC_PERIPHCLK_LPTIM1); + case 2: + return HAL_RCCEx_GetPeriphCLKFreq(RCC_PERIPHCLK_LPTIM2); + default: + return 0; + } + } } diff --git a/validation/firmware/PeripheralClocks.hpp b/validation/firmware/PeripheralClocks.hpp index 3c94adb9..26bc700b 100644 --- a/validation/firmware/PeripheralClocks.hpp +++ b/validation/firmware/PeripheralClocks.hpp @@ -6,4 +6,13 @@ namespace validation { bool TimerExists(uint8_t timer); uint32_t TimerClock(uint8_t timer); + + bool I2cExists(uint8_t index); + uint32_t I2cKernelClock(uint8_t index); + + bool SpiExists(uint8_t index); + bool SpiLimited(uint8_t index); + + bool LpTimerExists(uint8_t index); + uint32_t LpTimerClock(uint8_t index); } diff --git a/validation/firmware/PkaGroup.cpp b/validation/firmware/PkaGroup.cpp new file mode 100644 index 00000000..a736c663 --- /dev/null +++ b/validation/firmware/PkaGroup.cpp @@ -0,0 +1,214 @@ +#include "validation/firmware/PkaGroup.hpp" +#include "services/crypto/Secp256r1.hpp" +#include +#include + +namespace validation +{ + namespace + { + using services::HilStatus; + using PointOnCurveResult = services::EllipticCurveOperations::PointOnCurveResult; + using ComparisonResult = services::EllipticCurveOperations::ComparisonResult; + + constexpr std::size_t minimumComparison = 4; + constexpr std::chrono::milliseconds timeout{ 5000 }; + + bool IsHexDigit(char c) + { + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + } + + bool HexSize(const std::optional& text, std::size_t& size) + { + if (!text || text->empty() || text->size() % 2 != 0 || !std::all_of(text->begin(), text->end(), IsHexDigit)) + return false; + + size = text->size() / 2; + return true; + } + + template + void LoadPadded(infra::BoundedConstString text, std::array& operand) + { + std::size_t size = 0; + operand.fill(0); + services::HilArguments::ParseHex(text, infra::Tail(infra::MakeRange(operand), text.size() / 2), size); + } + } + + PkaCommands::PkaCommands(services::HilContext& context) + : services::TerminalCommands(context.terminal) + , context(context) + , pending(context.response) + , commands{ { + services::HilBind("pka.mul", "[k=] [x= y=]", *this, context.response), + services::HilBind("pka.check", "x= y=", *this, context.response), + services::HilBind("pka.cmp", "a= b=", *this, context.response), + } } + {} + + infra::MemoryRange PkaCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus PkaCommands::Multiply(const services::HilArguments& arguments) + { + if (!arguments.Shape(0, 0, { "k", "x", "y" })) + return HilStatus::usage; + + auto k = arguments.Key("k"); + auto pointX = arguments.Key("x"); + auto pointY = arguments.Key("y"); + std::size_t kSize = 0; + std::size_t xSize = 0; + std::size_t ySize = 0; + if (pointX.has_value() != pointY.has_value() || (k && !HexSize(k, kSize)) || (pointX && (!HexSize(pointX, xSize) || !HexSize(pointY, ySize)))) + return HilStatus::usage; + + if (std::max({ kSize, xSize, ySize }) > scalar.size()) + return HilStatus::range; + + auto status = Reserve(); + if (status != HilStatus::done) + return status; + + if (k) + LoadPadded(*k, scalar); + else + { + scalar.fill(0); + scalar.back() = 1; + } + + infra::ConstByteRange initialX; + infra::ConstByteRange initialY; + if (pointX) + { + LoadPadded(*pointX, x); + LoadPadded(*pointY, y); + initialX = infra::MakeRange(x); + initialY = infra::MakeRange(y); + } + + auto& driver = Pka(); + driver.ScalarMultiplication(services::secp256r1, infra::MakeRange(scalar), initialX, initialY, [this, operation = Start()](infra::ConstByteRange resultX, infra::ConstByteRange resultY) + { + auto elapsed = stopwatch.ElapsedUs(); + + if (!pending.Complete(operation)) + return; + + auto line = context.response.Ok(); + line << " x="; + line.Hex(resultX); + line << " y="; + line.Hex(resultY); + line << " us=" << elapsed; + }); + + return HilStatus::done; + } + + HilStatus PkaCommands::CheckPoint(const services::HilArguments& arguments) + { + if (!arguments.Shape(0, 0, { "x", "y" })) + return HilStatus::usage; + + auto pointX = arguments.Key("x"); + auto pointY = arguments.Key("y"); + std::size_t xSize = 0; + std::size_t ySize = 0; + if (!HexSize(pointX, xSize) || !HexSize(pointY, ySize)) + return HilStatus::usage; + + if (std::max(xSize, ySize) > x.size()) + return HilStatus::range; + + auto status = Reserve(); + if (status != HilStatus::done) + return status; + + LoadPadded(*pointX, x); + LoadPadded(*pointY, y); + + auto& driver = Pka(); + driver.CheckPointOnCurve(services::secp256r1, infra::MakeRange(x), infra::MakeRange(y), [this, operation = Start()](PointOnCurveResult result) + { + if (pending.Complete(operation)) + context.response.Ok() << " on=" << static_cast(result == PointOnCurveResult::pointOnCurve ? 1 : 0); + }); + + return HilStatus::done; + } + + HilStatus PkaCommands::Compare(const services::HilArguments& arguments) + { + if (!arguments.Shape(0, 0, { "a", "b" })) + return HilStatus::usage; + + auto textA = arguments.Key("a"); + auto textB = arguments.Key("b"); + std::size_t size = 0; + std::size_t sizeB = 0; + if (!HexSize(textA, size) || !HexSize(textB, sizeB) || size != sizeB) + return HilStatus::usage; + + if (size < minimumComparison || size > a.size()) + return HilStatus::range; + + auto status = Reserve(); + if (status != HilStatus::done) + return status; + + services::HilArguments::ParseHex(*textA, infra::MakeRange(a), sizeB); + services::HilArguments::ParseHex(*textB, infra::MakeRange(b), sizeB); + + auto& driver = Pka(); + driver.Comparison(infra::Head(infra::MakeRange(a), size), infra::Head(infra::MakeRange(b), size), [this, operation = Start()](ComparisonResult result) + { + if (!pending.Complete(operation)) + return; + + const char* name = "lt"; + if (result == ComparisonResult::aEqualsB) + name = "eq"; + else if (result == ComparisonResult::aGreaterThanB) + name = "gt"; + + context.response.Ok() << " cmp=" << name; + }); + + return HilStatus::done; + } + + HilStatus PkaCommands::Reserve() + { + if (!pending.Busy()) + return HilStatus::done; + + if (infra::Now() < deadline) + return HilStatus::busy; + + // An operation whose ERR timeout went out may never complete; the next command may start another one + pending.Cancel(); + return HilStatus::done; + } + + uint32_t PkaCommands::Start() + { + deadline = infra::Now() + timeout; + stopwatch.Start(); + return pending.Start(timeout); + } + + const hal::PkaStm& PkaCommands::Pka() + { + // Built on first use and kept: PkaStm::Enable waits for the PKA without a timeout, which must not stall the boot + if (!pka) + pka.emplace(); + + return *pka; + } +} diff --git a/validation/firmware/PkaGroup.hpp b/validation/firmware/PkaGroup.hpp new file mode 100644 index 00000000..07c26679 --- /dev/null +++ b/validation/firmware/PkaGroup.hpp @@ -0,0 +1,43 @@ +#pragma once + +#include "hal_st/stm32fxxx/PkaStm.hpp" +#include "infra/timer/Timer.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilPendingOperation.hpp" +#include "validation/firmware/Stopwatch.hpp" +#include +#include +#include + +namespace validation +{ + class PkaCommands + : public services::TerminalCommands + { + public: + explicit PkaCommands(services::HilContext& context); + + infra::MemoryRange Commands() override; + + private: + services::HilStatus Multiply(const services::HilArguments& arguments); + services::HilStatus CheckPoint(const services::HilArguments& arguments); + services::HilStatus Compare(const services::HilArguments& arguments); + services::HilStatus Reserve(); + uint32_t Start(); + const hal::PkaStm& Pka(); + + private: + services::HilContext& context; + services::HilPendingOperation pending; + std::optional pka; + Stopwatch stopwatch; + std::array scalar{}; + std::array x{}; + std::array y{}; + std::array a{}; + std::array b{}; + infra::TimePoint deadline; + std::array commands; + }; +} diff --git a/validation/firmware/PwmFactory.cpp b/validation/firmware/PwmFactory.cpp index 9d4e92fb..f572af3a 100644 --- a/validation/firmware/PwmFactory.cpp +++ b/validation/firmware/PwmFactory.cpp @@ -16,15 +16,32 @@ namespace validation { using services::HilChoice; using services::HilStatus; + using Alignment = hal::PwmStmBase::Alignment; + using TriggerOutput = hal::PwmStmBase::TriggerOutput; constexpr uint32_t maximumPrescaler = 0xffff; constexpr uint32_t maximumDeadTimeNs = 1000000; + constexpr uint32_t maximumBreakFilter = 15; - constexpr std::array openKeys{ { "channels", "pins", "freq", "mode", "prescaler", "dead", "inv", "invn", "idle", "idlen", "brk", "brkpol", "brkauto", "sync" } }; + constexpr std::array openKeys{ { "channels", "pins", "freq", "mode", "prescaler", "dead", "inv", "invn", "idle", "idlen", "brk", "brkpol", "brkauto", "sync", "preload", "brkfilter", "trgo" } }; - constexpr std::array, 2> alignments{ { - { "edge", false }, - { "center", true }, + constexpr std::array, 5> alignments{ { + { "edge", Alignment::edgeAligned }, + { "edgedown", Alignment::edgeAlignedDownCounting }, + { "center", Alignment::centerAlignedDownCounting }, + { "centerup", Alignment::centerAlignedUpCounting }, + { "centerboth", Alignment::centerAlignedBothCounting }, + } }; + + constexpr std::array, 8> triggerOutputs{ { + { "reset", TriggerOutput::reset }, + { "enable", TriggerOutput::enable }, + { "update", TriggerOutput::update }, + { "oc1", TriggerOutput::comparePulse }, + { "oc1ref", TriggerOutput::compareChannel1 }, + { "oc2ref", TriggerOutput::compareChannel2 }, + { "oc3ref", TriggerOutput::compareChannel3 }, + { "oc4ref", TriggerOutput::compareChannel4 }, } }; constexpr std::array, 2> breakPolarities{ { @@ -73,6 +90,11 @@ namespace validation return TimerClock(timer) / (prescaler + 1); } + bool IsCenterAligned(Alignment alignment) + { + return alignment != Alignment::edgeAligned && alignment != Alignment::edgeAlignedDownCounting; + } + bool ValidFrequency(uint8_t timer, uint32_t counterClock, bool centerAligned, uint32_t hertz) { const auto ticksPerPeriod = counterClock / hertz; @@ -198,7 +220,7 @@ namespace validation return status; } - timing = Timing{ CounterClock(index, request.prescaler), request.centerAligned }; + timing = Timing{ CounterClock(index, request.prescaler), IsCenterAligned(request.alignment) }; opened = &Construct(index, request, claimed); return HilStatus::done; } @@ -239,7 +261,10 @@ namespace validation return output.complementaryPin.has_value(); }); - if (request.centerAligned && !IS_TIM_COUNTER_MODE_SELECT_INSTANCE(Instance(timer))) + if (request.alignment != Alignment::edgeAligned && !IS_TIM_COUNTER_MODE_SELECT_INSTANCE(Instance(timer))) + return HilStatus::unsupported; + + if (request.triggerOutput && !IS_TIM_MASTER_INSTANCE(Instance(timer))) return HilStatus::unsupported; if ((complementary || request.deadTime || request.idleHigh || request.complementaryIdleHigh || request.breakPin) && !IS_TIM_BREAK_INSTANCE(Instance(timer))) @@ -252,7 +277,7 @@ namespace validation if (request.breakPin && !SupportsFunction(*request.breakPin, hal::PinConfigTypeStm::timerBreak, timer)) return HilStatus::pin; - if (!ValidFrequency(timer, CounterClock(timer, request.prescaler), request.centerAligned, request.frequency)) + if (!ValidFrequency(timer, CounterClock(timer, request.prescaler), IsCenterAligned(request.alignment), request.frequency)) return HilStatus::range; return HilStatus::done; @@ -262,7 +287,9 @@ namespace validation { HilStatus status = HilStatus::done; arguments.Number("freq", request.frequency, 1, std::numeric_limits::max(), status); - arguments.Select("mode", request.centerAligned, alignments, status); + arguments.Select("mode", request.alignment, alignments, status); + if (arguments.Has("trgo")) + arguments.Select("trgo", request.triggerOutput.emplace(), triggerOutputs, status); arguments.Number("prescaler", request.prescaler, 0, maximumPrescaler, status); if (auto deadTime = arguments.Key("dead"); deadTime && *deadTime != "off") @@ -278,11 +305,23 @@ namespace validation arguments.Flag("idlen", request.complementaryIdleHigh, status); arguments.Flag("brkauto", request.breakAutomaticOutput, status); arguments.Flag("sync", request.synchronous, status); + arguments.Flag("preload", request.preload, status); arguments.Select("brkpol", request.breakActiveHigh, breakPolarities, status); + + if (arguments.Has("brkfilter")) + { + uint32_t filter = 0; + arguments.Number("brkfilter", filter, 0, maximumBreakFilter, status); + request.breakFilter = static_cast(filter); + } + arguments.Pin("brk", naming, request.breakPin, status); if (status != HilStatus::done) return status; + if (request.breakFilter && !request.breakPin) + return HilStatus::usage; + return ParseOutputs(arguments, request); } @@ -442,9 +481,10 @@ namespace validation services::HilPwmHandle& PwmFactoryStm::Construct(uint8_t timer, const Request& request, const ClaimedPins& claimed) { hal::PwmStmBase::Config config; - // All three centre-aligned modes give the same waveform; they only differ in when CCxIF is set, which nothing here uses - config.alignment = request.centerAligned ? hal::PwmStmBase::Alignment::centerAlignedDownCounting : hal::PwmStmBase::Alignment::edgeAligned; + config.alignment = request.alignment; config.prescaler = static_cast(request.prescaler); + config.preloadEnabled = request.preload; + config.triggerOutput = request.triggerOutput; if (request.deadTime) config.deadTime.emplace().duration = std::chrono::nanoseconds(*request.deadTime); @@ -454,6 +494,7 @@ namespace validation auto& breakInput = config.breakInput.emplace(); breakInput.activeHigh = request.breakActiveHigh; breakInput.automaticOutputEnable = request.breakAutomaticOutput; + breakInput.filter = request.breakFilter.value_or(0); } infra::BoundedVector::WithMaxSize channels; diff --git a/validation/firmware/PwmFactory.hpp b/validation/firmware/PwmFactory.hpp index b9e780e3..677d221e 100644 --- a/validation/firmware/PwmFactory.hpp +++ b/validation/firmware/PwmFactory.hpp @@ -61,7 +61,7 @@ namespace validation { infra::BoundedVector::WithMaxSize outputs; uint32_t frequency = 10000; - bool centerAligned = false; + hal::PwmStmBase::Alignment alignment = hal::PwmStmBase::Alignment::edgeAligned; uint32_t prescaler = 0; std::optional deadTime; bool inverted = false; @@ -72,6 +72,9 @@ namespace validation bool breakActiveHigh = true; bool breakAutomaticOutput = false; bool synchronous = false; + bool preload = true; + std::optional breakFilter; + std::optional triggerOutput; }; struct ClaimedPins diff --git a/validation/firmware/QeiFactory.cpp b/validation/firmware/QeiFactory.cpp index a8552bf2..b04e6fa4 100644 --- a/validation/firmware/QeiFactory.cpp +++ b/validation/firmware/QeiFactory.cpp @@ -13,6 +13,7 @@ namespace validation namespace { using Config = hal::SynchronousQuadratureEncoderStm::Config; + using LowPowerDecode = QeiFactoryStm::LowPowerDecode; using services::HilChoice; using services::HilStatus; @@ -29,6 +30,12 @@ namespace validation { "ab", Config::DecodeMode::x4OnBothPhases }, } }; + constexpr std::array, 3> lowPowerDecodeModes{ { + { "ab", LowPowerDecode::bothEdges }, + { "rise", LowPowerDecode::risingEdges }, + { "fall", LowPowerDecode::fallingEdges }, + } }; + constexpr std::array timerInputs{ { hal::PinConfigTypeStm::timerChannel1, hal::PinConfigTypeStm::timerChannel2 } }; constexpr std::array lowPowerTimerInputs{ { hal::PinConfigTypeStm::lpTimerInput1, hal::PinConfigTypeStm::lpTimerInput2 } }; @@ -69,6 +76,11 @@ namespace validation return samples == 0 || samples == 2 || samples == 4 || samples == 8; } + bool IsSinglePhase(std::optional decodeMode) + { + return decodeMode && (*decodeMode == "a" || *decodeMode == "b"); + } + #if defined(HAS_PERIPHERAL_LPTIMER) hal::SynchronousQuadratureEncoderLpTimStm::Config::Filter LowPowerFilter(uint8_t samples) { @@ -87,10 +99,26 @@ namespace validation } } - hal::SynchronousQuadratureEncoderLpTimStm::Config LowPowerConfig(const Config& config) + hal::SynchronousQuadratureEncoderLpTimStm::Config::DecodeMode LowPowerDecodeMode(LowPowerDecode decode) + { + using DecodeMode = hal::SynchronousQuadratureEncoderLpTimStm::Config::DecodeMode; + + switch (decode) + { + case LowPowerDecode::risingEdges: + return DecodeMode::x2OnRisingEdges; + case LowPowerDecode::fallingEdges: + return DecodeMode::x2OnFallingEdges; + default: + return DecodeMode::x4OnBothEdges; + } + } + + hal::SynchronousQuadratureEncoderLpTimStm::Config LowPowerConfig(const Config& config, LowPowerDecode decode) { hal::SynchronousQuadratureEncoderLpTimStm::Config result; result.resolution = config.resolution; + result.decodeMode = LowPowerDecodeMode(decode); result.filter = LowPowerFilter(config.filter); result.reverseForMirroredMounting = config.invertPhaseA; result.speedSamplePeriod = config.speedSamplePeriod; @@ -108,9 +136,10 @@ namespace validation } } - QeiFactoryStm::QeiFactoryStm(const services::HilPinNaming& naming, TimerAllocation& timers) + QeiFactoryStm::QeiFactoryStm(const services::HilPinNaming& naming, TimerAllocation& timers, ResourceAllocation& resources) : naming(naming) , timers(timers) + , resources(resources) {} uint8_t QeiFactoryStm::Instances() const @@ -136,12 +165,9 @@ namespace validation if (status != HilStatus::done) return status; - if (!request.lowPower) - { - status = timers.Claim(index, services::HilOwners::qei); - if (status != HilStatus::done) - return status; - } + status = ClaimTimer(index, request.lowPower); + if (status != HilStatus::done) + return status; ClaimedPins claimed; status = Claim(index, request, pins, claimed); @@ -207,7 +233,7 @@ namespace validation HilStatus QeiFactoryStm::ParseSettings(uint8_t timer, const services::HilArguments& arguments, Request& request) const { - if (request.lowPower && (arguments.Has("cap") || arguments.Has("offset") || arguments.Has("invb"))) + if (request.lowPower && (arguments.Has("offset") || arguments.Has("invb") || IsSinglePhase(arguments.Key("cap")))) return HilStatus::unsupported; auto& config = request.config; @@ -221,7 +247,10 @@ namespace validation uint32_t filter = 0; arguments.Flag("inva", config.invertPhaseA, status); arguments.Flag("invb", config.invertPhaseB, status); - arguments.Select("cap", config.decodeMode, decodeModes, status); + if (request.lowPower) + arguments.Select("cap", request.lowPowerDecode, lowPowerDecodeModes, status); + else + arguments.Select("cap", config.decodeMode, decodeModes, status); arguments.Number("filter", filter, 0, maximumFilter, status); config.filter = static_cast(filter); @@ -289,15 +318,25 @@ namespace validation { #if defined(HAS_PERIPHERAL_LPTIMER) if (request.lowPower) - return WithIndex(driver.emplace(timer, PinOrDummy(claimed.a), PinOrDummy(claimed.b), PinOrDummy(claimed.index), LowPowerConfig(request.config)), claimed.index); + return WithIndex(driver.emplace(timer, PinOrDummy(claimed.a), PinOrDummy(claimed.b), PinOrDummy(claimed.index), LowPowerConfig(request.config, request.lowPowerDecode)), claimed.index); #endif return WithIndex(driver.emplace(timer, PinOrDummy(claimed.a), PinOrDummy(claimed.b), PinOrDummy(claimed.index), request.config), claimed.index); } + HilStatus QeiFactoryStm::ClaimTimer(uint8_t timer, bool lowPower) + { + if (lowPower) + return resources.Claim(Resource::lpTimer, timer, services::HilOwners::qei); + + return timers.Claim(timer, services::HilOwners::qei); + } + void QeiFactoryStm::ReleaseTimer(uint8_t timer, bool lowPower) { - if (!lowPower) + if (lowPower) + resources.Release(Resource::lpTimer, timer, services::HilOwners::qei); + else timers.Release(timer, services::HilOwners::qei); } diff --git a/validation/firmware/QeiFactory.hpp b/validation/firmware/QeiFactory.hpp index a4b8ad37..fc729ebe 100644 --- a/validation/firmware/QeiFactory.hpp +++ b/validation/firmware/QeiFactory.hpp @@ -6,6 +6,7 @@ #include "services/hil/commands/HilQeiCommands.hpp" #include "services/util/Terminal.hpp" #include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/ResourceAllocation.hpp" #include "validation/firmware/TimerAllocation.hpp" #include #include @@ -18,7 +19,14 @@ namespace validation : public services::HilQeiFactory { public: - QeiFactoryStm(const services::HilPinNaming& naming, TimerAllocation& timers); + enum class LowPowerDecode : uint8_t + { + bothEdges, + risingEdges, + fallingEdges, + }; + + QeiFactoryStm(const services::HilPinNaming& naming, TimerAllocation& timers, ResourceAllocation& resources); uint8_t Instances() const override; infra::MemoryRange OpenKeys() const override; @@ -38,6 +46,7 @@ namespace validation struct Request { bool lowPower = false; + LowPowerDecode lowPowerDecode = LowPowerDecode::bothEdges; std::optional a; std::optional b; std::optional index; @@ -63,11 +72,13 @@ namespace validation services::HilStatus ParsePins(uint8_t timer, const services::HilArguments& arguments, Request& request) const; services::HilStatus Claim(uint8_t timer, const Request& request, services::HilPinOwner& pins, ClaimedPins& claimed) const; hal::SynchronousQuadratureEncoder& Construct(uint8_t timer, const Request& request, const ClaimedPins& claimed); + services::HilStatus ClaimTimer(uint8_t timer, bool lowPower); void ReleaseTimer(uint8_t timer, bool lowPower); private: const services::HilPinNaming& naming; TimerAllocation& timers; + ResourceAllocation& resources; Driver driver; std::optional opened; }; diff --git a/validation/firmware/QuadSpiGroup.cpp b/validation/firmware/QuadSpiGroup.cpp new file mode 100644 index 00000000..40863952 --- /dev/null +++ b/validation/firmware/QuadSpiGroup.cpp @@ -0,0 +1,513 @@ +#include "validation/firmware/QuadSpiGroup.hpp" + +#if defined(HAS_PERIPHERAL_QUADSPI) + +#include "BoardProfile.hpp" +#include "infra/event/EventDispatcher.hpp" +#include "infra/util/ReallyAssert.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include +#include +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + using services::HilChoice; + using services::HilStatus; + + constexpr uint8_t quadSpiIndex = 1; + constexpr uint8_t pinFunctionInstance = 0; + constexpr uint32_t maximumPrescaler = 255; + constexpr uint32_t maximumFlashSizeLog2 = 32; + constexpr uint32_t maximumDummyCycles = 31; + constexpr uint32_t maximumPhaseBytes = 4; + constexpr uint32_t maximumRepeat = 8; + constexpr infra::Duration operationTimeout = std::chrono::milliseconds(2000); + + constexpr std::array openKeys{ { "variant", "prescaler", "size" } }; + constexpr std::array commandKeys{ { "instr", "addr", "abytes", "alt", "altbytes", "dummy", "lines", "tx", "len", "pattern", "seed", "rx", "out", "repeat" } }; + constexpr std::array pollKeys{ { "instr", "addr", "abytes", "alt", "altbytes", "dummy", "lines", "match", "mask", "size" } }; + constexpr std::array transferKeys{ { "len", "pattern", "seed", "rx", "repeat" } }; + + constexpr std::array, 3> variantChoices{ { + { "poll", QuadSpiVariant::poll }, + { "dma", QuadSpiVariant::dma }, + { "spi", QuadSpiVariant::spi }, + } }; + + constexpr std::array pinIds{ { board::qspiPins.clk, board::qspiPins.ncs, board::qspiPins.io[0], board::qspiPins.io[1], board::qspiPins.io[2], board::qspiPins.io[3] } }; + constexpr std::array pinFunctions{ { + hal::PinConfigTypeStm::quadSpiClock, + hal::PinConfigTypeStm::quadSpiSlaveSelect, + hal::PinConfigTypeStm::quadSpiData0, + hal::PinConfigTypeStm::quadSpiData1, + hal::PinConfigTypeStm::quadSpiData2, + hal::PinConfigTypeStm::quadSpiData3, + } }; + + static_assert(board::qspiDma.dma == 2 && LL_DMA_CHANNEL_1 == 1 && LL_DMA_CHANNEL_7 == 7); + + bool Fits(uint32_t value, uint32_t bytes) + { + return bytes >= maximumPhaseBytes || value < (uint32_t{ 1 } << (8 * bytes)); + } + + uint32_t FifoLevel() + { + return READ_BIT(QUADSPI->SR, QUADSPI_SR_FLEVEL) >> QUADSPI_SR_FLEVEL_Pos; + } + + bool PhasesShape(const services::HilArguments& arguments) + { + return (arguments.Has("addr") || !arguments.Has("abytes")) && (arguments.Has("alt") || !arguments.Has("altbytes")); + } + + bool CommandShape(const services::HilArguments& arguments) + { + const bool transmit = arguments.Has("tx"); + const bool generated = arguments.Has("len"); + const bool receive = arguments.Has("rx"); + const bool patterned = arguments.Has("pattern") || arguments.Has("seed"); + const int dataPhases = static_cast(transmit) + static_cast(generated) + static_cast(receive); + + return dataPhases <= 1 && (generated || !patterned) && (receive || !arguments.Has("out")) && !(receive && arguments.Has("repeat")) && PhasesShape(arguments); + } + + bool TransferShape(const services::HilArguments& arguments) + { + const bool transmit = arguments.Positional(1) != "-" || arguments.Has("len"); + const bool receive = arguments.Has("rx"); + + return transmit != receive && !(receive && arguments.Has("repeat")); + } + + template + HilStatus ParsePhases(const services::HilArguments& arguments, Phases& phases) + { + uint32_t instruction = 0; + uint32_t address = 0; + uint32_t addressBytes = 3; + uint32_t alternate = 0; + uint32_t alternateBytes = 1; + uint32_t dummyCycles = 0; + uint32_t lines = 1; + + HilStatus status = HilStatus::done; + arguments.Number("instr", instruction, 0, std::numeric_limits::max(), status); + arguments.Number("addr", address, 0, std::numeric_limits::max(), status); + arguments.Number("abytes", addressBytes, 1, maximumPhaseBytes, status); + arguments.Number("alt", alternate, 0, std::numeric_limits::max(), status); + arguments.Number("altbytes", alternateBytes, 1, maximumPhaseBytes, status); + arguments.Number("dummy", dummyCycles, 0, maximumDummyCycles, status); + arguments.Number("lines", lines, 1, 4, status); + if (status != HilStatus::done) + return status; + + if ((lines != 1 && lines != 4) || !Fits(address, addressBytes) || !Fits(alternate, alternateBytes)) + return HilStatus::range; + + if (arguments.Has("instr")) + phases.header.instruction = static_cast(instruction); + if (arguments.Has("addr")) + phases.header.address = hal::QuadSpi::AddressToVector(address, static_cast(addressBytes)); + if (arguments.Has("alt")) + phases.header.alternate = hal::QuadSpi::AddressToVector(alternate, static_cast(alternateBytes)); + phases.header.nofDummyCycles = static_cast(dummyCycles); + phases.lines = lines == 4 ? hal::QuadSpi::Lines::QuadSpeed() : hal::QuadSpi::Lines::SingleSpeed(); + return HilStatus::done; + } + } + + QuadSpiFactoryStm::QuadSpiFactoryStm(hal::DmaStm& dma, ResourceAllocation& resources) + : dma(dma) + , resources(resources) + {} + + uint8_t QuadSpiFactoryStm::Instances() const + { + return quadSpiIndex + 1; + } + + infra::MemoryRange QuadSpiFactoryStm::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus QuadSpiFactoryStm::Prepare(uint8_t index, const services::HilArguments& arguments) + { + Request request; + return Evaluate(index, arguments, request); + } + + void QuadSpiFactoryStm::Close(uint8_t, const infra::Function& onClosed) + { + this->onClosed = onClosed; + + // QuadSpiStm schedules its completion with a pointer to the driver, which must still be alive when it runs + infra::EventDispatcher::Instance().Schedule([this]() + { + Destroy(); + }); + } + + HilStatus QuadSpiFactoryStm::Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) + { + Request request; + ClaimedPins claimed{}; + + HilStatus status = Evaluate(index, arguments, request); + if (status == HilStatus::done) + status = Claim(request, pins, claimed); + if (status == HilStatus::done) + Construct(request, claimed); + + return status; + } + + uint32_t QuadSpiFactoryStm::Clock() const + { + return clock; + } + + hal::QuadSpi& QuadSpiFactoryStm::QuadSpi() + { + hal::QuadSpi* quadSpi = std::get_if(&driver); + if (quadSpi == nullptr) + quadSpi = std::get_if(&driver); + + really_assert(quadSpi != nullptr); + return *quadSpi; + } + + hal::SpiMaster* QuadSpiFactoryStm::Spi() + { + return spi ? &*spi : nullptr; + } + + HilStatus QuadSpiFactoryStm::Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const + { + HilStatus status = HilStatus::done; + arguments.Select("variant", request.variant, variantChoices, status); + arguments.Number("prescaler", request.prescaler, 0, maximumPrescaler, status); + arguments.Number("size", request.flashSizeLog2, 1, maximumFlashSizeLog2, status); + if (status != HilStatus::done) + return status; + + return index == quadSpiIndex ? HilStatus::done : HilStatus::range; + } + + HilStatus QuadSpiFactoryStm::Claim(const Request& request, services::HilPinOwner& pins, ClaimedPins& claimed) + { + for (std::size_t i = 0; i != pinIds.size(); ++i) + { + HilStatus status = pins.ClaimFunction(pinIds[i], Function(pinFunctions[i]), pinFunctionInstance, claimed[i]); + if (status != HilStatus::done) + return status; + } + + if (request.variant == QuadSpiVariant::poll) + return HilStatus::done; + + return resources.Claim(Resource::dma2, board::qspiDma.channel, owners::quadSpi); + } + + void QuadSpiFactoryStm::Construct(const Request& request, const ClaimedPins& claimed) + { + clock = HAL_RCC_GetHCLK4Freq() / (request.prescaler + 1); + + if (request.variant == QuadSpiVariant::poll) + { + hal::QuadSpiStm::Config config; + config.prescaler = request.prescaler; + config.flashSizeLog2 = request.flashSizeLog2; + driver.emplace(PinOrDummy(claimed[0]), PinOrDummy(claimed[1]), PinOrDummy(claimed[2]), PinOrDummy(claimed[3]), PinOrDummy(claimed[4]), PinOrDummy(claimed[5]), config); + return; + } + + hal::QuadSpiStmDma::Config config; + config.prescaler = static_cast(request.prescaler); + config.flashSizeLog2 = request.flashSizeLog2; + auto& transceiveStream = stream.emplace(dma, hal::DmaChannelId(board::qspiDma.dma, board::qspiDma.channel, board::qspiDmaRequest)); + auto& quadSpi = driver.emplace(transceiveStream, PinOrDummy(claimed[0]), PinOrDummy(claimed[1]), PinOrDummy(claimed[2]), PinOrDummy(claimed[3]), PinOrDummy(claimed[4]), PinOrDummy(claimed[5]), config); + + if (request.variant == QuadSpiVariant::spi) + spi.emplace(quadSpi); + } + + void QuadSpiFactoryStm::Destroy() + { + spi.reset(); + + // A command that timed out leaves its channel running, and HAL_DMA_Init keeps CCR.EN for the next open + if (stream) + LL_DMA_DisableChannel(DMA2, board::qspiDma.channel); + + // A data-only indirect read hangs the QUADSPI with BUSY set (erratum): only an abort or a reset clears it + __HAL_RCC_QUADSPI_FORCE_RESET(); + __HAL_RCC_QUADSPI_RELEASE_RESET(); + + driver.emplace(); + stream.reset(); + resources.Release(Resource::dma2, board::qspiDma.channel, owners::quadSpi); + onClosed(); + } + + QuadSpiGroup::QuadSpiGroup(services::HilContext& context, QuadSpiFactoryStm& factory) + : services::HilSingleInstanceGroup(context, factory, owners::quadSpi) + , factory(factory) + , pending(context.response) + , commands{ { + OpenCommand("qspi.open", "<1> [variant=poll|dma|spi] [prescaler=] [size=]"), + services::HilBind("qspi.cmd", "<1> [instr=] [addr=] [alt=] [dummy=] [lines=] [tx=|len=|rx=]", *this, context.response), + services::HilBind("qspi.poll", "<1> match= mask= [size=] [instr=] [lines=]", *this, context.response), + services::HilBind("qspi.xfer", "<1> [rx=]", *this, context.response), + CloseCommand("qspi.close", "<1>"), + } } + {} + + infra::MemoryRange QuadSpiGroup::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus QuadSpiGroup::OpenInstance(uint8_t index, const services::HilArguments& arguments) + { + return factory.Open(index, arguments, Pins()); + } + + void QuadSpiGroup::Opened(services::HilResponse::Line& line) const + { + line << " clk=" << factory.Clock(); + } + + void QuadSpiGroup::CloseInstance() + { + pending.Cancel(); + remaining = 0; + } + + HilStatus QuadSpiGroup::Execute(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, infra::MakeRange(commandKeys))) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done) + return status; + + if (!CommandShape(arguments)) + return HilStatus::usage; + + Request next; + status = ParsePhases(arguments, next.phases); + if (status != HilStatus::done) + return status; + + // The command in flight still reads or writes buffer, which the data arguments fill + if (pending.Busy()) + return HilStatus::busy; + + status = ParseCommandData(arguments, next); + if (status != HilStatus::done) + return status; + + Start(next); + return HilStatus::done; + } + + HilStatus QuadSpiGroup::Poll(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, infra::MakeRange(pollKeys))) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done) + return status; + + if (!arguments.Has("match") || !arguments.Has("mask") || !PhasesShape(arguments)) + return HilStatus::usage; + + Request next; + uint32_t match = 0; + uint32_t mask = 0; + uint32_t size = 1; + status = ParsePhases(arguments, next.phases); + arguments.Number("match", match, 0, std::numeric_limits::max(), status); + arguments.Number("mask", mask, 0, std::numeric_limits::max(), status); + arguments.Number("size", size, 1, maximumPhaseBytes, status); + if (status != HilStatus::done) + return status; + + if (!Fits(match, size) || !Fits(mask, size)) + return HilStatus::range; + + if (pending.Busy()) + return HilStatus::busy; + + request = next; + const auto operation = current = pending.Start(operationTimeout); + factory.QuadSpi().PollStatus(request.phases.header, static_cast(size), match, mask, request.phases.lines, [this, operation]() + { + Polled(operation); + }); + + return HilStatus::done; + } + + HilStatus QuadSpiGroup::Transfer(const services::HilArguments& arguments) + { + if (!arguments.Shape(2, 2, infra::MakeRange(transferKeys))) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done) + return status; + + if (!TransferShape(arguments)) + return HilStatus::usage; + + if (factory.Spi() == nullptr) + return HilStatus::unsupported; + + // The command in flight still reads or writes buffer, which the data arguments fill + if (pending.Busy()) + return HilStatus::busy; + + Request next; + status = ParseTransferData(arguments, next); + if (status != HilStatus::done) + return status; + + Start(next); + return HilStatus::done; + } + + HilStatus QuadSpiGroup::ParseCommandData(const services::HilArguments& arguments, Request& next) + { + HilStatus status = HilStatus::done; + + if (auto transmit = arguments.Key("tx")) + { + std::size_t size = 0; + status = services::HilArguments::ParseHex(*transmit, infra::MakeRange(buffer), size); + next.data = infra::Head(infra::MakeRange(buffer), size); + } + else if (arguments.Has("len")) + { + Pattern pattern = Pattern::inc; + uint32_t seed = 0; + uint32_t length = 0; + arguments.Select("pattern", pattern, patternChoices, status); + arguments.Number("seed", seed, 0, std::numeric_limits::max(), status); + arguments.Number("len", length, 1, static_cast(buffer.size()), status); + if (status == HilStatus::done) + { + next.data = infra::Head(infra::MakeRange(buffer), length); + Generate(next.data, pattern, seed); + } + } + else if (arguments.Has("rx")) + { + uint32_t length = 0; + arguments.Number("rx", length, 1, static_cast(buffer.size()), status); + if (status == HilStatus::done) + status = ParseOutput(arguments, next.output); + if (status == HilStatus::done) + status = CheckOutput(length, next.output); + + next.read = true; + next.data = infra::Head(infra::MakeRange(buffer), length); + } + + arguments.Number("repeat", next.repeat, 1, maximumRepeat, status); + return status; + } + + HilStatus QuadSpiGroup::ParseTransferData(const services::HilArguments& arguments, Request& next) + { + HilStatus status = ParsePayload(arguments, 1, infra::MakeRange(buffer), next.data); + + if (arguments.Has("rx")) + { + uint32_t length = 0; + arguments.Number("rx", length, 1, static_cast(maximumHexOutput), status); + next.read = true; + next.data = infra::Head(infra::MakeRange(buffer), length); + } + + arguments.Number("repeat", next.repeat, 1, maximumRepeat, status); + next.spi = true; + return status; + } + + void QuadSpiGroup::Start(const Request& next) + { + request = next; + remaining = request.repeat; + current = pending.Start(operationTimeout); + Issue(); + } + + void QuadSpiGroup::Issue() + { + const auto operation = current; + auto onWritten = [this, operation]() + { + Written(operation); + }; + auto onReceived = [this, operation]() + { + Received(operation); + }; + + if (request.spi && request.read) + factory.Spi()->SendAndReceive({}, request.data, hal::SpiAction::stop, onReceived); + else if (request.spi) + factory.Spi()->SendAndReceive(request.data, {}, hal::SpiAction::stop, onWritten); + else if (request.read) + factory.QuadSpi().ReceiveData(request.phases.header, request.data, request.phases.lines, onReceived); + else + factory.QuadSpi().SendData(request.phases.header, request.data, request.phases.lines, onWritten); + } + + void QuadSpiGroup::Written(uint32_t operation) + { + const auto level = FifoLevel(); + + if (!pending.Busy() || operation != current) + return; + + if (--remaining != 0) + { + Issue(); + return; + } + + if (pending.Complete(operation)) + Context().response.Ok() << " flevel=" << level; + } + + void QuadSpiGroup::Received(uint32_t operation) + { + if (!pending.Complete(operation)) + return; + + auto line = Context().response.Ok(); + if (request.spi) + (line << " rx=").Hex(request.data); + else + PrintData(line, request.data, request.output); + } + + void QuadSpiGroup::Polled(uint32_t operation) + { + if (pending.Complete(operation)) + Context().response.Ok(); + } +} + +#endif diff --git a/validation/firmware/QuadSpiGroup.hpp b/validation/firmware/QuadSpiGroup.hpp new file mode 100644 index 00000000..55db2d22 --- /dev/null +++ b/validation/firmware/QuadSpiGroup.hpp @@ -0,0 +1,128 @@ +#pragma once + +#include "generated/stm32fxxx/PeripheralTable.hpp" + +#if defined(HAS_PERIPHERAL_QUADSPI) + +#include "hal/interfaces/QuadSpi.hpp" +#include "hal/interfaces/Spi.hpp" +#include "hal_st/stm32fxxx/DmaStm.hpp" +#include "hal_st/stm32fxxx/QuadSpiStm.hpp" +#include "hal_st/stm32fxxx/QuadSpiStmDma.hpp" +#include "hal_st/stm32fxxx/SingleSpeedQuadSpiStmDma.hpp" +#include "infra/util/AutoResetFunction.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilPendingOperation.hpp" +#include "services/hil/commands/HilSingleInstanceGroup.hpp" +#include "validation/firmware/Payload.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include +#include +#include +#include + +namespace validation +{ + enum class QuadSpiVariant : uint8_t + { + poll, + dma, + spi, + }; + + class QuadSpiFactoryStm + : public services::HilInstanceFactory + { + public: + QuadSpiFactoryStm(hal::DmaStm& dma, ResourceAllocation& resources); + + uint8_t Instances() const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + void Close(uint8_t index, const infra::Function& onClosed) override; + + services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins); + uint32_t Clock() const; + hal::QuadSpi& QuadSpi(); + hal::SpiMaster* Spi(); + + private: + struct Request + { + QuadSpiVariant variant = QuadSpiVariant::poll; + uint32_t prescaler = 2; + uint32_t flashSizeLog2 = 24; + }; + + using ClaimedPins = std::array; + + services::HilStatus Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const; + services::HilStatus Claim(const Request& request, services::HilPinOwner& pins, ClaimedPins& claimed); + void Construct(const Request& request, const ClaimedPins& claimed); + void Destroy(); + + private: + hal::DmaStm& dma; + ResourceAllocation& resources; + std::optional stream; + std::variant driver; + std::optional spi; + uint32_t clock = 0; + infra::AutoResetFunction onClosed; + }; + + class QuadSpiGroup + : public services::HilSingleInstanceGroup + { + public: + QuadSpiGroup(services::HilContext& context, QuadSpiFactoryStm& factory); + + infra::MemoryRange Commands() override; + + protected: + services::HilStatus OpenInstance(uint8_t index, const services::HilArguments& arguments) override; + void Opened(services::HilResponse::Line& line) const override; + void CloseInstance() override; + + private: + struct Phases + { + hal::QuadSpi::Header header{ std::nullopt, {}, {}, 0 }; + hal::QuadSpi::Lines lines = hal::QuadSpi::Lines::SingleSpeed(); + }; + + struct Request + { + Phases phases; + infra::ByteRange data; + bool read = false; + bool spi = false; + Output output = Output::hex; + uint32_t repeat = 1; + }; + + services::HilStatus Execute(const services::HilArguments& arguments); + services::HilStatus Poll(const services::HilArguments& arguments); + services::HilStatus Transfer(const services::HilArguments& arguments); + + services::HilStatus ParseCommandData(const services::HilArguments& arguments, Request& next); + services::HilStatus ParseTransferData(const services::HilArguments& arguments, Request& next); + + void Start(const Request& next); + void Issue(); + void Written(uint32_t operation); + void Received(uint32_t operation); + void Polled(uint32_t operation); + + private: + QuadSpiFactoryStm& factory; + services::HilPendingOperation pending; + uint32_t current = 0; + uint32_t remaining = 0; + Request request; + std::array buffer{}; + std::array commands; + }; +} + +#endif diff --git a/validation/firmware/ResourceAllocation.cpp b/validation/firmware/ResourceAllocation.cpp new file mode 100644 index 00000000..d58fd338 --- /dev/null +++ b/validation/firmware/ResourceAllocation.cpp @@ -0,0 +1,32 @@ +#include "validation/firmware/ResourceAllocation.hpp" +#include "infra/util/ReallyAssert.hpp" + +namespace validation +{ + services::HilStatus ResourceAllocation::Claim(Resource resource, uint8_t index, services::HilOwner owner) + { + auto& slot = Slot(resource, index); + + if (slot && *slot != owner) + return services::HilStatus::busy; + + slot = owner; + return services::HilStatus::done; + } + + void ResourceAllocation::Release(Resource resource, uint8_t index, services::HilOwner owner) + { + auto& slot = Slot(resource, index); + + if (slot == owner) + slot = std::nullopt; + } + + std::optional& ResourceAllocation::Slot(Resource resource, uint8_t index) + { + auto row = static_cast(resource); + really_assert(row < owners.size() && index < indices); + + return owners[row][index]; + } +} diff --git a/validation/firmware/ResourceAllocation.hpp b/validation/firmware/ResourceAllocation.hpp new file mode 100644 index 00000000..3d08d8e2 --- /dev/null +++ b/validation/firmware/ResourceAllocation.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "services/hil/HilPinPool.hpp" +#include "services/hil/HilStatus.hpp" +#include +#include +#include +#include + +namespace validation +{ + enum class Resource : uint8_t + { + lpTimer, + spi, + i2c, + adc, + dma1, + dma2, + hsem, + }; + + // Several command groups reach the same peripheral instance or DMA channel; this keeps them from sharing one + class ResourceAllocation + { + public: + static constexpr std::size_t indices = 16; + + services::HilStatus Claim(Resource resource, uint8_t index, services::HilOwner owner); + void Release(Resource resource, uint8_t index, services::HilOwner owner); + + private: + std::optional& Slot(Resource resource, uint8_t index); + + private: + std::array, indices>, static_cast(Resource::hsem) + 1> owners; + }; +} diff --git a/validation/firmware/RngGroup.cpp b/validation/firmware/RngGroup.cpp new file mode 100644 index 00000000..cbb5f315 --- /dev/null +++ b/validation/firmware/RngGroup.cpp @@ -0,0 +1,327 @@ +#include "validation/firmware/RngGroup.hpp" +#include "infra/util/Endian.hpp" +#include "validation/firmware/Payload.hpp" +#include +#include +#include +#include +#include DEVICE_HEADER +#if defined(STM32WBA) +#include "stm32wbaxx_ll_rng.h" +#elif defined(STM32WB) +#include "hal_st/synchronous_stm32fxxx/SynchronousSynchronizedRandomDataGeneratorStm.hpp" +#include "stm32wbxx_ll_rng.h" +#include "validation/firmware/HsemMaster.hpp" +#include "validation/firmware/Owners.hpp" +#endif + +namespace validation +{ + namespace + { + using services::HilChoice; + using services::HilStatus; + using Variant = RngCommands::Variant; + + constexpr std::array readKeys{ { "variant", "lock5" } }; + constexpr std::array statsKeys{ { "variant" } }; + + constexpr std::array, 3> variants{ { + { "sync", Variant::sync }, + { "async", Variant::async }, + { "hsem", Variant::hsem }, + } }; + + constexpr uint32_t minimumStats = 16; + constexpr uint32_t maximumStats = 65536; + constexpr std::chrono::milliseconds readTimeout{ 1000 }; + constexpr std::chrono::milliseconds statsTimeout{ 5000 }; + +#if defined(STM32WB) + bool RngKernelClockStopped() + { + return __HAL_RCC_GET_RNG_SOURCE() == RCC_RNGCLKSOURCE_CLK48 && LL_RCC_GetCLK48ClockSource(LL_RCC_CLK48_CLKSOURCE) == LL_RCC_CLK48_CLKSOURCE_HSI48 && LL_RCC_HSI48_IsReady() == 0; + } +#endif + } + + RngCommands::RngCommands(services::HilContext& context, ResourceAllocation& resources) + : services::TerminalCommands(context.terminal) + , context(context) + , resources(resources) + , pending(context.response) + , commands{ { + services::HilBind("rng.read", " [variant=sync|async|hsem] [lock5=0|1]", *this, context.response), + services::HilBind("rng.stats", " [variant=sync|async|hsem]", *this, context.response), + } } + {} + + infra::MemoryRange RngCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus RngCommands::Read(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, infra::MakeRange(readKeys))) + return HilStatus::usage; + + Request newRequest; + HilStatus status = HilStatus::done; + arguments.NumberAt(0, newRequest.length, 1, maximumHexOutput, status); + arguments.Select("variant", newRequest.variant, variants, status); + arguments.Flag("lock5", newRequest.lockClock, status); + if (status != HilStatus::done) + return status; + + return Run(newRequest); + } + + HilStatus RngCommands::Stats(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, infra::MakeRange(statsKeys))) + return HilStatus::usage; + + Request newRequest; + newRequest.job = Job::stats; + HilStatus status = HilStatus::done; + arguments.NumberAt(0, newRequest.length, minimumStats, maximumStats, status); + arguments.Select("variant", newRequest.variant, variants, status); + if (status != HilStatus::done) + return status; + + return Run(newRequest); + } + + HilStatus RngCommands::Check(const Request& candidate) const + { +#if !defined(STM32WB) + if (candidate.variant == Variant::hsem || candidate.lockClock) + return HilStatus::unsupported; +#endif + + if (candidate.lockClock && candidate.variant != Variant::hsem) + return HilStatus::usage; + +#if defined(STM32WB) + // Only Hsi48Enabler (variant=hsem) starts HSI48; the plain drivers would abort on the clock error or wait forever for data + if (candidate.variant != Variant::hsem && RngKernelClockStopped()) + return HilStatus::failed; +#endif + + return HilStatus::done; + } + + HilStatus RngCommands::Reserve() + { + if (!pending.Busy()) + return HilStatus::done; + + if (infra::Now() < deadline) + return HilStatus::busy; + + // A run whose ERR timeout went out may never complete; drop it so the RNG serves the next command + pending.Cancel(); + operation = 0; + LL_RNG_DisableIT(RNG); + asyncGenerator.reset(); + return HilStatus::done; + } + + HilStatus RngCommands::Run(const Request& newRequest) + { + auto status = Check(newRequest); + if (status == HilStatus::done) + status = Reserve(); + if (status != HilStatus::done) + return status; + + request = newRequest; + remaining = request.length; + statistics.Reset(); + + if (request.variant != Variant::async) + return RunSynchronous(); + + StartAsynchronous(); + return HilStatus::done; + } + + HilStatus RngCommands::RunSynchronous() + { +#if defined(STM32WB) + if (request.variant == Variant::hsem) + { + auto status = resources.Claim(Resource::hsem, 0, owners::scaffold); + if (status != HilStatus::done) + return status; + + status = GenerateSynchronized(); + resources.Release(Resource::hsem, 0, owners::scaffold); + if (status != HilStatus::done) + return status; + + Finish(); + return HilStatus::done; + } +#endif + + hal::SynchronousRandomDataGeneratorStm generator; + Generate(generator); + Finish(); + return HilStatus::done; + } + +#if defined(STM32WB) + HilStatus RngCommands::GenerateSynchronized() + { + constexpr auto clockSemaphore = hal::Semaphore::recoveryAndIndependentClockConfiguration; + auto& master = HsemMaster(); + + // The driver spins on semaphore 0 with the event loop blocked: one this core already holds would never be freed + if (master.IsLockedByCurrentCore(hal::Semaphore::randomNumberGenerator)) + return HilStatus::busy; + + // With semaphore 5 locked by this core (any process), Hsi48Enabler asserts that HSI48 runs instead of starting it + if ((request.lockClock || master.IsLockedByCurrentCore(clockSemaphore)) && LL_RCC_HSI48_IsReady() == 0) + return HilStatus::failed; + + if (request.lockClock && HAL_HSEM_FastTake(static_cast(clockSemaphore)) != HAL_OK) + return HilStatus::busy; + + hal::SynchronousSynchronizedRandomDataGeneratorStm generator{ creator, master }; + Generate(generator); + + if (request.lockClock) + HAL_HSEM_Release(static_cast(clockSemaphore), 0); + + return HilStatus::done; + } +#endif + + void RngCommands::Generate(hal::SynchronousRandomDataGenerator& generator) + { + stopwatch.Start(); + + while (remaining != 0) + { + auto chunk = NextChunk(); + generator.GenerateRandomData(chunk); + Consume(chunk); + } + } + + void RngCommands::StartAsynchronous() + { + auto timeout = request.job == Job::read ? readTimeout : statsTimeout; + asyncGenerator.emplace(); + operation = pending.Start(timeout); + deadline = infra::Now() + timeout; + stopwatch.Start(); + RequestChunk(); + } + + void RngCommands::RequestChunk() + { + asyncGenerator->GenerateRandomData(NextChunk(), [this, chunkOperation = operation]() + { + ChunkDone(chunkOperation); + }); + } + + void RngCommands::ChunkDone(uint32_t chunkOperation) + { + if (chunkOperation != operation) + return; + + Consume(infra::Head(infra::MakeRange(buffer), chunkSize)); + + if (remaining != 0) + { + RequestChunk(); + return; + } + + operation = 0; + asyncGenerator.reset(); + + if (pending.Complete(chunkOperation)) + Finish(); + } + + infra::ByteRange RngCommands::NextChunk() + { + chunkSize = std::min(remaining, buffer.size()); + return infra::Head(infra::MakeRange(buffer), chunkSize); + } + + void RngCommands::Consume(infra::ConstByteRange chunk) + { + if (request.job == Job::stats) + statistics.Add(chunk); + + remaining -= chunk.size(); + } + + void RngCommands::Finish() + { + auto elapsed = stopwatch.ElapsedUs(); + auto line = context.response.Ok(); + + if (request.job == Job::read) + { + PrintData(line, infra::Head(infra::MakeRange(buffer), request.length), Output::hex); +#if defined(STM32WB) + if (request.variant == Variant::hsem) + line << " hsi48=" << static_cast(LL_RCC_HSI48_IsReady() != 0 ? 1 : 0); +#endif + return; + } + + infra::BigEndian crc{ statistics.crc.Result() }; + line << " n=" << statistics.bytes << " ones=" << statistics.ones << " runs=" << statistics.transitions + 1 << " chisq=" << statistics.ChiSquareTimes1000() << " crc="; + line.Hex(infra::MakeByteRange(crc)); + line << " us=" << elapsed; + } + + void RngCommands::Statistics::Reset() + { + bytes = 0; + ones = 0; + transitions = 0; + last = 0; + histogram.fill(0); + crc.Reset(); + } + + void RngCommands::Statistics::Add(infra::ConstByteRange data) + { + for (auto byte : data) + { + if (bytes != 0) + transitions += (last ^ (byte >> 7)) & 1u; + + transitions += static_cast(std::popcount(static_cast((byte ^ (byte >> 1)) & 0x7f))); + ones += static_cast(std::popcount(byte)); + ++histogram[byte]; + last = byte; + ++bytes; + } + + crc.Update(data); + } + + uint32_t RngCommands::Statistics::ChiSquareTimes1000() const + { + uint64_t sum = 0; + + for (auto count : histogram) + { + auto difference = static_cast(count) * static_cast(histogram.size()) - static_cast(bytes); + sum += static_cast(difference * difference); + } + + auto value = sum * 1000 / (static_cast(histogram.size()) * bytes); + return static_cast(std::min(value, std::numeric_limits::max())); + } +} diff --git a/validation/firmware/RngGroup.hpp b/validation/firmware/RngGroup.hpp new file mode 100644 index 00000000..5c91d45f --- /dev/null +++ b/validation/firmware/RngGroup.hpp @@ -0,0 +1,97 @@ +#pragma once + +#include "hal_st/stm32fxxx/RandomDataGeneratorStm.hpp" +#include "hal_st/synchronous_stm32fxxx/SynchronousRandomDataGeneratorStm.hpp" +#include "infra/timer/Timer.hpp" +#include "infra/util/Crc.hpp" +#include "infra/util/ProxyCreator.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilPendingOperation.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include "validation/firmware/Stopwatch.hpp" +#include +#include +#include + +namespace validation +{ + class RngCommands + : public services::TerminalCommands + { + public: + enum class Variant : uint8_t + { + sync, + async, + hsem, + }; + + RngCommands(services::HilContext& context, ResourceAllocation& resources); + + infra::MemoryRange Commands() override; + + private: + enum class Job : uint8_t + { + read, + stats, + }; + + struct Request + { + Job job = Job::read; + uint32_t length = 0; + Variant variant = Variant::sync; + bool lockClock = false; + }; + + struct Statistics + { + void Reset(); + void Add(infra::ConstByteRange data); + uint32_t ChiSquareTimes1000() const; + + uint32_t bytes = 0; + uint32_t ones = 0; + uint32_t transitions = 0; + uint8_t last = 0; + std::array histogram{}; + infra::Crc32 crc; + }; + + services::HilStatus Read(const services::HilArguments& arguments); + services::HilStatus Stats(const services::HilArguments& arguments); + services::HilStatus Check(const Request& request) const; + services::HilStatus Reserve(); + services::HilStatus Run(const Request& request); + services::HilStatus RunSynchronous(); +#if defined(STM32WB) + services::HilStatus GenerateSynchronized(); +#endif + void Generate(hal::SynchronousRandomDataGenerator& generator); + void StartAsynchronous(); + void RequestChunk(); + void ChunkDone(uint32_t chunkOperation); + infra::ByteRange NextChunk(); + void Consume(infra::ConstByteRange chunk); + void Finish(); + + private: + services::HilContext& context; + ResourceAllocation& resources; + services::HilPendingOperation pending; + std::optional asyncGenerator; +#if defined(STM32WB) + infra::Creator creator; +#endif + Stopwatch stopwatch; + Request request; + Statistics statistics; + std::array buffer{}; + uint32_t remaining = 0; + uint32_t chunkSize = 0; + uint32_t operation = 0; + infra::TimePoint deadline; + std::array commands; + }; +} diff --git a/validation/firmware/SpiFactory.cpp b/validation/firmware/SpiFactory.cpp index 55645508..eca5bdf0 100644 --- a/validation/firmware/SpiFactory.cpp +++ b/validation/firmware/SpiFactory.cpp @@ -2,6 +2,7 @@ #include "BoardProfile.hpp" #include "generated/stm32fxxx/PeripheralTable.hpp" #include "infra/event/EventDispatcher.hpp" +#include "validation/firmware/PeripheralClocks.hpp" #include "validation/firmware/PinFactoryStm.hpp" #include #include @@ -17,7 +18,11 @@ namespace validation // Longer than the slowest 64-byte transfer (2.05 ms at 250 kHz on WB55); one still running by then never completes constexpr infra::Duration quiesceTimeout = std::chrono::milliseconds(10); - constexpr std::array openKeys{ { "clk", "mosi", "miso", "cs", "baud", "mode", "dma", "sync" } }; + constexpr std::array openKeys{ { "clk", "mosi", "miso", "cs", "baud", "mode", "dma", "sync", "bits", "lsb", "nss" } }; + + constexpr uint32_t minimumBits = 4; + constexpr uint32_t maximumBits = 16; + constexpr uint32_t defaultBits = 8; constexpr std::array baudRatePrescalers{ { SPI_BAUDRATEPRESCALER_2, @@ -30,11 +35,6 @@ namespace validation SPI_BAUDRATEPRESCALER_256, } }; - bool InstanceExists(uint8_t index) - { - return index >= 1 && index <= hal::peripheralSpi.size() && hal::peripheralSpi[index - 1] != nullptr; - } - std::optional BaudRatePrescaler(uint32_t kernelClock, uint32_t baud) { const auto rate = static_cast(baud); @@ -52,6 +52,14 @@ namespace validation { return SupportsFunction(clock, hal::PinConfigTypeStm::spiClock, index) && SupportsFunction(mosi, hal::PinConfigTypeStm::spiMosi, index) && SupportsFunction(miso, hal::PinConfigTypeStm::spiMiso, index); } + + bool DataSizeSupported(uint8_t index, uint32_t bits, bool dma) + { + if (bits == defaultBits) + return true; + + return dma && (!SpiLimited(index) || bits == 16); + } } template @@ -61,6 +69,7 @@ namespace validation config.polarityLow = (request.mode & 2) == 0; config.phase1st = (request.mode & 1) == 0; config.baudRatePrescaler = request.baudRatePrescaler; + config.msbFirst = !request.lsb; return config; } @@ -101,9 +110,10 @@ namespace validation return busy; } - SpiFactoryStm::SpiFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma) + SpiFactoryStm::SpiFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma, ResourceAllocation& resources) : naming(naming) , dma(dma) + , resources(resources) {} uint8_t SpiFactoryStm::Instances() const @@ -131,9 +141,12 @@ namespace validation ClaimedPins claimed; status = Claim(index, request, pins, claimed); + if (status == HilStatus::done) + status = resources.Claim(Resource::spi, index, services::HilOwners::spi); if (status != HilStatus::done) return status; + this->index = index; Construct(index, request, claimed, handle); return HilStatus::done; } @@ -169,6 +182,10 @@ namespace validation void SpiFactoryStm::Destroy() { + if (auto spi = std::get_if(&driver); spi != nullptr && dataSize) + spi->ResetCommunicationConfigurator(); + dataSize.reset(); + // The driver goes first: an end-of-transfer interrupt must not reach a destroyed chip-select wrapper driver.emplace(); tracked.reset(); @@ -176,12 +193,13 @@ namespace validation synchronousChipSelect.reset(); receiveStream.reset(); transmitStream.reset(); + resources.Release(Resource::spi, index, services::HilOwners::spi); onClosed(); } HilStatus SpiFactoryStm::Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const { - if (!InstanceExists(index)) + if (!SpiExists(index)) return HilStatus::range; const HilStatus status = Parse(arguments, request); @@ -191,7 +209,7 @@ namespace validation if (!request.clock || !request.mosi || !request.miso) return HilStatus::usage; - if (request.dma && request.synchronous) + if ((request.dma && request.synchronous) || (request.slaveSelect && request.chipSelect)) return HilStatus::usage; const auto prescaler = BaudRatePrescaler(board::SpiKernelClock(index), request.baud); @@ -203,12 +221,18 @@ namespace validation if (!PinsSupportFunctions(index, *request.clock, *request.mosi, *request.miso)) return HilStatus::pin; + if (request.slaveSelect && !SupportsFunction(*request.slaveSelect, hal::PinConfigTypeStm::spiSlaveSelect, index)) + return HilStatus::pin; + if (request.chipSelect && !IsBonded(*request.chipSelect)) return HilStatus::pin; if (request.dma && !board::SpiDma(index)) return HilStatus::unsupported; + if (!DataSizeSupported(index, request.bits, request.dma)) + return HilStatus::unsupported; + return HilStatus::done; } @@ -219,10 +243,13 @@ namespace validation arguments.Pin("mosi", naming, request.mosi, status); arguments.Pin("miso", naming, request.miso, status); arguments.Pin("cs", naming, request.chipSelect, status); + arguments.Pin("nss", naming, request.slaveSelect, status); arguments.Number("baud", request.baud, 1, std::numeric_limits::max(), status); arguments.Number("mode", request.mode, 0, 3, status); + arguments.Number("bits", request.bits, minimumBits, maximumBits, status); arguments.Flag("dma", request.dma, status); arguments.Flag("sync", request.synchronous, status); + arguments.Flag("lsb", request.lsb, status); return status; } @@ -235,6 +262,8 @@ namespace validation status = pins.ClaimFunction(request.miso, Function(hal::PinConfigTypeStm::spiMiso), index, claimed.miso); if (status == HilStatus::done && request.chipSelect) status = pins.Claim(*request.chipSelect, services::HilPinPool::Use::exclusive, claimed.chipSelect); + if (status == HilStatus::done && request.slaveSelect) + status = pins.ClaimFunction(request.slaveSelect, Function(hal::PinConfigTypeStm::spiSlaveSelect), index, claimed.slaveSelect); return status; } @@ -243,7 +272,7 @@ namespace validation { if (request.synchronous) { - auto& spi = driver.emplace(index, PinOrDummy(claimed.clock), PinOrDummy(claimed.miso), PinOrDummy(claimed.mosi), MakeConfig(request)); + auto& spi = driver.emplace(index, PinOrDummy(claimed.clock), PinOrDummy(claimed.miso), PinOrDummy(claimed.mosi), MakeConfig(request), PinOrDummy(claimed.slaveSelect)); if (claimed.chipSelect != nullptr) handle.synchronous = &synchronousChipSelect.emplace(spi, *claimed.chipSelect); @@ -267,12 +296,17 @@ namespace validation hal::SpiMaster& SpiFactoryStm::ConstructAsynchronous(uint8_t index, const Request& request, const ClaimedPins& claimed) { if (!request.dma) - return driver.emplace(index, PinOrDummy(claimed.clock), PinOrDummy(claimed.miso), PinOrDummy(claimed.mosi), MakeConfig(request)); + return driver.emplace(index, PinOrDummy(claimed.clock), PinOrDummy(claimed.miso), PinOrDummy(claimed.mosi), MakeConfig(request), PinOrDummy(claimed.slaveSelect)); const DmaRequests requests = *board::SpiDma(index); transmitStream.emplace(dma, hal::DmaChannelId(1, board::spiDmaChannel, requests.transmit)); receiveStream.emplace(dma, hal::DmaChannelId(1, static_cast(board::spiDmaChannel + 1), requests.receive)); - return driver.emplace(*transmitStream, *receiveStream, index, PinOrDummy(claimed.clock), PinOrDummy(claimed.miso), PinOrDummy(claimed.mosi), MakeConfig(request)); + auto& spi = driver.emplace(*transmitStream, *receiveStream, index, PinOrDummy(claimed.clock), PinOrDummy(claimed.miso), PinOrDummy(claimed.mosi), MakeConfig(request), PinOrDummy(claimed.slaveSelect)); + + if (request.bits != defaultBits) + spi.SetCommunicationConfigurator(dataSize.emplace(spi, request.bits)); + + return spi; } } diff --git a/validation/firmware/SpiFactory.hpp b/validation/firmware/SpiFactory.hpp index 33fa7a56..cd6f2245 100644 --- a/validation/firmware/SpiFactory.hpp +++ b/validation/firmware/SpiFactory.hpp @@ -1,6 +1,7 @@ #pragma once #include "hal_st/stm32fxxx/DmaStm.hpp" +#include "hal_st/stm32fxxx/SpiDataSizeConfiguratorStm.hpp" #include "hal_st/stm32fxxx/SpiMasterStm.hpp" #include "hal_st/stm32fxxx/SpiMasterStmDma.hpp" #include "hal_st/synchronous_stm32fxxx/SynchronousSpiMasterStm.hpp" @@ -10,6 +11,7 @@ #include "services/peripheral/SpiMasterWithChipSelect.hpp" #include "services/synchronous_peripheral/SynchronousSpiMasterWithChipSelect.hpp" #include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/ResourceAllocation.hpp" #include #include #include @@ -20,7 +22,7 @@ namespace validation : public services::HilSpiFactory { public: - SpiFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma); + SpiFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma, ResourceAllocation& resources); uint8_t Instances() const override; infra::MemoryRange OpenKeys() const override; @@ -35,10 +37,13 @@ namespace validation std::optional mosi; std::optional miso; std::optional chipSelect; + std::optional slaveSelect; uint32_t baud = 1000000; uint32_t mode = 0; + uint32_t bits = 8; bool dma = false; bool synchronous = false; + bool lsb = false; uint32_t baudRatePrescaler = SPI_BAUDRATEPRESCALER_2; }; @@ -48,6 +53,7 @@ namespace validation hal::GpioPin* mosi = nullptr; hal::GpioPin* miso = nullptr; hal::GpioPin* chipSelect = nullptr; + hal::GpioPin* slaveSelect = nullptr; }; class TrackedSpiMaster @@ -84,9 +90,12 @@ namespace validation private: const services::HilPinNaming& naming; hal::DmaStm& dma; + ResourceAllocation& resources; + uint8_t index = 0; std::optional transmitStream; std::optional receiveStream; std::variant driver; + std::optional dataSize; std::optional chipSelect; std::optional synchronousChipSelect; std::optional tracked; diff --git a/validation/firmware/SpiSlaveGroup.cpp b/validation/firmware/SpiSlaveGroup.cpp new file mode 100644 index 00000000..5e151a2f --- /dev/null +++ b/validation/firmware/SpiSlaveGroup.cpp @@ -0,0 +1,332 @@ +#include "validation/firmware/SpiSlaveGroup.hpp" +#include "BoardProfile.hpp" +#include "infra/event/EventDispatcher.hpp" +#include "infra/util/ReallyAssert.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include +#include + +namespace validation +{ + namespace + { + using services::HilStatus; + + constexpr uint8_t instances = 4; + constexpr uint32_t defaultWaitMs = 1000; + constexpr uint32_t maximumWaitMs = 10000; + + constexpr std::array openKeys{ { "clk", "miso", "mosi", "nss" } }; + constexpr std::array armKeys{ { "rx", "len", "pattern", "seed" } }; + constexpr std::array resultKeys{ { "wait", "out" } }; + + bool PinsSupportFunctions(uint8_t index, HilPinId clock, HilPinId miso, HilPinId mosi, HilPinId slaveSelect) + { + return SupportsFunction(clock, hal::PinConfigTypeStm::spiClock, index) && SupportsFunction(miso, hal::PinConfigTypeStm::spiMiso, index) && SupportsFunction(mosi, hal::PinConfigTypeStm::spiMosi, index) && SupportsFunction(slaveSelect, hal::PinConfigTypeStm::spiSlaveSelect, index); + } + + Resource DmaResource(DmaChannel channel) + { + return channel.dma == 1 ? Resource::dma1 : Resource::dma2; + } + } + + SpiSlaveFactory::SpiSlaveFactory(const services::HilPinNaming& naming, hal::DmaStm& dma, ResourceAllocation& resources) + : naming(naming) + , dma(dma) + , resources(resources) + {} + + uint8_t SpiSlaveFactory::Instances() const + { + return instances; + } + + infra::MemoryRange SpiSlaveFactory::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus SpiSlaveFactory::Prepare(uint8_t index, const services::HilArguments& arguments) + { + Request request; + return Evaluate(index, arguments, request); + } + + void SpiSlaveFactory::Close(uint8_t, const infra::Function& onClosed) + { + this->onClosed = onClosed; + + // Stops both DMA channels: a channel left enabled keeps its count for the next open on STM32WB + slave->CancelTransmission(); + + // A DMA completion dispatched before the cancel still runs against the driver + infra::EventDispatcher::Instance().Schedule([this]() + { + Destroy(); + }); + } + + HilStatus SpiSlaveFactory::Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) + { + Request request; + ClaimedPins claimed; + + HilStatus status = Evaluate(index, arguments, request); + if (status == HilStatus::done) + status = Claim(index, request, pins, claimed); + if (status != HilStatus::done) + return status; + + const DmaRequests requests = *board::SpiDma(index); + transmitStream.emplace(dma, hal::DmaChannelId(board::spiSlaveDma.transmit.dma, board::spiSlaveDma.transmit.channel, requests.transmit)); + receiveStream.emplace(dma, hal::DmaChannelId(board::spiSlaveDma.receive.dma, board::spiSlaveDma.receive.channel, requests.receive)); + slave.emplace(*transmitStream, *receiveStream, index, PinOrDummy(claimed.clock), PinOrDummy(claimed.miso), PinOrDummy(claimed.mosi), PinOrDummy(claimed.slaveSelect)); + return HilStatus::done; + } + + hal::SpiSlave& SpiSlaveFactory::Slave() + { + really_assert(slave.has_value()); + return *slave; + } + + HilStatus SpiSlaveFactory::Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const + { + if (!SpiExists(index)) + return HilStatus::range; + + HilStatus status = HilStatus::done; + arguments.Pin("clk", naming, request.clock, status); + arguments.Pin("miso", naming, request.miso, status); + arguments.Pin("mosi", naming, request.mosi, status); + arguments.Pin("nss", naming, request.slaveSelect, status); + if (status != HilStatus::done) + return status; + + if (!request.clock || !request.miso || !request.mosi || !request.slaveSelect) + return HilStatus::usage; + + if (!PinsSupportFunctions(index, *request.clock, *request.miso, *request.mosi, *request.slaveSelect)) + return HilStatus::pin; + + if (!board::SpiDma(index)) + return HilStatus::unsupported; + + return HilStatus::done; + } + + HilStatus SpiSlaveFactory::Claim(uint8_t index, const Request& request, services::HilPinOwner& pins, ClaimedPins& claimed) + { + HilStatus status = pins.ClaimFunction(request.clock, Function(hal::PinConfigTypeStm::spiClock), index, claimed.clock); + if (status == HilStatus::done) + status = pins.ClaimFunction(request.miso, Function(hal::PinConfigTypeStm::spiMiso), index, claimed.miso); + if (status == HilStatus::done) + status = pins.ClaimFunction(request.mosi, Function(hal::PinConfigTypeStm::spiMosi), index, claimed.mosi); + if (status == HilStatus::done) + status = pins.ClaimFunction(request.slaveSelect, Function(hal::PinConfigTypeStm::spiSlaveSelect), index, claimed.slaveSelect); + if (status != HilStatus::done) + return status; + + this->index = index; + status = resources.Claim(Resource::spi, index, owners::spiSlave); + if (status == HilStatus::done) + status = ClaimChannel(board::spiSlaveDma.transmit); + if (status == HilStatus::done) + status = ClaimChannel(board::spiSlaveDma.receive); + if (status != HilStatus::done) + ReleaseResources(); + + return status; + } + + HilStatus SpiSlaveFactory::ClaimChannel(DmaChannel channel) + { + return resources.Claim(DmaResource(channel), channel.channel, owners::spiSlave); + } + + void SpiSlaveFactory::ReleaseResources() + { + resources.Release(Resource::spi, index, owners::spiSlave); + resources.Release(DmaResource(board::spiSlaveDma.transmit), board::spiSlaveDma.transmit.channel, owners::spiSlave); + resources.Release(DmaResource(board::spiSlaveDma.receive), board::spiSlaveDma.receive.channel, owners::spiSlave); + } + + void SpiSlaveFactory::Destroy() + { + slave.reset(); + receiveStream.reset(); + transmitStream.reset(); + ReleaseResources(); + onClosed(); + } + + SpiSlaveCommands::SpiSlaveCommands(services::HilContext& context, SpiSlaveFactory& factory) + : services::HilSingleInstanceGroup(context, factory, owners::spiSlave) + , factory(factory) + , commands{ { + OpenCommand("spis.open", " clk= miso= mosi= nss="), + services::HilBind("spis.arm", " [rx=] [len=] [pattern=] [seed=]", *this, context.response), + services::HilBind("spis.result", " [wait=] [out=hex|crc]", *this, context.response), + services::HilBind("spis.cancel", "", *this, context.response), + CloseCommand("spis.close", ""), + } } + {} + + infra::MemoryRange SpiSlaveCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus SpiSlaveCommands::OpenInstance(uint8_t index, const services::HilArguments& arguments) + { + armed = false; + done = false; + receiveSize = 0; + return factory.Open(index, arguments, Pins()); + } + + void SpiSlaveCommands::CloseInstance() + { + StopWaiting(); + armed = false; + done = false; + } + + HilStatus SpiSlaveCommands::Arm(const services::HilArguments& arguments) + { + if (!arguments.Shape(2, 2, infra::MakeRange(armKeys))) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done) + return status; + + // The armed transfer's transmit DMA still reads transmitBuffer + if (armed) + return HilStatus::busy; + + infra::ByteRange send; + status = ParsePayload(arguments, 1, infra::MakeRange(transmitBuffer), send); + auto receive = static_cast(send.size()); + arguments.Number("rx", receive, 0, static_cast(capacity), status); + if (status != HilStatus::done) + return status; + + // SpiSlaveStmDma runs full duplex on equal lengths only + if ((send.empty() && receive == 0) || (!send.empty() && receive != 0 && receive != send.size())) + return HilStatus::usage; + + armed = true; + done = false; + receiveSize = receive; + std::fill(receiveBuffer.begin(), receiveBuffer.end(), 0); + + factory.Slave().SendAndReceive(send, infra::Head(infra::MakeRange(receiveBuffer), receiveSize), [this]() + { + TransferDone(); + }); + + Context().response.Ok(); + return HilStatus::done; + } + + HilStatus SpiSlaveCommands::Result(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, infra::MakeRange(resultKeys))) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done) + return status; + + auto requested = Output::hex; + uint32_t wait = defaultWaitMs; + status = ParseOutput(arguments, requested); + arguments.Number("wait", wait, 0, maximumWaitMs, status); + if (status == HilStatus::done) + status = CheckOutput(receiveSize, requested); + if (status != HilStatus::done) + return status; + + if (waiting) + return HilStatus::busy; + + output = requested; + + if (done) + Report(); + else if (!armed || wait == 0) + Context().response.Ok() << " done=0"; + else + { + // HilPendingOperation answers an expired wait with ERR timeout; a transfer still pending is a valid answer here + waiting = true; + waitTimer.Start(std::chrono::milliseconds(wait), [this]() + { + waiting = false; + Context().response.Ok() << " done=0"; + }); + } + + return HilStatus::done; + } + + HilStatus SpiSlaveCommands::Cancel(const services::HilArguments& arguments) + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done) + return status; + + StopWaiting(); + const bool cancelled = factory.Slave().CancelTransmission(); + armed = false; + done = false; + + Context().response.Ok() << " cancelled=" << static_cast(cancelled ? 1 : 0); + return HilStatus::done; + } + + void SpiSlaveCommands::TransferDone() + { + armed = false; + done = true; + + if (waiting) + { + waitTimer.Cancel(); + waiting = false; + Report(); + } + } + + void SpiSlaveCommands::Report() + { + const infra::ConstByteRange data = infra::Head(infra::MakeRange(receiveBuffer), receiveSize); + auto line = Context().response.Ok(); + line << " done=1"; + + if (output == Output::hex) + { + line << " rx="; + line.Hex(data); + } + else + PrintData(line, data, Output::crc); + } + + void SpiSlaveCommands::StopWaiting() + { + if (!waiting) + return; + + waitTimer.Cancel(); + waiting = false; + Context().response.Ok() << " done=0"; + } +} diff --git a/validation/firmware/SpiSlaveGroup.hpp b/validation/firmware/SpiSlaveGroup.hpp new file mode 100644 index 00000000..6f1519cd --- /dev/null +++ b/validation/firmware/SpiSlaveGroup.hpp @@ -0,0 +1,102 @@ +#pragma once + +#include "hal_st/stm32fxxx/DmaStm.hpp" +#include "hal_st/stm32fxxx/SpiSlaveStmDma.hpp" +#include "infra/timer/Timer.hpp" +#include "infra/util/AutoResetFunction.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilSingleInstanceGroup.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/Payload.hpp" +#include "validation/firmware/ResourceAllocation.hpp" +#include +#include +#include +#include + +namespace validation +{ + class SpiSlaveFactory + : public services::HilInstanceFactory + { + public: + SpiSlaveFactory(const services::HilPinNaming& naming, hal::DmaStm& dma, ResourceAllocation& resources); + + uint8_t Instances() const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + void Close(uint8_t index, const infra::Function& onClosed) override; + + services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins); + hal::SpiSlave& Slave(); + + private: + struct Request + { + std::optional clock; + std::optional miso; + std::optional mosi; + std::optional slaveSelect; + }; + + struct ClaimedPins + { + hal::GpioPin* clock = nullptr; + hal::GpioPin* miso = nullptr; + hal::GpioPin* mosi = nullptr; + hal::GpioPin* slaveSelect = nullptr; + }; + + services::HilStatus Evaluate(uint8_t index, const services::HilArguments& arguments, Request& request) const; + services::HilStatus Claim(uint8_t index, const Request& request, services::HilPinOwner& pins, ClaimedPins& claimed); + services::HilStatus ClaimChannel(DmaChannel channel); + void ReleaseResources(); + void Destroy(); + + private: + const services::HilPinNaming& naming; + hal::DmaStm& dma; + ResourceAllocation& resources; + uint8_t index = 0; + std::optional transmitStream; + std::optional receiveStream; + std::optional slave; + infra::AutoResetFunction onClosed; + }; + + class SpiSlaveCommands + : public services::HilSingleInstanceGroup + { + public: + static constexpr std::size_t capacity = 1024; + + SpiSlaveCommands(services::HilContext& context, SpiSlaveFactory& factory); + + infra::MemoryRange Commands() override; + + protected: + services::HilStatus OpenInstance(uint8_t index, const services::HilArguments& arguments) override; + void CloseInstance() override; + + private: + services::HilStatus Arm(const services::HilArguments& arguments); + services::HilStatus Result(const services::HilArguments& arguments); + services::HilStatus Cancel(const services::HilArguments& arguments); + + void TransferDone(); + void Report(); + void StopWaiting(); + + private: + SpiSlaveFactory& factory; + bool armed = false; + bool done = false; + bool waiting = false; + Output output = Output::hex; + std::size_t receiveSize = 0; + infra::TimerSingleShot waitTimer; + std::array transmitBuffer{}; + std::array receiveBuffer{}; + std::array commands; + }; +} diff --git a/validation/firmware/Stopwatch.hpp b/validation/firmware/Stopwatch.hpp new file mode 100644 index 00000000..2cf689d4 --- /dev/null +++ b/validation/firmware/Stopwatch.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include "hal/cortex_m/DataWatchpointAndTrace.hpp" +#include +#include DEVICE_HEADER + +namespace validation +{ + // CYCCNT stops in Sleep (the core clock is gated), so a sleep window is timed with the scaffold timer instead + class Stopwatch + { + public: + Stopwatch(); + + void Start(); + uint32_t ElapsedUs() const; + + private: + hal::cortex::DataWatchpointAndTrace dwt; + uint32_t start = 0; + }; + + //// Implementation //// + + inline Stopwatch::Stopwatch() + { + // DataWatchpointAndTrace::Start() zeroes the one shared CYCCNT, which would corrupt another running stopwatch + static bool counting = false; + + if (!counting) + { + dwt.Start(); + counting = true; + } + } + + inline void Stopwatch::Start() + { + start = dwt.Cycles(); + } + + inline uint32_t Stopwatch::ElapsedUs() const + { + return static_cast(static_cast(dwt.Cycles() - start) * 1'000'000 / SystemCoreClock); + } +} diff --git a/validation/firmware/SyncGpioDriver.cpp b/validation/firmware/SyncGpioDriver.cpp new file mode 100644 index 00000000..19cb408f --- /dev/null +++ b/validation/firmware/SyncGpioDriver.cpp @@ -0,0 +1,62 @@ +#include "validation/firmware/SyncGpioDriver.hpp" +#include "hal_st/synchronous_stm32fxxx/SynchronousGpioStm.hpp" +#include "infra/util/ReallyAssert.hpp" +#include +#include + +namespace validation::syncgpio +{ + namespace + { + constexpr uint8_t ports = static_cast(hal::Port::I) + 1; + constexpr uint8_t speeds = static_cast(hal::Speed::High) + 1; + constexpr uint8_t pinsPerPort = 16; + constexpr uint8_t alternateFunctions = 16; + + std::array, outputSlots> outputs; + std::array, peripheralSlots> peripherals; + } + + bool Open(uint8_t slot, uint8_t port, uint8_t index, bool openDrain, uint8_t speed) + { + if (slot >= outputs.size() || outputs[slot] || port >= ports || index >= pinsPerPort || speed >= speeds) + return false; + + outputs[slot].emplace(static_cast(port), index, openDrain ? hal::Drive::OpenDrain : hal::Drive::PushPull, static_cast(speed)); + return true; + } + + void Set(uint8_t slot, bool value) + { + really_assert(slot < outputs.size() && outputs[slot]); + outputs[slot]->Set(value); + } + + bool Latch(uint8_t slot) + { + really_assert(slot < outputs.size() && outputs[slot]); + return outputs[slot]->GetOutputLatch(); + } + + void Close(uint8_t slot) + { + really_assert(slot < outputs.size()); + outputs[slot].reset(); + } + + bool OpenAf(uint8_t slot, uint8_t port, uint8_t index, uint8_t af) + { + if (slot >= peripherals.size() || peripherals[slot] || port >= ports || index >= pinsPerPort || af >= alternateFunctions) + return false; + + const hal::SmallPeripheralPinStm::Definition<1> definition{ { { { 0, static_cast(port), index, af } } }, hal::Drive::Default, hal::Speed::Default, hal::WeakPull::Default }; + peripherals[slot].emplace(definition); + return true; + } + + void CloseAf(uint8_t slot) + { + really_assert(slot < peripherals.size()); + peripherals[slot].reset(); + } +} diff --git a/validation/firmware/SyncGpioDriver.hpp b/validation/firmware/SyncGpioDriver.hpp new file mode 100644 index 00000000..f22535d2 --- /dev/null +++ b/validation/firmware/SyncGpioDriver.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +// SynchronousGpioStm.hpp redefines hal::Port, hal::Drive, hal::Speed and hal::WeakPull of GpioStm.hpp, so only +// SyncGpioDriver.cpp includes it and this interface passes plain numbers: port is the letter offset (A = 0) +namespace validation::syncgpio +{ + inline constexpr uint8_t outputSlots = 4; + inline constexpr uint8_t peripheralSlots = 4; + + bool Open(uint8_t slot, uint8_t port, uint8_t index, bool openDrain, uint8_t speed); + void Set(uint8_t slot, bool value); + bool Latch(uint8_t slot); + void Close(uint8_t slot); + + bool OpenAf(uint8_t slot, uint8_t port, uint8_t index, uint8_t af); + void CloseAf(uint8_t slot); +} diff --git a/validation/firmware/SyncGpioGroup.cpp b/validation/firmware/SyncGpioGroup.cpp new file mode 100644 index 00000000..d4d10d45 --- /dev/null +++ b/validation/firmware/SyncGpioGroup.cpp @@ -0,0 +1,371 @@ +#include "validation/firmware/SyncGpioGroup.hpp" +#include "BoardProfile.hpp" +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include "generated/stm32fxxx/PinoutTableDefault.hpp" +#include "infra/util/ReallyAssert.hpp" +#include "infra/util/Tokenizer.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" + +namespace validation +{ + namespace + { + using services::HilArguments; + using services::HilPinId; + using services::HilStatus; + + constexpr uint32_t maximumChannel = 4; + constexpr uint32_t maximumAlternateFunction = 15; + + constexpr std::array, 4> speeds{ { + { "low", 0 }, + { "medium", 1 }, + { "fast", 2 }, + { "high", 3 }, + } }; + + constexpr std::array channelFunctions{ { + hal::PinConfigTypeStm::timerChannel1, + hal::PinConfigTypeStm::timerChannel2, + hal::PinConfigTypeStm::timerChannel3, + hal::PinConfigTypeStm::timerChannel4, + } }; + + uint32_t MaximumTimer() + { + return static_cast(hal::peripheralTimer.size()); + } + + uint8_t LetterOffset(HilPinId pin) + { + return static_cast(board::portLetters[pin.port] - 'A'); + } + + std::optional TimerAlternateFunction(HilPinId pin, uint32_t timer, uint32_t channel) + { + if (!IsBonded(pin)) + return std::nullopt; + + for (const auto& subTable : hal::pinoutTableDefaultStm) + for (const auto& table : subTable) + if (table.pinConfigType == channelFunctions[channel - 1]) + for (const auto& position : table.pinPositions) + if (position.peripheralIndex == timer && position.port == PortOf(pin) && position.pin == pin.index) + return position.alternateFunction; + + return std::nullopt; + } + + template + std::optional FreeSlot(const std::array, N>& slots) + { + for (uint8_t slot = 0; slot != N; ++slot) + if (!slots[slot]) + return slot; + + return std::nullopt; + } + } + + SyncGpioCommands::SyncGpioCommands(services::HilContext& context) + : services::TerminalCommands(context.terminal) + , context(context) + , pins(context.pins, owners::syncGpio) + , commands{ { + services::HilBind("sgpio.out", " <0|1> [od=0|1] [speed=low|medium|fast|high]", *this, context.response), + services::HilBind("sgpio.latch", "", *this, context.response), + services::HilBind("sgpio.af", " timer= [ch=<1-4>] | af=<0-15>", *this, context.response), + services::HilBind("sgpio.multi", ",[,...] timer= [ch=<1-4>]", *this, context.response), + services::HilBind("sgpio.release", "", *this, context.response), + } } + {} + + infra::MemoryRange SyncGpioCommands::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus SyncGpioCommands::Out(const HilArguments& arguments) + { + if (!arguments.Shape(2, 2, { "od", "speed" })) + return HilStatus::usage; + + uint32_t value = 0; + bool openDrain = false; + uint8_t speed = 0; + HilPinId id{}; + HilStatus status = HilStatus::done; + arguments.NumberAt(1, value, 0, 1, status); + arguments.Flag("od", openDrain, status); + arguments.Select("speed", speed, speeds, status); + arguments.PinAt(0, context.naming, id, status); + if (status != HilStatus::done) + return status; + + auto slot = FindOutput(id); + if (slot) + { + if (!arguments.Has("od")) + openDrain = outputs[*slot]->openDrain; + if (!arguments.Has("speed")) + speed = outputs[*slot]->speed; + } + else + { + slot = FreeSlot(outputs); + hal::GpioPin* claimed = nullptr; + status = pins.Claim(id, services::HilPinPool::Use::exclusive, claimed); + if (status != HilStatus::done) + return status; + + if (!slot) + { + pins.Release(id); + return HilStatus::busy; + } + } + + auto& output = outputs[*slot]; + if (!output || openDrain != output->openDrain || speed != output->speed) + { + if (output) + syncgpio::Close(*slot); + + const bool opened = syncgpio::Open(*slot, LetterOffset(id), id.index, openDrain, speed); + really_assert(opened); + output = Output{ id, openDrain, speed }; + } + + syncgpio::Set(*slot, value != 0); + context.response.Ok(); + return HilStatus::done; + } + + HilStatus SyncGpioCommands::Latch(const HilArguments& arguments) + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + HilPinId id{}; + HilStatus status = HilStatus::done; + arguments.PinAt(0, context.naming, id, status); + if (status != HilStatus::done) + return status; + + const auto slot = FindOutput(id); + if (!slot) + return HilStatus::notOpen; + + context.response.Ok() << " value=" << static_cast(syncgpio::Latch(*slot) ? 1 : 0); + return HilStatus::done; + } + + HilStatus SyncGpioCommands::Af(const HilArguments& arguments) + { + if (!arguments.Shape(1, 1, { "timer", "ch", "af" })) + return HilStatus::usage; + + uint32_t timer = 0; + uint32_t channel = 1; + uint32_t alternateFunction = 0; + HilStatus status = HilStatus::done; + arguments.Number("timer", timer, 1, MaximumTimer(), status); + arguments.Number("ch", channel, 1, maximumChannel, status); + arguments.Number("af", alternateFunction, 0, maximumAlternateFunction, status); + if (status != HilStatus::done) + return status; + + const bool byTimer = arguments.Has("timer"); + if (byTimer == arguments.Has("af") || (arguments.Has("ch") && !byTimer)) + return HilStatus::usage; + + if (byTimer && !TimerExists(static_cast(timer))) + return HilStatus::range; + + HilPinId id{}; + arguments.PinAt(0, context.naming, id, status); + if (status != HilStatus::done) + return status; + + if (byTimer) + { + const auto found = TimerAlternateFunction(id, timer, channel); + if (!found) + return HilStatus::pin; + + alternateFunction = *found; + } + + const auto slot = FreeSlot(peripherals); + hal::GpioPin* claimed = nullptr; + status = pins.Claim(id, services::HilPinPool::Use::exclusive, claimed); + if (status != HilStatus::done) + return status; + + if (!slot) + { + pins.Release(id); + return HilStatus::busy; + } + + const bool opened = syncgpio::OpenAf(*slot, LetterOffset(id), id.index, static_cast(alternateFunction)); + really_assert(opened); + peripherals[*slot] = id; + context.response.Ok() << " af=" << alternateFunction; + return HilStatus::done; + } + + HilStatus SyncGpioCommands::Multi(const HilArguments& arguments) + { + if (!arguments.Shape(1, 1, { "timer", "ch" })) + return HilStatus::usage; + + uint32_t timer = 0; + uint32_t channel = 1; + HilStatus status = HilStatus::done; + arguments.Number("timer", timer, 1, MaximumTimer(), status); + arguments.Number("ch", channel, 1, maximumChannel, status); + if (status != HilStatus::done) + return status; + + if (!arguments.Has("timer")) + return HilStatus::usage; + + if (!TimerExists(static_cast(timer))) + return HilStatus::range; + + std::array ids{}; + std::size_t count = 0; + status = ParseMultiPins(arguments, ids, count); + if (status != HilStatus::done) + return status; + + const auto function = channelFunctions[channel - 1]; + for (std::size_t position = 0; position != count; ++position) + if (!SupportsFunction(ids[position], function, static_cast(timer))) + return HilStatus::pin; + + if (multiPeripheral) + return HilStatus::busy; + + for (std::size_t position = 0; position != count; ++position) + { + hal::GpioPin* claimed = nullptr; + status = pins.Claim(ids[position], services::HilPinPool::Use::exclusive, claimed); + if (status != HilStatus::done) + { + for (std::size_t held = 0; held != position; ++held) + pins.Release(ids[held]); + + return status; + } + + multiTable[position] = { PortOf(ids[position]), ids[position].index }; + } + + multiIds = ids; + multiCount = count; + multiPins.emplace(infra::MemoryRange>(multiTable.data(), multiTable.data() + count)); + multiPeripheral.emplace(*multiPins, function, static_cast(timer)); + context.response.Ok(); + return HilStatus::done; + } + + HilStatus SyncGpioCommands::Release(const HilArguments& arguments) + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + HilPinId id{}; + HilStatus status = HilStatus::done; + arguments.PinAt(0, context.naming, id, status); + if (status != HilStatus::done) + return status; + + if (const auto slot = FindOutput(id)) + { + syncgpio::Close(*slot); + outputs[*slot].reset(); + pins.Release(id); + } + else if (const auto peripheral = FindPeripheral(id)) + { + syncgpio::CloseAf(*peripheral); + peripherals[*peripheral].reset(); + pins.Release(id); + } + else if (InMulti(id)) + ReleaseMulti(); + else + return HilStatus::notOpen; + + context.response.Ok(); + return HilStatus::done; + } + + HilStatus SyncGpioCommands::ParseMultiPins(const HilArguments& arguments, std::array& ids, std::size_t& count) const + { + infra::Tokenizer tokens(arguments.Positional(0), ','); + count = tokens.Size(); + if (count == 0 || count > maximumMultiPins) + return HilStatus::usage; + + for (std::size_t position = 0; position != count; ++position) + { + const auto pin = HilArguments::ParsePin(tokens.Token(position), context.naming); + if (!pin) + return HilStatus::pin; + + for (std::size_t earlier = 0; earlier != position; ++earlier) + if (ids[earlier] == *pin) + return HilStatus::usage; + + ids[position] = *pin; + } + + return HilStatus::done; + } + + std::optional SyncGpioCommands::FindOutput(HilPinId pin) const + { + for (uint8_t slot = 0; slot != outputs.size(); ++slot) + if (outputs[slot] && outputs[slot]->pin == pin) + return slot; + + return std::nullopt; + } + + std::optional SyncGpioCommands::FindPeripheral(HilPinId pin) const + { + for (uint8_t slot = 0; slot != peripherals.size(); ++slot) + if (peripherals[slot] == pin) + return slot; + + return std::nullopt; + } + + bool SyncGpioCommands::InMulti(HilPinId pin) const + { + if (!multiPeripheral) + return false; + + for (std::size_t position = 0; position != multiCount; ++position) + if (multiIds[position] == pin) + return true; + + return false; + } + + void SyncGpioCommands::ReleaseMulti() + { + multiPeripheral.reset(); + multiPins.reset(); + + for (std::size_t position = 0; position != multiCount; ++position) + pins.Release(multiIds[position]); + + multiCount = 0; + } +} diff --git a/validation/firmware/SyncGpioGroup.hpp b/validation/firmware/SyncGpioGroup.hpp new file mode 100644 index 00000000..4c1c4115 --- /dev/null +++ b/validation/firmware/SyncGpioGroup.hpp @@ -0,0 +1,57 @@ +#pragma once + +#include "hal_st/stm32fxxx/GpioStm.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/HilPinPool.hpp" +#include "validation/firmware/SyncGpioDriver.hpp" +#include +#include +#include +#include +#include + +namespace validation +{ + class SyncGpioCommands + : public services::TerminalCommands + { + public: + static constexpr std::size_t maximumMultiPins = 4; + + explicit SyncGpioCommands(services::HilContext& context); + + infra::MemoryRange Commands() override; + + private: + struct Output + { + services::HilPinId pin; + bool openDrain; + uint8_t speed; + }; + + services::HilStatus Out(const services::HilArguments& arguments); + services::HilStatus Latch(const services::HilArguments& arguments); + services::HilStatus Af(const services::HilArguments& arguments); + services::HilStatus Multi(const services::HilArguments& arguments); + services::HilStatus Release(const services::HilArguments& arguments); + + services::HilStatus ParseMultiPins(const services::HilArguments& arguments, std::array& ids, std::size_t& count) const; + std::optional FindOutput(services::HilPinId pin) const; + std::optional FindPeripheral(services::HilPinId pin) const; + bool InMulti(services::HilPinId pin) const; + void ReleaseMulti(); + + private: + services::HilContext& context; + services::HilPinOwner pins; + std::array, syncgpio::outputSlots> outputs; + std::array, syncgpio::peripheralSlots> peripherals; + std::array multiIds{}; + std::array, maximumMultiPins> multiTable{}; + std::size_t multiCount = 0; + std::optional multiPins; + std::optional multiPeripheral; + std::array commands; + }; +} diff --git a/validation/firmware/TimerAllocation.cpp b/validation/firmware/TimerAllocation.cpp index c88289a9..00697b28 100644 --- a/validation/firmware/TimerAllocation.cpp +++ b/validation/firmware/TimerAllocation.cpp @@ -21,4 +21,11 @@ namespace validation if (owners[timer] == owner) owners[timer] = std::nullopt; } + + std::optional TimerAllocation::Owner(uint8_t timer) const + { + really_assert(timer < owners.size()); + + return owners[timer]; + } } diff --git a/validation/firmware/TimerAllocation.hpp b/validation/firmware/TimerAllocation.hpp index 7025fb46..954dde71 100644 --- a/validation/firmware/TimerAllocation.hpp +++ b/validation/firmware/TimerAllocation.hpp @@ -14,6 +14,7 @@ namespace validation public: services::HilStatus Claim(uint8_t timer, services::HilOwner owner); void Release(uint8_t timer, services::HilOwner owner); + std::optional Owner(uint8_t timer) const; private: std::array, 32> owners; diff --git a/validation/firmware/TimerGroup.cpp b/validation/firmware/TimerGroup.cpp new file mode 100644 index 00000000..482b8bb3 --- /dev/null +++ b/validation/firmware/TimerGroup.cpp @@ -0,0 +1,306 @@ +#include "validation/firmware/TimerGroup.hpp" +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include "infra/event/EventDispatcher.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + using CounterMode = hal::TimerBaseStm::CounterMode; + using services::HilChoice; + using services::HilStatus; + + constexpr uint32_t maximumPrescaler = 0xffff; + + constexpr std::array openKeys{ { "prescaler", "period", "irq", "mode", "pin" } }; + + constexpr std::array, 2> modeChoices{ { + { "up", CounterMode::up }, + { "down", CounterMode::down }, + } }; + + constexpr TimerCounterGroupNames names{ "tim.open", "tim.start", "tim.stop", "tim.count", "tim.close", " timclk=" }; + + TIM_TypeDef* Instance(uint8_t timer) + { + return hal::peripheralTimer[timer - 1]; + } + + uint32_t MaximumPeriod(uint8_t timer) + { + return IS_TIM_32B_COUNTER_INSTANCE(Instance(timer)) ? 0xffffffffu : 0xffffu; + } + } + + HilStatus TimerMarker::Claim(services::HilPinOwner& pins, const std::optional& id) + { + updates = 0; + pin = nullptr; + if (!id) + return HilStatus::done; + + hal::GpioPin* claimed = nullptr; + HilStatus status = pins.Claim(*id, services::HilPinPool::Use::exclusive, claimed); + if (status != HilStatus::done) + return status; + + claimed->Config(hal::PinConfigType::output, false); + pin = claimed; + return HilStatus::done; + } + + void TimerMarker::Update() + { + if (pin != nullptr) + pin->Set(!pin->GetOutputLatch()); + + updates.fetch_add(1); + } + + uint32_t TimerMarker::Updates() const + { + return updates.load(); + } + + void TimerMarker::Release() + { + if (pin != nullptr) + pin->ResetConfig(); + + pin = nullptr; + } + + TimerCounterGroup::TimerCounterGroup(services::HilContext& context, TimerCounterFactory& factory, services::HilOwner owner, const TimerCounterGroupNames& names) + : services::HilSingleInstanceGroup(context, factory, owner) + , factory(factory) + , clockKey(names.clock) + , commands{ { + OpenCommand(names.open, " [key=value]..."), + services::HilBind(names.start, "", *this, context.response), + services::HilBind(names.stop, "", *this, context.response), + services::HilBind(names.count, "", *this, context.response), + CloseCommand(names.close, ""), + } } + {} + + infra::MemoryRange TimerCounterGroup::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus TimerCounterGroup::OpenInstance(uint8_t index, const services::HilArguments& arguments) + { + return factory.Open(index, arguments, Pins()); + } + + void TimerCounterGroup::Opened(services::HilResponse::Line& line) const + { + line << clockKey << factory.Clock(); + } + + void TimerCounterGroup::CloseInstance() + {} + + HilStatus TimerCounterGroup::Start(const services::HilArguments& arguments) + { + HilStatus status = FindOpened(arguments); + if (status != HilStatus::done) + return status; + + factory.Start(); + Context().response.Ok(); + return HilStatus::done; + } + + HilStatus TimerCounterGroup::Stop(const services::HilArguments& arguments) + { + HilStatus status = FindOpened(arguments); + if (status != HilStatus::done) + return status; + + factory.Stop(); + Context().response.Ok(); + return HilStatus::done; + } + + HilStatus TimerCounterGroup::Count(const services::HilArguments& arguments) + { + HilStatus status = FindOpened(arguments); + if (status != HilStatus::done) + return status; + + Context().response.Ok() << " cnt=" << factory.Counter() << " irqs=" << factory.Interrupts(); + return HilStatus::done; + } + + HilStatus TimerCounterGroup::FindOpened(const services::HilArguments& arguments) const + { + if (!arguments.Shape(1, 1, {})) + return HilStatus::usage; + + return Instance().Find(arguments); + } + + TimerFactoryStm::TimerFactoryStm(const services::HilPinNaming& naming, TimerAllocation& timers) + : naming(naming) + , timers(timers) + {} + + uint8_t TimerFactoryStm::Instances() const + { + return 18; + } + + infra::MemoryRange TimerFactoryStm::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus TimerFactoryStm::Prepare(uint8_t index, const services::HilArguments& arguments) + { + Request request; + return Evaluate(index, arguments, request); + } + + HilStatus TimerFactoryStm::Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) + { + Request request; + HilStatus status = Evaluate(index, arguments, request); + if (status != HilStatus::done) + return status; + + status = timers.Claim(index, owners::timer); + if (status != HilStatus::done) + return status; + + status = marker.Claim(pins, request.pin); + if (status != HilStatus::done) + { + timers.Release(index, owners::timer); + return status; + } + + Construct(index, request); + return HilStatus::done; + } + + void TimerFactoryStm::Close(uint8_t, const infra::Function& onClosed) + { + this->onClosed = onClosed; + Stop(); + + // A dispatched update may already be queued with a pointer to the driver + infra::EventDispatcher::Instance().Schedule([this]() + { + Destroy(); + }); + } + + uint32_t TimerFactoryStm::Clock() const + { + return clock; + } + + void TimerFactoryStm::Start() + { + if (running) + return; + + running = true; + if (auto freeRunning = std::get_if(&driver)) + freeRunning->Start(); + else if (auto withInterrupt = std::get_if(&driver)) + withInterrupt->Start([this]() + { + marker.Update(); + }, + irq == TimerIrq::immediate ? hal::InterruptType::immediate : hal::InterruptType::dispatched); + } + + void TimerFactoryStm::Stop() + { + if (!running) + return; + + running = false; + services::HilWithDriver(driver, [](auto& alternative) + { + alternative.Stop(); + }); + } + + uint32_t TimerFactoryStm::Counter() + { + uint32_t counter = 0; + services::HilWithDriver(driver, [&counter](auto& alternative) + { + counter = __HAL_TIM_GET_COUNTER(&alternative.Handle()); + }); + + return counter; + } + + uint32_t TimerFactoryStm::Interrupts() const + { + return marker.Updates(); + } + + HilStatus TimerFactoryStm::Evaluate(uint8_t timer, const services::HilArguments& arguments, Request& request) const + { + if (!TimerExists(timer)) + return HilStatus::range; + + HilStatus status = HilStatus::done; + arguments.Number("prescaler", request.prescaler, 0, maximumPrescaler, status); + arguments.Number("period", request.period, 1, MaximumPeriod(timer), status); + arguments.Select("irq", request.irq, timerIrqChoices, status); + arguments.Select("mode", request.mode, modeChoices, status); + arguments.Pin("pin", naming, request.pin, status); + if (status != HilStatus::done) + return status; + + if (request.pin && request.irq == TimerIrq::none) + return HilStatus::usage; + + if (request.pin && !IsBonded(*request.pin)) + return HilStatus::pin; + + if (request.mode == CounterMode::down && (request.irq != TimerIrq::none || !IS_TIM_COUNTER_MODE_SELECT_INSTANCE(Instance(timer)))) + return HilStatus::unsupported; + + if (request.irq != TimerIrq::none && TimerClock(timer) / ((uint64_t{ request.prescaler } + 1) * (uint64_t{ request.period } + 1)) > maximumUpdateInterruptRate) + return HilStatus::range; + + return HilStatus::done; + } + + void TimerFactoryStm::Construct(uint8_t timer, const Request& request) + { + this->timer = timer; + clock = TimerClock(timer); + irq = request.irq; + running = false; + + const hal::TimerBaseStm::Timing timing{ request.prescaler, request.period }; + if (request.irq == TimerIrq::none) + driver.emplace(timer, timing, hal::TimerBaseStm::Config{ request.mode, std::nullopt }); + else + driver.emplace(timer, timing); + } + + void TimerFactoryStm::Destroy() + { + driver.emplace(); + marker.Release(); + timers.Release(timer, owners::timer); + onClosed(); + } + + TimerGroup::TimerGroup(services::HilContext& context, TimerFactoryStm& factory) + : TimerCounterGroup(context, factory, owners::timer, names) + {} +} diff --git a/validation/firmware/TimerGroup.hpp b/validation/firmware/TimerGroup.hpp new file mode 100644 index 00000000..110f754b --- /dev/null +++ b/validation/firmware/TimerGroup.hpp @@ -0,0 +1,149 @@ +#pragma once + +#include "hal_st/stm32fxxx/TimerStm.hpp" +#include "infra/util/AutoResetFunction.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilSingleInstanceGroup.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/TimerAllocation.hpp" +#include +#include +#include +#include +#include + +namespace validation +{ + enum class TimerIrq : uint8_t + { + none, + immediate, + dispatched, + }; + + inline constexpr std::array, 3> timerIrqChoices{ { + { "none", TimerIrq::none }, + { "immediate", TimerIrq::immediate }, + { "dispatched", TimerIrq::dispatched }, + } }; + + // Above this the update interrupt, which runs on every update in both irq modes, starves the event loop. + inline constexpr uint32_t maximumUpdateInterruptRate = 100000; + + class TimerMarker + { + public: + services::HilStatus Claim(services::HilPinOwner& pins, const std::optional& id); + void Update(); + uint32_t Updates() const; + void Release(); + + private: + hal::GpioPin* pin = nullptr; + std::atomic updates{ 0 }; + }; + + class TimerCounterFactory + : public services::HilInstanceFactory + { + protected: + TimerCounterFactory() = default; + TimerCounterFactory(const TimerCounterFactory& other) = delete; + TimerCounterFactory& operator=(const TimerCounterFactory& other) = delete; + ~TimerCounterFactory() = default; + + public: + virtual services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) = 0; + virtual uint32_t Clock() const = 0; + virtual void Start() = 0; + virtual void Stop() = 0; + virtual uint32_t Counter() = 0; + virtual uint32_t Interrupts() const = 0; + }; + + struct TimerCounterGroupNames + { + const char* open; + const char* start; + const char* stop; + const char* count; + const char* close; + const char* clock; + }; + + class TimerCounterGroup + : public services::HilSingleInstanceGroup + { + public: + TimerCounterGroup(services::HilContext& context, TimerCounterFactory& factory, services::HilOwner owner, const TimerCounterGroupNames& names); + + infra::MemoryRange Commands() override; + + protected: + services::HilStatus OpenInstance(uint8_t index, const services::HilArguments& arguments) override; + void Opened(services::HilResponse::Line& line) const override; + void CloseInstance() override; + + private: + services::HilStatus Start(const services::HilArguments& arguments); + services::HilStatus Stop(const services::HilArguments& arguments); + services::HilStatus Count(const services::HilArguments& arguments); + services::HilStatus FindOpened(const services::HilArguments& arguments) const; + + private: + TimerCounterFactory& factory; + const char* clockKey; + std::array commands; + }; + + class TimerFactoryStm + : public TimerCounterFactory + { + public: + TimerFactoryStm(const services::HilPinNaming& naming, TimerAllocation& timers); + + uint8_t Instances() const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + void Close(uint8_t index, const infra::Function& onClosed) override; + + services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) override; + uint32_t Clock() const override; + void Start() override; + void Stop() override; + uint32_t Counter() override; + uint32_t Interrupts() const override; + + private: + struct Request + { + uint32_t prescaler = 0; + uint32_t period = 999; + TimerIrq irq = TimerIrq::dispatched; + hal::TimerBaseStm::CounterMode mode = hal::TimerBaseStm::CounterMode::up; + std::optional pin; + }; + + services::HilStatus Evaluate(uint8_t timer, const services::HilArguments& arguments, Request& request) const; + void Construct(uint8_t timer, const Request& request); + void Destroy(); + + private: + const services::HilPinNaming& naming; + TimerAllocation& timers; + std::variant driver; + TimerMarker marker; + uint8_t timer = 0; + uint32_t clock = 0; + TimerIrq irq = TimerIrq::none; + bool running = false; + infra::AutoResetFunction onClosed; + }; + + class TimerGroup + : public TimerCounterGroup + { + public: + TimerGroup(services::HilContext& context, TimerFactoryStm& factory); + }; +} diff --git a/validation/firmware/TimerPwmGroup.cpp b/validation/firmware/TimerPwmGroup.cpp new file mode 100644 index 00000000..d8250f5d --- /dev/null +++ b/validation/firmware/TimerPwmGroup.cpp @@ -0,0 +1,366 @@ +#include "validation/firmware/TimerPwmGroup.hpp" +#include "generated/stm32fxxx/PeripheralTable.hpp" +#include "infra/util/Tokenizer.hpp" +#include "validation/firmware/Owners.hpp" +#include "validation/firmware/PeripheralClocks.hpp" +#include "validation/firmware/PinFactoryStm.hpp" +#include DEVICE_HEADER + +namespace validation +{ + namespace + { + using services::HilStatus; + + constexpr uint32_t maximumPrescaler = 0xffff; + + constexpr std::array openKeys{ { "pins", "prescaler", "period" } }; + + constexpr std::array channelFunctions{ { + hal::PinConfigTypeStm::timerChannel1, + hal::PinConfigTypeStm::timerChannel2, + hal::PinConfigTypeStm::timerChannel3, + hal::PinConfigTypeStm::timerChannel4, + } }; + + constexpr std::array timerChannels{ { + TIM_CHANNEL_1, + TIM_CHANNEL_2, + TIM_CHANNEL_3, + TIM_CHANNEL_4, + } }; + + constexpr PwmChannelGroupNames names{ "tpwm.open", "tpwm.duty", "tpwm.pulse", "tpwm.start", "tpwm.stop", "tpwm.close", " timclk=" }; + + TIM_TypeDef* Instance(uint8_t timer) + { + return hal::peripheralTimer[timer - 1]; + } + + uint32_t MaximumPeriod(uint8_t timer) + { + return IS_TIM_32B_COUNTER_INSTANCE(Instance(timer)) ? 0xffffffffu : 0xffffu; + } + } + + HilStatus ParseChannelPins(const services::HilArguments& arguments, const services::HilPinNaming& naming, infra::MemoryRange> pins, std::size_t& count) + { + const auto text = arguments.Key("pins"); + if (!text) + return HilStatus::usage; + + infra::Tokenizer tokens(*text, ','); + count = tokens.Size(); + if (count == 0 || count > pins.size()) + return HilStatus::usage; + + bool used = false; + for (std::size_t position = 0; position != count; ++position) + { + const auto token = tokens.Token(position); + pins[position] = std::nullopt; + if (token == "-") + continue; + + pins[position] = services::HilArguments::ParsePin(token, naming); + if (!pins[position]) + return HilStatus::pin; + + used = true; + } + + return used ? HilStatus::done : HilStatus::usage; + } + + PwmChannelGroup::PwmChannelGroup(services::HilContext& context, PwmChannelFactory& factory, services::HilOwner owner, const PwmChannelGroupNames& names) + : services::HilSingleInstanceGroup(context, factory, owner) + , factory(factory) + , clockKey(names.clock) + , commands{ { + OpenCommand(names.open, " pins=[,...] [key=value]..."), + services::HilBind(names.duty, " ", *this, context.response), + services::HilBind(names.pulse, " ", *this, context.response), + services::HilBind(names.start, " [ch=]", *this, context.response), + services::HilBind(names.stop, " [ch=]", *this, context.response), + CloseCommand(names.close, ""), + } } + {} + + infra::MemoryRange PwmChannelGroup::Commands() + { + return infra::MakeRange(commands); + } + + HilStatus PwmChannelGroup::OpenInstance(uint8_t index, const services::HilArguments& arguments) + { + return factory.Open(index, arguments, Pins()); + } + + void PwmChannelGroup::Opened(services::HilResponse::Line& line) const + { + line << clockKey << factory.Clock(); + } + + void PwmChannelGroup::CloseInstance() + {} + + HilStatus PwmChannelGroup::Duty(const services::HilArguments& arguments) + { + if (!arguments.Shape(3, 3, {})) + return HilStatus::usage; + + uint8_t channel = 0; + HilStatus status = FindChannel(arguments, 1, channel); + + uint32_t percent = 0; + arguments.NumberAt(2, percent, 0, 100, status); + if (status != HilStatus::done) + return status; + + factory.SetDuty(channel, static_cast(percent)); + Context().response.Ok(); + return HilStatus::done; + } + + HilStatus PwmChannelGroup::Pulse(const services::HilArguments& arguments) + { + if (!arguments.Shape(4, 4, {})) + return HilStatus::usage; + + uint8_t channel = 0; + HilStatus status = FindChannel(arguments, 1, channel); + + uint32_t pulseOn = 0; + uint32_t period = 0; + if (status == HilStatus::done) + { + arguments.NumberAt(2, pulseOn, 0, factory.MaximumCount(), status); + arguments.NumberAt(3, period, 1, factory.MaximumCount(), status); + } + + if (status != HilStatus::done) + return status; + + factory.SetPulse(channel, pulseOn, period); + Context().response.Ok(); + return HilStatus::done; + } + + HilStatus PwmChannelGroup::Start(const services::HilArguments& arguments) + { + std::optional channel; + HilStatus status = FindChannels(arguments, channel); + if (status != HilStatus::done) + return status; + + factory.Start(channel); + Context().response.Ok(); + return HilStatus::done; + } + + HilStatus PwmChannelGroup::Stop(const services::HilArguments& arguments) + { + std::optional channel; + HilStatus status = FindChannels(arguments, channel); + if (status != HilStatus::done) + return status; + + factory.Stop(channel); + Context().response.Ok(); + return HilStatus::done; + } + + HilStatus PwmChannelGroup::FindChannel(const services::HilArguments& arguments, std::size_t positional, uint8_t& channel) const + { + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done) + return status; + + uint32_t number = 0; + arguments.NumberAt(positional, number, 1, static_cast(factory.Channels()), status); + channel = static_cast(number); + return status; + } + + HilStatus PwmChannelGroup::FindChannels(const services::HilArguments& arguments, std::optional& channel) const + { + static constexpr std::array keys{ { "ch" } }; + if (!arguments.Shape(1, 1, infra::MakeRange(keys))) + return HilStatus::usage; + + HilStatus status = Instance().Find(arguments); + if (status != HilStatus::done || !arguments.Has("ch")) + return status; + + uint32_t number = 0; + arguments.Number("ch", number, 1, static_cast(factory.Channels()), status); + channel = static_cast(number); + return status; + } + + TimerPwmFactoryStm::TimerPwmFactoryStm(const services::HilPinNaming& naming, TimerAllocation& timers) + : naming(naming) + , timers(timers) + {} + + uint8_t TimerPwmFactoryStm::Instances() const + { + return 18; + } + + infra::MemoryRange TimerPwmFactoryStm::OpenKeys() const + { + return infra::MakeRange(openKeys); + } + + HilStatus TimerPwmFactoryStm::Prepare(uint8_t index, const services::HilArguments& arguments) + { + Request request; + return Evaluate(index, arguments, request); + } + + HilStatus TimerPwmFactoryStm::Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) + { + Request request; + HilStatus status = Evaluate(index, arguments, request); + if (status != HilStatus::done) + return status; + + status = timers.Claim(index, owners::timerPwm); + if (status != HilStatus::done) + return status; + + status = Claim(index, request, pins); + if (status != HilStatus::done) + { + timers.Release(index, owners::timerPwm); + return status; + } + + Construct(index, request); + return HilStatus::done; + } + + void TimerPwmFactoryStm::Close(uint8_t index, const infra::Function& onClosed) + { + run.Stop(std::nullopt); + driver.emplace(); + base = nullptr; + timers.Release(index, owners::timerPwm); + onClosed(); + } + + uint32_t TimerPwmFactoryStm::Clock() const + { + return clock; + } + + std::size_t TimerPwmFactoryStm::Channels() const + { + return channels; + } + + uint32_t TimerPwmFactoryStm::MaximumCount() const + { + return MaximumPeriod(timer); + } + + void TimerPwmFactoryStm::SetDuty(uint8_t channel, uint8_t percent) + { + base->Channel(channel).SetDuty(percent); + } + + void TimerPwmFactoryStm::SetPulse(uint8_t channel, uint32_t pulseOn, uint32_t period) + { + base->Channel(channel).SetPulse(pulseOn, period); + } + + void TimerPwmFactoryStm::Start(std::optional channel) + { + run.Start(channel); + } + + void TimerPwmFactoryStm::Stop(std::optional channel) + { + run.Stop(channel); + } + + HilStatus TimerPwmFactoryStm::Evaluate(uint8_t timer, const services::HilArguments& arguments, Request& request) const + { + if (!TimerExists(timer)) + return HilStatus::range; + + HilStatus status = HilStatus::done; + arguments.Number("prescaler", request.prescaler, 0, maximumPrescaler, status); + arguments.Number("period", request.period, 1, MaximumPeriod(timer), status); + if (status != HilStatus::done) + return status; + + status = ParseChannelPins(arguments, naming, infra::MakeRange(request.pins), request.channels); + if (status != HilStatus::done) + return status; + + for (std::size_t position = 0; position != request.channels; ++position) + if (request.pins[position] && !SupportsFunction(*request.pins[position], channelFunctions[position], timer)) + return HilStatus::pin; + + for (std::size_t position = 0; position != request.channels; ++position) + if (!IS_TIM_CCX_INSTANCE(Instance(timer), timerChannels[position])) + return HilStatus::unsupported; + + return HilStatus::done; + } + + HilStatus TimerPwmFactoryStm::Claim(uint8_t timer, const Request& request, services::HilPinOwner& pins) const + { + HilStatus status = HilStatus::done; + + for (std::size_t position = 0; position != request.channels && status == HilStatus::done; ++position) + { + hal::GpioPin* claimed = nullptr; + status = pins.ClaimFunction(request.pins[position], Function(channelFunctions[position]), timer, claimed); + } + + return status; + } + + void TimerPwmFactoryStm::Construct(uint8_t timer, const Request& request) + { + this->timer = timer; + clock = TimerClock(timer); + channels = request.channels; + + for (std::size_t position = 0; position != request.channels; ++position) + { + if (request.pins[position]) + channelPins.Emplace(position, PortOf(*request.pins[position]), request.pins[position]->index); + else + channelPins.EmplaceUnused(position); + } + + const hal::TimerBaseStm::Timing timing{ request.prescaler, request.period }; + const auto pins = channelPins.Range(request.channels); + + switch (request.channels) + { + case 1: + base = &driver.emplace>(timer, timing, pins); + break; + case 2: + base = &driver.emplace>(timer, timing, pins); + break; + case 3: + base = &driver.emplace>(timer, timing, pins); + break; + default: + base = &driver.emplace>(timer, timing, pins); + break; + } + + run.Attach(*base, request.channels); + } + + TimerPwmGroup::TimerPwmGroup(services::HilContext& context, TimerPwmFactoryStm& factory) + : PwmChannelGroup(context, factory, owners::timerPwm, names) + {} +} diff --git a/validation/firmware/TimerPwmGroup.hpp b/validation/firmware/TimerPwmGroup.hpp new file mode 100644 index 00000000..f23ebfcd --- /dev/null +++ b/validation/firmware/TimerPwmGroup.hpp @@ -0,0 +1,213 @@ +#pragma once + +#include "hal_st/stm32fxxx/TimerPwmStm.hpp" +#include "services/hil/HilCommand.hpp" +#include "services/hil/commands/HilSingleInstanceGroup.hpp" +#include "validation/firmware/BoardTypes.hpp" +#include "validation/firmware/ChannelPins.hpp" +#include "validation/firmware/TimerAllocation.hpp" +#include +#include +#include +#include +#include +#include + +namespace validation +{ + services::HilStatus ParseChannelPins(const services::HilArguments& arguments, const services::HilPinNaming& naming, infra::MemoryRange> pins, std::size_t& count); + + // HAL_TIM_PWM_Start and HAL_LPTIM_PWM_Start refuse a channel that already runs, and the drivers assert on that + template + class PwmChannelRun + { + public: + void Attach(Base& base, std::size_t channels); + void Start(std::optional channel); + void Stop(std::optional channel); + + private: + void StartChannel(std::size_t position); + + private: + Base* base = nullptr; + std::size_t channels = 0; + std::array running{}; + bool started = false; + }; + + class PwmChannelFactory + : public services::HilInstanceFactory + { + protected: + PwmChannelFactory() = default; + PwmChannelFactory(const PwmChannelFactory& other) = delete; + PwmChannelFactory& operator=(const PwmChannelFactory& other) = delete; + ~PwmChannelFactory() = default; + + public: + virtual services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) = 0; + virtual uint32_t Clock() const = 0; + virtual std::size_t Channels() const = 0; + virtual uint32_t MaximumCount() const = 0; + virtual void SetDuty(uint8_t channel, uint8_t percent) = 0; + virtual void SetPulse(uint8_t channel, uint32_t pulseOn, uint32_t period) = 0; + virtual void Start(std::optional channel) = 0; + virtual void Stop(std::optional channel) = 0; + }; + + struct PwmChannelGroupNames + { + const char* open; + const char* duty; + const char* pulse; + const char* start; + const char* stop; + const char* close; + const char* clock; + }; + + class PwmChannelGroup + : public services::HilSingleInstanceGroup + { + public: + PwmChannelGroup(services::HilContext& context, PwmChannelFactory& factory, services::HilOwner owner, const PwmChannelGroupNames& names); + + infra::MemoryRange Commands() override; + + protected: + services::HilStatus OpenInstance(uint8_t index, const services::HilArguments& arguments) override; + void Opened(services::HilResponse::Line& line) const override; + void CloseInstance() override; + + private: + services::HilStatus Duty(const services::HilArguments& arguments); + services::HilStatus Pulse(const services::HilArguments& arguments); + services::HilStatus Start(const services::HilArguments& arguments); + services::HilStatus Stop(const services::HilArguments& arguments); + services::HilStatus FindChannel(const services::HilArguments& arguments, std::size_t positional, uint8_t& channel) const; + services::HilStatus FindChannels(const services::HilArguments& arguments, std::optional& channel) const; + + private: + PwmChannelFactory& factory; + const char* clockKey; + std::array commands; + }; + + class TimerPwmFactoryStm + : public PwmChannelFactory + { + public: + static constexpr std::size_t maximumChannels = 4; + + TimerPwmFactoryStm(const services::HilPinNaming& naming, TimerAllocation& timers); + + uint8_t Instances() const override; + infra::MemoryRange OpenKeys() const override; + services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; + void Close(uint8_t index, const infra::Function& onClosed) override; + + services::HilStatus Open(uint8_t index, const services::HilArguments& arguments, services::HilPinOwner& pins) override; + uint32_t Clock() const override; + std::size_t Channels() const override; + uint32_t MaximumCount() const override; + void SetDuty(uint8_t channel, uint8_t percent) override; + void SetPulse(uint8_t channel, uint32_t pulseOn, uint32_t period) override; + void Start(std::optional channel) override; + void Stop(std::optional channel) override; + + private: + struct Request + { + uint32_t prescaler = 0; + uint32_t period = 6399; + std::size_t channels = 0; + std::array, maximumChannels> pins; + }; + + services::HilStatus Evaluate(uint8_t timer, const services::HilArguments& arguments, Request& request) const; + services::HilStatus Claim(uint8_t timer, const Request& request, services::HilPinOwner& pins) const; + void Construct(uint8_t timer, const Request& request); + + private: + const services::HilPinNaming& naming; + TimerAllocation& timers; + ChannelPins channelPins; + std::variant, hal::TimerPwmWithChannels<2>, hal::TimerPwmWithChannels<3>, hal::TimerPwmWithChannels<4>> driver; + hal::TimerPwmBaseStm* base = nullptr; + PwmChannelRun run; + uint8_t timer = 0; + uint32_t clock = 0; + std::size_t channels = 0; + }; + + class TimerPwmGroup + : public PwmChannelGroup + { + public: + TimerPwmGroup(services::HilContext& context, TimerPwmFactoryStm& factory); + }; + + //// Implementation //// + + template + void PwmChannelRun::Attach(Base& base, std::size_t channels) + { + this->base = &base; + this->channels = channels; + running.fill(false); + started = false; + } + + template + void PwmChannelRun::Start(std::optional channel) + { + if (channel) + { + StartChannel(*channel - 1); + return; + } + + if (!started && std::none_of(running.begin(), running.begin() + channels, [](bool value) + { + return value; + })) + { + base->Start(); + started = true; + std::fill_n(running.begin(), channels, true); + return; + } + + for (std::size_t position = 0; position != channels; ++position) + StartChannel(position); + } + + template + void PwmChannelRun::Stop(std::optional channel) + { + if (!channel) + { + base->Stop(); + started = false; + running.fill(false); + return; + } + + if (running[*channel - 1]) + { + base->Channel(*channel).Stop(); + running[*channel - 1] = false; + } + } + + template + void PwmChannelRun::StartChannel(std::size_t position) + { + if (running[position]) + return; + + base->Channel(static_cast(position + 1)).Start(); + running[position] = true; + } +} diff --git a/validation/firmware/UartFactory.cpp b/validation/firmware/UartFactory.cpp index 8d0c9e60..abcfcf5a 100644 --- a/validation/firmware/UartFactory.cpp +++ b/validation/firmware/UartFactory.cpp @@ -14,7 +14,7 @@ namespace validation using services::HilStatus; using FlowControl = hal::SynchronousUartStm::HwFlowControl; - constexpr std::array openKeys{ { "lp", "tx", "rx", "rts", "cts", "baud", "parity", "flow", "swap", "dma", "duplex", "sync" } }; + constexpr std::array openKeys{ { "lp", "tx", "rx", "rts", "cts", "baud", "parity", "flow", "swap", "dma", "duplex", "sync", "sendonly" } }; constexpr std::array, 3> parities{ { { "none", USART_PARITY_NONE }, @@ -36,6 +36,13 @@ namespace validation constexpr uint32_t usartMinimumDivider = 0x10; constexpr uint32_t lpuartMinimumDivider = 0x300; + // SynchronousUartStmSendOnly has SyncLpUart constructors on STM32WB only +#if defined(STM32WB) + constexpr bool lpuartSendOnly = true; +#else + constexpr bool lpuartSendOnly = false; +#endif + struct PinFunctions { hal::PinConfigTypeStm tx; @@ -115,9 +122,14 @@ namespace validation return divider >= usartMinimumDivider && divider <= static_cast(USART_BRR_BRR); } - bool PinsSupportFunctions(uint8_t index, const PinFunctions& functions, HilPinId tx, HilPinId rx, const std::optional& rts, const std::optional& cts) + bool Supports(const std::optional& pin, hal::PinConfigTypeStm function, uint8_t index) { - return SupportsFunction(tx, functions.tx, index) && SupportsFunction(rx, functions.rx, index) && (!rts || SupportsFunction(*rts, functions.rts, index)) && (!cts || SupportsFunction(*cts, functions.cts, index)); + return !pin || SupportsFunction(*pin, function, index); + } + + bool PinsSupportFunctions(uint8_t index, const PinFunctions& functions, HilPinId tx, const std::optional& rx, const std::optional& rts, const std::optional& cts) + { + return SupportsFunction(tx, functions.tx, index) && Supports(rx, functions.rx, index) && Supports(rts, functions.rts, index) && Supports(cts, functions.cts, index); } template @@ -136,6 +148,27 @@ namespace validation return driver.template emplace(streams..., index, pins.tx, pins.rx, config); } + + template + Driver& EmplaceSendOnly(Variant& driver, uint8_t index, bool lpuart, bool rts, const DriverPins& pins, uint32_t baud) + { + constexpr auto rtsOnly = Driver::HwFlowControl::hwControlRtsEnable; + +#if defined(STM32WB) + if (lpuart) + { + if (rts) + return driver.template emplace(index, pins.tx, pins.rts, hal::SyncLpUart{}, rtsOnly, baud); + + return driver.template emplace(index, pins.tx, hal::SyncLpUart{}, baud); + } +#endif + + if (rts) + return driver.template emplace(index, pins.tx, pins.rts, rtsOnly, baud); + + return driver.template emplace(index, pins.tx, baud); + } } UartFactoryStm::UartFactoryStm(const services::HilPinNaming& naming, hal::DmaStm& dma) @@ -196,7 +229,7 @@ namespace validation if (!InstanceExists(index, request.lpuart)) return HilStatus::range; - if (request.dma + request.duplex + request.synchronous > 1) + if (request.dma + request.duplex + request.synchronous + request.sendOnly > 1) return HilStatus::usage; if (UsesRts(request.flow) != request.rts.has_value() || UsesCts(request.flow) != request.cts.has_value()) @@ -205,10 +238,14 @@ namespace validation if (request.lpuart && (request.duplex || request.synchronous)) return HilStatus::unsupported; - if (request.synchronous && (request.parity != USART_PARITY_NONE || request.swap)) + const bool polled = request.synchronous || request.sendOnly; + if (polled && (request.parity != USART_PARITY_NONE || request.swap)) + return HilStatus::unsupported; + + if (request.sendOnly && ((request.flow != FlowControl::hwControlDisable && request.flow != FlowControl::hwControlRtsEnable) || (request.lpuart && !lpuartSendOnly))) return HilStatus::unsupported; - if (!request.synchronous && request.flow != FlowControl::hwControlDisable && request.flow != FlowControl::hwControlRtsCtsEnable) + if (!polled && request.flow != FlowControl::hwControlDisable && request.flow != FlowControl::hwControlRtsCtsEnable) return HilStatus::unsupported; if ((request.dma || request.duplex) && !board::UartDma(index, request.lpuart)) @@ -224,10 +261,10 @@ namespace validation request.rx = defaults->rx; } - if (!request.tx || !request.rx) + if (!request.tx || (!request.rx && !request.sendOnly)) return HilStatus::usage; - if (!PinsSupportFunctions(index, FunctionsOf(request.lpuart), *request.tx, *request.rx, request.rts, request.cts)) + if (!PinsSupportFunctions(index, FunctionsOf(request.lpuart), *request.tx, request.rx, request.rts, request.cts)) return HilStatus::pin; if (IsTerminal(index, request.lpuart)) @@ -251,6 +288,7 @@ namespace validation arguments.Flag("dma", request.dma, status); arguments.Flag("duplex", request.duplex, status); arguments.Flag("sync", request.synchronous, status); + arguments.Flag("sendonly", request.sendOnly, status); return status; } @@ -275,6 +313,12 @@ namespace validation const bool handshake = request.flow != FlowControl::hwControlDisable; handle.baudRate = request.baud; + if (request.sendOnly) + { + handle.synchronous = &EmplaceSendOnly(driver, index, request.lpuart, handshake, pins, request.baud); + return; + } + if (request.synchronous) { if (handshake) diff --git a/validation/firmware/UartFactory.hpp b/validation/firmware/UartFactory.hpp index f7140bda..67837d01 100644 --- a/validation/firmware/UartFactory.hpp +++ b/validation/firmware/UartFactory.hpp @@ -30,6 +30,7 @@ namespace validation using DuplexUart = hal::UartStmDuplexDma::WithRxBuffer<64>; // The polled driver buffers everything until uart.recv drains it: one ring slot stays empty, uart.recv returns up to 256 bytes using SynchronousUart = hal::SynchronousUartStm::WithStorage<257>; + using SendOnlyUart = hal::SynchronousUartStmSendOnly; struct Request { @@ -45,6 +46,7 @@ namespace validation bool dma = false; bool duplex = false; bool synchronous = false; + bool sendOnly = false; }; struct ClaimedPins @@ -65,7 +67,7 @@ namespace validation hal::DmaStm& dma; std::optional transmitStream; std::optional receiveStream; - std::variant driver; + std::variant driver; hal::SerialCommunication* serial = nullptr; }; } diff --git a/validation/firmware/UnsupportedGroups.cpp b/validation/firmware/UnsupportedGroups.cpp index bafc86b2..9ce81d65 100644 --- a/validation/firmware/UnsupportedGroups.cpp +++ b/validation/firmware/UnsupportedGroups.cpp @@ -6,7 +6,8 @@ namespace validation { namespace { - constexpr std::array commandNames{ { +#if defined(STM32WB) + constexpr std::array commandNames{ { "comp.open", "comp.read", "comp.irq", @@ -15,13 +16,44 @@ namespace validation "can.open", "can.send", "can.close", - "eeprom.write", - "eeprom.read", - "eeprom.erase", "eth.open", "eth.status", "eth.close", + "lptpwm.open", + "lptpwm.duty", + "lptpwm.pulse", + "lptpwm.start", + "lptpwm.stop", + "lptpwm.close", } }; +#elif defined(STM32WBA) + constexpr std::array commandNames{ { + "comp.open", + "comp.read", + "comp.irq", + "comp.count", + "comp.close", + "can.open", + "can.send", + "can.close", + "eth.open", + "eth.status", + "eth.close", + "clock.mco", + "clock.hsi48", + "hsem.take", + "hsem.release", + "hsem.status", + "hsem.lock", + "hsem.mine", + "flash.stack", + "qspi.open", + "qspi.cmd", + "qspi.poll", + "qspi.xfer", + "qspi.close", + } }; +#endif } void CreateUnsupportedGroups(services::HilContext& context) diff --git a/validation/firmware/WatchDogFactory.cpp b/validation/firmware/WatchDogFactory.cpp index b5f1b07e..c5206592 100644 --- a/validation/firmware/WatchDogFactory.cpp +++ b/validation/firmware/WatchDogFactory.cpp @@ -102,6 +102,9 @@ namespace validation if (status != HilStatus::done) return status; + if (watchDog) + return HilStatus::busy; + hal::GpioPin* gpio = nullptr; if (request.pin) { @@ -118,9 +121,25 @@ namespace validation if (gpio != nullptr) created = &toggle.emplace(*watchDog, *gpio); + started = true; return HilStatus::done; } + hal::WatchDogStm& WatchDogFactoryStm::Borrow() + { + // Until wdt.start, the borrowed WWDG only has its clock and interrupt; refreshing it does not start it + if (!watchDog) + watchDog.emplace(); + + return *watchDog; + } + + void WatchDogFactoryStm::Return() + { + if (!started) + watchDog.reset(); + } + HilStatus WatchDogFactoryStm::Parse(const services::HilArguments& arguments, Request& request) const { HilStatus status = HilStatus::done; diff --git a/validation/firmware/WatchDogFactory.hpp b/validation/firmware/WatchDogFactory.hpp index effba6ea..8cc5c8f1 100644 --- a/validation/firmware/WatchDogFactory.hpp +++ b/validation/firmware/WatchDogFactory.hpp @@ -20,6 +20,9 @@ namespace validation services::HilStatus Prepare(uint8_t index, const services::HilArguments& arguments) override; services::HilStatus Create(uint8_t index, infra::Duration timeout, const services::HilArguments& arguments, hal::Watchdog*& watchDog) override; + hal::WatchDogStm& Borrow(); + void Return(); + private: class WarningToggle : public hal::Watchdog @@ -51,5 +54,6 @@ namespace validation services::HilPinOwner warningPins; std::optional watchDog; std::optional toggle; + bool started = false; }; } diff --git a/validation/firmware/boards/stm32wb55/BoardProfile.hpp b/validation/firmware/boards/stm32wb55/BoardProfile.hpp index 394481ce..5ee8ea1f 100644 --- a/validation/firmware/boards/stm32wb55/BoardProfile.hpp +++ b/validation/firmware/boards/stm32wb55/BoardProfile.hpp @@ -40,6 +40,12 @@ namespace validation::board inline constexpr std::optional defaultUart = UartPins{ 1, true, Pin(Port::A, 2), Pin(Port::A, 3) }; inline constexpr QeiPins defaultQei{ 2, Pin(Port::A, 15), Pin(Port::A, 1), Pin(Port::C, 6) }; + inline constexpr std::array i2cPins{ { + { 1, Pin(Port::B, 8), Pin(Port::B, 9) }, + { 3, Pin(Port::C, 0), Pin(Port::C, 1) }, + } }; + inline constexpr QuadSpiPins qspiPins{ Pin(Port::A, 3), Pin(Port::A, 2), { { Pin(Port::B, 9), Pin(Port::B, 8), Pin(Port::A, 7), Pin(Port::A, 6) } } }; + inline constexpr LowPowerPins lowPowerDefaults{ Pin(Port::C, 6), Pin(Port::B, 0) }; inline constexpr auto aliases = std::to_array({ { "terminaltx", terminal.tx }, @@ -89,11 +95,37 @@ namespace validation::board { "sw1", Pin(Port::C, 4) }, { "sw2", Pin(Port::D, 0) }, { "sw3", Pin(Port::D, 1) }, + { "i2c1scl", i2cPins[0].scl }, + { "i2c1sda", i2cPins[0].sda }, + { "i2c3scl", i2cPins[1].scl }, + { "i2c3sda", i2cPins[1].sda }, + { "spi2clk", Pin(Port::B, 13) }, + { "spi2miso", Pin(Port::B, 14) }, + { "spi2mosi", Pin(Port::B, 15) }, + { "spi2nss", Pin(Port::B, 12) }, + { "spi1nss", Pin(Port::A, 4) }, + { "qspiclk", qspiPins.clk }, + { "qspincs", qspiPins.ncs }, + { "qspiio0", qspiPins.io[0] }, + { "qspiio1", qspiPins.io[1] }, + { "qspiio2", qspiPins.io[2] }, + { "qspiio3", qspiPins.io[3] }, + { "mco", Pin(Port::A, 8) }, }); inline constexpr uint8_t uartDmaChannel = 3; inline constexpr uint8_t spiDmaChannel = 5; inline constexpr uint8_t adcDmaChannel = 7; + inline constexpr DmaChannel adcDma{ 1, adcDmaChannel }; + inline constexpr DmaPair spiSlaveDma{ { 2, 1 }, { 2, 2 } }; + inline constexpr DmaChannel qspiDma{ 2, 3 }; + inline constexpr uint8_t qspiDmaRequest = DMA_REQUEST_QUADSPI; + inline constexpr DmaChannel dmaGroupDma{ 2, 4 }; + inline constexpr uint8_t dmaGroupDmaRequest = DMA_REQUEST_TIM2_UP; + + inline constexpr uint8_t scaffoldTimer = 17; + inline constexpr uint32_t flashScratchFirstPage = 64; + inline constexpr uint32_t flashScratchEndPage = 128; constexpr std::optional UartDma(uint8_t index, bool lpuart) { diff --git a/validation/firmware/boards/stm32wba55/BoardProfile.hpp b/validation/firmware/boards/stm32wba55/BoardProfile.hpp index ff60ee63..1633877b 100644 --- a/validation/firmware/boards/stm32wba55/BoardProfile.hpp +++ b/validation/firmware/boards/stm32wba55/BoardProfile.hpp @@ -19,14 +19,17 @@ namespace validation::board inline constexpr uint8_t maximumPinIndex = 15; static_assert(static_cast(Port::H) == 3, "portLetters must follow hal::Port"); - inline constexpr std::array bondedPins{ { 0xffef, 0xffff, 0xe000, 0x0008 } }; + // The generated pinout table comes from the WBA52 XML; only these are bonded out on the UFQFPN48 (PA3, PB10, PB11 + // and PB13 are SMPS and VDD11 pads) + inline constexpr std::array bondedPins{ { 0xffe7, 0xd3ff, 0xe000, 0x0008 } }; inline constexpr UartPins terminal{ 1, false, Pin(Port::B, 12), Pin(Port::A, 8) }; inline constexpr uint32_t terminalBaudRate = 921600; inline constexpr DmaRequests terminalDma{ GPDMA1_REQUEST_USART1_TX, GPDMA1_REQUEST_USART1_RX }; - // Red LD3: the blue LD1 is the SPI1 clock pin, and the green LD2 needs a solder bridge on some board revisions - inline constexpr HilPinId debugLed = Pin(Port::B, 8); + // Green LD2: PB8 (red LD3) is the only SPI3 MOSI and the only bonded TIM16 CH1N pin, and the blue LD1 is the SPI1 + // clock pin. LD2 is not connected on a stock board (SB28 open), so the heartbeat stays dark unless SB28 is closed + inline constexpr HilPinId debugLed = Pin(Port::A, 9); inline constexpr std::array reservedPins{ { terminal.tx, terminal.rx, @@ -40,6 +43,11 @@ namespace validation::board inline constexpr std::optional defaultUart = UartPins{ 1, true, Pin(Port::B, 5), Pin(Port::A, 10) }; inline constexpr QeiPins defaultQei{ 1, Pin(Port::A, 11), Pin(Port::A, 12), Pin(Port::A, 15) }; + inline constexpr std::array i2cPins{ { + { 1, Pin(Port::B, 2), Pin(Port::B, 1) }, + { 3, Pin(Port::A, 6), Pin(Port::A, 7) }, + } }; + inline constexpr LowPowerPins lowPowerDefaults{ Pin(Port::B, 14), Pin(Port::A, 2) }; inline constexpr auto aliases = std::to_array({ { "terminaltx", terminal.tx }, @@ -94,11 +102,36 @@ namespace validation::board { "sw1", Pin(Port::C, 13) }, { "sw2", Pin(Port::B, 6) }, { "sw3", Pin(Port::B, 7) }, + { "i2c1scl", i2cPins[0].scl }, + { "i2c1sda", i2cPins[0].sda }, + { "i2c3scl", i2cPins[1].scl }, + { "i2c3sda", i2cPins[1].sda }, + { "spi3clk", Pin(Port::A, 0) }, + { "spi3miso", Pin(Port::B, 9) }, + { "spi3mosi", Pin(Port::B, 8) }, + { "spi3nss", Pin(Port::A, 5) }, + { "spi1nss", Pin(Port::A, 12) }, + { "lptim1ch2", Pin(Port::A, 15) }, + { "lptim2ch1", Pin(Port::A, 11) }, + { "lptim2ch2", Pin(Port::A, 1) }, + { "lptim1in1", Pin(Port::A, 0) }, + { "lptim1in2", Pin(Port::B, 3) }, + { "lptim2in1", Pin(Port::B, 9) }, + { "lptim2in2", Pin(Port::B, 0) }, + { "tim16ch1n", Pin(Port::B, 8) }, }); inline constexpr uint8_t uartDmaChannel = 3; inline constexpr uint8_t spiDmaChannel = 5; inline constexpr uint8_t adcDmaChannel = 7; + inline constexpr DmaChannel adcDma{ 1, adcDmaChannel }; + inline constexpr DmaPair spiSlaveDma{ { 1, 8 }, { 1, 7 } }; + inline constexpr DmaChannel dmaGroupDma{ 1, 8 }; + inline constexpr uint8_t dmaGroupDmaRequest = GPDMA1_REQUEST_TIM2_UP; + + inline constexpr uint8_t scaffoldTimer = 17; + inline constexpr uint32_t flashScratchFirstPage = 64; + inline constexpr uint32_t flashScratchEndPage = 128; constexpr std::optional UartDma(uint8_t index, bool lpuart) { diff --git a/validation/host/boards/nucleo_wb55rg.yaml b/validation/host/boards/nucleo_wb55rg.yaml index 1ba007d2..07161872 100644 --- a/validation/host/boards/nucleo_wb55rg.yaml +++ b/validation/host/boards/nucleo_wb55rg.yaml @@ -20,6 +20,12 @@ clocks: uart: usart1: 64000000 lpuart1: 64000000 + # LPTIM1SEL/LPTIM2SEL reset value: PCLK1. + lptim: + 1: 64000000 + 2: 64000000 + # QUADSPI kernel clock (HCLK4); qspi.open divides it by prescaler + 1. + qspi: 64000000 terminal: baud: 921600 @@ -79,6 +85,22 @@ pins: sw1: PC4 sw2: PD0 sw3: PD1 + i2c1scl: PB8 + i2c1sda: PB9 + i2c3scl: PC0 + i2c3sda: PC1 + spi2clk: PB13 + spi2miso: PB14 + spi2mosi: PB15 + spi2nss: PB12 + spi1nss: PA4 + qspiclk: PA3 + qspincs: PA2 + qspiio0: PB9 + qspiio1: PB8 + qspiio2: PA7 + qspiio3: PA6 + mco: PA8 ad3: # V+/V- stay off (null): the Nucleo is powered from the ST-LINK USB. @@ -94,6 +116,14 @@ ad3: # stock board. CN10-6 (PB7) and CN10-34 (PB6) next to DIO5 and DIO11 are # the terminal to the ST-LINK: keep them free. PB3 (SWO) is on no header # of a stock board, so TIM2 CH2 / encoder B use PA1. +# Options (--with ) are fitted on top of a bundle, and only together +# with their tag: `jumpered` ties each key pin (the end test_wiring.py +# drives) to the listed pins, `loads` are the pins the wiring loads, +# `pullups` the pins it pulls up to 3V3, `excludes` the options that cannot +# be fitted at the same time. The I2C option sits on a breadboard +# (validation/README.md, "Optional wiring") with its rails fed from CN6-4 +# (3V3, UM2435 Table 10 and 7.5.2) and CN6-6 (GND, UM2435 Table 10, CN6 +# pins 6 and 7). bundle2 offers only the loopback: its DIO9 sits on PC0. wiring_sets: bundle1: description: >- @@ -101,10 +131,58 @@ wiring_sets: SPI1, LPUART1 with flow control, the TIM1/TIM2 encoders, GPIO, EXTI, LED, watchdog toggle and ADC1; W1/W2 on two ADC inputs options: - loopback: >- - jumper CN10-15 (PA7, SPI1 MOSI) to CN10-13 (PA6, SPI1 MISO), or D11 - to D12; the SPI tests then leave DIO7 an input and check the - read-back + loopback: + description: >- + jumper the D11 socket (CN5-4, PA7, SPI1 MOSI) to the D12 socket + (CN5-5, PA6, SPI1 MISO), UM2435 Table 10; CN10-15 and CN10-13 + carry the DIO1 and DIO7 leads. The SPI tests then leave DIO7 an + input and check the read-back. Fit the jumper only with --with + loopback; not together with --with spiloop (both use D11/D12) + jumpered: + PA7: [PA6] + loads: [PA6, PA7] + excludes: [spiloop] + i2c: + description: >- + I2C1 (PB8 SCL, PB9 SDA) to I2C3 (PC0 SCL, PC1 SDA) with a 24Cxx + EEPROM, ST's one-board pairing (STM32CubeWB + Examples_LL/I2C/I2C_OneBoard_Communication_IT readme), built on a + breadboard: D15 socket (CN5-10, PB8) and A0 socket (CN8-1, PC0) + to the SCL row; D14 socket (CN5-9, PB9) and A1 socket (CN8-2, PC1) + to the SDA row (UM2435 Table 10); CN6-4 (3V3, UM2435 Table 10 and + 7.5.2) to the 3V3 rail; CN6-6 (GND, UM2435 Table 10 CN6 pins 6 + and 7; CN6-7 spare) to the GND rail. 4.7 kOhm from the SCL row + and from the SDA row to the 3V3 rail. EEPROM (24LC256/AT24C256, + DIP-8): pins 1-3 (A0-A2, address 0x50), 4 (VSS) and 7 (WP) to the + GND rail, 5 to the SDA row, 6 to the SCL row, 8 (VCC) to the 3V3 + rail, 100 nF from VCC to GND at the part. The AD3 leads stay: + DIO3 (CN10-3) observes SCL, DIO5 (CN10-5) SDA. Measure 3.3 V + between the rails before inserting the EEPROM + jumpered: + PB8: [PC0] + PB9: [PC1] + loads: [PB8, PB9, PC0, PC1] + pullups: [PB8, PB9, PC0, PC1] + spiloop: + description: >- + SPI1 (PA5 SCK, PA6 MISO, PA7 MOSI, PA4 NSS) to SPI2 (PB13, PB14, + PB15, PB12), ST's one-board pairing (STM32CubeWB + Examples_LL/SPI/SPI_OneBoard_HalfDuplex_IT_Init readme): D13 + socket (CN5-6, PA5) to CN10-30 (PB13, via SB12); D12 socket + (CN5-5, PA6) to CN10-28 (PB14); D11 socket (CN5-4, PA7) to CN10-26 + (PB15, via SB9); D10 socket (CN5-3, PA4 via SB41) to a breadboard + row, CN10-16 (PB12, its only position) to the same row with an + M-F wire, and the DIO14 lead moved from CN10-16 to a male pin in + that row (UM2435 Tables 10 and 11, Fig. 8). SPI2 is observed + through DIO6, DIO7, DIO1 and DIO14. Not together with --with + loopback (both use D11/D12) + jumpered: + PA5: [PB13] + PA6: [PB14] + PA7: [PB15] + PA4: [PB12] + loads: [PA4, PA5, PA6, PA7, PB12, PB13, PB14, PB15] + excludes: [loopback] jumpers: - >- AD3 GND to CN10-9 and CN10-20; scope 1- and 2- to CN7-19 and CN7-20 @@ -116,27 +194,31 @@ wiring_sets: 0: pin: tim1ch1 note: >- - TIM1 CH1, encoder TIM1 A, GPIO (CN10-25; D6 = CN9-7) + TIM1 CH1, encoder TIM1 A, MCO, GPIO (CN10-25; D6 = CN9-7) 1: pin: tim1ch1n note: >- - TIM1 CH1N, SPI1 MOSI, GPIO (CN10-15 via SB1; D11 = CN5-4) + TIM1 CH1N, SPI1 MOSI, I2C3 SCL, QUADSPI IO2, GPIO (CN10-15 via + SB1; D11 = CN5-4) 2: pin: tim1ch2 note: >- - TIM1 CH2, encoder TIM1 B, GPIO (CN10-19 via SB8; D9 = CN5-2) + TIM1 CH2, encoder TIM1 B, I2C1 SCL, GPIO (CN10-19 via SB8; D9 = + CN5-2) 3: pin: tim1ch2n note: >- - TIM1 CH2N, GPIO (CN10-3; D15 = CN5-10) + TIM1 CH2N, I2C1 SCL, QUADSPI IO1, GPIO (CN10-3; D15 = CN5-10) 4: pin: tim1ch3 note: >- - TIM1 CH3, TIM17 break, GPIO (CN10-31 via SB11; D3 = CN9-4) + TIM1 CH3, TIM17 break, I2C1 SDA, GPIO (CN10-31 via SB11; D3 = + CN9-4) 5: pin: tim1ch3n note: >- - TIM1 CH3N, TIM17 CH1, GPIO (CN10-5; D14 = CN5-9) + TIM1 CH3N, TIM17 CH1, I2C1 SDA, QUADSPI IO0, GPIO (CN10-5; D14 = + CN5-9) 6: pin: spi1clk note: >- @@ -144,12 +226,13 @@ wiring_sets: 7: pin: spi1miso note: >- - SPI1 MISO, LPUART1 CTS, TIM16 CH1, EXTI line 6 partner of PC6, - GPIO (CN10-13; D12 = CN5-5) + SPI1 MISO, LPUART1 CTS, TIM16 CH1, QUADSPI IO3, EXTI line 6 + partner of PC6, GPIO (CN10-13; D12 = CN5-5) 8: pin: spi1cs note: >- - SPI1 chip select, GPIO (CN10-17 via SB5; D10 = CN5-3 via SB41) + SPI1 chip select and NSS, GPIO (CN10-17 via SB5; D10 = CN5-3 via + SB41) 9: pin: qei2a note: >- @@ -166,15 +249,17 @@ wiring_sets: 12: pin: lpuart1tx note: >- - LPUART1 TX, TIM2 CH3, GPIO (CN10-35 via SB15; D1 = CN9-2) + LPUART1 TX, TIM2 CH3, QUADSPI NCS, GPIO (CN10-35 via SB15; D1 = + CN9-2) 13: pin: lpuart1rx note: >- - LPUART1 RX, TIM2 CH4, GPIO (CN10-37; D0 = CN9-1) + LPUART1 RX, TIM2 CH4, QUADSPI CLK, GPIO (CN10-37; D0 = CN9-1) 14: pin: lpuart1rts note: >- - LPUART1 RTS, TIM1 break, GPIO (CN10-16) + LPUART1 RTS, TIM1 break, SPI2 NSS, GPIO (CN10-16; with --with + spiloop the lead sits in the PB12 breadboard row) 15: pin: led0 note: >- @@ -196,10 +281,17 @@ wiring_sets: LPTIM encoder tests and tests that need nothing on DIO9/DIO10/W2 run with it options: - loopback: >- - jumper CN10-15 (PA7, SPI1 MOSI) to CN10-13 (PA6, SPI1 MISO), or D11 - to D12; the SPI tests then leave DIO7 an input and check the - read-back + loopback: + description: >- + jumper the D11 socket (CN5-4, PA7, SPI1 MOSI) to the D12 socket + (CN5-5, PA6, SPI1 MISO), UM2435 Table 10; CN10-15 and CN10-13 + carry the DIO1 and DIO7 leads. The SPI tests then leave DIO7 an + input and check the read-back. Fit the jumper only with --with + loopback + jumpered: + PA7: [PA6] + loads: [PA6, PA7] + excludes: [spiloop] jumpers: - >- AD3 GND to CN10-9 and CN10-20; scope 1- and 2- to CN7-19 and CN7-20 @@ -211,27 +303,31 @@ wiring_sets: 0: pin: tim1ch1 note: >- - TIM1 CH1, encoder TIM1 A, GPIO (CN10-25; D6 = CN9-7) + TIM1 CH1, encoder TIM1 A, MCO, GPIO (CN10-25; D6 = CN9-7) 1: pin: tim1ch1n note: >- - TIM1 CH1N, SPI1 MOSI, GPIO (CN10-15 via SB1; D11 = CN5-4) + TIM1 CH1N, SPI1 MOSI, I2C3 SCL, QUADSPI IO2, GPIO (CN10-15 via + SB1; D11 = CN5-4) 2: pin: tim1ch2 note: >- - TIM1 CH2, encoder TIM1 B, GPIO (CN10-19 via SB8; D9 = CN5-2) + TIM1 CH2, encoder TIM1 B, I2C1 SCL, GPIO (CN10-19 via SB8; D9 = + CN5-2) 3: pin: tim1ch2n note: >- - TIM1 CH2N, GPIO (CN10-3; D15 = CN5-10) + TIM1 CH2N, I2C1 SCL, QUADSPI IO1, GPIO (CN10-3; D15 = CN5-10) 4: pin: tim1ch3 note: >- - TIM1 CH3, TIM17 break, GPIO (CN10-31 via SB11; D3 = CN9-4) + TIM1 CH3, TIM17 break, I2C1 SDA, GPIO (CN10-31 via SB11; D3 = + CN9-4) 5: pin: tim1ch3n note: >- - TIM1 CH3N, TIM17 CH1, GPIO (CN10-5; D14 = CN5-9) + TIM1 CH3N, TIM17 CH1, I2C1 SDA, QUADSPI IO0, GPIO (CN10-5; D14 = + CN5-9) 6: pin: spi1clk note: >- @@ -239,12 +335,13 @@ wiring_sets: 7: pin: spi1miso note: >- - SPI1 MISO, LPUART1 CTS, TIM16 CH1, EXTI line 6 partner of PC6, - GPIO (CN10-13; D12 = CN5-5) + SPI1 MISO, LPUART1 CTS, TIM16 CH1, QUADSPI IO3, EXTI line 6 + partner of PC6, GPIO (CN10-13; D12 = CN5-5) 8: pin: spi1cs note: >- - SPI1 chip select, GPIO (CN10-17 via SB5; D10 = CN5-3 via SB41) + SPI1 chip select and NSS, GPIO (CN10-17 via SB5; D10 = CN5-3 via + SB41) 9: pin: lptim1in1 note: >- @@ -261,15 +358,16 @@ wiring_sets: 12: pin: lpuart1tx note: >- - LPUART1 TX, TIM2 CH3, GPIO (CN10-35 via SB15; D1 = CN9-2) + LPUART1 TX, TIM2 CH3, QUADSPI NCS, GPIO (CN10-35 via SB15; D1 = + CN9-2) 13: pin: lpuart1rx note: >- - LPUART1 RX, TIM2 CH4, GPIO (CN10-37; D0 = CN9-1) + LPUART1 RX, TIM2 CH4, QUADSPI CLK, GPIO (CN10-37; D0 = CN9-1) 14: pin: lpuart1rts note: >- - LPUART1 RTS, TIM1 break, GPIO (CN10-16) + LPUART1 RTS, TIM1 break, SPI2 NSS, GPIO (CN10-16) 15: pin: led0 note: >- @@ -286,7 +384,21 @@ wiring_sets: # literal); `hangs` gaps abort or hang the firmware, so their tests are # skipped unless --run-known-gaps; the others are expected failures. --fake # ignores them. -known_gaps: [] +known_gaps: + - tests: + - test_spi_ext.py::test_hardware_nss* + reason: >- + hal_st/stm32fxxx/SpiMasterStm.cpp:24, SpiMasterStmDma.cpp:38 and + hal_st/synchronous_stm32fxxx/SynchronousSpiMasterStm.cpp:23 - the SPI + masters mux slaveSelect to NSS but initialise SPI_NSS_SOFT, so NSS is + never driven; hardware NSS framing needs a decision (DESIGN B.14) + - tests: + - "test_qspi.py::test_poll_timeout[*poll*]" + reason: >- + QuadSpiStm.cpp:52,73,97 - QuadSpiStm waits with HAL_MAX_DELAY, so a + PollStatus whose status never matches blocks the event loop for good + instead of answering ERR timeout (B.14, needs a decision) + hangs: true # Test parameters. A mapping used by `@pytest.mark.matrix` is one dimension # per key: `--depth full` runs the cartesian product, `--depth quick` a @@ -313,6 +425,8 @@ tests: - qei.open 3 - adc.open 4 - wdt.start 1 timeout=100 + - spis.open 0 + - spis.open 3 # Features the MCU lacks: ERR unsupported. unsupported_instances: - qei.open 16 @@ -503,6 +617,15 @@ tests: mosi: spi1mosi miso: spi1miso cs: spi1cs + # SPI2 reaches the AD3 only through the spiloop jumpers (`option`): + # its tests skip without --with spiloop. + - name: spi2 + index: 2 + clk: spi2clk + mosi: spi2mosi + miso: spi2miso + cs: spi2nss + option: spiloop variants: interrupt: dma: 0 @@ -614,12 +737,17 @@ tests: ab: 4 a: 2 b: 2 + # LPTIM capture modes: both edges of both inputs (x4) or the rising or + # falling edges only (x2). + rise: 2 + fall: 2 lp_position: freq: [100, 1000, 10000] cycles: [1, 10, 250] direction: [fwd, rev] inva: [0, 1] filter: [0, 2, 4, 8] + cap: [ab, rise, fall] velocity: freq: [1000, 10000] vel: [1000, 10000] @@ -647,6 +775,615 @@ tests: feed_seconds: 2.0 observe_periods: 3 period_tolerance: 0.03 + i2c: + # Standalone cases on bundle1 with the MCU pull-ups (pull=up), each + # instance on its own pins; `observed` names the lines with a DIO + # (PB8 DIO3, PB9 DIO5, PA9 DIO2, PA10 DIO4, PA7 DIO1; PB4 has none). + instances: + - name: i2c1 + index: 1 + scl: PB8 + sda: PB9 + observed: [scl, sda] + - name: i2c1alt + index: 1 + scl: PA9 + sda: PA10 + observed: [scl, sda] + - name: i2c3 + index: 3 + scl: PA7 + sda: PB4 + observed: [scl] + # --with i2c: I2C3 master on PC0/PC1, the i2cs target on I2C1 PB8/PB9 + # (jumpered PB8-PC0, PB9-PC1; DIO3 sees SCL, DIO5 SDA), 4.7 kOhm + # pull-ups and the 24LC256 at 0x50 on the same rows. + loop: + master: {index: 3, scl: PC0, sda: PC1} + target: {index: 1, scl: PB8, sda: PB9, addr: 0x42} + freqs: + standalone: [100000] + option: [100000, 400000] + # Nobody answers these: an address NACK (absent_addr) and the + # arbitration-loss address (all ones, so the injected 0 always loses). + absent_addr: 0x10 + arbitration_addr: 0x7f + payload_lengths: [1, 2, 255, 256, 257, 300, 1024] + # nack=k for k in nack_positions and nack_length - 1, nack_length. + nack_positions: [1, 2, 5] + nack_length: 8 + stretch_us: [100, 2000] + # Measured SCL frequency / nominal: I2cTiming never runs faster than + # nominal; a slow rise lowers it. + tolerance: [0.8, 1.01] + # 30-70 % rise time limit per mode (needs a scope on the bus rows; the + # WB55 scopes stay on the ADC inputs, so test_rise_time skips here). + rise_ns: {sm: 1000, fm: 300} + # D.2: the target's misplaced STOP needs tHIGH >= 4 us. + fault_freq: 100000 + eeprom: + # The adapter on the loop master's instance and pins; the i2cs target + # sits on the other instance for the 1-byte-address case. + index: 3 + scl: PC0 + sda: PC1 + addr: 0x50 + size: 32768 + page: 64 + abytes: 2 + wcycle_ms: 10 + erase_size: 8192 + freq: 400000 + # hal::SpiSlaveStmDma (`spis.*`): the AD3 SPI master (mode 0, MSB first, + # its chip select on `nss`) clocks the instances; `option` as above. + spis: + instances: + - name: spi1 + index: 1 + clk: spi1clk + miso: spi1miso + mosi: spi1mosi + nss: spi1nss + - name: spi2 + index: 2 + clk: spi2clk + miso: spi2miso + mosi: spi2mosi + nss: spi2nss + option: spiloop + freqs: [100000, 1000000, 4000000] + # Above 128 bytes the result is compared by CRC (`out=crc`). + lengths: [1, 2, 64, 255, 1024] + # The board's own master against the slave of the other instance + # (requires --with spiloop); EMIL's spi.xfer holds 64 bytes. The + # master drives the slave's NSS with its GPIO chip select `cs`. + loop_lengths: [1, 2, 64] + loop_baud: 1000000 + loop_variants: [interrupt, dma, sync] + loops: + - name: spi1-to-spi2 + master: + index: 1 + clk: spi1clk + mosi: spi1mosi + miso: spi1miso + cs: spi1cs + slave: + index: 2 + clk: spi2clk + miso: spi2miso + mosi: spi2mosi + nss: spi2nss + - name: spi2-to-spi1 + master: + index: 2 + clk: spi2clk + mosi: spi2mosi + miso: spi2miso + cs: spi2nss + slave: + index: 1 + clk: spi1clk + miso: spi1miso + mosi: spi1mosi + nss: spi1nss + # spi.open lsb=, bits= (dma=1, SpiDataSizeConfiguratorStm) and nss=. Frames + # above 8 bits take two buffer bytes, little endian (spiwords.spi_frames), + # so `payload` has an even length. `bits` lists the sizes decoded on the + # logic analyzer per instance; `limited` instances take 8 and 16 only. + spi_ext: + instances: + - name: spi1 + index: 1 + clk: spi1clk + mosi: spi1mosi + miso: spi1miso + cs: spi1cs + nss: spi1nss + bits: [4, 8, 12, 16] + - name: spi2 + index: 2 + clk: spi2clk + mosi: spi2mosi + miso: spi2miso + cs: spi2nss + nss: spi2nss + bits: [4, 8, 12, 16] + option: spiloop + lsb: [0, 1] + baud: 1000000 + payload: "a53c0f8001fe7e42" + timer: + # bundle1: the marker (gpio0, DIO11) toggles once per update interrupt. + timers: + - name: tim1 + timer: 1 + - name: tim2 + timer: 2 + - name: tim16 + timer: 16 + - name: tim17 + timer: 17 + marker: gpio0 + # TIM numbers the MCU lacks: ERR range. + missing: [3, 15, 18] + # No counter mode select (up counting only): mode=down is ERR unsupported. + up_only: [16, 17] + # Update rate timclk / ((prescaler + 1) (period + 1)); `dispatched: + # false` marks rates above 1 kHz, where dispatched callbacks coalesce. + interrupt: + irq: [immediate, dispatched] + update: + - name: 100hz + prescaler: 639 + period: 999 + - name: 1khz + prescaler: 63 + period: 999 + - name: 20khz + prescaler: 0 + period: 3199 + dispatched: false + # Free running (irq=none): a 1 kHz counter that wraps after 65.5 s. + free_running: + prescaler: 63999 + period: 65535 + window_s: 0.5 + record_periods: 10 + tolerance: + frequency: 0.01 + count: 0.05 + latency_s: 0.02 + timer_pwm: + # Every channel pin of bundle1 in channel order (null = `-`); TIM1 CH4 + # (PA11) is on no header of a stock board, so TIM1 runs N = 3. + timers: + - name: tim1 + timer: 1 + pins: [tim1ch1, tim1ch2, tim1ch3] + - name: tim2 + timer: 2 + pins: [tim2ch1, tim2ch2, tim2ch3, tim2ch4] + - name: tim16 + timer: 16 + pins: [tim16ch1] + - name: tim17 + timer: 17 + pins: [tim17ch1] + # 1 MHz counter (64 MHz / 64), 10 kHz period: the low tick of 100 % + # lasts 1 us. + prescaler: 63 + period: 99 + duties: [0, 50, 100] + channel_duties: [20, 40, 60, 80] + # tpwm.pulse 1 + pulse: + ccr: 30 + period: 199 + # A pin without a channel function of any timer under test. + foreign_pin: gpio0 + record_periods: 20 + tolerance: + frequency: 0.01 + duty: 0.01 + lptim: + instances: + - name: lptim1 + index: 1 + - name: lptim2 + index: 2 + marker: gpio0 + missing: [0, 3] + # Update rate lptimclk / prescaler / (period + 1) at 64 MHz. + interrupt: + irq: [immediate, dispatched] + update: + - name: 100hz + prescaler: 128 + period: 4999 + - name: 1khz + prescaler: 64 + period: 999 + - name: 16khz + prescaler: 1 + period: 3999 + dispatched: false + # Every divider with this period, immediate interrupt (500 Hz .. 64 kHz). + prescalers: [1, 2, 4, 8, 16, 32, 64, 128] + prescaler_period: 999 + # The STM32WB LPTIM has no repetition counter: `rep` is ERR unsupported. + repetition: false + repetitions: [] + free_running: + prescaler: 128 + period: 65535 + window_s: 0.5 + record_periods: 10 + tolerance: + frequency: 0.01 + count: 0.05 + latency_s: 0.02 + pwm_ext: + # A pwm.timers entry with two wired channels and a counter mode select. + alignment_timer: tim1 + modes: [edge, edgedown, center, centerup, centerboth] + alignment_frequency: 10000 + alignment_duties: [30, 60] + # edgedown cannot reach 0 %: the output stays active for one counter + # tick per period (PwmStm.hpp). The prescaler makes a tick `tick_us`. + zero_duty: + tick_us: 10 + frequency: 100 + periods: 5 + # pwm.duty 80 then 20, runt_repeats times while the LA records + # runt_window_s: with preload=0 at least one high pulse lies strictly + # between 20 % and 80 % of the period (beyond runt_margin), with + # preload=1 none. 1 MHz counter clock. + preload_timer: tim1 + preload_frequency: 200 + runt_repeats: 20 + runt_window_s: 0.5 + runt_margin: 0.03 + # pwm.timers entries with `brk`, each hit by one AD3 pulse of `ticks` + # timer kernel clocks (brkpol=high, brkauto=0). BKF 15 samples 8 times + # at fDTS / 32: 256 kernel clocks. + break_filter_timers: [tim1, tim17] + break_filter_cases: + - name: unfiltered_short + filter: 0 + ticks: 64 + trips: true + - name: filtered_short + filter: 15 + ticks: 64 + trips: false + - name: filtered_long + filter: 15 + ticks: 1024 + trips: true + break_frequency: 10000 + break_settle_s: 0.01 + # adc.open trgo= runs one sequence per period of the pwm + # group's timer (pwm.timers names); test_trgo_paces_adc compares + # n=trgo_runs with n=1 like test_adc.py::test_trigger_rate. + trgo: + timer: [tim1, tim2] + source: [update, oc1ref] + trgo_frequency: 1000 + trgo_runs: 64 + trgo_repeats: 3 + trgo_tolerance: 0.2 + trgo_jitter_s: 0.002 + # Driven by the pwm group, but no TRGO the ADC can trigger from. + trgo_unsupported_timers: [tim16] + capture_periods: 20 + tolerance: + frequency: 0.01 + duty: 1.0 + alignment_s: 2.0e-7 + ain: + # W1/W2 inputs (bundle1); codes against tests.adc vref/bits/tolerance. + pins: [ain4, ain3] + levels: [0.2, 1.65, 3.1] + sampling: ["2.5", "47.5", "640.5"] + # The sensor needs a long sampling time; the driver's default is below + # its minimum (B.14), so with it only "a code arrives" is checked. + temp_sampling: "640.5" + temp_range: [10, 45] + # One AdcTriggeredByTimerWithDma over a `buffer`-sample buffer per + # command, Measure(n) at `rate`: n samples in n / rate (B.12). + burst: + n: [16, 100] + rate: 1000 + buffer: 256 + tolerance: 0.1 + latency_s: 0.01 + # Wavegen ramp during an out=list burst: the codes rise with the ramp. + ramp: + low: 0.3 + high: 3.0 + frequency: 2 + n: 64 + rate: 1000 + dma: + # dma.wave output pin, wired to a DIO in every wiring set. + pin: gpio0 + wave: + rate: [1000, 100000, 1000000] + pattern: ["a5", "0ff0", "deadbeef", "0100000000000080"] + ms: 300 + samples_per_bit: 8 + record_bits: 200 + start_delay_s: 0.05 + tolerance: + rate: 0.01 + bit: 0.25 + uart_sendonly: + # hal::SynchronousUartStmSendOnly; on STM32WB the LPUART goes through + # the SyncLpUart constructors. `rx` only gives the AD3 UART a transmit + # DIO: the send-only driver leaves the pin alone. + instances: + - name: lpuart1 + index: 1 + lp: 1 + tx: lpuart1tx + rx: lpuart1rx + rts: lpuart1rts + cts: lpuart1cts + # Opens on lpuart1tx/lpuart1rx without pins. + default_pins: true + # LPUART1 at 64 MHz takes 15626 Bd and more. + bauds: [19200, 115200, 921600] + payloads: ["55", "00ff", "0123456789abcdef", "48656c6c6f2c20484954"] + # uart.close right after uart.send: SendData returns at TXE, so the + # last frame (0.5 ms at 19200 Bd) may be cut. + cut: + baud: 19200 + payload: 16 + # LPUARTs without send-only constructors: none on STM32WB. + unsupported: [] + sgpio: + # SynchronousOutputPinStm; led0 carries LED2 and 680 ohm to GND. + out_pins: [tim1ch1, led0] + # Output pins without a board load, for the input-without-pull check. + float_pins: [tim1ch1] + speeds: [low, medium, fast, high] + # Five free pins for the four output and four alternate-function slots. + limit_pins: [gpio0, gpio1, gpio2, gpio3, gpio4] + # SmallPeripheralPinStm on TIM2 CH1 (AF1). + af: + pin: tim2ch1 + timer: 2 + ch: 1 + af: 1 + # MultiGpioPinStm + MultiPeripheralPinStm: TIM2 CH1 on PA15 and PA5. + multi: + pins: [tim2ch1, spi1clk] + timer: 2 + ch: 1 + # tpwm runs TIM2 CH1 without a pin of its own (`-`); CH2 drives + # tim2ch2. 64 MHz / 64 / 1000 = 1 kHz. + tpwm: + timer: 2 + pins: ["-", tim2ch2] + prescaler: 63 + period: 999 + duty: 50 + record_periods: 50 + tolerance: + frequency: 0.01 + duty: 0.02 + # No TIM2 CH1 function; a timer the MCU lacks. + foreign_pin: gpio0 + missing_timer: 4 + clock: + frequencies: + sysclk: 64000000 + hclk: 64000000 + pclk1: 64000000 + pclk2: 64000000 + # Oscillator ready flags after ConfigureDefaultClockNucleoWB55RG. + flags: + hse: 1 + lse: 1 + hsi: 1 + hsi48: 1 + pll: 1 + # RNG on CLK48, CLK48 on HSI48 (reset defaults of RCC_CCIPR). + rngsel: clk48 + clk48: hsi48 + # MCO on PA8 (AF0, DIO0 of bundle1), muxed with `sgpio.af PA8 af=0`. + mco: + pin: PA8 + af: 0 + # Rising edges of the least-squares fit. + periods: 1000 + outputs: + # SYSCLK = PLL from HSI16 (+-1 %). + - name: sysclk-div16 + source: sysclk + div: 16 + hz: 4000000 + tolerance: 0.01 + - name: hsi-div16 + source: hsi + div: 16 + hz: 1000000 + tolerance: 0.01 + # 32 MHz crystal. + - name: hse-div16 + source: hse + div: 16 + hz: 2000000 + tolerance: 0.001 + # 32.768 kHz crystal, +-100 ppm including the AD3 time base. + - name: lse + source: lse + div: 1 + hz: 32768 + tolerance: 0.0001 + # HSI48 without CRS trimming. + hsi48: + div: 16 + hz: 3000000 + tolerance: 0.05 + uid: + # Bytes [5, 12) of `info uid=` hold the lot number in ASCII. + lot: [5, 12] + rng: + # sync: SynchronousRandomDataGeneratorStm; async: RandomDataGeneratorStm; + # hsem: SynchronousSynchronizedRandomDataGeneratorStm (HSEM 0 around the + # read, HSI48 through Hsi48Enabler unless semaphore 5 is locked). + variants: [sync, async, hsem] + lengths: [1, 2, 3, 4, 5, 7, 16, 127, 128] + stats: + length: 65536 + # Seconds the host waits for the rng.stats reply (the firmware gives + # the async variant 5 s). + timeout_s: 6.0 + # HSEM semaphores the hsem variant takes (0) or reads (5): free after + # every read (hsem.status, when the hsem group is built in). + semaphores: [0, 5] + # Every variant exists on STM32WB55. + unsupported: [] + aes: + # FIPS-197 C.1 and NIST SP 800-38A F.1.1 (ECB-AES128, all four blocks); + # a list of hex strings is one byte string. + vectors: + - name: fips197-c1 + key: "000102030405060708090a0b0c0d0e0f" + plaintext: "00112233445566778899aabbccddeeff" + ciphertext: "69c4e0d86a7b0430d8cdb78070b4c55a" + - name: sp800-38a-f11 + key: "2b7e151628aed2a6abf7158809cf4f3c" + plaintext: + - "6bc1bee22e409f96e93d7e117393172a" + - "ae2d8a571e03ac9c9eb76fac45af8e51" + - "30c81c46a35ce411e5fbc1191a0a52ef" + - "f69f2445df4f9b17ad2b417be66c3710" + ciphertext: + - "3ad77bb40d7a3660a89ecaf32466ef97" + - "f5d3d58503b9699de785895a96fdbaaf" + - "43b1cd7f598ece23881b00e3ed030688" + - "7b0c785e27e8ad3f8223207104725dd4" + # AES_CR.DATATYPE of hal::detail::DataSwapping, against + # crypto_ref.aes_stm (model from the reference manual). + swaps: [none, half, byte, bit] + # Bytes per command: one to five blocks (the group holds 80). + lengths: [16, 32, 48, 64, 80] + pka: + # Multiples of G on services::secp256r1 (P-256): k -> (x, y). + multiples: + - name: k1 + k: "01" + x: "6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296" + y: "4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5" + - name: k2 + k: "02" + x: "7cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc47669978" + y: "07775510db8ed040293d9ac69f7430dbba7dade63ce982299e04b79d227873d1" + - name: k3 + k: "03" + x: "5ecbe4d1a6330a44c8f7ef951d4bf165e6c6b721efada985fb41661bc6e7fd6c" + y: "8734640c4998ff7e374b06ce1a64a2ecd82ab036384fb83d9a79b127a27d5032" + # pka.cmp operand sizes in bytes (4..60; 60 keeps the line at 253). + compare_lengths: [4, 32, 60] + # One full-scalar multiplication at 64 MHz takes tens of ms. + max_us: 500000 + flash: + # sync: SynchronousFlash*InternalStm; async: Flash*InternalStm; coord: + # FlashCoordinatedWithWirelessStack over the async driver. + variants: [sync, async, coord] + # homogeneous: one sector per page; table: single pages, then the + # pattern 1, 1, 2, 4 pages twice (groups.system_ext.table_sectors). + layouts: [homogeneous, table] + # Scratch region: absolute pages 64-127 (0x08040000, below the 512 KB + # the linker script gives the firmware), 4 KB pages, 64-bit flash words. + base: 0x08040000 + first_page: 64 + pages: 64 + page: 4096 + word: 8 + # Page erase time (DS11929: 22 ms typical), wide bounds per page. + erase_us_per_page: [5000, 50000] + # hsem.take 7 hold= before a coordinated step; the step may start up to + # hold_margin_ms early (the time between the two commands). + hsem_hold_ms: 200 + hold_margin_ms: 50 + # The firmware's ERR timeout of a held write. + transfer_timeout_s: 2.0 + # wdt.start timeout for the coordinated erase under a running watchdog: + # warning period 64.5 ms, longer than one page erase with interrupts + # masked. + watchdog_timeout_ms: 50 + hsem: + # A semaphore nothing else uses (rng takes 0 and reads 5, the + # coordinated flash 7). + semaphore: 12 + # COREID of CPU1 in HSEM_R. + core: 4 + holds_us: [2000, 20000] + tolerance: 0.1 + # A free semaphore locks within this time; also the slack of the hold. + free_wait_max_us: 100 + bkp: + # RTC BKP0R-BKP19R. + words: 20 + lowpower: + # Defaults of the firmware (BoardProfile lowPowerDefaults). + wake: gpio0 + marker: led0 + modes: [sleep, deep] + edges: [rising, falling] + # Wake edge to marker high. + latency_us: 50 + # Host delay between lpm.enter and the AD3 edge. + wake_delay_s: 0.05 + wake_timeout_ms: 2000 + # test_timeout: no edge. + timeout_ms: 50 + la_rate: 4000000 + unbonded_pin: PC7 + qspi: + index: 1 + # board::qspiPins (aliases qspiclk, qspincs, qspiio0-3), all on bundle1 + # DIOs: CLK DIO13, NCS DIO12, IO0 DIO5, IO1 DIO3, IO2 DIO1, IO3 DIO7. + pins: + clk: PA3 + ncs: PA2 + io: [PB9, PB8, PA7, PA6] + # 64 MHz / 8 = 8 MHz: 12.5 samples per clock at the AD3's 100 MS/s. + decode_prescaler: 7 + # 64 MHz / 256 = 250 kHz: 16 bytes in the FIFO take 0.5 ms to leave on + # one line (B.15, flevel). + slow_prescaler: 255 + flevel_length: 64 + # Decoded writes: every phase, on 1 and on 4 lines. + lines: [1, 4] + write: + instr: 0x38 + addr: 0x123456 + abytes: 3 + alt: 0xa55a + altbytes: 2 + data: "00ff5aa5c3" + # An instruction-only write, and the instruction of the 1-line reads. + instruction: 0x06 + read_instr: 0x0b + read_length: 4 + # 4-line reads of static AD3 levels: each byte is the nibble twice. + nibbles: [0x0, 0x5, 0xa, 0xf] + # Status poll: 1-line instruction, 1-line status on IO1 (DIO3). + poll: + instr: 0x05 + match: 0x01 + mask: 0x01 + # Two writes from one completion callback (repeat), 1 line. + back_to_back: + length: 32 + seed: 1 + repeat: 2 + xfer: "9f0102a5" unsupported: commands: - comp.open 1 @@ -657,9 +1394,12 @@ tests: - can.open 1 - can.send 1 0x123 01 - can.close 1 - - eeprom.write 0 00 - - eeprom.read 0 1 - - eeprom.erase - eth.open - eth.status - eth.close + - lptpwm.open 1 pins=PA15 + - lptpwm.duty 1 1 50 + - lptpwm.pulse 1 1 10 100 + - lptpwm.start 1 + - lptpwm.stop 1 + - lptpwm.close 1 diff --git a/validation/host/boards/nucleo_wba55cg.yaml b/validation/host/boards/nucleo_wba55cg.yaml index 9f2149fa..4cdbc97a 100644 --- a/validation/host/boards/nucleo_wba55cg.yaml +++ b/validation/host/boards/nucleo_wba55cg.yaml @@ -21,6 +21,10 @@ clocks: usart1: 100000000 usart2: 100000000 lpuart1: 100000000 + # Reset kernel clock selection: LPTIM1 PCLK7, LPTIM2 PCLK1. + lptim: + 1: 100000000 + 2: 100000000 terminal: baud: 921600 @@ -85,6 +89,23 @@ pins: sw1: PC13 sw2: PB6 sw3: PB7 + i2c1scl: PB2 + i2c1sda: PB1 + i2c3scl: PA6 + i2c3sda: PA7 + spi3clk: PA0 + spi3miso: PB9 + spi3mosi: PB8 + spi3nss: PA5 + spi1nss: PA12 + lptim1ch2: PA15 + lptim2ch1: PA11 + lptim2ch2: PA1 + lptim1in1: PA0 + lptim1in2: PB3 + lptim2in1: PB9 + lptim2in2: PB0 + tim16ch1n: PB8 ad3: # V+/V- stay off (null): the Nucleo is powered from the ST-LINK USB. @@ -93,9 +114,19 @@ ad3: # Wavegen levels are refused outside this range (3.3 V I/O). analog_limits: [0.0, 3.3] -# One fixed bundle covers every test. Header positions follow the Nucleo-64 -# Arduino mapping (check against UM3390). Always connect AD3 GND to a Nucleo -# GND pin. +# Wiring sets: wire one, run its tests, then switch. The board manual is +# UM3301, which could not be fetched, so every position names its source: +# Arduino labels (Dn, An) from Zephyr's nucleo_wba55cg +# arduino_r3_connector.dtsi; CN3/CN4 morpho and CN6-CN8 Arduino positions +# from the STM32CubeWBA v1.10.0 example readmes ("readmes xN": N readmes +# agree); CN4-35/CN4-37 from modm-data's transcription of UM3301 Table 8; +# 3V3 CN5-4 and GND CN5-6 from the STM32-Sidewalk-SDK README (a single +# source: do the pre-power check of validation/README.md). AD3 leads sit +# on male morpho pins where a sourced position exists; DIO11/DIO13 use a +# male pin in their Arduino socket, and the A1/A4 sockets are named by +# their label only. Options as on the NUCLEO-WB55RG (`jumpered` key pin = +# the end test_wiring.py drives); bundle1 offers only the loopback (its +# W1/W2 sit on the I2C3 pins), bundle2 also the I2C and SPI loops. wiring_sets: bundle1: description: >- @@ -104,74 +135,308 @@ wiring_sets: TIM1/TIM3 encoders, GPIO, EXTI, LED, watchdog toggle and ADC4; W1/W2 on two ADC inputs options: - loopback: >- - jumper PA15 (SPI1 MOSI, D11) to PB3 (SPI1 MISO, D12); the SPI tests - then leave DIO1 an input and check the read-back + loopback: + description: >- + jumper the D11 socket (CN6-4, PA15, SPI1 MOSI) to the D12 socket + (CN6-5, PB3, SPI1 MISO): labels from Zephyr, positions from the + CubeWBA readmes (x14, x11); CN4-15 and CN4-10 carry the DIO2 and + DIO1 leads. The SPI tests then leave DIO1 an input and check the + read-back. Fit the jumper only with --with loopback + jumpered: + PA15: [PB3] + loads: [PA15, PB3] + excludes: [spiloop] jumpers: + - >- + AD3 GND to CN5-6 (Sidewalk SDK README: GND on CN5 pin 6, the only + sourced GND pin). Scope 1- and 2- need GND as well: feed a + breadboard GND rail from CN5-6 and put the AD3 GND, 1- and 2- leads + on male pins in that rail + - >- + check before wiring (modm-data, UM3301 Table 8): MB1801 SB7/SB8 + connect PA10/PB5 to the ST-LINK VCP2; MB1801 SB25 with MB1803 SB8 + on and SB9 off routes STLINK_RTS to PA0 (with MB1803 SB32 on and + SB31 off to PB15); SB23/SB25 route STLINK_CTS to PB9. None is the + stock setting; test_wiring.py::test_no_foreign_drivers checks that + PA0, PB9, PA10, PB5 and PB15 follow the MCU pulls - >- PB4 (D13) also drives the blue LED LD1, which loads the SPI1 clock line + - >- + PB8 carries the red LED LD3 (active low, Zephyr board file): the LED + and its resistor pull PB8 towards 3V3, so no pull-level or idle + assertion uses PB8. PA9 is the green LD2, the firmware's debug LED, + dark unless SB28 is closed (open on a stock board) dio: 0: pin: spi1clk - note: SPI1 SCK, TIM1 CH3, blue LED (led0), GPIO (D13) + note: >- + SPI1 SCK, TIM1 CH3, blue LED LD1 (led0), GPIO (CN4-6, readmes x15; + D13) 1: pin: spi1miso - note: SPI1 MISO, TIM1 CH4, TIM17 CH1N, GPIO (D12) + note: >- + SPI1 MISO, I2C1 SDA, TIM1 CH4, TIM17 CH1N, GPIO (CN4-10, readmes + x13; D12) 2: pin: spi1mosi - note: SPI1 MOSI, encoder TIM1 index, TIM17 break, GPIO (D11) + note: >- + SPI1 MOSI, I2C1 SCL, LPTIM1 CH2, encoder TIM1 index, TIM17 break, + GPIO (CN4-15, readmes x12; D11) 3: pin: spi1cs - note: SPI1 chip select, TIM1 CH2, encoder TIM1 B, GPIO (D10) + note: >- + SPI1 chip select and NSS, TIM1 CH2, encoder TIM1 B, GPIO (CN4-17, + readmes x10; D10) 4: pin: tim1ch1 - note: TIM1 CH1, encoder TIM1 A, USART2 RX, GPIO (D4) + note: >- + TIM1 CH1, encoder TIM1 A, USART2 RX, LPTIM2 CH1, GPIO (CN4-24, + readmes x7; D4) 5: pin: tim1ch1n - note: TIM1 CH1N, USART2 CTS, GPIO (D15) + note: >- + TIM1 CH1N, USART2 CTS, I2C1 SCL, I2C3 SCL, GPIO (CN4-3, readmes + x31; D15) 6: pin: tim1ch2n - note: TIM1 CH2N, USART2 RTS, GPIO (D14) + note: >- + TIM1 CH2N, USART2 RTS, I2C1 SDA, I2C3 SDA, GPIO (CN4-5, readmes + x31; D14) 7: pin: tim1ch3n - note: TIM1 CH3N, USART2 TX, GPIO (D6) + note: >- + TIM1 CH3N, USART2 TX, encoder LPTIM2 B (LPTIM2 IN2), GPIO (CN4-25, + readme x1; D6) 8: pin: lpuart1tx - note: LPUART1 TX, GPIO (D1) + note: >- + LPUART1 TX, GPIO (CN4-35, modm-data UM3301 Table 8; D1) 9: pin: lpuart1rx - note: LPUART1 RX, TIM3 CH1, encoder TIM3 A, GPIO (D0) + note: >- + LPUART1 RX, TIM3 CH1, encoder TIM3 A, GPIO (CN4-37, modm-data + UM3301 Table 8; D0) 10: pin: lpuart1rts - note: LPUART1 RTS, TIM3 CH4, TIM16 CH1, GPIO (D7) + note: >- + LPUART1 RTS, TIM3 CH4, TIM16 CH1, encoder LPTIM2 A (LPTIM2 IN1), + GPIO (CN4-23, readmes x4; D7) 11: pin: lpuart1cts - note: LPUART1 CTS, TIM16 break, GPIO (D8) + note: >- + LPUART1 CTS, TIM16 break, GPIO (male pin in the D8 socket: no + sourced morpho position) 12: pin: tim3ch2 - note: TIM3 CH2, encoder TIM3 B, TIM17 CH1, GPIO (A3) + note: >- + TIM3 CH2, encoder TIM3 B, TIM17 CH1, LPTIM2 CH2, GPIO (CN3-34, + readme x1; A3) 13: pin: gpio0 note: >- GPIO/EXTI, TIM3 CH3, encoder TIM3 index, watchdog warning toggle - (D5) + (male pin in the D5 socket: no sourced morpho position) 14: pin: tim1bkin - note: TIM1 break input, GPIO (A2) + note: >- + TIM1 break input, GPIO (CN3-32, readmes x8; A2) 15: pin: tim2ch1 - note: TIM2 CH1, GPIO (A4) + note: >- + TIM2 CH1, GPIO (CN3-36, readmes x7; A4) wavegen: 1: pin: ain2 - note: ADC4 IN2 (A0) + note: ADC4 IN2 (CN3-28, readmes x23; A0) 2: pin: ain3 - note: ADC4 IN3 (A1) + note: ADC4 IN3 (CN3-30, readmes x10; A1) scope: - 1: ain2 - 2: ain3 + 1: + pin: ain2 + note: >- + same net as W1: male pin in the A0 socket (CN7-1, readmes x12), + 1- on the GND rail + 2: + pin: ain3 + note: >- + same net as W2: male pin in the A1 socket (label only), 2- on the + GND rail + bundle2: + description: >- + bundle1 with DIO8 on PA7, DIO9 on PA6, DIO11 on PB8 and DIO14 on PA0; + W1, W2 and both scopes unplugged (with --with i2c the scopes sit in + the I2C rows). SPI3 master and slave, PWM TIM2 CH3/CH4 and TIM16 + CH1N, timer PWM TIM2 with four channels, the LPTIM1 encoder (PA0, + PB3), the I2C and SPI loop options, and everything of bundle1 that + needs none of PB5, PA10, PB15, PA2, W1 and W2. The LPUART1 tests, the + TIM1/TIM16 break tests and the ADC tests skip + options: + loopback: + description: >- + jumper the D11 socket (CN6-4, PA15, SPI1 MOSI) to the D12 socket + (CN6-5, PB3, SPI1 MISO): labels from Zephyr, positions from the + CubeWBA readmes (x14, x11); CN4-15 and CN4-10 carry the DIO2 and + DIO1 leads. The SPI tests then leave DIO1 an input and check the + read-back. Fit the jumper only with --with loopback + jumpered: + PA15: [PB3] + loads: [PA15, PB3] + excludes: [spiloop] + i2c: + description: >- + I2C1 (PB2 SCL, PB1 SDA) to I2C3 (PA6 SCL, PA7 SDA) with a 24Cxx + EEPROM, ST's pairing (STM32CubeWBA + Examples_MIX/I2C/I2C_OneBoard_ComSlave7_10bits_IT README), built on + a breadboard: D15 socket (CN6-10, PB2) and A1 socket (PA6, label + only) to the SCL row; D14 socket (CN6-9, PB1) and A0 socket (CN7-1, + PA7) to the SDA row (labels from Zephyr, positions from the CubeWBA + readmes); CN5-4 (3V3) to the 3V3 rail and CN5-6 (GND) to the GND + rail (Sidewalk SDK README); the AD3 GND lead moves from CN5-6 to + the GND rail. 4.7 kOhm from the SCL row and from the SDA row to + the 3V3 rail (2.2 kOhm when test_rise_time fails). EEPROM wired as + on the NUCLEO-WB55RG (validation/README.md). Scope 1+ on a male pin + in the SCL row, 2+ in the SDA row, 1- and 2- on the GND rail. + DIO5 (CN4-3) and DIO9 (CN3-30) observe SCL, DIO6 (CN4-5) and DIO8 + (CN3-28) SDA. Measure 3.3 V between the rails before inserting the + EEPROM + jumpered: + PB2: [PA6] + PB1: [PA7] + loads: [PB2, PB1, PA6, PA7] + pullups: [PB2, PB1, PA6, PA7] + spiloop: + description: >- + SPI1 (PB4 SCK, PB3 MISO, PA15 MOSI, PA12 NSS) to SPI3 (PA0, PB9, + PB8, PA5): D13 socket (CN6-6, PB4) to the A5 socket (CN7-6, PA0); + D12 socket (CN6-5, PB3) to the D7 socket (CN8-8, PB9); D11 socket + (CN6-4, PA15) to a breadboard row, CN4-38 (PB8, its only position) + to the same row with an M-F wire, and the DIO11 lead moved from + CN4-38 to a male pin in that row; D10 socket (CN6-3, PA12) to the + A4 socket (PA5, label only). Labels from Zephyr, positions from + the CubeWBA readmes. SPI3 is observed through DIO14, DIO10, DIO11 + and DIO15. Not together with --with loopback (both use D11/D12) + jumpered: + PB8: [PA15] + PA0: [PB4] + PB9: [PB3] + PA5: [PA12] + loads: [PB4, PA0, PB3, PB9, PA15, PB8, PA12, PA5] + excludes: [loopback] + jumpers: + - >- + AD3 GND to CN5-6 (Sidewalk SDK README: GND on CN5 pin 6, the only + sourced GND pin); with --with i2c the lead moves to the breadboard + GND rail fed from CN5-6 + - >- + check before wiring (modm-data, UM3301 Table 8): MB1801 SB7/SB8 + connect PA10/PB5 to the ST-LINK VCP2; MB1801 SB25 with MB1803 SB8 + on and SB9 off routes STLINK_RTS to PA0 (with MB1803 SB32 on and + SB31 off to PB15); SB23/SB25 route STLINK_CTS to PB9. None is the + stock setting; test_wiring.py::test_no_foreign_drivers checks that + PA0, PB9, PA10, PB5 and PB15 follow the MCU pulls + - >- + PB4 (D13) also drives the blue LED LD1, which loads the SPI1 clock + line + - >- + PB8 (DIO11) carries the red LED LD3 (active low, Zephyr board + file): the LED and its resistor pull PB8 towards 3V3, so no + pull-level or idle assertion uses PB8, and the TIM16 entry of this + set has no break input. PA9 is the green LD2, the firmware's debug + LED, dark unless SB28 is closed (open on a stock board) + dio: + 0: + pin: spi1clk + note: >- + SPI1 SCK, TIM1 CH3, blue LED LD1 (led0), GPIO (CN4-6, readmes x15; + D13) + 1: + pin: spi1miso + note: >- + SPI1 MISO, I2C1 SDA, TIM1 CH4, TIM17 CH1N, encoder LPTIM1 B + (LPTIM1 IN2), GPIO (CN4-10, readmes x13; D12) + 2: + pin: spi1mosi + note: >- + SPI1 MOSI, I2C1 SCL, LPTIM1 CH2, encoder TIM1 index, TIM17 break, + GPIO (CN4-15, readmes x12; D11) + 3: + pin: spi1cs + note: >- + SPI1 chip select and NSS, TIM1 CH2, encoder TIM1 B, GPIO (CN4-17, + readmes x10; D10) + 4: + pin: tim1ch1 + note: >- + TIM1 CH1, encoder TIM1 A, USART2 RX, LPTIM2 CH1, GPIO (CN4-24, + readmes x7; D4) + 5: + pin: tim1ch1n + note: >- + TIM1 CH1N, USART2 CTS, I2C1 SCL, I2C3 SCL, GPIO (CN4-3, readmes + x31; D15) + 6: + pin: tim1ch2n + note: >- + TIM1 CH2N, USART2 RTS, I2C1 SDA, I2C3 SDA, GPIO (CN4-5, readmes + x31; D14) + 7: + pin: tim1ch3n + note: >- + TIM1 CH3N, USART2 TX, encoder LPTIM2 B (LPTIM2 IN2), GPIO (CN4-25, + readme x1; D6) + 8: + pin: i2c3sda + note: >- + I2C3 SDA, TIM2 CH3, GPIO (CN3-28, readmes x23; A0) + 9: + pin: i2c3scl + note: >- + I2C3 SCL, TIM2 CH4, GPIO (CN3-30, readmes x10; A1) + 10: + pin: lpuart1rts + note: >- + SPI3 MISO, LPUART1 RTS, TIM3 CH4, TIM16 CH1, encoder LPTIM2 A + (LPTIM2 IN1), GPIO (CN4-23, readmes x4; D7) + 11: + pin: spi3mosi + note: >- + SPI3 MOSI, TIM16 CH1N, GPIO; red LED LD3 (CN4-38, readmes x12; no + Arduino socket; with --with spiloop the lead sits in the PB8 + breadboard row) + 12: + pin: tim3ch2 + note: >- + TIM3 CH2, encoder TIM3 B, TIM17 CH1, LPTIM2 CH2, GPIO (CN3-34, + readme x1; A3) + 13: + pin: gpio0 + note: >- + GPIO/EXTI, TIM3 CH3, encoder TIM3 index, watchdog warning toggle + (male pin in the D5 socket: no sourced morpho position) + 14: + pin: spi3clk + note: >- + SPI3 SCK, encoder LPTIM1 A (LPTIM1 IN1), GPIO (CN3-38, readmes x3; + A5) + 15: + pin: tim2ch1 + note: >- + TIM2 CH1, SPI3 NSS, GPIO (CN3-36, readmes x7; A4) + scope: + 1: + pin: i2c1scl + requires: i2c + note: >- + male pin in the SCL breadboard row (PB2 and PA6), 1- on the GND + rail + 2: + pin: i2c1sda + requires: i2c + note: >- + male pin in the SDA breadboard row (PB1 and PA7), 2- on the GND + rail # Driver gaps the HIL tests run into (tests/conftest.py): `tests` match the # test ids relative to tests/hil (`*` and `?` are wildcards, brackets are @@ -187,6 +452,13 @@ known_gaps: and CTS held off SendData waits for TXE without a timeout and blocks the event loop for good hangs: true + - tests: + - test_spi_ext.py::test_hardware_nss* + reason: >- + hal_st/stm32fxxx/SpiMasterStm.cpp:24, SpiMasterStmDma.cpp:38 and + hal_st/synchronous_stm32fxxx/SynchronousSpiMasterStm.cpp:23 - the SPI + masters mux slaveSelect to NSS but initialise SPI_NSS_SOFT, so NSS is + never driven; hardware NSS framing needs a decision (DESIGN B.14) # Test parameters. A mapping used by `@pytest.mark.matrix` is one dimension # per key: `--depth full` runs the cartesian product, `--depth quick` a @@ -197,8 +469,9 @@ tests: boot_timeout: 5.0 # USART1 is the terminal: `uart.open 1` answers busy. reserved_uarts: [1] - # Blinking debug LED (red LD3), reserved by the firmware. - debug_led: PB8 + # Blinking debug LED (green LD2), reserved by the firmware; dark unless + # SB28 is closed (open on a stock board). + debug_led: PA9 # SWD, LSE crystal and BOOT0: reserved like the terminal pins. reserved_pins: [PA13, PA14, PC14, PC15, PH3] # Instances the MCU lacks: ERR range (0 too, the peripherals start at 1). @@ -212,15 +485,22 @@ tests: - pwm.open 4 - qei.open 4 - wdt.start 1 timeout=100 - # Features the MCU lacks: ERR unsupported (no LPTIM encoder on the WBA55). + - spis.open 0 + - spis.open 2 + # Features the MCU lacks: ERR unsupported. unsupported_instances: - - qei.open 1 lp=1 - qei.open 16 # A pin the package does not bond out (or a port it lacks): ERR pin. - unbonded_pins: [PA4, PC0, PD0] + # PA3, PB10, PB11 and PB13 are SMPS and VDD11 pads on the UFQFPN48. + unbonded_pins: [PA4, PC0, PD0, PA3, PB10, PB11, PB13] # Names that are no pin: ERR pin. invalid_pins: [PA16, PA08, PD0, LED0, nosuchalias] delays_ms: [10, 200] + wiring: + # Pins nothing on the board may drive: ST-LINK lines reach them only + # through solder bridges that are open on a stock board + # (test_wiring.py::test_no_foreign_drivers). + undriven: [PA0, PB9, PA10, PB5, PB15] gpio: # bundle1 pins used as plain GPIO, on ports A and B. loop_pins: [gpio0, PA11, PB0, PA12, PA5] @@ -231,7 +511,7 @@ tests: input_pulls: [none, up, down] # RAM holds `limit` GPIO pins; `limit_pins` names one more. limit: 8 - limit_pins: [gpio0, gpio1, gpio2, led0, led1, PA11, PB0, PA12, PB2] + limit_pins: [gpio0, gpio1, gpio2, led0, PB8, PA11, PB0, PA12, PB2] irq: edge: [rising, falling, both] handler: [immediate, dispatched] @@ -295,6 +575,22 @@ tests: pin: tim17ch1 npin: tim17ch1n brk: PA15 + # bundle2 only (DIO8/DIO9 on PA7/PA6, DIO11 on PB8; with bundle1 the + # tests that need their DIOs skip): TIM2 CH3/CH4 and TIM16 CH1 with + # CH1N, without break input (PB8 carries LD3). + - name: tim2_bundle2 + timer: 2 + channels: + - channel: 3 + pin: tim2ch3 + - channel: 4 + pin: tim2ch4 + - name: tim16_bundle2 + timer: 16 + channels: + - channel: 1 + pin: tim16ch1 + npin: tim16ch1n # One channel of each timer; ERR range exactly where the period does # not fit (expect.pwm_fits), unsupported for center on TIM16/TIM17. waveform: @@ -419,6 +715,13 @@ tests: mosi: spi1mosi miso: spi1miso cs: spi1cs + # SPI3 is wired in bundle2 only (its tests skip in bundle1). + - name: spi3 + index: 3 + clk: spi3clk + mosi: spi3mosi + miso: spi3miso + cs: spi3nss variants: interrupt: dma: 0 @@ -507,8 +810,19 @@ tests: max_res: 65536 # Opens on its default pins without a and b. default_instance: 1 - # No LPTIM on the STM32WBA55. - lp_instances: [] + # LPTIM2 is wired in bundle1 (DIO10 PB9, DIO7 PB0), LPTIM1 in bundle2 + # (DIO14 PA0, DIO1 PB3). + lp_instances: + - name: lptim2 + index: 2 + a: lptim2in1 + b: lptim2in2 + max_res: 65536 + - name: lptim1 + index: 1 + a: lptim1in1 + b: lptim1in2 + max_res: 65536 lp_missing_instances: [] resolution: 4096 position: @@ -523,12 +837,17 @@ tests: ab: 4 a: 2 b: 2 + # LPTIM capture modes: both edges of both inputs (x4) or the rising or + # falling edges only (x2). + rise: 2 + fall: 2 lp_position: freq: [100, 1000, 10000] cycles: [1, 10, 250] direction: [fwd, rev] inva: [0, 1] filter: [0, 2, 4, 8] + cap: [ab, rise, fall] velocity: freq: [1000, 10000] vel: [1000, 10000] @@ -556,6 +875,555 @@ tests: feed_seconds: 2.0 observe_periods: 3 period_tolerance: 0.03 + i2c: + # Standalone cases on bundle1 (or bundle2) with the MCU pull-ups + # (pull=up): PB2 DIO5, PB1 DIO6, PA15 DIO2, PB3 DIO1. I2C3 reaches + # PB2/PB1 through AF6. + instances: + - name: i2c1 + index: 1 + scl: PB2 + sda: PB1 + observed: [scl, sda] + - name: i2c3 + index: 3 + scl: PB2 + sda: PB1 + observed: [scl, sda] + - name: i2c1alt + index: 1 + scl: PA15 + sda: PB3 + observed: [scl, sda] + # bundle2 --with i2c: I2C3 master on PA6/PA7 (DIO9/DIO8), the i2cs + # target on I2C1 PB2/PB1 (DIO5/DIO6; jumpered PB2-PA6, PB1-PA7), scope + # 1/2 on the SCL/SDA rows, 4.7 kOhm pull-ups and the 24LC256 at 0x50. + loop: + master: {index: 3, scl: PA6, sda: PA7} + target: {index: 1, scl: PB2, sda: PB1, addr: 0x42} + freqs: + standalone: [100000] + option: [100000, 400000] + absent_addr: 0x10 + arbitration_addr: 0x7f + payload_lengths: [1, 2, 255, 256, 257, 300, 1024] + nack_positions: [1, 2, 5] + nack_length: 8 + stretch_us: [100, 2000] + tolerance: [0.8, 1.01] + # 30-70 % rise time on the bundle2 scopes; with two DIOs and a probe + # per row a 4.7 kOhm pull-up may miss Fast mode: use 2.2 kOhm then. + rise_ns: {sm: 1000, fm: 300} + fault_freq: 100000 + eeprom: + index: 3 + scl: PA6 + sda: PA7 + addr: 0x50 + size: 32768 + page: 64 + abytes: 2 + wcycle_ms: 10 + erase_size: 8192 + freq: 400000 + # hal::SpiSlaveStmDma (`spis.*`): the AD3 SPI master (mode 0, MSB first, + # its chip select on `nss`) clocks the instances; SPI3 in bundle2 only. + spis: + instances: + - name: spi1 + index: 1 + clk: spi1clk + miso: spi1miso + mosi: spi1mosi + nss: spi1nss + - name: spi3 + index: 3 + clk: spi3clk + miso: spi3miso + mosi: spi3mosi + nss: spi3nss + freqs: [100000, 1000000, 4000000] + # Above 128 bytes the result is compared by CRC (`out=crc`). + lengths: [1, 2, 64, 255, 1024] + # The board's own master against the slave of the other instance + # (requires bundle2 --with spiloop); EMIL's spi.xfer holds 64 bytes. The + # master drives the slave's NSS with its GPIO chip select `cs`. + loop_lengths: [1, 2, 64] + loop_baud: 1000000 + loop_variants: [interrupt, dma, sync] + loops: + - name: spi1-to-spi3 + master: + index: 1 + clk: spi1clk + mosi: spi1mosi + miso: spi1miso + cs: spi1cs + slave: + index: 3 + clk: spi3clk + miso: spi3miso + mosi: spi3mosi + nss: spi3nss + - name: spi3-to-spi1 + master: + index: 3 + clk: spi3clk + mosi: spi3mosi + miso: spi3miso + cs: spi3nss + slave: + index: 1 + clk: spi1clk + miso: spi1miso + mosi: spi1mosi + nss: spi1nss + # spi.open lsb=, bits= (dma=1, SpiDataSizeConfiguratorStm) and nss=. Frames + # above 8 bits take two buffer bytes, little endian (spiwords.spi_frames), + # so `payload` has an even length. `bits` lists the sizes decoded on the + # logic analyzer per instance; SPI3 is a limited instance + # (IS_SPI_LIMITED_INSTANCE): 8 and 16 bits only, other sizes answer + # ERR unsupported. + spi_ext: + instances: + - name: spi1 + index: 1 + clk: spi1clk + mosi: spi1mosi + miso: spi1miso + cs: spi1cs + nss: spi1nss + bits: [4, 8, 12, 16] + - name: spi3 + index: 3 + clk: spi3clk + mosi: spi3mosi + miso: spi3miso + cs: spi3nss + nss: spi3nss + bits: [8, 16] + limited: true + lsb: [0, 1] + baud: 1000000 + payload: "a53c0f8001fe7e42" + timer: + # bundle1: the marker (gpio0, DIO13) toggles once per update interrupt. + timers: + - name: tim1 + timer: 1 + - name: tim2 + timer: 2 + - name: tim3 + timer: 3 + - name: tim16 + timer: 16 + - name: tim17 + timer: 17 + marker: gpio0 + # TIM numbers the MCU lacks: ERR range. + missing: [4, 15, 18] + # No counter mode select (up counting only): mode=down is ERR unsupported. + up_only: [16, 17] + # Update rate timclk / ((prescaler + 1) (period + 1)); `dispatched: + # false` marks rates above 1 kHz, where dispatched callbacks coalesce. + interrupt: + irq: [immediate, dispatched] + update: + - name: 100hz + prescaler: 999 + period: 999 + - name: 1khz + prescaler: 99 + period: 999 + - name: 20khz + prescaler: 0 + period: 4999 + dispatched: false + # Free running (irq=none): a 2 kHz counter that wraps after 32.8 s. + free_running: + prescaler: 49999 + period: 65535 + window_s: 0.5 + record_periods: 10 + tolerance: + frequency: 0.01 + count: 0.05 + latency_s: 0.02 + timer_pwm: + # Every channel pin in channel order (null = `-`). TIM2 CH2 is only on + # PA8, the terminal RX: bundle1 runs TIM2 with N = 1, bundle2 (TIM2 + # CH3/CH4 on DIO8/DIO9) with N = 4 and CH2 unused. + timers: + - name: tim1 + timer: 1 + pins: [tim1ch1, tim1ch2, tim1ch3, tim1ch4] + - name: tim2 + timer: 2 + pins: [tim2ch1] + - name: tim2_bundle2 + timer: 2 + pins: [tim2ch1, null, tim2ch3, tim2ch4] + - name: tim3 + timer: 3 + pins: [tim3ch1, tim3ch2, tim3ch3, tim3ch4] + - name: tim16 + timer: 16 + pins: [tim16ch1] + - name: tim17 + timer: 17 + pins: [tim17ch1] + # 1 MHz counter (100 MHz / 100), 10 kHz period: the low tick of 100 % + # lasts 1 us. + prescaler: 99 + period: 99 + duties: [0, 50, 100] + channel_duties: [20, 40, 60, 80] + # tpwm.pulse 1 + pulse: + ccr: 30 + period: 199 + # A pin without a channel function of any timer under test. + foreign_pin: PB2 + record_periods: 20 + tolerance: + frequency: 0.01 + duty: 0.01 + lptim: + instances: + - name: lptim1 + index: 1 + - name: lptim2 + index: 2 + marker: gpio0 + missing: [0, 3] + # Update rate lptimclk / prescaler / (period + 1) at 100 MHz. + interrupt: + irq: [immediate, dispatched] + update: + - name: 100hz + prescaler: 128 + period: 7811 + - name: 1khz + prescaler: 128 + period: 799 + - name: 20khz + prescaler: 1 + period: 4999 + dispatched: false + # Every divider with this period, immediate interrupt (781 Hz .. 100 kHz). + prescalers: [1, 2, 4, 8, 16, 32, 64, 128] + prescaler_period: 999 + # The WBA LPTIM repetition counter: one update every rep + 1 periods. + repetition: true + repetitions: [1, 3, 15] + repetition_timing: + prescaler: 128 + period: 799 + free_running: + prescaler: 128 + period: 65535 + window_s: 0.5 + record_periods: 10 + tolerance: + frequency: 0.01 + count: 0.05 + latency_s: 0.02 + lptim_pwm: + # bundle1: LPTIM1 CH2 on PA15 (DIO2; CH1 is PB11, not bonded) and LPTIM2 + # CH1/CH2 on PA11/PA1 (DIO4/DIO12). + instances: + - name: lptim1 + index: 1 + pins: [null, lptim1ch2] + - name: lptim2 + index: 2 + pins: [lptim2ch1, lptim2ch2] + # 25 MHz counter (100 MHz / 4), 25 kHz period. + prescaler: 4 + period: 999 + duties: [25, 50, 75] + pulse: + ccr: 300 + period: 1999 + # A bonded pin without an LPTIM channel function. + foreign_pin: PB2 + record_periods: 20 + tolerance: + frequency: 0.01 + duty: 0.01 + pwm_ext: + # A pwm.timers entry with two wired channels and a counter mode select. + alignment_timer: tim1 + modes: [edge, edgedown, center, centerup, centerboth] + alignment_frequency: 10000 + alignment_duties: [30, 60] + # edgedown cannot reach 0 %: the output stays active for one counter + # tick per period (PwmStm.hpp). The prescaler makes a tick `tick_us`. + zero_duty: + tick_us: 10 + frequency: 100 + periods: 5 + # pwm.duty 80 then 20, runt_repeats times while the LA records + # runt_window_s: with preload=0 at least one high pulse lies strictly + # between 20 % and 80 % of the period (beyond runt_margin), with + # preload=1 none. 1 MHz counter clock. + preload_timer: tim1 + preload_frequency: 200 + runt_repeats: 20 + runt_window_s: 0.5 + runt_margin: 0.03 + # pwm.timers entries with `brk`, each hit by one AD3 pulse of `ticks` + # timer kernel clocks (brkpol=high, brkauto=0). BKF 15 samples 8 times + # at fDTS / 32: 256 kernel clocks. + break_filter_timers: [tim1, tim16, tim17] + break_filter_cases: + - name: unfiltered_short + filter: 0 + ticks: 64 + trips: true + - name: filtered_short + filter: 15 + ticks: 64 + trips: false + - name: filtered_long + filter: 15 + ticks: 1024 + trips: true + break_frequency: 10000 + break_settle_s: 0.01 + # adc.open trgo= runs one sequence per period of the pwm + # group's timer (pwm.timers names); test_trgo_paces_adc compares + # n=trgo_runs with n=1 like test_adc.py::test_trigger_rate. + trgo: + timer: [tim2] + source: [update, oc1ref] + trgo_frequency: 1000 + trgo_runs: 64 + trgo_repeats: 3 + trgo_tolerance: 0.2 + trgo_jitter_s: 0.002 + # Driven by the pwm group, but no TRGO the ADC can trigger from: ADC4 + # reaches TIM1 through TRGO2 only, which PwmStm leaves at reset. + trgo_unsupported_timers: [tim1, tim16] + capture_periods: 20 + tolerance: + frequency: 0.01 + duty: 1.0 + alignment_s: 2.0e-7 + ain: + # W1/W2 inputs (bundle1); codes against tests.adc vref/bits/tolerance. + pins: [ain2, ain3] + levels: [0.2, 1.65, 3.1] + # Accepted, but the STM32WBA driver samples every channel with + # SamplingTimeCommon1 (79.5 cycles, AnalogToDigitalPinStm.cpp). + sampling: ["3.5", "39.5", "814.5"] + # The sensor needs a long sampling time; the driver's default is below + # its minimum (B.14), so with it only "a code arrives" is checked. + temp_sampling: "814.5" + temp_range: [10, 45] + # One AdcTriggeredByTimerWithDma over a `buffer`-sample buffer per + # command, Measure(n) at `rate`: n samples in n / rate (B.12). + burst: + n: [16, 100] + rate: 1000 + buffer: 256 + tolerance: 0.1 + latency_s: 0.01 + # Wavegen ramp during an out=list burst: the codes rise with the ramp. + ramp: + low: 0.3 + high: 3.0 + frequency: 2 + n: 64 + rate: 1000 + dma: + # dma.wave output pin, wired to a DIO in every wiring set. + pin: gpio0 + wave: + rate: [1000, 100000, 1000000] + pattern: ["a5", "0ff0", "deadbeef", "0100000000000080"] + ms: 300 + samples_per_bit: 8 + record_bits: 200 + start_delay_s: 0.05 + tolerance: + rate: 0.01 + bit: 0.25 + uart_sendonly: + # hal::SynchronousUartStmSendOnly on USART2. `rx` only gives the AD3 + # UART a transmit DIO: the send-only driver leaves the pin alone. + instances: + - name: usart2 + index: 2 + lp: 0 + tx: usart2tx + rx: usart2rx + rts: usart2rts + cts: usart2cts + bauds: [9600, 115200, 921600] + payloads: ["55", "00ff", "0123456789abcdef", "48656c6c6f2c20484954"] + # uart.close right after uart.send: SendData returns at TXE, so the + # last frame (1 ms at 9600 Bd) may be cut. + cut: + baud: 9600 + payload: 16 + # The SyncLpUart constructors exist on STM32WB only: ERR unsupported. + unsupported: + - name: lpuart1 + index: 1 + lp: 1 + tx: lpuart1tx + sgpio: + # SynchronousOutputPinStm; led0 (PB4) carries the blue LD1. + out_pins: [led0, tim1ch1] + # Output pins without a board load, for the input-without-pull check. + float_pins: [tim1ch1] + speeds: [low, medium, fast, high] + # Five free pins for the four output and four alternate-function slots + # (not PA0/PB9, which solder bridges may tie to ST-LINK lines). + limit_pins: [led0, tim1ch1, gpio0, gpio1, spi1cs] + # SmallPeripheralPinStm on TIM3 CH1 (AF2). + af: + pin: tim3ch1 + timer: 3 + ch: 1 + af: 2 + # MultiGpioPinStm + MultiPeripheralPinStm: TIM3 CH1 on PA10, PB5, PA2. + multi: + pins: [tim3ch1, PB5, PA2] + timer: 3 + ch: 1 + # tpwm runs TIM3 CH1 without a pin of its own (`-`); CH2 drives + # tim3ch2. 100 MHz / 64 / 1000 = 1.5625 kHz. + tpwm: + timer: 3 + pins: ["-", tim3ch2] + prescaler: 63 + period: 999 + duty: 50 + record_periods: 50 + tolerance: + frequency: 0.01 + duty: 0.02 + # No TIM3 CH1 function (TIM3 CH3); a timer the MCU lacks. + foreign_pin: gpio0 + missing_timer: 4 + clock: + frequencies: + sysclk: 100000000 + hclk: 100000000 + pclk1: 100000000 + pclk2: 100000000 + pclk7: 100000000 + # Oscillator ready flags after ConfigureDefaultClockNucleoWBA55CG; the + # LSE lives in the backup domain, which a reset keeps, so it is not + # asserted. + flags: + hse: 1 + hsi: 1 + pll: 1 + # RNG on HSI16 (B.7). + rngsel: hsi + # No MCO: its only pin, PA8, is the terminal RX. + uid: + # Bytes [5, 12) of `info uid=` hold the lot number in ASCII. + lot: [5, 12] + rng: + # sync: SynchronousRandomDataGeneratorStm; async: RandomDataGeneratorStm + # (RNG kernel clock HSI16, B.7). The HSEM-synchronized variant exists on + # STM32WB only. + variants: [sync, async] + lengths: [1, 2, 3, 4, 5, 7, 16, 127, 128] + stats: + length: 65536 + # Seconds the host waits for the rng.stats reply (the firmware gives + # the async variant 5 s). + timeout_s: 6.0 + semaphores: [] + # variant=hsem and lock5=1: STM32WB only. + unsupported: + - rng.read 4 variant=hsem + - rng.read 4 variant=hsem lock5=1 + - rng.read 4 lock5=1 + - rng.stats 16 variant=hsem + aes: + # FIPS-197 C.1 and NIST SP 800-38A F.1.1 (ECB-AES128, all four blocks); + # a list of hex strings is one byte string. + vectors: + - name: fips197-c1 + key: "000102030405060708090a0b0c0d0e0f" + plaintext: "00112233445566778899aabbccddeeff" + ciphertext: "69c4e0d86a7b0430d8cdb78070b4c55a" + - name: sp800-38a-f11 + key: "2b7e151628aed2a6abf7158809cf4f3c" + plaintext: + - "6bc1bee22e409f96e93d7e117393172a" + - "ae2d8a571e03ac9c9eb76fac45af8e51" + - "30c81c46a35ce411e5fbc1191a0a52ef" + - "f69f2445df4f9b17ad2b417be66c3710" + ciphertext: + - "3ad77bb40d7a3660a89ecaf32466ef97" + - "f5d3d58503b9699de785895a96fdbaaf" + - "43b1cd7f598ece23881b00e3ed030688" + - "7b0c785e27e8ad3f8223207104725dd4" + # AES_CR.DATATYPE of hal::detail::DataSwapping, against + # crypto_ref.aes_stm (model from the reference manual). + swaps: [none, half, byte, bit] + # Bytes per command: one to five blocks (the group holds 80). + lengths: [16, 32, 48, 64, 80] + pka: + # Multiples of G on services::secp256r1 (P-256): k -> (x, y). + multiples: + - name: k1 + k: "01" + x: "6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296" + y: "4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5" + - name: k2 + k: "02" + x: "7cf27b188d034f7e8a52380304b51ac3c08969e277f21b35a60b48fc47669978" + y: "07775510db8ed040293d9ac69f7430dbba7dade63ce982299e04b79d227873d1" + - name: k3 + k: "03" + x: "5ecbe4d1a6330a44c8f7ef951d4bf165e6c6b721efada985fb41661bc6e7fd6c" + y: "8734640c4998ff7e374b06ce1a64a2ecd82ab036384fb83d9a79b127a27d5032" + # pka.cmp operand sizes in bytes (4..60; 60 keeps the line at 253). + compare_lengths: [4, 32, 60] + # One full-scalar multiplication at 100 MHz takes tens of ms. + max_us: 500000 + flash: + # sync: SynchronousFlash*InternalStm; async: Flash*InternalStm. No + # wireless-stack coordination on the STM32WBA55. + variants: [sync, async] + # homogeneous: one sector per page; table: single pages, then the + # pattern 1, 1, 2, 4 pages twice (groups.system_ext.table_sectors). + layouts: [homogeneous, table] + # Scratch region: absolute pages 64-127 (0x08080000-0x080FFFFF, the + # upper half of the 1 MB flash), 8 KB pages, 128-bit flash words. + base: 0x08080000 + first_page: 64 + pages: 64 + page: 8192 + word: 16 + # Page erase time, wide bounds per page. + erase_us_per_page: [200, 50000] + bkp: + # TAMP BKP0R-BKP15R. + words: 16 + lowpower: + # Defaults of the firmware (BoardProfile lowPowerDefaults). + wake: gpio0 + marker: tim1bkin + modes: [sleep, deep] + edges: [rising, falling] + # Wake edge to marker high. + latency_us: 50 + # Host delay between lpm.enter and the AD3 edge. + wake_delay_s: 0.05 + wake_timeout_ms: 2000 + # test_timeout: no edge. + timeout_ms: 50 + la_rate: 4000000 + unbonded_pin: PA4 unsupported: commands: - comp.open 1 @@ -566,9 +1434,19 @@ tests: - can.open 1 - can.send 1 0x123 01 - can.close 1 - - eeprom.write 0 00 - - eeprom.read 0 1 - - eeprom.erase - eth.open - eth.status - eth.close + - hsem.take 0 procid=1 + - hsem.release 0 procid=1 + - hsem.status 0 + - hsem.lock 0 + - hsem.mine 0 + - qspi.open 1 + - qspi.cmd 1 instr=0x9f rx=3 + - qspi.poll 1 match=0x01 mask=0x01 + - qspi.xfer 1 9f + - qspi.close 1 + - flash.stack stopped + - clock.mco sysclk + - clock.hsi48 1 diff --git a/validation/host/hal_st_validation/config.py b/validation/host/hal_st_validation/config.py index 3fa2b0ca..3733b7a2 100644 --- a/validation/host/hal_st_validation/config.py +++ b/validation/host/hal_st_validation/config.py @@ -17,6 +17,7 @@ BOARDS_DIR = Path(__file__).resolve().parent.parent / "boards" ChannelKind = Literal["dio", "wavegen", "scope"] _KINDS: tuple[ChannelKind, ...] = ("dio", "wavegen", "scope") +_OPTION_KEYS = frozenset({"description", "jumpered", "loads", "pullups", "excludes"}) _MISSING = object() @@ -53,43 +54,105 @@ def describe(self) -> str: return " ".join(parts) +@dataclass(frozen=True) +class WiringOption: + """Optional wiring a set offers (`--with `): `jumpered` ties each key pin to the listed pins (the key is the + end the wiring self-check drives), `loads` are pins the wiring loads, `pullups` pins it pulls up, and `excludes` + names options that cannot be fitted at the same time.""" + + tag: str + description: str = "" + jumpered: Mapping[str, tuple[str, ...]] = field(default_factory=dict) + loads: tuple[str, ...] = () + pullups: tuple[str, ...] = () + excludes: frozenset[str] = frozenset() + + def merged(self, other: WiringOption) -> WiringOption: + """The option as two selected sets offer it together.""" + jumpered = {pin: tuple(dict.fromkeys((*self.jumpered.get(pin, ()), *others))) for pin, others in other.jumpered.items()} + return WiringOption( + tag=self.tag, + description=self.description or other.description, + jumpered={**self.jumpered, **jumpered}, + loads=tuple(dict.fromkeys((*self.loads, *other.loads))), + pullups=tuple(dict.fromkeys((*self.pullups, *other.pullups))), + excludes=self.excludes | other.excludes, + ) + + @dataclass(frozen=True) class WiringSet: name: str description: str connections: tuple[Connection, ...] - options: Mapping[str, str] = field(default_factory=dict) + options: Mapping[str, WiringOption] = field(default_factory=dict) jumpers: tuple[str, ...] = () @dataclass(frozen=True) class Wiring: - """The merged active wiring sets, restricted to the optional connections that are enabled.""" + """The merged active wiring sets, restricted to the optional connections that are enabled. + + `options` are the options the selected sets offer, `enabled` the tags of `--with`; `option_jumpers` holds the + `jumpered` pins of the enabled options. A channel reaches an option-jumpered pin only for lookups that pass + the tag in `allowed` (tests marked `uses_option`/`requires_option`), and pins an enabled option loads are + reported by `loaded()` so such lookups can be refused for other tests.""" sets: tuple[str, ...] connections: tuple[Connection, ...] enabled: frozenset[str] jumpers: tuple[str, ...] = () - - def channel(self, kind: ChannelKind, pin: str | None = None, role: str | None = None) -> int | None: - for connection in self.connections: - if connection.kind != kind: - continue - if role is not None and connection.role != role: + options: Mapping[str, WiringOption] = field(default_factory=dict) + option_jumpers: Mapping[str, Mapping[str, tuple[str, ...]]] = field(default_factory=dict) + + def connection( + self, kind: ChannelKind, pin: str | None = None, role: str | None = None, allowed: Iterable[str] = () + ) -> Connection | None: + """The first connection of `kind` (and `role`) on `pin`: its own pins first, then the pins the jumpers of the + enabled options in `allowed` tie to them.""" + candidates = [c for c in self.connections if c.kind == kind and (role is None or c.role == role)] + if pin is None: + return candidates[0] if candidates else None + for connection in candidates: + if pin in connection.pins: + return connection + reach = self.jumpered_to(pin, allowed) + return next((connection for connection in candidates if reach & set(connection.pins)), None) + + def channel(self, kind: ChannelKind, pin: str | None = None, role: str | None = None, allowed: Iterable[str] = ()) -> int | None: + connection = self.connection(kind, pin, role, allowed) + return None if connection is None else connection.channel + + def dio(self, pin: str | None = None, role: str | None = None, allowed: Iterable[str] = ()) -> int | None: + return self.channel("dio", pin, role, allowed) + + def wavegen(self, pin: str | None = None, role: str | None = None, allowed: Iterable[str] = ()) -> int | None: + return self.channel("wavegen", pin, role, allowed) + + def scope(self, pin: str | None = None, role: str | None = None, allowed: Iterable[str] = ()) -> int | None: + return self.channel("scope", pin, role, allowed) + + def jumpered_to(self, pin: str, allowed: Iterable[str] = ()) -> set[str]: + """Pins the jumpers of the enabled options in `allowed` tie to `pin`.""" + tags = set(allowed) + reach: set[str] = set() + for tag, jumpers in self.option_jumpers.items(): + if tag not in tags: continue - if pin is not None and pin not in connection.pins: - continue - return connection.channel - return None - - def dio(self, pin: str | None = None, role: str | None = None) -> int | None: - return self.channel("dio", pin, role) + for key, others in jumpers.items(): + net = {key, *others} + if pin in net: + reach |= net - {pin} + return reach - def wavegen(self, pin: str | None = None, role: str | None = None) -> int | None: - return self.channel("wavegen", pin, role) + def loaded(self, pin: str) -> tuple[str, ...]: + """The enabled options that load `pin`.""" + return tuple(tag for tag, option in self.options.items() if tag in self.enabled and pin in option.loads) - def scope(self, pin: str | None = None, role: str | None = None) -> int | None: - return self.channel("scope", pin, role) + def blocking(self, pin: str, allowed: Iterable[str] = ()) -> tuple[str, ...]: + """The enabled options that load `pin` and are not in `allowed`: a test outside them must leave it alone.""" + tags = set(allowed) + return tuple(tag for tag in self.loaded(pin) if tag not in tags) def has(self, tag: str) -> bool: return tag in self.enabled @@ -98,6 +161,12 @@ def describe(self) -> str: lines = [f"wiring sets: {', '.join(self.sets) or '(none)'}"] lines += [f" {connection.describe()}" for connection in self.connections] lines += [f" jumper: {jumper}" for jumper in self.jumpers] + for tag in sorted(self.enabled): + option = self.options[tag] + parts = [f"{key}-{'+'.join(others)}" for key, others in option.jumpered.items()] + if option.loads: + parts.append(f"loads {', '.join(option.loads)}") + lines.append(f" --with {tag}: {'; '.join(parts) or option.description or 'enabled'}") return "\n".join(lines) @@ -167,6 +236,8 @@ class BoardConfig: wiring_sets: dict[str, WiringSet] tests: dict[str, Any] path: Path | None = None + # `--board-extra` files merged over the board file, in order. + extras: tuple[Path, ...] = () firmware_name: str | None = None # Kernel clocks in Hz (`sysclk`, `pclk1`, `pclk2`, `timer`, `spi.`, `uart.`). clocks: dict[str, Any] = field(default_factory=dict) @@ -237,9 +308,13 @@ def apply_overrides(self, overrides: Iterable[str]) -> None: self.set_param(path.strip(), yaml.safe_load(text)) def wiring(self, names: Iterable[str], enabled: Iterable[str] = ()) -> Wiring: + """The wiring of the sets `names` with the options `enabled`; `ConfigError` for an unknown set, for sets + that wire one AD3 channel differently, for an enabled option no selected set offers (or enabled options + without a set) and for enabled options that exclude each other.""" tags = frozenset(enabled) selected: list[Connection] = [] jumpers: list[str] = [] + options: dict[str, WiringOption] = {} used: dict[tuple[str, int], str] = {} names = tuple(name for name in names if name) for name in names: @@ -248,6 +323,8 @@ def wiring(self, names: Iterable[str], enabled: Iterable[str] = ()) -> Wiring: raise ConfigError(f"{self.name}: unknown wiring set {name!r} (known: {known})") wiring_set = self.wiring_sets[name] jumpers += [jumper for jumper in wiring_set.jumpers if jumper not in jumpers] + for tag, option in wiring_set.options.items(): + options[tag] = options[tag].merged(option) if tag in options else option for connection in wiring_set.connections: if connection.requires and connection.requires not in tags: continue @@ -261,7 +338,21 @@ def wiring(self, names: Iterable[str], enabled: Iterable[str] = ()) -> Wiring: continue used[key] = name selected.append(connection) - return Wiring(names, tuple(selected), tags, tuple(jumpers)) + self._check_options(names, options, tags) + option_jumpers = {tag: options[tag].jumpered for tag in sorted(tags) if options[tag].jumpered} + return Wiring(names, tuple(selected), tags, tuple(jumpers), options, option_jumpers) + + def _check_options(self, names: tuple[str, ...], options: Mapping[str, WiringOption], tags: frozenset[str]) -> None: + if tags and not names: + raise ConfigError(f"{self.name}: --with {', '.join(sorted(tags))} needs the --wiring-set that offers it") + missing = sorted(tags - set(options)) + if missing: + offered = ", ".join(sorted(options)) or "none" + raise ConfigError(f"{self.name}: wiring set(s) {', '.join(names)} offer no --with {', '.join(missing)} (offered: {offered})") + for tag in sorted(tags): + clash = sorted(options[tag].excludes & tags) + if clash: + raise ConfigError(f"{self.name}: --with {tag} and --with {', '.join(clash)} exclude each other: fit one of them") def _connection(kind: ChannelKind, channel: Any, spec: Any, pins: Mapping[str, str]) -> Connection: @@ -284,21 +375,63 @@ def _connection(kind: ChannelKind, channel: Any, spec: Any, pins: Mapping[str, s ) +def _pin_list(where: str, value: Any, pins: Mapping[str, str]) -> tuple[str, ...]: + if value is None: + return () + if isinstance(value, str) or not isinstance(value, (list, tuple)): + raise ConfigError(f"{where} must be a list of pins") + return tuple(normalize_pin(str(pin), pins, strict=True) for pin in value) + + +def _option(set_name: str, tag: str, spec: Any, pins: Mapping[str, str]) -> WiringOption: + """An option is a description (the wiring is only described) or a mapping of `_OPTION_KEYS`.""" + if spec is None or isinstance(spec, str): + return WiringOption(tag, str(spec or "")) + where = f"{set_name}: option {tag}" + if not isinstance(spec, Mapping): + raise ConfigError(f"{where} must be a description or a mapping") + unknown = sorted(str(key) for key in set(spec) - _OPTION_KEYS) + if unknown: + raise ConfigError(f"{where}: unknown keys {', '.join(unknown)} (known: {', '.join(sorted(_OPTION_KEYS))})") + jumpered_raw = spec.get("jumpered") or {} + if not isinstance(jumpered_raw, Mapping): + raise ConfigError(f"{where}: jumpered must map a pin to the pins it is tied to") + jumpered = { + normalize_pin(str(key), pins, strict=True): _pin_list(f"{where}: jumpered.{key}", others, pins) + for key, others in jumpered_raw.items() + } + excludes = spec.get("excludes") or [] + if isinstance(excludes, str) or not isinstance(excludes, (list, tuple)): + raise ConfigError(f"{where}: excludes must be a list of option tags") + return WiringOption( + tag=tag, + description=str(spec.get("description", "")), + jumpered=jumpered, + loads=_pin_list(f"{where}: loads", spec.get("loads"), pins), + pullups=_pin_list(f"{where}: pullups", spec.get("pullups"), pins), + excludes=frozenset(str(other) for other in excludes), + ) + + def _wiring_set(name: str, raw: Mapping[str, Any], pins: Mapping[str, str]) -> WiringSet: connections: list[Connection] = [] for kind in _KINDS: for channel, spec in (raw.get(kind) or {}).items(): connections.append(_connection(kind, channel, spec, pins)) + options = {str(tag): _option(name, str(tag), spec, pins) for tag, spec in (raw.get("options") or {}).items()} for connection in connections: if connection.kind == "dio" and not 0 <= connection.channel <= 15: raise ConfigError(f"{name}: DIO{connection.channel} does not exist on the AD3") if connection.kind != "dio" and connection.channel not in (1, 2): raise ConfigError(f"{name}: {connection.kind} channel must be 1 or 2") + if connection.requires is not None and connection.requires not in options: + what = f"{connection.kind}{connection.channel}" + raise ConfigError(f"{name}: {what} requires option {connection.requires!r}, which the set does not offer") return WiringSet( name=name, description=str(raw.get("description", "")), connections=tuple(connections), - options=dict(raw.get("options") or {}), + options=options, jumpers=tuple(raw.get("jumpers") or ()), ) @@ -331,7 +464,7 @@ def _known_gaps(raw: Any) -> tuple[KnownGap, ...]: return tuple(gaps) -def parse_board(raw: Mapping[str, Any], path: Path | None = None) -> BoardConfig: +def parse_board(raw: Mapping[str, Any], path: Path | None = None, extras: Iterable[Path] = ()) -> BoardConfig: try: pins = {str(alias).lower(): normalize_pin(str(pin)) for alias, pin in (raw.get("pins") or {}).items()} unknown = sorted(alias for alias in pins if not is_alias(alias)) @@ -354,6 +487,7 @@ def parse_board(raw: Mapping[str, Any], path: Path | None = None) -> BoardConfig wiring_sets={name: _wiring_set(name, spec or {}, pins) for name, spec in (raw.get("wiring_sets") or {}).items()}, tests=copy.deepcopy(dict(raw.get("tests") or {})), path=path, + extras=tuple(extras), firmware_name=raw.get("firmware_name"), clocks=_clocks(raw.get("clocks") or {}), known_gaps=_known_gaps(raw.get("known_gaps") or []), @@ -373,11 +507,56 @@ def board_path(name_or_path: str | Path, boards_dir: Path = BOARDS_DIR) -> Path: raise ConfigError(f"no board {name_or_path!r} (known: {known})") -def load_board(name_or_path: str | Path, boards_dir: Path = BOARDS_DIR) -> BoardConfig: +def merge_board(base: Any, extra: Any) -> Any: + """`extra` deep-merged over `base`: mappings merge key by key, lists append, a scalar or a change of type + replaces, and a key ending in `!` replaces the value of the key without it (`limit_pins!: [...]`).""" + if isinstance(base, Mapping) and isinstance(extra, Mapping): + result = dict(base) + for key, value in extra.items(): + if isinstance(key, str) and key.endswith("!"): + result[key[:-1]] = _without_bangs(value) + elif key in result: + result[key] = merge_board(result[key], value) + else: + result[key] = _without_bangs(value) + return result + if isinstance(base, list) and isinstance(extra, list): + return base + _without_bangs(extra) + return _without_bangs(extra) + + +def _without_bangs(value: Any) -> Any: + """A copy of `value` whose `key!` mapping keys are plain keys (nothing to replace inside a new value).""" + if isinstance(value, Mapping): + return {(key[:-1] if isinstance(key, str) and key.endswith("!") else key): _without_bangs(item) for key, item in value.items()} + if isinstance(value, list): + return [_without_bangs(item) for item in value] + return copy.deepcopy(value) + + +def _read_yaml(path: Path) -> Any: + try: + with path.open(encoding="utf-8") as stream: + return yaml.safe_load(stream) + except (OSError, yaml.YAMLError) as error: + raise ConfigError(f"{path}: {error}") from error + + +def load_board(name_or_path: str | Path, extras: Iterable[str | Path] = (), boards_dir: Path = BOARDS_DIR) -> BoardConfig: + """The board file, with the `extras` (`--board-extra` files) deep-merged over it in order (`merge_board`).""" path = board_path(name_or_path, boards_dir) - with path.open(encoding="utf-8") as stream: - raw = yaml.safe_load(stream) - return parse_board(raw, path) + raw = _read_yaml(path) + extra_paths = tuple(Path(extra) for extra in extras) + for extra_path in extra_paths: + if not extra_path.is_file(): + raise ConfigError(f"no board extra file {extra_path}") + extra = _read_yaml(extra_path) + if extra is None: + continue + if not isinstance(extra, Mapping): + raise ConfigError(f"{extra_path}: a board extra file must be a mapping") + raw = merge_board(raw, extra) + return parse_board(raw, path, extra_paths) def available_boards(boards_dir: Path = BOARDS_DIR) -> list[str]: diff --git a/validation/host/hal_st_validation/crypto_ref.py b/validation/host/hal_st_validation/crypto_ref.py new file mode 100644 index 00000000..4ffbebbe --- /dev/null +++ b/validation/host/hal_st_validation/crypto_ref.py @@ -0,0 +1,311 @@ +"""Reference models of the `aes` and `pka` groups (validation/PROTOCOL.md): pure-Python AES-128 (FIPS-197) with the +data swapping of the STM32 AES peripheral as `hal::SynchronousAes128EcbStm` uses it, and affine arithmetic on the +NIST P-256 curve (`services::secp256r1`). + +Data swapping (RM0434 / RM0493, AES_CR.DATATYPE): the driver writes each 32-bit word of the input to AES_DINR as the +little-endian CPU loads it, the first word being bits 127:96 of the block, and stores AES_DOUTR the same way; the +peripheral swaps the halves (`half`), the bytes (`byte`) or the bits (`bit`) of every word on the way in and out. +So the result is `T(AES(T(data)))` with T per 4-byte word: `none` reverses the bytes, `half` gives b1 b0 b3 b2, +`byte` is the identity (standard AES on the byte string, the driver's default) and `bit` reverses the bits of every +byte. The key is unaffected: the driver writes it byte-reversed per word (`__REV`), i.e. in standard order. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Literal + +__all__ = [ + "AES_FIPS197_C1", + "AES_SP800_38A_F11", + "INVERSE_SBOX", + "P256", + "P256_CAVP_CDH", + "SBOX", + "SWAPS", + "AesVector", + "CdhVector", + "Curve", + "Point", + "Swap", + "aes_decrypt_block", + "aes_ecb_decrypt", + "aes_ecb_encrypt", + "aes_encrypt_block", + "aes_stm", + "from_bytes", + "point_add", + "point_double", + "scalar_multiply", + "swap_words", + "to_bytes", +] + +Swap = Literal["none", "half", "byte", "bit"] +SWAPS: tuple[Swap, ...] = ("none", "half", "byte", "bit") + +BLOCK = 16 + + +def _multiply(a: int, b: int) -> int: + """Product in GF(2^8) modulo x^8 + x^4 + x^3 + x + 1.""" + result = 0 + while b: + if b & 1: + result ^= a + a = ((a << 1) ^ 0x11B) if a & 0x80 else a << 1 + b >>= 1 + return result + + +def _power(a: int, exponent: int) -> int: + result = 1 + while exponent: + if exponent & 1: + result = _multiply(result, a) + a = _multiply(a, a) + exponent >>= 1 + return result + + +def _sbox() -> tuple[tuple[int, ...], tuple[int, ...]]: + """FIPS-197 5.1.1: the multiplicative inverse followed by the affine transformation.""" + inverse = [0] + [_power(value, 254) for value in range(1, 256)] + forward = [] + for value in inverse: + result = 0x63 + for shift in range(5): + result ^= ((value << shift) | (value >> (8 - shift))) & 0xFF + forward.append(result) + backward = [0] * 256 + for index, value in enumerate(forward): + backward[value] = index + return tuple(forward), tuple(backward) + + +SBOX, INVERSE_SBOX = _sbox() + + +def _expand_key(key: bytes) -> list[list[int]]: + """FIPS-197 5.2: eleven round keys of 16 bytes for AES-128.""" + if len(key) != BLOCK: + raise ValueError("AES-128 takes a 16-byte key") + words = [list(key[i : i + 4]) for i in range(0, BLOCK, 4)] + rcon = 1 + for index in range(4, 44): + word = list(words[index - 1]) + if index % 4 == 0: + word = [SBOX[byte] for byte in word[1:] + word[:1]] + word[0] ^= rcon + rcon = _multiply(rcon, 2) + words.append([a ^ b for a, b in zip(words[index - 4], word)]) + return [sum(words[round_ * 4 : round_ * 4 + 4], []) for round_ in range(11)] + + +def _add(state: list[int], round_key: list[int]) -> list[int]: + return [a ^ b for a, b in zip(state, round_key)] + + +def _shift_rows(state: list[int], direction: int) -> list[int]: + """The state is column major (byte r + 4c is row r, column c); row r rotates left by `direction * r`.""" + return [state[(row + 4 * (column + direction * row)) % 16] for column in range(4) for row in range(4)] + + +def _mix_columns(state: list[int], matrix: tuple[int, int, int, int]) -> list[int]: + result = [] + for column in range(4): + values = state[4 * column : 4 * column + 4] + for row in range(4): + byte = 0 + for k in range(4): + byte ^= _multiply(matrix[(k - row) % 4], values[k]) + result.append(byte) + return result + + +def aes_encrypt_block(key: bytes, block: bytes) -> bytes: + keys = _expand_key(key) + state = _add(list(block), keys[0]) + for round_ in range(1, 11): + state = _shift_rows([SBOX[byte] for byte in state], 1) + if round_ != 10: + state = _mix_columns(state, (2, 3, 1, 1)) + state = _add(state, keys[round_]) + return bytes(state) + + +def aes_decrypt_block(key: bytes, block: bytes) -> bytes: + keys = _expand_key(key) + state = _add(list(block), keys[10]) + for round_ in range(9, -1, -1): + state = [INVERSE_SBOX[byte] for byte in _shift_rows(state, -1)] + state = _add(state, keys[round_]) + if round_ != 0: + state = _mix_columns(state, (14, 11, 13, 9)) + return bytes(state) + + +def _ecb(function: Callable[[bytes, bytes], bytes], key: bytes, data: bytes) -> bytes: + if len(data) % BLOCK: + raise ValueError("ECB data must be a multiple of 16 bytes") + return b"".join(function(key, data[i : i + BLOCK]) for i in range(0, len(data), BLOCK)) + + +def aes_ecb_encrypt(key: bytes, data: bytes) -> bytes: + return _ecb(aes_encrypt_block, key, data) + + +def aes_ecb_decrypt(key: bytes, data: bytes) -> bytes: + return _ecb(aes_decrypt_block, key, data) + + +def _reverse_bits(byte: int) -> int: + return int(f"{byte:08b}"[::-1], 2) + + +def swap_words(data: bytes, swap: Swap) -> bytes: + """The memory image of the 128-bit block the AES core sees (or the inverse: every T is an involution).""" + if len(data) % 4: + raise ValueError("data must be whole 32-bit words") + if swap == "byte": + return bytes(data) + if swap == "bit": + return bytes(_reverse_bits(byte) for byte in data) + order = {"none": (3, 2, 1, 0), "half": (1, 0, 3, 2)}[swap] + return b"".join(bytes(data[i + j] for j in order) for i in range(0, len(data), 4)) + + +def aes_stm(key: bytes, data: bytes, swap: Swap = "byte", decrypt: bool = False) -> bytes: + """What `aes.enc`/`aes.dec` return for `swap`.""" + function = aes_ecb_decrypt if decrypt else aes_ecb_encrypt + return swap_words(function(key, swap_words(data, swap)), swap) + + +@dataclass(frozen=True) +class AesVector: + name: str + key: bytes + plaintext: bytes + ciphertext: bytes + + +# FIPS-197 Appendix C.1 (AES-128). +AES_FIPS197_C1 = AesVector( + "fips197-c1", + bytes.fromhex("000102030405060708090a0b0c0d0e0f"), + bytes.fromhex("00112233445566778899aabbccddeeff"), + bytes.fromhex("69c4e0d86a7b0430d8cdb78070b4c55a"), +) + +# NIST SP 800-38A F.1.1 (ECB-AES128.Encrypt), all four blocks. +AES_SP800_38A_F11 = AesVector( + "sp800-38a-f11", + bytes.fromhex("2b7e151628aed2a6abf7158809cf4f3c"), + bytes.fromhex( + "6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e5130c81c46a35ce411e5fbc1191a0a52eff69f2445df4f9b17ad2b417be66c3710" + ), + bytes.fromhex( + "3ad77bb40d7a3660a89ecaf32466ef97f5d3d58503b9699de785895a96fdbaaf43b1cd7f598ece23881b00e3ed0306887b0c785e27e8ad3f8223207104725dd4" + ), +) + +Point = tuple[int, int] | None + + +@dataclass(frozen=True) +class Curve: + """y^2 = x^3 + ax + b over GF(p), base point (gx, gy) of order n.""" + + p: int + a: int + b: int + gx: int + gy: int + n: int + + @property + def g(self) -> tuple[int, int]: + return (self.gx, self.gy) + + def on_curve(self, point: Point) -> bool: + if point is None: + return True + x, y = point + return 0 <= x < self.p and 0 <= y < self.p and (y * y - x * x * x - self.a * x - self.b) % self.p == 0 + + +# FIPS 186-4 D.1.2.3 (= services::secp256r1). +P256 = Curve( + p=0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF, + a=0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC, + b=0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B, + gx=0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296, + gy=0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5, + n=0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551, +) + + +def point_double(curve: Curve, point: Point) -> Point: + if point is None or point[1] == 0: + return None + x, y = point + slope = (3 * x * x + curve.a) * pow(2 * y, -1, curve.p) % curve.p + rx = (slope * slope - 2 * x) % curve.p + return rx, (slope * (x - rx) - y) % curve.p + + +def point_add(curve: Curve, first: Point, second: Point) -> Point: + if first is None: + return second + if second is None: + return first + (x1, y1), (x2, y2) = first, second + if x1 == x2: + return point_double(curve, first) if (y1 + y2) % curve.p else None + slope = (y2 - y1) * pow(x2 - x1, -1, curve.p) % curve.p + rx = (slope * slope - x1 - x2) % curve.p + return rx, (slope * (x1 - rx) - y1) % curve.p + + +def scalar_multiply(curve: Curve, k: int, point: Point | None = None) -> Point: + """k * point (default the base point) by double-and-add from the most significant bit; None is the point at + infinity.""" + base = curve.g if point is None else point + result: Point = None + for bit in range(k.bit_length() - 1, -1, -1): + result = point_double(curve, result) + if (k >> bit) & 1: + result = point_add(curve, result, base) + return result + + +def to_bytes(value: int, length: int = 32) -> bytes: + return value.to_bytes(length, "big") + + +def from_bytes(data: bytes) -> int: + return int.from_bytes(data, "big") + + +@dataclass(frozen=True) +class CdhVector: + """NIST CAVP ECC CDH primitive test (KAS_ECC_CDH_PrimitiveTest, P-256 COUNT 0): `d * Q` has the x coordinate + `z`, and `d * G` is the public key (`qx`, `qy`).""" + + d: int + peer_x: int + peer_y: int + qx: int + qy: int + z: int + + +P256_CAVP_CDH = CdhVector( + d=0x7D7DC5F71EB29DDAF80D6214632EEAE03D9058AF1FB6D22ED80BADB62BC1A534, + peer_x=0x700C48F77F56584C5CC632CA65640DB91B6BACCE3A4DF6B42CE7CC838833D287, + peer_y=0xDB71E509E3FD9B060DDB20BA5C51DCC5948D46FBF640DFE0441782CAB85FA4AC, + qx=0xEAD218590119E8876B29146FF89CA61770C4EDBBF97D38CE385ED281D8A6B230, + qy=0x28AF61281FD35E2FA7002523ACC85A429CB06EE6648325389F59EDFCE1405141, + z=0x46FC62106420FF012E54A434FBDD2D25CCC5852060561E68040DD7778997BD7B, +) diff --git a/validation/host/hal_st_validation/fake_firmware.py b/validation/host/hal_st_validation/fake_firmware.py index 6a9f86f6..b8cf035b 100644 --- a/validation/host/hal_st_validation/fake_firmware.py +++ b/validation/host/hal_st_validation/fake_firmware.py @@ -5,10 +5,15 @@ order (`usage`, `range`, `pin`, `unsupported` before `busy`, `notopen`), and models what needs no hardware: the board profiles (ports, bonded pins, reserved pins, instances, the pin functions of the generated pinout tables, analog pins), pin ownership, the one-instance-per-group limits, timers shared between PWM, encoder and -timer-triggered ADC, EXTI line ownership, ADC trigger timing and WWDG warnings and resets over a (fake) clock. +timer-triggered ADC, the peripherals and DMA channels groups share (`ResourceAllocation`), EXTI line ownership, +ADC trigger timing and WWDG warnings and resets over a (fake) clock. It models the protocol, not the driver gaps of the board files' `known_gaps`. Measured signals (DIO levels, analog codes, UART/SPI peers, encoder counts) are not emulated; tests preset `gpio_levels`, `adc_codes`, `uart_rx` and `spi_miso` instead. + +The groups of `fakes/*.py` (`FakeGroup` subclasses) are found at construction and answer `.` +before the groups modelled here; they share the pin claims, timer owners and resources through the methods +listed in `fakes/base.py`. """ from __future__ import annotations @@ -25,8 +30,14 @@ from ad3_waveforms_bench.protocol import format_hex from . import expect +from .fakes import FakeGroup +from .fakes import discover as discover_groups +from .fakes.base import UINT32_MAX, FakeError, _choice, _fail, _flag, _hex, _number, _shape, _tokens +from .fakes.base import _Error as _Error + +__all__ = ["FakeFirmware", "FakeSerial", "OPEN_LIMITS", "RESOURCES", "UNSUPPORTED_COMMANDS", "WB55_PINS", "WBA55_PINS"] -__all__ = ["FakeFirmware", "FakeSerial", "UNSUPPORTED_COMMANDS", "WB55_PINS", "WBA55_PINS"] +Owner = tuple[str, str] # The alias tables of PROTOCOL.md, in the order `board.pins` prints them. WB55_PINS: dict[str, str] = { @@ -77,6 +88,22 @@ "sw1": "PC4", "sw2": "PD0", "sw3": "PD1", + "i2c1scl": "PB8", + "i2c1sda": "PB9", + "i2c3scl": "PC0", + "i2c3sda": "PC1", + "spi2clk": "PB13", + "spi2miso": "PB14", + "spi2mosi": "PB15", + "spi2nss": "PB12", + "spi1nss": "PA4", + "qspiclk": "PA3", + "qspincs": "PA2", + "qspiio0": "PB9", + "qspiio1": "PB8", + "qspiio2": "PA7", + "qspiio3": "PA6", + "mco": "PA8", } WBA55_PINS: dict[str, str] = { @@ -132,9 +159,26 @@ "sw1": "PC13", "sw2": "PB6", "sw3": "PB7", + "i2c1scl": "PB2", + "i2c1sda": "PB1", + "i2c3scl": "PA6", + "i2c3sda": "PA7", + "spi3clk": "PA0", + "spi3miso": "PB9", + "spi3mosi": "PB8", + "spi3nss": "PA5", + "spi1nss": "PA12", + "lptim1ch2": "PA15", + "lptim2ch1": "PA11", + "lptim2ch2": "PA1", + "lptim1in1": "PA0", + "lptim1in2": "PB3", + "lptim2in1": "PB9", + "lptim2in2": "PB0", + "tim16ch1n": "PB8", } -UNSUPPORTED_COMMANDS = ( +_UNSUPPORTED_EVERYWHERE = ( "comp.open", "comp.read", "comp.irq", @@ -143,14 +187,44 @@ "can.open", "can.send", "can.close", - "eeprom.write", - "eeprom.read", - "eeprom.erase", "eth.open", "eth.status", "eth.close", ) +# `HilUnsupportedCommands` of validation/firmware/UnsupportedGroups.cpp: groups hal-st has no driver for on the MCU. +UNSUPPORTED_COMMANDS: dict[str, tuple[str, ...]] = { + "stm32wb55": ( + *_UNSUPPORTED_EVERYWHERE, + "lptpwm.open", + "lptpwm.duty", + "lptpwm.pulse", + "lptpwm.start", + "lptpwm.stop", + "lptpwm.close", + ), + "stm32wba55": ( + *_UNSUPPORTED_EVERYWHERE, + "hsem.take", + "hsem.release", + "hsem.status", + "hsem.lock", + "hsem.mine", + "qspi.open", + "qspi.cmd", + "qspi.poll", + "qspi.xfer", + "qspi.close", + "flash.stack", + "clock.mco", + "clock.hsi48", + ), +} + +# `ResourceAllocation`: one owner per peripheral or shared DMA channel across groups. +RESOURCES = ("lpTimer", "spi", "i2c", "adc", "dma1", "dma2", "hsem") +_RESOURCE_INDEX_LIMIT = 16 + Table = dict[int, tuple[str, ...]] @@ -159,8 +233,9 @@ def _table(**instances: str) -> Table: return {int(name[1:]): tuple(pins.split()) for name, pins in instances.items()} -# Pin functions of the generated pinout tables (hal_st/stm32fxxx/PinoutTableDefault), one-based instances. -# The WB55 table also lists pins of larger packages; `bonded` filters them like the firmware does. +# Pin functions of the generated pinout tables (`PinoutTableDefault.cpp` of build/stm32wb55 and build/stm32wba55), +# one-based instances; QUADSPI uses instance 0, as `QuadSpiStm` claims its pins. The tables also list pins of +# larger packages; `bonded` filters them like the firmware does. _WB55_FUNCTIONS: dict[str, Table] = { "uartTx": _table(i1="PA9 PB6"), "uartRx": _table(i1="PA10 PB7"), @@ -173,6 +248,9 @@ def _table(**instances: str) -> Table: "spiClock": _table(i1="PA1 PA5 PB3", i2="PA9 PB10 PB13 PD1 PD3"), "spiMiso": _table(i1="PA6 PA11 PB4", i2="PB14 PC2 PD3"), "spiMosi": _table(i1="PA7 PA12 PB5", i2="PB15 PC1 PC3 PD4"), + "spiSlaveSelect": _table(i1="PA4 PA15 PB2", i2="PB9 PB12 PD0"), + "i2cScl": _table(i1="PA9 PB6 PB8", i3="PA7 PB10 PB13 PC0"), + "i2cSda": _table(i1="PA10 PB7 PB9", i3="PB4 PB11 PB14 PC1"), "timerChannel1": _table(i1="PA8 PD14", i2="PA0 PA5 PA15", i16="PA6 PB8 PE0", i17="PA7 PB9 PE1"), "timerChannel2": _table(i1="PA9 PD15", i2="PA1 PB3"), "timerChannel3": _table(i1="PA10", i2="PA2 PB10"), @@ -181,8 +259,16 @@ def _table(**instances: str) -> Table: "timerChannel2N": _table(i1="PB8 PB14"), "timerChannel3N": _table(i1="PB9 PB15"), "timerBreak": _table(i1="PA6 PB7 PB12 PC9", i16="PB5", i17="PA10 PB4"), + "lpTimerChannel1": {}, + "lpTimerChannel2": {}, "lpTimerInput1": _table(i1="PB5 PC0", i2="PB1 PC0 PD12"), "lpTimerInput2": _table(i1="PB7 PC2"), + "quadSpiClock": _table(i0="PA3 PB10"), + "quadSpiSlaveSelect": _table(i0="PA2 PB11 PD3"), + "quadSpiData0": _table(i0="PB9 PD4"), + "quadSpiData1": _table(i0="PB8 PD5"), + "quadSpiData2": _table(i0="PA7 PD6"), + "quadSpiData3": _table(i0="PA6 PD7"), } _WBA55_FUNCTIONS: dict[str, Table] = { @@ -197,6 +283,9 @@ def _table(**instances: str) -> Table: "spiClock": _table(i1="PB4", i3="PA0"), "spiMiso": _table(i1="PB3", i3="PB9"), "spiMosi": _table(i1="PA15", i3="PB8"), + "spiSlaveSelect": _table(i1="PA12", i3="PA5"), + "i2cScl": _table(i1="PA15 PB2", i3="PA6 PB2"), + "i2cSda": _table(i1="PB1 PB3", i3="PA7 PB1"), "timerChannel1": _table(i1="PA11 PB8", i2="PA5 PB6 PB12", i3="PA2 PA10 PB5", i16="PA2 PB9", i17="PA1 PB4"), "timerChannel2": _table(i1="PA12", i2="PA8", i3="PA1 PA9"), "timerChannel3": _table(i1="PB4", i2="PA7", i3="PA0 PB14"), @@ -205,8 +294,10 @@ def _table(**instances: str) -> Table: "timerChannel2N": _table(i1="PA0 PB1"), "timerChannel3N": _table(i1="PB0 PB9"), "timerBreak": _table(i1="PA2", i16="PB10 PB15", i17="PA15"), - "lpTimerInput1": {}, - "lpTimerInput2": {}, + "lpTimerChannel1": _table(i1="PB11", i2="PA11"), + "lpTimerChannel2": _table(i1="PA8 PA15", i2="PA1"), + "lpTimerInput1": _table(i1="PA0", i2="PB9"), + "lpTimerInput2": _table(i1="PB3", i2="PB0 PB4"), } @@ -224,9 +315,17 @@ class _Family: lpuarts: frozenset[int] # (lp, index) of the UARTs with DMA requests (`board::UartDma`). uart_dma: frozenset[tuple[bool, int]] + # `SynchronousUartStmSendOnly` has LPUART constructors on STM32WB only. + lpuart_send_only: bool spis: frozenset[int] + # `IS_SPI_LIMITED_INSTANCE`: 8- and 16-bit frames only. + spi_limited: frozenset[int] + i2cs: frozenset[int] + qspi: frozenset[int] timers: frozenset[int] lptims: frozenset[int] + # `IS_LPTIM_ENCODER_INTERFACE_INSTANCE` + lptim_encoders: frozenset[int] adc: int default_lpuart: tuple[str, str] default_qei: tuple[int, str, str, str] @@ -237,6 +336,10 @@ class _Family: def sampling(self) -> tuple[str, ...]: return expect.ADC_SAMPLING_TIMES[self.family] + @property + def unsupported(self) -> tuple[str, ...]: + return UNSUPPORTED_COMMANDS[self.family] + _FAMILIES: dict[str, _Family] = { "stm32wb55": _Family( @@ -251,9 +354,14 @@ def sampling(self) -> tuple[str, ...]: usarts=frozenset({1}), lpuarts=frozenset({1}), uart_dma=frozenset({(False, 1), (True, 1)}), + lpuart_send_only=True, spis=frozenset({1, 2}), + spi_limited=frozenset(), + i2cs=frozenset({1, 3}), + qspi=frozenset({1}), timers=frozenset({1, 2, 16, 17}), - lptims=frozenset({1}), + lptims=frozenset({1, 2}), + lptim_encoders=frozenset({1}), adc=1, default_lpuart=("PA2", "PA3"), default_qei=(2, "PA15", "PA1", "PC6"), @@ -263,21 +371,28 @@ def sampling(self) -> tuple[str, ...]: **{"PC0": 1, "PC1": 2, "PC2": 3, "PC3": 4, "PC4": 13, "PC5": 14}, }, ), + # PA3, PB10, PB11 and PB13 are SMPS and core supply pins on the UFQFPN48; hal-st builds the WBA55 from the + # WBA52 description, whose tables still list them. "stm32wba55": _Family( family="stm32wba55", board="NUCLEO-WBA55CG", sysclk=100_000_000, ports="ABCH", - bonded={"A": 0xFFEF, "B": 0xFFFF, "C": 0xE000, "H": 0x0008}, + bonded={"A": 0xFFE7, "B": 0xD3FF, "C": 0xE000, "H": 0x0008}, pins=WBA55_PINS, - debug_led="PB8", + debug_led="PA9", reserved=("PA13", "PA14", "PC14", "PC15", "PH3"), usarts=frozenset({1, 2}), lpuarts=frozenset({1}), uart_dma=frozenset({(False, 1), (False, 2), (True, 1)}), + lpuart_send_only=False, spis=frozenset({1, 3}), + spi_limited=frozenset({3}), + i2cs=frozenset({1, 3}), + qspi=frozenset(), timers=frozenset({1, 2, 3, 16, 17}), - lptims=frozenset(), + lptims=frozenset({1, 2}), + lptim_encoders=frozenset({1, 2}), adc=4, default_lpuart=("PB5", "PA10"), default_qei=(1, "PA11", "PA12", "PA15"), @@ -293,22 +408,48 @@ def sampling(self) -> tuple[str, ...]: # `hal::peripheralTimer` holds TIM1 .. TIM17. _TIMER_TABLE_SIZE = 17 _ADC_KEY_MAX = 0xFFFF -_OPEN_LIMITS = {"pwm": 1, "uart": 1, "spi": 1, "adc": 1, "qei": 1, "gpio": 8} +# Instances a group holds at once (PROTOCOL.md: one per group, eight GPIO pins); groups not listed hold one. +OPEN_LIMITS = { + "pwm": 1, + "uart": 1, + "spi": 1, + "adc": 1, + "qei": 1, + "gpio": 8, + "i2c": 1, + "i2cs": 1, + "eeprom": 1, + "spis": 1, + "tim": 1, + "tpwm": 1, + "lptim": 1, + "lptpwm": 1, + "qspi": 1, +} _PWM_CHANNELS_MAX = 4 +_PWM_MODES = ("edge", "edgedown", "center", "centerup", "centerboth") +_PWM_TRIGGER_OUTPUTS = ("reset", "enable", "update", "oc1", "oc1ref", "oc2ref", "oc3ref", "oc4ref") +_PWM_BREAK_FILTER_MAX = 15 _UART_RECEIVE_CAPACITY = 256 _UART_TRANSMIT_CAPACITY = 112 _SPI_CAPACITY = 64 +_SPI_BITS = (4, 16) +_SPI_LIMITED_BITS = (8, 16) _DELAY_MAX_MS = 600_000 _PULSES_MAX = 1_000_000 _PULSE_PERIOD_MAX_MS = 60_000 _RECEIVE_TIMEOUT_MAX_MS = 10_000 _ADC_MEASURE_TIMEOUT = 1.0 +_ADC_DMA = ("dma1", 7) +# `adc.open trgo=`: PwmStm drives TRGO only, and the WBA55 ADC4 reaches TIM1 through TRGO2 alone (AdcFactory.cpp). +_ADC_PWM_TRIGGER_TIMERS = {"stm32wb55": frozenset({1, 2}), "stm32wba55": frozenset({2})} _QEI_RESOLUTION_16BIT = 65536 _QEI_VELOCITY_MAX_US = 1_000_000 +_QEI_CAPTURES = ("a", "b", "ab") +_QEI_LP_CAPTURES = ("ab", "rise", "fall") _LPTIM_FILTERS = (0, 2, 4, 8) # `HilPinNamingDefault`: a port letter and up to three digits without leading zero. _PIN_RE = re.compile(r"^[Pp]([A-Za-z])(0|[1-9][0-9]{0,2})$") -_UINT32_MAX = 0xFFFFFFFF class FakeSerial(_FakeSerial): @@ -333,78 +474,6 @@ def _poll(self) -> None: poll() -class _Error(Exception): - def __init__(self, reason: str) -> None: - super().__init__(reason) - self.reason = reason - - -def _fail(reason: str) -> None: - raise _Error(reason) - - -def _parse_uint(text: str) -> int: - """`HilArguments::ParseNumber`: decimal or `0x` hex, unsigned 32 bits.""" - base, digits = 10, "0123456789" - if len(text) > 2 and text[0] == "0" and text[1] in "xX": - base, digits, text = 16, "0123456789abcdefABCDEF", text[2:] - if not text or any(char not in digits for char in text): - _fail("usage") - value = int(text, base) - if value > _UINT32_MAX: - _fail("usage") - return value - - -def _number(text: str | None, low: int = 0, high: int = _UINT32_MAX) -> int: - if text is None: - _fail("usage") - assert text is not None - value = _parse_uint(text) - if not low <= value <= high: - _fail("range") - return value - - -def _flag(options: Mapping[str, str], key: str, default: bool = False) -> bool: - if key not in options: - return default - return _number(options[key], 0, 1) == 1 - - -def _choice(options: Mapping[str, str], key: str, choices: Iterable[str], default: str) -> str: - value = options.get(key, default) - if value not in tuple(choices): - _fail("usage") - return value - - -def _shape(args: list[str], options: Mapping[str, str], low: int, high: int, keys: Iterable[str] = ()) -> None: - """`HilArguments::Shape`: positional count and known keys, else `ERR usage`.""" - allowed = tuple(keys) - if not low <= len(args) <= high or any(key not in allowed for key in options): - _fail("usage") - - -def _tokens(text: str) -> list[str]: - """`infra::Tokenizer` on `,`: empty entries are skipped.""" - return [token for token in text.split(",") if token] - - -def _hex(text: str, capacity: int) -> bytes: - """`HilArguments::ParseHex`: `-` is empty, odd or non-hex is `usage`, beyond `capacity` is `range`.""" - if text == "-": - return b"" - if not text or len(text) % 2: - _fail("usage") - if len(text) // 2 > capacity: - _fail("range") - try: - return bytes.fromhex(text) - except ValueError: - raise _Error("usage") from None - - def _duty(text: str) -> float: """`HilArguments::ParseDutyCycle`: decimal percent 0-100 with up to 4 decimals.""" integer, dot, fraction = text.partition(".") @@ -416,6 +485,11 @@ def _duty(text: str) -> float: return value +def _pwm_alignment(mode: str) -> expect.PwmMode: + """`center`, `centerup` and `centerboth` count up and down; `edge` and `edgedown` one way.""" + return "center" if mode.startswith("center") else "edge" + + @dataclass class _PwmOutput: """One entry of `pwm.open`: channel 0 until it is inferred from the pin.""" @@ -460,10 +534,12 @@ def __post_init__(self) -> None: self.sysclk = self.sysclk or spec.sysclk if self.pins is None: self.pins = dict(spec.pins) - self.claims: dict[str, tuple[tuple[str, str], bool]] = {} - self.timer_owners: dict[int, tuple[str, str]] = {} + self.claims: dict[str, tuple[Owner, bool]] = {} + self.timer_owners: dict[int, Owner] = {} + self.resources: dict[tuple[str, int], Owner] = {} self.exti: dict[int, str] = {} self.watchdog: dict[str, Any] | None = None + self.groups: dict[str, FakeGroup] = {cls.prefix: cls(self) for cls in discover_groups()} super().__post_init__() @property @@ -479,8 +555,11 @@ def boot(self) -> None: self.opened.clear() self.claims = {} self.timer_owners = {} + self.resources = {} self.exti = {} self.watchdog = None + for group in self.groups.values(): + group.boot() super().boot() def event(self, line: str) -> None: @@ -490,20 +569,28 @@ def event(self, line: str) -> None: def boot_message(self) -> str: return f"EVT boot board={self.board} family={self.family} sysclk={self.sysclk} reset={self.reset_cause}" + def group(self, prefix: str) -> FakeGroup | None: + return self.groups.get(prefix) + def lookup(self, name: str) -> Handler | None: handler = self.handlers.get(name) if handler is not None: return handler - if name in UNSUPPORTED_COMMANDS: + if name in self.spec.unsupported: return lambda device, args, options: "ERR unsupported" - method = getattr(self, "_cmd_" + name.replace(".", "_"), None) + prefix, _, verb = name.partition(".") + group = self.groups.get(prefix) + method = group.handler(verb) if group is not None and verb else None + if method is None: + method = getattr(self, "_cmd_" + name.replace(".", "_"), None) if method is None: return None + command = method def run(device: FakeTerminalDevice, args: list[str], options: dict[str, str]) -> str | list[str] | None: try: - return method(args, options) - except _Error as error: + return command(args, options) + except FakeError as error: return f"ERR {error.reason}" return run @@ -540,18 +627,19 @@ def supports(self, function: str, instance: int, pin: str | None) -> bool: def supports_analog(self, pin: str) -> bool: return self.bonded(pin) and pin in self.spec.analog - def _check_function(self, function: str, instance: int, pin: str | None) -> None: + def check_function(self, function: str, instance: int, pin: str | None) -> None: + """`ERR pin` unless a given pin offers `function` of `instance`.""" if pin is not None and not self.supports(function, instance, pin): _fail("pin") - def _first_function_pin(self, function: str, instance: int) -> str | None: + def first_function_pin(self, function: str, instance: int) -> str | None: """`FindFunctionPin`: the first bonded pin of the table that is not reserved.""" for pin in self.spec.functions.get(function, {}).get(instance, ()): if self.bonded(pin) and pin not in self.reserved(): return pin return None - def _check_pins(self, owner: tuple[str, str], pins: Iterable[str | None], analog: bool = False) -> None: + def check_pins(self, owner: Owner, pins: Iterable[str | None], analog: bool = False) -> None: """`HilPinPool::Claim`: `pin` for a pin the package lacks, `busy` for a reserved or held pin (analog users share a pin).""" seen: set[str] = set() @@ -569,19 +657,37 @@ def _check_pins(self, owner: tuple[str, str], pins: Iterable[str | None], analog _fail("busy") seen.add(pin) - def _claim(self, owner: tuple[str, str], pins: Iterable[str | None], analog: bool = False) -> None: + def claim(self, owner: Owner, pins: Iterable[str | None], analog: bool = False) -> None: for pin in pins: if pin is not None: self.claims.setdefault(pin, (owner, analog)) - def _release(self, owner: tuple[str, str]) -> None: + def release(self, owner: Owner) -> None: + """Everything `owner` holds: pins, timers and resources.""" self.claims = {pin: holder for pin, holder in self.claims.items() if holder[0] != owner} self.timer_owners = {timer: holder for timer, holder in self.timer_owners.items() if holder != owner} + self.release_resources(owner) + + def check_resources(self, owner: Owner, resources: Iterable[tuple[str, int]]) -> None: + """`ResourceAllocation::Claim` without claiming: `busy` when another owner holds one of them.""" + for kind, index in resources: + if kind not in RESOURCES or not 0 <= index < _RESOURCE_INDEX_LIMIT: + raise ValueError(f"no resource {kind} {index}") + if self.resources.get((kind, index), owner) != owner: + _fail("busy") + + def claim_resource(self, kind: str, index: int, owner: Owner) -> None: + """`ResourceAllocation::Claim`: `busy` when another owner holds it; the same owner may claim it again.""" + self.check_resources(owner, [(kind, index)]) + self.resources[(kind, index)] = owner + + def release_resources(self, owner: Owner) -> None: + self.resources = {key: holder for key, holder in self.resources.items() if holder != owner} - def _timer_exists(self, timer: int) -> bool: + def timer_exists(self, timer: int) -> bool: return timer in self.spec.timers - def _open( + def open_instance( self, group: str, key: str, @@ -589,37 +695,47 @@ def _open( pins: Iterable[str | None], analog: bool = False, timer: int | None = None, + resources: Iterable[tuple[str, int]] = (), ) -> None: """Claims an instance after its arguments were validated: everything here answers `ERR busy`.""" owner = (group, key) pins = list(pins) - if owner in self.opened or sum(1 for opened_group, _ in self.opened if opened_group == group) >= _OPEN_LIMITS[group]: + resources = list(resources) + held = sum(1 for opened_group, _ in self.opened if opened_group == group) + if owner in self.opened or held >= OPEN_LIMITS.get(group, 1): _fail("busy") if timer is not None and self.timer_owners.get(timer, owner) != owner: _fail("busy") - self._check_pins(owner, pins, analog) - self._claim(owner, pins, analog) + self.check_resources(owner, resources) + self.check_pins(owner, pins, analog) + self.claim(owner, pins, analog) if timer is not None: self.timer_owners[timer] = owner + for kind, index in resources: + self.resources[(kind, index)] = owner self.opened[owner] = state - def _index(self, group: str, text: str) -> int: - """`HilSingleInstance::Parse`: the instance number of the command.""" - return _number(text, 0, _INSTANCES[group] - 1) - - def _find(self, group: str, text: str) -> tuple[str, dict[str, Any]]: - key = str(self._index(group, text)) + def find_instance(self, group: str, key: str) -> dict[str, Any]: + """The state of an open instance, else `ERR notopen`.""" state = self.opened.get((group, key)) if state is None: _fail("notopen") assert state is not None - return key, state + return state - def _close(self, group: str, key: str) -> str: + def close_instance(self, group: str, key: str) -> str: del self.opened[(group, key)] - self._release((group, key)) + self.release((group, key)) return "OK" + def _index(self, group: str, text: str) -> int: + """`HilSingleInstance::Parse`: the instance number of the command.""" + return _number(text, 0, _INSTANCES[group] - 1) + + def _find(self, group: str, text: str) -> tuple[str, dict[str, Any]]: + key = str(self._index(group, text)) + return key, self.find_instance(group, key) + # general def _cmd_ping(self, args: list[str], options: dict[str, str]) -> str: @@ -661,7 +777,7 @@ def _gpio_free(self, pin: str) -> None: if self.exti.get(line) == pin: del self.exti[line] del self.opened[("gpio", pin)] - self._release(("gpio", pin)) + self.release(("gpio", pin)) def _cmd_gpio_cfg(self, args: list[str], options: dict[str, str]) -> str: _shape(args, options, 2, 2, ("pull", "drive")) @@ -676,7 +792,7 @@ def _cmd_gpio_cfg(self, args: list[str], options: dict[str, str]) -> str: _fail("usage") if ("gpio", pin) in self.opened: self._gpio_free(pin) - self._open("gpio", pin, {"mode": mode, "pull": pull, "drive": drive, "irq": "off"}, [pin]) + self.open_instance("gpio", pin, {"mode": mode, "pull": pull, "drive": drive, "irq": "off"}, [pin]) if mode == "out": self.gpio_levels[pin] = 0 elif mode == "od": @@ -803,43 +919,54 @@ def _pwm_resolve(self, timer: int, outputs: list[_PwmOutput]) -> None: ): _fail("unsupported") if output.pin is None and output.npin is None: - output.pin = self._first_function_pin(f"timerChannel{channel}", timer) + output.pin = self.first_function_pin(f"timerChannel{channel}", timer) if output.pin is None: _fail("pin") - self._check_function(f"timerChannel{channel}", timer, output.pin) - self._check_function(f"timerChannel{channel}N", timer, output.npin) + self.check_function(f"timerChannel{channel}", timer, output.pin) + self.check_function(f"timerChannel{channel}N", timer, output.npin) def _cmd_pwm_open(self, args: list[str], options: dict[str, str]) -> str: keys = ("channels", "pins", "freq", "mode", "prescaler", "dead", "inv", "invn", "idle", "idlen", "brk", "brkpol", "brkauto", "sync") - _shape(args, options, 1, 1, keys) + _shape(args, options, 1, 1, (*keys, "preload", "brkfilter", "trgo")) timer = self._index("pwm", args[0]) - if not self._timer_exists(timer): + if not self.timer_exists(timer): _fail("range") freq = _number(options.get("freq", "10000"), 1) - mode = _choice(options, "mode", ("edge", "center"), "edge") + mode = _choice(options, "mode", _PWM_MODES, "edge") + trgo = _choice(options, "trgo", _PWM_TRIGGER_OUTPUTS, "reset") if "trgo" in options else None prescaler = _number(options.get("prescaler", "0"), 0, expect.PWM_PRESCALER_MAX) dead = None if options.get("dead", "off") == "off" else _number(options["dead"], 0, expect.PWM_DEAD_MAX_NS) flags = {key: _flag(options, key) for key in ("inv", "invn", "idle", "idlen", "brkauto", "sync")} + preload = _flag(options, "preload", True) _choice(options, "brkpol", ("low", "high"), "high") + brkfilter = _number(options["brkfilter"], 0, _PWM_BREAK_FILTER_MAX) if "brkfilter" in options else None brk = self.pin(options.get("brk")) + if brkfilter is not None and brk is None: + _fail("usage") outputs = self._pwm_outputs(options) - if mode == "center" and not expect.timer_has_center_mode(timer): + # PwmStm asserts a counter mode select instance for every alignment but edgeAligned (PwmStm.cpp:224). + if mode != "edge" and not expect.timer_has_center_mode(timer): + _fail("unsupported") + # `IS_TIM_MASTER_INSTANCE`: the timers with a counter mode select, on both MCUs. + if trgo is not None and not expect.timer_has_center_mode(timer): _fail("unsupported") complementary = any(output.npin is not None for output in outputs) needs_break = complementary or dead is not None or flags["idle"] or flags["idlen"] or brk is not None if needs_break and not expect.timer_has_break(timer): _fail("unsupported") self._pwm_resolve(timer, outputs) - self._check_function("timerBreak", timer, brk) + self.check_function("timerBreak", timer, brk) pwmclk = expect.pwm_clock(self.kernel_clock, prescaler) counter_max = expect.timer_counter_max(timer) - if not expect.pwm_fits(pwmclk, freq, mode, counter_max): + alignment = _pwm_alignment(mode) + if not expect.pwm_fits(pwmclk, freq, alignment, counter_max): _fail("range") channels = [(output.channel, output.pin, output.npin) for output in outputs] pins = [pin for _, first, second in channels for pin in (first, second)] + [brk] state = {"channels": channels, "freq": freq, "mode": mode, "pwmclk": pwmclk, "counter_max": counter_max, "running": False} - state.update(dead=dead, brk=brk, sync=flags["sync"], duties=None) - self._open("pwm", str(timer), state, pins, timer=timer) + state.update(alignment=alignment, dead=dead, brk=brk, brkfilter=brkfilter, sync=flags["sync"], duties=None) + state.update(preload=preload, trgo=trgo) + self.open_instance("pwm", str(timer), state, pins, timer=timer) return f"OK pwmclk={pwmclk}" def _cmd_pwm_duty(self, args: list[str], options: dict[str, str]) -> str: @@ -856,7 +983,7 @@ def _cmd_pwm_freq(self, args: list[str], options: dict[str, str]) -> str: _shape(args, options, 2, 2) _, state = self._find("pwm", args[0]) freq = _number(args[1], 1) - if not expect.pwm_fits(state["pwmclk"], freq, state["mode"], state["counter_max"]): + if not expect.pwm_fits(state["pwmclk"], freq, state["alignment"], state["counter_max"]): _fail("range") state["freq"] = freq return "OK" @@ -870,12 +997,12 @@ def _cmd_pwm_stop(self, args: list[str], options: dict[str, str]) -> str: def _cmd_pwm_close(self, args: list[str], options: dict[str, str]) -> str: _shape(args, options, 1, 1) key, _ = self._find("pwm", args[0]) - return self._close("pwm", key) + return self.close_instance("pwm", key) # uart (`UartFactory.cpp` `Evaluate`) def _cmd_uart_open(self, args: list[str], options: dict[str, str]) -> str: - keys = ("lp", "tx", "rx", "rts", "cts", "baud", "parity", "flow", "swap", "dma", "duplex", "sync") + keys = ("lp", "tx", "rx", "rts", "cts", "baud", "parity", "flow", "swap", "dma", "duplex", "sync", "sendonly") _shape(args, options, 1, 1, keys) index = self._index("uart", args[0]) lp = _flag(options, "lp") @@ -883,14 +1010,20 @@ def _cmd_uart_open(self, args: list[str], options: dict[str, str]) -> str: baud = _number(options.get("baud", "115200"), expect.UART_BAUD_MIN, expect.UART_BAUD_MAX) parity = _choice(options, "parity", ("none", "even", "odd"), "none") flow = _choice(options, "flow", ("none", "rts", "cts", "rtscts"), "none") - swap, dma, duplex, sync = (_flag(options, key) for key in ("swap", "dma", "duplex", "sync")) + swap, dma, duplex, sync, sendonly = (_flag(options, key) for key in ("swap", "dma", "duplex", "sync", "sendonly")) if index not in (self.spec.lpuarts if lp else self.spec.usarts): _fail("range") - if dma + duplex + sync > 1: + if dma + duplex + sync + sendonly > 1: _fail("usage") if (flow in ("rts", "rtscts")) != (rts is not None) or (flow in ("cts", "rtscts")) != (cts is not None): _fail("usage") - if (lp and (duplex or sync)) or (sync and (parity != "none" or swap)) or (not sync and flow not in ("none", "rtscts")): + synchronous = sync or sendonly + if (lp and (duplex or sync)) or (synchronous and (parity != "none" or swap)): + _fail("unsupported") + # `SynchronousUartStmSendOnly` takes an RTS pin but no CTS pin, and LPUARTs on STM32WB only. + if sendonly and (flow not in ("none", "rts") or (lp and not self.spec.lpuart_send_only)): + _fail("unsupported") + if not synchronous and flow not in ("none", "rtscts"): _fail("unsupported") if (dma or duplex) and (lp, index) not in self.spec.uart_dma: _fail("unsupported") @@ -900,15 +1033,16 @@ def _cmd_uart_open(self, args: list[str], options: dict[str, str]) -> str: defaults = self._terminal_pins() if terminal else self.spec.default_lpuart if lp and index == _DEFAULT_LPUART else None if defaults is not None and tx is None and rx is None and rts is None and cts is None: tx, rx = defaults - if tx is None or rx is None: + if tx is None or (rx is None and not sendonly): _fail("usage") prefix = "lpuart" if lp else "uart" for function, pin in (("Tx", tx), ("Rx", rx), ("Rts", rts), ("Cts", cts)): - self._check_function(prefix + function, index, pin) + self.check_function(prefix + function, index, pin) if terminal: _fail("busy") state = {"lp": lp, "baud": baud, "parity": parity, "flow": flow, "swap": swap, "dma": dma, "duplex": duplex, "sync": sync} - self._open("uart", str(index), state, [tx, rx, rts, cts]) + state["sendonly"] = sendonly + self.open_instance("uart", str(index), state, [tx, rx, rts, cts]) self.uart_rx[index] = bytearray() return "OK" @@ -926,10 +1060,12 @@ def _cmd_uart_send(self, args: list[str], options: dict[str, str]) -> str: def _cmd_uart_recv(self, args: list[str], options: dict[str, str]) -> str: _shape(args, options, 1, 1, ("timeout", "len")) - key, _ = self._find("uart", args[0]) + key, state = self._find("uart", args[0]) _number(options.get("timeout", "1000"), 0, _RECEIVE_TIMEOUT_MAX_MS) if "len" in options: _number(options["len"], 1, _UART_RECEIVE_CAPACITY) + if state["sendonly"]: + return "OK data=-" buffer = self.uart_rx.setdefault(int(key), bytearray()) data = bytes(buffer[:_UART_RECEIVE_CAPACITY]) del buffer[: len(data)] @@ -938,31 +1074,36 @@ def _cmd_uart_recv(self, args: list[str], options: dict[str, str]) -> str: def _cmd_uart_close(self, args: list[str], options: dict[str, str]) -> str: _shape(args, options, 1, 1) key, _ = self._find("uart", args[0]) - return self._close("uart", key) + return self.close_instance("uart", key) # spi (`SpiFactory.cpp` `Evaluate`) def _cmd_spi_open(self, args: list[str], options: dict[str, str]) -> str: - _shape(args, options, 1, 1, ("clk", "mosi", "miso", "cs", "baud", "mode", "dma", "sync")) + _shape(args, options, 1, 1, ("clk", "mosi", "miso", "cs", "baud", "mode", "dma", "sync", "bits", "lsb", "nss")) index = self._index("spi", args[0]) if index not in self.spec.spis: _fail("range") - clk, mosi, miso, cs = (self.pin(options.get(key)) for key in ("clk", "mosi", "miso", "cs")) + clk, mosi, miso, cs, nss = (self.pin(options.get(key)) for key in ("clk", "mosi", "miso", "cs", "nss")) baud = _number(options.get("baud", "1000000"), 1) mode = _number(options.get("mode", "0"), 0, 3) - dma, sync = _flag(options, "dma"), _flag(options, "sync") + bits = _number(options.get("bits", "8"), *_SPI_BITS) + dma, sync, lsb = _flag(options, "dma"), _flag(options, "sync"), _flag(options, "lsb") if clk is None or mosi is None or miso is None: _fail("usage") - if dma and sync: + if (dma and sync) or (nss is not None and cs is not None): _fail("usage") if not expect.spi_baud_fits(self.kernel_clock, baud): _fail("range") - for function, pin in (("spiClock", clk), ("spiMosi", mosi), ("spiMiso", miso)): - self._check_function(function, index, pin) + for function, pin in (("spiClock", clk), ("spiMosi", mosi), ("spiMiso", miso), ("spiSlaveSelect", nss)): + self.check_function(function, index, pin) if cs is not None and not self.bonded(cs): _fail("pin") + # Only `SpiMasterStmDma` has a data size configurator; a limited instance takes 8- and 16-bit frames only. + if (bits != 8 and not dma) or (index in self.spec.spi_limited and bits not in _SPI_LIMITED_BITS): + _fail("unsupported") state = {"baud": baud, "clock": expect.spi_clock(self.kernel_clock, baud), "mode": mode, "dma": dma, "sync": sync, "cs": cs} - self._open("spi", str(index), state, [clk, mosi, miso, cs]) + state.update(bits=bits, lsb=lsb, nss=nss) + self.open_instance("spi", str(index), state, [clk, mosi, miso, cs, nss], resources=[("spi", index)]) return "OK" def _cmd_spi_xfer(self, args: list[str], options: dict[str, str]) -> str: @@ -978,7 +1119,7 @@ def _cmd_spi_xfer(self, args: list[str], options: dict[str, str]) -> str: def _cmd_spi_close(self, args: list[str], options: dict[str, str]) -> str: _shape(args, options, 1, 1) key, _ = self._find("spi", args[0]) - return self._close("spi", key) + return self.close_instance("spi", key) # adc (`AdcFactory.cpp` `ParseKey` and `Parse`) @@ -997,13 +1138,16 @@ def _adc_find(self, args: list[str]) -> tuple[str, dict[str, Any]]: return key, state def _cmd_adc_open(self, args: list[str], options: dict[str, str]) -> str: - _shape(args, options, 1, 1, ("pins", "sampling", "timer", "rate")) + _shape(args, options, 1, 1, ("pins", "sampling", "timer", "rate", "trgo")) key = self._adc_key(args) _choice(options, "sampling", self.spec.sampling, expect.ADC_DEFAULT_SAMPLING[self.family]) timer = _number(options["timer"], 0, _TIMER_TABLE_SIZE) if "timer" in options else None rate = _number(options.get("rate", str(expect.ADC_DEFAULT_RATE)), 1, expect.ADC_RATE_MAX) + trgo = _number(options["trgo"], 0, _TIMER_TABLE_SIZE) if "trgo" in options else None if "rate" in options and timer is None: _fail("usage") + if trgo is not None and (timer is not None or "rate" in options): + _fail("usage") if "pins" not in options: _fail("usage") entries = options["pins"].split(",") @@ -1016,12 +1160,23 @@ def _cmd_adc_open(self, args: list[str], options: dict[str, str]) -> str: if not self.supports_analog(pin): _fail("pin") pins.append(pin) - if timer is not None and not self._timer_exists(timer): + if timer is not None and not self.timer_exists(timer): _fail("range") if timer is not None and timer not in expect.ADC_TRIGGER_TIMERS: _fail("unsupported") - state = {"pins": pins, "timer": timer, "rate": rate} - self._open("adc", key, state, pins, analog=True, timer=timer) + if trgo is not None: + if not self.timer_exists(trgo): + _fail("range") + # The sequence runs on the TRGO of a timer the pwm group drives; the adc group does not own it. + holder = self.timer_owners.get(trgo) + if trgo not in _ADC_PWM_TRIGGER_TIMERS[self.family] or holder is None: + _fail("unsupported") + assert holder is not None + if holder[0] != "pwm": + _fail("busy") + state = {"pins": pins, "timer": timer, "rate": rate, "trgo": trgo} + resources = [("adc", self.spec.adc), _ADC_DMA] + self.open_instance("adc", key, state, pins, analog=True, timer=timer, resources=resources) return "OK" def _cmd_adc_measure(self, args: list[str], options: dict[str, str]) -> str: @@ -1030,8 +1185,9 @@ def _cmd_adc_measure(self, args: list[str], options: dict[str, str]) -> str: runs = _number(options.get("n", "1"), 1, expect.ADC_MAX_VALUES) if runs * len(state["pins"]) > expect.ADC_MAX_VALUES: _fail("range") - if state["timer"] is not None: - duration = expect.adc_measure_time(runs, state["rate"]) + rate = state["rate"] if state["trgo"] is None else self._trigger_rate(state["trgo"]) + if state["timer"] is not None or state["trgo"] is not None: + duration = math.inf if rate is None else expect.adc_measure_time(runs, rate) if duration > _ADC_MEASURE_TIMEOUT: self.sleep(_ADC_MEASURE_TIMEOUT) return "ERR timeout" @@ -1042,10 +1198,15 @@ def _cmd_adc_measure(self, args: list[str], options: dict[str, str]) -> str: self.sleep(expect.terminal_line_time(reply, self.terminal_baud)) return reply + def _trigger_rate(self, timer: int) -> int | None: + """Runs per second of `adc.open trgo=`: one per period of the pwm group's timer, none while it is stopped.""" + state = self.opened.get(("pwm", str(timer))) + return state["freq"] if state is not None and state["running"] else None + def _cmd_adc_close(self, args: list[str], options: dict[str, str]) -> str: _shape(args, options, 1, 1) key, _ = self._adc_find(args) - return self._close("adc", key) + return self.close_instance("adc", key) # qei (`QeiFactory.cpp` `Evaluate`) @@ -1056,22 +1217,23 @@ def _cmd_qei_open(self, args: list[str], options: dict[str, str]) -> str: lp = _flag(options, "lp") if lp and not self.spec.lptims: _fail("unsupported") - if lp and timer not in self.spec.lptims: + if lp and timer not in self.spec.lptim_encoders: _fail("range") - if not lp and not self._timer_exists(timer): + if not lp and not self.timer_exists(timer): _fail("range") if not lp and timer not in expect.ENCODER_TIMERS: _fail("unsupported") - if lp and any(key in options for key in ("cap", "offset", "invb")): + # The LPTIM encoder counts both inputs (x4) or one edge of both (x2); it has no offset and one polarity. + if lp and (any(key in options for key in ("offset", "invb")) or options.get("cap") in ("a", "b")): _fail("unsupported") - maximum = _UINT32_MAX if not lp and timer in expect.TIMERS_32BIT else _QEI_RESOLUTION_16BIT + maximum = UINT32_MAX if not lp and timer in expect.TIMERS_32BIT else _QEI_RESOLUTION_16BIT res = _number(options.get("res", "4096"), 2, maximum) offset = _number(options.get("offset", "0")) if offset >= res: _fail("range") _flag(options, "inva") _flag(options, "invb") - _choice(options, "cap", ("a", "b", "ab"), "ab") + cap = _choice(options, "cap", _QEI_LP_CAPTURES if lp else _QEI_CAPTURES, "ab") filter_samples = _number(options.get("filter", "0"), 0, 15) if options.get("vel") != "off": _number(options.get("vel", "1000"), 1, _QEI_VELOCITY_MAX_US) @@ -1088,8 +1250,9 @@ def _cmd_qei_open(self, args: list[str], options: dict[str, str]) -> str: _fail("pin") if idx is not None and not self.bonded(idx): _fail("pin") - state = {"lp": lp, "res": res, "pos": offset, "dir": "fwd", "speed": 0, "idx": idx} - self._open("qei", str(timer), state, [a, b, idx], timer=None if lp else timer) + state = {"lp": lp, "res": res, "pos": offset, "dir": "fwd", "speed": 0, "idx": idx, "cap": cap} + resources = [("lpTimer", timer)] if lp else [] + self.open_instance("qei", str(timer), state, [a, b, idx], timer=None if lp else timer, resources=resources) return "OK" def _cmd_qei_read(self, args: list[str], options: dict[str, str]) -> str: @@ -1107,7 +1270,7 @@ def _cmd_qei_index(self, args: list[str], options: dict[str, str]) -> str: def _cmd_qei_close(self, args: list[str], options: dict[str, str]) -> str: _shape(args, options, 1, 1) key, _ = self._find("qei", args[0]) - return self._close("qei", key) + return self.close_instance("qei", key) # watchdog (`HilWatchDogCommands::Parse` around `WatchDogFactory.cpp` `Prepare`) @@ -1132,8 +1295,8 @@ def _cmd_wdt_start(self, args: list[str], options: dict[str, str]) -> str: if self.watchdog is not None: _fail("busy") owner = ("wdt", str(index)) - self._check_pins(owner, [pin]) - self._claim(owner, [pin]) + self.check_pins(owner, [pin]) + self.claim(owner, [pin]) if pin is not None: self.gpio_levels[pin] = 0 period = expect.wwdg_period(self.pclk1, prescaler) @@ -1165,6 +1328,12 @@ def _cmd_wdt_feed(self, args: list[str], options: dict[str, str]) -> str: return "OK" def poll(self) -> None: + """Time-driven output of the groups, then of the watchdog.""" + for group in self.groups.values(): + group.poll() + self._poll_watchdog() + + def _poll_watchdog(self) -> None: """Early warnings every period; `feed=auto` refreshes on each, otherwise the WWDG resets the board one counter tick after a warning that was not answered with `wdt.feed`. A tick shorter than the transmission of the `EVT wdt` line cuts the line: the host gets the part sent before the reset.""" diff --git a/validation/host/hal_st_validation/fakes/__init__.py b/validation/host/hal_st_validation/fakes/__init__.py new file mode 100644 index 00000000..00e242e0 --- /dev/null +++ b/validation/host/hal_st_validation/fakes/__init__.py @@ -0,0 +1,28 @@ +"""Command groups of the fake firmware, one module per area (`fakes/.py`); `discover()` finds them.""" + +from __future__ import annotations + +import importlib +import inspect +import pkgutil + +from .base import FakeGroup + +__all__ = ["FakeGroup", "discover"] + + +def discover() -> tuple[type[FakeGroup], ...]: + """Every `FakeGroup` subclass with a `prefix` defined in a module of this package, sorted by prefix; two groups + with one prefix are an error.""" + found: dict[str, type[FakeGroup]] = {} + for info in pkgutil.iter_modules(__path__): + if info.name == "base": + continue + module = importlib.import_module(f"{__name__}.{info.name}") + for _, cls in inspect.getmembers(module, inspect.isclass): + if cls.__module__ != module.__name__ or not issubclass(cls, FakeGroup) or not cls.prefix: + continue + other = found.setdefault(cls.prefix, cls) + if other is not cls: + raise ValueError(f"fake groups {other.__qualname__} and {cls.__qualname__} both serve {cls.prefix!r}") + return tuple(found[prefix] for prefix in sorted(found)) diff --git a/validation/host/hal_st_validation/fakes/analog.py b/validation/host/hal_st_validation/fakes/analog.py new file mode 100644 index 00000000..c79c1ef8 --- /dev/null +++ b/validation/host/hal_st_validation/fakes/analog.py @@ -0,0 +1,179 @@ +"""Fake `ain` and `dma` groups: the argument checks of validation/firmware/AnalogInputGroup.cpp and DmaGroup.cpp in +their order, and what they share with `adc`, `pwm` and the other timer users: the ADC and its DMA channel +(`ResourceAllocation` adc, dma1 7), TIM2 (`TimerAllocation`) and the `dma.wave` channel (WB55 DMA2 4, WBA55 GPDMA1 8). + +`ain.burst` and `dma.wave` answer later, as the firmware does: they hold their claims until the fake clock reaches +the end of the measurements or of the wave, and `poll()` prints the `EVT ain` lines and the final line then.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import ClassVar + +from ..groups.analog import ( + AIN_BUFFER, + AIN_LIST_MAX, + AIN_RATE_MAX, + AIN_RATE_MIN, + AIN_REPEAT_MAX, + DMA_PATTERN_MAX, + DMA_RATE_MAX, + DMA_WAVE_DEFAULT_MS, + DMA_WAVE_MAX_MS, + burst_seconds, +) +from .base import FakeGroup, _choice, _fail, _hex, _number, _shape + +_INDEX_MAX = 0xFFFF +# `AdcTriggeredByTimerWithDma` paces its conversions with TIM2; TIM2's update request feeds `dma.wave`. +_PACING_TIMER = 2 +_ADC_DMA = ("dma1", 7) +_WAVE_DMA = {"stm32wb55": ("dma2", 4), "stm32wba55": ("dma1", 8)} +_OUTPUTS = ("list", "stats") +# What the fake temperature sensor reads: a code and 25 degrees Celsius. +TEMPERATURE_CODE = 940 +TEMPERATURE_MCELSIUS = 25_000 + + +@dataclass +class _Burst: + adc: int + code: int + samples: int + seconds: float + repeat: int + output: str + started: float + reported: int = 0 + + +class FakeAnalogInput(FakeGroup): + prefix = "ain" + owner: ClassVar[tuple[str, str]] = ("ain", "ain") + + def boot(self) -> None: + self.burst: _Burst | None = None + + def _index(self, text: str) -> int: + return _number(text, 0, _INDEX_MAX) + + def _analog_pin(self, text: str) -> str: + """`ParseAnalogPin`: a name of a pin with an ADC channel, else `ERR pin`.""" + pin = self.fw.pin(text) + assert pin is not None + if not self.fw.supports_analog(pin): + _fail("pin") + return pin + + def _claim(self, pin: str | None, timed: bool) -> None: + """`Claim`: the ADC, with `timed` its DMA channel and TIM2, and the pin as an analog input (all `busy`).""" + resources = [("adc", self.fw.spec.adc)] + ([_ADC_DMA] if timed else []) + self.fw.check_resources(self.owner, resources) + if timed and self.fw.timer_owners.get(_PACING_TIMER, self.owner) != self.owner: + _fail("busy") + self.fw.check_pins(self.owner, [pin], analog=True) + for kind, index in resources: + self.fw.claim_resource(kind, index, self.owner) + if timed: + self.fw.timer_owners[_PACING_TIMER] = self.owner + self.fw.claim(self.owner, [pin], analog=True) + + def cmd_read(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 2, ("sampling",)) + adc = self._index(args[0]) + _choice(options, "sampling", self.fw.spec.sampling, self.fw.spec.sampling[0]) + if adc != self.fw.spec.adc: + _fail("range") + temperature = args[1] == "temp" + pin = None if temperature else self._analog_pin(args[1]) + if self.burst is not None: + _fail("busy") + self._claim(pin, timed=False) + self.fw.release(self.owner) + if temperature: + return f"OK code={TEMPERATURE_CODE} mcelsius={TEMPERATURE_MCELSIUS}" + assert pin is not None + return f"OK code={self.fw.adc_codes.get(pin, 2048)}" + + def cmd_burst(self, args: list[str], options: dict[str, str]) -> None: + _shape(args, options, 2, 2, ("n", "rate", "repeat", "out")) + if "n" not in options or "rate" not in options: + _fail("usage") + adc = self._index(args[0]) + samples = _number(options["n"], 1, AIN_BUFFER) + rate = _number(options["rate"], AIN_RATE_MIN, AIN_RATE_MAX) + repeat = _number(options.get("repeat", "1"), 1, AIN_REPEAT_MAX) + output = _choice(options, "out", _OUTPUTS, "list") + if adc != self.fw.spec.adc or (output == "list" and samples > AIN_LIST_MAX): + _fail("range") + pin = self._analog_pin(args[1]) + if self.burst is not None: + _fail("busy") + self._claim(pin, timed=True) + seconds = burst_seconds(samples, self.fw.kernel_clock, rate) + code = self.fw.adc_codes.get(pin, 2048) + self.burst = _Burst(adc, code, samples, seconds, repeat, output, self.fw.clock()) + return None + + def _run_fields(self, burst: _Burst) -> str: + us = round(burst.seconds * 1e6) + if burst.output == "list": + return f"samples={','.join([str(burst.code)] * burst.samples)} us={us}" + return f"n={burst.samples} min={burst.code} max={burst.code} mean={burst.code} us={us}" + + def poll(self) -> None: + burst = self.burst + if burst is None: + return + while burst.reported < burst.repeat and self.fw.clock() >= burst.started + (burst.reported + 1) * burst.seconds: + burst.reported += 1 + if burst.reported < burst.repeat: + self.fw.event(f"EVT ain index={burst.adc} run={burst.reported} {self._run_fields(burst)}") + continue + self.burst = None + self.fw.release(self.owner) + self.fw.event(f"OK {self._run_fields(burst)}") + + +@dataclass +class _Wave: + ends: float + + +class FakeDma(FakeGroup): + prefix = "dma" + owner: ClassVar[tuple[str, str]] = ("dma", "wave") + + def boot(self) -> None: + self.wave: _Wave | None = None + + def cmd_wave(self, args: list[str], options: dict[str, str]) -> None: + _shape(args, options, 1, 1, ("rate", "pattern", "ms")) + if "rate" not in options or "pattern" not in options: + _fail("usage") + _number(options["rate"], 1, DMA_RATE_MAX) + if not _hex(options["pattern"], DMA_PATTERN_MAX): + _fail("usage") + ms = _number(options.get("ms", str(DMA_WAVE_DEFAULT_MS)), 1, DMA_WAVE_MAX_MS) + pin = self.fw.pin(args[0]) + assert pin is not None + if not self.fw.bonded(pin): + _fail("pin") + if self.wave is not None: + _fail("busy") + resource = _WAVE_DMA[self.fw.family] + self.fw.check_pins(self.owner, [pin]) + if self.fw.timer_owners.get(_PACING_TIMER, self.owner) != self.owner: + _fail("busy") + self.fw.check_resources(self.owner, [resource]) + self.fw.claim(self.owner, [pin]) + self.fw.timer_owners[_PACING_TIMER] = self.owner + self.fw.claim_resource(*resource, self.owner) + self.wave = _Wave(self.fw.clock() + ms / 1000) + return None + + def poll(self) -> None: + if self.wave is not None and self.fw.clock() >= self.wave.ends: + self.wave = None + self.fw.release(self.owner) + self.fw.event("OK") diff --git a/validation/host/hal_st_validation/fakes/base.py b/validation/host/hal_st_validation/fakes/base.py new file mode 100644 index 00000000..0ca48e70 --- /dev/null +++ b/validation/host/hal_st_validation/fakes/base.py @@ -0,0 +1,130 @@ +"""Building blocks of the fake firmware's command groups: the argument helpers that mirror EMIL's `HilArguments` +(`ERR usage` / `ERR range` as `FakeError`) and `FakeGroup`, the base of the groups in `fakes/*.py`. + +A group sees the firmware through `self.fw` (a `FakeFirmware`): `spec` (the board profile), `pin()`, `bonded()`, +`supports()`, `check_function()`, `first_function_pin()`, `supports_analog()`, `reserved()`, `timer_exists()`, +`check_pins()`/`claim()`/`release()` (the `HilPinPool`), `timer_owners` (`TimerAllocation`), +`check_resources()`/`claim_resource()`/`release_resources()` (`ResourceAllocation`: lpTimer, spi, i2c, adc, dma1, +dma2, hsem), `open_instance()`/`find_instance()`/`close_instance()` (one instance per group, with its pins, timer +and resources), `group()`, `event()`, `clock()` and `sleep()`. Owners are `(prefix, key)` tuples, as the +firmware's groups use one owner id each. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Mapping +from typing import TYPE_CHECKING, ClassVar + +if TYPE_CHECKING: + from ..fake_firmware import FakeFirmware + +__all__ = ["Command", "FakeError", "FakeGroup", "_Error", "_choice", "_fail", "_flag", "_hex", "_number", "_shape", "_tokens"] + +UINT32_MAX = 0xFFFFFFFF + +Command = Callable[[list[str], dict[str, str]], str | list[str] | None] + + +class FakeError(Exception): + """Answered as `ERR `.""" + + def __init__(self, reason: str) -> None: + super().__init__(reason) + self.reason = reason + + +_Error = FakeError + + +def _fail(reason: str) -> None: + raise FakeError(reason) + + +def _parse_uint(text: str) -> int: + """`HilArguments::ParseNumber`: decimal or `0x` hex, unsigned 32 bits.""" + base, digits = 10, "0123456789" + if len(text) > 2 and text[0] == "0" and text[1] in "xX": + base, digits, text = 16, "0123456789abcdefABCDEF", text[2:] + if not text or any(char not in digits for char in text): + _fail("usage") + value = int(text, base) + if value > UINT32_MAX: + _fail("usage") + return value + + +def _number(text: str | None, low: int = 0, high: int = UINT32_MAX) -> int: + if text is None: + _fail("usage") + assert text is not None + value = _parse_uint(text) + if not low <= value <= high: + _fail("range") + return value + + +def _flag(options: Mapping[str, str], key: str, default: bool = False) -> bool: + if key not in options: + return default + return _number(options[key], 0, 1) == 1 + + +def _choice(options: Mapping[str, str], key: str, choices: Iterable[str], default: str) -> str: + value = options.get(key, default) + if value not in tuple(choices): + _fail("usage") + return value + + +def _shape(args: list[str], options: Mapping[str, str], low: int, high: int, keys: Iterable[str] = ()) -> None: + """`HilArguments::Shape`: positional count and known keys, else `ERR usage`.""" + allowed = tuple(keys) + if not low <= len(args) <= high or any(key not in allowed for key in options): + _fail("usage") + + +def _tokens(text: str) -> list[str]: + """`infra::Tokenizer` on `,`: empty entries are skipped.""" + return [token for token in text.split(",") if token] + + +def _hex(text: str, capacity: int) -> bytes: + """`HilArguments::ParseHex`: `-` is empty, odd or non-hex is `usage`, beyond `capacity` is `range`.""" + if text == "-": + return b"" + if not text or len(text) % 2: + _fail("usage") + if len(text) // 2 > capacity: + _fail("range") + try: + return bytes.fromhex(text) + except ValueError: + raise FakeError("usage") from None + + +class FakeGroup: + """One command group of the fake firmware, discovered in `fakes/*.py` (every subclass with a `prefix`). + + `. args key=value` runs `cmd_(args, options)`, which returns the final line(s) (or None + for a command without one) and raises `FakeError` for `ERR `. Check arguments in the firmware's order + (`usage`, `range`, `pin`, `unsupported`, then `busy`). `boot()` runs on every boot (state is lost), `poll()` + whenever the host reads or writes (time-driven output).""" + + prefix: ClassVar[str] = "" + + def __init__(self, fw: FakeFirmware) -> None: + self.fw = fw + + @property + def firmware(self) -> FakeFirmware: + return self.fw + + def boot(self) -> None: + """The firmware rebooted: the pins, timers and resources the group held are already released.""" + + def poll(self) -> None: + """Called whenever the host reads or writes; print `EVT` lines with `self.fw.event()`.""" + + def handler(self, verb: str) -> Command | None: + method = getattr(self, "cmd_" + verb, None) + return method if callable(method) else None diff --git a/validation/host/hal_st_validation/fakes/crypto.py b/validation/host/hal_st_validation/fakes/crypto.py new file mode 100644 index 00000000..9e82ceb2 --- /dev/null +++ b/validation/host/hal_st_validation/fakes/crypto.py @@ -0,0 +1,181 @@ +"""Fake `rng`, `aes` and `pka` groups: the argument checks of validation/firmware/RngGroup.cpp, AesGroup.cpp and +PkaGroup.cpp in their order, a deterministic RNG (the `prbs` payload pattern from a new seed per call, so two reads +differ), and AES/PKA results from `crypto_ref`. + +`variant=hsem` exists on STM32WB55 only; its `hsi48=` is the fake clock group's HSI48 state, which the driver leaves +as it found it (it switches HSI48 on for the read only when it was off). Like the firmware it answers `ERR busy` while +the coordinated flash driver holds HSEM 0 or `hsem.take` holds semaphore 0, and `ERR failed` with semaphore 5 locked +(`lock5=1` or `hsem.take 5`) and HSI48 off, which would trip the driver's assertion. On STM32WB55 the other variants +answer `ERR failed` while HSI48, their kernel clock, is off.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +from .. import crypto_ref, patterns, rngstats +from ..groups.crypto import VARIANTS +from .base import FakeGroup, _choice, _fail, _flag, _number, _shape +from .system_ext import FakeHsem + +if TYPE_CHECKING: + from ..fake_firmware import FakeFirmware + +_READ_MAX = 128 +_STATS_MIN = 16 +_STATS_MAX = 65536 +_AES_DATA_MAX = 80 +_AES_BLOCK = 16 +_OPERAND = 32 +_COMPARE_MIN = 4 +_COMPARE_MAX = 60 +# A P-256 scalar multiplication on the PKA takes some ten milliseconds; the fake reports a fixed duration. +_MULTIPLY_US = 20000 +_HEX_DIGITS = frozenset("0123456789abcdefABCDEF") +# The firmware's `owners::scaffold`, which `hsem.lock` claims HSEM 0 with as well (fakes/system_ext.py). +_HSEM_OWNER = ("hsem", "lock") +_RNG_SEMAPHORE = 0 +_CLOCK_SEMAPHORE = 5 + + +class FakeRng(FakeGroup): + prefix = "rng" + + def __init__(self, fw: FakeFirmware) -> None: + super().__init__(fw) + self.boot() + + def boot(self) -> None: + self.seed = 0 + + def _generate(self, length: int) -> bytes: + self.seed = (self.seed + 0x9E3779B9) & patterns.SEED_MAX + return patterns.generate(length, "prbs", self.seed) + + def _check(self, variant: str, lock5: bool) -> None: + if (variant == "hsem" or lock5) and self.fw.family != "stm32wb55": + _fail("unsupported") + if lock5 and variant != "hsem": + _fail("usage") + if variant != "hsem" and self.fw.family == "stm32wb55" and not self._hsi48(): + _fail("failed") + + def _hsi48(self) -> int: + clock = self.fw.group("clock") + return int(getattr(clock, "hsi48", 1)) + + def _synchronized(self, lock5: bool) -> None: + """`RunSynchronous` and `GenerateSynchronized` of RngGroup.cpp: HSEM 0 against the coordinated flash driver, + then the semaphores the fake `hsem` group holds (all of them on this core).""" + self.fw.check_resources(_HSEM_OWNER, [("hsem", 0)]) + hsem = self.fw.group("hsem") + held: set[int] = set() + if isinstance(hsem, FakeHsem): + held = {semaphore for semaphore in (_RNG_SEMAPHORE, _CLOCK_SEMAPHORE) if hsem.holder(semaphore) is not None} + if _RNG_SEMAPHORE in held: + _fail("busy") + if (lock5 or _CLOCK_SEMAPHORE in held) and not self._hsi48(): + _fail("failed") + if lock5 and _CLOCK_SEMAPHORE in held: + _fail("busy") + + def cmd_read(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("variant", "lock5")) + length = _number(args[0], 1, _READ_MAX) + variant = _choice(options, "variant", VARIANTS, "sync") + lock5 = _flag(options, "lock5") + self._check(variant, lock5) + if variant == "hsem": + self._synchronized(lock5) + line = f"OK data={self._generate(length).hex()}" + if variant == "hsem": + line += f" hsi48={self._hsi48()}" + return line + + def cmd_stats(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("variant",)) + length = _number(args[0], _STATS_MIN, _STATS_MAX) + variant = _choice(options, "variant", VARIANTS, "sync") + self._check(variant, False) + if variant == "hsem": + self._synchronized(False) + stats = rngstats.byte_stats(self._generate(length)) + return f"OK n={stats.n} ones={stats.ones} runs={stats.runs} chisq={stats.chisq_x1000} crc={stats.crc:08x} us={length // 4 + 50}" + + +def _blocks(text: str, capacity: int) -> bytes: + """`ParseBlocks` of AesGroup.cpp: every malformed, empty, too long or partial-block hex is `ERR usage`.""" + if text == "-" or not text or len(text) % 2 or len(text) // 2 > capacity or not set(text) <= _HEX_DIGITS: + _fail("usage") + data = bytes.fromhex(text) + if len(data) % _AES_BLOCK: + _fail("usage") + return data + + +class FakeAes(FakeGroup): + prefix = "aes" + + def _run(self, args: list[str], options: dict[str, str], decrypt: bool) -> str: + _shape(args, options, 2, 2, ("swap",)) + key = _blocks(args[0], _AES_BLOCK) + data = _blocks(args[1], _AES_DATA_MAX) + swap = _choice(options, "swap", crypto_ref.SWAPS, "byte") + return f"OK data={crypto_ref.aes_stm(key, data, cast(crypto_ref.Swap, swap), decrypt).hex()}" + + def cmd_enc(self, args: list[str], options: dict[str, str]) -> str: + return self._run(args, options, False) + + def cmd_dec(self, args: list[str], options: dict[str, str]) -> str: + return self._run(args, options, True) + + +def _hex_size(text: str | None) -> int | None: + """`HexSize` of PkaGroup.cpp: the byte count of a non-empty, even, all-hex operand, else None (`ERR usage`).""" + if not text or len(text) % 2 or not set(text) <= _HEX_DIGITS: + return None + return len(text) // 2 + + +def _operand(text: str) -> int: + return int(text, 16) + + +class FakePka(FakeGroup): + prefix = "pka" + + def cmd_mul(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 0, 0, ("k", "x", "y")) + k, x, y = options.get("k"), options.get("x"), options.get("y") + sizes = [_hex_size(text) for text in (k, x, y) if text is not None] + if (x is None) != (y is None) or None in sizes: + _fail("usage") + if any(size is not None and size > _OPERAND for size in sizes): + _fail("range") + curve = crypto_ref.P256 + point = None if x is None or y is None else (_operand(x), _operand(y)) + result = crypto_ref.scalar_multiply(curve, 1 if k is None else _operand(k), point) + rx, ry = result if result is not None else (0, 0) + return f"OK x={crypto_ref.to_bytes(rx).hex()} y={crypto_ref.to_bytes(ry).hex()} us={_MULTIPLY_US}" + + def cmd_check(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 0, 0, ("x", "y")) + x, y = options.get("x"), options.get("y") + sizes = [_hex_size(x), _hex_size(y)] + if None in sizes: + _fail("usage") + if any(size is not None and size > _OPERAND for size in sizes): + _fail("range") + assert x is not None and y is not None + return f"OK on={int(crypto_ref.P256.on_curve((_operand(x), _operand(y))))}" + + def cmd_cmp(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 0, 0, ("a", "b")) + a, b = options.get("a"), options.get("b") + size, size_b = _hex_size(a), _hex_size(b) + if size is None or size != size_b: + _fail("usage") + assert size is not None and a is not None and b is not None + if not _COMPARE_MIN <= size <= _COMPARE_MAX: + _fail("range") + first, second = _operand(a), _operand(b) + return "OK cmp=" + ("eq" if first == second else "gt" if first > second else "lt") diff --git a/validation/host/hal_st_validation/fakes/i2c.py b/validation/host/hal_st_validation/fakes/i2c.py new file mode 100644 index 00000000..6b5cce0d --- /dev/null +++ b/validation/host/hal_st_validation/fakes/i2c.py @@ -0,0 +1,550 @@ +"""Fake `i2c`, `i2cs` and `eeprom` groups: the argument checks of validation/firmware/I2cGroup.cpp, I2cTarget.cpp, +EepromGroup.cpp and EMIL's `HilEepromCommands` in their order, the pins and `ResourceAllocation` i2c instances they +hold, and one bus that every open master, target and attached EEPROM adapter share (the fake does not know the +wiring), with two devices: the `i2cs` target while it is open, and a 24LC256 at 0x50 that keeps its contents across +resets. General calls (address 0) are not acknowledged. Arbitration loss and clock stretching are accepted and not +modelled (they need the AD3); `i2cs.cfg fault=stop` makes the next read answer `buserror`. +""" + +from __future__ import annotations + +import zlib +from dataclasses import dataclass, field +from typing import Any + +from ..groups.i2c import ( + DEFAULT_TIMING, + EEPROM_BUFFER, + I2C_BUFFER, + I2C_HEX_MAX, + TARGET_DUMP_MAX, + TARGET_LAST_MAX, + TARGET_REGISTERS, + TIMING_MASK, +) +from ..i2c import BUS_MAX, BUS_MIN, expected_timing +from ..patterns import PATTERNS, SEED_MAX, crc_text, generate +from .base import UINT32_MAX, FakeGroup, _choice, _fail, _hex, _number, _shape + +_INSTANCES = 4 +_ADDRESS_MAX = 0x7F +_TARGET_ADDRESS_MIN = 0x08 +_TARGET_ADDRESS_MAX = 0x77 +_TARGET_ADDRESS = 0x42 +_STRETCH_MAX = 10_000 +_POSITION_MAX = 0xFFFF +_TARGET_BUS = 400_000 +_EEPROM_SIZE_MAX = 65536 +_EEPROM_PAGE_MIN = 8 +_EEPROM_PAGE_MAX = 256 +_EEPROM_WRITE_CYCLE_MAX = 20 +_ONE_BYTE_SPACE = 256 +# A page write keeps the chip busy for this many address phases (the adapter's ACK polling sees them NACKed) or +# until the host's next command, whichever comes first. +_WRITE_CYCLE_POLLS = 2 +_POLLS_MAX = 1000 + +_OPEN_KEYS = ("scl", "sda", "freq", "timing", "pull") +_WRITE_KEYS = ("next", "len", "pattern", "seed") +_READ_KEYS = ("next", "out") +_TARGET_KEYS = ("scl", "sda", "addr", "mode", "timing") +_CONFIGURE_KEYS = ("nack", "addrnack", "stretch", "stretchat", "fault", "faultat", "pattern", "seed") +_ATTACH_KEYS = ("scl", "sda", "addr", "size", "page", "abytes", "freq", "wcycle") +_NEXT = ("stop", "restart", "continue") + + +@dataclass +class _Chip: + """24LC256: 32 KiB, 64-byte pages, a 16-bit word address. Data bytes of a write land inside the page of the + address (wrapping at its end) once the STOP arrives, which starts the write cycle; reads continue from the + address counter and wrap at the end of the memory.""" + + address: int = 0x50 + size: int = 32768 + page: int = 64 + memory: bytearray = field(default_factory=lambda: bytearray(b"\xff" * 32768)) + pointer: int = 0 + cycle: int = 0 + received: bytearray = field(default_factory=bytearray) + + def acknowledge(self, read: bool) -> bool: + if self.cycle: + self.cycle -= 1 + return False + self.received = bytearray() + return True + + def write(self, data: bytes) -> int: + for byte in data: + self.received.append(byte) + if len(self.received) == 2: + self.pointer = (self.received[0] << 8 | self.received[1]) % self.size + return len(data) + + def read(self, length: int) -> bytes | None: + data = bytes(self.memory[(self.pointer + offset) % self.size] for offset in range(length)) + self.pointer = (self.pointer + length) % self.size + return data + + def stop(self) -> None: + data = self.received[2:] + self.received = bytearray() + if not data: + return + base = self.pointer - self.pointer % self.page + for offset, byte in enumerate(data): + self.memory[base + (self.pointer - base + offset) % self.page] = byte + self.pointer = base + (self.pointer - base + len(data)) % self.page + self.cycle = _WRITE_CYCLE_POLLS + + +@dataclass +class _Target: + """The `i2cs` scaffold: a 256-byte register file (the first byte of a write sets the pointer) or a sink that + reads back the C.8 pattern; the counters of `i2cs.status`.""" + + address: int + sink: bool + registers: bytearray = field(default_factory=lambda: bytearray(TARGET_REGISTERS)) + pointer: int = 0 + position: int = 0 + nack: int = 0 + addrnack: bool = False + fault: bool = False + faultat: int = 1 + pattern: str = "inc" + seed: int = 0 + rx: int = 0 + tx: int = 0 + crc: int = 0 + writes: int = 0 + reads: int = 0 + stops: int = 0 + nacked: int = 0 + errors: int = 0 + last: bytearray = field(default_factory=bytearray) + + def acknowledge(self, read: bool) -> bool: + if self.addrnack: + return False + self.position = 0 + if read: + self.reads += 1 + else: + self.writes += 1 + self.last = bytearray() + return True + + def write(self, data: bytes) -> int: + for index, byte in enumerate(data): + self.position += 1 + if self.position == self.nack: + self.nacked += 1 + return index + self.rx += 1 + self.crc = zlib.crc32(bytes([byte]), self.crc) + if len(self.last) < TARGET_LAST_MAX: + self.last.append(byte) + if self.sink: + continue + if self.position == 1: + self.pointer = byte + else: + self.registers[self.pointer] = byte + self.pointer = (self.pointer + 1) % TARGET_REGISTERS + return len(data) + + def read(self, length: int) -> bytes | None: + if self.fault and self.faultat <= length: + self.fault = False + return None + self.tx += length + if self.sink: + return generate(length, self.pattern, self.seed) + data = bytes(self.registers[(self.pointer + offset) % TARGET_REGISTERS] for offset in range(length)) + self.pointer = (self.pointer + length) % TARGET_REGISTERS + return data + + def stop(self) -> None: + self.stops += 1 + + def status(self) -> str: + return ( + f"OK rx={self.rx} tx={self.tx} crc={self.crc:08x} writes={self.writes} reads={self.reads} stops={self.stops}" + f" nacked={self.nacked} errors={self.errors} last={self.last.hex()}" + ) + + def clear(self) -> None: + self.rx = self.tx = self.crc = self.writes = self.reads = self.stops = self.nacked = self.errors = 0 + self.last = bytearray() + + +Device = _Chip | _Target + + +def _device(fw: Any, address: int) -> Device | None: + """The device that answers `address` on the shared bus: the open target first, then the EEPROM chip.""" + target = fw.group("i2cs") + if isinstance(target, FakeI2cTarget) and target.device is not None and target.device.address == address: + return target.device + eeprom = fw.group("eeprom") + if isinstance(eeprom, FakeEeprom) and eeprom.chip.address == address: + return eeprom.chip + return None + + +def _write(fw: Any, address: int, data: bytes, stop: bool = True) -> tuple[int, str, bool]: + """A master write with its address phase: (acknowledged data bytes, `complete`|`nack`, address acknowledged). + A NACK ends the transfer with a STOP.""" + device = _device(fw, address) + if device is None or not device.acknowledge(False): + return 0, "nack", False + sent = device.write(data) + if sent < len(data): + device.stop() + return sent, "nack", True + if stop: + device.stop() + return sent, "complete", True + + +def _read(fw: Any, address: int, length: int, stop: bool = True) -> tuple[bytes | None, str]: + device = _device(fw, address) + if device is None or not device.acknowledge(True): + return None, "nack" + data = device.read(length) + if data is None: + return None, "buserror" + if stop: + device.stop() + return data, "complete" + + +def _payload(args: list[str], options: dict[str, str], position: int, capacity: int) -> bytes: + """`ParsePayload` (C.8): hex, or `-` with `len`/`pattern`/`seed`; `-` alone is empty.""" + text = args[position] + generated = "len" in options + patterned = "pattern" in options or "seed" in options + if text != "-": + if generated or patterned: + _fail("usage") + return _hex(text, capacity) + if not generated: + if patterned: + _fail("usage") + return b"" + pattern = _choice(options, "pattern", PATTERNS, "inc") + seed = _number(options.get("seed", "0"), 0, SEED_MAX) + length = _number(options["len"], 1, capacity) + return generate(length, pattern, seed) + + +def _pins(fw: Any, options: dict[str, str], index: int) -> tuple[str, str]: + scl = fw.pin(options["scl"]) + sda = fw.pin(options["sda"]) + fw.check_function("i2cScl", index, scl) + fw.check_function("i2cSda", index, sda) + return scl, sda + + +class FakeI2c(FakeGroup): + prefix = "i2c" + + def cmd_open(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, _OPEN_KEYS) + index = _number(args[0], 0, _INSTANCES - 1) + if "scl" not in options or "sda" not in options or ("freq" in options and "timing" in options): + _fail("usage") + pull = _choice(options, "pull", ("none", "up"), "none") + raw = _number(options.get("timing", "0"), 0, UINT32_MAX) + frequency = _number(options.get("freq", "0"), 0, UINT32_MAX) + if index not in self.fw.spec.i2cs: + _fail("range") + kernel = self.fw.kernel_clock + timing = DEFAULT_TIMING + if "timing" in options: + timing = raw & TIMING_MASK + elif "freq" in options: + computed = expected_timing(kernel, frequency) + if computed is None: + _fail("range") + assert computed is not None + timing = computed + scl, sda = _pins(self.fw, options, index) + state = {"index": index, "pull": pull, "continuing": None} + self.fw.open_instance(self.prefix, str(index), state, [scl, sda], resources=[("i2c", index)]) + return f"OK timing=0x{timing:08x} kernel={kernel}" + + def _find(self, args: list[str], options: dict[str, str], keys: tuple[str, ...]) -> dict[str, Any]: + _shape(args, options, 3, 3, keys) + return self.fw.find_instance(self.prefix, str(_number(args[0], 0, _INSTANCES - 1))) + + def _hook(self, state: dict[str, Any], hook: str) -> None: + self.fw.event(f"EVT i2c index={state['index']} hook={hook}") + + def _continued(self, state: dict[str, Any], read: bool) -> Device | None: + """The device of a transfer that ended with `next=continue` in the same direction: no new address phase.""" + continuing, state["continuing"] = state["continuing"], None + if continuing is None or continuing[1] != read: + return None + return _device(self.fw, continuing[0]) + + def cmd_write(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options, _WRITE_KEYS) + address = _number(args[1], 0, _ADDRESS_MAX) + action = _choice(options, "next", _NEXT, "stop") + data = _payload(args, options, 2, I2C_BUFFER) + device = self._continued(state, False) + if device is not None: + sent = device.write(data) + result = "complete" if sent == len(data) else "nack" + if result == "nack" or action == "stop": + device.stop() + else: + sent, result, found = _write(self.fw, address, data, stop=action == "stop") + if not found: + self._hook(state, "notfound") + if result == "complete" and action == "continue": + state["continuing"] = (address, False) + return f"OK sent={sent} result={result}" + + def cmd_read(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options, _READ_KEYS) + address = _number(args[1], 0, _ADDRESS_MAX) + length = _number(args[2], 1, I2C_BUFFER) + action = _choice(options, "next", _NEXT, "stop") + output = _choice(options, "out", ("hex", "crc"), "hex") + if output == "hex" and length > I2C_HEX_MAX: + _fail("range") + device = self._continued(state, True) + if device is not None: + data = device.read(length) + result = "complete" if data is not None else "buserror" + if data is not None and action == "stop": + device.stop() + else: + data, result = _read(self.fw, address, length, stop=action == "stop") + if result == "nack": + self._hook(state, "notfound") + elif result == "buserror": + self._hook(state, "buserror") + if data is not None and action == "continue": + state["continuing"] = (address, True) + if data is None: + return f"OK result={result}" + if output == "crc": + return f"OK result={result} len={length} crc={crc_text(data)}" + return f"OK result={result} data={data.hex()}" + + def cmd_close(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + key = str(_number(args[0], 0, _INSTANCES - 1)) + self.fw.find_instance(self.prefix, key) + return self.fw.close_instance(self.prefix, key) + + +class FakeI2cTarget(FakeGroup): + prefix = "i2cs" + device: _Target | None = None + + def boot(self) -> None: + self.device = None + + def cmd_open(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, _TARGET_KEYS) + index = _number(args[0], 0, _INSTANCES - 1) + if "scl" not in options or "sda" not in options: + _fail("usage") + mode = _choice(options, "mode", ("regs", "sink"), "regs") + _number(options.get("timing", "0"), 0, UINT32_MAX) + address = _number(options.get("addr", str(_TARGET_ADDRESS)), _TARGET_ADDRESS_MIN, _TARGET_ADDRESS_MAX) + if index not in self.fw.spec.i2cs or expected_timing(self.fw.kernel_clock, _TARGET_BUS) is None: + _fail("range") + scl, sda = _pins(self.fw, options, index) + self.fw.open_instance(self.prefix, str(index), {"index": index}, [scl, sda], resources=[("i2c", index)]) + self.device = _Target(address, mode == "sink") + return f"OK addr=0x{address:02x}" + + def _find(self, args: list[str], options: dict[str, str], low: int, high: int, keys: tuple[str, ...] = ()) -> _Target: + _shape(args, options, low, high, keys) + self.fw.find_instance(self.prefix, str(_number(args[0], 0, _INSTANCES - 1))) + assert self.device is not None + return self.device + + def cmd_cfg(self, args: list[str], options: dict[str, str]) -> str: + device = self._find(args, options, 1, 1, _CONFIGURE_KEYS) + nack = _number(options.get("nack", "0"), 0, _POSITION_MAX) + addrnack = _number(options.get("addrnack", "0"), 0, 1) == 1 + _number(options.get("stretch", "0"), 0, _STRETCH_MAX) + _number(options.get("stretchat", "0"), 0, _POSITION_MAX) + fault = _choice(options, "fault", ("none", "stop"), "none") == "stop" + faultat = _number(options.get("faultat", "1"), 1, _POSITION_MAX) + pattern = _choice(options, "pattern", PATTERNS, "inc") + seed = _number(options.get("seed", "0"), 0, SEED_MAX) + device.nack, device.addrnack, device.fault, device.faultat = nack, addrnack, fault, faultat + device.pattern, device.seed = pattern, seed + return "OK" + + def cmd_status(self, args: list[str], options: dict[str, str]) -> str: + device = self._find(args, options, 1, 1, ("clear",)) + clear = _number(options.get("clear", "0"), 0, 1) == 1 + line = device.status() + if clear: + device.clear() + return line + + def cmd_regs(self, args: list[str], options: dict[str, str]) -> str: + device = self._find(args, options, 3, 3) + offset = _number(args[1], 0, TARGET_REGISTERS - 1) + data = _hex(args[2], TARGET_DUMP_MAX) + if not data: + _fail("usage") + if offset + len(data) > TARGET_REGISTERS: + _fail("range") + device.registers[offset : offset + len(data)] = data + return "OK" + + def cmd_dump(self, args: list[str], options: dict[str, str]) -> str: + device = self._find(args, options, 3, 3) + offset = _number(args[1], 0, TARGET_REGISTERS - 1) + length = _number(args[2], 1, TARGET_DUMP_MAX) + if offset + length > TARGET_REGISTERS: + _fail("range") + return f"OK data={device.registers[offset : offset + length].hex()}" + + def cmd_close(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + key = str(_number(args[0], 0, _INSTANCES - 1)) + self.fw.find_instance(self.prefix, key) + self.device = None + return self.fw.close_instance(self.prefix, key) + + +@dataclass +class _Adapter: + """`I2cEepromStm`: page writes with ACK polling, random reads with a repeated START.""" + + address: int + size: int + page: int + abytes: int + wcycle: int + failed: bool = False + + def word(self, address: int) -> bytes: + return address.to_bytes(self.abytes, "big") if self.abytes == 2 else bytes([address & 0xFF]) + + +class FakeEeprom(FakeGroup): + """`eeprom.attach`/`eeprom.detach` and EMIL's `eeprom.write`/`eeprom.read`/`eeprom.erase` over the adapter. An + error prints `EVT eeprom error=... address=...` and answers `ERR timeout` at once (the firmware after 5 s); the + group stays busy until `eeprom.detach`.""" + + prefix = "eeprom" + adapter: _Adapter | None = None + + def __init__(self, fw: Any) -> None: + super().__init__(fw) + self.chip = _Chip() + + def boot(self) -> None: + self.adapter = None + self.chip.cycle = 0 + self.chip.received = bytearray() + + def poll(self) -> None: + self.chip.cycle = 0 + + def cmd_attach(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, _ATTACH_KEYS) + index = _number(args[0], 0, _INSTANCES - 1) + if "scl" not in options or "sda" not in options: + _fail("usage") + address, size, page, abytes, wcycle, frequency = ( + _number(options.get(key, default), 0, UINT32_MAX) + for key, default in (("addr", "0x50"), ("size", "32768"), ("page", "64"), ("abytes", "2"), ("wcycle", "10"), ("freq", "400000")) + ) + if index not in self.fw.spec.i2cs or not _TARGET_ADDRESS_MIN <= address <= _TARGET_ADDRESS_MAX or not 1 <= size <= _EEPROM_SIZE_MAX: + _fail("range") + if not _EEPROM_PAGE_MIN <= page <= _EEPROM_PAGE_MAX or page & (page - 1) or abytes not in (1, 2): + _fail("range") + if wcycle > _EEPROM_WRITE_CYCLE_MAX: + _fail("range") + if (abytes == 1 and size > _ONE_BYTE_SPACE) or not BUS_MIN <= frequency <= BUS_MAX: + _fail("range") + if expected_timing(self.fw.kernel_clock, frequency) is None: + _fail("range") + scl, sda = _pins(self.fw, options, index) + self.fw.open_instance(self.prefix, "attached", {"index": index}, [scl, sda], resources=[("i2c", index)]) + self.adapter = _Adapter(address, size, page, abytes, wcycle) + return "OK" + + def cmd_detach(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 0, 0) + self.fw.find_instance(self.prefix, "attached") + self.adapter = None + return self.fw.close_instance(self.prefix, "attached") + + def _size(self) -> int: + return 0 if self.adapter is None else self.adapter.size + + def _check_busy(self) -> None: + if self.adapter is not None and self.adapter.failed: + _fail("busy") + + def _fail(self, adapter: _Adapter, result: str, address: int) -> str: + adapter.failed = True + self.fw.event(f"EVT eeprom error={result} address={address}") + return "ERR timeout" + + def cmd_write(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 2) + address = _number(args[0], 0, self._size()) + data = _hex(args[1], EEPROM_BUFFER) + if not data: + _fail("usage") + if len(data) > self._size() - address: + _fail("range") + self._check_busy() + assert self.adapter is not None + return self._write(self.adapter, address, data) + + def _write(self, adapter: _Adapter, address: int, data: bytes) -> str: + while data: + chunk = min(len(data), adapter.page - address % adapter.page) + _, result, _ = _write(self.fw, adapter.address, adapter.word(address) + data[:chunk]) + if result != "complete": + return self._fail(adapter, result, address) + if adapter.wcycle: + for _ in range(_POLLS_MAX): + if _write(self.fw, adapter.address, adapter.word(address))[1] == "complete": + break + else: + return self._fail(adapter, "nack", address) + address += chunk + data = data[chunk:] + return "OK" + + def cmd_read(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 2) + address = _number(args[0], 0, self._size()) + length = _number(args[1], 1, EEPROM_BUFFER) + if length > self._size() - address: + _fail("range") + self._check_busy() + adapter = self.adapter + assert adapter is not None + _, result, _ = _write(self.fw, adapter.address, adapter.word(address), stop=False) + if result != "complete": + return self._fail(adapter, result, address) + data, result = _read(self.fw, adapter.address, length) + if data is None: + return self._fail(adapter, result, address) + return f"OK data={data.hex()}" + + def cmd_erase(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 0, 0) + self._check_busy() + if self.adapter is None: + return "OK" + return self._write(self.adapter, 0, b"\xff" * self.adapter.size) diff --git a/validation/host/hal_st_validation/fakes/io.py b/validation/host/hal_st_validation/fakes/io.py new file mode 100644 index 00000000..5f225537 --- /dev/null +++ b/validation/host/hal_st_validation/fakes/io.py @@ -0,0 +1,193 @@ +"""Fake `sgpio` and `clock` groups: the argument checks of validation/firmware/SyncGpioGroup.cpp and ClockGroup.cpp in +their order, the pins `sgpio` holds in the pin pool (four outputs, four alternate-function pins and one `multi` set), +and the default clock tree of each board. + +The fake firmware's unique device ID follows the real layout here (lot number bytes in ASCII), so `test_uid.py` runs +against it; a UID passed to `FakeFirmware` explicitly is kept.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..groups.io import MCO_DIVIDERS, MCO_SOURCES, SPEEDS +from .base import FakeGroup, _choice, _fail, _flag, _number, _shape, _tokens + +if TYPE_CHECKING: + from ..fake_firmware import FakeFirmware + +_OUTPUT_SLOTS = 4 +_PERIPHERAL_SLOTS = 4 +_MULTI_PINS = 4 +_CHANNELS = 4 +_ALTERNATE_FUNCTION_MAX = 15 +# `hal::peripheralTimer` holds TIM1 .. TIM17. +_TIMER_TABLE_SIZE = 17 +# The timer channel alternate functions of the generated pinout tables (GPIO_AF1_TIM1, GPIO_AF1_TIM2, GPIO_AF2_TIM3, +# GPIO_AF14_TIM16, GPIO_AF14_TIM17), the same on both boards. +_TIMER_AF = {1: 1, 2: 1, 3: 2, 16: 14, 17: 14} +# X 0x0024, Y 0x0051, wafer 7, lot "HALSTFK". +FAKE_UID = "2400510007" + b"HALSTFK".hex() + +_CLOCK_TREES: dict[str, dict[str, Any]] = { + "stm32wb55": {"buses": ("sysclk", "hclk", "pclk1", "pclk2"), "flags": {"hse": 1, "lse": 1, "hsi": 1}, "rngsel": "clk48"}, + "stm32wba55": {"buses": ("sysclk", "hclk", "pclk1", "pclk2", "pclk7"), "flags": {"hse": 1, "lse": 0, "hsi": 1}, "rngsel": "hsi"}, +} + + +class FakeSyncGpio(FakeGroup): + prefix = "sgpio" + + def __init__(self, fw: FakeFirmware) -> None: + super().__init__(fw) + self.boot() + + def boot(self) -> None: + self.outputs: dict[str, dict[str, Any]] = {} + self.peripherals: dict[str, int] = {} + self.multi: tuple[str, ...] = () + + def cmd_out(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 2, ("od", "speed")) + value = _number(args[1], 0, 1) + od = _flag(options, "od") + speed = _choice(options, "speed", SPEEDS, "low") + pin = self.fw.pin(args[0]) + assert pin is not None + output = self.outputs.get(pin) + if output is None: + owner = (self.prefix, f"out:{pin}") + self.fw.check_pins(owner, [pin]) + if len(self.outputs) >= _OUTPUT_SLOTS: + _fail("busy") + self.fw.claim(owner, [pin]) + output = {"od": od, "speed": speed} + else: + output["od"] = od if "od" in options else output["od"] + output["speed"] = speed if "speed" in options else output["speed"] + output["latch"] = value + self.outputs[pin] = output + return "OK" + + def cmd_latch(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + pin = self.fw.pin(args[0]) + assert pin is not None + output = self.outputs.get(pin) + if output is None: + _fail("notopen") + assert output is not None + return f"OK value={output['latch']}" + + def _timer(self, options: dict[str, str]) -> int | None: + return _number(options["timer"], 1, _TIMER_TABLE_SIZE) if "timer" in options else None + + def cmd_af(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("timer", "ch", "af")) + timer = self._timer(options) + channel = _number(options["ch"], 1, _CHANNELS) if "ch" in options else 1 + af = _number(options["af"], 0, _ALTERNATE_FUNCTION_MAX) if "af" in options else 0 + if (timer is not None) == ("af" in options) or ("ch" in options and timer is None): + _fail("usage") + if timer is not None and not self.fw.timer_exists(timer): + _fail("range") + pin = self.fw.pin(args[0]) + assert pin is not None + if timer is not None: + if not self.fw.supports(f"timerChannel{channel}", timer, pin): + _fail("pin") + af = _TIMER_AF[timer] + owner = (self.prefix, f"af:{pin}") + self.fw.check_pins(owner, [pin]) + if pin in self.peripherals or len(self.peripherals) >= _PERIPHERAL_SLOTS: + _fail("busy") + self.fw.claim(owner, [pin]) + self.peripherals[pin] = af + return f"OK af={af}" + + def cmd_multi(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("timer", "ch")) + timer = self._timer(options) + channel = _number(options["ch"], 1, _CHANNELS) if "ch" in options else 1 + if timer is None: + _fail("usage") + assert timer is not None + if not self.fw.timer_exists(timer): + _fail("range") + tokens = _tokens(args[0]) + if not 1 <= len(tokens) <= _MULTI_PINS: + _fail("usage") + pins: list[str] = [] + for token in tokens: + pin = self.fw.pin(token) + assert pin is not None + if pin in pins: + _fail("usage") + pins.append(pin) + if any(not self.fw.supports(f"timerChannel{channel}", timer, pin) for pin in pins): + _fail("pin") + if self.multi: + _fail("busy") + owner = (self.prefix, "multi") + self.fw.check_pins(owner, pins) + self.fw.claim(owner, pins) + self.multi = tuple(pins) + return "OK" + + def cmd_release(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + pin = self.fw.pin(args[0]) + assert pin is not None + if pin in self.outputs: + del self.outputs[pin] + self.fw.release((self.prefix, f"out:{pin}")) + elif pin in self.peripherals: + del self.peripherals[pin] + self.fw.release((self.prefix, f"af:{pin}")) + elif pin in self.multi: + self.multi = () + self.fw.release((self.prefix, "multi")) + else: + _fail("notopen") + return "OK" + + +class FakeClock(FakeGroup): + """`clock.mco` and `clock.hsi48` exist on STM32WB55 only; on STM32WBA55 the unsupported names answer first.""" + + prefix = "clock" + + def __init__(self, fw: FakeFirmware) -> None: + super().__init__(fw) + if fw.uid == type(fw).uid: + fw.uid = FAKE_UID + self.boot() + + def boot(self) -> None: + self.mco = ("off", 1) + self.hsi48 = 1 + + def cmd_info(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 0, 0) + tree = _CLOCK_TREES[self.fw.family] + parts = [f"{bus}={self.fw.kernel_clock}" for bus in tree["buses"]] + flags = dict(tree["flags"]) + if self.fw.family == "stm32wb55": + flags["hsi48"] = self.hsi48 + flags["pll"] = 1 + parts += [f"{flag}={value}" for flag, value in flags.items()] + parts.append(f"rngsel={tree['rngsel']}") + if self.fw.family == "stm32wb55": + parts.append("clk48=hsi48") + return "OK " + " ".join(parts) + + def cmd_mco(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("div",)) + source = _choice({"source": args[0]}, "source", MCO_SOURCES, "off") + divider = _choice(options, "div", tuple(str(value) for value in MCO_DIVIDERS), "1") + self.mco = (source, int(divider)) + return "OK" + + def cmd_hsi48(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + self.hsi48 = _number(args[0], 0, 1) + return "OK" diff --git a/validation/host/hal_st_validation/fakes/qspi.py b/validation/host/hal_st_validation/fakes/qspi.py new file mode 100644 index 00000000..8cf5d23a --- /dev/null +++ b/validation/host/hal_st_validation/fakes/qspi.py @@ -0,0 +1,193 @@ +"""Fake `qspi` group (STM32WB55): the argument checks of validation/firmware/QuadSpiGroup.cpp in their order, the +QUADSPI pins and the DMA2 channel 3 it claims, and a bus whose IO lines rest at the levels of `io` (IO0 in bit 0; +the AD3 is not modelled): reads return those levels, a poll matches them or times out. The STM32WBA55 has no +QUADSPI; its `qspi.*` names are unsupported commands of the fake firmware, which answer before this group. + +`variant=poll` blocks for good on a status that never matches (`QuadSpiStm` waits with `HAL_MAX_DELAY`, a known +gap); the fake answers `ERR timeout` for it as for `variant=dma`, which is what test_qspi.py expects.""" + +from __future__ import annotations + +from typing import Any + +from ..groups.qspi import ( + QSPI_BUFFER, + QSPI_DUMMY_MAX, + QSPI_HEX_MAX, + QSPI_INDEX, + QSPI_PRESCALER_MAX, + QSPI_REPEAT_MAX, + QSPI_SIZE_MAX, + QSPI_TIMEOUT_S, + qspi_clock, +) +from ..patterns import PATTERNS, SEED_MAX, crc_text, generate +from .base import UINT32_MAX, FakeGroup, _choice, _fail, _hex, _number, _shape + +_VARIANTS = ("poll", "dma", "spi") +_OUTPUTS = ("hex", "crc") +# `board::qspiPins` with the pin functions `QuadSpiStm` claims (instance 0 in the pinout table). +_PINS = ( + ("quadSpiClock", "PA3"), + ("quadSpiSlaveSelect", "PA2"), + ("quadSpiData0", "PB9"), + ("quadSpiData1", "PB8"), + ("quadSpiData2", "PA7"), + ("quadSpiData3", "PA6"), +) +_DMA = ("dma2", 3) +_PHASE_KEYS = ("instr", "addr", "abytes", "alt", "altbytes", "dummy", "lines") +_COMMAND_KEYS = (*_PHASE_KEYS, "tx", "len", "pattern", "seed", "rx", "out", "repeat") +_POLL_KEYS = (*_PHASE_KEYS, "match", "mask", "size") +_TRANSFER_KEYS = ("len", "pattern", "seed", "rx", "repeat") +_PHASE_BYTES_MAX = 4 +_IDLE_LEVELS = 0xF + + +def _fits(value: int, size: int) -> bool: + return size >= _PHASE_BYTES_MAX or value < 1 << (8 * size) + + +def _phases_shape(options: dict[str, str]) -> bool: + return ("addr" in options or "abytes" not in options) and ("alt" in options or "altbytes" not in options) + + +class FakeQuadSpi(FakeGroup): + prefix = "qspi" + io = _IDLE_LEVELS + + def boot(self) -> None: + self.io = _IDLE_LEVELS + + def _byte(self, lines: int) -> int: + """A received byte: the nibble of IO3-IO0 twice with 4 lines, IO1 eight times with 1 line.""" + if lines == 4: + return self.io << 4 | self.io + return 0xFF if self.io & 0b10 else 0x00 + + def _find(self, args: list[str], options: dict[str, str], low: int, high: int, keys: tuple[str, ...]) -> dict[str, Any]: + _shape(args, options, low, high, keys) + return self.fw.find_instance(self.prefix, str(_number(args[0], 0, QSPI_INDEX))) + + def _phases(self, options: dict[str, str]) -> int: + """`ParsePhases`: the numbers in order, then `lines` other than 1 and 4 or values wider than their bytes + (`range`); returns `lines`.""" + _number(options.get("instr", "0"), 0, 0xFF) + address = _number(options.get("addr", "0"), 0, UINT32_MAX) + address_bytes = _number(options.get("abytes", "3"), 1, _PHASE_BYTES_MAX) + alternate = _number(options.get("alt", "0"), 0, UINT32_MAX) + alternate_bytes = _number(options.get("altbytes", "1"), 1, _PHASE_BYTES_MAX) + _number(options.get("dummy", "0"), 0, QSPI_DUMMY_MAX) + lines = _number(options.get("lines", "1"), 1, 4) + if lines not in (1, 4) or not _fits(address, address_bytes) or not _fits(alternate, alternate_bytes): + _fail("range") + return lines + + @staticmethod + def _generated(options: dict[str, str]) -> bytes: + pattern = _choice(options, "pattern", PATTERNS, "inc") + seed = _number(options.get("seed", "0"), 0, SEED_MAX) + length = _number(options["len"], 1, QSPI_BUFFER) + return generate(length, pattern, seed) + + @staticmethod + def _check_busy(state: dict[str, Any]) -> None: + if state["stuck"]: + _fail("busy") + + def cmd_open(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("variant", "prescaler", "size")) + index = _number(args[0], 0, QSPI_INDEX) + variant = _choice(options, "variant", _VARIANTS, "poll") + prescaler = _number(options.get("prescaler", "2"), 0, QSPI_PRESCALER_MAX) + _number(options.get("size", "24"), 1, QSPI_SIZE_MAX) + if index not in self.fw.spec.qspi: + _fail("range") + for function, pin in _PINS: + self.fw.check_function(function, 0, pin) + state = {"variant": variant, "prescaler": prescaler, "stuck": False} + resources = [] if variant == "poll" else [_DMA] + self.fw.open_instance(self.prefix, str(index), state, [pin for _, pin in _PINS], resources=resources) + return f"OK clk={qspi_clock(self.fw.kernel_clock, prescaler)}" + + def cmd_cmd(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options, 1, 1, _COMMAND_KEYS) + transmit, generated, receive = "tx" in options, "len" in options, "rx" in options + patterned = "pattern" in options or "seed" in options + if ( + transmit + generated + receive > 1 + or (patterned and not generated) + or ("out" in options and not receive) + or (receive and "repeat" in options) + or not _phases_shape(options) + ): + _fail("usage") + lines = self._phases(options) + self._check_busy(state) + length = 0 + output = "hex" + if transmit: + _hex(options["tx"], QSPI_BUFFER) + elif generated: + self._generated(options) + elif receive: + length = _number(options["rx"], 1, QSPI_BUFFER) + output = _choice(options, "out", _OUTPUTS, "hex") + if output == "hex" and length > QSPI_HEX_MAX: + _fail("range") + _number(options.get("repeat", "1"), 1, QSPI_REPEAT_MAX) + if not receive: + return "OK flevel=0" + data = bytes([self._byte(lines)]) * length + if output == "crc": + return f"OK len={length} crc={crc_text(data)}" + return f"OK data={data.hex()}" + + def cmd_poll(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options, 1, 1, _POLL_KEYS) + if "match" not in options or "mask" not in options or not _phases_shape(options): + _fail("usage") + lines = self._phases(options) + match = _number(options["match"], 0, UINT32_MAX) + mask = _number(options["mask"], 0, UINT32_MAX) + size = _number(options.get("size", "1"), 1, _PHASE_BYTES_MAX) + if not _fits(match, size) or not _fits(mask, size): + _fail("range") + self._check_busy(state) + status = int.from_bytes(bytes([self._byte(lines)]) * size, "big") + if status & mask == match & mask: + return "OK" + self.fw.sleep(QSPI_TIMEOUT_S) + state["stuck"] = True + return "ERR timeout" + + def cmd_xfer(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options, 2, 2, _TRANSFER_KEYS) + text = args[1] + receive = "rx" in options + if (text != "-" or "len" in options) == receive or (receive and "repeat" in options): + _fail("usage") + if state["variant"] != "spi": + _fail("unsupported") + self._check_busy(state) + generated = "len" in options + patterned = "pattern" in options or "seed" in options + if text != "-": + if generated or patterned: + _fail("usage") + _hex(text, QSPI_BUFFER) + elif generated: + self._generated(options) + elif patterned: + _fail("usage") + length = _number(options["rx"], 1, QSPI_HEX_MAX) if receive else 0 + _number(options.get("repeat", "1"), 1, QSPI_REPEAT_MAX) + if not receive: + return "OK flevel=0" + return f"OK rx={(bytes([self._byte(1)]) * length).hex()}" + + def cmd_close(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + key = str(_number(args[0], 0, QSPI_INDEX)) + self.fw.find_instance(self.prefix, key) + return self.fw.close_instance(self.prefix, key) diff --git a/validation/host/hal_st_validation/fakes/spis.py b/validation/host/hal_st_validation/fakes/spis.py new file mode 100644 index 00000000..70e72bc3 --- /dev/null +++ b/validation/host/hal_st_validation/fakes/spis.py @@ -0,0 +1,168 @@ +"""Fake `spis` group: the argument checks of validation/firmware/SpiSlaveGroup.cpp in their order, the pins, the +`ResourceAllocation` spi instance and slave DMA channels it holds (WB55 DMA2 1/2, WBA55 GPDMA1 8/7, shared with +`dma.wave` and the ADC), and the arm/result state of `hal::SpiSlaveStmDma`. + +Nothing clocks the slave without the AD3, except the SPI loop: `FakeFirmware.spi_loop`, a mapping {master instance: +slave instance} (default: the two SPI instances of the MCU on each other, the `--with spiloop` wiring), lets +`spi.xfer` on an open master clock an open `spis` instance (`FakeSpiLoop` serves `spi.xfer` for that; the other +`spi.*` commands stay with `FakeFirmware`). Each byte the master clocks goes into the armed transfer (MOSI) and +takes the slave's next byte (MISO, zero for a receive-only transfer, whose DMA dummy the firmware does not +define); bytes beyond the armed length, or with no transfer armed, read `FakeFirmware.spi_miso`. The master's +`bits`, `lsb` and `mode` are not modelled. `spis.result` answers synchronously (a not yet done transfer sleeps +`wait`), so a second `spis.result` while one waits cannot happen here. +""" + +from __future__ import annotations + +from typing import Any + +from ..groups.spis import SPIS_BUFFER, SPIS_HEX_MAX, SPIS_WAIT_DEFAULT_MS, SPIS_WAIT_MAX_MS +from ..patterns import PATTERNS, SEED_MAX, crc_text, generate +from .base import FakeGroup, _choice, _fail, _flag, _hex, _number, _shape + +__all__ = ["FakeSpiLoop", "FakeSpiSlave"] + +_INSTANCES = 4 +_MASTER_CAPACITY = 64 +_OPEN_KEYS = ("clk", "miso", "mosi", "nss") +_ARM_KEYS = ("rx", "len", "pattern", "seed") +_RESULT_KEYS = ("wait", "out") +_FUNCTIONS = (("spiClock", "clk"), ("spiMiso", "miso"), ("spiMosi", "mosi"), ("spiSlaveSelect", "nss")) +# `board::spiSlaveDma`: (transmit, receive) as `ResourceAllocation` keys. +_SLAVE_DMA = {"stm32wb55": (("dma2", 1), ("dma2", 2)), "stm32wba55": (("dma1", 8), ("dma1", 7))} + + +def _payload(args: list[str], options: dict[str, str], position: int, capacity: int) -> bytes: + """`ParsePayload`: hex, or `-` with `len`/`pattern`/`seed`; `-` alone is empty.""" + text = args[position] + generated = "len" in options + patterned = "pattern" in options or "seed" in options + if text != "-": + if generated or patterned: + _fail("usage") + return _hex(text, capacity) + if not generated: + if patterned: + _fail("usage") + return b"" + pattern = _choice(options, "pattern", PATTERNS, "inc") + seed = _number(options.get("seed", "0"), 0, SEED_MAX) + length = _number(options["len"], 1, capacity) + return generate(length, pattern, seed) + + +class FakeSpiSlave(FakeGroup): + prefix = "spis" + + def _key(self, args: list[str]) -> str: + return str(_number(args[0], 0, _INSTANCES - 1)) + + def _find(self, args: list[str], options: dict[str, str], low: int, high: int, keys: tuple[str, ...] = ()) -> dict[str, Any]: + _shape(args, options, low, high, keys) + return self.fw.find_instance(self.prefix, self._key(args)) + + def cmd_open(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, _OPEN_KEYS) + index = _number(args[0], 0, _INSTANCES - 1) + if index not in self.fw.spec.spis: + _fail("range") + pins = {key: self.fw.pin(options.get(key)) for key in _OPEN_KEYS} + if any(pin is None for pin in pins.values()): + _fail("usage") + for function, key in _FUNCTIONS: + self.fw.check_function(function, index, pins[key]) + state = {"index": index, "armed": False, "done": False, "tx": b"", "rx": 0, "received": bytearray(), "clocked": 0} + resources = [("spi", index), *_SLAVE_DMA[self.fw.family]] + self.fw.open_instance(self.prefix, str(index), state, pins.values(), resources=resources) + return "OK" + + def cmd_arm(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options, 2, 2, _ARM_KEYS) + if state["armed"]: + _fail("busy") + send = _payload(args, options, 1, SPIS_BUFFER) + receive = _number(options.get("rx", str(len(send))), 0, SPIS_BUFFER) + if (not send and receive == 0) or (send and receive not in (0, len(send))): + _fail("usage") + state.update(armed=True, done=False, tx=send, rx=receive, received=bytearray(), clocked=0) + return "OK" + + def cmd_result(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options, 1, 1, _RESULT_KEYS) + output = _choice(options, "out", ("hex", "crc"), "hex") + wait = _number(options.get("wait", str(SPIS_WAIT_DEFAULT_MS)), 0, SPIS_WAIT_MAX_MS) + if output == "hex" and state["rx"] > SPIS_HEX_MAX: + _fail("range") + if state["done"]: + received = bytes(state["received"]) + if output == "crc": + return f"OK done=1 len={len(received)} crc={crc_text(received)}" + return f"OK done=1 rx={received.hex()}" + if state["armed"]: + self.fw.sleep(wait / 1000) + return "OK done=0" + + def cmd_cancel(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options, 1, 1) + cancelled = state["armed"] + state.update(armed=False, done=False) + return f"OK cancelled={int(cancelled)}" + + def cmd_close(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + key = self._key(args) + self.fw.find_instance(self.prefix, key) + return self.fw.close_instance(self.prefix, key) + + def clock(self, index: int, mosi: bytes) -> bytes | None: + """A master clocks `mosi` into the `spis` instance `index`: its MISO bytes, or None when it is not open.""" + state = self.fw.opened.get((self.prefix, str(index))) + if state is None: + return None + miso = bytearray() + for byte in mosi: + if not state["armed"]: + miso.append(self.fw.spi_miso) + continue + position = state["clocked"] + length = max(len(state["tx"]), state["rx"]) + miso.append(state["tx"][position] if state["tx"] else 0x00) + if position < state["rx"]: + state["received"].append(byte) + state["clocked"] = position + 1 + if state["clocked"] == length: + state.update(armed=False, done=True) + return bytes(miso) + + +class FakeSpiLoop(FakeGroup): + """`spi.xfer` of the SPI master with the loop to `spis`; the checks are those of EMIL's `HilSpiCommands`.""" + + prefix = "spi" + + def loop(self) -> dict[int, int]: + """`FakeFirmware.spi_loop`, or the two SPI instances of the MCU wired to each other.""" + loop = getattr(self.fw, "spi_loop", None) + if loop is not None: + return dict(loop) + first, second = sorted(self.fw.spec.spis) + return {first: second, second: first} + + def cmd_xfer(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 2, ("rx", "continue")) + index = _number(args[0], 0, _INSTANCES - 1) + self.fw.find_instance("spi", str(index)) + data = _hex(args[1], _MASTER_CAPACITY) + size = _number(options.get("rx", str(len(data))), 0, _MASTER_CAPACITY) + _flag(options, "continue") + length = max(len(data), size) + if length == 0: + _fail("usage") + target = self.loop().get(index) + slave = self.fw.group(FakeSpiSlave.prefix) + miso = None + if target is not None and isinstance(slave, FakeSpiSlave): + miso = slave.clock(target, data + bytes(length - len(data))) + if miso is None: + miso = bytes([self.fw.spi_miso]) * length + return f"OK rx={miso[:size].hex() or '-'}" diff --git a/validation/host/hal_st_validation/fakes/system_ext.py b/validation/host/hal_st_validation/fakes/system_ext.py new file mode 100644 index 00000000..cbe2676d --- /dev/null +++ b/validation/host/hal_st_validation/fakes/system_ext.py @@ -0,0 +1,422 @@ +"""Fake `flash`, `hsem`, `bkp` and `lpm` groups: the argument checks of validation/firmware/FlashGroup.cpp, +HsemGroup.cpp, BackupRamGroup.cpp and LowPowerGroup.cpp in their order, and simple models behind them. + +- `flash`: the scratch region as a byte array with page erase, both layouts, the double-safe first sector and the + erased-word check. `variant=coord` (STM32WB55) waits while HSEM 7 is held or `flash.stack starting` holds it; the + coordinated driver borrows the watchdog (`wdt.start` answers `ERR busy` meanwhile) and holds HSEM 0 against + `hsem.lock`. +- `hsem`: lock state of the 32 semaphores, `hold=` timers on the fake clock; `hsem.lock hold=` waits the hold. +- `bkp`: words that survive `reset` (the backup domain keeps them). +- `lpm`: wakes at once (`woke=exti`, `sleeps=1`), as if the wake edge came right after the marker went low. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, ClassVar, cast + +from ..groups.system_ext import LAYOUTS, VARIANTS, bkp_fill_value, table_sectors +from ..patterns import PATTERNS, SEED_MAX, Pattern, crc_text, generate +from .base import UINT32_MAX, FakeGroup, _choice, _fail, _hex, _number, _shape + +if TYPE_CHECKING: + from ..fake_firmware import FakeFirmware + +__all__ = ["FakeBackupRam", "FakeFlash", "FakeHsem", "FakeLowPower", "FakeWatchdogShare"] + +_PAYLOAD = 512 +_HEX_OUTPUT_MAX = 128 +_FIRST_PAGE = 64 +_END_PAGE = 128 +_FLASH_BASE = 0x08000000 +# The fake image ends a little below the region, like the real one (C.10 of the design: ~200 KB on STM32WB55). +_GEOMETRY = {"stm32wb55": (4096, 8, 50), "stm32wba55": (8192, 16, 24)} +_ERASE_US_PER_PAGE = {"stm32wb55": 22000, "stm32wba55": 3000} +_WRITE_US_PER_WORD = 90 +_ERASE_TIMEOUT = 10.0 +_TRANSFER_TIMEOUT = 2.0 +_FLASH_SEMAPHORE = 7 +_STACK_STATES = ("stopped", "starting", "fus") +_FLASH_OWNER = ("flash", "coord") + +_SEMAPHORES = 32 +_PROCESS_MAX = 255 +_HOLD_MAX_MS = 60000 +_LOCK_HOLD_MIN_US = 2 +_LOCK_HOLD_MAX_US = 65536 +_LOCK_HOLD_PROCESS = 1 +_CORE_CPU1 = 4 +_HSEM_OWNER = ("hsem", "lock") +_SCAFFOLD_TIMER = 17 + +_BKP_WORDS = {"stm32wb55": 20, "stm32wba55": 16} + +_LPM_DEFAULTS = {"stm32wb55": ("PC6", "PB0"), "stm32wba55": ("PB14", "PA2")} +_LPM_TIMEOUT_MAX_MS = 10000 +_LPM_OWNER = ("lpm", "enter") +_LPM_WAKE_US = 100 + + +def _payload(text: str, options: dict[str, str], capacity: int) -> bytes: + """`ParsePayload` of validation/firmware/Payload.cpp.""" + generated = "len" in options + patterned = "pattern" in options or "seed" in options + if text != "-": + if generated or patterned: + _fail("usage") + return _hex(text, capacity) + if not generated: + if patterned: + _fail("usage") + return b"" + pattern = _choice(options, "pattern", PATTERNS, "inc") + seed = _number(options.get("seed", "0"), 0, SEED_MAX) + length = _number(options["len"], 1, capacity) + return generate(length, cast(Pattern, pattern), seed) + + +@dataclass +class _Held: + """A coordinated erase or write waiting for HSEM 7 or for the end of `flash.stack starting`.""" + + job: str + apply: Callable[[], int] + started: float + deadline: float + timed_out: bool = False + + +class FakeFlash(FakeGroup): + prefix = "flash" + + def __init__(self, fw: FakeFirmware) -> None: + super().__init__(fw) + page, self.word, self.image = _GEOMETRY.get(fw.family, _GEOMETRY["stm32wb55"]) + self.page = page + self.pages = _END_PAGE - _FIRST_PAGE + self.memory = bytearray(b"\xff" * (self.pages * page)) + self.table = table_sectors(self.pages) + self.boot() + + def boot(self) -> None: + self.held: _Held | None = None + self.stack_held = False + self.layout = "homogeneous" + + @property + def base(self) -> int: + return _FLASH_BASE + _FIRST_PAGE * self.page + + def borrowing(self) -> bool: + """The coordinated driver exists (it holds the watchdog and HSEM 0).""" + return self.stack_held or self.held is not None + + def _sizes(self, layout: str) -> list[int]: + return self.table if layout == "table" else [1] * self.pages + + def _address(self, layout: str, sector: int) -> int: + return sum(self._sizes(layout)[:sector]) * self.page + + def _first(self, layout: str) -> int: + return min(self.image, len(self._sizes(layout))) + + def _driver(self, options: dict[str, str]) -> tuple[str, str]: + variant = _choice(options, "variant", VARIANTS, "sync") + layout = _choice(options, "layout", LAYOUTS, "homogeneous") + return variant, layout + + def _check_variant(self, variant: str) -> None: + if variant == "coord" and self.fw.family != "stm32wb55": + _fail("unsupported") + + def _semaphore_free(self) -> bool: + hsem = self.fw.group("hsem") + return not isinstance(hsem, FakeHsem) or hsem.holder(_FLASH_SEMAPHORE) is None + + def _reserve(self, variant: str, layout: str, synchronous_read: bool = False) -> None: + """`FlashCommands::Reserve`.""" + if synchronous_read: + return + if self.stack_held: + if self.held is not None or variant != "coord" or layout != self.layout: + _fail("busy") + return + if self.held is not None: + _fail("busy") + + def _release(self) -> None: + self.held = None + self.fw.release_resources(_FLASH_OWNER) + + def _run(self, variant: str, job: str, apply: Callable[[], int], timeout: float) -> str | None: + if variant != "coord": + return f"OK us={apply()}" + self.fw.claim_resource("hsem", 0, _FLASH_OWNER) + if not self.stack_held and self._semaphore_free(): + us = apply() + self._release() + return f"OK us={us}" + now = self.fw.clock() + self.held = _Held(job, apply, now, now + timeout) + return None + + def poll(self) -> None: + held = self.held + if held is None: + return + if not self.stack_held and self._semaphore_free(): + apply_us = held.apply() + us = max(apply_us, round((self.fw.clock() - held.started) * 1e6)) + self.held = None + if not self.stack_held: + self.fw.release_resources(_FLASH_OWNER) + self.fw.event(f"EVT flash op={held.job} us={us}" if held.timed_out else f"OK us={us}") + return + if not held.timed_out and self.fw.clock() >= held.deadline: + held.timed_out = True + self.fw.event("ERR timeout") + + def cmd_info(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 0, 0, ("variant", "layout")) + variant, layout = self._driver(options) + self._check_variant(variant) + sectors = len(self._sizes(layout)) + return ( + f"OK base=0x{self.base:08x} sectors={sectors} size={len(self.memory)} first={self._first(layout)} " + f"image={self.image} layout={layout}" + ) + + def cmd_erase(self, args: list[str], options: dict[str, str]) -> str | None: + _shape(args, options, 2, 2, ("variant", "layout")) + begin = _number(args[0]) + end = _number(args[1]) + variant, layout = self._driver(options) + sectors = len(self._sizes(layout)) + if begin < self._first(layout) or begin >= end or end > sectors: + _fail("range") + self._check_variant(variant) + self._reserve(variant, layout) + start, stop = self._address(layout, begin), self._address(layout, end) + + def apply() -> int: + self.memory[start:stop] = b"\xff" * (stop - start) + return (stop - start) // self.page * _ERASE_US_PER_PAGE.get(self.fw.family, 22000) + + return self._run(variant, "erase", apply, _ERASE_TIMEOUT) + + def cmd_write(self, args: list[str], options: dict[str, str]) -> str | None: + _shape(args, options, 2, 2, ("len", "pattern", "seed", "variant", "layout")) + address = _number(args[0]) + variant, layout = self._driver(options) + if self.held is not None: + _fail("busy") + data = _payload(args[1], options, _PAYLOAD) + if not data: + _fail("usage") + if address < self._address(layout, self._first(layout)) or address + len(data) > len(self.memory): + _fail("range") + self._check_variant(variant) + self._reserve(variant, layout) + first = address // self.word * self.word + last = min((address + len(data) + self.word - 1) // self.word * self.word, len(self.memory)) + if any(byte != 0xFF for byte in self.memory[first:last]): + _fail("failed") + + def apply() -> int: + self.memory[address : address + len(data)] = data + return (last - first) // self.word * _WRITE_US_PER_WORD + + return self._run(variant, "write", apply, _TRANSFER_TIMEOUT) + + def cmd_read(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 2, ("out", "variant", "layout")) + address = _number(args[0]) + length = _number(args[1], 1, UINT32_MAX) + output = _choice(options, "out", ("hex", "crc"), "hex") + variant, layout = self._driver(options) + if address + length > len(self.memory) or (output == "hex" and length > _HEX_OUTPUT_MAX): + _fail("range") + self._check_variant(variant) + self._reserve(variant, layout, synchronous_read=variant == "sync") + data = bytes(self.memory[address : address + length]) + if output == "crc": + return f"OK len={length} crc={crc_text(data)}" + return f"OK data={data.hex()}" + + def cmd_stack(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("layout",)) + state = args[0] + if state not in _STACK_STATES: + _fail("usage") + layout = _choice(options, "layout", LAYOUTS, "homogeneous") + if state != "starting": + if self.stack_held: + self.stack_held = False + if self.held is None: + self._release() + return "OK" + if self.stack_held: + _fail("busy") + self._reserve("coord", layout) + self.fw.claim_resource("hsem", 0, _FLASH_OWNER) + self.stack_held = True + self.layout = layout + return "OK" + + +class FakeWatchdogShare(FakeGroup): + """`wdt.start` of WatchDogFactory.cpp while the coordinated flash borrows the unstarted WWDG: `ERR busy` after the + argument checks; the other `wdt` commands stay with the fake firmware.""" + + prefix = "wdt" + + def cmd_start(self, args: list[str], options: dict[str, str]) -> str: + flash = self.fw.group("flash") + borrowed = isinstance(flash, FakeFlash) and flash.borrowing() and self.fw.watchdog is None + result = self.fw._cmd_wdt_start(args, options) + if borrowed: + self.fw.watchdog = None + self.fw.release(("wdt", args[0])) + _fail("busy") + return result + + +class FakeHsem(FakeGroup): + prefix = "hsem" + + def __init__(self, fw: FakeFirmware) -> None: + super().__init__(fw) + self.boot() + + def boot(self) -> None: + self.locks: dict[int, int] = {} + self.hold: tuple[int, int, float] | None = None + + def holder(self, semaphore: int) -> int | None: + """The process that holds `semaphore`, after the `hold=` timer had its turn.""" + if self.hold is not None and self.fw.clock() >= self.hold[2]: + held, process, _ = self.hold + self.hold = None + if self.locks.get(held) == process: + del self.locks[held] + return self.locks.get(semaphore) + + def poll(self) -> None: + self.holder(0) + + def _semaphore(self, text: str) -> int: + return _number(text, 0, _SEMAPHORES - 1) + + def _process(self, args: list[str], options: dict[str, str]) -> tuple[int, int]: + if "procid" not in options: + _fail("usage") + return self._semaphore(args[0]), _number(options["procid"], 1, _PROCESS_MAX) + + def cmd_take(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("procid", "hold")) + semaphore, process = self._process(args, options) + hold = _number(options["hold"], 1, _HOLD_MAX_MS) if "hold" in options else None + holder = self.holder(semaphore) + if hold is not None and self.hold is not None: + _fail("busy") + if holder is not None and holder != process: + _fail("busy") + self.locks[semaphore] = process + if hold is not None: + self.hold = (semaphore, process, self.fw.clock() + hold / 1000) + return "OK" + + def cmd_release(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("procid",)) + semaphore, process = self._process(args, options) + if self.holder(semaphore) == process: + del self.locks[semaphore] + if self.hold is not None and self.hold[:2] == (semaphore, process): + self.hold = None + return "OK" + + def cmd_status(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + process = self.holder(self._semaphore(args[0])) + if process is None: + return "OK locked=0 core=0 procid=0" + return f"OK locked=1 core={_CORE_CPU1} procid={process}" + + def cmd_lock(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("hold",)) + semaphore = self._semaphore(args[0]) + hold = _number(options["hold"], _LOCK_HOLD_MIN_US, _LOCK_HOLD_MAX_US) if "hold" in options else 0 + self.fw.check_resources(_HSEM_OWNER, [("hsem", 0)]) + if hold and self.fw.timer_owners.get(_SCAFFOLD_TIMER, _HSEM_OWNER) != _HSEM_OWNER: + _fail("busy") + if self.holder(semaphore) is not None: + _fail("busy") + return f"OK waited={hold}" + + def cmd_mine(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + return f"OK mine={int(self.holder(self._semaphore(args[0])) is not None)}" + + +class FakeBackupRam(FakeGroup): + prefix = "bkp" + + def __init__(self, fw: FakeFirmware) -> None: + super().__init__(fw) + self.words = [0] * _BKP_WORDS.get(fw.family, 20) + + def _index(self, text: str) -> int: + return _number(text, 0, len(self.words) - 1) + + def cmd_info(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 0, 0) + return f"OK words={len(self.words)}" + + def cmd_write(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 2, 2) + index = self._index(args[0]) + self.words[index] = _number(args[1]) + return "OK" + + def cmd_read(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + return f"OK value={self.words[self._index(args[0])]:08x}" + + def cmd_fill(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + seed = _number(args[0]) + self.words = [bkp_fill_value(seed, index) for index in range(len(self.words))] + return "OK" + + def cmd_check(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + seed = _number(args[0]) + mismatches = sum(1 for index, word in enumerate(self.words) if word != bkp_fill_value(seed, index)) + return f"OK mismatches={mismatches}" + + +class FakeLowPower(FakeGroup): + prefix = "lpm" + owner: ClassVar[tuple[str, str]] = _LPM_OWNER + + def cmd_enter(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("wake", "edge", "marker", "timeout")) + if args[0] not in ("sleep", "deep"): + _fail("usage") + _choice(options, "edge", ("rising", "falling"), "rising") + if "timeout" in options: + _number(options["timeout"], 1, _LPM_TIMEOUT_MAX_MS) + default_wake, default_marker = _LPM_DEFAULTS.get(self.fw.family, _LPM_DEFAULTS["stm32wb55"]) + wake = self.fw.pin(options.get("wake")) or default_wake + marker = self.fw.pin(options.get("marker")) or default_marker + if wake == marker: + _fail("usage") + if not self.fw.bonded(wake) or not self.fw.bonded(marker): + _fail("pin") + if not self.fw.supports_interrupt(wake): + _fail("unsupported") + if self.fw.timer_owners.get(_SCAFFOLD_TIMER, self.owner) != self.owner: + _fail("busy") + self.fw.check_pins(self.owner, [wake, marker]) + return f"OK woke=exti restored=0 us={_LPM_WAKE_US} sleeps=1" diff --git a/validation/host/hal_st_validation/fakes/timers.py b/validation/host/hal_st_validation/fakes/timers.py new file mode 100644 index 00000000..15816e2d --- /dev/null +++ b/validation/host/hal_st_validation/fakes/timers.py @@ -0,0 +1,263 @@ +"""Fake `tim`, `tpwm`, `lptim` and `lptpwm` groups: the argument checks of validation/firmware/TimerGroup.cpp, +TimerPwmGroup.cpp, LpTimerGroup.cpp and LpTimerPwmGroup.cpp in their order, the timers and LPTIMs they share with +`pwm`, `qei` and `adc` (`TimerAllocation`, `ResourceAllocation` lpTimer), and counters that follow the fake clock.""" + +from __future__ import annotations + +from typing import Any, ClassVar + +from .. import expect +from ..groups.timers import ( + LPTIM_PERIOD_MAX, + LPTIM_PRESCALERS, + LPTIM_REPETITION_MAX, + TIMER_PRESCALER_MAX, + UPDATE_INTERRUPT_MAX_HZ, + timer_period_max, +) +from .base import FakeGroup, _choice, _fail, _number, _shape, _tokens + +_IRQS = ("none", "immediate", "dispatched") +_MODES = ("up", "down") +_TIMER_INSTANCES = 18 +_LPTIM_INSTANCES = 3 + + +class _Group(FakeGroup): + """One instance at a time (`HilSingleInstanceGroup`): `.open` claims, the other commands find it.""" + + instances: ClassVar[int] = 1 + + def _index(self, text: str) -> int: + return _number(text, 0, self.instances - 1) + + def _find(self, args: list[str], options: dict[str, str], keys: tuple[str, ...] = (), positional: int = 1) -> dict[str, Any]: + _shape(args, options, positional, positional, keys) + return self.fw.find_instance(self.prefix, str(self._index(args[0]))) + + def cmd_close(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1) + key = str(self._index(args[0])) + self.fw.find_instance(self.prefix, key) + return self.fw.close_instance(self.prefix, key) + + +class _Counter(_Group): + """`TimerCounterGroup`: start/stop/count over a counter that runs at `rate` ticks per second while started and + wraps after `period + 1` ticks; with an update interrupt every wrap counts in `irqs`.""" + + def _counter_state(self, rate: float, period: int, irq: str, down: bool = False) -> dict[str, Any]: + return {"rate": rate, "period": period, "irq": irq, "down": down, "ticks": 0.0, "since": None} + + def _ticks(self, state: dict[str, Any]) -> float: + running = 0.0 if state["since"] is None else (self.fw.clock() - state["since"]) * state["rate"] + return state["ticks"] + running + + def cmd_start(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options) + if state["since"] is None: + state["since"] = self.fw.clock() + return "OK" + + def cmd_stop(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options) + if state["since"] is not None: + state["ticks"] = self._ticks(state) + state["since"] = None + return "OK" + + def cmd_count(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options) + ticks = int(self._ticks(state)) + wrap = state["period"] + 1 + counter = (state["period"] - ticks % wrap) if state["down"] else ticks % wrap + irqs = 0 if state["irq"] == "none" else ticks // wrap + if state["pin"] is not None: + self.fw.gpio_levels[state["pin"]] = irqs % 2 + return f"OK cnt={counter} irqs={irqs}" + + def _marker(self, irq: str, pin: str | None) -> str | None: + """The update marker of `pin=`: none without an update interrupt (`usage`), a bonded pin (`pin`).""" + if pin is not None and irq == "none": + _fail("usage") + if pin is not None and not self.fw.bonded(pin): + _fail("pin") + return pin + + +class FakeTimer(_Counter): + prefix = "tim" + instances = _TIMER_INSTANCES + + def cmd_open(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("prescaler", "period", "irq", "mode", "pin")) + timer = self._index(args[0]) + if not self.fw.timer_exists(timer): + _fail("range") + prescaler = _number(options.get("prescaler", "0"), 0, TIMER_PRESCALER_MAX) + period = _number(options.get("period", "999"), 1, timer_period_max(timer)) + irq = _choice(options, "irq", _IRQS, "dispatched") + mode = _choice(options, "mode", _MODES, "up") + pin = self._marker(irq, self.fw.pin(options.get("pin"))) + if mode == "down" and (irq != "none" or not expect.timer_has_center_mode(timer)): + _fail("unsupported") + timclk = self.fw.kernel_clock + if irq != "none" and timclk // ((prescaler + 1) * (period + 1)) > UPDATE_INTERRUPT_MAX_HZ: + _fail("range") + state = self._counter_state(timclk / (prescaler + 1), period, irq, mode == "down") + state.update(timer=timer, prescaler=prescaler, pin=pin, timclk=timclk) + self.fw.open_instance(self.prefix, str(timer), state, [pin], timer=timer) + return f"OK timclk={timclk}" + + +class FakeLpTimer(_Counter): + prefix = "lptim" + instances = _LPTIM_INSTANCES + + def cmd_open(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("period", "prescaler", "irq", "rep", "pin")) + index = self._index(args[0]) + if index not in self.fw.spec.lptims: + _fail("range") + period = _number(options.get("period", "999"), 1, LPTIM_PERIOD_MAX) + divider = _number(options.get("prescaler", "1"), 1, LPTIM_PRESCALERS[-1]) + repetition = _number(options.get("rep", "0"), 0, LPTIM_REPETITION_MAX) + irq = _choice(options, "irq", _IRQS, "dispatched") + pin = self.fw.pin(options.get("pin")) + if divider not in LPTIM_PRESCALERS: + _fail("range") + pin = self._marker(irq, pin) + # The STM32WB LPTIM has no repetition counter (LpTimerStm.cpp). + if "rep" in options and self.fw.family == "stm32wb55": + _fail("unsupported") + lptimclk = self.fw.kernel_clock + # ARRM interrupts on every period, whatever `rep`. + if irq != "none" and lptimclk // (divider * (period + 1)) > UPDATE_INTERRUPT_MAX_HZ: + _fail("range") + state = self._counter_state(lptimclk / divider / (repetition + 1), period, irq) + state.update(index=index, divider=divider, repetition=repetition, pin=pin, lptimclk=lptimclk) + self.fw.open_instance(self.prefix, str(index), state, [pin], resources=[("lpTimer", index)]) + return f"OK lptimclk={lptimclk}" + + +class _ChannelPwm(_Group): + """`PwmChannelGroup`: N channels in `pins` order (`-` unused), duty/pulse per channel, start/stop one or all.""" + + clock_key: ClassVar[str] = "" + functions: ClassVar[tuple[str, ...]] = () + + def _channel_pins(self, options: dict[str, str]) -> list[str | None]: + """`ParseChannelPins`: 1..N entries, `-` for an unused channel, at least one pin.""" + if "pins" not in options: + _fail("usage") + tokens = _tokens(options["pins"]) + if not 1 <= len(tokens) <= len(self.functions): + _fail("usage") + pins = [None if token == "-" else self.fw.pin(token) for token in tokens] + if all(pin is None for pin in pins): + _fail("usage") + return pins + + def _open(self, number: int, pins: list[str | None], counter_max: int, period: int, clock: int, **extra: Any) -> str: + """`Evaluate` after the numbers and `ParseChannelPins`: channel functions (`pin`), channels (`unsupported`).""" + for position, pin in enumerate(pins): + if pin is not None and not self.fw.supports(self.functions[position], number, pin): + _fail("pin") + if not all(self._has_channel(number, position + 1) for position in range(len(pins))): + _fail("unsupported") + state = {"pins": pins, "period": period, "counter_max": counter_max, "running": [False] * len(pins), **extra} + state["ccr"] = [0] * len(pins) + state[self.clock_key] = clock + self.fw.open_instance(self.prefix, str(number), state, pins, **self._claims(number)) + return f"OK {self.clock_key}={clock}" + + def _has_channel(self, index: int, channel: int) -> bool: + raise NotImplementedError + + def _claims(self, index: int) -> dict[str, Any]: + raise NotImplementedError + + def _channel(self, state: dict[str, Any], text: str) -> int: + return _number(text, 1, len(state["pins"])) + + def cmd_duty(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options, positional=3) + channel = self._channel(state, args[1]) + percent = _number(args[2], 0, 100) + state["ccr"][channel - 1] = ((state["period"] * percent) & 0xFFFFFFFF) // 100 + return "OK" + + def cmd_pulse(self, args: list[str], options: dict[str, str]) -> str: + state = self._find(args, options, positional=4) + channel = self._channel(state, args[1]) + on = _number(args[2], 0, state["counter_max"]) + state["period"] = _number(args[3], 1, state["counter_max"]) + state["ccr"][channel - 1] = on + return "OK" + + def _channels(self, args: list[str], options: dict[str, str]) -> tuple[dict[str, Any], list[int]]: + state = self._find(args, options, ("ch",)) + if "ch" in options: + return state, [self._channel(state, options["ch"]) - 1] + return state, list(range(len(state["pins"]))) + + def cmd_start(self, args: list[str], options: dict[str, str]) -> str: + state, positions = self._channels(args, options) + for position in positions: + state["running"][position] = True + return "OK" + + def cmd_stop(self, args: list[str], options: dict[str, str]) -> str: + state, positions = self._channels(args, options) + for position in positions: + state["running"][position] = False + return "OK" + + +class FakeTimerPwm(_ChannelPwm): + prefix = "tpwm" + instances = _TIMER_INSTANCES + clock_key = "timclk" + functions = ("timerChannel1", "timerChannel2", "timerChannel3", "timerChannel4") + + def cmd_open(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("pins", "prescaler", "period")) + timer = self._index(args[0]) + if not self.fw.timer_exists(timer): + _fail("range") + counter_max = timer_period_max(timer) + prescaler = _number(options.get("prescaler", "0"), 0, TIMER_PRESCALER_MAX) + period = _number(options.get("period", "6399"), 1, counter_max) + pins = self._channel_pins(options) + return self._open(timer, pins, counter_max, period, self.fw.kernel_clock, timer=timer, prescaler=prescaler) + + def _has_channel(self, index: int, channel: int) -> bool: + return expect.timer_has_channel(index, channel) + + def _claims(self, index: int) -> dict[str, Any]: + return {"timer": index} + + +class FakeLpTimerPwm(_ChannelPwm): + prefix = "lptpwm" + instances = _LPTIM_INSTANCES + clock_key = "lptimclk" + functions = ("lpTimerChannel1", "lpTimerChannel2") + + def cmd_open(self, args: list[str], options: dict[str, str]) -> str: + _shape(args, options, 1, 1, ("pins", "prescaler", "period")) + index = self._index(args[0]) + if index not in self.fw.spec.lptims: + _fail("range") + divider = _number(options.get("prescaler", "1"), 1, LPTIM_PRESCALERS[-1]) + period = _number(options.get("period", "6399"), 1, LPTIM_PERIOD_MAX) + if divider not in LPTIM_PRESCALERS: + _fail("range") + pins = self._channel_pins(options) + return self._open(index, pins, LPTIM_PERIOD_MAX, period, self.fw.kernel_clock, index=index, divider=divider) + + def _has_channel(self, index: int, channel: int) -> bool: + return 1 <= channel <= 2 + + def _claims(self, index: int) -> dict[str, Any]: + return {"resources": [("lpTimer", index)]} diff --git a/validation/host/hal_st_validation/firmware.py b/validation/host/hal_st_validation/firmware.py index 3de213db..41461813 100644 --- a/validation/host/hal_st_validation/firmware.py +++ b/validation/host/hal_st_validation/firmware.py @@ -11,6 +11,8 @@ from ad3_waveforms_bench.protocol import Event, Response, format_command, format_decimal from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal, PendingCommand, TerminalError +from .groups import discover as discover_groups +from .groups.base import Group from .protocol import normalize_pin, parse_pin_map Pin = str @@ -18,6 +20,8 @@ Edge = Literal["rising", "falling", "both", "off"] Drive = Literal["low", "medium", "fast", "high"] PwmOutput = Pin | None | tuple[Pin | None, Pin | None] +PwmMode = Literal["edge", "edgedown", "center", "centerup", "centerboth"] +TriggerOutput = Literal["reset", "enable", "update", "oc1", "oc1ref", "oc2ref", "oc3ref", "oc4ref"] @dataclass(frozen=True) @@ -71,20 +75,7 @@ def quiesce(terminal: FirmwareTerminal, quiet: float = 0.1, limit: float = 3.0) return False -class _Group: - prefix = "" - - def __init__(self, firmware: Firmware) -> None: - self._fw = firmware - - def _cmd(self, name: str, *args: object, cmd_timeout: float | None = None, **options: object) -> Response: - return self._fw.command(f"{self.prefix}.{name}", *args, cmd_timeout=cmd_timeout, **options) - - def _pin(self, pin: Pin | None) -> str | None: - return None if pin is None else self._fw.pin(pin) - - def _pins(self, pins: Sequence[Pin] | None) -> list[str] | None: - return None if pins is None else [self._fw.pin(pin) for pin in pins] +_Group = Group class System(_Group): @@ -155,7 +146,7 @@ def open( channels: Sequence[int] | None = None, pins: Sequence[PwmOutput] | None = None, freq: int | None = None, - mode: Literal["edge", "center"] | None = None, + mode: PwmMode | None = None, prescaler: int | None = None, dead: int | Literal["off"] | None = None, inv: bool | None = None, @@ -166,9 +157,13 @@ def open( brkpol: Literal["low", "high"] | None = None, brkauto: bool | None = None, sync: bool | None = None, + preload: bool | None = None, + brkfilter: int | None = None, + trgo: TriggerOutput | None = None, ) -> int: """Returns `pwmclk`. A `pins` entry is the channel output, `(output, complementary)` or None (`-`); a None - inside the tuple leaves that position unused (`-:PA7` drives only CH1N).""" + inside the tuple leaves that position unused (`-:PA7` drives only CH1N). `brkfilter` (0..15) needs `brk`; + `trgo` selects the trigger output an `adc.open trgo=` sequence runs on.""" response = self._cmd( "open", timer, @@ -186,6 +181,9 @@ def open( brkpol=brkpol, brkauto=brkauto, sync=sync, + preload=preload, + brkfilter=brkfilter, + trgo=trgo, ) self._fw.track(("pwm", timer), "pwm.close", timer) return response.as_int("pwmclk") @@ -234,8 +232,10 @@ def open( dma: bool | None = None, duplex: bool | None = None, sync: bool | None = None, + sendonly: bool | None = None, ) -> None: - """`lp=True` selects LPUART; the group keeps one instance, addressed by `index` afterwards.""" + """`lp=True` selects LPUART; the group keeps one instance, addressed by `index` afterwards. + `sendonly=True` opens a transmit-only synchronous UART: `rx` is optional and `recv` returns no data.""" self._cmd( "open", index, @@ -251,6 +251,7 @@ def open( dma=dma, duplex=duplex, sync=sync, + sendonly=sendonly, ) self._fw.track(("uart", index), "uart.close", index) @@ -282,7 +283,12 @@ def open( mode: int | None = None, dma: bool | None = None, sync: bool | None = None, + bits: int | None = None, + lsb: bool | None = None, + nss: Pin | None = None, ) -> None: + """`bits` (4..16, `dma=True` only) is the frame size, `lsb=True` sends the least significant bit first and + `nss` muxes the hardware slave select (exclusive with the GPIO chip select `cs`).""" self._cmd( "open", index, @@ -294,6 +300,9 @@ def open( mode=mode, dma=dma, sync=sync, + bits=bits, + lsb=lsb, + nss=self._pin(nss), ) self._fw.track(("spi", index), "spi.close", index) @@ -315,9 +324,11 @@ def open( sampling: str | float | None = None, timer: int | None = None, rate: int | None = None, + trgo: int | None = None, ) -> None: - """`sampling` in ADC clock cycles (`2.5`, `"640.5"`); `timer` triggers the runs at `rate` per second.""" - self._cmd("open", adc, pins=self._pins(pins), sampling=sampling, timer=timer, rate=rate) + """`sampling` in ADC clock cycles (`2.5`, `"640.5"`); `timer` triggers the runs at `rate` per second, or + `trgo` on the trigger output of a timer the pwm group drives.""" + self._cmd("open", adc, pins=self._pins(pins), sampling=sampling, timer=timer, rate=rate, trgo=trgo) self._fw.track(("adc", adc), "adc.close", adc) def measure(self, adc: int, n: int | None = None, cmd_timeout: float | None = None) -> list[int]: @@ -342,11 +353,11 @@ def open( offset: int | None = None, inva: bool | None = None, invb: bool | None = None, - cap: Literal["a", "b", "ab"] | None = None, + cap: Literal["a", "b", "ab", "rise", "fall"] | None = None, filter: int | None = None, vel: int | Literal["off"] | None = None, ) -> None: - """`lp=True` selects LPTIM; `vel` is the speed sampling period in µs.""" + """`lp=True` selects LPTIM (`cap` ab, rise or fall); `vel` is the speed sampling period in µs.""" self._cmd( "open", timer, @@ -404,6 +415,7 @@ class Firmware: Open instances are tracked so `close_all()` can restore a clean state between tests. Pins are sent as `P` after resolving aliases with `aliases` (when given). + The groups of `groups/*.py` are attached under the names of their `GROUPS` (`fw.i2c`, ...). """ def __init__(self, terminal: FirmwareTerminal, aliases: Mapping[str, str] | None = None) -> None: @@ -418,6 +430,10 @@ def __init__(self, terminal: FirmwareTerminal, aliases: Mapping[str, str] | None self.adc = Adc(self) self.qei = Qei(self) self.wdt = Watchdog(self) + for name, cls in discover_groups().items(): + if hasattr(self, name): + raise ValueError(f"group {name!r} ({cls.__qualname__}) clashes with Firmware.{name}") + setattr(self, name, cls(self)) def command(self, name: str, *args: object, cmd_timeout: float | None = None, **options: object) -> Response: return self.terminal.command(format_command(name, *args, **options), timeout=cmd_timeout) diff --git a/validation/host/hal_st_validation/groups/__init__.py b/validation/host/hal_st_validation/groups/__init__.py new file mode 100644 index 00000000..b55d2c38 --- /dev/null +++ b/validation/host/hal_st_validation/groups/__init__.py @@ -0,0 +1,29 @@ +"""Command group wrappers, one module per area (`groups/.py`), each exporting +`GROUPS: dict[str, type[Group]]` (attribute name on `Firmware` -> class); `discover()` collects them.""" + +from __future__ import annotations + +import importlib +import pkgutil + +from .base import Group + +__all__ = ["Group", "discover"] + + +def discover() -> dict[str, type[Group]]: + """The `GROUPS` of every module of this package; one name exported twice is an error.""" + found: dict[str, type[Group]] = {} + owners: dict[str, str] = {} + for info in sorted(pkgutil.iter_modules(__path__), key=lambda info: info.name): + if info.name == "base": + continue + module = importlib.import_module(f"{__name__}.{info.name}") + for name, cls in getattr(module, "GROUPS", {}).items(): + if not (isinstance(cls, type) and issubclass(cls, Group)): + raise TypeError(f"{module.__name__}.GROUPS[{name!r}] is not a Group") + if name in found: + raise ValueError(f"group {name!r} is exported by {owners[name]} and {module.__name__}") + found[name] = cls + owners[name] = module.__name__ + return found diff --git a/validation/host/hal_st_validation/groups/analog.py b/validation/host/hal_st_validation/groups/analog.py new file mode 100644 index 00000000..a0ac8785 --- /dev/null +++ b/validation/host/hal_st_validation/groups/analog.py @@ -0,0 +1,157 @@ +"""Analog input and DMA groups of validation/PROTOCOL.md: `ain` (`hal::AnalogToDigitalPinImplStm`, +`hal::AnalogToDigitalInternalTemperatureStm`, `hal::AdcTriggeredByTimerWithDma`) and `dma` +(`hal::CircularTransmitDmaChannel`), with the expected waveforms (pure functions). + +Sources: `validation/firmware/AnalogInputGroup.cpp` (one driver per command; `ain.burst` builds one +`AdcTriggeredByTimerWithDma` over a 256-sample buffer and calls `Measure(n)` `repeat` times, paced by TIM2 through +`TriggerTiming`), `validation/firmware/DmaGroup.cpp` (TIM2 update requests write one BSRR word per pattern bit, +least significant bit of each byte first). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from ad3_waveforms_bench.protocol import Event, Response + +from .base import Group, Pin + +__all__ = [ + "AIN_BUFFER", + "AIN_LIST_MAX", + "AIN_RATE_MAX", + "AIN_RATE_MIN", + "AIN_REPEAT_MAX", + "DMA_PATTERN_MAX", + "DMA_RATE_MAX", + "DMA_WAVE_DEFAULT_MS", + "DMA_WAVE_MAX_MS", + "GROUPS", + "AinReading", + "AnalogInput", + "BurstRun", + "Dma", + "burst_seconds", + "trigger_rate", + "trigger_timing", + "wave_bits", +] + +Output = Literal["list", "stats"] + +AIN_BUFFER = 256 +AIN_LIST_MAX = 64 +AIN_RATE_MIN = 10 +AIN_RATE_MAX = 100_000 +AIN_REPEAT_MAX = 2 +DMA_RATE_MAX = 1_000_000 +DMA_PATTERN_MAX = 32 +DMA_WAVE_DEFAULT_MS = 50 +DMA_WAVE_MAX_MS = 10_000 +# Extra time `ain.burst` allows itself on top of `repeat` measurements (AnalogInputGroup.cpp `burstMarginMs`). +_BURST_MARGIN_S = 1.0 + + +def trigger_timing(clock: int, rate: int, counter_bits: int = 32) -> tuple[int, int]: + """`TriggerTiming` (AdcFactory.cpp): (PSC, ARR) of the smallest prescaler that fits, with the tick count rounded + to the nearer of floor and ceiling, at least 2 ticks.""" + maximum_ticks = 1 << counter_bits + prescale = clock // (rate * (maximum_ticks + 1)) + 1 + floor_ticks = max(clock // (prescale * rate), 2) + ceil_ticks = min(floor_ticks + 1, maximum_ticks) + round_up = abs(clock - prescale * ceil_ticks * rate) * floor_ticks < abs(clock - prescale * floor_ticks * rate) * ceil_ticks + return prescale - 1, (ceil_ticks if round_up else floor_ticks) - 1 + + +def trigger_rate(clock: int, rate: int, counter_bits: int = 32) -> float: + """Update events per second the timer really runs at for a requested `rate` (TIM2 counts 32 bits).""" + prescaler, period = trigger_timing(clock, rate, counter_bits) + return clock / ((prescaler + 1) * (period + 1)) + + +def burst_seconds(samples: int, clock: int, rate: int) -> float: + """Duration of one `Measure(samples)`: one conversion per TIM2 update, the first one period after the start.""" + return samples / trigger_rate(clock, rate) + + +def wave_bits(pattern: bytes) -> list[int]: + """The levels `dma.wave` drives, one per update: byte by byte, least significant bit first.""" + return [(byte >> bit) & 1 for byte in pattern for bit in range(8)] + + +@dataclass(frozen=True) +class AinReading: + code: int + # Whole degrees Celsius times 1000 (`__LL_ADC_CALC_TEMPERATURE` returns whole degrees); only for `temp`. + mcelsius: int | None + + +@dataclass(frozen=True) +class BurstRun: + """One `Measure(n)` of `ain.burst`: `samples` with `out=list` (None with `out=stats`), the statistics the + firmware reports (or computes the same way: mean rounded half up), and `us`, the measurement time.""" + + n: int + minimum: int + maximum: int + mean: int + us: int + samples: tuple[int, ...] | None + + @classmethod + def from_values(cls, source: Response | Event) -> BurstRun: + us = source.as_int("us") + if "samples" in source: + samples = tuple(source.as_ints("samples")) + mean = (sum(samples) + len(samples) // 2) // len(samples) + return cls(n=len(samples), minimum=min(samples), maximum=max(samples), mean=mean, us=us, samples=samples) + return cls( + n=source.as_int("n"), + minimum=source.as_int("min"), + maximum=source.as_int("max"), + mean=source.as_int("mean"), + us=us, + samples=None, + ) + + +class AnalogInput(Group): + prefix = "ain" + + def read(self, adc: int, target: Pin, sampling: str | float | None = None) -> AinReading: + """`target` is a pin or `"temp"` (the internal temperature sensor); `sampling` in ADC clock cycles.""" + source = "temp" if target == "temp" else self._pin(target) + response = self._cmd("read", adc, source, sampling=sampling) + mcelsius = response.get("mcelsius") + return AinReading(code=response.as_int("code"), mcelsius=None if mcelsius is None else int(mcelsius)) + + def burst( + self, + adc: int, + pin: Pin, + n: int, + rate: int, + repeat: int | None = None, + out: Output | None = None, + cmd_timeout: float | None = None, + ) -> list[BurstRun]: + """One run per `Measure(n)`: every run but the last arrives as `EVT ain index= run=`, the last one is + the final line.""" + timeout = cmd_timeout or self._fw.terminal.timeout + (repeat or 1) * n / rate + _BURST_MARGIN_S + response = self._cmd("burst", adc, self._pin(pin), n=n, rate=rate, repeat=repeat, out=out, cmd_timeout=timeout) + events = [event for event in self._fw.terminal.drain_events("ain") if event.as_int("index") == adc] + return [BurstRun.from_values(event) for event in events] + [BurstRun.from_values(response)] + + +class Dma(Group): + prefix = "dma" + + def wave(self, pin: Pin, rate: int, pattern: bytes, ms: int | None = None, cmd_timeout: float | None = None) -> None: + """Drives `pattern` on `pin` at `rate` bits per second for `ms` (default 50) and answers afterwards.""" + duration = (ms or DMA_WAVE_DEFAULT_MS) / 1000 + timeout = cmd_timeout or self._fw.terminal.timeout + duration + self._cmd("wave", self._pin(pin), rate=rate, pattern=pattern, ms=ms, cmd_timeout=timeout) + + +GROUPS: dict[str, type[Group]] = {"ain": AnalogInput, "dma": Dma} diff --git a/validation/host/hal_st_validation/groups/base.py b/validation/host/hal_st_validation/groups/base.py new file mode 100644 index 00000000..e472df6c --- /dev/null +++ b/validation/host/hal_st_validation/groups/base.py @@ -0,0 +1,37 @@ +"""`Group`: the base of the typed command wrappers (`fw..(...)`).""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING + +from ad3_waveforms_bench.protocol import Response, format_command +from ad3_waveforms_bench.terminal import PendingCommand + +if TYPE_CHECKING: + from ..firmware import Firmware + +Pin = str + + +class Group: + """Commands `.`; keyword arguments map 1:1 to protocol options (None leaves one out). Pins are + sent as `P` after resolving the firmware's aliases.""" + + prefix = "" + + def __init__(self, firmware: Firmware) -> None: + self._fw = firmware + + def _cmd(self, name: str, *args: object, cmd_timeout: float | None = None, **options: object) -> Response: + return self._fw.command(f"{self.prefix}.{name}", *args, cmd_timeout=cmd_timeout, **options) + + def begin(self, name: str, *args: object, cmd_timeout: float | None = None, **options: object) -> PendingCommand: + """Send `.` without waiting for its final line (`settle()` finishes it whatever happens).""" + return self._fw.terminal.begin(format_command(f"{self.prefix}.{name}", *args, **options), timeout=cmd_timeout) + + def _pin(self, pin: Pin | None) -> str | None: + return None if pin is None else self._fw.pin(pin) + + def _pins(self, pins: Sequence[Pin] | None) -> list[str] | None: + return None if pins is None else [self._fw.pin(pin) for pin in pins] diff --git a/validation/host/hal_st_validation/groups/crypto.py b/validation/host/hal_st_validation/groups/crypto.py new file mode 100644 index 00000000..86a87d65 --- /dev/null +++ b/validation/host/hal_st_validation/groups/crypto.py @@ -0,0 +1,95 @@ +"""Crypto groups of validation/PROTOCOL.md: `rng` (`hal::SynchronousRandomDataGeneratorStm`, `variant=async` +`hal::RandomDataGeneratorStm`, `variant=hsem` `hal::SynchronousSynchronizedRandomDataGeneratorStm` on STM32WB55), +`aes` (`hal::SynchronousAes128EcbStm`) and `pka` (`hal::PkaStm` on `services::secp256r1`). All three are stateless: +nothing to close. The reference models are `crypto_ref` and `rngstats`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from .base import Group + +__all__ = ["GROUPS", "VARIANTS", "Aes", "Pka", "PkaProduct", "Rng", "RngRead", "RngStats"] + +Variant = Literal["sync", "async", "hsem"] +Swap = Literal["none", "half", "byte", "bit"] + +VARIANTS: tuple[Variant, ...] = ("sync", "async", "hsem") + + +@dataclass(frozen=True) +class RngRead: + data: bytes + # `LL_RCC_HSI48_IsReady` after a `variant=hsem` read, else None. + hsi48: int | None + raw: str + + +@dataclass(frozen=True) +class RngStats: + n: int + ones: int + runs: int + chisq_x1000: int + crc: int + us: int + raw: str + + +class Rng(Group): + prefix = "rng" + + def read(self, length: int, variant: Variant | None = None, lock5: bool | None = None) -> RngRead: + response = self._cmd("read", length, variant=variant, lock5=lock5) + hsi48 = response.get("hsi48") + return RngRead(data=response.as_bytes("data"), hsi48=None if hsi48 is None else int(hsi48), raw=response.raw) + + def stats(self, length: int, variant: Variant | None = None, cmd_timeout: float | None = None) -> RngStats: + response = self._cmd("stats", length, variant=variant, cmd_timeout=cmd_timeout) + return RngStats( + n=response.as_int("n"), + ones=response.as_int("ones"), + runs=response.as_int("runs"), + chisq_x1000=response.as_int("chisq"), + crc=int(response["crc"], 16), + us=response.as_int("us"), + raw=response.raw, + ) + + +class Aes(Group): + prefix = "aes" + + def enc(self, key: bytes, data: bytes, swap: Swap | None = None) -> bytes: + return self._cmd("enc", key, data, swap=swap).as_bytes("data") + + def dec(self, key: bytes, data: bytes, swap: Swap | None = None) -> bytes: + return self._cmd("dec", key, data, swap=swap).as_bytes("data") + + +@dataclass(frozen=True) +class PkaProduct: + x: bytes + y: bytes + us: int + + +class Pka(Group): + """Operands are big-endian byte strings, 1-32 bytes (the firmware pads them to 32), or `cmp` 4-60 bytes each.""" + + prefix = "pka" + + def mul(self, k: bytes | None = None, x: bytes | None = None, y: bytes | None = None, cmd_timeout: float | None = None) -> PkaProduct: + response = self._cmd("mul", k=k, x=x, y=y, cmd_timeout=cmd_timeout) + return PkaProduct(x=response.as_bytes("x"), y=response.as_bytes("y"), us=response.as_int("us")) + + def check(self, x: bytes, y: bytes) -> bool: + return self._cmd("check", x=x, y=y).as_bool("on") + + def cmp(self, a: bytes, b: bytes) -> str: + return self._cmd("cmp", a=a, b=b)["cmp"] + + +GROUPS: dict[str, type[Group]] = {"rng": Rng, "aes": Aes, "pka": Pka} diff --git a/validation/host/hal_st_validation/groups/i2c.py b/validation/host/hal_st_validation/groups/i2c.py new file mode 100644 index 00000000..38f66907 --- /dev/null +++ b/validation/host/hal_st_validation/groups/i2c.py @@ -0,0 +1,307 @@ +"""I2C groups of validation/PROTOCOL.md: `i2c` (master, `hal::I2cStm`), `i2cs` (the LL target scaffold on another +instance) and `eeprom` (`eeprom.attach`/`eeprom.detach` of validation/firmware/EepromGroup.cpp around EMIL's +`eeprom.write`/`eeprom.read`/`eeprom.erase` over the `I2cEepromStm` adapter). + +Sources: validation/firmware/I2cGroup.cpp (`i2c.*` answers within 1000 ms or `ERR timeout`; `EVT i2c index= +hook=notfound|buserror|arblost` precedes the final line of the transfer), I2cTarget.cpp, EepromGroup.cpp and +I2cEepromStm.cpp; EMIL `HilEepromCommands.cpp` (128-byte buffer, 5 s timeout). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from ad3_waveforms_bench.protocol import Event + +from ..patterns import Pattern +from .base import Group, Pin + +__all__ = [ + "DEFAULT_TIMING", + "EEPROM_BUFFER", + "EEPROM_TIMEOUT_S", + "GROUPS", + "I2C_BUFFER", + "I2C_HEX_MAX", + "I2C_TIMEOUT_S", + "TARGET_DUMP_MAX", + "TARGET_LAST_MAX", + "TARGET_REGISTERS", + "TIMING_MASK", + "Eeprom", + "I2c", + "I2cOpen", + "I2cRead", + "I2cTarget", + "I2cWrite", + "Next", + "TargetStatus", +] + +Next = Literal["stop", "restart", "continue"] + +# `hal::I2cStm::Config::timing` on STM32WB/WBA (B.1f): Standard mode for every kernel clock up to 100 MHz. +DEFAULT_TIMING = 0x70B03D3D +# `HAL_I2C_Init` drops TIMINGR bits 24-27 (reserved); `timing=` replies the register as written. +TIMING_MASK = 0xF0FFFFFF +I2C_BUFFER = 1024 +I2C_HEX_MAX = 128 +I2C_TIMEOUT_S = 1.0 +TARGET_REGISTERS = 256 +TARGET_DUMP_MAX = 128 +TARGET_LAST_MAX = 32 +EEPROM_BUFFER = 128 +EEPROM_TIMEOUT_S = 5.0 + + +@dataclass(frozen=True) +class I2cOpen: + timing: int + kernel: int + + +@dataclass(frozen=True) +class I2cWrite: + """`sent` is `numberOfBytesSent` of `hal::I2cMaster::SendData`: the acknowledged data bytes; `result` is + `complete`, `nack` or `buserror`.""" + + sent: int + result: str + + +@dataclass(frozen=True) +class I2cRead: + """`data` (hex output) or `length` and `crc` (`out=crc`) when `result` is complete, else only `result`.""" + + result: str + data: bytes | None = None + length: int | None = None + crc: str | None = None + + +@dataclass(frozen=True) +class TargetStatus: + rx: int + tx: int + crc: str + writes: int + reads: int + stops: int + nacked: int + errors: int + last: bytes + + +class I2c(Group): + prefix = "i2c" + + def _timeout(self, cmd_timeout: float | None) -> float: + return cmd_timeout or self._fw.terminal.timeout + I2C_TIMEOUT_S + + def open( + self, + index: int, + scl: Pin, + sda: Pin, + freq: int | None = None, + timing: int | None = None, + pull: Literal["none", "up"] | None = None, + ) -> I2cOpen: + response = self._cmd( + "open", + index, + scl=self._pin(scl), + sda=self._pin(sda), + freq=freq, + timing=None if timing is None else f"0x{timing:08x}", + pull=pull, + ) + self._fw.track((self.prefix, index), f"{self.prefix}.close", index) + return I2cOpen(response.as_int("timing"), response.as_int("kernel")) + + def write( + self, + index: int, + address: int, + data: bytes = b"", + next: Next | None = None, + len: int | None = None, + pattern: Pattern | None = None, + seed: int | None = None, + cmd_timeout: float | None = None, + ) -> I2cWrite: + """`data` empty and no `len` is a zero-length write (an address probe).""" + response = self._cmd( + "write", + index, + f"0x{address:02x}", + data, + next=next, + len=len, + pattern=pattern, + seed=seed, + cmd_timeout=self._timeout(cmd_timeout), + ) + return I2cWrite(response.as_int("sent"), response["result"]) + + def read( + self, + index: int, + address: int, + length: int, + next: Next | None = None, + out: Literal["hex", "crc"] | None = None, + cmd_timeout: float | None = None, + ) -> I2cRead: + response = self._cmd("read", index, f"0x{address:02x}", length, next=next, out=out, cmd_timeout=self._timeout(cmd_timeout)) + result = response["result"] + if "crc" in response: + return I2cRead(result, length=response.as_int("len"), crc=response["crc"]) + if "data" in response: + return I2cRead(result, data=response.as_bytes("data")) + return I2cRead(result) + + def close(self, index: int) -> None: + self._cmd("close", index) + self._fw.untrack((self.prefix, index)) + + def events(self, index: int) -> list[Event]: + """The `EVT i2c` lines of `index` received so far, removed from the terminal's queue.""" + return [event for event in self._fw.terminal.drain_events("i2c") if event.as_int("index") == index] + + def hooks(self, index: int) -> list[str]: + """The driver hooks (`notfound`, `buserror`, `arblost`) reported for `index` since the last call.""" + return [event["hook"] for event in self.events(index)] + + +class I2cTarget(Group): + prefix = "i2cs" + + def open( + self, + index: int, + scl: Pin, + sda: Pin, + addr: int | None = None, + mode: Literal["regs", "sink"] | None = None, + timing: int | None = None, + ) -> int: + """Returns the own address.""" + response = self._cmd( + "open", + index, + scl=self._pin(scl), + sda=self._pin(sda), + addr=None if addr is None else f"0x{addr:02x}", + mode=mode, + timing=None if timing is None else f"0x{timing:08x}", + ) + self._fw.track((self.prefix, index), f"{self.prefix}.close", index) + return response.as_int("addr") + + def cfg( + self, + index: int, + nack: int | None = None, + addrnack: bool | None = None, + stretch: int | None = None, + stretchat: int | None = None, + fault: Literal["none", "stop"] | None = None, + faultat: int | None = None, + pattern: Pattern | None = None, + seed: int | None = None, + ) -> None: + """Replaces the whole behaviour: options left out take their defaults (`nack=0 addrnack=0 stretch=0 + stretchat=0 fault=none faultat=1 pattern=inc seed=0`).""" + self._cmd( + "cfg", + index, + nack=nack, + addrnack=addrnack, + stretch=stretch, + stretchat=stretchat, + fault=fault, + faultat=faultat, + pattern=pattern, + seed=seed, + ) + + def status(self, index: int, clear: bool | None = None) -> TargetStatus: + response = self._cmd("status", index, clear=clear) + return TargetStatus( + rx=response.as_int("rx"), + tx=response.as_int("tx"), + crc=response["crc"], + writes=response.as_int("writes"), + reads=response.as_int("reads"), + stops=response.as_int("stops"), + nacked=response.as_int("nacked"), + errors=response.as_int("errors"), + last=response.as_bytes("last"), + ) + + def regs(self, index: int, offset: int, data: bytes) -> None: + self._cmd("regs", index, offset, data) + + def dump(self, index: int, offset: int, length: int) -> bytes: + return self._cmd("dump", index, offset, length).as_bytes("data") + + def close(self, index: int) -> None: + self._cmd("close", index) + self._fw.untrack((self.prefix, index)) + + +class Eeprom(Group): + prefix = "eeprom" + + def _timeout(self, cmd_timeout: float | None) -> float: + return cmd_timeout or self._fw.terminal.timeout + EEPROM_TIMEOUT_S + + def attach( + self, + index: int, + scl: Pin, + sda: Pin, + addr: int | None = None, + size: int | None = None, + page: int | None = None, + abytes: Literal[1, 2] | None = None, + freq: int | None = None, + wcycle: int | None = None, + ) -> None: + self._cmd( + "attach", + index, + scl=self._pin(scl), + sda=self._pin(sda), + addr=None if addr is None else f"0x{addr:02x}", + size=size, + page=page, + abytes=abytes, + freq=freq, + wcycle=wcycle, + ) + self._fw.track((self.prefix,), f"{self.prefix}.detach") + + def detach(self, cmd_timeout: float | None = None) -> None: + self._cmd("detach", cmd_timeout=cmd_timeout) + self._fw.untrack((self.prefix,)) + + def write(self, address: int, data: bytes, cmd_timeout: float | None = None) -> None: + """EMIL `eeprom.write`: at most `EEPROM_BUFFER` bytes, written page by page with ACK polling.""" + self._cmd("write", address, data, cmd_timeout=self._timeout(cmd_timeout)) + + def read(self, address: int, length: int, cmd_timeout: float | None = None) -> bytes: + return self._cmd("read", address, length, cmd_timeout=self._timeout(cmd_timeout)).as_bytes("data") + + def erase(self, cmd_timeout: float | None = None) -> None: + """Every byte of the attached `size` to 0xFF, a page at a time.""" + self._cmd("erase", cmd_timeout=self._timeout(cmd_timeout)) + + def errors(self) -> list[Event]: + """The `EVT eeprom error= address=` lines received so far.""" + return self._fw.terminal.drain_events("eeprom") + + +GROUPS: dict[str, type[Group]] = {"i2c": I2c, "i2cs": I2cTarget, "eeprom": Eeprom} diff --git a/validation/host/hal_st_validation/groups/io.py b/validation/host/hal_st_validation/groups/io.py new file mode 100644 index 00000000..7d8668e0 --- /dev/null +++ b/validation/host/hal_st_validation/groups/io.py @@ -0,0 +1,184 @@ +"""I/O groups of validation/PROTOCOL.md: `sgpio` (`hal::SynchronousOutputPinStm`, `hal::SmallPeripheralPinStm`, +`hal::MultiGpioPinStm` with `hal::MultiPeripheralPinStm`) and `clock` (the hal-st default clock configuration, MCO and +HSI48 scaffolding on STM32WB55), with the expectations of the clock and unique device ID tests (pure functions). + +The send-only UART (`hal::SynchronousUartStmSendOnly`) is `fw.uart.open(..., sendonly=True)`. + +Sources: `SyncGpioGroup.cpp`, `ClockGroup.cpp`; the unique device ID layout of RM0434 / RM0493 ("Unique device ID +register (96 bits)"): bytes 0-3 the X and Y wafer coordinates, byte 4 the wafer number, bytes 5-11 the lot number in +ASCII. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal + +from ad3_waveforms_bench import analysis + +from .base import Group, Pin + +if TYPE_CHECKING: + from ..firmware import Firmware + +__all__ = [ + "GROUPS", + "MCO_DIVIDERS", + "MCO_SOURCES", + "SPEEDS", + "UID_BYTES", + "UID_LOT", + "Clock", + "ClockInfo", + "SyncGpio", + "Uid", + "edge_fit_frequency", + "parse_uid", +] + +Speed = Literal["low", "medium", "fast", "high"] +McoSource = Literal["sysclk", "hse", "hsi", "lse", "hsi48", "off"] + +SPEEDS: tuple[Speed, ...] = ("low", "medium", "fast", "high") +MCO_SOURCES: tuple[McoSource, ...] = ("sysclk", "hse", "hsi", "lse", "hsi48", "off") +MCO_DIVIDERS = (1, 2, 4, 8, 16) +UID_BYTES = 12 +UID_LOT = slice(5, 12) +_CLOCK_FLAGS = ("hse", "lse", "hsi", "hsi48", "pll") + + +class SyncGpio(Group): + """`sgpio`: one owner for every pin it holds; `release` of any pin of a `multi` set releases the whole set.""" + + prefix = "sgpio" + + def __init__(self, firmware: Firmware) -> None: + super().__init__(firmware) + self._multi: tuple[str, ...] = () + + def out(self, pin: Pin, value: int | bool, od: bool | None = None, speed: Speed | None = None) -> None: + """The first use constructs the pin; `od` and `speed` rebuild it when they change, else they stay.""" + pin = self._fw.pin(pin) + self._cmd("out", pin, int(bool(value)), od=od, speed=speed) + self._fw.track(("sgpio", pin), "sgpio.release", pin) + + def latch(self, pin: Pin) -> int: + return self._cmd("latch", self._fw.pin(pin)).as_int("value") + + def af(self, pin: Pin, timer: int | None = None, ch: int | None = None, af: int | None = None) -> int: + """Mux `pin` to channel `ch` of TIM`timer` (the alternate function of the pinout table) or to the raw `af`; + returns the alternate function number.""" + pin = self._fw.pin(pin) + response = self._cmd("af", pin, timer=timer, ch=ch, af=af) + self._fw.track(("sgpio", pin), "sgpio.release", pin) + return response.as_int("af") + + def multi(self, pins: Sequence[Pin], timer: int, ch: int | None = None) -> None: + resolved = tuple(self._fw.pin(pin) for pin in pins) + self._cmd("multi", list(resolved), timer=timer, ch=ch) + self._multi = resolved + self._fw.track(("sgpio", "multi"), "sgpio.release", resolved[0]) + + def release(self, pin: Pin) -> None: + pin = self._fw.pin(pin) + self._cmd("release", pin) + self._fw.untrack(("sgpio", pin)) + if pin in self._multi: + self._multi = () + self._fw.untrack(("sgpio", "multi")) + + +@dataclass(frozen=True) +class ClockInfo: + """`clock.info`: bus clocks in Hz, oscillator ready flags, the RNG kernel clock selection and (STM32WB) the + CLK48 source; `pclk7` and the `hsi48` flag only where the MCU has them.""" + + sysclk: int + hclk: int + pclk1: int + pclk2: int + pclk7: int | None + flags: dict[str, int] + rngsel: str + clk48: str | None + raw: str + + +class Clock(Group): + prefix = "clock" + + def info(self) -> ClockInfo: + response = self._cmd("info") + pclk7 = response.get("pclk7") + return ClockInfo( + sysclk=response.as_int("sysclk"), + hclk=response.as_int("hclk"), + pclk1=response.as_int("pclk1"), + pclk2=response.as_int("pclk2"), + pclk7=None if pclk7 is None else int(pclk7), + flags={flag: response.as_int(flag) for flag in _CLOCK_FLAGS if flag in response}, + rngsel=response["rngsel"], + clk48=response.get("clk48"), + raw=response.raw, + ) + + def mco(self, source: McoSource, div: int | None = None) -> None: + """STM32WB55 only; the pin is muxed separately (`sgpio.af PA8 af=0`). Anything but `off` is undone by + `close_all`.""" + self._cmd("mco", source, div=div) + if source == "off": + self._fw.untrack(("clock", "mco")) + else: + self._fw.track(("clock", "mco"), "clock.mco", "off") + + def hsi48(self, on: bool) -> None: + """STM32WB55 only: switch HSI48 and wait for its ready flag; `close_all` switches it back on.""" + self._cmd("hsi48", int(bool(on))) + if on: + self._fw.untrack(("clock", "hsi48")) + else: + self._fw.track(("clock", "hsi48"), "clock.hsi48", 1) + + +def edge_fit_frequency(bits: Sequence[int], rate: float) -> float: + """Frequency of a periodic signal from a least-squares line through its rising edge times (edge k at t0 + k T): + far more precise than first-to-last edge counting for long captures, and robust to the sample quantisation.""" + times = [edge.index / rate for edge in analysis.edges(bits) if edge.rising] + if len(times) < 3: + raise ValueError("fewer than three rising edges") + rough = (times[-1] - times[0]) / (len(times) - 1) + periods = [round((time - times[0]) / rough) for time in times] + count = len(times) + mean_k = sum(periods) / count + mean_t = sum(times) / count + covariance = sum((k - mean_k) * (t - mean_t) for k, t in zip(periods, times)) + variance = sum((k - mean_k) ** 2 for k in periods) + return variance / covariance + + +@dataclass(frozen=True) +class Uid: + raw: bytes + x: int + y: int + wafer: int + lot: bytes + + +def parse_uid(text: str) -> Uid: + """`info uid=<24 hex digits>`: the 96-bit unique device ID as the firmware prints its bytes, lowest address + first (X in bytes 0-1, Y in bytes 2-3, little endian).""" + raw = bytes.fromhex(text) + if len(raw) != UID_BYTES: + raise ValueError(f"uid has {len(raw)} bytes, not {UID_BYTES}") + return Uid( + raw=raw, + x=int.from_bytes(raw[0:2], "little"), + y=int.from_bytes(raw[2:4], "little"), + wafer=raw[4], + lot=raw[UID_LOT], + ) + + +GROUPS: dict[str, type[Group]] = {"sgpio": SyncGpio, "clock": Clock} diff --git a/validation/host/hal_st_validation/groups/qspi.py b/validation/host/hal_st_validation/groups/qspi.py new file mode 100644 index 00000000..cd20e499 --- /dev/null +++ b/validation/host/hal_st_validation/groups/qspi.py @@ -0,0 +1,235 @@ +"""QUADSPI group of validation/PROTOCOL.md (STM32WB55 only): `qspi` over `hal::QuadSpiStm` (`variant=poll`), +`hal::QuadSpiStmDma` (`variant=dma`) and `hal::SingleSpeedQuadSpiStmDma` as `hal::SpiMaster` (`variant=spi`), plus +the decoding of a logic-analyzer capture of the bus (pure functions). + +Sources: validation/firmware/QuadSpiGroup.cpp (one instance, QUADSPI 1, pins of the board profile; every command +answers within 2000 ms or `ERR timeout`), `QuadSpiStm::CreateConfig` and `QuadSpiStmDma::SetConfig` (phases in the +order instruction, address, alternate bytes, dummy cycles, data; `lines` applies to every phase), EMIL +`hal::QuadSpi::AddressToVector` (address and alternate bytes go out most significant byte first), clock mode 0 +(CLK idles low, the IO lines are sampled on the rising edge). +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Literal + +from .base import Group + +__all__ = [ + "GROUPS", + "QSPI_BUFFER", + "QSPI_DUMMY_MAX", + "QSPI_HEX_MAX", + "QSPI_INDEX", + "QSPI_PRESCALER_MAX", + "QSPI_REPEAT_MAX", + "QSPI_SIZE_MAX", + "QSPI_TIMEOUT_S", + "QuadSpi", + "QuadSpiReply", + "Variant", + "qspi_bytes", + "qspi_clock", + "qspi_frame", + "qspi_samples", +] + +Variant = Literal["poll", "dma", "spi"] +Lines = Literal[1, 4] + +QSPI_INDEX = 1 +QSPI_BUFFER = 256 +QSPI_HEX_MAX = 128 +QSPI_PRESCALER_MAX = 255 +QSPI_SIZE_MAX = 32 +QSPI_DUMMY_MAX = 31 +QSPI_REPEAT_MAX = 8 +QSPI_TIMEOUT_S = 2.0 + + +def qspi_clock(kernel: int, prescaler: int) -> int: + """`clk=` of `qspi.open`: the QUADSPI kernel clock (HCLK4) divided by `prescaler + 1`.""" + return kernel // (prescaler + 1) + + +def qspi_frame(instr: int | None = None, address: bytes = b"", alternate: bytes = b"", data: bytes = b"") -> bytes: + """The bytes of one command on the bus, without the dummy cycles: instruction, address, alternate bytes, data.""" + return (b"" if instr is None else bytes([instr])) + address + alternate + data + + +def qspi_samples(clk: Sequence[int], ncs: Sequence[int], ios: Sequence[Sequence[int]]) -> list[list[int]]: + """One list per chip-select-low region: the IO levels at each rising CLK edge, as nibbles with IO0 in bit 0.""" + frames: list[list[int]] = [] + current: list[int] | None = None + for index in range(1, len(clk)): + if ncs[index]: + if current: + frames.append(current) + current = None + continue + if current is None: + current = [] + if clk[index] and not clk[index - 1]: + current.append(sum((io[index] & 1) << bit for bit, io in enumerate(ios))) + if current: + frames.append(current) + return frames + + +def qspi_bytes(samples: Sequence[int], lines: int, line: int = 0) -> bytes: + """Bytes of consecutive samples, most significant bit first: 4 lines carry a nibble per clock (IO3 is bit 3), + 1 line one bit per clock on IO `line` (IO0 out, IO1 in). A partial last byte is dropped.""" + if lines == 4: + return bytes((samples[i] << 4 | samples[i + 1]) & 0xFF for i in range(0, len(samples) - 1, 2)) + if lines != 1: + raise ValueError(f"lines must be 1 or 4: {lines}") + result = bytearray() + for start in range(0, len(samples) - 7, 8): + value = 0 + for sample in samples[start : start + 8]: + value = value << 1 | (sample >> line) & 1 + result.append(value) + return bytes(result) + + +@dataclass(frozen=True) +class QuadSpiReply: + """A write answers `flevel` (the FIFO level sampled in the completion callback, B.15), a read its `data`, or + with `out=crc` its length and CRC-32 (`crc` as 8 hex digits).""" + + data: bytes | None = None + flevel: int | None = None + crc: str | None = None + + +class QuadSpi(Group): + prefix = "qspi" + + def _timeout(self, cmd_timeout: float | None) -> float: + return cmd_timeout or self._fw.terminal.timeout + QSPI_TIMEOUT_S + + def open( + self, + index: int, + variant: Variant | None = None, + prescaler: int | None = None, + size: int | None = None, + ) -> int: + """Returns `clk`, the QUADSPI clock; `size` is the flash size as log2 of bytes (DCR FSIZE + 1).""" + response = self._cmd("open", index, variant=variant, prescaler=prescaler, size=size) + self._fw.track((self.prefix, index), f"{self.prefix}.close", index) + return response.as_int("clk") + + def cmd( + self, + index: int, + instr: int | None = None, + addr: int | None = None, + abytes: int | None = None, + alt: int | None = None, + altbytes: int | None = None, + dummy: int | None = None, + lines: Lines | None = None, + tx: bytes | None = None, + len: int | None = None, + pattern: str | None = None, + seed: int | None = None, + rx: int | None = None, + out: Literal["hex", "crc"] | None = None, + repeat: int | None = None, + cmd_timeout: float | None = None, + ) -> QuadSpiReply: + """`hal::QuadSpi::SendData` (`tx`, or `len`/`pattern`/`seed`, or no data phase) or `ReceiveData` (`rx`); + `repeat` issues the write again from each completion callback. Every phase is optional.""" + response = self._cmd( + "cmd", + index, + instr=instr, + addr=addr, + abytes=abytes, + alt=alt, + altbytes=altbytes, + dummy=dummy, + lines=lines, + tx=tx, + len=len, + pattern=pattern, + seed=seed, + rx=rx, + out=out, + repeat=repeat, + cmd_timeout=self._timeout(cmd_timeout), + ) + if rx is None: + return QuadSpiReply(flevel=response.as_int("flevel")) + if "crc" in response: + return QuadSpiReply(crc=response["crc"]) + return QuadSpiReply(data=response.as_bytes("data")) + + def poll( + self, + index: int, + match: int, + mask: int, + size: int | None = None, + instr: int | None = None, + addr: int | None = None, + abytes: int | None = None, + alt: int | None = None, + altbytes: int | None = None, + dummy: int | None = None, + lines: Lines | None = None, + cmd_timeout: float | None = None, + ) -> None: + """`hal::QuadSpi::PollStatus`: answers once the status bytes, masked with `mask`, equal `match`.""" + self._cmd( + "poll", + index, + instr=instr, + addr=addr, + abytes=abytes, + alt=alt, + altbytes=altbytes, + dummy=dummy, + lines=lines, + match=match, + mask=mask, + size=size, + cmd_timeout=self._timeout(cmd_timeout), + ) + + def xfer( + self, + index: int, + tx: bytes = b"", + rx: int | None = None, + len: int | None = None, + pattern: str | None = None, + seed: int | None = None, + repeat: int | None = None, + cmd_timeout: float | None = None, + ) -> QuadSpiReply: + """`hal::SingleSpeedQuadSpiStmDma::SendAndReceive` (`variant=spi`), half duplex: `tx` (or `len`) or `rx`.""" + response = self._cmd( + "xfer", + index, + tx, + len=len, + pattern=pattern, + seed=seed, + rx=rx, + repeat=repeat, + cmd_timeout=self._timeout(cmd_timeout), + ) + if rx is None: + return QuadSpiReply(flevel=response.as_int("flevel")) + return QuadSpiReply(data=response.as_bytes("rx")) + + def close(self, index: int) -> None: + self._cmd("close", index) + self._fw.untrack((self.prefix, index)) + + +GROUPS: dict[str, type[Group]] = {"qspi": QuadSpi} diff --git a/validation/host/hal_st_validation/groups/spis.py b/validation/host/hal_st_validation/groups/spis.py new file mode 100644 index 00000000..ed72b133 --- /dev/null +++ b/validation/host/hal_st_validation/groups/spis.py @@ -0,0 +1,85 @@ +"""SPI slave group of validation/PROTOCOL.md: `spis` (`hal::SpiSlaveStmDma`, validation/firmware/SpiSlaveGroup.cpp). + +`spis.arm` hands the driver one transfer and answers at once; `spis.result` answers when that transfer is done, or +`done=0` after `wait` ms (default 1000) with the transfer still armed. A second `spis.result` while one waits +answers `ERR busy`; `spis.cancel` and `spis.close` answer a waiting `spis.result` with `done=0` first. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from ad3_waveforms_bench.protocol import Response +from ad3_waveforms_bench.terminal import PendingCommand + +from ..patterns import Pattern +from .base import Group, Pin + +__all__ = ["GROUPS", "SPIS_BUFFER", "SPIS_HEX_MAX", "SPIS_WAIT_DEFAULT_MS", "SPIS_WAIT_MAX_MS", "SpiSlave", "SpisResult"] + +SPIS_BUFFER = 1024 +SPIS_HEX_MAX = 128 +SPIS_WAIT_DEFAULT_MS = 1000 +SPIS_WAIT_MAX_MS = 10000 + +Output = Literal["hex", "crc"] + + +@dataclass(frozen=True) +class SpisResult: + """`done` with the received bytes (`rx`, hex output) or their `length` and `crc` (`out=crc`); not done, none.""" + + done: bool + rx: bytes | None = None + length: int | None = None + crc: str | None = None + + @classmethod + def parse(cls, response: Response) -> SpisResult: + if not response.as_bool("done"): + return cls(False) + if "crc" in response: + return cls(True, length=response.as_int("len"), crc=response["crc"]) + return cls(True, rx=response.as_bytes("rx")) + + +class SpiSlave(Group): + prefix = "spis" + + def _timeout(self, wait: int | None, cmd_timeout: float | None) -> float: + return cmd_timeout or self._fw.terminal.timeout + (SPIS_WAIT_DEFAULT_MS if wait is None else wait) / 1000 + + def open(self, index: int, clk: Pin, miso: Pin, mosi: Pin, nss: Pin) -> None: + self._cmd("open", index, clk=self._pin(clk), miso=self._pin(miso), mosi=self._pin(mosi), nss=self._pin(nss)) + self._fw.track((self.prefix, index), f"{self.prefix}.close", index) + + def arm( + self, + index: int, + tx: bytes = b"", + rx: int | None = None, + len: int | None = None, + pattern: Pattern | None = None, + seed: int | None = None, + ) -> None: + """Full duplex: `tx` (or `len=`) with `rx` left out or equal; send only: `rx=0`; receive only: no `tx`, `rx=n`.""" + self._cmd("arm", index, bytes(tx), rx=rx, len=len, pattern=pattern, seed=seed) + + def result(self, index: int, wait: int | None = None, out: Output | None = None, cmd_timeout: float | None = None) -> SpisResult: + return SpisResult.parse(self._cmd("result", index, wait=wait, out=out, cmd_timeout=self._timeout(wait, cmd_timeout))) + + def begin_result(self, index: int, wait: int | None = None, out: Output | None = None) -> PendingCommand: + """`spis.result` without waiting for its final line (`SpisResult.parse(settle(pending))`).""" + return self.begin("result", index, wait=wait, out=out, cmd_timeout=self._timeout(wait, None)) + + def cancel(self, index: int) -> bool: + """True when a transfer was still running.""" + return self._cmd("cancel", index).as_bool("cancelled") + + def close(self, index: int) -> None: + self._cmd("close", index) + self._fw.untrack((self.prefix, index)) + + +GROUPS = {"spis": SpiSlave} diff --git a/validation/host/hal_st_validation/groups/system_ext.py b/validation/host/hal_st_validation/groups/system_ext.py new file mode 100644 index 00000000..23db21c9 --- /dev/null +++ b/validation/host/hal_st_validation/groups/system_ext.py @@ -0,0 +1,281 @@ +"""System groups of validation/PROTOCOL.md: `flash` (`hal::FlashInternalStm`/`FlashHomogeneousInternalStm`, +`variant=sync` their `hal::Synchronous*` twins, on STM32WB55 `variant=coord` `hal::FlashCoordinatedWithWirelessStack`) +over the scratch region of the board, `hsem` (STM32WB55: `HAL_HSEM_*`, `hal::SynchronousHardwareSemaphoreStm`), `bkp` +(`hal::BackupRamStm`) and `lpm` (`hal::LowPowerModeStm`). All four are stateless; `flash.stack starting` and +`hsem.take` register their undo with `close_all`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +from ad3_waveforms_bench.protocol import Event +from ad3_waveforms_bench.terminal import PendingCommand + +from ..patterns import Pattern +from .base import Group, Pin + +__all__ = [ + "BKP_FILL_STEP", + "GROUPS", + "LAYOUTS", + "VARIANTS", + "BackupRam", + "Flash", + "FlashInfo", + "Hsem", + "HsemStatus", + "LowPower", + "LowPowerWake", + "bkp_fill_value", + "flash_words", + "sector_addresses", + "table_sectors", +] + +Variant = Literal["sync", "async", "coord"] +Layout = Literal["homogeneous", "table"] +StackState = Literal["stopped", "starting", "fus"] +Output = Literal["hex", "crc"] +PowerMode = Literal["sleep", "deep"] + +VARIANTS: tuple[Variant, ...] = ("sync", "async", "coord") +LAYOUTS: tuple[Layout, ...] = ("homogeneous", "table") + +# `layout=table` (FlashGroup.cpp): single pages first, then this page pattern repeated twice, so the multi-page sectors +# keep indices past the image end page. +TABLE_PATTERN = (1, 1, 2, 4) +TABLE_PATTERN_REPEATS = 2 +# `bkp.fill`/`bkp.check` (BackupRamGroup.cpp): word i = seed ^ (step * (i + 1)), 32 bits. +BKP_FILL_STEP = 0x9E3779B9 +_MASK32 = 0xFFFFFFFF + + +def table_sectors(pages: int) -> list[int]: + """Sector sizes in pages of `layout=table` for a region of `pages` pages.""" + pattern = sum(TABLE_PATTERN) + repeats = min(TABLE_PATTERN_REPEATS, pages // pattern) + return [1] * (pages - repeats * pattern) + list(TABLE_PATTERN) * repeats + + +def sector_addresses(sizes: list[int], page: int) -> list[int]: + """Region-relative start address of every sector, plus the region size as the last entry.""" + addresses = [0] + for size in sizes: + addresses.append(addresses[-1] + size * page) + return addresses + + +def flash_words(address: int, length: int, word: int) -> range: + """Indices of the flash words (`word` bytes: 8 on STM32WB55, 16 on STM32WBA55) a write of `length` bytes at + `address` programs.""" + return range(address // word, (address + length + word - 1) // word) + + +def bkp_fill_value(seed: int, index: int) -> int: + return (seed ^ (BKP_FILL_STEP * (index + 1))) & _MASK32 + + +@dataclass(frozen=True) +class FlashInfo: + base: int + sectors: int + size: int + # The first sector `flash.erase`/`flash.write` accept: an unfixed erase cannot reach the running image from it on. + first: int + # The first absolute page past the running image. + image: int + layout: str + raw: str + + +class Flash(Group): + """Addresses are relative to the scratch region; sectors are those of the selected layout.""" + + prefix = "flash" + + def info(self, variant: Variant | None = None, layout: Layout | None = None) -> FlashInfo: + response = self._cmd("info", variant=variant, layout=layout) + return FlashInfo( + base=response.as_int("base"), + sectors=response.as_int("sectors"), + size=response.as_int("size"), + first=response.as_int("first"), + image=response.as_int("image"), + layout=response["layout"], + raw=response.raw, + ) + + def erase( + self, first: int, end: int, variant: Variant | None = None, layout: Layout | None = None, cmd_timeout: float | None = None + ) -> int: + """Erases sectors `first` up to `end` (exclusive); returns `us`.""" + return self._cmd("erase", first, end, variant=variant, layout=layout, cmd_timeout=cmd_timeout).as_int("us") + + def write( + self, + address: int, + data: bytes | None = None, + length: int | None = None, + pattern: Pattern | None = None, + seed: int | None = None, + variant: Variant | None = None, + layout: Layout | None = None, + cmd_timeout: float | None = None, + ) -> int: + """Writes `data`, or `length` bytes the firmware generates (`pattern`, `seed`); returns `us`.""" + return self.begin_write(address, data, length, pattern, seed, variant, layout, cmd_timeout).wait().as_int("us") + + def begin_write( + self, + address: int, + data: bytes | None = None, + length: int | None = None, + pattern: Pattern | None = None, + seed: int | None = None, + variant: Variant | None = None, + layout: Layout | None = None, + cmd_timeout: float | None = None, + ) -> PendingCommand: + payload = data if data is not None else b"" + return self.begin( + "write", address, payload, len=length, pattern=pattern, seed=seed, variant=variant, layout=layout, cmd_timeout=cmd_timeout + ) + + def read(self, address: int, length: int, variant: Variant | None = None, layout: Layout | None = None) -> bytes: + return self._cmd("read", address, length, variant=variant, layout=layout).as_bytes("data") + + def read_crc(self, address: int, length: int, variant: Variant | None = None, layout: Layout | None = None) -> int: + """CRC-32 of `length` bytes (`out=crc`, any length within the region).""" + response = self._cmd("read", address, length, out="crc", variant=variant, layout=layout) + if response.as_int("len") != length: + raise AssertionError(f"read {response['len']} bytes instead of {length}: {response.raw}") + return int(response["crc"], 16) + + def stack(self, state: StackState, layout: Layout | None = None) -> None: + """STM32WB55: `starting` keeps a coordinated driver whose steps wait until `stopped` or `fus`.""" + self._cmd("stack", state, layout=layout) + if state == "starting": + self._fw.track(("flash", "stack"), "flash.stack", "fus") + else: + self._fw.untrack(("flash", "stack")) + + def wait_done(self, timeout: float) -> Event: + """The `EVT flash` of an operation that completed after its `ERR timeout`.""" + return self._fw.terminal.wait_event("flash", timeout=timeout) + + +@dataclass(frozen=True) +class HsemStatus: + locked: bool + core: int + procid: int + + +class Hsem(Group): + """STM32WB55 hardware semaphores 0-31.""" + + prefix = "hsem" + + def take(self, semaphore: int, procid: int, hold: int | None = None) -> None: + """`hold` (ms): an EMIL timer releases it; without, `close_all` does.""" + self._cmd("take", semaphore, procid=procid, hold=hold) + if hold is None: + self._fw.track(("hsem", semaphore, procid), "hsem.release", semaphore, f"procid={procid}") + + def release(self, semaphore: int, procid: int) -> None: + self._cmd("release", semaphore, procid=procid) + self._fw.untrack(("hsem", semaphore, procid)) + + def status(self, semaphore: int) -> HsemStatus: + response = self._cmd("status", semaphore) + return HsemStatus(locked=response.as_bool("locked"), core=response.as_int("core"), procid=response.as_int("procid")) + + def lock(self, semaphore: int, hold: int | None = None, cmd_timeout: float | None = None) -> int: + """Locks through `hal::SynchronousHardwareSemaphoreStm`; with `hold` (us) the scaffold timer holds it that long + first. Returns `waited` (us).""" + return self._cmd("lock", semaphore, hold=hold, cmd_timeout=cmd_timeout).as_int("waited") + + def mine(self, semaphore: int) -> bool: + return self._cmd("mine", semaphore).as_bool("mine") + + +class BackupRam(Group): + prefix = "bkp" + + def info(self) -> int: + return self._cmd("info").as_int("words") + + def write(self, index: int, value: int) -> None: + self._cmd("write", index, f"0x{value:08x}") + + def read(self, index: int) -> int: + return int(self._cmd("read", index)["value"], 16) + + def fill(self, seed: int) -> None: + self._cmd("fill", seed) + + def check(self, seed: int) -> int: + """Words that differ from `bkp_fill_value(seed, i)`.""" + return self._cmd("check", seed).as_int("mismatches") + + +@dataclass(frozen=True) +class LowPowerWake: + woke: str + restored: int + us: int + sleeps: int + raw: str + + +class LowPower(Group): + """`lpm.enter` masks every interrupt but the wake line and the scaffold timer: nothing may be sent to the board + until its final line.""" + + prefix = "lpm" + + def enter( + self, + mode: PowerMode, + wake: Pin | None = None, + edge: Literal["rising", "falling"] | None = None, + marker: Pin | None = None, + timeout: int | None = None, + cmd_timeout: float | None = None, + ) -> LowPowerWake: + return self.begin_enter(mode, wake, edge, marker, timeout, cmd_timeout).wait() + + def begin_enter( + self, + mode: PowerMode, + wake: Pin | None = None, + edge: Literal["rising", "falling"] | None = None, + marker: Pin | None = None, + timeout: int | None = None, + cmd_timeout: float | None = None, + ) -> PendingEnter: + limit = cmd_timeout if cmd_timeout is not None else self._fw.terminal.timeout + (timeout if timeout is not None else 2000) / 1000 + pending = self.begin("enter", mode, wake=self._pin(wake), edge=edge, marker=self._pin(marker), timeout=timeout, cmd_timeout=limit) + return PendingEnter(pending) + + +class PendingEnter: + """An `lpm.enter` whose final line is still to come.""" + + def __init__(self, pending: PendingCommand) -> None: + self.pending = pending + + def wait(self, timeout: float | None = None) -> LowPowerWake: + response = self.pending.wait(timeout) + return LowPowerWake( + woke=response["woke"], + restored=response.as_int("restored"), + us=response.as_int("us"), + sleeps=response.as_int("sleeps"), + raw=response.raw, + ) + + +GROUPS: dict[str, type[Group]] = {"flash": Flash, "hsem": Hsem, "bkp": BackupRam, "lpm": LowPower} diff --git a/validation/host/hal_st_validation/groups/timers.py b/validation/host/hal_st_validation/groups/timers.py new file mode 100644 index 00000000..3427f858 --- /dev/null +++ b/validation/host/hal_st_validation/groups/timers.py @@ -0,0 +1,181 @@ +"""Timer groups of validation/PROTOCOL.md: `tim` (`hal::FreeRunningTimerStm`, `hal::TimerWithInterruptStm`), `tpwm` +(`hal::TimerPwmWithChannels`), `lptim` (`hal::FreeRunningLowPowerTimerStm`, `hal::LowPowerTimerWithInterruptStm`) +and `lptpwm` (`hal::LpTimerPwmWithChannels`, STM32WBA55 only), with the expected waveforms (pure functions). + +Sources: `TimerBaseStm` (hal_st/stm32fxxx/TimerStm.cpp: PSC = prescaler, ARR = period), `PwmChannelGpio::SetDuty` +(TimerPwmStm.cpp: CCR = ARR * duty / 100 in 32 bits, PWM mode 1, so the output is high while CNT < CCR), +`LowPowerTimerBaseStm` (LpTimerStm.cpp: prescaler divider, ARR = period, repetition counter on the WBA LPTIM only). +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Literal + +from .. import expect +from .base import Group, Pin + +__all__ = [ + "GROUPS", + "LPTIM_PERIOD_MAX", + "LPTIM_PRESCALERS", + "LPTIM_REPETITION_MAX", + "TIMER_DISPATCHED_MAX_HZ", + "TIMER_PRESCALER_MAX", + "UPDATE_INTERRUPT_MAX_HZ", + "LpTimer", + "LpTimerPwm", + "Timer", + "TimerCount", + "TimerPwm", + "lptim_update_rate", + "pwm_duty_fraction", + "pwm_high_ticks", + "timer_period_max", + "timer_update_rate", +] + +Irq = Literal["none", "immediate", "dispatched"] +ChannelPin = Pin | None + +TIMER_PRESCALER_MAX = 0xFFFF +LPTIM_PRESCALERS = (1, 2, 4, 8, 16, 32, 64, 128) +LPTIM_PERIOD_MAX = 0xFFFF +LPTIM_REPETITION_MAX = 0xFF +# Above this update rate `irq=dispatched` callbacks coalesce in the event loop (one queued callback at a time). +TIMER_DISPATCHED_MAX_HZ = 1000 +# Above this update rate `tim.open`/`lptim.open` with an update interrupt are `ERR range`: the interrupt runs on every +# update in both irq modes and would starve the event loop. +UPDATE_INTERRUPT_MAX_HZ = 100_000 + + +def timer_period_max(timer: int) -> int: + """`period` (ARR) of `tim.open`/`tpwm.open`: 16 bits, 32 bits on TIM2.""" + return expect.timer_counter_max(timer) + + +def timer_update_rate(timclk: int, prescaler: int, period: int) -> float: + """Update events per second of a TIM: one per `period + 1` counter ticks of `timclk / (prescaler + 1)`.""" + return timclk / (prescaler + 1) / (period + 1) + + +def lptim_update_rate(lptimclk: int, divider: int, period: int, repetition: int = 0) -> float: + """Update events per second of an LPTIM: one per `period + 1` ticks of `lptimclk / divider`, and on the WBA + LPTIM only every `repetition + 1` periods.""" + return lptimclk / divider / (period + 1) / (repetition + 1) + + +def pwm_high_ticks(period: int, percent: int) -> int: + """`SetDuty`: CCR = ARR * percent / 100 computed in 32 bits (it wraps above ARR 42949672 on TIM2).""" + return ((period * percent) & 0xFFFFFFFF) // 100 + + +def pwm_duty_fraction(period: int, percent: int) -> float: + """High fraction of a channel: high while the counter is below CCR, out of `period + 1` ticks; 100 % keeps one + low tick (CCR = ARR).""" + return min(pwm_high_ticks(period, percent), period + 1) / (period + 1) + + +@dataclass(frozen=True) +class TimerCount: + cnt: int + irqs: int + + +class _Counter(Group): + def start(self, index: int) -> None: + self._cmd("start", index) + + def stop(self, index: int) -> None: + self._cmd("stop", index) + + def count(self, index: int) -> TimerCount: + response = self._cmd("count", index) + return TimerCount(cnt=response.as_int("cnt"), irqs=response.as_int("irqs")) + + def close(self, index: int) -> None: + self._cmd("close", index) + self._fw.untrack((self.prefix, index)) + + def _track(self, index: int) -> None: + self._fw.track((self.prefix, index), f"{self.prefix}.close", index) + + +class Timer(_Counter): + prefix = "tim" + + def open( + self, + timer: int, + prescaler: int | None = None, + period: int | None = None, + irq: Irq | None = None, + mode: Literal["up", "down"] | None = None, + pin: Pin | None = None, + ) -> int: + """Returns `timclk`, the timer kernel clock. `pin` toggles on every update interrupt (not with `irq="none"`); + `mode="down"` needs `irq="none"`.""" + response = self._cmd("open", timer, prescaler=prescaler, period=period, irq=irq, mode=mode, pin=self._pin(pin)) + self._track(timer) + return response.as_int("timclk") + + +class LpTimer(_Counter): + prefix = "lptim" + + def open( + self, + index: int, + period: int | None = None, + prescaler: int | None = None, + irq: Irq | None = None, + rep: int | None = None, + pin: Pin | None = None, + ) -> int: + """Returns `lptimclk`, the LPTIM kernel clock. `prescaler` is the divider (1, 2, 4, ..., 128); `rep` needs the + WBA LPTIM.""" + response = self._cmd("open", index, period=period, prescaler=prescaler, irq=irq, rep=rep, pin=self._pin(pin)) + self._track(index) + return response.as_int("lptimclk") + + +class _ChannelPwm(Group): + clock_key = "" + + def open(self, index: int, pins: Sequence[ChannelPin], prescaler: int | None = None, period: int | None = None) -> int: + """`pins` in channel order, None for an unused channel (`-`); returns the kernel clock.""" + entries = ["-" if pin is None else self._fw.pin(pin) for pin in pins] + response = self._cmd("open", index, pins=entries, prescaler=prescaler, period=period) + self._fw.track((self.prefix, index), f"{self.prefix}.close", index) + return response.as_int(self.clock_key) + + def duty(self, index: int, channel: int, percent: int) -> None: + self._cmd("duty", index, channel, percent) + + def pulse(self, index: int, channel: int, on: int, period: int) -> None: + """`SetPulse`: CCR of `channel` = `on`, ARR = `period` for every channel.""" + self._cmd("pulse", index, channel, on, period) + + def start(self, index: int, ch: int | None = None) -> None: + self._cmd("start", index, ch=ch) + + def stop(self, index: int, ch: int | None = None) -> None: + self._cmd("stop", index, ch=ch) + + def close(self, index: int) -> None: + self._cmd("close", index) + self._fw.untrack((self.prefix, index)) + + +class TimerPwm(_ChannelPwm): + prefix = "tpwm" + clock_key = "timclk" + + +class LpTimerPwm(_ChannelPwm): + prefix = "lptpwm" + clock_key = "lptimclk" + + +GROUPS: dict[str, type[Group]] = {"tim": Timer, "tpwm": TimerPwm, "lptim": LpTimer, "lptpwm": LpTimerPwm} diff --git a/validation/host/hal_st_validation/i2c.py b/validation/host/hal_st_validation/i2c.py new file mode 100644 index 00000000..491d7944 --- /dev/null +++ b/validation/host/hal_st_validation/i2c.py @@ -0,0 +1,409 @@ +"""I2C bus helpers for the `i2c`, `i2cs` and `eeprom` groups of validation/PROTOCOL.md: decoding a logic-analyzer +capture of SCL and SDA, the TIMINGR arithmetic of validation/firmware/I2cTiming.cpp (`expected_timing`, bit for bit), +and AD3 helpers that start on a START condition (a logic-analyzer capture, or an SDA pulse for arbitration loss). + +The AD3 helpers pass every argument as a ctypes value, like `ad3_waveforms_bench.instruments.ad3`, so they also work +through `--ad3-remote`. +""" + +from __future__ import annotations + +import time +from collections.abc import Sequence +from ctypes import byref, c_double, c_int, c_ubyte, c_uint +from dataclasses import dataclass, field +from statistics import median +from typing import TYPE_CHECKING, Any, NamedTuple + +from ad3_waveforms_bench.instruments.ad3 import PendingCapture + +if TYPE_CHECKING: + from ad3_waveforms_bench import AnalogDiscovery3 + +__all__ = [ + "BUS_MAX", + "BUS_MIN", + "FAST_MODE", + "STANDARD_MODE", + "I2cByte", + "I2cTransfer", + "ModeLimits", + "PendingScope", + "SclTiming", + "arbitration_window", + "arm_on_start", + "arm_scope", + "bit_times", + "expected_timing", + "i2c_decode", + "inject_on_start", + "mode_limits", + "rise_times", + "scl_timing", + "timing_fields", +] + +BUS_MIN = 20_000 +BUS_MAX = 400_000 +_PICOSECONDS = 10**12 +_FIELD_MAX = 15 +_PERIOD_MAX = 255 +_ANALOG_FILTER_MIN = 50_000 +_ANALOG_FILTER_MAX = 260_000 + + +@dataclass(frozen=True) +class ModeLimits: + """I2C specification limits of one speed mode, in picoseconds (`rise`/`fall` are the characterisation edges + the TIMINGR computation assumes).""" + + hddat_min: int + vddat_max: int + sudat_min: int + lscl_min: int + hscl_min: int + rise: int + fall: int + + @property + def tlow_min(self) -> float: + return self.lscl_min / _PICOSECONDS + + @property + def thigh_min(self) -> float: + return self.hscl_min / _PICOSECONDS + + +STANDARD_MODE = ModeLimits(0, 3_450_000, 250_000, 4_700_000, 4_000_000, 640_000, 20_000) +FAST_MODE = ModeLimits(0, 900_000, 100_000, 1_300_000, 600_000, 250_000, 100_000) + + +def mode_limits(bus_hz: int) -> ModeLimits: + """Standard mode up to 100 kHz, Fast mode above.""" + return STANDARD_MODE if bus_hz <= 100_000 else FAST_MODE + + +def _ceil_div(numerator: int, denominator: int) -> int: + return -(-numerator // denominator) + + +def expected_timing(kernel_hz: int, bus_hz: int) -> int | None: + """TIMINGR for an I2C kernel clock and bus frequency, as `I2cTiming` computes it; None outside 20..400 kHz. + + Integer picoseconds: the first SCLDEL/SDADEL pair per prescaler, the minimal SCLL/SCLH, then the fewest clock + units whose period with a zero rise time is not shorter than 1/bus (extra units go to SCLL first); the shortest + period wins, ties to the smaller prescaler.""" + if kernel_hz <= 0 or not BUS_MIN <= bus_hz <= BUS_MAX: + return None + mode = mode_limits(bus_hz) + clock = _PICOSECONDS // kernel_hz + target = _PICOSECONDS // bus_hz + sdadel_min = max(mode.fall + mode.hddat_min - _ANALOG_FILTER_MIN - 3 * clock, 0) + sdadel_max = max(mode.vddat_max - mode.rise - _ANALOG_FILTER_MAX - 4 * clock, 0) + scldel_min = mode.rise + mode.sudat_min + best: tuple[int, int, int, int, int, int] | None = None + for prescaler in range(_FIELD_MAX + 1): + prescaled = (prescaler + 1) * clock + scldel = next((value for value in range(_FIELD_MAX + 1) if (value + 1) * prescaled >= scldel_min), None) + if scldel is None: + continue + sdadel = next( + (value for value in range(_FIELD_MAX + 1) if sdadel_min <= (value * (prescaler + 1) + 1) * clock <= sdadel_max), + None, + ) + if sdadel is None: + continue + sync = _ANALOG_FILTER_MIN + 2 * clock + scll_min = max(0, (mode.lscl_min - sync) // prescaled) + sclh_min = max(0, _ceil_div(mode.hscl_min - sync, prescaled) - 1) + fixed = 2 * sync + mode.fall + units = max(scll_min + sclh_min + 2, _ceil_div(target - fixed, prescaled)) + extra = units - (scll_min + sclh_min + 2) + scll = scll_min + (extra + 1) // 2 + sclh = sclh_min + extra // 2 + if scll > _PERIOD_MAX or sclh > _PERIOD_MAX: + continue + period = fixed + units * prescaled + if best is None or period < best[0]: + best = (period, prescaler, scldel, sdadel, sclh, scll) + if best is None: + return None + _, prescaler, scldel, sdadel, sclh, scll = best + return prescaler << 28 | scldel << 20 | sdadel << 16 | sclh << 8 | scll + + +def timing_fields(timingr: int) -> dict[str, int]: + """The fields of a TIMINGR value: `presc`, `scldel`, `sdadel`, `sclh`, `scll`.""" + return { + "presc": timingr >> 28 & 0xF, + "scldel": timingr >> 20 & 0xF, + "sdadel": timingr >> 16 & 0xF, + "sclh": timingr >> 8 & 0xFF, + "scll": timingr & 0xFF, + } + + +def bit_times(timingr: int, kernel_hz: int) -> tuple[float, float]: + """(tLOW, tHIGH) in seconds of a master running `timingr` at `kernel_hz` on a bus with zero rise time: each is + (SCLx + 1) prescaled clocks plus the analog filter and the two-clock synchronisation (ST's timing model).""" + fields = timing_fields(timingr) + clock = 1 / kernel_hz + prescaled = (fields["presc"] + 1) * clock + sync = _ANALOG_FILTER_MIN / _PICOSECONDS + 2 * clock + return sync + (fields["scll"] + 1) * prescaled, sync + (fields["sclh"] + 1) * prescaled + + +def arbitration_window(timingr: int, kernel_hz: int) -> tuple[float, float]: + """(delay, width) in seconds of an SDA pulse that starts in the middle of the SCL low phase of the second address + bit after a START and lasts one SCL period, so it covers that bit's high phase: the START hold is one tHIGH, then + bit 1, then half of bit 2's tLOW.""" + low, high = bit_times(timingr, kernel_hz) + return 2 * high + 1.5 * low, low + high + + +@dataclass(frozen=True) +class I2cByte: + value: int + ack: bool + + +@dataclass +class I2cTransfer: + """One address phase and its data bytes, from a START (`restart` when no STOP preceded it) to the next START or + STOP; `start` and `end` are sample indices (the START, and the STOP or the last sampled bit).""" + + restart: bool + address: int + read: bool + address_ack: bool + start: int = 0 + end: int = 0 + data: list[I2cByte] = field(default_factory=list) + stop: bool = False + + @property + def payload(self) -> bytes: + return bytes(byte.value for byte in self.data) + + +def i2c_decode(scl: Sequence[int], sda: Sequence[int]) -> list[I2cTransfer]: + """START/repeated START: SDA falls while SCL stays high; STOP: SDA rises while SCL stays high; a bit is the SDA + level at the first sample of each SCL high phase, nine bits per byte with the acknowledge last (0 = ACK).""" + transfers: list[I2cTransfer] = [] + bits: list[int] = [] + current: I2cTransfer | None = None + in_frame = False + restart = False + started = 0 + for index in range(1, min(len(scl), len(sda))): + if scl[index] and scl[index - 1] and sda[index] != sda[index - 1]: + if current is not None: + current.end = index + current.stop = bool(sda[index]) + transfers.append(current) + if not sda[index]: + restart = in_frame + started = index + in_frame = True + else: + in_frame = False + current, bits = None, [] + continue + if in_frame and scl[index] and not scl[index - 1]: + bits.append(1 if sda[index] else 0) + if len(bits) == 9: + value = int("".join(map(str, bits[:8])), 2) + ack = bits[8] == 0 + if current is None: + current = I2cTransfer(restart, value >> 1, bool(value & 1), ack, start=started) + else: + current.data.append(I2cByte(value, ack)) + current.end = index + bits = [] + if current is not None: + transfers.append(current) + return transfers + + +class SclTiming(NamedTuple): + """`freq` from the median SCL period (rising edge to rising edge); `tlow_min`/`thigh_min` the shortest complete + low and high phases, in seconds.""" + + freq: float + tlow_min: float + thigh_min: float + + +def scl_timing(scl: Sequence[int], rate: float) -> SclTiming: + """SCL clock figures of a capture sampled at `rate`; only phases bounded by edges on both sides count, so the + idle level before the first and after the last edge is ignored.""" + edges = [index for index in range(1, len(scl)) if scl[index] != scl[index - 1]] + lows = [(edges[i + 1] - edges[i]) / rate for i in range(len(edges) - 1) if not scl[edges[i]]] + highs = [(edges[i + 1] - edges[i]) / rate for i in range(len(edges) - 1) if scl[edges[i]]] + rising = [index for index in edges if scl[index]] + periods = [(rising[i + 1] - rising[i]) / rate for i in range(len(rising) - 1)] + if not periods or not lows or not highs: + raise ValueError("fewer than two SCL clock periods in the capture") + return SclTiming(1 / median(periods), min(lows), min(highs)) + + +def _wait(condition: Any, timeout: float, what: str) -> None: + deadline = time.monotonic() + timeout + while not condition(): + if time.monotonic() > deadline: + raise TimeoutError(f"{what} timed out after {timeout} s") + time.sleep(0.001) + + +def _detect_start(ad3: AnalogDiscovery3, scl_dio: int, sda_dio: int) -> None: + """The DigitalIn trigger detector on a START: SDA falling while SCL is high.""" + ad3.api.FDwfDigitalInTriggerSet(ad3.handle, c_uint(0), c_uint(1 << scl_dio), c_uint(0), c_uint(1 << sda_dio)) + + +def arm_on_start( + ad3: AnalogDiscovery3, scl_dio: int, sda_dio: int, rate: float, samples: int, pretrigger: float = 0.1, arm_timeout: float = 2.0 +) -> PendingCapture: + """`LogicAnalyzer.arm` with the trigger on a START condition instead of one edge; returns once armed, so a + transfer started afterwards cannot be missed. `wait()` the result for the capture.""" + analyzer = ad3.logic + buffer = analyzer.buffer_size + if samples > buffer: + raise ValueError(f"{samples} samples exceed the logic analyzer buffer ({buffer})") + api, handle, constants = ad3.api, ad3.handle, ad3.api.constants + divider = max(1, round(analyzer.clock_hz / rate)) + before = int(samples * pretrigger) + api.FDwfDigitalInReset(handle) + api.FDwfDigitalInAcquisitionModeSet(handle, constants.acqmodeSingle) + api.FDwfDigitalInDividerSet(handle, c_uint(divider)) + api.FDwfDigitalInSampleFormatSet(handle, c_int(16)) + api.FDwfDigitalInBufferSizeSet(handle, c_int(samples)) + api.FDwfDigitalInTriggerPositionSet(handle, c_uint(samples - before)) + api.FDwfDigitalInTriggerSourceSet(handle, constants.trigsrcDetectorDigitalIn) + _detect_start(ad3, scl_dio, sda_dio) + api.FDwfDigitalInConfigure(handle, c_int(1), c_int(1)) + waiting = {int(getattr(state, "value", state)) for state in (constants.DwfStateConfig, constants.DwfStatePrefill)} + + def armed() -> bool: + state = c_ubyte() + api.FDwfDigitalInStatus(handle, c_int(0), byref(state)) + return int(state.value) not in waiting + + _wait(armed, arm_timeout, "logic analyzer arm") + return PendingCapture(analyzer, analyzer.clock_hz / divider, samples, before) + + +def inject_on_start(ad3: AnalogDiscovery3, scl_dio: int, sda_dio: int, delay_s: float, width_s: float) -> None: + """Arm one open-drain low pulse on SDA, `width_s` long, `delay_s` after the next START (the DigitalIn trigger + detector starts the pattern generator): a master sending a 1 during the pulse loses arbitration. SDA is released + (high impedance) before and after the pulse; `ad3.pattern.stop()` disarms it.""" + if delay_s < 0 or width_s <= 0: + raise ValueError("delay must not be negative and width must be positive") + api, handle, constants = ad3.api, ad3.handle, ad3.api.constants + clock = ad3.pattern.clock_hz + low, high = c_uint(), c_uint() + api.FDwfDigitalOutCounterInfo(handle, c_int(sda_dio), byref(low), byref(high)) + counter_max = int(high.value) or 0x7FFF + divider = max(1, -(-round(width_s * clock) // counter_max)) + ticks = max(1, round(width_s * clock / divider)) + api.FDwfDigitalOutReset(handle) + _detect_start(ad3, scl_dio, sda_dio) + api.FDwfDigitalInConfigure(handle, c_int(1), c_int(0)) + api.FDwfDigitalOutEnableSet(handle, c_int(sda_dio), c_int(1)) + api.FDwfDigitalOutOutputSet(handle, c_int(sda_dio), constants.DwfDigitalOutOutputOpenDrain) + api.FDwfDigitalOutTypeSet(handle, c_int(sda_dio), constants.DwfDigitalOutTypePulse) + api.FDwfDigitalOutIdleSet(handle, c_int(sda_dio), constants.DwfDigitalOutIdleZet) + api.FDwfDigitalOutDividerSet(handle, c_int(sda_dio), c_uint(divider)) + api.FDwfDigitalOutCounterSet(handle, c_int(sda_dio), c_uint(ticks), c_uint(ticks)) + api.FDwfDigitalOutCounterInitSet(handle, c_int(sda_dio), c_int(0), c_uint(ticks)) + api.FDwfDigitalOutTriggerSourceSet(handle, constants.trigsrcDetectorDigitalIn) + api.FDwfDigitalOutWaitSet(handle, c_double(delay_s)) + api.FDwfDigitalOutRunSet(handle, c_double(width_s)) + api.FDwfDigitalOutRepeatSet(handle, c_uint(1)) + api.FDwfDigitalOutConfigure(handle, c_int(1)) + + +def rise_times(samples: Sequence[float], rate: float, low: float, high: float) -> list[float]: + """The `low` to `high` crossing times (seconds) of every rising edge of an analog capture, interpolated + linearly between samples; an edge still rising at the end of the capture is left out.""" + + def crossing(index: int, level: float) -> float: + before, after = samples[index - 1], samples[index] + return index - 1 + (level - before) / (after - before) + + times: list[float] = [] + start: float | None = None + for index in range(1, len(samples)): + if samples[index - 1] < low <= samples[index]: + start = crossing(index, low) + if start is not None and samples[index - 1] < high <= samples[index]: + times.append((crossing(index, high) - start) / rate) + start = None + elif samples[index] < low: + start = None + return times + + +@dataclass +class PendingScope: + """An armed scope acquisition (`arm_scope`); `wait()` returns the samples (volts) per scope channel.""" + + ad3: AnalogDiscovery3 + channels: tuple[int, ...] + samples: int + + def wait(self, timeout: float = 5.0) -> dict[int, list[float]]: + api, handle, constants = self.ad3.api, self.ad3.handle, self.ad3.api.constants + done = int(getattr(constants.DwfStateDone, "value", constants.DwfStateDone)) + + def finished() -> bool: + state = c_ubyte() + api.FDwfAnalogInStatus(handle, c_int(1), byref(state)) + return int(state.value) == done + + _wait(finished, timeout, "scope acquisition") + result: dict[int, list[float]] = {} + for channel in self.channels: + buffer = (c_double * self.samples)() + api.FDwfAnalogInStatusData(handle, c_int(channel - 1), byref(buffer), c_int(self.samples)) + result[channel] = list(buffer) + return result + + +def arm_scope( + ad3: AnalogDiscovery3, + channels: Sequence[int], + rate: float, + samples: int, + trigger: int, + level: float, + range_v: float = 10.0, + arm_timeout: float = 2.0, +) -> PendingScope: + """A single scope acquisition of `channels` (1/2) that starts on the next rising edge of channel `trigger` + through `level` volts, with the trigger at 10 % of the buffer and no auto trigger; returns once armed.""" + api, handle, constants = ad3.api, ad3.handle, ad3.api.constants + for index in (0, 1): + api.FDwfAnalogInChannelEnableSet(handle, c_int(index), c_int(int(index + 1 in channels))) + for channel in channels: + api.FDwfAnalogInChannelRangeSet(handle, c_int(channel - 1), c_double(range_v)) + api.FDwfAnalogInChannelOffsetSet(handle, c_int(channel - 1), c_double(0.0)) + api.FDwfAnalogInAcquisitionModeSet(handle, constants.acqmodeSingle) + api.FDwfAnalogInFrequencySet(handle, c_double(rate)) + api.FDwfAnalogInBufferSizeSet(handle, c_int(samples)) + api.FDwfAnalogInTriggerAutoTimeoutSet(handle, c_double(0.0)) + api.FDwfAnalogInTriggerSourceSet(handle, constants.trigsrcDetectorAnalogIn) + api.FDwfAnalogInTriggerTypeSet(handle, getattr(constants, "trigtypeEdge", c_int(0))) + api.FDwfAnalogInTriggerChannelSet(handle, c_int(trigger - 1)) + api.FDwfAnalogInTriggerLevelSet(handle, c_double(level)) + api.FDwfAnalogInTriggerConditionSet(handle, constants.DwfTriggerSlopeRise) + api.FDwfAnalogInTriggerPositionSet(handle, c_double(0.4 * samples / rate)) + api.FDwfAnalogInConfigure(handle, c_int(1), c_int(1)) + waiting = {int(getattr(state, "value", state)) for state in (constants.DwfStateConfig, constants.DwfStatePrefill)} + + def armed() -> bool: + state = c_ubyte() + api.FDwfAnalogInStatus(handle, c_int(0), byref(state)) + return int(state.value) not in waiting + + _wait(armed, arm_timeout, "scope arm") + return PendingScope(ad3, tuple(channels), samples) diff --git a/validation/host/hal_st_validation/patterns.py b/validation/host/hal_st_validation/patterns.py new file mode 100644 index 00000000..a3e305ac --- /dev/null +++ b/validation/host/hal_st_validation/patterns.py @@ -0,0 +1,45 @@ +"""Host twin of the firmware-generated payloads (`len=`, `pattern=`, `seed=` of validation/PROTOCOL.md; firmware +`validation/firmware/Payload.cpp`) and of their `crc=` replies. + +`inc`: byte i = (seed + i) & 0xFF; `const`: seed & 0xFF; `prbs`: xorshift32 (x ^= x << 13; x ^= x >> 17; +x ^= x << 5, on 32 bits) from state `seed or 1`, one step per byte, the low byte of the state. +""" + +from __future__ import annotations + +import zlib +from typing import Literal + +Pattern = Literal["inc", "const", "prbs"] +PATTERNS: tuple[Pattern, ...] = ("inc", "const", "prbs") +SEED_MAX = 0xFFFFFFFF +_MASK32 = 0xFFFFFFFF + +# `infra::Crc32` of the firmware (reflected, polynomial 0xEDB88320, initial and final xor 0xFFFFFFFF). +crc32 = zlib.crc32 + + +def generate(length: int, pattern: Pattern = "inc", seed: int = 0) -> bytes: + if length < 0: + raise ValueError(f"length must not be negative: {length}") + if not 0 <= seed <= SEED_MAX: + raise ValueError(f"seed must fit 32 bits: {seed}") + if pattern == "inc": + return bytes((seed + index) & 0xFF for index in range(length)) + if pattern == "const": + return bytes([seed & 0xFF]) * length + if pattern == "prbs": + state = seed or 1 + result = bytearray(length) + for index in range(length): + state ^= (state << 13) & _MASK32 + state ^= state >> 17 + state ^= (state << 5) & _MASK32 + result[index] = state & 0xFF + return bytes(result) + raise ValueError(f"unknown pattern {pattern!r}") + + +def crc_text(data: bytes) -> str: + """The `crc=` field of a reply: CRC-32 as 8 lower-case hex digits.""" + return f"{crc32(data):08x}" diff --git a/validation/host/hal_st_validation/protocol.py b/validation/host/hal_st_validation/protocol.py index c18878eb..aa5ce571 100644 --- a/validation/host/hal_st_validation/protocol.py +++ b/validation/host/hal_st_validation/protocol.py @@ -22,13 +22,16 @@ r"tim(1[0-7]|[1-9])ch[1-3]n", r"tim(1[0-7]|[1-9])bkin", r"qei(1[0-7]|[1-9])(a|b|idx)", - r"lptim[12]in[12]", - r"spi[1-3](clk|miso|mosi|cs)", + r"lptim[12](in|ch)[12]", + r"spi[1-3](clk|miso|mosi|cs|nss)", + r"i2c[1-3](scl|sda)", + r"qspi(clk|ncs|io[0-3])", r"usart[1-3](tx|rx|rts|cts)", r"lpuart1(tx|rx|rts|cts)", r"led[0-7]", r"gpio(1[0-5]|[0-9])", r"sw[1-3]", + r"mco", ) _ALIAS_RE = re.compile("^(?:" + "|".join(_ALIAS_PATTERNS) + ")$") @@ -41,13 +44,16 @@ def _generic_aliases() -> frozenset[str]: names |= {f"tim{timer}ch{channel}n" for channel in range(1, 4)} names |= {f"tim{timer}bkin"} names |= {f"qei{timer}{signal}" for signal in ("a", "b", "idx")} - names |= {f"lptim{index}in{line}" for index in (1, 2) for line in (1, 2)} - names |= {f"spi{index}{signal}" for index in range(1, 4) for signal in ("clk", "miso", "mosi", "cs")} + names |= {f"lptim{index}{kind}{line}" for index in (1, 2) for kind in ("in", "ch") for line in (1, 2)} + names |= {f"spi{index}{signal}" for index in range(1, 4) for signal in ("clk", "miso", "mosi", "cs", "nss")} + names |= {f"i2c{index}{signal}" for index in range(1, 4) for signal in ("scl", "sda")} + names |= {f"qspi{signal}" for signal in ("clk", "ncs", "io0", "io1", "io2", "io3")} names |= {f"usart{index}{signal}" for index in range(1, 4) for signal in ("tx", "rx", "rts", "cts")} names |= {f"lpuart1{signal}" for signal in ("tx", "rx", "rts", "cts")} names |= {f"led{index}" for index in range(8)} names |= {f"gpio{index}" for index in range(16)} names |= {f"sw{index}" for index in range(1, 4)} + names |= {"mco"} assert all(_ALIAS_RE.match(name) for name in names) return frozenset(names) diff --git a/validation/host/hal_st_validation/rngstats.py b/validation/host/hal_st_validation/rngstats.py new file mode 100644 index 00000000..e3667511 --- /dev/null +++ b/validation/host/hal_st_validation/rngstats.py @@ -0,0 +1,96 @@ +"""Statistics of `rng.stats` (validation/PROTOCOL.md) and the pass bounds the RNG tests apply to them. + +The firmware reports, over `n` generated bytes read as one bit stream (each byte most significant bit first): +`ones` (set bits), `runs` (bit transitions + 1), `chisq` (the chi-square of the byte histogram against a uniform +distribution, 255 degrees of freedom, times 1000 rounded down and saturated at 2^32 - 1) and `crc` (zlib CRC-32). +`byte_stats` computes the same from the data, for the fake firmware and to check the firmware's definitions. + +Bounds: monobit |ones - N/2| < 4 sigma with N = 8n bits and sigma = sqrt(N)/2 (NIST SP 800-22 2.1 at p ~ 6e-5); the +byte chi-square below 330.5 (the 0.999 quantile of chi-square with 255 degrees of freedom, p > 0.001); the runs +test of NIST SP 800-22 2.3 (prerequisite |pi - 1/2| < 2/sqrt(N)) with |runs - 2N pi (1 - pi)| below 4 of its +standard deviations 2 sqrt(N) pi (1 - pi). +""" + +from __future__ import annotations + +import math +import zlib +from dataclasses import dataclass + +__all__ = [ + "CHI_SQUARE_LIMIT", + "SIGMAS", + "ByteStats", + "byte_stats", + "chi_square_ok", + "chi_square_x1000", + "failures", + "monobit_ok", + "runs_ok", +] + +CHI_SQUARE_LIMIT = 330.5 +SIGMAS = 4.0 +_UINT32_MAX = 0xFFFFFFFF + + +@dataclass(frozen=True) +class ByteStats: + n: int + ones: int + runs: int + chisq_x1000: int + crc: int + + +def chi_square_x1000(histogram: list[int], n: int) -> int: + """sum((c - n/256)^2 / (n/256)) * 1000, in the firmware's integer arithmetic.""" + total = sum((count * 256 - n) ** 2 for count in histogram) + return min(total * 1000 // (256 * n), _UINT32_MAX) + + +def byte_stats(data: bytes) -> ByteStats: + histogram = [0] * 256 + for byte in data: + histogram[byte] += 1 + bits = 8 * len(data) + stream = int.from_bytes(data, "big") + # Bit i of stream ^ (stream >> 1) is b(i) ^ b(i+1); the top one compares the first bit with nothing. + transitions = ((stream ^ (stream >> 1)) & ((1 << max(bits - 1, 0)) - 1)).bit_count() + return ByteStats( + n=len(data), + ones=stream.bit_count(), + runs=transitions + 1, + chisq_x1000=chi_square_x1000(histogram, len(data)) if data else 0, + crc=zlib.crc32(data), + ) + + +def monobit_ok(n: int, ones: int, sigmas: float = SIGMAS) -> bool: + bits = 8 * n + return abs(ones - bits / 2) < sigmas * math.sqrt(bits) / 2 + + +def chi_square_ok(chisq_x1000: int, limit: float = CHI_SQUARE_LIMIT) -> bool: + return chisq_x1000 < limit * 1000 + + +def runs_ok(n: int, ones: int, runs: int, sigmas: float = SIGMAS) -> bool: + bits = 8 * n + pi = ones / bits + if abs(pi - 0.5) >= 2 / math.sqrt(bits): + return False + spread = 2 * math.sqrt(bits) * pi * (1 - pi) + return abs(runs - 2 * bits * pi * (1 - pi)) < sigmas * spread + + +def failures(n: int, ones: int, runs: int, chisq_x1000: int) -> list[str]: + """The names of the tests the counts fail (empty when random enough).""" + failed = [] + if not monobit_ok(n, ones): + failed.append(f"monobit: {ones} ones in {8 * n} bits") + if not chi_square_ok(chisq_x1000): + failed.append(f"chi-square: {chisq_x1000 / 1000} >= {CHI_SQUARE_LIMIT}") + if not runs_ok(n, ones, runs): + failed.append(f"runs: {runs} runs, {ones} ones in {8 * n} bits") + return failed diff --git a/validation/host/hal_st_validation/spiwords.py b/validation/host/hal_st_validation/spiwords.py new file mode 100644 index 00000000..75c4954f --- /dev/null +++ b/validation/host/hal_st_validation/spiwords.py @@ -0,0 +1,112 @@ +"""SPI frames of 4 to 16 bits: a logic-capture decoder and the frame model of `hal::SpiMasterStmDma` with a +`hal::SpiDataSizeConfiguratorStm` (`spi.open ... dma=1 bits=`). + +`spi_decode_words` decodes like `ad3_waveforms_bench.analysis.spi_decode` but keeps whole words: the bench decoder +masks every word to 8 bits. + +The frame model (`spi_frames`, `spi_bytes`) follows the driver, not the API's byte view: with up to 8 bits every +buffer byte is one frame (its low `bits` bits); above 8 bits `SpiMasterStmDma::SendAndReceive` hands the DMA the +buffer as 16-bit words (SpiMasterStmDma.cpp:109-129, memory width `sizeof(uint16_t)`, DmaStm.hpp:461), so every +frame takes two buffer bytes, little endian, and a buffer needs an even length (the count is bytes / 2, +DmaStm.cpp:816; GPDMA takes the byte count, DmaStm.cpp:812). Received frames come back right aligned, the bits above +the frame zero. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, field + +from ad3_waveforms_bench.analysis import Bits, runs, spi_mode_bits + +__all__ = ["BITS_MAX", "BITS_MIN", "SpiWords", "spi_bytes", "spi_decode_words", "spi_frames", "spi_join_words"] + +BITS_MIN = 4 +BITS_MAX = 16 +_BYTE_BITS = 8 + + +@dataclass +class SpiWords: + """One chip-select-active region (or the whole capture): its words, as wide as `bits`.""" + + mosi: list[int] = field(default_factory=list) + miso: list[int] = field(default_factory=list) + clock_edges: int = 0 + + +def _check_bits(bits: int) -> None: + if not BITS_MIN <= bits <= BITS_MAX: + raise ValueError(f"frame size {bits} outside {BITS_MIN}..{BITS_MAX} bits") + + +def spi_decode_words( + clk: Bits, + mosi: Bits, + miso: Bits | None = None, + cs: Bits | None = None, + mode: int = 0, + bits: int = 8, + msb_first: bool = True, + cs_active_low: bool = True, +) -> list[SpiWords]: + """Decode `bits`-wide words from sampled lines. Each chip-select-active region is one entry, without `cs` the + whole capture is one; a partial word at the end of a region is dropped.""" + if bits < 1: + raise ValueError(f"word size {bits}") + cpol, cpha = spi_mode_bits(mode) + sample_on_rising = cpol == cpha + active = 0 if cs_active_low else 1 + regions = [(0, len(clk))] if cs is None else [(start, start + length) for level, start, length in runs(cs) if level == active] + result: list[SpiWords] = [] + for start, end in regions: + words = SpiWords() + word_mosi = word_miso = 0 + count = 0 + for i in range(max(start, 1), end): + if clk[i] == clk[i - 1] or bool(clk[i]) != sample_on_rising: + continue + words.clock_edges += 1 + bit_mosi = int(mosi[i]) + bit_miso = int(miso[i]) if miso is not None else 0 + if msb_first: + word_mosi = (word_mosi << 1) | bit_mosi + word_miso = (word_miso << 1) | bit_miso + else: + word_mosi |= bit_mosi << count + word_miso |= bit_miso << count + count += 1 + if count == bits: + words.mosi.append(word_mosi) + words.miso.append(word_miso) + word_mosi = word_miso = 0 + count = 0 + if words.clock_edges: + result.append(words) + return result + + +def spi_join_words(regions: Sequence[SpiWords]) -> tuple[list[int], list[int]]: + """The MOSI and MISO words of every region, in order.""" + return [word for region in regions for word in region.mosi], [word for region in regions for word in region.miso] + + +def spi_frames(data: bytes, bits: int) -> list[int]: + """The frames `SpiMasterStmDma` clocks for the buffer `data` with frames of `bits` bits.""" + _check_bits(bits) + mask = (1 << bits) - 1 + if bits <= _BYTE_BITS: + return [byte & mask for byte in data] + if len(data) % 2: + raise ValueError(f"frames of {bits} bits take two buffer bytes each: {len(data)} bytes") + return [int.from_bytes(data[i : i + 2], "little") & mask for i in range(0, len(data), 2)] + + +def spi_bytes(frames: Sequence[int], bits: int) -> bytes: + """The receive buffer `SpiMasterStmDma` fills with `frames` of `bits` bits (the inverse of `spi_frames` for + the bits a frame carries).""" + _check_bits(bits) + mask = (1 << bits) - 1 + if bits <= _BYTE_BITS: + return bytes(frame & mask for frame in frames) + return b"".join((frame & mask).to_bytes(2, "little") for frame in frames) diff --git a/validation/host/pyproject.toml b/validation/host/pyproject.toml index b09bed68..46502c23 100644 --- a/validation/host/pyproject.toml +++ b/validation/host/pyproject.toml @@ -40,7 +40,10 @@ markers = [ "matrix(path): one parameter dimension per key of the tests. mapping of the board YAML", "constraint(valid): keep a parameter combination only when valid(argname -> value) is true", "family(name): only run on boards of this family (stm32wb55 or stm32wba55)", - "requires_option(tag): only run when enabled with --with ", + "requires_option(tag): only run when enabled with --with (implies uses_option(tag))", + "uses_option(tag): the test handles the wiring of --with : it may resolve the pins that option loads or jumpers", + "conflicts_option(tag): skip when enabled with --with ", + "wiring_options(argname, enabled=True): one case per wiring option of the selected sets (enabled, or left out with enabled=False)", "resets_board: the test resets the board on purpose", "slow: takes more than a few seconds", ] diff --git a/validation/host/tests/conftest.py b/validation/host/tests/conftest.py index b9dbebba..cfd8a56b 100644 --- a/validation/host/tests/conftest.py +++ b/validation/host/tests/conftest.py @@ -7,6 +7,11 @@ `--ad3-serial`, `--no-ad3`, `--fake`, the `ad3` marker and the `ad3` fixture come from the `ad3_waveforms_bench` pytest plugin; `ad3_settings` below feeds it the board file's AD3 section. +`--board-extra` files are deep-merged over the board file (`config.merge_board`). `--with` options must be +offered by a `--wiring-set` and must not exclude each other, else the run stops with a usage error. A pin an +enabled option loads (`loads:`) or ties to another pin (`jumpered:`) is only resolved by `need` for tests marked +`uses_option()` or `requires_option()`; the others skip ("pin X loaded by --with T"). + The board file's `known_gaps` mark the HIL tests that run into a driver gap: a gap that aborts or hangs the firmware skips its tests (run them with `--run-known-gaps`), any other is an expected failure (xfail, not strict). They describe the firmware, so `--fake` ignores them. @@ -14,8 +19,9 @@ from __future__ import annotations +import contextlib import os -from collections.abc import Iterator +from collections.abc import Iterable, Iterator from pathlib import Path from typing import Any @@ -23,17 +29,26 @@ from ad3_waveforms_bench.pytest_plugin import Ad3Settings from ad3_waveforms_bench.terminal import FirmwareTerminal, TerminalError -from hal_st_validation.config import BoardConfig, ConfigError, Wiring, known_gap_outcome, load_board +from hal_st_validation.config import BoardConfig, ChannelKind, ConfigError, Connection, Wiring, known_gap_outcome, load_board from hal_st_validation.firmware import Firmware, quiesce from hal_st_validation.pairwise import DEPTHS, combinations HIL_DIR = Path(__file__).parent / "hil" _BOARD_KEY = pytest.StashKey[BoardConfig]() +_WIRING_KEY = pytest.StashKey[Wiring]() +_OPTION_MARKERS = ("uses_option", "requires_option") def pytest_addoption(parser: pytest.Parser) -> None: group = parser.getgroup("hal-st validation") group.addoption("--board", default=os.environ.get("HAL_ST_BOARD", "nucleo_wb55rg"), help="board YAML name or path") + group.addoption( + "--board-extra", + dest="board_extras", + action="append", + default=[path for path in os.environ.get("HAL_ST_BOARD_EXTRA", "").split(os.pathsep) if path], + help="YAML deep-merged over the board file (repeatable; or HAL_ST_BOARD_EXTRA, a list separated by os.pathsep)", + ) group.addoption("--port", default=os.environ.get("HAL_ST_PORT"), help="firmware terminal serial port; HIL tests skip without it") group.addoption("--baud", type=int, default=None, help="terminal baud rate (default from the board YAML)") group.addoption( @@ -60,12 +75,25 @@ def pytest_addoption(parser: pytest.Parser) -> None: def board_config(config: pytest.Config) -> BoardConfig: if _BOARD_KEY not in config.stash: - board = load_board(config.getoption("--board")) + board = load_board(config.getoption("--board"), config.getoption("board_extras")) board.apply_overrides(config.getoption("overrides")) config.stash[_BOARD_KEY] = board return config.stash[_BOARD_KEY] +def wiring_config(config: pytest.Config) -> Wiring: + """`--wiring-set` with the `--with` options, validated once (`BoardConfig.wiring`).""" + if _WIRING_KEY not in config.stash: + names = [name.strip() for name in config.getoption("--wiring-set").split(",") if name.strip()] + config.stash[_WIRING_KEY] = board_config(config).wiring(names, config.getoption("with_tags")) + return config.stash[_WIRING_KEY] + + +def option_tags(node: pytest.Item) -> frozenset[str]: + """The options a test handles: its `uses_option` and `requires_option` markers.""" + return frozenset(str(marker.args[0]) for name in _OPTION_MARKERS for marker in node.iter_markers(name)) + + def _ids(value: Any) -> str: if isinstance(value, dict): if "name" in value: @@ -125,13 +153,34 @@ def _dimensions(metafunc: pytest.Metafunc, board: BoardConfig) -> tuple[list[_Di return dimensions, None +def _parametrize_options(metafunc: pytest.Metafunc, marker: pytest.Mark) -> None: + """`@pytest.mark.wiring_options("tag")` runs the test once per option the selected wiring sets offer: the + enabled ones (default), or with `enabled=False` the ones left out; each case is marked `uses_option(tag)`.""" + argname = marker.args[0] + enabled = marker.kwargs.get("enabled", True) + try: + wiring = wiring_config(metafunc.config) + except ConfigError as error: + raise pytest.UsageError(str(error)) from error + tags = [tag for tag in wiring.options if (tag in wiring.enabled) == enabled] + if not tags: + reason = "no option enabled with --with" if enabled else "every offered option is enabled" + metafunc.parametrize(argname, [pytest.param(None, marks=pytest.mark.skip(reason=reason))]) + return + metafunc.parametrize(argname, [pytest.param(tag, id=tag, marks=pytest.mark.uses_option(tag)) for tag in tags]) + + def pytest_generate_tests(metafunc: pytest.Metafunc) -> None: """`@pytest.mark.board_params("freq", "pwm.frequencies")` adds one dimension from the board YAML (or from `values=[...]`); for several argnames each item is a mapping (picked by name) or a sequence (positional). `@pytest.mark.matrix("pwm.waveform")` adds one dimension per key of a YAML mapping, named like the argnames. `@pytest.mark.constraint(valid=predicate)` keeps a combination only when `predicate(values)` is true; it receives a possibly partial `argname -> value` mapping and must return False only for impossible assignments. + `@pytest.mark.wiring_options(argname)` parametrizes over wiring options instead (`_parametrize_options`). """ + options = metafunc.definition.get_closest_marker("wiring_options") + if options is not None: + _parametrize_options(metafunc, options) try: board = board_config(metafunc.config) dimensions, skip = _dimensions(metafunc, board) @@ -204,6 +253,11 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item board: BoardConfig | None = board_config(config) except ConfigError: board = None + if board is not None: + try: + wiring_config(config) + except ConfigError as error: + raise pytest.UsageError(str(error)) from error family = None if board is None else board.family for item in items: if HIL_DIR in Path(str(item.path)).parents: @@ -214,9 +268,12 @@ def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item marker = item.get_closest_marker("family") if marker and family and marker.args[0] != family: item.add_marker(pytest.mark.skip(reason=f"only for {marker.args[0]}")) - marker = item.get_closest_marker("requires_option") - if marker and marker.args[0] not in tags: - item.add_marker(pytest.mark.skip(reason=f"enable with --with {marker.args[0]}")) + for marker in item.iter_markers("requires_option"): + if marker.args[0] not in tags: + item.add_marker(pytest.mark.skip(reason=f"enable with --with {marker.args[0]}")) + for marker in item.iter_markers("conflicts_option"): + if marker.args[0] in tags: + item.add_marker(pytest.mark.skip(reason=f"disconnect --with {marker.args[0]}")) @pytest.fixture(scope="session") @@ -225,50 +282,79 @@ def board_cfg(pytestconfig: pytest.Config) -> BoardConfig: @pytest.fixture(scope="session") -def wiring(pytestconfig: pytest.Config, board_cfg: BoardConfig) -> Wiring: - names = [name.strip() for name in pytestconfig.getoption("--wiring-set").split(",") if name.strip()] - return board_cfg.wiring(names, pytestconfig.getoption("with_tags")) +def wiring(pytestconfig: pytest.Config) -> Wiring: + return wiring_config(pytestconfig) class Need: - """Wiring lookups that skip the test when the active wiring lacks the connection.""" + """Wiring lookups that skip the test when the active wiring lacks the connection, or when the pin is loaded by + an enabled option the test does not handle (`allowed`: the tags of its `uses_option`/`requires_option` + markers). Pins an enabled option ties to a channel by a jumper resolve only for the tags in `allowed`.""" - def __init__(self, wiring: Wiring, board: BoardConfig) -> None: + def __init__(self, wiring: Wiring, board: BoardConfig, allowed: Iterable[str] = ()) -> None: self.wiring = wiring self.board = board + self.allowed = frozenset(allowed) - def _found(self, value: int | None, what: str) -> int: - if value is None: - pytest.skip(f"{what} is not wired in wiring set(s) {', '.join(self.wiring.sets) or '(none)'}") - return value + def _blocked(self, pins: Iterable[str]) -> str | None: + for pin in pins: + tags = self.wiring.blocking(pin, self.allowed) + if tags: + return f'pin {pin} loaded by --with {tags[0]} (mark the test uses_option("{tags[0]}") if it handles that wiring)' + return None - def dio(self, pin: str | None = None, role: str | None = None) -> int: + def _lookup(self, kind: ChannelKind, pin: str | None, role: str | None) -> tuple[Connection | None, str | None]: + """The connection and, when the test must leave its pins alone, why.""" resolved = None if pin is None else self.board.resolve_pin(pin) - return self._found(self.wiring.dio(resolved, role), f"DIO for {pin or role}") + connection = self.wiring.connection(kind, resolved, role, self.allowed) + if connection is None: + return None, None + pins = ([resolved] if resolved is not None else []) + list(connection.pins) + return connection, self._blocked(pins) + + def _required(self, kind: ChannelKind, pin: str | None, role: str | None, what: str) -> int: + connection, blocked = self._lookup(kind, pin, role) + if connection is None: + pytest.skip(f"{what} for {pin or role} is not wired in wiring set(s) {', '.join(self.wiring.sets) or '(none)'}") + if blocked is not None: + pytest.skip(blocked) + assert connection is not None + return connection.channel + + def _optional(self, kind: ChannelKind, pin: str | None, role: str | None) -> int | None: + connection, blocked = self._lookup(kind, pin, role) + return None if connection is None or blocked is not None else connection.channel + + def dio(self, pin: str | None = None, role: str | None = None) -> int: + return self._required("dio", pin, role, "DIO") def wavegen(self, pin: str | None = None, role: str | None = None) -> int: - resolved = None if pin is None else self.board.resolve_pin(pin) - return self._found(self.wiring.wavegen(resolved, role), f"wavegen for {pin or role}") + return self._required("wavegen", pin, role, "wavegen") def scope(self, pin: str | None = None, role: str | None = None) -> int: - resolved = None if pin is None else self.board.resolve_pin(pin) - return self._found(self.wiring.scope(resolved, role), f"scope for {pin or role}") + return self._required("scope", pin, role, "scope") def optional_scope(self, pin: str) -> int | None: - return self.wiring.scope(self.board.resolve_pin(pin)) + return self._optional("scope", pin, None) def optional_dio(self, pin: str | None = None, role: str | None = None) -> int | None: - resolved = None if pin is None else self.board.resolve_pin(pin) - return self.wiring.dio(resolved, role) + return self._optional("dio", pin, role) + + def unloaded(self, *pins: str) -> None: + """Skip when an enabled option the test does not handle loads one of `pins` (for pins used without an AD3 + channel).""" + blocked = self._blocked(self.board.resolve_pin(pin) for pin in pins) + if blocked is not None: + pytest.skip(blocked) def tag(self, tag: str) -> None: if not self.wiring.has(tag): pytest.skip(f"enable with --with {tag}") -@pytest.fixture(scope="session") -def need(wiring: Wiring, board_cfg: BoardConfig) -> Need: - return Need(wiring, board_cfg) +@pytest.fixture +def need(request: pytest.FixtureRequest, wiring: Wiring, board_cfg: BoardConfig) -> Need: + return Need(wiring, board_cfg, option_tags(request.node)) @pytest.fixture(scope="session") @@ -334,6 +420,25 @@ def ad3_settings(board_cfg: BoardConfig) -> Ad3Settings: ) +def release_ad3(ad3: Any) -> None: + """Outputs off (`reset_outputs()`), then the weak pulls off and the I2C and SPI protocol engines reset, which + `reset_outputs()` leaves as they are. A device or runtime without them (or the `--fake` API) is fine.""" + ad3.reset_outputs() + with contextlib.suppress(AttributeError, RuntimeError): + ad3.dio.pull() + for name in ("FDwfDigitalI2cReset", "FDwfDigitalSpiReset"): + with contextlib.suppress(AttributeError, RuntimeError): + getattr(ad3.api, name)(ad3.handle) + + +@pytest.fixture +def ad3_released(request: pytest.FixtureRequest) -> None: + """The AD3 outputs and pulls off before the test, so the pins see only the board and its wiring; without + `--no-ad3` this needs the AD3 (the test skips when none is found).""" + if not request.config.getoption("--no-ad3"): + release_ad3(request.getfixturevalue("ad3")) + + @pytest.fixture(autouse=True) def _hil_isolation(request: pytest.FixtureRequest) -> Iterator[None]: """Clear stale events before a HIL test; afterwards close what it opened and reset the AD3 outputs.""" @@ -347,7 +452,7 @@ def _hil_isolation(request: pytest.FixtureRequest) -> Iterator[None]: boots = firmware.terminal.drain_events("boot") failures = firmware.close_all() if "ad3" in request.fixturenames: - request.getfixturevalue("ad3").reset_outputs() + release_ad3(request.getfixturevalue("ad3")) if boots and request.node.get_closest_marker("resets_board") is None: pytest.fail(f"unexpected reset during the test: {boots[-1].raw}") if failures: diff --git a/validation/host/tests/hil/test_aes.py b/validation/host/tests/hil/test_aes.py new file mode 100644 index 00000000..092b6cf1 --- /dev/null +++ b/validation/host/tests/hil/test_aes.py @@ -0,0 +1,114 @@ +"""AES-128 ECB (`hal::SynchronousAes128EcbStm`) through the `aes` group: the FIPS-197 C.1 and SP 800-38A F.1.1 +known answers, decryption, one to five blocks per command, key changes between commands, and every data swapping mode +(`swap=none|half|byte|bit`, AES_CR.DATATYPE) against the model of `crypto_ref.aes_stm`. No wiring. +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import crypto_ref, patterns + +BLOCK = 16 + + +@pytest.fixture +def aes_cfg(board_cfg): + return board_cfg.param("aes") + + +def hex_bytes(value): + """A hex string, or a list of them (the board file splits long vectors into blocks).""" + return bytes.fromhex(value if isinstance(value, str) else "".join(value)) + + +def unpack(vector): + return hex_bytes(vector["key"]), hex_bytes(vector["plaintext"]), hex_bytes(vector["ciphertext"]) + + +def expect_reason(fw, line, reason): + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line + + +@pytest.mark.board_params("vector", "aes.vectors") +def test_known_answer(fw, vector): + key, plaintext, ciphertext = unpack(vector) + assert crypto_ref.aes_ecb_encrypt(key, plaintext) == ciphertext, "the vector itself" + assert fw.aes.enc(key, plaintext) == ciphertext + assert fw.aes.enc(key, plaintext, swap="byte") == ciphertext, "swap=byte is the default" + assert fw.aes.dec(key, ciphertext) == plaintext + + +@pytest.mark.board_params("vector", "aes.vectors") +def test_block_by_block(fw, vector): + """ECB: each block on its own gives the same as all blocks in one command.""" + key, plaintext, ciphertext = unpack(vector) + for offset in range(0, len(plaintext), BLOCK): + assert fw.aes.enc(key, plaintext[offset : offset + BLOCK]) == ciphertext[offset : offset + BLOCK], offset + + +@pytest.mark.board_params("length", "aes.lengths") +def test_round_trip(fw, length): + key = patterns.generate(BLOCK, "prbs", 7) + data = patterns.generate(length, "prbs", length) + ciphertext = fw.aes.enc(key, data) + assert ciphertext == crypto_ref.aes_ecb_encrypt(key, data) + assert fw.aes.dec(key, ciphertext) == data + + +def test_key_change(fw): + """The key of one command does not leak into the next.""" + data = patterns.generate(2 * BLOCK, "inc", 0) + first = patterns.generate(BLOCK, "prbs", 1) + second = patterns.generate(BLOCK, "prbs", 2) + with_first = fw.aes.enc(first, data) + with_second = fw.aes.enc(second, data) + assert with_first == crypto_ref.aes_ecb_encrypt(first, data) + assert with_second == crypto_ref.aes_ecb_encrypt(second, data) + assert with_first != with_second + assert fw.aes.enc(first, data) == with_first + assert fw.aes.dec(second, with_second) == data + + +@pytest.mark.board_params("swap", "aes.swaps") +def test_swap(fw, swap): + """Encryption and decryption with the data swapping of AES_CR.DATATYPE (model: `crypto_ref.aes_stm`).""" + key = patterns.generate(BLOCK, "prbs", 3) + data = patterns.generate(3 * BLOCK, "prbs", 4) + ciphertext = fw.aes.enc(key, data, swap=swap) + assert ciphertext == crypto_ref.aes_stm(key, data, swap), swap + assert fw.aes.dec(key, ciphertext, swap=swap) == data + assert fw.aes.dec(key, data, swap=swap) == crypto_ref.aes_stm(key, data, swap, decrypt=True) + + +KEY = "000102030405060708090a0b0c0d0e0f" +DATA = "00112233445566778899aabbccddeeff" + + +@pytest.mark.parametrize( + ("line", "reason"), + [ + ("aes.enc", "usage"), + (f"aes.enc {KEY}", "usage"), + (f"aes.enc {KEY} {DATA} {DATA}", "usage"), + (f"aes.enc {KEY} {DATA} mode=cbc", "usage"), + (f"aes.enc {KEY[:-2]} {DATA}", "usage"), + (f"aes.enc {KEY}00 {DATA}", "usage"), + (f"aes.enc {KEY}{KEY} {DATA}", "usage"), + (f"aes.enc - {DATA}", "usage"), + (f"aes.enc {KEY} -", "usage"), + (f"aes.enc {KEY} {DATA[:-2]}", "usage"), + (f"aes.enc {KEY} {DATA}00", "usage"), + (f"aes.enc {KEY} {DATA[:-1]}", "usage"), + (f"aes.enc {KEY} {DATA[:-2]}zz", "usage"), + (f"aes.enc {KEY} {DATA * 6}", "usage"), + (f"aes.enc {KEY} {DATA} swap=word", "usage"), + (f"aes.dec {KEY} {DATA * 6}", "usage"), + (f"aes.dec {KEY} {DATA} swap=nibble", "usage"), + ], +) +def test_errors(fw, line, reason): + expect_reason(fw, line, reason) diff --git a/validation/host/tests/hil/test_ain.py b/validation/host/tests/hil/test_ain.py new file mode 100644 index 00000000..31ada0b6 --- /dev/null +++ b/validation/host/tests/hil/test_ain.py @@ -0,0 +1,195 @@ +"""Analog input (`hal::AnalogToDigitalPinImplStm`, `hal::AnalogToDigitalInternalTemperatureStm`, +`hal::AdcTriggeredByTimerWithDma`, PROTOCOL.md D.11): single conversions against wavegen levels, the temperature +sensor, and TIM2-paced bursts on one driver over a 256-sample buffer (B.12: `Measure(n)` delivers exactly n samples +in n / rate, also a second time on the same driver). + +Wiring set `bundle1`: W1/W2 and the scope on `tests.ain.pins` (the ADC inputs of `tests.adc`); the burst counts, +the temperature and the argument checks need no AD3. +""" + +from __future__ import annotations + +import statistics +import time + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import expect +from hal_st_validation.groups import analog + + +@pytest.fixture +def ain_cfg(board_cfg): + return board_cfg.param("ain") + + +@pytest.fixture +def adc_cfg(board_cfg): + return board_cfg.param("adc") + + +def apply_level(ad3, need, pin, volts): + """Drive `pin` to `volts`; returns the level the scope measures (100 ms, whole mains periods), else `volts`.""" + ad3.wavegen.dc(need.wavegen(pin), volts) + time.sleep(0.02) + scope = need.optional_scope(pin) + if scope is None: + return volts + return statistics.fmean(ad3.scope.acquire([scope], rate=5e4, samples=5000, range_v=5.0, offset_v=1.65)[scope]) + + +def expected_code(volts, adc_cfg): + return expect.adc_code(volts, adc_cfg["vref"], adc_cfg["bits"]) + + +def check_timing(run, samples, board_cfg, ain_cfg): + settings = ain_cfg["burst"] + expected = analog.burst_seconds(samples, board_cfg.clock("timer"), settings["rate"]) + measured = run.us / 1e6 + assert abs(measured - expected) <= settings["tolerance"] * expected + settings["latency_s"], ( + f"{run.n} samples in {measured * 1000:.2f} ms, {samples} take {expected * 1000:.2f} ms" + ) + + +@pytest.mark.ad3 +@pytest.mark.board_params("pin", "ain.pins") +@pytest.mark.board_params("level", "ain.levels") +def test_read_dc(fw, ad3, need, adc_cfg, pin, level): + """`ain.read` converts the pin once (`AnalogToDigitalPinImplStm`).""" + volts = apply_level(ad3, need, pin, level) + code = fw.ain.read(adc_cfg["adc"], pin).code + assert code == pytest.approx(expected_code(volts, adc_cfg), abs=adc_cfg["tolerance_codes"]), f"{volts:.3f} V" + + +@pytest.mark.ad3 +@pytest.mark.board_params("sampling", "ain.sampling") +def test_read_sampling(fw, ad3, need, adc_cfg, ain_cfg, sampling): + pin = ain_cfg["pins"][0] + volts = apply_level(ad3, need, pin, 1.65) + code = fw.ain.read(adc_cfg["adc"], pin, sampling=sampling).code + assert code == pytest.approx(expected_code(volts, adc_cfg), abs=adc_cfg["tolerance_codes"]) + + +def test_temperature(fw, adc_cfg, ain_cfg): + """The internal sensor through `__LL_ADC_CALC_TEMPERATURE` (whole degrees) is at room temperature with a long + sampling time; with the driver's default, below the sensor's minimum (B.14), a code still arrives.""" + low, high = ain_cfg["temp_range"] + reading = fw.ain.read(adc_cfg["adc"], "temp", sampling=ain_cfg["temp_sampling"]) + assert reading.mcelsius is not None and reading.mcelsius % 1000 == 0, reading + assert low * 1000 <= reading.mcelsius <= high * 1000, f"{reading.mcelsius / 1000} C" + default = fw.ain.read(adc_cfg["adc"], "temp") + assert 0 < default.code < 1 << adc_cfg["bits"] + + +@pytest.mark.board_params("n", "ain.burst.n") +def test_burst_count(fw, need, board_cfg, adc_cfg, ain_cfg, n): + """`Measure(n)` with n below the buffer delivers exactly n samples in n / rate; the unfixed driver fills the + whole buffer (256 samples in 256 / rate).""" + pin = ain_cfg["pins"][0] + need.unloaded(pin) + assert n < ain_cfg["burst"]["buffer"] + (run,) = fw.ain.burst(adc_cfg["adc"], pin, n=n, rate=ain_cfg["burst"]["rate"], out="stats") + assert run.n == n + check_timing(run, n, board_cfg, ain_cfg) + + +@pytest.mark.board_params("n", "ain.burst.n") +def test_burst_twice(fw, need, board_cfg, adc_cfg, ain_cfg, n): + """`repeat=2` measures twice on one driver: the second `Measure(n)` starts from the state the first left (the + unfixed driver leaves the ADC enabled with conversions pending) and again gives n samples in n / rate.""" + pin = ain_cfg["pins"][0] + need.unloaded(pin) + runs = fw.ain.burst(adc_cfg["adc"], pin, n=n, rate=ain_cfg["burst"]["rate"], repeat=2, out="stats") + assert [run.n for run in runs] == [n, n] + for run in runs: + check_timing(run, n, board_cfg, ain_cfg) + + +@pytest.mark.ad3 +@pytest.mark.board_params("pin", "ain.pins") +def test_burst_values(fw, ad3, need, adc_cfg, ain_cfg, pin): + volts = apply_level(ad3, need, pin, 2.0) + (run,) = fw.ain.burst(adc_cfg["adc"], pin, n=16, rate=ain_cfg["burst"]["rate"]) + assert run.samples is not None and len(run.samples) == 16 + expected = expected_code(volts, adc_cfg) + assert all(abs(sample - expected) <= adc_cfg["tolerance_codes"] for sample in run.samples), run.samples + + +@pytest.mark.ad3 +def test_burst_follows_a_ramp(fw, ad3, need, board_cfg, adc_cfg, ain_cfg): + """A wavegen ramp during the burst: the samples rise at the ramp's slope per TIM2 period (the window holds at + most one wrap of the sawtooth, so the samples split into at most two rising pieces).""" + settings = ain_cfg["ramp"] + pin = ain_cfg["pins"][0] + ad3.wavegen.ramp(need.wavegen(pin), settings["low"], settings["high"], settings["frequency"]) + time.sleep(0.05) + (run,) = fw.ain.burst(adc_cfg["adc"], pin, n=settings["n"], rate=settings["rate"]) + assert run.samples is not None + span = expected_code(settings["high"], adc_cfg) - expected_code(settings["low"], adc_cfg) + pieces: list[list[int]] = [[]] + for sample in run.samples: + if pieces[-1] and sample < pieces[-1][-1] - span / 2: + pieces.append([]) + pieces[-1].append(sample) + assert len(pieces) <= 2, f"{len(pieces) - 1} wraps in one burst" + longest = max(pieces, key=len) + slope = statistics.linear_regression(range(len(longest)), longest).slope + rate = analog.trigger_rate(board_cfg.clock("timer"), settings["rate"]) + expected = span * settings["frequency"] / rate + assert slope == pytest.approx(expected, rel=0.3), f"{slope:.2f} codes per sample, the ramp gives {expected:.2f}" + + +def test_busy_with_adc_and_tim2(fw, need, board_cfg, adc_cfg, ain_cfg): + """One group at a time holds the ADC: while `adc` is open `ain.read` and `ain.burst` answer `ERR busy`. The + burst also needs TIM2 and the ADC's DMA channel, so a PWM on TIM2 blocks it while `ain.read` works.""" + adc = adc_cfg["adc"] + pin, other = ain_cfg["pins"][:2] + need.unloaded(pin, other) + fw.adc.open(adc, pins=[other]) + for target in (pin, "temp"): + with pytest.raises(FirmwareError) as error: + fw.ain.read(adc, target) + assert error.value.reason == "busy", target + with pytest.raises(FirmwareError) as error: + fw.ain.burst(adc, pin, n=16, rate=1000) + assert error.value.reason == "busy", "burst" + fw.adc.close(adc) + tim2 = next(timer for timer in board_cfg.param("pwm.timers") if timer["timer"] == 2) + fw.pwm.open(2, pins=[tim2["channels"][0]["pin"]]) + with pytest.raises(FirmwareError) as error: + fw.ain.burst(adc, pin, n=16, rate=1000) + assert error.value.reason == "busy", "TIM2 held by pwm" + fw.ain.read(adc, pin) + fw.pwm.close(2) + assert fw.ain.burst(adc, pin, n=16, rate=1000)[0].n == 16 + fw.adc.open(adc, pins=[other], timer=2) + + +def test_errors(fw, board_cfg, adc_cfg, ain_cfg): + adc, pin = adc_cfg["adc"], ain_cfg["pins"][0] + digital = board_cfg.param("gpio.output_pins")[0] + missing = next(number for number in range(8) if number != adc) + burst = {"n": 16, "rate": 1000} + cases = [ + (("ain.read", adc), {}, "usage"), + (("ain.read", adc, pin), {"sampling": "1"}, "usage"), + (("ain.read", missing, pin), {}, "range"), + (("ain.read", adc, digital), {}, "pin"), + (("ain.read", adc, "nosuchpin"), {}, "pin"), + (("ain.burst", adc, pin), {"n": 16}, "usage"), + (("ain.burst", adc, pin), {"rate": 1000}, "usage"), + (("ain.burst", adc, pin), {**burst, "n": 0}, "range"), + (("ain.burst", adc, pin), {**burst, "n": analog.AIN_BUFFER + 1}, "range"), + (("ain.burst", adc, pin), {**burst, "n": analog.AIN_LIST_MAX + 1}, "range"), + (("ain.burst", adc, pin), {**burst, "rate": analog.AIN_RATE_MIN - 1}, "range"), + (("ain.burst", adc, pin), {**burst, "rate": analog.AIN_RATE_MAX + 1}, "range"), + (("ain.burst", adc, pin), {**burst, "repeat": analog.AIN_REPEAT_MAX + 1}, "range"), + (("ain.burst", adc, pin), {**burst, "out": "hex"}, "usage"), + (("ain.burst", missing, pin), burst, "range"), + (("ain.burst", adc, digital), burst, "pin"), + ] + for (name, *args), options, reason in cases: + with pytest.raises(FirmwareError) as error: + fw.command(name, *args, **options) + assert error.value.reason == reason, (name, args, options) diff --git a/validation/host/tests/hil/test_backup_ram.py b/validation/host/tests/hil/test_backup_ram.py new file mode 100644 index 00000000..3f4349ae --- /dev/null +++ b/validation/host/tests/hil/test_backup_ram.py @@ -0,0 +1,65 @@ +"""Backup registers through the `bkp` group: `hal::BackupRamStm` used as `hal::BackupRam` (B.8: +public inheritance; on STM32WBA55 the driver also clocks the TAMP registers and opens the backup domain). STM32WB55: +RTC BKP0R-BKP19R; STM32WBA55: TAMP BKP0R-BKP15R. The words survive a system reset. No wiring. +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.groups.system_ext import bkp_fill_value + + +@pytest.fixture +def bkp_cfg(board_cfg): + return board_cfg.param("bkp") + + +def test_words(fw, bkp_cfg): + assert fw.bkp.info() == bkp_cfg["words"] + + +@pytest.mark.parametrize("pattern", [0xA5A5A5A5, 0x5A5A5A5A, 0x00000000, 0xFFFFFFFF]) +def test_write_read_every_word(fw, bkp_cfg, pattern): + """Each word holds its own value: written one by one, all read back, none disturbed by its neighbours.""" + words = bkp_cfg["words"] + values = [(pattern ^ (index * 0x01010101)) & 0xFFFFFFFF for index in range(words)] + for index, value in enumerate(values): + fw.bkp.write(index, value) + assert [fw.bkp.read(index) for index in range(words)] == values + + +@pytest.mark.parametrize("seed", [1, 0xDEADBEEF]) +def test_fill_and_check(fw, bkp_cfg, seed): + fw.bkp.fill(seed) + assert fw.bkp.check(seed) == 0 + assert fw.bkp.check(seed ^ 1) == bkp_cfg["words"] + assert fw.bkp.read(0) == bkp_fill_value(seed, 0) + + +@pytest.mark.resets_board +def test_retained_across_reset(fw, board_cfg, bkp_cfg): + """The backup domain keeps the words through a system reset.""" + seed = 0x13579BDF + fw.bkp.fill(seed) + fw.system.reset(timeout=board_cfg.param("system.boot_timeout", 5.0)) + assert fw.bkp.check(seed) == 0 + assert fw.bkp.read(bkp_cfg["words"] - 1) == bkp_fill_value(seed, bkp_cfg["words"] - 1) + + +def test_errors(fw, bkp_cfg): + words = bkp_cfg["words"] + for line, reason in [ + (f"bkp.read {words}", "range"), + (f"bkp.write {words} 1", "range"), + ("bkp.write 0 0x100000000", "usage"), + ("bkp.write 0", "usage"), + ("bkp.read", "usage"), + ("bkp.info 1", "usage"), + ("bkp.fill", "usage"), + ("bkp.check x", "usage"), + ]: + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line diff --git a/validation/host/tests/hil/test_clock.py b/validation/host/tests/hil/test_clock.py new file mode 100644 index 00000000..cd453abf --- /dev/null +++ b/validation/host/tests/hil/test_clock.py @@ -0,0 +1,118 @@ +"""The hal-st default clock configurations (`ConfigureDefaultClockNucleoWB55RG`, `ConfigureDefaultClockNucleoWBA55CG`) +through the `clock` group: bus clocks, oscillator ready flags and the RNG kernel clock selection against +`tests.clock`, and on STM32WB55 the clocks themselves on the MCO pin. + +MCO (STM32WB55 only; the only MCO pin of the STM32WBA55 is the terminal RX): `sgpio.af af=0` muxes the +pin, `clock.mco` selects source and divider, and the logic analyzer measures the frequency with a least-squares fit +of the rising edge times (`groups.io.edge_fit_frequency`), precise enough for the LSE crystal's +-100 ppm. +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.groups.io import MCO_SOURCES, edge_fit_frequency + + +@pytest.fixture +def clock_cfg(board_cfg): + cfg = board_cfg.param("clock", None) + if cfg is None: + pytest.skip("tests.clock not configured") + return cfg + + +@pytest.fixture +def mco_cfg(clock_cfg): + cfg = clock_cfg.get("mco") + if cfg is None: + pytest.skip("no MCO pin on this board") + return cfg + + +def measure(ad3, dio, hz, periods): + capture = ad3.logic.record_for(periods / hz, timeout=periods / hz + 2) + return edge_fit_frequency(capture.channel(dio), capture.rate) + + +def test_bus_clocks(fw, board_cfg, clock_cfg): + info = fw.clock.info() + for bus, hz in clock_cfg["frequencies"].items(): + assert getattr(info, bus) == hz, f"{bus}: {info.raw}" + assert info.pclk7 is None or "pclk7" in clock_cfg["frequencies"], "pclk7 only where the MCU has APB7" + assert info.sysclk == board_cfg.sysclk == fw.system.info().sysclk + assert (info.pclk1, info.pclk2) == (board_cfg.clock("pclk1"), board_cfg.clock("pclk2")), "the clocks the expectations use" + + +def test_oscillators_and_rng_clock(fw, clock_cfg): + info = fw.clock.info() + for flag, ready in clock_cfg["flags"].items(): + assert info.flags.get(flag) == ready, f"{flag}: {info.raw}" + assert info.rngsel == clock_cfg["rngsel"], info.raw + assert info.clk48 == clock_cfg.get("clk48"), info.raw + + +@pytest.mark.ad3 +@pytest.mark.board_params("output", "clock.mco.outputs") +def test_mco_frequency(fw, ad3, need, mco_cfg, output): + dio = need.dio(mco_cfg["pin"]) + fw.sgpio.af(mco_cfg["pin"], af=mco_cfg["af"]) + fw.clock.mco(output["source"], div=output["div"]) + measured = measure(ad3, dio, output["hz"], mco_cfg["periods"]) + assert measured == pytest.approx(output["hz"], rel=output["tolerance"]), f"{output['source']}/{output['div']}" + + +@pytest.mark.ad3 +def test_mco_off(fw, ad3, need, mco_cfg): + dio = need.dio(mco_cfg["pin"]) + fw.sgpio.af(mco_cfg["pin"], af=mco_cfg["af"]) + fw.clock.mco("sysclk", div=16) + fw.clock.mco("off") + capture = ad3.logic.record_for(1e-3, timeout=2.0) + assert analysis.edge_count(capture.channel(dio)) == 0, "MCO still running after clock.mco off" + + +@pytest.mark.ad3 +def test_hsi48_switch(fw, ad3, need, mco_cfg): + """`clock.hsi48 0` stops HSI48 (no MCO edges, ready flag 0); `clock.hsi48 1` brings both back.""" + output = mco_cfg["hsi48"] + dio = need.dio(mco_cfg["pin"]) + fw.sgpio.af(mco_cfg["pin"], af=mco_cfg["af"]) + fw.clock.mco("hsi48", div=output["div"]) + fw.clock.hsi48(False) + assert fw.clock.info().flags["hsi48"] == 0 + capture = ad3.logic.record_for(1e-3, timeout=2.0) + assert analysis.edge_count(capture.channel(dio)) == 0, "HSI48 still on the MCO pin" + fw.clock.hsi48(True) + assert fw.clock.info().flags["hsi48"] == 1 + measured = measure(ad3, dio, output["hz"], mco_cfg["periods"]) + assert measured == pytest.approx(output["hz"], rel=output["tolerance"]) + + +def expect_reason(fw, line, reason): + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line + + +def test_info_errors(fw, clock_cfg): + expect_reason(fw, "clock.info 1", "usage") + expect_reason(fw, "clock.info all=1", "usage") + + +def test_mco_errors(fw, mco_cfg): + for line, reason in ( + ("clock.mco", "usage"), + ("clock.mco pll", "usage"), + ("clock.mco sysclk div=3", "usage"), + ("clock.mco sysclk div=32", "usage"), + ("clock.mco sysclk speed=1", "usage"), + ("clock.hsi48", "usage"), + ("clock.hsi48 2", "range"), + ): + expect_reason(fw, line, reason) + for source in MCO_SOURCES: + fw.clock.mco(source) + fw.clock.hsi48(True) diff --git a/validation/host/tests/hil/test_dma.py b/validation/host/tests/hil/test_dma.py new file mode 100644 index 00000000..60935f10 --- /dev/null +++ b/validation/host/tests/hil/test_dma.py @@ -0,0 +1,115 @@ +"""DMA (`hal::CircularTransmitDmaChannel`, PROTOCOL.md D.12): `dma.wave` writes one 32-bit BSRR word per pattern bit +on every TIM2 update, memory and peripheral side 32 bits wide (B.4 on both MCUs), so the pin shows the pattern, +least significant bit first, at `rate` bits per second. + +Wiring set `bundle1` or `bundle2`: `tests.dma.pin` on a DIO; the argument and sharing checks need no AD3. +""" + +from __future__ import annotations + +import math +import time + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.firmware import settle +from hal_st_validation.groups import analog + + +@pytest.fixture +def dma_cfg(board_cfg): + return board_cfg.param("dma") + + +def decode_bits(levels, samples_per_bit, tolerance): + """The bits between the first and the last edge: every level run lasts a whole number of bit times.""" + runs = analysis.runs(levels)[1:-1] + assert runs, "fewer than two edges captured" + bits: list[int] = [] + for level, _, length in runs: + count = length / samples_per_bit + assert abs(count - round(count)) <= tolerance, f"a run of {count:.2f} bit times" + bits += [level] * round(count) + return bits, runs[0][1], runs[-1][1] + runs[-1][2] + + +def is_cyclic_part(bits, pattern): + repeated = pattern * (len(bits) // len(pattern) + 2) + return any(repeated[start : start + len(bits)] == bits for start in range(len(pattern))) + + +@pytest.mark.ad3 +@pytest.mark.matrix("dma.wave") +def test_wave_32_bit(fw, ad3, need, board_cfg, dma_cfg, rate, pattern): + """The LA sees `pattern` repeated at `rate` (TIM2 rounded to whole kernel clocks): each level lasts a whole + number of bit times and the bits between the first and the last edge are a part of the repeated pattern.""" + pin = dma_cfg["pin"] + dio = need.dio(pin) + data = bytes.fromhex(pattern) + actual = analog.trigger_rate(board_cfg.clock("timer"), rate) + sample_rate = min(ad3.logic.clock_hz, actual * dma_cfg["samples_per_bit"]) + samples = min(ad3.logic.buffer_size, math.ceil(dma_cfg["record_bits"] * sample_rate / actual)) + delay = dma_cfg["start_delay_s"] + ms = max(dma_cfg["ms"], math.ceil((delay + samples / sample_rate + 0.1) * 1000)) + pending = fw.dma.begin("wave", fw.pin(pin), rate=rate, pattern=data, ms=ms, cmd_timeout=fw.terminal.timeout + ms / 1000) + try: + time.sleep(delay) + capture = ad3.logic.record(sample_rate, samples, timeout=samples / sample_rate + 2) + finally: + response = settle(pending) + assert response is not None and response.ok, "dma.wave did not answer after ms" + tolerance = dma_cfg["tolerance"] + samples_per_bit = capture.rate / actual + bits, first, last = decode_bits(capture.channel(dio), samples_per_bit, tolerance["bit"]) + assert is_cyclic_part(bits, analog.wave_bits(data)), f"{''.join(map(str, bits))} is no part of the pattern" + measured = len(bits) * capture.rate / (last - first) + assert measured == pytest.approx(actual, rel=tolerance["rate"]), "bit rate" + + +def test_wave_errors(fw, board_cfg, dma_cfg): + """`rate` and `pattern` are required, `pattern` holds 1-32 bytes, `rate` 1-1000000 and `ms` 1-10000.""" + pin = dma_cfg["pin"] + unbonded = board_cfg.param("system.unbonded_pins")[0] + wave = {"rate": 1000, "pattern": "a5"} + cases = [ + ((pin,), {"rate": 1000}, "usage"), + ((pin,), {"pattern": "a5"}, "usage"), + ((pin,), {**wave, "pattern": "-"}, "usage"), + ((pin,), {**wave, "pattern": "abc"}, "usage"), + ((pin,), {**wave, "pattern": "00" * (analog.DMA_PATTERN_MAX + 1)}, "range"), + ((pin,), {**wave, "rate": 0}, "range"), + ((pin,), {**wave, "rate": analog.DMA_RATE_MAX + 1}, "range"), + ((pin,), {**wave, "ms": 0}, "range"), + ((pin,), {**wave, "ms": analog.DMA_WAVE_MAX_MS + 1}, "range"), + ((pin, pin), wave, "usage"), + ((unbonded,), wave, "pin"), + (("nosuchpin",), wave, "pin"), + (("terminaltx",), wave, "busy"), + ] + for args, options, reason in cases: + with pytest.raises(FirmwareError) as error: + fw.command("dma.wave", *args, **options) + assert error.value.reason == reason, (args, options) + + +def test_wave_shares_tim2_and_its_pin(fw, need, board_cfg, dma_cfg): + """TIM2 paces the wave, so a PWM on TIM2 makes `dma.wave` busy; so does its pin held as a GPIO. Both are free + again once the wave answered.""" + pin = dma_cfg["pin"] + need.unloaded(pin) + tim2 = next(timer for timer in board_cfg.param("pwm.timers") if timer["timer"] == 2) + fw.pwm.open(2, pins=[tim2["channels"][0]["pin"]]) + with pytest.raises(FirmwareError) as error: + fw.dma.wave(pin, rate=1000, pattern=b"\xa5", ms=10) + assert error.value.reason == "busy", "TIM2 held by pwm" + fw.pwm.close(2) + fw.gpio.cfg(pin, "out") + with pytest.raises(FirmwareError) as error: + fw.dma.wave(pin, rate=1000, pattern=b"\xa5", ms=10) + assert error.value.reason == "busy", "pin held by gpio" + fw.gpio.release(pin) + fw.dma.wave(pin, rate=1000, pattern=b"\xa5", ms=10) + fw.gpio.cfg(pin, "out") + fw.pwm.open(2, pins=[tim2["channels"][0]["pin"]]) diff --git a/validation/host/tests/hil/test_eeprom.py b/validation/host/tests/hil/test_eeprom.py new file mode 100644 index 00000000..b885dc57 --- /dev/null +++ b/validation/host/tests/hil/test_eeprom.py @@ -0,0 +1,219 @@ +"""EMIL's `eeprom.*` group over the `I2cEepromStm` adapter (`hal::Eeprom` on `hal::I2cStm`), against the external +24Cxx of `--with i2c` (`tests.eeprom`: a 24LC256 at 0x50 by default) and, for the 1-byte word address, against the +`i2cs` register target on the other instance. + +The adapter splits writes at page boundaries and polls the chip's address after each page (B.1 address-NACK path) +until it acknowledges or `wcycle` ms pass; reads set the word address and read after a repeated START. An error +outside polling prints `EVT eeprom error=... address=...` and leaves EMIL's command pending (`ERR timeout` after +5 s); `eeprom.detach` recovers. Data written by one test is overwritten by the next, each at its own address range. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.firmware import quiesce, settle +from hal_st_validation.groups.i2c import EEPROM_BUFFER, EEPROM_TIMEOUT_S +from hal_st_validation.i2c import arm_on_start, i2c_decode +from hal_st_validation.patterns import generate + +pytestmark = pytest.mark.requires_option("i2c") + +READ_CHUNK = 128 +# `eeprom.write
` must fit the 255-character command line. +WRITE_MAX = (255 - len("eeprom.write 65535 ")) // 2 + + +@pytest.fixture +def eeprom_cfg(need, board_cfg) -> dict[str, Any]: + cfg = board_cfg.param("eeprom") + need.unloaded(cfg["scl"], cfg["sda"]) + return cfg + + +def attach(fw, cfg, **options) -> None: + settings = {key: cfg[key] for key in ("addr", "size", "page", "abytes", "freq")} + settings["wcycle"] = cfg["wcycle_ms"] + settings.update(options) + fw.eeprom.attach(cfg["index"], cfg["scl"], cfg["sda"], **settings) + + +def read_all(fw, address: int, length: int) -> bytes: + data = b"" + while len(data) < length: + chunk = min(READ_CHUNK, length - len(data)) + data += fw.eeprom.read(address + len(data), chunk) + return data + + +@pytest.fixture +def bench(request) -> None: + """The case measures the physical bus, which the fake does not model.""" + if request.config.getoption("--fake"): + pytest.skip("measures the physical bus: nothing to measure with --fake") + + +def expect_error(reason, call, *args, **options): + with pytest.raises(FirmwareError) as error: + call(*args, **options) + assert error.value.reason == reason, (args, options) + + +def test_eeprom_present(fw, eeprom_cfg): + """A zero-length write (address probe) to the chip is acknowledged.""" + fw.i2c.open(eeprom_cfg["index"], eeprom_cfg["scl"], eeprom_cfg["sda"]) + assert fw.i2c.write(eeprom_cfg["index"], eeprom_cfg["addr"]).result == "complete" + + +def test_attach_detach(fw, eeprom_cfg): + attach(fw, eeprom_cfg) + expect_error("busy", fw.eeprom.attach, eeprom_cfg["index"], eeprom_cfg["scl"], eeprom_cfg["sda"]) + expect_error("busy", fw.i2c.open, eeprom_cfg["index"], eeprom_cfg["scl"], eeprom_cfg["sda"]) + fw.eeprom.detach() + expect_error("notopen", fw.eeprom.detach) + expect_error("range", fw.eeprom.read, 0, 1) + fw.eeprom.erase() + attach(fw, eeprom_cfg) + + +def test_write_read_inside_a_page(fw, eeprom_cfg): + attach(fw, eeprom_cfg) + address = 2 * eeprom_cfg["page"] + 3 + data = generate(eeprom_cfg["page"] // 2, "prbs", 11) + fw.eeprom.write(address, data) + assert fw.eeprom.read(address, len(data)) == data + + +def test_write_across_a_page_boundary(fw, eeprom_cfg): + """The adapter splits the write at the page boundary, so nothing wraps inside a page.""" + attach(fw, eeprom_cfg) + page = eeprom_cfg["page"] + address = 4 * page - 5 + data = generate(min(WRITE_MAX, page + 10), "prbs", 12) + fw.eeprom.write(address, data) + assert fw.eeprom.read(address, len(data)) == data + assert fw.eeprom.read(4 * page, 5) == data[5:10] + + +def test_reads_across_pages(fw, eeprom_cfg): + attach(fw, eeprom_cfg) + address = 6 * eeprom_cfg["page"] + data = generate(EEPROM_BUFFER, "inc", 0x30) + half = EEPROM_BUFFER // 2 + fw.eeprom.write(address, data[:half]) + fw.eeprom.write(address + half, data[half:]) + assert fw.eeprom.read(address, EEPROM_BUFFER) == data + assert fw.eeprom.read(address + 1, EEPROM_BUFFER - 1) == data[1:] + + +def test_write_read_write_read(fw, eeprom_cfg): + """B.1h: ACK polling after a write that follows a read (stale counters used to abort or report 4294967295).""" + attach(fw, eeprom_cfg) + address = 10 * eeprom_cfg["page"] + for seed in (1, 2): + data = generate(16, "prbs", seed) + fw.eeprom.write(address, data) + assert fw.eeprom.read(address, 16) == data + + +@pytest.mark.slow +def test_erase(fw, eeprom_cfg): + """`eeprom.erase` writes 0xFF over `erase_size` (attached with that size: EMIL's 5 s limit covers it).""" + size = eeprom_cfg["erase_size"] + attach(fw, eeprom_cfg, size=size) + fw.eeprom.write(size - 8, b"\x00" * 8) + fw.eeprom.erase() + assert read_all(fw, 0, size) == b"\xff" * size + + +@pytest.mark.resets_board +def test_data_survives_reset(fw, eeprom_cfg): + attach(fw, eeprom_cfg) + address = 12 * eeprom_cfg["page"] + data = generate(8, "prbs", 99) + fw.eeprom.write(address, data) + fw.system.reset() + fw.forget_open() + attach(fw, eeprom_cfg) + assert fw.eeprom.read(address, 8) == data + + +def test_range_at_size(fw, eeprom_cfg): + size = eeprom_cfg["size"] + attach(fw, eeprom_cfg) + expect_error("range", fw.eeprom.write, size - 1, b"\x00\x00") + expect_error("range", fw.eeprom.read, size, 1) + expect_error("range", fw.eeprom.read, size - 1, 2) + fw.eeprom.write(size - 1, b"\xa5") + assert fw.eeprom.read(size - 1, 1) == b"\xa5" + + +@pytest.mark.ad3 +def test_ack_polling_on_la(fw, ad3, need, bench, eeprom_cfg): + """At 100 kHz from the START of the page write: the control byte (0xA0 for a chip at 0x50), the word address and + the data, then the first poll (the address bytes again) NACKed during the write cycle.""" + scl, sda = need.dio(eeprom_cfg["scl"]), need.dio(eeprom_cfg["sda"]) + attach(fw, eeprom_cfg, freq=100_000) + address = 14 * eeprom_cfg["page"] + data = generate(4, "prbs", 3) + rate = min(ad3.logic.clock_hz, 2e6) + samples = min(ad3.logic.buffer_size, int(rate * 2e-3)) + capture = arm_on_start(ad3, scl, sda, rate, samples, pretrigger=0.01) + fw.eeprom.write(address, data) + transfers = i2c_decode(*capture.wait(timeout=2.0).channels(scl, sda)) + assert len(transfers) >= 2, "no poll after the page write" + write, poll = transfers[0], transfers[1] + chip = eeprom_cfg["addr"] + assert (write.address, write.read, write.address_ack) == (chip, False, True) + assert write.payload == address.to_bytes(eeprom_cfg["abytes"], "big") + data + assert write.stop + assert (poll.address, poll.address_ack) == (chip, False) + + +@pytest.mark.slow +def test_error_then_detach_recovers(fw, eeprom_cfg): + """No device at 0x57: the first page write is NACKed (`EVT eeprom error=nack address=0`), EMIL answers + `ERR timeout` and stays busy; `eeprom.detach` completes the operation and a new attach works.""" + attach(fw, eeprom_cfg, addr=0x57) + fw.eeprom.errors() + expect_error("timeout", fw.eeprom.write, 0, b"\x01") + errors = fw.eeprom.errors() + assert [(event["error"], event.as_int("address")) for event in errors] == [("nack", 0)] + expect_error("busy", fw.eeprom.read, 0, 1) + fw.eeprom.detach() + attach(fw, eeprom_cfg) + fw.eeprom.write(16 * eeprom_cfg["page"], b"\x01") + + +def test_detach_while_busy(fw, request, eeprom_cfg): + """`eeprom.detach` while the adapter is in a transfer answers `ERR busy` (the longest write the command line + holds spans two pages, two write cycles).""" + if request.config.getoption("--fake"): + pytest.skip("the fake completes every write at once") + attach(fw, eeprom_cfg) + address = 18 * eeprom_cfg["page"] + pending = fw.eeprom.begin("write", address, generate(WRITE_MAX), cmd_timeout=fw.terminal.timeout + EEPROM_TIMEOUT_S) + fw.terminal.send_nowait("eeprom.detach") + # The detach answers first; the write's own OK follows once its pages are written and is dropped by quiesce. + reply = settle(pending) + quiesce(fw.terminal, quiet=0.2) + assert reply is not None and reply.reason == "busy", reply + assert fw.eeprom.read(address, 8) == generate(8) + + +def test_one_byte_addressing_on_target(fw, board_cfg, eeprom_cfg, need): + """`abytes=1` and 16-byte pages against the `i2cs` register file at 0x42 on the other instance: the word address + is the register pointer, so the data lands in the target's registers.""" + target = board_cfg.param("i2c.loop.target") + if target["index"] == eeprom_cfg["index"]: + target = board_cfg.param("i2c.loop.master") + need.unloaded(target["scl"], target["sda"]) + fw.i2cs.open(target["index"], target["scl"], target["sda"], addr=0x42) + attach(fw, eeprom_cfg, addr=0x42, size=256, page=16, abytes=1, wcycle=0) + data = generate(40, "prbs", 21) + fw.eeprom.write(10, data) + assert fw.eeprom.read(10, len(data)) == data + assert fw.i2cs.dump(target["index"], 10, len(data)) == data diff --git a/validation/host/tests/hil/test_flash.py b/validation/host/tests/hil/test_flash.py new file mode 100644 index 00000000..cbe5e9f9 --- /dev/null +++ b/validation/host/tests/hil/test_flash.py @@ -0,0 +1,314 @@ +"""Internal flash through the `flash` group, over the scratch region of the board (absolute pages 64-127): +`hal::FlashHomogeneousInternalStm` / `hal::FlashInternalStm` (`variant=async`), their synchronous twins +(`variant=sync`) and on STM32WB55 `hal::FlashCoordinatedWithWirelessStack` (`variant=coord`) over the async driver. + +`layout=homogeneous` has one sector per page; `layout=table` has single pages first and the page pattern 1, 1, 2, 4 +twice at the end (`groups.system_ext.table_sectors`). The firmware accepts erases and writes only from the sector +`first` on: an unfixed erase (B.5) takes the sector index for the absolute page, which from there on cannot reach the +running image. Every write targets flash words (8 bytes on STM32WB55, 16 on STM32WBA55) no earlier write programmed +since the last erase; programming one twice would fail the driver's assertion, so the firmware answers `ERR failed`. + +The coordinated driver waits for HSEM 7 (CPU2 holds it while it needs the flash), holds its steps while the wireless +stack is starting (`flash.stack starting` until `fus`), refreshes the watchdog it shares with `wdt` and excludes +`hsem.lock`. No wiring. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import patterns +from hal_st_validation.firmware import settle +from hal_st_validation.groups.system_ext import FlashInfo, flash_words, sector_addresses, table_sectors + + +@pytest.fixture +def flash_cfg(board_cfg): + return board_cfg.param("flash") + + +@dataclass(frozen=True) +class Geometry: + info: FlashInfo + layout: str + page: int + word: int + pages: list[int] + addresses: list[int] + + @classmethod + def read(cls, fw, flash_cfg, layout: str) -> Geometry: + info = fw.flash.info(layout=layout) + page = flash_cfg["page"] + count = info.size // page + pages = table_sectors(count) if layout == "table" else [1] * count + return cls(info, layout, page, flash_cfg["word"], pages, sector_addresses(pages, page)) + + def size(self, sector: int) -> int: + return self.pages[sector] * self.page + + def pick(self, pages: int, following: int = 1) -> int: + """The first accepted sector of `pages` pages followed by `following` accepted sectors.""" + for sector in range(self.info.first, len(self.pages) - following): + if self.pages[sector] == pages: + return sector + pytest.skip(f"no accepted {pages}-page sector in layout {self.layout} (first={self.info.first})") + + +def erased(length: int) -> int: + return patterns.crc32(b"\xff" * length) + + +def expect_reason(fw, line: str, reason: str) -> None: + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line + + +@pytest.mark.board_params("layout", "flash.layouts") +def test_geometry(fw, flash_cfg, layout): + """The region, its sectors and the first accepted sector agree with the board file and the layout rule; every + variant reports the same geometry.""" + geometry = Geometry.read(fw, flash_cfg, layout) + info = geometry.info + assert info.base == flash_cfg["base"], info.raw + assert info.size == flash_cfg["pages"] * flash_cfg["page"], info.raw + assert info.sectors == len(geometry.pages), info.raw + assert info.layout == layout + assert info.image <= flash_cfg["first_page"], f"the image reaches the scratch region: {info.raw}" + assert info.first == min(info.image, info.sectors), info.raw + assert info.first < info.sectors, f"no sector left to test: {info.raw}" + for variant in flash_cfg["variants"]: + other = fw.flash.info(variant=variant, layout=layout) + assert (other.base, other.sectors, other.size, other.first, other.image) == ( + info.base, + info.sectors, + info.size, + info.first, + info.image, + ) + + +@pytest.mark.board_params("variant", "flash.variants") +@pytest.mark.board_params("layout", "flash.layouts") +def test_erase_hits_region_page(fw, flash_cfg, variant, layout): + """Erasing sector r erases exactly its pages of the region (B.5): a marker in r disappears, the markers in the + next sector and the content of the previous one stay. The table layout erases a 2- and a 4-page sector.""" + geometry = Geometry.read(fw, flash_cfg, layout) + for pages in (2, 4) if layout == "table" else (1,): + sector = geometry.pick(pages) + start, following = geometry.addresses[sector], geometry.addresses[sector + 1] + marker = patterns.generate(geometry.word, "prbs", sector + 1) + fw.flash.erase(sector, sector + 2, variant=variant, layout=layout) + assert fw.flash.read_crc(start, geometry.size(sector) + geometry.size(sector + 1)) == erased( + geometry.size(sector) + geometry.size(sector + 1) + ) + previous = None + if sector - 1 >= geometry.info.first: + previous = fw.flash.read_crc(geometry.addresses[sector - 1], geometry.size(sector - 1)) + fw.flash.write(start, marker, variant=variant, layout=layout) + fw.flash.write(start + geometry.size(sector) - geometry.word, marker, variant=variant, layout=layout) + fw.flash.write(following, marker, variant=variant, layout=layout) + + fw.flash.erase(sector, sector + 1, variant=variant, layout=layout) + + assert fw.flash.read_crc(start, geometry.size(sector)) == erased(geometry.size(sector)), f"sector {sector} ({pages} pages)" + assert fw.flash.read(following, len(marker)) == marker, f"sector {sector + 1} after erasing {sector}" + if previous is not None: + assert fw.flash.read_crc(geometry.addresses[sector - 1], geometry.size(sector - 1)) == previous + + +@pytest.mark.board_params("variant", "flash.variants") +@pytest.mark.board_params("layout", "flash.layouts") +def test_write_shapes(fw, flash_cfg, variant, layout): + """Aligned, unaligned, odd-length, page-crossing and long writes on distinct flash words read back exactly, the + bytes around them stay erased, and every variant reads the same data.""" + geometry = Geometry.read(fw, flash_cfg, layout) + if layout == "table": + first = geometry.pick(2, following=0) + end = first + 1 + else: + first = geometry.pick(1, following=1) + end = first + 2 + base, word, page = geometry.addresses[first], geometry.word, geometry.page + span = geometry.addresses[end] - base + writes: list[tuple[int, bytes]] = [ + (base, patterns.generate(word, "inc", 0x10)), + (base + 2 * word + 3, patterns.generate(3, "inc", 0x20)), + (base + 4 * word + 1, patterns.generate(2 * word + 3, "prbs", 3)), + (base + page - 5, patterns.generate(2 * word, "inc", 0x40)), + ] + long_address, long_length = base + 16 * word, 300 + used = [set(flash_words(address, len(data), word)) for address, data in writes] + used.append(set(flash_words(long_address, long_length, word))) + assert all(a.isdisjoint(b) for index, a in enumerate(used) for b in used[index + 1 :]), "test writes share a flash word" + + fw.flash.erase(first, end, variant=variant, layout=layout) + model = bytearray(b"\xff" * span) + for address, data in writes: + fw.flash.write(address, data, variant=variant, layout=layout) + model[address - base : address - base + len(data)] = data + fw.flash.write(long_address, length=long_length, pattern="prbs", seed=11, variant=variant, layout=layout) + model[long_address - base : long_address - base + long_length] = patterns.generate(long_length, "prbs", 11) + + for reader in flash_cfg["variants"]: + for address, data in writes: + assert fw.flash.read(address, len(data), variant=reader, layout=layout) == data, f"{reader} at {address:#x}" + assert fw.flash.read_crc(base, span, variant=reader, layout=layout) == patterns.crc32(bytes(model)), reader + + +def test_programmed_word_is_refused(fw, flash_cfg): + """A second write to a programmed flash word answers `ERR failed` (the driver would assert); a word next to it + is still writable.""" + geometry = Geometry.read(fw, flash_cfg, "homogeneous") + sector = geometry.pick(1, following=0) + start, word = geometry.addresses[sector], geometry.word + fw.flash.erase(sector, sector + 1) + fw.flash.write(start + 1, b"\x5a") + expect_reason(fw, f"flash.write {start} 00", "failed") + expect_reason(fw, f"flash.write {start + word - 1} 0000", "failed") + fw.flash.write(start + word, b"\xa5") + assert fw.flash.read(start, word + 1) == b"\xff\x5a" + b"\xff" * (word - 2) + b"\xa5" + + +def test_erase_duration(fw, flash_cfg): + """A synchronous erase blocks for the page erase time of the flash (`us` per page within the board's bounds).""" + geometry = Geometry.read(fw, flash_cfg, "homogeneous") + sector = geometry.pick(1, following=1) + low, high = flash_cfg["erase_us_per_page"] + us = fw.flash.erase(sector, sector + 2) + assert 2 * low <= us <= 2 * high, f"{us} us for two pages" + + +def test_errors(fw, flash_cfg): + geometry = Geometry.read(fw, flash_cfg, "homogeneous") + first, sectors, size = geometry.info.first, geometry.info.sectors, geometry.info.size + start = geometry.addresses[first] + lines = [ + ("flash.erase", "usage"), + (f"flash.erase {first} {first + 1} variant=fast", "usage"), + (f"flash.erase {first} {first + 1} layout=uneven", "usage"), + (f"flash.erase {first} {first}", "range"), + (f"flash.erase {first} {sectors + 1}", "range"), + (f"flash.write {start} -", "usage"), + (f"flash.write {start} 0011 len=2", "usage"), + (f"flash.write {start} - pattern=inc", "usage"), + (f"flash.write {start} 001", "usage"), + (f"flash.write {start} - len=513", "range"), + (f"flash.write {size - 1} 0011", "range"), + (f"flash.read {size} 1", "range"), + ("flash.read 0 0", "range"), + ("flash.read 0 129", "range"), + ("flash.read 0 4 out=bin", "usage"), + ("flash.info 1", "usage"), + ] + if first > 0: + lines += [(f"flash.erase {first - 1} {first}", "range"), (f"flash.write {start - 1} 00", "range")] + if "coord" not in flash_cfg["variants"]: + lines += [("flash.info variant=coord", "unsupported"), ("flash.read 0 4 variant=coord", "unsupported")] + else: + lines += [("flash.stack running", "usage"), ("flash.stack", "usage")] + for line, reason in lines: + expect_reason(fw, line, reason) + + +@pytest.mark.family("stm32wb55") +@pytest.mark.parametrize("job", ["write", "erase"]) +def test_coord_waits_for_cpu2_semaphore(fw, flash_cfg, job): + """While HSEM 7 (CPU2's flash request) is held, a coordinated step waits; it runs once the semaphore is freed + (here by the `hold=` timer of `hsem.take`), which the HSEM interrupt reports to the driver.""" + geometry = Geometry.read(fw, flash_cfg, "homogeneous") + sector = geometry.pick(1, following=0) + start = geometry.addresses[sector] + data = patterns.generate(geometry.word * 2, "prbs", 5) + hold_ms, margin_ms = flash_cfg["hsem_hold_ms"], flash_cfg["hold_margin_ms"] + timeout = fw.terminal.timeout + hold_ms / 1000 + fw.flash.erase(sector, sector + 1) + if job == "erase": + fw.flash.write(start, data) + fw.hsem.take(7, procid=1, hold=hold_ms) + if job == "write": + us = fw.flash.write(start, data, variant="coord", cmd_timeout=timeout) + assert fw.flash.read(start, len(data)) == data + else: + us = fw.flash.erase(sector, sector + 1, variant="coord", cmd_timeout=timeout) + assert fw.flash.read_crc(start, geometry.size(sector)) == erased(geometry.size(sector)) + assert us >= (hold_ms - margin_ms) * 1000, f"{job} took {us} us with HSEM 7 held {hold_ms} ms" + assert not fw.hsem.status(7).locked + + +@pytest.mark.family("stm32wb55") +def test_coord_stack_starting_holds_until_fus(fw, flash_cfg): + """`flash.stack starting` (`WirelessStackStarting()`) holds a coordinated write: its `ERR timeout` goes out, the + flash stays erased, and `flash.stack fus` (`FirmwareUpgradeServicesReady()`) lets it complete with `EVT flash`.""" + geometry = Geometry.read(fw, flash_cfg, "homogeneous") + sector = geometry.pick(1, following=0) + start = geometry.addresses[sector] + data = patterns.generate(geometry.word, "inc", 0x33) + fw.flash.erase(sector, sector + 1) + fw.flash.stack("starting") + try: + held = fw.flash.begin_write(start, data, variant="coord", cmd_timeout=flash_cfg["transfer_timeout_s"] + 2) + response = settle(held) + assert response is not None and not response.ok and response.reason == "timeout", response + assert fw.flash.read(start, len(data)) == b"\xff" * len(data), "written while the stack was starting" + finally: + fw.flash.stack("fus") + event = fw.flash.wait_done(timeout=2.0) + assert event["op"] == "write", event.raw + assert fw.flash.read(start, len(data)) == data + + +@pytest.mark.family("stm32wb55") +def test_coord_excludes_hsem_lock(fw): + """The coordinated driver and `hsem.lock` both rely on the HSEM interrupt: they exclude each other (HSEM 0 of + `ResourceAllocation`).""" + fw.flash.stack("starting") + try: + expect_reason(fw, "hsem.lock 12", "busy") + finally: + fw.flash.stack("fus") + assert fw.hsem.lock(12) >= 0 + + +@pytest.fixture +def reset_afterwards(fw, board_cfg): + """A started watchdog cannot be stopped.""" + yield + boot_timeout = board_cfg.param("system.boot_timeout", 5.0) + fw.terminal.drain_events() + try: + fw.system.ping() + fw.system.reset(timeout=boot_timeout) + except Exception: # noqa: BLE001 - the board may be rebooting right now + fw.system.wait_boot(timeout=boot_timeout) + fw.terminal.drain_events() + + +@pytest.mark.family("stm32wb55") +@pytest.mark.resets_board +def test_watchdog_borrowed_while_stack_starting(fw, reset_afterwards): + """The coordinated driver borrows the unstarted WWDG: `wdt.start` answers `ERR busy` until it is given back.""" + fw.flash.stack("starting") + try: + expect_reason(fw, "wdt.start 0 timeout=100", "busy") + finally: + fw.flash.stack("fus") + fw.wdt.start(0, timeout=100, feed="auto") + + +@pytest.mark.family("stm32wb55") +@pytest.mark.resets_board +def test_coord_shares_running_watchdog(fw, flash_cfg, reset_afterwards): + """With the watchdog running (`feed=auto`), a coordinated erase refreshes it around every step it runs with the + interrupts masked, so the board does not reset.""" + geometry = Geometry.read(fw, flash_cfg, "homogeneous") + sector = geometry.pick(1, following=3) + fw.wdt.start(0, timeout=flash_cfg["watchdog_timeout_ms"], feed="auto") + fw.flash.erase(sector, sector + 4, variant="coord", cmd_timeout=fw.terminal.timeout + 2) + fw.terminal.collect_events("wdt", 0.3) + assert not fw.terminal.events("boot"), "the board reset during a coordinated erase" + fw.system.ping() diff --git a/validation/host/tests/hil/test_hsem.py b/validation/host/tests/hil/test_hsem.py new file mode 100644 index 00000000..df1bb778 --- /dev/null +++ b/validation/host/tests/hil/test_hsem.py @@ -0,0 +1,119 @@ +"""Hardware semaphores of the STM32WB55 through the `hsem` group: two-step locks per process (`HAL_HSEM_Take`/ +`HAL_HSEM_Release`), the lock state read from R (never RLR, whose read is itself a lock attempt), and +`hal::SynchronousHardwareSemaphoreStm` over `hal::SynchronousHardwareSemaphoreMasterStm`. + +`hsem.lock hold=` takes the semaphore for process 1 and lets the scaffold timer (TIM17) free it from its +interrupt after `hold`: the synchronous lock must wait that long (B.9: `WaitLock` used to return at once), and the +query `IsLockedByCurrentCore` must not lock a free semaphore. No wiring. +""" + +from __future__ import annotations + +import time + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +pytestmark = pytest.mark.family("stm32wb55") + + +@pytest.fixture +def hsem_cfg(board_cfg): + return board_cfg.param("hsem") + + +@pytest.fixture +def semaphore(fw, hsem_cfg): + number = hsem_cfg["semaphore"] + assert not fw.hsem.status(number).locked, f"semaphore {number} is held before the test" + yield number + + +def expect_reason(fw, line: str, reason: str) -> None: + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line + + +def test_take_and_release(fw, hsem_cfg, semaphore): + """A two-step lock shows this core and the process in R; release frees it.""" + fw.hsem.take(semaphore, procid=3) + status = fw.hsem.status(semaphore) + assert (status.locked, status.core, status.procid) == (True, hsem_cfg["core"], 3) + assert fw.hsem.mine(semaphore) + fw.hsem.release(semaphore, procid=3) + assert not fw.hsem.status(semaphore).locked + + +def test_other_process_is_refused(fw, semaphore): + """A semaphore held by one process cannot be taken by another; a release by the wrong process changes nothing.""" + fw.hsem.take(semaphore, procid=3) + expect_reason(fw, f"hsem.take {semaphore} procid=4", "busy") + fw.terminal.command(f"hsem.release {semaphore} procid=4") + assert fw.hsem.status(semaphore).procid == 3 + fw.hsem.release(semaphore, procid=3) + + +def test_hold_frees_the_semaphore(fw, semaphore): + """`hold=` frees the semaphore from an EMIL timer.""" + fw.hsem.take(semaphore, procid=2, hold=100) + assert fw.hsem.status(semaphore).locked + time.sleep(0.3) + assert not fw.hsem.status(semaphore).locked + + +@pytest.mark.board_params("hold_us", "hsem.holds_us") +def test_lock_waits_for_foreign_owner(fw, hsem_cfg, semaphore, hold_us): + """`SynchronousHardwareSemaphoreStm` waits until process 1 frees the semaphore from the timer interrupt, and + leaves it free afterwards (B.9).""" + waited = fw.hsem.lock(semaphore, hold=hold_us) + tolerance = hsem_cfg["tolerance"] + assert hold_us * (1 - tolerance) - hsem_cfg["free_wait_max_us"] <= waited <= hold_us * (1 + tolerance) + hsem_cfg["free_wait_max_us"] + assert not fw.hsem.status(semaphore).locked + + +def test_lock_of_free_semaphore_returns_at_once(fw, hsem_cfg, semaphore): + assert fw.hsem.lock(semaphore) <= hsem_cfg["free_wait_max_us"] + assert not fw.hsem.status(semaphore).locked + + +def test_lock_refuses_held_semaphore(fw, semaphore): + """Nothing could free a semaphore held by another process while the lock blocks the event loop: `ERR busy`.""" + fw.hsem.take(semaphore, procid=5) + expect_reason(fw, f"hsem.lock {semaphore}", "busy") + expect_reason(fw, f"hsem.lock {semaphore} hold=1000", "busy") + fw.hsem.release(semaphore, procid=5) + + +def test_query_takes_no_lock(fw, semaphore): + """`IsLockedByCurrentCore` reads R: a free semaphore is not ours and stays free (B.9).""" + assert not fw.hsem.mine(semaphore) + assert not fw.hsem.status(semaphore).locked + fw.hsem.take(semaphore, procid=7) + assert fw.hsem.mine(semaphore) + fw.hsem.release(semaphore, procid=7) + assert not fw.hsem.mine(semaphore) + + +@pytest.mark.parametrize( + "line, reason", + [ + ("hsem.take 1", "usage"), + ("hsem.take procid=1", "usage"), + ("hsem.take 32 procid=1", "range"), + ("hsem.take 1 procid=0", "range"), + ("hsem.take 1 procid=256", "range"), + ("hsem.take 1 procid=1 hold=0", "range"), + ("hsem.take 1 procid=1 hold=60001", "range"), + ("hsem.release 1", "usage"), + ("hsem.status", "usage"), + ("hsem.status 32", "range"), + ("hsem.lock 32", "range"), + ("hsem.lock 1 hold=1", "range"), + ("hsem.lock 1 hold=65537", "range"), + ("hsem.lock 1 procid=1", "usage"), + ("hsem.mine x", "usage"), + ], +) +def test_errors(fw, line, reason): + expect_reason(fw, line, reason) diff --git a/validation/host/tests/hil/test_i2c.py b/validation/host/tests/hil/test_i2c.py new file mode 100644 index 00000000..c007e4d1 --- /dev/null +++ b/validation/host/tests/hil/test_i2c.py @@ -0,0 +1,585 @@ +"""I2C master (`hal::I2cStm`) through the `i2c` group, against the `i2cs` LL target scaffold on the other instance. + +Standalone cases (`tests.i2c.instances`, bundle1, no option): each instance on its own pins with the MCU pull-ups +(`pull=up`), enough for Standard mode on a short bus: the default and computed TIMINGR, address NACK and its +recovery, the zero-length probe, arbitration loss (the AD3 holds SDA low before `i2c.open`, or pulls it low inside +the second address bit after a START) and the argument errors. Nobody answers on these buses, so every transfer +ends in an address NACK. + +`--with i2c` cases (`tests.i2c.loop`): the master and the target on the jumpered rows with 4.7 kOhm pull-ups and the +24LC256 at 0x50: Fast mode, rise time (where a scope sits on the rows), data NACK at every position, the address +NACK after the other direction (B.1h), RELOAD lengths, repeated START, continued sessions in both directions (B.1d), +clock stretching, a bus error from the target's misplaced STOP (B.1e), the general call against an idle I2cStm +(B.1i), close while the bus is held, and both instance directions. + +The module is marked `uses_option("i2c")`: the standalone cases also run with the option fitted (they then share the +rows with the pull-ups and the EEPROM, which answers neither 0x10 nor 0x7f). Checks of the physical bus (levels, +timing, rise time) skip under `--fake`; the fake answers the protocol. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.groups.i2c import DEFAULT_TIMING, I2C_HEX_MAX +from hal_st_validation.i2c import ( + arbitration_window, + arm_on_start, + arm_scope, + expected_timing, + i2c_decode, + inject_on_start, + mode_limits, + rise_times, + scl_timing, +) +from hal_st_validation.patterns import crc_text, generate + +pytestmark = pytest.mark.uses_option("i2c") + +# Scope levels of the 30-70 % rise time on a 3.3 V bus. +VDD = 3.3 +WRITE_READ_LENGTHS = [1, 2, 64, 128] +# (direction, byte position) of the target's clock stretch: the address phase, a written byte, a read byte. +STRETCH_POSITIONS = [("write", 0), ("write", 2), ("read", 0), ("read", 1)] + + +@pytest.fixture +def i2c_cfg(board_cfg) -> dict[str, Any]: + return board_cfg.param("i2c") + + +@pytest.fixture +def bench(request) -> None: + """The case measures the physical bus, which the fake does not model.""" + if request.config.getoption("--fake"): + pytest.skip("measures the physical bus: nothing to measure with --fake") + + +@pytest.fixture +def loop(need, i2c_cfg) -> dict[str, dict[str, Any]]: + """`tests.i2c.loop`, after the gate check of its four pins.""" + loop = i2c_cfg["loop"] + need.unloaded(loop["master"]["scl"], loop["master"]["sda"], loop["target"]["scl"], loop["target"]["sda"]) + return loop + + +def expect_error(reason, call, *args, **options): + with pytest.raises(FirmwareError) as error: + call(*args, **options) + assert error.value.reason == reason, (args, options) + + +def reason(fw, line): + response = fw.terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +def open_standalone(fw, need, instance, **options): + need.unloaded(instance["scl"], instance["sda"]) + return fw.i2c.open(instance["index"], instance["scl"], instance["sda"], pull="up", **options) + + +def bus_dios(need, instance) -> tuple[int, int | None]: + """SCL's DIO (required) and SDA's when `observed` lists it.""" + scl = need.dio(instance["scl"]) + sda = need.dio(instance["sda"]) if "sda" in instance.get("observed", ["scl", "sda"]) else None + return scl, sda + + +def arm_bus(ad3, scl: int, sda: int | None, freq: int, seconds: float): + """The logic analyzer at 100 samples per SCL period (or what the buffer holds for `seconds`), started on a START + when SDA is observed, else on the first SCL falling edge.""" + rate = min(ad3.logic.clock_hz, 100 * freq, ad3.logic.buffer_size / seconds) + samples = int(rate * seconds) + if sda is None: + return ad3.logic.arm(rate, samples, trigger=(scl, "falling"), pretrigger=0.02) + return arm_on_start(ad3, scl, sda, rate, samples, pretrigger=0.02) + + +def check_scl(capture, scl: int, freq: int, tolerance) -> None: + """Frequency within `tolerance` of nominal, and the Standard/Fast mode tLOW/tHIGH minima (one sample of slack).""" + timing = scl_timing(capture.channel(scl), capture.rate) + limits = mode_limits(freq) + slack = 1 / capture.rate + assert tolerance[0] <= timing.freq / freq <= tolerance[1], f"{timing.freq:.0f} Hz for {freq} Hz" + assert timing.tlow_min + slack >= limits.tlow_min, f"tLOW {timing.tlow_min * 1e9:.0f} ns" + assert timing.thigh_min + slack >= limits.thigh_min, f"tHIGH {timing.thigh_min * 1e9:.0f} ns" + + +def open_loop(fw, loop, freq: int | None = None, mode: str = "regs") -> tuple[int, int, int]: + """Master and target of `tests.i2c.loop`: returns (master index, target index, target address).""" + master, target = loop["master"], loop["target"] + fw.i2c.open(master["index"], master["scl"], master["sda"], freq=freq) + address = fw.i2cs.open(target["index"], target["scl"], target["sda"], addr=target["addr"], mode=mode) + return master["index"], target["index"], address + + +# standalone, bundle1, pull=up + + +@pytest.mark.board_params("instance", "i2c.instances") +def test_open_replies_timing(fw, need, i2c_cfg, instance): + """Without `freq` the driver's default `Config` (0x70B03D3D on STM32WB/WBA, B.1f); with `freq` the `I2cTiming` + value for the reported kernel clock; `timing=` is written as given.""" + opened = open_standalone(fw, need, instance) + assert opened.timing == DEFAULT_TIMING + fw.i2c.close(instance["index"]) + for freq in sorted({*i2c_cfg["freqs"]["standalone"], *i2c_cfg["freqs"]["option"]}): + opened = open_standalone(fw, need, instance, freq=freq) + assert opened.timing == expected_timing(opened.kernel, freq) + fw.i2c.close(instance["index"]) + assert open_standalone(fw, need, instance, timing=0x10B0172F).timing == 0x10B0172F + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "i2c.instances") +def test_default_timing_in_spec(fw, ad3, need, bench, i2c_cfg, instance): + """B.1f: the default TIMINGR runs Standard mode on the bus: tLOW >= 4.7 us, tHIGH >= 4.0 us, f <= 100 kHz.""" + scl, sda = bus_dios(need, instance) + open_standalone(fw, need, instance) + capture = arm_bus(ad3, scl, sda, 100_000, 400e-6) + assert fw.i2c.write(instance["index"], i2c_cfg["absent_addr"], b"\x00").result == "nack" + check_scl(capture.wait(timeout=2.0), scl, 100_000, (0.0, 1.01)) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "i2c.instances") +@pytest.mark.board_params("freq", "i2c.freqs.standalone") +def test_timing(fw, ad3, need, bench, i2c_cfg, instance, freq): + scl, sda = bus_dios(need, instance) + open_standalone(fw, need, instance, freq=freq) + capture = arm_bus(ad3, scl, sda, freq, 30 / freq) + fw.i2c.write(instance["index"], i2c_cfg["absent_addr"], b"\x00") + check_scl(capture.wait(timeout=2.0), scl, freq, i2c_cfg["tolerance"]) + + +@pytest.mark.board_params("instance", "i2c.instances") +@pytest.mark.board_params("direction", values=["write", "read"]) +def test_address_nack_recovers(fw, need, i2c_cfg, instance, direction): + """B.1a/b: an address NACK answers `nack` (`sent=0`) after `EVT i2c hook=notfound`, and the same command behaves + the same again: no stale NACKF, no callback left armed.""" + index, absent = instance["index"], i2c_cfg["absent_addr"] + open_standalone(fw, need, instance) + for _ in range(2): + if direction == "write": + reply = fw.i2c.write(index, absent, b"\x01\x02") + assert (reply.sent, reply.result) == (0, "nack") + else: + assert fw.i2c.read(index, absent, 2).result == "nack" + assert fw.i2c.hooks(index) == ["notfound"] + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "i2c.instances") +def test_address_nack_on_bus(fw, ad3, need, bench, i2c_cfg, instance): + """The address and the R/W bit on the bus, NACKed, followed by a STOP.""" + scl = need.dio(instance["scl"]) + sda = need.dio(instance["sda"]) + open_standalone(fw, need, instance, freq=100_000) + for read in (False, True): + capture = arm_bus(ad3, scl, sda, 100_000, 400e-6) + if read: + fw.i2c.read(instance["index"], i2c_cfg["absent_addr"], 1) + else: + fw.i2c.write(instance["index"], i2c_cfg["absent_addr"], b"\x55") + samples = capture.wait(timeout=2.0) + transfers = i2c_decode(samples.channel(scl), samples.channel(sda)) + assert transfers, "no transfer decoded" + first = transfers[0] + assert (first.address, first.read, first.address_ack, first.data, first.stop) == (i2c_cfg["absent_addr"], read, False, [], True) + + +@pytest.mark.board_params("instance", "i2c.instances") +def test_zero_length_probe(fw, need, i2c_cfg, instance): + """`-` without `len` writes no data (NBYTES=0, R2): an address probe that answers `nack` here.""" + open_standalone(fw, need, instance) + reply = fw.i2c.write(instance["index"], i2c_cfg["absent_addr"]) + assert (reply.sent, reply.result) == (0, "nack") + assert fw.i2c.hooks(instance["index"]) == ["notfound"] + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "i2c.instances") +@pytest.mark.board_params("direction", values=["write", "read"]) +def test_arbitration_lost_held(fw, ad3, need, bench, i2c_cfg, instance, direction): + """B.1e: SDA held low by the AD3 before `i2c.open` (no START is latched, BUSY stays 0): the first address 1 bit + loses arbitration (`buserror`, `EVT hook=arblost`); once SDA is released the same instance works (no device: + `nack`).""" + sda = need.dio(instance["sda"]) + index, address = instance["index"], i2c_cfg["arbitration_addr"] + ad3.dio.drive(sda, 0) + try: + open_standalone(fw, need, instance, freq=100_000) + if direction == "write": + reply = fw.i2c.write(index, address, b"\x00") + assert reply.result == "buserror" + else: + assert fw.i2c.read(index, address, 1).result == "buserror" + assert "arblost" in fw.i2c.hooks(index) + finally: + ad3.dio.release(sda) + assert fw.i2c.write(index, address, b"\x00").result == "nack" + assert fw.i2c.hooks(index) == ["notfound"] + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "i2c.instances") +def test_arbitration_lost_triggered(fw, ad3, need, bench, i2c_cfg, instance): + """B.1e: an open instance writes to 0x7f while the AD3 pulls SDA low for one SCL period inside the second + address bit (started by the START detector): `buserror` and `EVT hook=arblost`, then a good transfer.""" + scl, sda = need.dio(instance["scl"]), need.dio(instance["sda"]) + index, address = instance["index"], i2c_cfg["arbitration_addr"] + opened = open_standalone(fw, need, instance, freq=100_000) + delay, width = arbitration_window(opened.timing, opened.kernel) + try: + inject_on_start(ad3, scl, sda, delay, width) + assert fw.i2c.write(index, address, b"\x00").result == "buserror" + assert "arblost" in fw.i2c.hooks(index) + finally: + ad3.pattern.stop() + assert fw.i2c.write(index, address, b"\x00").result == "nack" + + +def test_errors(fw, need, i2c_cfg): + instance = i2c_cfg["instances"][0] + index, scl, sda = instance["index"], fw.pin(instance["scl"]), fw.pin(instance["sda"]) + need.unloaded(scl, sda) + cases = [ + ("i2c.open", "usage"), + (f"i2c.open {index} sda={sda}", "usage"), + (f"i2c.open {index} scl={scl}", "usage"), + (f"i2c.open {index} scl={scl} sda={sda} freq=100000 timing=0x10", "usage"), + (f"i2c.open {index} scl={scl} sda={sda} pull=down", "usage"), + (f"i2c.open {index} scl={scl} sda={sda} speed=1", "usage"), + (f"i2c.open 2 scl={scl} sda={sda}", "range"), + (f"i2c.open 4 scl={scl} sda={sda}", "range"), + (f"i2c.open {index} scl={scl} sda={sda} freq=19999", "range"), + (f"i2c.open {index} scl={scl} sda={sda} freq=400001", "range"), + (f"i2c.open {index} scl={sda} sda={scl}", "pin"), + (f"i2c.write {index} 0x10 00", "notopen"), + (f"i2c.read {index} 0x10 1", "notopen"), + (f"i2c.close {index}", "notopen"), + ] + for line, expected in cases: + assert reason(fw, line) == expected, line + fw.i2c.open(index, scl, sda, pull="up") + cases = [ + (f"i2c.open {index} scl={scl} sda={sda}", "busy"), + (f"i2cs.open {index} scl={scl} sda={sda}", "busy"), + (f"eeprom.attach {index} scl={scl} sda={sda}", "busy"), + (f"gpio.cfg {scl} in", "busy"), + (f"i2c.write {index} 0x80 00", "range"), + (f"i2c.write {index} 0x10 0", "usage"), + (f"i2c.write {index} 0x10 00 next=later", "usage"), + (f"i2c.write {index} 0x10 00 len=1", "usage"), + (f"i2c.write {index} 0x10 - len=1025", "range"), + (f"i2c.read {index} 0x10 0", "range"), + (f"i2c.read {index} 0x10 {I2C_HEX_MAX + 1}", "range"), + (f"i2c.read {index} 0x10 1025 out=crc", "range"), + (f"i2c.read {index} 0x10 1 out=bin", "usage"), + ] + for line, expected in cases: + assert reason(fw, line) == expected, line + fw.i2c.close(index) + fw.i2cs.open(index, scl, sda) + expect_error("busy", fw.i2c.open, index, scl, sda) + + +# --with i2c: master and target on the jumpered rows + + +@pytest.mark.requires_option("i2c") +def test_option_bus_pullups(fw, bench, ad3_released, loop): + """The external pull-ups win against the MCU pull-down on every row pin.""" + for end in ("master", "target"): + for line in ("scl", "sda"): + pin = loop[end][line] + fw.gpio.cfg(pin, "in", pull="down") + try: + assert fw.gpio.get(pin) == 1, pin + finally: + fw.gpio.release(pin) + + +@pytest.mark.board_params("instance", "i2c.instances") +@pytest.mark.conflicts_option("i2c") +def test_no_onboard_pullups(fw, need, bench, ad3_released, instance): + """Without the option nothing on the board pulls the I2C pins up (documents WBA55 PB1/PB2): the MCU pull-down + wins, which is why the standalone cases need `pull=up`.""" + for line in ("scl", "sda"): + pin = instance[line] + need.unloaded(pin) + fw.gpio.cfg(pin, "in", pull="down") + try: + assert fw.gpio.get(pin) == 0, pin + finally: + fw.gpio.release(pin) + + +@pytest.mark.ad3 +@pytest.mark.requires_option("i2c") +@pytest.mark.board_params("freq", "i2c.freqs.option") +def test_option_timing(fw, ad3, need, bench, i2c_cfg, loop, freq): + """Standard and Fast mode with the external pull-ups: frequency within tolerance and the tLOW/tHIGH minima.""" + master, _, address = open_loop(fw, loop, freq=freq) + scl, sda = need.dio(loop["master"]["scl"]), need.dio(loop["master"]["sda"]) + capture = arm_bus(ad3, scl, sda, freq, 60 / freq) + assert fw.i2c.write(master, address, generate(4)).result == "complete" + check_scl(capture.wait(timeout=2.0), scl, freq, i2c_cfg["tolerance"]) + + +@pytest.mark.ad3 +@pytest.mark.requires_option("i2c") +@pytest.mark.board_params("freq", "i2c.freqs.option") +def test_rise_time(fw, ad3, need, bench, i2c_cfg, loop, freq): + """30-70 % rise time of SCL and SDA on the rows (needs a scope on the bus: WBA55 bundle2; the WB55 scopes stay on + the ADC inputs, A.3), within `rise_ns` of the mode.""" + scopes = {line: need.optional_scope(loop["master"][line]) for line in ("scl", "sda")} + if None in scopes.values(): + pytest.skip("no scope on the I2C rows in this wiring set") + limit = i2c_cfg["rise_ns"]["sm" if freq <= 100_000 else "fm"] * 1e-9 + master, _, address = open_loop(fw, loop, freq=freq) + rate = 100e6 + pending = arm_scope(ad3, list(scopes.values()), rate, 8192, scopes["scl"], VDD / 2) + fw.i2c.write(master, address, len=64, pattern="prbs", seed=1) + samples = pending.wait(timeout=2.0) + for line, channel in scopes.items(): + rises = rise_times(samples[channel], rate, 0.3 * VDD, 0.7 * VDD) + assert rises, f"no rising edge on {line}" + assert max(rises) <= limit, f"{line}: {max(rises) * 1e9:.0f} ns rise (use 2.2 kOhm pull-ups)" + + +@pytest.mark.requires_option("i2c") +def test_target_answers(fw, loop): + master, target, address = open_loop(fw, loop) + assert fw.i2c.write(master, address).result == "complete" + assert fw.i2c.hooks(master) == [] + assert fw.i2cs.status(target).writes == 1 + + +@pytest.mark.requires_option("i2c") +@pytest.mark.board_params("position", "i2c.nack_positions") +def test_data_nack(fw, i2c_cfg, loop, position): + _data_nack(fw, loop, i2c_cfg["nack_length"], position) + + +@pytest.mark.requires_option("i2c") +@pytest.mark.board_params("from_end", values=[1, 0]) +def test_data_nack_at_the_end(fw, i2c_cfg, loop, from_end): + """B.1c: a NACK of the next-to-last and of the last byte (the empty `onReceived` used to abort).""" + length = i2c_cfg["nack_length"] + _data_nack(fw, loop, length, length - from_end) + + +def _data_nack(fw, loop, length: int, position: int) -> None: + """B.1a/c: the target NACKs byte `position`: `sent` counts the acknowledged bytes and no address NACK is + reported.""" + master, target, address = open_loop(fw, loop, mode="sink") + fw.i2cs.cfg(target, nack=position) + reply = fw.i2c.write(master, address, len=length, pattern="prbs", seed=position) + assert (reply.sent, reply.result) == (position - 1, "nack") + assert "notfound" not in fw.i2c.hooks(master) + status = fw.i2cs.status(target) + assert (status.rx, status.nacked) == (position - 1, 1) + fw.i2cs.cfg(target) + assert fw.i2c.write(master, address, len=length).result == "complete" + + +@pytest.mark.requires_option("i2c") +def test_address_nack_after_other_direction(fw, i2c_cfg, loop): + """B.1h: a read, then a write to an absent address (`sent=0`, `EVT notfound`); a write, then a read from an + absent address (`nack`, `EVT notfound`).""" + master, _, address = open_loop(fw, loop) + absent = i2c_cfg["absent_addr"] + assert fw.i2c.read(master, address, 4).result == "complete" + reply = fw.i2c.write(master, absent, b"\x01") + assert (reply.sent, reply.result) == (0, "nack") + assert fw.i2c.hooks(master) == ["notfound"] + assert fw.i2c.write(master, address, b"\x00\x01").result == "complete" + assert fw.i2c.read(master, absent, 4).result == "nack" + assert fw.i2c.hooks(master) == ["notfound"] + + +@pytest.mark.requires_option("i2c") +@pytest.mark.board_params("length", values=WRITE_READ_LENGTHS) +def test_write_read_regs(fw, loop, length): + """Register file: the first written byte sets the pointer; a write and a restart read return the data. The write + is the `inc` pattern from 0: pointer 0, then the bytes 1 to `length`.""" + master, target, address = open_loop(fw, loop) + data = generate(length + 1)[1:] + assert fw.i2c.write(master, address, len=length + 1).sent == length + 1 + assert fw.i2c.write(master, address, b"\x00", next="restart").result == "complete" + assert fw.i2c.read(master, address, length).data == data + assert fw.i2cs.dump(target, 0, length) == data + + +@pytest.mark.requires_option("i2c") +@pytest.mark.board_params("length", "i2c.payload_lengths") +def test_reload_lengths(fw, loop, length): + """NBYTES/RELOAD: writes and reads of every length, checked by CRC at both ends.""" + master, target, address = open_loop(fw, loop, mode="sink") + fw.i2cs.cfg(target, pattern="prbs", seed=length) + reply = fw.i2c.write(master, address, len=length, pattern="prbs", seed=7) + assert (reply.sent, reply.result) == (length, "complete") + status = fw.i2cs.status(target) + assert (status.rx, status.crc) == (length, crc_text(generate(length, "prbs", 7))) + read = fw.i2c.read(master, address, length, out="crc") + assert (read.result, read.length, read.crc) == ("complete", length, crc_text(generate(length, "prbs", length))) + + +@pytest.mark.requires_option("i2c") +def test_repeated_start(fw, loop): + """`next=restart`: write the register, read it back after a repeated START, one STOP for both.""" + master, target, address = open_loop(fw, loop) + fw.i2cs.regs(target, 0x20, b"\xde\xad\xbe\xef") + fw.i2c.write(master, address, b"\x20", next="restart") + assert fw.i2c.read(master, address, 4).data == b"\xde\xad\xbe\xef" + status = fw.i2cs.status(target) + assert (status.writes, status.reads, status.stops) == (1, 1, 1) + + +@pytest.mark.ad3 +@pytest.mark.requires_option("i2c") +def test_repeated_start_on_bus(fw, ad3, need, bench, loop): + master, target, address = open_loop(fw, loop, freq=100_000) + scl, sda = need.dio(loop["master"]["scl"]), need.dio(loop["master"]["sda"]) + fw.i2cs.regs(target, 0x20, b"\x5a") + # The master holds SCL low after the write until the host's `i2c.read` arrives: the window spans that round trip. + capture = arm_bus(ad3, scl, sda, 100_000, 30e-3) + fw.i2c.write(master, address, b"\x20", next="restart") + fw.i2c.read(master, address, 1) + samples = capture.wait(timeout=2.0) + transfers = i2c_decode(samples.channel(scl), samples.channel(sda)) + assert len(transfers) >= 2, f"{len(transfers)} transfer(s) captured: the read fell outside the window" + write, read = transfers[:2] + assert (write.read, write.payload, write.stop) == (False, b"\x20", False) + assert (read.restart, read.read, read.payload, read.stop) == (True, True, b"\x5a", True) + + +@pytest.mark.requires_option("i2c") +def test_continue_session(fw, loop): + """`next=continue`: the next write continues the same transaction without a START or an address.""" + master, target, address = open_loop(fw, loop) + assert fw.i2c.write(master, address, b"\x30\x01\x02", next="continue").result == "complete" + assert fw.i2c.write(master, address, b"\x03\x04").result == "complete" + status = fw.i2cs.status(target) + assert (status.writes, status.stops, status.last) == (1, 1, b"\x30\x01\x02\x03\x04") + assert fw.i2cs.dump(target, 0x30, 4) == b"\x01\x02\x03\x04" + + +@pytest.mark.requires_option("i2c") +def test_receive_continue_session(fw, loop): + """B.1d: a read with `next=continue` completes (RELOAD set) and the next read continues the same transfer.""" + master, target, address = open_loop(fw, loop) + fw.i2cs.regs(target, 0x40, bytes(range(8))) + fw.i2c.write(master, address, b"\x40", next="restart") + first = fw.i2c.read(master, address, 3, next="continue") + second = fw.i2c.read(master, address, 5) + assert (first.result, second.result) == ("complete", "complete") + assert (first.data or b"") + (second.data or b"") == bytes(range(8)) + status = fw.i2cs.status(target) + assert (status.reads, status.stops) == (1, 1) + + +def _stretched_transfer(fw, loop, direction: str, position: int, us: int, arm: Callable[[], Any] = lambda: None) -> Any: + """One stretched transfer, `arm()` (its result returned) called right before it. A read's pointer is written + first, unstretched and with a STOP, so the read is a transfer of its own.""" + master, target, address = open_loop(fw, loop, freq=100_000) + fw.i2cs.regs(target, 0, generate(8)) + if direction == "read": + assert fw.i2c.write(master, address, b"\x00").result == "complete" + fw.i2cs.cfg(target, stretch=us, stretchat=position) + capture = arm() + if direction == "write": + assert fw.i2c.write(master, address, bytes([0x80]) + generate(4)).result == "complete" + else: + assert fw.i2c.read(master, address, 4).data == generate(4) + return capture + + +@pytest.mark.requires_option("i2c") +@pytest.mark.board_params("us", "i2c.stretch_us") +@pytest.mark.board_params("direction,position", values=STRETCH_POSITIONS) +def test_clock_stretch_completes(fw, loop, us, direction, position): + """The target holds SCL low for `us` at the address phase (position 0) or at a byte: the transfer completes.""" + _stretched_transfer(fw, loop, direction, position, us) + + +@pytest.mark.ad3 +@pytest.mark.requires_option("i2c") +@pytest.mark.board_params("us", "i2c.stretch_us") +@pytest.mark.board_params("direction,position", values=STRETCH_POSITIONS) +def test_clock_stretch_on_bus(fw, ad3, need, bench, loop, us, direction, position): + """The longest SCL low phase of the stretched transfer alone is at least the stretch (5 % slack for the + Stopwatch).""" + scl = need.dio(loop["master"]["scl"]) + seconds = us * 1e-6 + 2e-3 + rate = min(ad3.logic.clock_hz, ad3.logic.buffer_size / seconds, 10e6) + + def arm(): + return ad3.logic.arm(rate, int(rate * seconds), trigger=(scl, "falling"), pretrigger=0.01) + + capture = _stretched_transfer(fw, loop, direction, position, us, arm) + trace = capture.wait(timeout=2.0).channel(scl) + edges = [index for index in range(1, len(trace)) if trace[index] != trace[index - 1]] + lows = [(edges[i + 1] - edges[i]) / rate for i in range(len(edges) - 1) if not trace[edges[i]]] + assert lows and max(lows) >= 0.95 * us * 1e-6, f"longest SCL low {max(lows, default=0) * 1e6:.0f} us" + + +@pytest.mark.requires_option("i2c") +def test_bus_error(fw, i2c_cfg, loop): + """B.1e: the target turns byte 2 of a read into a misplaced STOP: `buserror` after `EVT hook=buserror`, then + a good read on the same open instance.""" + master, target, address = open_loop(fw, loop, freq=i2c_cfg["fault_freq"], mode="sink") + fw.i2cs.cfg(target, fault="stop", faultat=2) + assert fw.i2c.read(master, address, 8).result == "buserror" + assert "buserror" in fw.i2c.hooks(master) + assert fw.i2c.read(master, address, 8).data == generate(8) + + +@pytest.mark.requires_option("i2c") +def test_general_call_does_not_hang(fw, loop): + """B.1i: an idle I2cStm (the EEPROM adapter's, own address disabled) on the bus does not answer the general call, + so the write is NACKed and the bus stays usable.""" + master, target = loop["master"], loop["target"] + fw.eeprom.attach(target["index"], target["scl"], target["sda"]) + fw.i2c.open(master["index"], master["scl"], master["sda"]) + assert fw.i2c.write(master["index"], 0x00, b"\x00").result == "nack" + assert fw.i2c.write(master["index"], 0x50).result == "complete" + + +@pytest.mark.requires_option("i2c") +def test_close_releases_held_bus(fw, request, loop): + """`next=continue` leaves SCL held low by the master; `i2c.close` releases both lines (regression check, A.3) + and a new open transfers normally.""" + master, _, address = open_loop(fw, loop) + assert fw.i2c.write(master, address, b"\x00\x01", next="continue").result == "complete" + fw.i2c.close(master) + if not request.config.getoption("--fake"): + for line in ("scl", "sda"): + pin = loop["master"][line] + fw.gpio.cfg(pin, "in") + try: + assert fw.gpio.get(pin) == 1, pin + finally: + fw.gpio.release(pin) + fw.i2c.open(master, loop["master"]["scl"], loop["master"]["sda"]) + assert fw.i2c.write(master, address, b"\x00\x02").result == "complete" + + +@pytest.mark.requires_option("i2c") +def test_both_instances(fw, loop): + """Each instance as master against the other as target.""" + for master, target in ((loop["master"], loop["target"]), (loop["target"], loop["master"])): + fw.i2c.open(master["index"], master["scl"], master["sda"]) + address = fw.i2cs.open(target["index"], target["scl"], target["sda"], addr=loop["target"]["addr"]) + assert fw.i2c.write(master["index"], address, b"\x10\xa5").result == "complete" + fw.i2c.write(master["index"], address, b"\x10", next="restart") + assert fw.i2c.read(master["index"], address, 1).data == b"\xa5" + fw.i2c.close(master["index"]) + fw.i2cs.close(target["index"]) diff --git a/validation/host/tests/hil/test_low_power.py b/validation/host/tests/hil/test_low_power.py new file mode 100644 index 00000000..485f181d --- /dev/null +++ b/validation/host/tests/hil/test_low_power.py @@ -0,0 +1,120 @@ +"""Low-power mode through the `lpm` group: `hal::LowPowerModeStm::Enter` inside a window where only the wake line's +EXTI interrupt and the scaffold timer (TIM17, 1 us ticks, also the safety timeout) are enabled and the SysTick tick is +off. The marker pin is low while the core sleeps; `sleeps` (the `Enter` calls, about one per millisecond when WFI +really sleeps) shows that it did. + +`deep` maps to Sleep on STM32WB/WBA (`LowPowerModeStm::Stop`), so it behaves like `sleep` and never calls the +clock-restore callback (`restored=0`). The wake edge comes from the AD3 on the wake pin (bundle1 `gpio0`); the logic +analyzer sees the marker low before the edge and high right after it. +""" + +from __future__ import annotations + +import time + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.firmware import settle + + +@pytest.fixture +def lp_cfg(board_cfg): + return board_cfg.param("lowpower") + + +def expect_reason(fw, line: str, reason: str) -> None: + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line + + +@pytest.mark.ad3 +@pytest.mark.board_params("mode", "lowpower.modes") +@pytest.mark.board_params("edge", "lowpower.edges") +def test_wake_on_edge(request, fw, ad3, need, lp_cfg, mode, edge): + """The core sleeps (marker low) until the edge on the wake pin; the marker rises within `latency_us` of it and + `us` covers the time asleep. `sleeps` proves the WFI: a sleeping core returns from `Enter` once per TIM17 update + (1 ms) plus once for the edge, a driver that never sleeps busy-polls some 10^5 calls in that window.""" + if request.config.getoption("--fake"): + pytest.skip("the fake AD3 sees no board") + wake_dio, marker_dio = need.dio(lp_cfg["wake"]), need.dio(lp_cfg["marker"]) + idle, active = (0, 1) if edge == "rising" else (1, 0) + delay = lp_cfg["wake_delay_s"] + rate = min(ad3.logic.clock_hz, lp_cfg["la_rate"]) + samples = ad3.logic.buffer_size + ad3.dio.drive(wake_dio, idle) + capture = ad3.logic.arm(rate, samples, trigger=(wake_dio, edge), pretrigger=0.5) + timeout_ms = lp_cfg["wake_timeout_ms"] + pending = fw.lpm.begin_enter(mode, wake=lp_cfg["wake"], edge=edge, marker=lp_cfg["marker"], timeout=timeout_ms) + try: + time.sleep(delay) + ad3.dio.drive(wake_dio, active) + result = capture.wait(timeout=2.0) + wake = pending.wait() + finally: + settle(pending.pending, timeout_ms / 1000 + 1) + ad3.dio.release(wake_dio) + assert (wake.woke, wake.restored) == ("exti", 0), wake.raw + assert delay * 1e6 * 0.5 <= wake.us <= timeout_ms * 1000, wake.raw + assert 1 <= wake.sleeps <= 2 * (wake.us // 1000) + 2, wake.raw + marker = result.channel(marker_dio) + trigger = result.trigger_index + assert trigger is not None + assert all(bit == 0 for bit in marker[:trigger]), "the marker was high before the wake edge" + rises = [change.index for change in analysis.edges(marker) if change.index >= trigger and change.rising] + assert rises, "the marker did not rise after the wake edge" + latency = (rises[0] - trigger) / result.rate + assert latency <= lp_cfg["latency_us"] * 1e-6, f"woke {latency * 1e6:.1f} us after the edge" + + +def test_timeout(request, fw, need, lp_cfg): + """Without an edge the scaffold timer ends the window: `ERR timeout` after `timeout` ms; the board then answers, + and its tick runs again (`delay` lasts its time).""" + if request.config.getoption("--fake"): + pytest.skip("the fake firmware has no wake pin model: it wakes at once") + need.unloaded(lp_cfg["wake"], lp_cfg["marker"]) + timeout_ms = lp_cfg["timeout_ms"] + started = time.monotonic() + expect_reason(fw, f"lpm.enter sleep wake={lp_cfg['wake']} marker={lp_cfg['marker']} timeout={timeout_ms}", "timeout") + assert time.monotonic() - started >= timeout_ms / 1000 * 0.9 + fw.system.ping() + started = time.monotonic() + fw.system.delay(100) + assert time.monotonic() - started >= 0.09, "the SysTick tick did not resume" + + +def test_pins_released(fw, lp_cfg): + """`lpm.enter` frees the wake and marker pins and the scaffold timer, whatever its outcome.""" + try: + fw.lpm.enter("sleep", wake=lp_cfg["wake"], marker=lp_cfg["marker"], timeout=lp_cfg["timeout_ms"]) + except FirmwareError as error: + assert error.reason == "timeout" + for pin in (lp_cfg["wake"], lp_cfg["marker"]): + fw.gpio.cfg(pin, "in") + fw.gpio.release(pin) + + +def test_errors(fw, lp_cfg): + wake, marker = lp_cfg["wake"], lp_cfg["marker"] + lines = [ + ("lpm.enter", "usage"), + ("lpm.enter nap", "usage"), + ("lpm.enter sleep edge=both", "usage"), + ("lpm.enter sleep timeout=0", "range"), + ("lpm.enter sleep timeout=10001", "range"), + (f"lpm.enter sleep wake={wake} marker={wake}", "usage"), + ("lpm.enter sleep wake=PA16", "pin"), + (f"lpm.enter sleep wake={lp_cfg['unbonded_pin']}", "pin"), + (f"lpm.enter sleep marker={lp_cfg['unbonded_pin']}", "pin"), + ("lpm.enter sleep extra", "usage"), + ] + for line, reason in lines: + expect_reason(fw, line, reason) + + fw.gpio.cfg(marker, "out") + try: + expect_reason(fw, f"lpm.enter sleep wake={wake} marker={marker} timeout=1", "busy") + finally: + fw.gpio.release(marker) diff --git a/validation/host/tests/hil/test_lptim.py b/validation/host/tests/hil/test_lptim.py new file mode 100644 index 00000000..cd243c13 --- /dev/null +++ b/validation/host/tests/hil/test_lptim.py @@ -0,0 +1,215 @@ +"""Low-power timers (`hal::FreeRunningLowPowerTimerStm`, `hal::LowPowerTimerWithInterruptStm`) through the `lptim` +group: update rate on the marker pin for every prescaler, the WBA repetition counter, interrupt counts, the +free-running counter, stop, and the LPTIM shared with the LPTIM encoder (`qei lp=1`) and `lptpwm`. + +Wiring set `bundle1`: `tests.lptim.marker` (gpio0) on a DIO; the marker toggles once per update interrupt, so it runs +at half the update rate `lptimclk / prescaler / (period + 1) / (rep + 1)`, with the LPTIM kernel clock `lptimclk` of +the board file's `clocks.lptim` (`lptim.open` reports it, test_reports_the_kernel_clock). +""" + +from __future__ import annotations + +import time + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.groups.timers import LPTIM_PERIOD_MAX, LPTIM_REPETITION_MAX, lptim_update_rate + + +@pytest.fixture +def lptim_cfg(board_cfg): + return board_cfg.param("lptim") + + +def dispatchable(values): + """Dispatched callbacks coalesce above 1 kHz: those rates run with `irq=immediate` only.""" + update = values.get("update") + return values.get("irq") != "dispatched" or update is None or update.get("dispatched", True) + + +def expect_error(reason, call, *args, **options): + with pytest.raises(FirmwareError) as error: + call(*args, **options) + assert error.value.reason == reason, (args, options) + + +def marker_frequency(ad3, dio, cfg, marker): + periods = cfg["record_periods"] + capture = ad3.logic.record_for(periods / marker, trigger=(dio, "rising"), timeout=periods / marker + 2) + return capture.frequency(dio) + + +@pytest.mark.board_params("instance", "lptim.instances") +def test_reports_the_kernel_clock(fw, board_cfg, instance): + index = instance["index"] + assert fw.lptim.open(index, irq="none") == board_cfg.clock("lptim", index) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "lptim.instances") +@pytest.mark.matrix("lptim.interrupt") +@pytest.mark.constraint(valid=dispatchable) +def test_update_marker(fw, ad3, need, board_cfg, lptim_cfg, instance, irq, update): + """The marker toggles on every update interrupt: it runs at half the update rate.""" + dio = need.dio(lptim_cfg["marker"]) + index = instance["index"] + fw.lptim.open(index, prescaler=update["prescaler"], period=update["period"], irq=irq, pin=lptim_cfg["marker"]) + fw.lptim.start(index) + lptimclk = board_cfg.clock("lptim", index) + marker = lptim_update_rate(lptimclk, update["prescaler"], update["period"]) / 2 + assert marker_frequency(ad3, dio, lptim_cfg, marker) == pytest.approx(marker, rel=lptim_cfg["tolerance"]["frequency"]) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "lptim.instances") +@pytest.mark.board_params("prescaler", "lptim.prescalers") +def test_prescaler(fw, ad3, need, board_cfg, lptim_cfg, instance, prescaler): + """Every divider of the LPTIM clock (1 to 128) divides the update rate.""" + dio = need.dio(lptim_cfg["marker"]) + index, period = instance["index"], lptim_cfg["prescaler_period"] + fw.lptim.open(index, prescaler=prescaler, period=period, irq="immediate", pin=lptim_cfg["marker"]) + fw.lptim.start(index) + lptimclk = board_cfg.clock("lptim", index) + marker = lptim_update_rate(lptimclk, prescaler, period) / 2 + assert marker_frequency(ad3, dio, lptim_cfg, marker) == pytest.approx(marker, rel=lptim_cfg["tolerance"]["frequency"]) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "lptim.instances") +@pytest.mark.board_params("rep", "lptim.repetitions") +def test_repetition_counter(fw, ad3, need, board_cfg, lptim_cfg, instance, rep): + """`rep` (WBA LPTIM): one update every `rep + 1` periods.""" + dio = need.dio(lptim_cfg["marker"]) + index, timing = instance["index"], lptim_cfg["repetition_timing"] + marker_pin = lptim_cfg["marker"] + fw.lptim.open(index, prescaler=timing["prescaler"], period=timing["period"], rep=rep, irq="immediate", pin=marker_pin) + fw.lptim.start(index) + lptimclk = board_cfg.clock("lptim", index) + marker = lptim_update_rate(lptimclk, timing["prescaler"], timing["period"], rep) / 2 + assert marker_frequency(ad3, dio, lptim_cfg, marker) == pytest.approx(marker, rel=lptim_cfg["tolerance"]["frequency"]) + + +@pytest.mark.board_params("instance", "lptim.instances") +@pytest.mark.matrix("lptim.interrupt") +@pytest.mark.constraint(valid=dispatchable) +def test_interrupt_count(fw, lptim_cfg, instance, irq, update): + """`irqs` follows the update rate; dispatched callbacks at or below 1 kHz are not lost.""" + index = instance["index"] + lptimclk = fw.lptim.open(index, prescaler=update["prescaler"], period=update["period"], irq=irq) + rate = lptim_update_rate(lptimclk, update["prescaler"], update["period"]) + fw.lptim.start(index) + start = time.monotonic() + before = fw.lptim.count(index) + time.sleep(lptim_cfg["window_s"]) + after = fw.lptim.count(index) + elapsed = time.monotonic() - start + tolerance = lptim_cfg["tolerance"] + counted = after.irqs - before.irqs + assert counted >= rate * elapsed * (1 - tolerance["count"]) - rate * tolerance["latency_s"], (counted, rate * elapsed) + assert counted <= rate * elapsed * (1 + tolerance["count"]) + 1, (counted, rate * elapsed) + + +@pytest.mark.board_params("instance", "lptim.instances") +def test_free_running_counter_and_stop(fw, lptim_cfg, instance): + """`irq=none` counts without interrupts; `lptim.stop` freezes the counter; repeated start and stop are harmless.""" + index, free = instance["index"], lptim_cfg["free_running"] + fw.lptim.open(index, prescaler=free["prescaler"], period=free["period"], irq="none") + assert fw.lptim.count(index).irqs == 0 + fw.lptim.start(index) + fw.lptim.start(index) + readings = set() + for _ in range(5): + reading = fw.lptim.count(index) + assert reading.irqs == 0 and reading.cnt <= free["period"] + readings.add(reading.cnt) + assert len(readings) > 1, "the counter does not count" + fw.lptim.stop(index) + fw.lptim.stop(index) + stopped = fw.lptim.count(index) + time.sleep(lptim_cfg["window_s"]) + assert fw.lptim.count(index) == stopped + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "lptim.instances") +def test_stop_stops_the_marker(fw, ad3, need, lptim_cfg, instance): + dio = need.dio(lptim_cfg["marker"]) + index, update = instance["index"], lptim_cfg["interrupt"]["update"][1] + lptimclk = fw.lptim.open(index, prescaler=update["prescaler"], period=update["period"], irq="immediate", pin=lptim_cfg["marker"]) + fw.lptim.start(index) + fw.lptim.stop(index) + marker = lptim_update_rate(lptimclk, update["prescaler"], update["period"]) / 2 + capture = ad3.logic.record_for(lptim_cfg["record_periods"] / marker) + assert len(set(capture.channel(dio))) == 1, "the marker moved after lptim.stop" + + +def test_repetition_support(fw, lptim_cfg): + """`rep` 0..255 on the WBA LPTIM; the STM32WB LPTIM has no repetition counter (`ERR unsupported`).""" + index = lptim_cfg["instances"][0]["index"] + expect_error("range", fw.lptim.open, index, rep=LPTIM_REPETITION_MAX + 1) + if not lptim_cfg["repetition"]: + expect_error("unsupported", fw.lptim.open, index, rep=0) + return + fw.lptim.open(index, rep=LPTIM_REPETITION_MAX) + fw.lptim.close(index) + + +def test_open_errors(fw, board_cfg, lptim_cfg): + """Argument errors in the protocol order: usage, range, pin.""" + index, marker = lptim_cfg["instances"][0]["index"], lptim_cfg["marker"] + cases = [ + ({"irq": "nmi"}, "usage"), + ({"irq": "none", "pin": marker}, "usage"), + ({"period": 0}, "range"), + ({"period": LPTIM_PERIOD_MAX + 1}, "range"), + ({"prescaler": 0}, "range"), + ({"prescaler": 3}, "range"), + ({"prescaler": 256}, "range"), + ({"pin": board_cfg.param("system.unbonded_pins")[0]}, "pin"), + ({"period": 1}, "range"), + ({"period": 1, "irq": "immediate"}, "range"), + ] + for options, reason in cases: + expect_error(reason, fw.lptim.open, index, **options) + assert fw.lptim.open(index, period=1, irq="none") > 0, "the update interrupt rate limit needs an interrupt" + fw.lptim.close(index) + for missing in lptim_cfg["missing"]: + expect_error("range", fw.lptim.open, missing) + for command in ("start", "stop", "count", "close"): + expect_error("notopen", getattr(fw.lptim, command), index) + assert fw.terminal.command(f"lptim.open {index} mode=down", check=False).reason == "usage" + assert fw.terminal.command(f"lptim.open {index} pin=nosuchalias", check=False).reason == "pin" + + +def test_one_lptim_at_a_time(fw, lptim_cfg): + first, second = (instance["index"] for instance in lptim_cfg["instances"][:2]) + fw.lptim.open(first, irq="none") + expect_error("busy", fw.lptim.open, second, irq="none") + + +def test_marker_is_released_on_close(fw, need, lptim_cfg): + index, marker = lptim_cfg["instances"][0]["index"], lptim_cfg["marker"] + need.unloaded(marker) + fw.lptim.open(index, irq="immediate", pin=marker) + expect_error("busy", fw.gpio.cfg, marker, "in") + fw.lptim.close(index) + fw.gpio.cfg(marker, "in") + + +@pytest.mark.board_params("encoder", "qei.lp_instances") +def test_lptim_shared_with_the_encoder(fw, board_cfg, encoder): + """An LPTIM serves one group (`ResourceAllocation` lpTimer): the LPTIM encoder, `lptim` and `lptpwm`.""" + index = encoder["index"] + fw.qei.open(index, lp=True, a=encoder["a"], b=encoder["b"]) + expect_error("busy", fw.lptim.open, index, irq="none") + pwm = next((entry for entry in board_cfg.param("lptim_pwm.instances", []) if entry["index"] == index), None) + if pwm is not None: + expect_error("busy", fw.lptpwm.open, index, pins=pwm["pins"]) + fw.qei.close(index) + fw.lptim.open(index, irq="none") + expect_error("busy", fw.qei.open, index, lp=True, a=encoder["a"], b=encoder["b"]) + fw.lptim.close(index) + if pwm is not None: + fw.lptpwm.open(index, pins=pwm["pins"]) + expect_error("busy", fw.lptim.open, index, irq="none") diff --git a/validation/host/tests/hil/test_lptim_pwm.py b/validation/host/tests/hil/test_lptim_pwm.py new file mode 100644 index 00000000..29c69279 --- /dev/null +++ b/validation/host/tests/hil/test_lptim_pwm.py @@ -0,0 +1,145 @@ +"""LPTIM PWM (`hal::LpTimerPwmWithChannels`, `hal::LpPwmChannelGpio`, STM32WBA55 only: LpTimerPwmStm is not +built for STM32WB) through the `lptpwm` group: frequency and duty per channel, `SetPulse`, an unused channel and the +argument checks. + +Wiring set `bundle1`: LPTIM1 CH2 (PA15) and LPTIM2 CH1/CH2 (PA11/PA1) on DIOs. The tests assert the duty that +`lptpwm.duty` asks for (CCR = ARR * duty / 100, high while the counter is below CCR). If the bench measures 1 - duty +instead (the output polarity of the LPTIM IP, DESIGN R14), the measured value is in the assertion message: record +it as a known gap rather than changing the expectation. The LPTIM takes compare writes only while it is enabled, so +the tests set duties after `lptpwm.start`. +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.groups.timers import LPTIM_PERIOD_MAX, lptim_update_rate, pwm_duty_fraction + +pytestmark = pytest.mark.family("stm32wba55") + + +@pytest.fixture +def lptpwm_cfg(board_cfg): + return board_cfg.param("lptim_pwm") + + +def expect_error(reason, call, *args, **options): + with pytest.raises(FirmwareError) as error: + call(*args, **options) + assert error.value.reason == reason, (args, options) + + +def record(ad3, cfg, frequency): + periods = cfg["record_periods"] + return ad3.logic.record_for(periods / frequency, timeout=periods / frequency + 2) + + +def check_channel(capture, dio, frequency, fraction, cfg): + assert capture.frequency(dio) == pytest.approx(frequency, rel=cfg["tolerance"]["frequency"]), f"DIO{dio} frequency" + measured = capture.duty(dio) + quantisation = 2 * frequency / capture.rate + assert measured == pytest.approx(fraction, abs=cfg["tolerance"]["duty"] + quantisation), ( + f"DIO{dio}: high fraction {measured:.4f}, expected {fraction:.4f} (1 - duty would be {1 - fraction:.4f})" + ) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "lptim_pwm.instances") +@pytest.mark.board_params("duty", "lptim_pwm.duties") +def test_duty(fw, ad3, need, board_cfg, lptpwm_cfg, instance, duty): + """Frequency lptimclk / prescaler / (period + 1) and the duty of every wired channel.""" + dios = [None if pin is None else need.dio(pin) for pin in instance["pins"]] + index = instance["index"] + lptimclk = fw.lptpwm.open(index, pins=instance["pins"], prescaler=lptpwm_cfg["prescaler"], period=lptpwm_cfg["period"]) + assert lptimclk == board_cfg.clock("lptim", index) + fw.lptpwm.start(index) + for channel, dio in enumerate(dios, 1): + if dio is not None: + fw.lptpwm.duty(index, channel, duty) + frequency = lptim_update_rate(lptimclk, lptpwm_cfg["prescaler"], lptpwm_cfg["period"]) + capture = record(ad3, lptpwm_cfg, frequency) + for dio in dios: + if dio is not None: + check_channel(capture, dio, frequency, pwm_duty_fraction(lptpwm_cfg["period"], duty), lptpwm_cfg) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "lptim_pwm.instances") +def test_pulse(fw, ad3, need, lptpwm_cfg, instance): + """`SetPulse(on, period)`: the channel's compare value and the shared auto-reload.""" + channel = next(position for position, pin in enumerate(instance["pins"], 1) if pin is not None) + dio = need.dio(instance["pins"][channel - 1]) + index, pulse = instance["index"], lptpwm_cfg["pulse"] + lptimclk = fw.lptpwm.open(index, pins=instance["pins"], prescaler=lptpwm_cfg["prescaler"], period=lptpwm_cfg["period"]) + fw.lptpwm.start(index) + fw.lptpwm.pulse(index, channel, pulse["ccr"], pulse["period"]) + frequency = lptim_update_rate(lptimclk, lptpwm_cfg["prescaler"], pulse["period"]) + check_channel(record(ad3, lptpwm_cfg, frequency), dio, frequency, pulse["ccr"] / (pulse["period"] + 1), lptpwm_cfg) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "lptim_pwm.instances") +def test_stop(fw, ad3, need, lptpwm_cfg, instance): + """`lptpwm.stop` stops the outputs; start again resumes them; repeating either is harmless.""" + dios = [need.dio(pin) for pin in instance["pins"] if pin is not None] + index = instance["index"] + lptimclk = fw.lptpwm.open(index, pins=instance["pins"], prescaler=lptpwm_cfg["prescaler"], period=lptpwm_cfg["period"]) + fw.lptpwm.start(index) + fw.lptpwm.start(index) + for channel, pin in enumerate(instance["pins"], 1): + if pin is not None: + fw.lptpwm.duty(index, channel, 50) + fw.lptpwm.stop(index) + fw.lptpwm.stop(index) + frequency = lptim_update_rate(lptimclk, lptpwm_cfg["prescaler"], lptpwm_cfg["period"]) + capture = record(ad3, lptpwm_cfg, frequency) + stopped = lptpwm_cfg["record_periods"] // 4 + assert all(analysis.edge_count(capture.channel(dio), "rising") <= stopped for dio in dios), "an output runs after lptpwm.stop" + fw.lptpwm.start(index) + for channel, pin in enumerate(instance["pins"], 1): + if pin is not None: + fw.lptpwm.duty(index, channel, 50) + capture = record(ad3, lptpwm_cfg, frequency) + for dio in dios: + check_channel(capture, dio, frequency, pwm_duty_fraction(lptpwm_cfg["period"], 50), lptpwm_cfg) + + +def test_open_errors(fw, lptpwm_cfg, board_cfg): + """Argument errors in the protocol order: usage, range, pin.""" + instance = next(entry for entry in lptpwm_cfg["instances"] if entry["pins"][0] is not None) + index, pins = instance["index"], instance["pins"] + cases = [ + ({"pins": [None, None]}, "usage"), + ({"pins": [*pins, pins[0]]}, "usage"), + ({"pins": pins, "prescaler": 3}, "range"), + ({"pins": pins, "prescaler": 256}, "range"), + ({"pins": pins, "period": 0}, "range"), + ({"pins": pins, "period": LPTIM_PERIOD_MAX + 1}, "range"), + ({"pins": [lptpwm_cfg["foreign_pin"]]}, "pin"), + ({"pins": list(reversed(pins))}, "pin"), + ] + for options, reason in cases: + expect_error(reason, fw.lptpwm.open, index, **options) + for line, reason in ((f"lptpwm.open {index}", "usage"), (f"lptpwm.open {index} pins=nosuchalias", "pin")): + assert fw.terminal.command(line, check=False).reason == reason, line + for missing in board_cfg.param("lptim.missing"): + expect_error("range", fw.lptpwm.open, missing, pins=pins) + for command, args in (("duty", (1, 50)), ("pulse", (1, 1, 2)), ("start", ()), ("stop", ()), ("close", ())): + expect_error("notopen", getattr(fw.lptpwm, command), index, *args) + fw.lptpwm.open(index, pins=pins) + for command, args in (("duty", (3, 50)), ("duty", (1, 101)), ("pulse", (1, LPTIM_PERIOD_MAX + 1, 2)), ("pulse", (1, 1, 0))): + expect_error("range", getattr(fw.lptpwm, command), index, *args) + + +def test_one_lptim_pwm_at_a_time_and_pins_claimed(fw, need, lptpwm_cfg): + first, second = lptpwm_cfg["instances"][:2] + need.unloaded(*(pin for pin in first["pins"] if pin is not None)) + pin = next(pin for pin in first["pins"] if pin is not None) + fw.lptpwm.open(first["index"], pins=first["pins"]) + expect_error("busy", fw.lptpwm.open, second["index"], pins=second["pins"]) + expect_error("busy", fw.lptim.open, first["index"], irq="none") + expect_error("busy", fw.gpio.cfg, pin, "in") + fw.lptpwm.close(first["index"]) + fw.gpio.cfg(pin, "in") diff --git a/validation/host/tests/hil/test_pka.py b/validation/host/tests/hil/test_pka.py new file mode 100644 index 00000000..56c2c970 --- /dev/null +++ b/validation/host/tests/hil/test_pka.py @@ -0,0 +1,124 @@ +"""Public key accelerator (`hal::PkaStm` on `services::secp256r1`) through the `pka` group: scalar multiplication +(multiples of G, the CAVP ECC CDH vector, n - 1, operands shorter than 32 bytes padded by the firmware), the point +check on and off the curve, the comparison of 4-60 byte operands and the duration of one multiplication. Results +are checked against `crypto_ref`'s affine P-256 arithmetic. No wiring. +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.crypto_ref import P256, P256_CAVP_CDH, from_bytes, scalar_multiply, to_bytes + + +@pytest.fixture +def pka_cfg(board_cfg): + return board_cfg.param("pka") + + +def expect_reason(fw, line, reason): + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line + + +def as_point(product): + return from_bytes(product.x), from_bytes(product.y) + + +def test_default_is_g(fw): + """Without operands the firmware multiplies G by 1.""" + assert as_point(fw.pka.mul()) == P256.g + + +@pytest.mark.board_params("multiple", "pka.multiples") +def test_multiple_of_g(fw, multiple): + k = bytes.fromhex(multiple["k"]) + expected = (int(multiple["x"], 16), int(multiple["y"], 16)) + assert scalar_multiply(P256, from_bytes(k)) == expected, "the vector itself" + product = fw.pka.mul(k=k) + assert len(product.x) == len(product.y) == 32 + assert as_point(product) == expected + + +def test_short_scalar_is_padded(fw): + """A 1-byte scalar equals the same value in 32 bytes: the firmware left-pads every operand.""" + assert as_point(fw.pka.mul(k=b"\x05")) == as_point(fw.pka.mul(k=to_bytes(5))) == scalar_multiply(P256, 5) + + +def test_order_minus_one(fw): + assert as_point(fw.pka.mul(k=to_bytes(P256.n - 1))) == (P256.gx, P256.p - P256.gy) + + +def test_cavp_cdh(fw): + """NIST CAVP ECC CDH (P-256): d * G is the public key, d * Q has the shared secret as x coordinate.""" + vector = P256_CAVP_CDH + public = fw.pka.mul(k=to_bytes(vector.d)) + assert as_point(public) == (vector.qx, vector.qy) + shared = fw.pka.mul(k=to_bytes(vector.d), x=to_bytes(vector.peer_x), y=to_bytes(vector.peer_y)) + assert from_bytes(shared.x) == vector.z + assert as_point(shared) == scalar_multiply(P256, vector.d, (vector.peer_x, vector.peer_y)) + + +def test_given_point(fw): + """`x`/`y` replace G: 3 * (2G) = 6G.""" + double = scalar_multiply(P256, 2) + assert double is not None + product = fw.pka.mul(k=b"\x03", x=to_bytes(double[0]), y=to_bytes(double[1])) + assert as_point(product) == scalar_multiply(P256, 6) + + +def test_point_check(fw): + double = scalar_multiply(P256, 2) + assert double is not None + assert fw.pka.check(to_bytes(P256.gx), to_bytes(P256.gy)) + assert fw.pka.check(to_bytes(double[0]), to_bytes(double[1])) + assert not fw.pka.check(to_bytes(P256.gx), to_bytes(P256.gy + 1)) + assert not fw.pka.check(b"\x01", b"\x02") + + +@pytest.mark.board_params("length", "pka.compare_lengths") +def test_compare(fw, length): + low = bytes(length - 1) + b"\x01" + high = b"\x01" + bytes(length - 1) + assert fw.pka.cmp(low, high) == "lt" + assert fw.pka.cmp(high, low) == "gt" + assert fw.pka.cmp(high, high) == "eq" + assert fw.pka.cmp(low, low[:-1] + b"\x02") == "lt" + + +def test_duration(fw, pka_cfg): + """One multiplication with a full 256-bit scalar is timed by the firmware (`us`).""" + product = fw.pka.mul(k=to_bytes(P256.n - 2)) + assert 0 < product.us <= pka_cfg["max_us"], product.us + + +OPERAND_33 = "01" * 33 + + +@pytest.mark.parametrize( + ("line", "reason"), + [ + ("pka.mul 1", "usage"), + ("pka.mul z=01", "usage"), + ("pka.mul x=01", "usage"), + ("pka.mul y=01", "usage"), + ("pka.mul k=1", "usage"), + ("pka.mul k=-", "usage"), + ("pka.mul k=zz", "usage"), + ("pka.mul k=01 x=01 y=0", "usage"), + (f"pka.mul k={OPERAND_33}", "range"), + (f"pka.mul x={OPERAND_33} y=01", "range"), + ("pka.check", "usage"), + ("pka.check x=01", "usage"), + ("pka.check x=01 y=02 k=01", "usage"), + (f"pka.check x=01 y={OPERAND_33}", "range"), + ("pka.cmp a=00000001", "usage"), + ("pka.cmp a=00000001 b=0000000001", "usage"), + ("pka.cmp a=0000000g b=00000001", "usage"), + ("pka.cmp a=000001 b=000002", "range"), + ], +) +def test_errors(fw, line, reason): + expect_reason(fw, line, reason) diff --git a/validation/host/tests/hil/test_pwm_ext.py b/validation/host/tests/hil/test_pwm_ext.py new file mode 100644 index 00000000..95a887c7 --- /dev/null +++ b/validation/host/tests/hil/test_pwm_ext.py @@ -0,0 +1,296 @@ +"""PWM extensions (`hal::PwmStm`, PROTOCOL.md D.10): the five counter modes, compare preload, the break input +filter, and the trigger output an `adc.open trgo=` sequence runs on. + +Wiring set `bundle1`: the outputs and break inputs of `tests.pwm.timers` on DIOs (`tests.pwm_ext` names the +entries); the TRGO and argument tests need no AD3. +""" + +from __future__ import annotations + +import math +import statistics +import time + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import expect + +# The waveform each mode gives: every centre-aligned mode counts up and down; `edgedown` counts down. +ALIGNMENTS: dict[str, expect.PwmMode] = { + "edge": "edge", + "edgedown": "edge", + "center": "center", + "centerup": "center", + "centerboth": "center", +} +# Where the outputs of one timer line up: the period starts counting up (rising edges) and ends counting down +# (falling edges); centre-aligned pulses share their centres. +FEATURES = {"edge": "rising", "edgedown": "falling"} +# BDTR.BKF: (fDTS divider, consecutive samples); the filter needs divider x samples kernel clocks (CKD = 1). +BREAK_FILTERS = { + 0: (1, 0), + 1: (1, 2), + 2: (1, 4), + 3: (1, 8), + 4: (2, 6), + 5: (2, 8), + 6: (4, 6), + 7: (4, 8), + 8: (8, 6), + 9: (8, 8), + 10: (16, 5), + 11: (16, 6), + 12: (16, 8), + 13: (32, 5), + 14: (32, 6), + 15: (32, 8), +} + + +@pytest.fixture +def ext_cfg(board_cfg): + return board_cfg.param("pwm_ext") + + +@pytest.fixture(scope="module") +def timer_clock(board_cfg): + return board_cfg.clock("timer") + + +def pwm_timer(board_cfg, name): + return next(timer for timer in board_cfg.param("pwm.timers") if timer["name"] == name) + + +def break_filter_ticks(value): + divider, samples = BREAK_FILTERS[value] + return divider * samples + + +def record(ad3, ext_cfg, frequency, trigger_dio=None, periods=None): + periods = periods or ext_cfg["capture_periods"] + trigger = None if trigger_dio is None else (trigger_dio, "rising") + return ad3.logic.record_for(periods / frequency, trigger=trigger, timeout=periods / frequency + 2) + + +def feature_times(capture, dio, feature): + bits = capture.channel(dio) + if feature == "rising": + return analysis.rising_times(bits, capture.rate) + if feature == "falling": + return analysis.falling_times(bits, capture.rate) + return analysis.pulse_centers(bits, capture.rate) + + +@pytest.mark.ad3 +@pytest.mark.board_params("mode", "pwm_ext.modes") +def test_alignment(fw, ad3, need, board_cfg, ext_cfg, mode): + """Every mode gives the frequency and duty of its waveform; two outputs of one timer share their rising edges + counting up, their falling edges counting down (`edgedown`) and their pulse centres centre aligned.""" + timer = pwm_timer(board_cfg, ext_cfg["alignment_timer"]) + channels = timer["channels"][:2] + dios = [need.dio(channel["pin"]) for channel in channels] + frequency, duties = ext_cfg["alignment_frequency"], ext_cfg["alignment_duties"] + tolerance = ext_cfg["tolerance"] + pwmclk = fw.pwm.open(timer["timer"], pins=[channel["pin"] for channel in channels], freq=frequency, mode=mode) + fw.pwm.duty(timer["timer"], *duties) + capture = record(ad3, ext_cfg, frequency, dios[0]) + alignment = ALIGNMENTS[mode] + expected_frequency = expect.pwm_frequency(pwmclk, frequency, alignment) + for dio, duty in zip(dios, duties): + assert capture.frequency(dio) == pytest.approx(expected_frequency, rel=tolerance["frequency"]), f"DIO{dio} frequency" + quantisation = 100 * 2 * frequency / capture.rate + expect.pwm_duty_step(pwmclk, frequency, alignment) + expected_duty = expect.pwm_duty(pwmclk, frequency, alignment, duty) + assert capture.duty(dio) * 100 == pytest.approx(expected_duty, abs=tolerance["duty"] + quantisation), f"DIO{dio} duty" + feature = FEATURES.get(mode, "center") + reference = feature_times(capture, dios[0], feature)[1:-1] + assert reference, "no complete pulses captured" + offsets = analysis.nearest_offsets(reference, feature_times(capture, dios[1], feature)) + worst = max(abs(offset) for offset in offsets) + assert worst <= tolerance["alignment_s"] + 2 / capture.rate, f"{feature} edges off by {worst * 1e9:.0f} ns" + + +@pytest.mark.ad3 +def test_edgedown_cannot_reach_zero(fw, ad3, need, board_cfg, ext_cfg, timer_clock): + """At 0 % `mode=edge` stays low, while `mode=edgedown` keeps one counter tick high per period: counting down, + PWM mode 1 is active while CNT <= CCR, so CCR = 0 still matches once (PwmStm.hpp).""" + settings = ext_cfg["zero_duty"] + timer = pwm_timer(board_cfg, ext_cfg["alignment_timer"]) + pin = timer["channels"][0]["pin"] + dio = need.dio(pin) + tick = settings["tick_us"] / 1e6 + frequency, periods = settings["frequency"], settings["periods"] + prescaler = round(timer_clock * tick) - 1 + for mode in ("edge", "edgedown"): + fw.pwm.open(timer["timer"], pins=[pin], freq=frequency, mode=mode, prescaler=prescaler) + fw.pwm.duty(timer["timer"], 0) + capture = ad3.logic.record_for(periods / frequency, timeout=periods / frequency + 2) + fw.pwm.close(timer["timer"]) + highs = analysis.high_low_times(capture.channel(dio), capture.rate)[0] + if mode == "edge": + assert capture.edge_count(dio) == 0 and capture.channel(dio)[0] == 0, "edge aligned 0 % must stay low" + continue + assert len(highs) >= periods - 2, f"{len(highs)} pulses in {periods} periods" + assert statistics.median(highs) == pytest.approx(tick, abs=0.1 * tick + 2 / capture.rate) + + +@pytest.mark.board_params("timer", "pwm.timers") +def test_modes_and_trgo_need_a_master_timer(fw, timer): + """Every mode but `edge` needs a counter mode select and `trgo` a master mode (both: TIM1, TIM2 and on + STM32WBA55 TIM3); TIM16/TIM17 answer `ERR unsupported`, the others open.""" + number, pin = timer["timer"], timer["channels"][0]["pin"] + supported = expect.timer_has_center_mode(number) + cases = [{"mode": mode} for mode in ALIGNMENTS if mode != "edge"] + [{"trgo": "update"}, {"trgo": "oc1ref"}] + for options in cases: + try: + fw.pwm.open(number, pins=[pin], **options) + except FirmwareError as error: + assert error.reason == "unsupported" and not supported, f"{options}: ERR {error.reason}" + continue + assert supported, f"{options} opened on TIM{number}" + fw.pwm.close(number) + + +@pytest.mark.ad3 +@pytest.mark.parametrize("preload", [0, 1]) +def test_preload(fw, ad3, need, board_cfg, ext_cfg, timer_clock, preload): + """Duty 80 % then 20 %, written `runt_repeats` times while the LA records: without preload a write lands + mid-period and cuts or stretches that period's pulse (some high pulse lies strictly between 20 % and 80 %); + with preload every period shows 20 % or 80 %.""" + timer = pwm_timer(board_cfg, ext_cfg["preload_timer"]) + number, pin = timer["timer"], timer["channels"][0]["pin"] + dio = need.dio(pin) + frequency = ext_cfg["preload_frequency"] + fw.pwm.open(number, pins=[pin], freq=frequency, prescaler=timer_clock // 1_000_000 - 1, preload=bool(preload)) + fw.pwm.duty(number, 20) + window = ext_cfg["runt_window_s"] + rate = min(ad3.logic.clock_hz, ad3.logic.buffer_size / window) + pending = ad3.logic.arm(rate, math.floor(rate * window)) + started = time.monotonic() + for _ in range(ext_cfg["runt_repeats"]): + fw.pwm.duty(number, 80) + fw.pwm.duty(number, 20) + elapsed = time.monotonic() - started + capture = pending.wait(timeout=window + 2) + assert elapsed < window, f"the writes took {elapsed:.3f} s, longer than the {window} s recording" + period = 1 / frequency + margin = ext_cfg["runt_margin"] * period + 2 / capture.rate + highs = analysis.high_low_times(capture.channel(dio), capture.rate)[0] + assert highs, "no pulses captured" + runts = [high for high in highs if 0.2 * period + margin < high < 0.8 * period - margin] + if preload: + assert not runts, f"preload=1: pulses of {[round(high / period * 100, 1) for high in runts]} %" + else: + assert runts, f"preload=0: no cut pulse in {ext_cfg['runt_repeats']} writes" + + +@pytest.mark.ad3 +@pytest.mark.board_params("name", "pwm_ext.break_filter_timers") +@pytest.mark.board_params("case", "pwm_ext.break_filter_cases") +def test_break_filter(fw, ad3, need, board_cfg, ext_cfg, timer_clock, name, case): + """`brkfilter` (BDTR.BKF) ignores a break pulse shorter than the filter: one AD3 pulse of `ticks` kernel clocks + trips the break (outputs off, `brkauto=0`) exactly when it outlasts the filter; without a filter even the + short pulse trips it.""" + timer = pwm_timer(board_cfg, name) + pin, brk = timer["channels"][0]["pin"], timer.get("brk") + if brk is None: + pytest.skip(f"{name} has no wired break input") + dio, brk_dio = need.dio(pin), need.dio(brk) + filter_ticks = break_filter_ticks(case["filter"]) + assert case["trips"] == (case["ticks"] > filter_ticks), "tests.pwm_ext.break_filter_cases contradicts BKF" + frequency = ext_cfg["break_frequency"] + ad3.dio.drive(brk_dio, 0) + fw.pwm.open(timer["timer"], pins=[pin], freq=frequency, brk=brk, brkpol="high", brkfilter=case["filter"]) + fw.pwm.duty(timer["timer"], 50) + assert record(ad3, ext_cfg, frequency).edge_count(dio) > 0, "outputs must run before the pulse" + width = case["ticks"] / timer_clock + ad3.pattern.pulses(brk_dio, 1, 0.5 / width, 0.5) + ad3.pattern.wait_done(timeout=2) + time.sleep(ext_cfg["break_settle_s"]) + edges = record(ad3, ext_cfg, frequency).edge_count(dio) + assert (edges == 0) is case["trips"], f"{width * 1e6:.2f} us pulse, filter {filter_ticks} clocks: {edges} edges after it" + + +def timed_measure(fw, adc, runs, repeats, baud): + """Shortest of `repeats` round trips of `adc.measure n=`, less the transmission of its reply.""" + shortest = math.inf + for _ in range(repeats): + start = time.monotonic() + samples = fw.adc.measure(adc, n=runs, cmd_timeout=3.0) + shortest = min(shortest, time.monotonic() - start) + assert len(samples) == runs + return shortest - expect.uart_transfer_time(len(",".join(str(sample) for sample in samples)), baud) + + +@pytest.mark.matrix("pwm_ext.trgo") +def test_trgo_paces_adc(fw, board_cfg, ext_cfg, need, timer, source): + """`adc.open trgo=` converts once per TRGO of the timer the pwm group drives: with `trgo=update` or + `trgo=oc1ref` once per period, so `adc.measure n=` takes (runs - 1) periods longer than `n=1`.""" + entry = pwm_timer(board_cfg, timer) + adc_cfg = board_cfg.param("adc") + adc, pin = adc_cfg["adc"], adc_cfg["inputs"][0] + need.unloaded(pin, entry["channels"][0]["pin"]) + frequency, runs = ext_cfg["trgo_frequency"], ext_cfg["trgo_runs"] + pwmclk = fw.pwm.open(entry["timer"], pins=[entry["channels"][0]["pin"]], freq=frequency, trgo=source) + fw.adc.open(adc, pins=[pin], trgo=entry["timer"]) + fw.pwm.duty(entry["timer"], 50) + baud, repeats = board_cfg.terminal.baud, ext_cfg["trgo_repeats"] + single = timed_measure(fw, adc, 1, repeats, baud) + measured = timed_measure(fw, adc, runs, repeats, baud) - single + expected = (runs - 1) / expect.pwm_frequency(pwmclk, frequency, "edge") + tolerance, jitter = ext_cfg["trgo_tolerance"], ext_cfg["trgo_jitter_s"] + assert (1 - tolerance) * expected - jitter <= measured <= (1 + tolerance) * expected + jitter, ( + f"{runs - 1} more runs took {measured * 1000:.2f} ms, {expected * 1000:.2f} ms at {frequency} Hz" + ) + + +def test_trgo_errors(fw, board_cfg, ext_cfg): + """`trgo` excludes `timer` and `rate` (`ERR usage`); a timer the MCU lacks is `ERR range`; a timer nobody drives, + or one whose TRGO the ADC cannot trigger from, is `ERR unsupported`; one another group holds is `ERR busy`.""" + adc_cfg = board_cfg.param("adc") + adc, pin = adc_cfg["adc"], adc_cfg["inputs"][0] + number = pwm_timer(board_cfg, ext_cfg["trgo"]["timer"][0])["timer"] + cases = [ + ({"trgo": number}, "unsupported"), + ({"trgo": number, "timer": number}, "usage"), + ({"trgo": number, "rate": 1000}, "usage"), + ({"trgo": 18}, "range"), + ] + for options, reason in cases: + with pytest.raises(FirmwareError) as error: + fw.adc.open(adc, pins=[pin], **options) + assert error.value.reason == reason, options + for name in ext_cfg["trgo_unsupported_timers"]: + entry = pwm_timer(board_cfg, name) + fw.pwm.open(entry["timer"], pins=[entry["channels"][0]["pin"]]) + with pytest.raises(FirmwareError) as error: + fw.adc.open(adc, pins=[pin], trgo=entry["timer"]) + assert error.value.reason == "unsupported", name + fw.pwm.close(entry["timer"]) + encoder = next((instance for instance in board_cfg.param("qei.instances") if instance["index"] == number), None) + if encoder is not None: + fw.qei.open(number, a=encoder["a"], b=encoder["b"]) + with pytest.raises(FirmwareError) as error: + fw.adc.open(adc, pins=[pin], trgo=number) + assert error.value.reason == "busy", "the encoder holds the timer" + + +def test_open_errors(fw, board_cfg, ext_cfg): + """`brkfilter` 0-15 needs `brk` (`ERR usage`), `preload` is 0 or 1, `mode` and `trgo` take their names only.""" + timer = pwm_timer(board_cfg, ext_cfg["alignment_timer"]) + number, brk = timer["timer"], timer["brk"] + cases = [ + ({"brkfilter": 3}, "usage"), + ({"brkfilter": 16}, "range"), + ({"brkfilter": "x", "brk": brk}, "usage"), + ({"brkfilter": 16, "brk": brk}, "range"), + ({"preload": 2}, "range"), + ({"mode": "edgeup"}, "usage"), + ({"trgo": "oc5ref"}, "usage"), + ] + for options, reason in cases: + with pytest.raises(FirmwareError) as error: + fw.command("pwm.open", number, channels=1, **options) + assert error.value.reason == reason, options + fw.pwm.open(number, channels=[1], brk=brk, brkfilter=15, preload=False, mode="centerboth", trgo="oc4ref") diff --git a/validation/host/tests/hil/test_qei.py b/validation/host/tests/hil/test_qei.py index d42f19ac..7c6567a9 100644 --- a/validation/host/tests/hil/test_qei.py +++ b/validation/host/tests/hil/test_qei.py @@ -1,9 +1,10 @@ """Quadrature encoder (`hal::SynchronousQuadratureEncoderStm`, LPTIM `SynchronousQuadratureEncoderLpTimStm`) driven by the AD3 pattern generator. -Wiring set `bundle1`: A, B and index of each `tests.qei.instances` entry on DIOs; the LPTIM1 encoder of the -NUCLEO-WB55RG (`tests.qei.lp_instances`) is wired in `bundle2`. The pattern generator produces an exact number of -4-state cycles (A leads B for `fwd`). +Wiring set `bundle1`: A, B and index of each `tests.qei.instances` entry on DIOs. The LPTIM encoders +(`tests.qei.lp_instances`): NUCLEO-WB55RG LPTIM1 in `bundle2`; NUCLEO-WBA55CG LPTIM2 in `bundle1` and LPTIM1 in +`bundle2`. The pattern generator produces an exact number of 4-state cycles (A leads B for `fwd`); the LPTIM counts +both edges of both inputs (`cap=ab`) or the rising or falling edges only (`cap=rise|fall`, two counts per cycle). """ from __future__ import annotations @@ -46,6 +47,12 @@ def run_inverted(ad3, dios, frequency, cycles, invert_a, invert_b, direction="fw ad3.pattern.wait_done(timeout=cycles / frequency + 2) +def lp_counts(qei, cycles, cap, direction, inva): + """Signed count change of the LPTIM encoder: `counts_per_cycle[cap]` per cycle, reversed by `inva`.""" + sign = 1 if expect.qei_counts(1, "ab", direction, inva) > 0 else -1 + return qei["counts_per_cycle"][cap] * cycles * sign + + def check_counts(fw, qei, index, before, counts, resolution=None): resolution = resolution or qei["resolution"] after = fw.qei.read(index) @@ -125,17 +132,17 @@ def test_index_input(fw, ad3, need, qei, instance): @pytest.mark.ad3 -@pytest.mark.family("stm32wb55") @pytest.mark.board_params("instance", "qei.lp_instances") @pytest.mark.matrix("qei.lp_position") -def test_lptim_position(fw, ad3, need, qei, instance, freq, cycles, direction, inva, filter): - """LPTIM1 (`lp=1`) counts both edges of both inputs; `inva=1` reverses the direction (mirrored mounting).""" +def test_lptim_position(fw, ad3, need, qei, instance, freq, cycles, direction, inva, filter, cap): + """The LPTIM encoder (`lp=1`) counts both edges of both inputs (`ab`) or the rising or falling edges only; + `inva=1` reverses the direction (mirrored mounting).""" dios = instance_dios(need, instance, ("a", "b")) resolution = instance["max_res"] - fw.qei.open(instance["index"], lp=True, a=instance["a"], b=instance["b"], res=resolution, inva=inva, filter=filter) + fw.qei.open(instance["index"], lp=True, a=instance["a"], b=instance["b"], res=resolution, inva=inva, filter=filter, cap=cap) before = fw.qei.read(instance["index"]).pos run(ad3, dios, freq, cycles, direction) - check_counts(fw, qei, instance["index"], before, expect.qei_counts(cycles, "ab", direction, inva), resolution) + check_counts(fw, qei, instance["index"], before, lp_counts(qei, cycles, cap, direction, inva), resolution) def test_default_instance(fw, qei): @@ -183,6 +190,8 @@ def test_open_errors(fw, instance): pins = {"a": instance["a"], "b": instance["b"]} cases = [ ({**pins, "cap": "x"}, "usage"), + ({**pins, "cap": "rise"}, "usage"), + ({**pins, "cap": "fall"}, "usage"), ({**pins, "filter": 16}, "range"), ({**pins, "vel": 0}, "range"), ({**pins, "vel": 1000001}, "range"), @@ -202,14 +211,24 @@ def test_open_errors(fw, instance): assert error.value.reason == "unsupported", "opened without idx" -@pytest.mark.family("stm32wb55") +@pytest.mark.board_params("instance", "qei.lp_instances") +def test_lptim_capture_modes(fw, qei, instance): + """Every LPTIM capture mode opens and reads back the resolution.""" + for cap in qei["lp_position"]["cap"]: + fw.qei.open(instance["index"], lp=True, a=instance["a"], b=instance["b"], cap=cap) + assert fw.qei.read(instance["index"]).res == qei["resolution"], cap + fw.qei.close(instance["index"]) + + @pytest.mark.board_params("instance", "qei.lp_instances") def test_lptim_errors(fw, qei, instance): pins = {"lp": True, "a": instance["a"], "b": instance["b"]} cases = [ ({**pins, "filter": 3}, "range"), ({**pins, "res": instance["max_res"] + 1}, "range"), - ({**pins, "cap": "ab"}, "unsupported"), + ({**pins, "cap": "x"}, "usage"), + ({**pins, "cap": "a"}, "unsupported"), + ({**pins, "cap": "b"}, "unsupported"), ({**pins, "offset": 0}, "unsupported"), ({**pins, "invb": 0}, "unsupported"), ({"lp": True}, "usage"), diff --git a/validation/host/tests/hil/test_qspi.py b/validation/host/tests/hil/test_qspi.py new file mode 100644 index 00000000..25ecde42 --- /dev/null +++ b/validation/host/tests/hil/test_qspi.py @@ -0,0 +1,474 @@ +"""QUADSPI (`hal::QuadSpiStm`, `hal::QuadSpiStmDma`, `hal::SingleSpeedQuadSpiStmDma`) through the `qspi` group, +NUCLEO-WB55RG only. + +Wiring set `bundle1` (or `bundle2`): every QUADSPI pin is on a DIO (CLK PA3 DIO13, NCS PA2 DIO12, IO0 PB9 DIO5, +IO1 PB8 DIO3, IO2 PA7 DIO1, IO3 PA6 DIO7). The AD3 never drives a line the QUADSPI drives: writes are decoded with +the logic analyzer only; reads take their data from AD3 levels on lines the QUADSPI only receives on (IO1 in +single-line mode; IO0-IO3 in a 4-line read without instruction, address or alternate bytes, driven only between +`qspi.open` and the reply, with the weakest AD3 drive). Every case resolves the six pins through the load gate, so +`--with loopback` (PA6-PA7), `--with spiloop` (PA6, PA7) and `--with i2c` (PB8, PB9) skip them. + +A 4-line read with a data phase only hangs the QUADSPI with BUSY set (the STM32 QUADSPI erratum "cannot be used in +indirect read mode when only data phase is activated"; its workaround is two dummy cycles), so the receive tests +add `dummy=2`, which keeps IO0-IO3 undriven. `SingleSpeedQuadSpiStmDma` receives with a data phase only +(`SingleSpeedQuadSpiStmDma.cpp:27`): test_xfer_receive shows whether the erratum hits it. The tests that can leave +the QUADSPI busy reset the board when they fail, so the next test starts clean. + +B.15 (QuadSpiStmDma completes a write on the DMA transfer-complete, while the FIFO still drains): writes answer +`flevel`, the FIFO level sampled in the completion callback, which must be 0 (test_write_completes_after_last_byte), +and two writes issued from one completion callback must both reach the bus (test_back_to_back_writes). +""" + +from __future__ import annotations + +import contextlib +from collections.abc import Iterator +from ctypes import byref, c_double, c_int +from typing import Any + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.firmware import quiesce, settle +from hal_st_validation.groups.qspi import ( + QSPI_BUFFER, + QSPI_DUMMY_MAX, + QSPI_HEX_MAX, + QSPI_PRESCALER_MAX, + QSPI_REPEAT_MAX, + QSPI_SIZE_MAX, + QSPI_TIMEOUT_S, + qspi_bytes, + qspi_clock, + qspi_frame, + qspi_samples, +) +from hal_st_validation.patterns import crc_text, generate + +pytestmark = pytest.mark.family("stm32wb55") + +READ_DUMMY_CYCLES = 2 + + +@pytest.fixture +def qspi_cfg(board_cfg): + return board_cfg.param("qspi") + + +def qspi_pins(cfg) -> dict[str, str]: + pins = cfg["pins"] + return {"clk": pins["clk"], "ncs": pins["ncs"], **{f"io{line}": pin for line, pin in enumerate(pins["io"])}} + + +@pytest.fixture +def index(need, qspi_cfg) -> int: + """The QUADSPI drives all six pins: skip while an enabled option loads one of them.""" + need.unloaded(*qspi_pins(qspi_cfg).values()) + return qspi_cfg["index"] + + +def qspi_dios(need, cfg) -> dict[str, int]: + return {name: need.dio(pin) for name, pin in qspi_pins(cfg).items()} + + +def expect_error(reason, call, *args, **options): + with pytest.raises(FirmwareError) as error: + call(*args, **options) + assert error.value.reason == reason, (args, options) + + +def write_frame(cfg) -> tuple[dict[str, Any], bytes]: + """The `qspi.cmd` options of `tests.qspi.write` and the bytes the bus must carry for them.""" + write = cfg["write"] + options = {key: write[key] for key in ("instr", "addr", "abytes", "alt", "altbytes")} + data = bytes.fromhex(write["data"]) + frame = qspi_frame( + write["instr"], write["addr"].to_bytes(write["abytes"], "big"), write["alt"].to_bytes(write["altbytes"], "big"), data + ) + return {**options, "tx": data}, frame + + +def arm(ad3, dios, clock, duration): + """The logic analyzer on NCS falling, at the highest rate that holds `duration` (at least 4 samples per clock).""" + rate = min(ad3.logic.clock_hz, ad3.logic.buffer_size / duration) + if rate < 4 * clock: + pytest.skip(f"{duration * 1e6:.0f} us at {clock} Hz do not fit the logic analyzer buffer") + return ad3.logic.arm(rate, int(duration * rate), trigger=(dios["ncs"], "falling"), pretrigger=0.02) + + +def frames(capture, dios): + ios = [capture.channel(dios[f"io{line}"]) for line in range(4)] + return qspi_samples(capture.channel(dios["clk"]), capture.channel(dios["ncs"]), ios) + + +def bus_seconds(length, lines, clock, frames_count=1, gap=200e-6): + return frames_count * (length * 8 / lines / clock + gap) + + +@contextlib.contextmanager +def weakest_drive(ad3, dios) -> Iterator[None]: + """The weakest AD3 output current where `FDwfDigitalIODriveInfo` offers a choice, restored afterwards; a device or + runtime without drive control (or the `--fake` API) keeps its default.""" + saved: list[tuple[int, float, int]] = [] + api, handle = ad3.api, ad3.handle + try: + for dio in dios: + with contextlib.suppress(AttributeError, RuntimeError): + low, high, steps, slews = c_double(), c_double(), c_int(), c_int() + api.FDwfDigitalIODriveInfo(handle, c_int(dio), byref(low), byref(high), byref(steps), byref(slews)) + if steps.value > 1: + amplitude, slew = c_double(), c_int() + api.FDwfDigitalIODriveGet(handle, c_int(dio), byref(amplitude), byref(slew)) + api.FDwfDigitalIODriveSet(handle, c_int(dio), c_double(low.value), c_int(0)) + saved.append((dio, amplitude.value, slew.value)) + yield + finally: + for dio, amplitude, slew in saved: + with contextlib.suppress(AttributeError, RuntimeError): + api.FDwfDigitalIODriveSet(handle, c_int(dio), c_double(amplitude), c_int(slew)) + with contextlib.suppress(AttributeError, RuntimeError): + api.FDwfDigitalIOConfigure(handle) + + +@contextlib.contextmanager +def reset_on_failure(fw) -> Iterator[None]: + """A QUADSPI left busy by a command that never completed fails every later command: reset the board.""" + try: + yield + except Exception: + with contextlib.suppress(Exception): + fw.system.reset() + raise + + +@pytest.mark.board_params("variant", values=["poll", "dma", "spi"]) +@pytest.mark.board_params("prescaler", values=[0, 7, QSPI_PRESCALER_MAX]) +def test_open_reports_the_clock(fw, board_cfg, index, variant, prescaler): + assert fw.qspi.open(index, variant=variant, prescaler=prescaler) == qspi_clock(board_cfg.clock("qspi"), prescaler) + + +def test_open_errors(fw, index): + cases = [ + ("qspi.open", "usage"), + ("qspi.open 1 2", "usage"), + ("qspi.open 1 speed=1", "usage"), + ("qspi.open x", "usage"), + ("qspi.open 2", "range"), + ("qspi.open 0", "range"), + ("qspi.open 0 variant=octal", "usage"), + ("qspi.open 1 variant=octal", "usage"), + ("qspi.open 1 prescaler=x", "usage"), + (f"qspi.open 1 prescaler={QSPI_PRESCALER_MAX + 1}", "range"), + ("qspi.open 1 size=0", "range"), + (f"qspi.open 1 size={QSPI_SIZE_MAX + 1}", "range"), + ] + for line, reason in cases: + response = fw.terminal.command(line, check=False) + assert response.reason == reason, line + fw.qspi.open(index, variant="dma", size=QSPI_SIZE_MAX) + expect_error("busy", fw.qspi.open, index) + + +def test_pins_are_held(fw, qspi_cfg, index): + """`qspi.open` claims the six pins of the board profile: a pin held elsewhere makes it busy, and its pins are busy + for the other groups until `qspi.close`.""" + pins = qspi_pins(qspi_cfg) + fw.gpio.cfg(pins["io2"], "out") + expect_error("busy", fw.qspi.open, index) + fw.gpio.release(pins["io2"]) + fw.qspi.open(index, variant="spi") + for pin in pins.values(): + expect_error("busy", fw.gpio.cfg, pin, "in") + fw.qspi.close(index) + fw.gpio.cfg(pins["clk"], "in") + + +def test_commands_need_an_open_instance(fw, index): + for line in ("qspi.cmd 1 instr=0x06", "qspi.poll 1 match=1 mask=1", "qspi.xfer 1 9f", "qspi.close 1", "qspi.cmd 0"): + assert fw.terminal.command(line, check=False).reason == "notopen", line + for line in ("qspi.cmd 2", "qspi.close 2"): + assert fw.terminal.command(line, check=False).reason == "range", line + + +def test_command_errors(fw, index): + fw.qspi.open(index, variant="dma") + cases = [ + ("qspi.cmd 1 2", "usage"), + ("qspi.cmd 1 speed=1", "usage"), + ("qspi.cmd 1 tx=00 rx=1", "usage"), + ("qspi.cmd 1 tx=00 len=1", "usage"), + ("qspi.cmd 1 len=1 rx=1", "usage"), + ("qspi.cmd 1 pattern=inc", "usage"), + ("qspi.cmd 1 tx=00 seed=1", "usage"), + ("qspi.cmd 1 tx=00 out=crc", "usage"), + ("qspi.cmd 1 instr=0x03 rx=1 repeat=2", "usage"), + ("qspi.cmd 1 abytes=2", "usage"), + ("qspi.cmd 1 altbytes=2", "usage"), + ("qspi.cmd 1 lines=3 tx=00 rx=1", "usage"), + ("qspi.cmd 1 instr=x", "usage"), + ("qspi.cmd 1 instr=0x100", "range"), + ("qspi.cmd 1 addr=0x10000 abytes=2", "range"), + ("qspi.cmd 1 addr=0 abytes=5", "range"), + ("qspi.cmd 1 alt=0x100", "range"), + (f"qspi.cmd 1 dummy={QSPI_DUMMY_MAX + 1}", "range"), + ("qspi.cmd 1 lines=2", "range"), + ("qspi.cmd 1 lines=0", "range"), + ("qspi.cmd 1 tx=abc", "usage"), + ("qspi.cmd 1 len=0", "range"), + (f"qspi.cmd 1 len={QSPI_BUFFER + 1}", "range"), + ("qspi.cmd 1 len=4 pattern=noise", "usage"), + ("qspi.cmd 1 rx=0", "range"), + (f"qspi.cmd 1 rx={QSPI_BUFFER + 1} out=crc", "range"), + (f"qspi.cmd 1 rx={QSPI_HEX_MAX + 1}", "range"), + ("qspi.cmd 1 rx=4 out=bin", "usage"), + ("qspi.cmd 1 tx=00 repeat=0", "range"), + (f"qspi.cmd 1 tx=00 repeat={QSPI_REPEAT_MAX + 1}", "range"), + ] + for line, reason in cases: + assert fw.terminal.command(line, check=False).reason == reason, line + + +def test_poll_errors(fw, index): + fw.qspi.open(index, variant="dma") + cases = [ + ("qspi.poll 1", "usage"), + ("qspi.poll 1 match=1", "usage"), + ("qspi.poll 1 mask=1", "usage"), + ("qspi.poll 1 match=1 mask=1 tx=00", "usage"), + ("qspi.poll 1 match=1 mask=1 abytes=1", "usage"), + ("qspi.poll 1 match=x mask=1", "usage"), + ("qspi.poll 1 match=1 mask=1 size=0", "range"), + ("qspi.poll 1 match=1 mask=1 size=5", "range"), + ("qspi.poll 1 match=0x100 mask=1", "range"), + ("qspi.poll 1 match=1 mask=0x1ff size=1", "range"), + ("qspi.poll 1 match=1 mask=1 lines=2", "range"), + ] + for line, reason in cases: + assert fw.terminal.command(line, check=False).reason == reason, line + + +def test_xfer_errors(fw, index): + """`qspi.xfer` needs `variant=spi` and exactly one of tx and rx: `SingleSpeedQuadSpiStmDma` is half duplex.""" + fw.qspi.open(index, variant="dma") + expect_error("unsupported", fw.qspi.xfer, index, b"\x9f") + fw.qspi.close(index) + fw.qspi.open(index, variant="spi") + cases = [ + ("qspi.xfer 1", "usage"), + ("qspi.xfer 1 -", "usage"), + ("qspi.xfer 1 9f rx=1", "usage"), + ("qspi.xfer 1 - len=2 rx=1", "usage"), + ("qspi.xfer 1 - rx=1 repeat=2", "usage"), + ("qspi.xfer 1 9f tx=1", "usage"), + ("qspi.xfer 1 9f len=1", "usage"), + ("qspi.xfer 1 - pattern=inc rx=1", "usage"), + ("qspi.xfer 1 9", "usage"), + ("qspi.xfer 1 - rx=0", "range"), + (f"qspi.xfer 1 - rx={QSPI_HEX_MAX + 1}", "range"), + (f"qspi.xfer 1 - len={QSPI_BUFFER + 1}", "range"), + (f"qspi.xfer 1 9f repeat={QSPI_REPEAT_MAX + 1}", "range"), + ] + for line, reason in cases: + assert fw.terminal.command(line, check=False).reason == reason, line + + +@pytest.mark.board_params("variant", values=["poll", "dma", "spi"]) +def test_replies(fw, qspi_cfg, index, variant): + """Writes answer `flevel=0`, reads their data (or `len` and `crc` with `out=crc`); every phase is optional. The + reads have an instruction phase (read_instr), so they never take the data-only path of the erratum.""" + instr = qspi_cfg["read_instr"] + fw.qspi.open(index, variant=variant) + assert fw.qspi.cmd(index, instr=qspi_cfg["instruction"]).flevel == 0 + options, _ = write_frame(qspi_cfg) + assert fw.qspi.cmd(index, lines=4, **options).flevel == 0 + assert fw.qspi.cmd(index, lines=1, len=QSPI_BUFFER, pattern="prbs", seed=7).flevel == 0 + data = fw.qspi.cmd(index, instr=instr, lines=1, rx=qspi_cfg["read_length"]).data + assert data is not None and len(data) == qspi_cfg["read_length"] + crc = fw.qspi.cmd(index, instr=instr, addr=0, lines=4, dummy=READ_DUMMY_CYCLES, rx=QSPI_BUFFER, out="crc").crc + assert crc is not None and len(crc) == 8 + if variant == "spi": + assert fw.qspi.xfer(index, bytes.fromhex(qspi_cfg["xfer"])).flevel == 0 + + +@pytest.mark.board_params("variant", values=["dma", "spi"]) +def test_write_completes_after_last_byte(fw, qspi_cfg, index, variant): + """B.15: at the slowest clock the last bytes of a write take a while to leave the FIFO; the completion + callback must see it empty.""" + fw.qspi.open(index, variant=variant, prescaler=qspi_cfg["slow_prescaler"]) + length = qspi_cfg["flevel_length"] + if variant == "spi": + reply = fw.qspi.xfer(index, len=length, pattern="prbs", seed=1) + else: + reply = fw.qspi.cmd(index, instr=qspi_cfg["instruction"], lines=1, len=length, pattern="prbs", seed=1) + assert reply.flevel == 0 + + +@pytest.mark.ad3 +@pytest.mark.board_params("variant", values=["poll", "dma"]) +@pytest.mark.board_params("lines", "qspi.lines") +def test_write_decoded(fw, ad3, need, qspi_cfg, index, variant, lines): + """Instruction, address, alternate bytes and data of a write on the bus, all on 1 or on 4 lines.""" + dios = qspi_dios(need, qspi_cfg) + clock = fw.qspi.open(index, variant=variant, prescaler=qspi_cfg["decode_prescaler"]) + options, expected = write_frame(qspi_cfg) + capture = arm(ad3, dios, clock, bus_seconds(len(expected), lines, clock)) + assert fw.qspi.cmd(index, lines=lines, **options).flevel == 0 + found = frames(capture.wait(timeout=2.0), dios) + assert found, "no chip-select frame captured" + assert qspi_bytes(found[0], lines) == expected + + +@pytest.mark.ad3 +@pytest.mark.resets_board +@pytest.mark.board_params("variant", values=["poll", "dma", "spi"]) +def test_back_to_back_writes(fw, ad3, need, qspi_cfg, index, variant): + """B.15: `repeat=2` issues the second write from the completion callback of the first; both reach the bus.""" + dios = qspi_dios(need, qspi_cfg) + cfg = qspi_cfg["back_to_back"] + clock = fw.qspi.open(index, variant=variant, prescaler=qspi_cfg["decode_prescaler"]) + payload = generate(cfg["length"], "prbs", cfg["seed"]) + instr = None if variant == "spi" else qspi_cfg["instruction"] + expected = qspi_frame(instr, data=payload) + capture = arm(ad3, dios, clock, bus_seconds(len(expected), 1, clock, cfg["repeat"])) + with reset_on_failure(fw): + if variant == "spi": + reply = fw.qspi.xfer(index, len=cfg["length"], pattern="prbs", seed=cfg["seed"], repeat=cfg["repeat"]) + else: + reply = fw.qspi.cmd(index, instr=instr, lines=1, len=cfg["length"], pattern="prbs", seed=cfg["seed"], repeat=cfg["repeat"]) + assert reply.flevel == 0 + found = frames(capture.wait(timeout=2.0), dios) + assert [qspi_bytes(frame, 1) for frame in found] == [expected] * cfg["repeat"] + + +@pytest.mark.ad3 +@pytest.mark.resets_board +@pytest.mark.board_params("variant", values=["poll", "dma"]) +@pytest.mark.board_params("nibble", "qspi.nibbles") +def test_receive_nibbles(fw, ad3, need, qspi_cfg, index, variant, nibble): + """The first command after `qspi.open` is a 4-line read without instruction, address or alternate bytes, so the + QUADSPI drives none of IO0-IO3 while the AD3 holds them at `nibble`: every byte is the nibble twice.""" + dios = qspi_dios(need, qspi_cfg) + ios = [dios[f"io{line}"] for line in range(4)] + length = qspi_cfg["read_length"] + fw.qspi.open(index, variant=variant, prescaler=qspi_cfg["decode_prescaler"]) + with reset_on_failure(fw): + try: + with weakest_drive(ad3, ios): + ad3.dio.drive_many({dio: (nibble >> line) & 1 for line, dio in enumerate(ios)}) + reply = fw.qspi.cmd(index, lines=4, dummy=READ_DUMMY_CYCLES, rx=length) + finally: + ad3.dio.release(*ios) + assert reply.data == bytes([nibble << 4 | nibble]) * length + + +@pytest.mark.ad3 +@pytest.mark.board_params("variant", values=["poll", "dma"]) +def test_poll_match(fw, ad3, need, qspi_cfg, index, variant): + """A 1-line instruction followed by a 1-line status read on IO1, which the AD3 holds high: the status matches + at once. The status is read once first, because one that never matches blocks `variant=poll` for good.""" + dios = qspi_dios(need, qspi_cfg) + poll = qspi_cfg["poll"] + clock = fw.qspi.open(index, variant=variant, prescaler=qspi_cfg["decode_prescaler"]) + ad3.dio.drive(dios["io1"], 1) + status = fw.qspi.cmd(index, instr=poll["instr"], lines=1, rx=1).data + if status != b"\xff": + pytest.fail(f"IO1 reads {status!r} with the AD3 driving it high: check DIO3 on PB8") + capture = arm(ad3, dios, clock, bus_seconds(2, 1, clock)) + fw.qspi.poll(index, match=poll["match"], mask=poll["mask"], instr=poll["instr"], lines=1) + found = frames(capture.wait(timeout=2.0), dios) + assert found and len(found[0]) >= 16, "no instruction and status on the bus" + assert qspi_bytes(found[0][:8], 1, line=0) == bytes([poll["instr"]]) + assert qspi_bytes(found[0][8:16], 1, line=1) == b"\xff" + + +@pytest.mark.ad3 +@pytest.mark.board_params("variant", values=["poll", "dma", "spi"]) +@pytest.mark.board_params("level", values=[0, 1]) +def test_read_crc(fw, ad3, need, qspi_cfg, index, variant, level): + """A full-buffer 1-line read of IO1, which the AD3 holds at `level`, answers the CRC-32 of those bytes.""" + dios = qspi_dios(need, qspi_cfg) + fw.qspi.open(index, variant=variant) + ad3.dio.drive(dios["io1"], level) + reply = fw.qspi.cmd(index, instr=qspi_cfg["read_instr"], lines=1, rx=QSPI_BUFFER, out="crc") + assert reply.crc == crc_text(bytes([0xFF * level]) * QSPI_BUFFER) + + +@pytest.mark.ad3 +@pytest.mark.slow +@pytest.mark.resets_board +@pytest.mark.board_params("variant", values=["dma", "poll"]) +def test_poll_timeout(fw, ad3, need, qspi_cfg, index, variant): + """A status that never matches answers `ERR timeout` after 2 s; the group stays busy until `qspi.close`, which + stops the polling (`~QuadSpiStmDma` clears CR), and a new open works. `variant=poll` blocks instead (B.14): + the AD3 then makes the status match, so the firmware returns.""" + dios = qspi_dios(need, qspi_cfg) + poll = qspi_cfg["poll"] + fw.qspi.open(index, variant=variant) + ad3.dio.drive(dios["io1"], 0) + with reset_on_failure(fw): + pending = fw.qspi.begin( + "poll", + index, + match=poll["match"], + mask=poll["mask"], + instr=poll["instr"], + lines=1, + cmd_timeout=fw.terminal.timeout + QSPI_TIMEOUT_S, + ) + response = settle(pending) + if response is None: + ad3.dio.drive(dios["io1"], 1) + quiesce(fw.terminal, quiet=0.5) + assert response is not None and response.reason == "timeout", f"qspi.poll answered {response!r}" + expect_error("busy", fw.qspi.cmd, index, instr=qspi_cfg["instruction"]) + fw.qspi.close(index) + ad3.dio.release(dios["io1"]) + fw.qspi.open(index, variant=variant) + assert fw.qspi.cmd(index, instr=qspi_cfg["instruction"]).flevel == 0 + + +@pytest.mark.slow +@pytest.mark.resets_board +def test_close_recovers_from_a_data_only_read(fw, qspi_cfg, index): + """A 4-line read with a data phase only hangs the QUADSPI with BUSY set (erratum), which only an abort or a + reset clears: `variant=dma` answers `ERR timeout`. `qspi.close` resets the QUADSPI, so after a new open both + drivers complete a write again (`QuadSpiStm` would otherwise wait in `HAL_QSPI_Init` and fail every command).""" + fw.qspi.open(index, variant="dma") + with reset_on_failure(fw): + response = settle(fw.qspi.begin("cmd", index, lines=4, rx=4, cmd_timeout=fw.terminal.timeout + QSPI_TIMEOUT_S)) + if response is not None and response.ok: + pytest.skip("the data-only read completed: the erratum did not hang the QUADSPI") + assert response is not None and response.reason == "timeout", f"qspi.cmd answered {response!r}" + fw.qspi.close(index) + for variant in ("dma", "poll"): + fw.qspi.open(index, variant=variant) + assert fw.qspi.cmd(index, instr=qspi_cfg["instruction"]).flevel == 0, variant + fw.qspi.close(index) + + +@pytest.mark.ad3 +def test_xfer_decoded(fw, ad3, need, qspi_cfg, index): + """`variant=spi`: `SingleSpeedQuadSpiStmDma` writes are SPI mode 0 with MOSI on IO0 and NCS as chip select.""" + dios = qspi_dios(need, qspi_cfg) + payload = bytes.fromhex(qspi_cfg["xfer"]) + clock = fw.qspi.open(index, variant="spi", prescaler=qspi_cfg["decode_prescaler"]) + capture = arm(ad3, dios, clock, bus_seconds(len(payload), 1, clock)) + assert fw.qspi.xfer(index, payload).flevel == 0 + result = capture.wait(timeout=2.0) + spi_frames = result.spi(dios["clk"], dios["io0"], None, dios["ncs"], 0) + assert analysis.spi_join(spi_frames)[0] == payload + assert analysis.clock_idle_level(result.channel(dios["clk"]), result.channel(dios["ncs"])) == 0 + + +@pytest.mark.ad3 +@pytest.mark.resets_board +@pytest.mark.board_params("level", values=[0, 1]) +def test_xfer_receive(fw, ad3, need, qspi_cfg, index, level): + """`variant=spi` receives on IO1 (MISO), which the AD3 holds at `level`; the read has a data phase only.""" + dios = qspi_dios(need, qspi_cfg) + length = qspi_cfg["read_length"] + fw.qspi.open(index, variant="spi", prescaler=qspi_cfg["decode_prescaler"]) + ad3.dio.drive(dios["io1"], level) + with reset_on_failure(fw): + assert fw.qspi.xfer(index, rx=length).data == bytes([0xFF * level]) * length diff --git a/validation/host/tests/hil/test_rng.py b/validation/host/tests/hil/test_rng.py new file mode 100644 index 00000000..f4c74ca9 --- /dev/null +++ b/validation/host/tests/hil/test_rng.py @@ -0,0 +1,241 @@ +"""Random number generator through the `rng` group: `hal::SynchronousRandomDataGeneratorStm` (`variant=sync`), +`hal::RandomDataGeneratorStm` (`variant=async`, interrupt driven) and on STM32WB55 +`hal::SynchronousSynchronizedRandomDataGeneratorStm` (`variant=hsem`: HSEM semaphore 0 around the read, HSI48 managed +by its `Hsi48Enabler` unless semaphore 5 is locked). + +Every variant returns exactly the requested bytes, two reads differ, and 64 KiB pass the monobit, byte chi-square and +runs tests of `rngstats`. On STM32WB55 the synchronized variant keeps HSI48 (the RNG kernel clock) running when it was +on and switches it off again when it was off (B.9b), reads with semaphore 5 held (`lock5=1`), and leaves HSEM +semaphores 0 and 5 free (B.9). The firmware refuses what would hang or abort it: the plain variants while HSI48 is off, +the synchronized one while this core holds semaphore 0, while semaphore 5 is locked with HSI48 off, and while the +coordinated flash driver exists. No wiring. +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import rngstats + + +@pytest.fixture +def rng_cfg(board_cfg): + return board_cfg.param("rng") + + +def expect_reason(fw, line, reason): + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line + + +def assert_semaphores_free(fw, rng_cfg): + """`hsem.status` reads the semaphores without taking them (the hsem group of area-system, when built in).""" + if not hasattr(fw, "hsem"): + return + for semaphore in rng_cfg["semaphores"]: + response = fw.command("hsem.status", semaphore) + assert response.as_int("locked") == 0, f"semaphore {semaphore}: {response.raw}" + + +def needs_clock_group(fw): + if not hasattr(fw, "clock"): + pytest.skip("needs the clock group (clock.hsi48, clock.info)") + + +def needs_hsem_group(fw): + if not hasattr(fw, "hsem"): + pytest.skip("needs the hsem group (hsem.take, hsem.release)") + + +@pytest.mark.board_params("variant", "rng.variants") +@pytest.mark.board_params("length", "rng.lengths") +def test_read_length(fw, variant, length): + read = fw.rng.read(length, variant=variant) + assert len(read.data) == length, read.raw + assert (read.hsi48 is not None) == (variant == "hsem"), read.raw + + +@pytest.mark.board_params("variant", "rng.variants") +def test_reads_differ(fw, variant): + first = fw.rng.read(16, variant=variant).data + second = fw.rng.read(16, variant=variant).data + assert first != second + assert first not in (bytes(16), b"\xff" * 16) + + +@pytest.mark.board_params("variant", "rng.variants") +def test_statistics(fw, rng_cfg, variant): + """64 KiB generated in 256-byte chunks pass the monobit, byte chi-square and runs bounds of `rngstats`.""" + settings = rng_cfg["stats"] + stats = fw.rng.stats(settings["length"], variant=variant, cmd_timeout=settings["timeout_s"]) + assert stats.n == settings["length"], stats.raw + assert rngstats.failures(stats.n, stats.ones, stats.runs, stats.chisq_x1000) == [], stats.raw + assert stats.us > 0, stats.raw + + +@pytest.mark.board_params("variant", "rng.variants") +def test_statistics_differ(fw, variant): + first = fw.rng.stats(256, variant=variant) + second = fw.rng.stats(256, variant=variant) + assert first.n == second.n == 256 + assert first.crc != second.crc + + +def test_variants_take_turns(fw, rng_cfg): + """Each variant enables and disables the RNG on its own; one after the other they all still deliver.""" + variants = list(rng_cfg["variants"]) + for variant in variants + variants[::-1]: + assert len(fw.rng.read(32, variant=variant).data) == 32, variant + + +@pytest.mark.family("stm32wb55") +def test_hsem_keeps_hsi48(fw, rng_cfg): + """With HSI48 running (the default clock's RNG kernel clock) the synchronized read leaves it on, so the other + variants still have their clock afterwards (B.9b).""" + read = fw.rng.read(16, variant="hsem") + assert read.hsi48 == 1, read.raw + assert len(fw.rng.read(16, variant="sync").data) == 16 + assert len(fw.rng.read(16, variant="async").data) == 16 + if hasattr(fw, "clock"): + assert fw.clock.info().flags["hsi48"] == 1 + assert_semaphores_free(fw, rng_cfg) + + +@pytest.mark.family("stm32wb55") +def test_hsem_restores_hsi48_off(fw, rng_cfg): + """With HSI48 off the synchronized read starts it for the read and switches it off again afterwards.""" + needs_clock_group(fw) + fw.clock.hsi48(False) + try: + read = fw.rng.read(16, variant="hsem") + assert read.hsi48 == 0, read.raw + assert len(read.data) == 16 + assert fw.clock.info().flags["hsi48"] == 0 + finally: + fw.clock.hsi48(True) + assert fw.rng.read(16, variant="hsem").hsi48 == 1 + assert_semaphores_free(fw, rng_cfg) + + +@pytest.mark.family("stm32wb55") +def test_hsem_clock_locked(fw, rng_cfg): + """`lock5=1` holds semaphore 5 (clock configuration) around the read. With HSI48 on, the read works, leaves HSI48 on + and frees both semaphores. The locked branch of `Hsi48Enabler` differs from the unlocked one only by its assert with + HSI48 off, which the firmware refuses (test_clock_locked_needs_hsi48); the query itself is covered by + test_hsem.py::test_query_takes_no_lock.""" + first = fw.rng.read(16, variant="hsem", lock5=True) + second = fw.rng.read(16, variant="hsem", lock5=True) + assert first.hsi48 == second.hsi48 == 1, (first.raw, second.raw) + assert first.data != second.data + assert_semaphores_free(fw, rng_cfg) + + +@pytest.mark.family("stm32wb55") +@pytest.mark.parametrize("line", ["rng.read 4 lock5=1", "rng.read 4 variant=async lock5=1"]) +def test_clock_lock_needs_hsem_variant(fw, line): + """`lock5` only applies to the synchronized variant.""" + expect_reason(fw, line, "usage") + + +@pytest.mark.family("stm32wb55") +def test_clock_locked_needs_hsi48(fw, rng_cfg): + """With semaphore 5 locked the driver asserts that HSI48 runs; the firmware refuses that read with HSI48 off.""" + needs_clock_group(fw) + fw.clock.hsi48(False) + try: + expect_reason(fw, "rng.read 4 variant=hsem lock5=1", "failed") + finally: + fw.clock.hsi48(True) + assert_semaphores_free(fw, rng_cfg) + + +@pytest.mark.family("stm32wb55") +def test_plain_variants_need_hsi48(fw): + """HSI48 is the RNG kernel clock (CLK48) and only `Hsi48Enabler` starts it: with HSI48 off the plain drivers would + abort on the clock error or wait forever for data, so the firmware refuses them.""" + needs_clock_group(fw) + fw.clock.hsi48(False) + try: + for line in ("rng.read 4", "rng.read 4 variant=async", "rng.stats 16", "rng.stats 16 variant=async"): + expect_reason(fw, line, "failed") + assert len(fw.rng.read(4, variant="hsem").data) == 4 + finally: + fw.clock.hsi48(True) + assert len(fw.rng.read(4).data) == 4 + + +@pytest.mark.family("stm32wb55") +def test_hsem_refuses_semaphore_0_of_this_core(fw, rng_cfg): + """The driver waits for semaphore 0 with the event loop blocked: held by `hsem.take` nothing could free it.""" + needs_hsem_group(fw) + fw.hsem.take(0, procid=1) + try: + expect_reason(fw, "rng.read 4 variant=hsem", "busy") + expect_reason(fw, "rng.stats 16 variant=hsem", "busy") + finally: + fw.hsem.release(0, procid=1) + assert len(fw.rng.read(4, variant="hsem").data) == 4 + assert_semaphores_free(fw, rng_cfg) + + +@pytest.mark.family("stm32wb55") +def test_hsem_semaphore_5_of_another_process(fw, rng_cfg): + """`IsLockedByCurrentCore` ignores the process: semaphore 5 taken by `hsem.take` leaves HSI48 to the clock owner, + and with HSI48 off the firmware refuses the read the driver would assert on.""" + needs_hsem_group(fw) + needs_clock_group(fw) + fw.hsem.take(5, procid=1) + try: + assert fw.rng.read(4, variant="hsem").hsi48 == 1 + expect_reason(fw, "rng.read 4 variant=hsem lock5=1", "busy") + fw.clock.hsi48(False) + try: + expect_reason(fw, "rng.read 4 variant=hsem", "failed") + finally: + fw.clock.hsi48(True) + finally: + fw.hsem.release(5, procid=1) + assert_semaphores_free(fw, rng_cfg) + + +@pytest.mark.family("stm32wb55") +def test_hsem_excludes_coordinated_flash(fw): + """`variant=hsem` shares HSEM 0 of `ResourceAllocation` with the coordinated flash driver and `hsem.lock`.""" + if not hasattr(fw, "flash"): + pytest.skip("needs the flash group (flash.stack)") + fw.flash.stack("starting") + try: + expect_reason(fw, "rng.read 16 variant=hsem", "busy") + assert len(fw.rng.read(16).data) == 16 + finally: + fw.flash.stack("fus") + assert len(fw.rng.read(16, variant="hsem").data) == 16 + + +@pytest.mark.parametrize( + ("line", "reason"), + [ + ("rng.read", "usage"), + ("rng.read 4 5", "usage"), + ("rng.read x", "usage"), + ("rng.read 4 speed=1", "usage"), + ("rng.read 0", "range"), + ("rng.read 129", "range"), + ("rng.read 4 variant=dma", "usage"), + ("rng.read 4 lock5=2", "range"), + ("rng.stats", "usage"), + ("rng.stats 16 lock5=1", "usage"), + ("rng.stats 15", "range"), + ("rng.stats 65537", "range"), + ("rng.stats 16 variant=x", "usage"), + ], +) +def test_errors(fw, line, reason): + expect_reason(fw, line, reason) + + +@pytest.mark.board_params("line", "rng.unsupported") +def test_unsupported(fw, line): + expect_reason(fw, line, "unsupported") diff --git a/validation/host/tests/hil/test_sgpio.py b/validation/host/tests/hil/test_sgpio.py new file mode 100644 index 00000000..a1c2841e --- /dev/null +++ b/validation/host/tests/hil/test_sgpio.py @@ -0,0 +1,214 @@ +"""Synchronous GPIO (`hal::SynchronousOutputPinStm`, `hal::SmallPeripheralPinStm`, `hal::MultiGpioPinStm` with +`hal::MultiPeripheralPinStm`) through the `sgpio` group. + +Wiring set `bundle1`: the pins of `tests.sgpio` on DIOs. The output pins are read with the AD3 static I/O. An open-drain +output has no pull (`SynchronousOutputPinStm` takes none): on `out_pins` only its low level and its latch are asserted, +on `float_pins` (no board load) the AD3 pulls show that a high level releases the line. The alternate-function cases +mux a timer channel to the pins while the `tpwm` group runs that channel with no pin of its own (`-`): the PWM appears +on every muxed pin exactly when the mux works. +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.groups.timers import pwm_duty_fraction, timer_update_rate + + +@pytest.fixture +def sgpio_cfg(board_cfg): + cfg = board_cfg.param("sgpio", None) + if cfg is None: + pytest.skip("tests.sgpio not configured") + return cfg + + +def expect_error(reason, call, *args, **options): + with pytest.raises(FirmwareError) as error: + call(*args, **options) + assert error.value.reason == reason, (args, options) + + +def follows_pulls(ad3, dio): + """An input without pull reads whatever pull the AD3 applies.""" + ad3.dio.pull(up=[dio]) + up = ad3.dio.read(dio) + ad3.dio.pull(down=[dio]) + down = ad3.dio.read(dio) + ad3.dio.pull() + return up == 1 and down == 0 + + +def run_channel(fw, cfg): + """Start the timer of `tests.sgpio.tpwm` with its first channel unused (`-`); returns the PWM frequency and the + expected high fraction.""" + if not hasattr(fw, "tpwm"): + pytest.skip("needs the tpwm group") + tpwm = cfg["tpwm"] + pins = [None if pin == "-" else pin for pin in tpwm["pins"]] + timclk = fw.tpwm.open(tpwm["timer"], pins, prescaler=tpwm["prescaler"], period=tpwm["period"]) + fw.tpwm.duty(tpwm["timer"], 1, tpwm["duty"]) + fw.tpwm.start(tpwm["timer"]) + return timer_update_rate(timclk, tpwm["prescaler"], tpwm["period"]), pwm_duty_fraction(tpwm["period"], tpwm["duty"]) + + +def check_pwm(ad3, cfg, dios, frequency, fraction): + periods = cfg["record_periods"] + capture = ad3.logic.record_for(periods / frequency, timeout=periods / frequency + 2) + tolerance = cfg["tolerance"] + for dio in dios: + assert capture.frequency(dio) == pytest.approx(frequency, rel=tolerance["frequency"]), f"DIO{dio} frequency" + quantisation = 2 * frequency / capture.rate + assert capture.duty(dio) == pytest.approx(fraction, abs=tolerance["duty"] + quantisation), f"DIO{dio} duty" + + +@pytest.mark.ad3 +@pytest.mark.board_params("pin", "sgpio.out_pins") +@pytest.mark.board_params("speed", "sgpio.speeds") +def test_push_pull_levels(fw, ad3, need, pin, speed): + dio = need.dio(pin) + ad3.dio.release(dio) + for level in (1, 0, 1, 0): + fw.sgpio.out(pin, level, speed=speed) + assert ad3.dio.read(dio) == level, f"{pin} set to {level}" + assert fw.sgpio.latch(pin) == level + + +@pytest.mark.ad3 +@pytest.mark.board_params("pin", "sgpio.out_pins") +def test_open_drain_low_and_latch(fw, ad3, need, pin): + dio = need.dio(pin) + ad3.dio.release(dio) + fw.sgpio.out(pin, 0, od=True) + assert ad3.dio.read(dio) == 0, "open drain must pull low" + assert fw.sgpio.latch(pin) == 0 + fw.sgpio.out(pin, 1) + assert fw.sgpio.latch(pin) == 1, "a later out keeps open drain and sets the latch" + fw.sgpio.out(pin, 0) + assert ad3.dio.read(dio) == 0 + + +@pytest.mark.ad3 +@pytest.mark.board_params("pin", "sgpio.float_pins") +def test_switch_drive_and_release(fw, ad3, need, pin): + """Open drain releases the line at 1 and holds it low at 0; a changed `od` rebuilds the pin as push-pull, which + drives the high level against both pulls; `sgpio.release` leaves an input without pull (`float_pins` carry no board load, so the AD3 + pulls decide its level).""" + dio = need.dio(pin) + ad3.dio.release(dio) + fw.sgpio.out(pin, 1, od=True) + assert follows_pulls(ad3, dio), f"{pin} open drain at 1 must release the line" + fw.sgpio.out(pin, 0, od=True) + assert not follows_pulls(ad3, dio), f"{pin} open drain at 0 must hold the line low" + fw.sgpio.out(pin, 1, od=False) + assert not follows_pulls(ad3, dio), f"{pin} push-pull high must override the AD3 pull-down" + assert ad3.dio.read(dio) == 1, "push-pull high after open drain" + fw.sgpio.release(pin) + assert follows_pulls(ad3, dio), f"{pin} must be an input without pull after sgpio.release" + + +@pytest.mark.ad3 +def test_af_by_timer(fw, ad3, need, sgpio_cfg): + af = sgpio_cfg["af"] + dio = need.dio(af["pin"]) + frequency, fraction = run_channel(fw, sgpio_cfg) + assert fw.sgpio.af(af["pin"], timer=af["timer"], ch=af["ch"]) == af["af"] + check_pwm(ad3, sgpio_cfg, [dio], frequency, fraction) + fw.sgpio.release(af["pin"]) + assert follows_pulls(ad3, dio), "SmallPeripheralPinStm must leave an input without pull" + + +@pytest.mark.ad3 +def test_multi(fw, ad3, need, sgpio_cfg): + multi = sgpio_cfg["multi"] + dios = [need.dio(pin) for pin in multi["pins"]] + frequency, fraction = run_channel(fw, sgpio_cfg) + fw.sgpio.multi(multi["pins"], timer=multi["timer"], ch=multi["ch"]) + check_pwm(ad3, sgpio_cfg, dios, frequency, fraction) + fw.sgpio.release(multi["pins"][-1]) + ad3.dio.pull(down=dios) + capture = ad3.logic.record_for(sgpio_cfg["record_periods"] / frequency, timeout=sgpio_cfg["record_periods"] / frequency + 2) + for dio in dios: + assert analysis.edge_count(capture.channel(dio)) == 0, f"DIO{dio} still muxed after releasing the set" + + +def test_af_number(fw, sgpio_cfg): + """`af=` muxes a raw alternate function and reports it.""" + pin = sgpio_cfg["af"]["pin"] + assert fw.sgpio.af(pin, af=sgpio_cfg["af"]["af"]) == sgpio_cfg["af"]["af"] + fw.sgpio.release(pin) + + +def test_slots(fw, sgpio_cfg): + """Four outputs, four alternate-function pins and one set at a time; a pin serves one use.""" + pins = sgpio_cfg["limit_pins"] + for pin in pins[:4]: + fw.sgpio.out(pin, 0) + expect_error("busy", fw.sgpio.out, pins[4], 0) + expect_error("busy", fw.sgpio.af, pins[0], af=0) + for pin in pins[:4]: + fw.sgpio.release(pin) + for pin in pins[:4]: + fw.sgpio.af(pin, af=0) + expect_error("busy", fw.sgpio.af, pins[4], af=0) + expect_error("busy", fw.sgpio.out, pins[0], 0) + for pin in pins[:4]: + fw.sgpio.release(pin) + multi = sgpio_cfg["multi"] + fw.sgpio.multi(multi["pins"], timer=multi["timer"], ch=multi["ch"]) + expect_error("busy", fw.sgpio.multi, multi["pins"][:1], timer=multi["timer"], ch=multi["ch"]) + expect_error("busy", fw.sgpio.out, multi["pins"][0], 0) + fw.sgpio.release(multi["pins"][0]) + for pin in multi["pins"]: + fw.sgpio.out(pin, 0) + + +def test_shared_with_gpio(fw, sgpio_cfg): + pin = sgpio_cfg["out_pins"][0] + fw.gpio.cfg(pin, "in") + expect_error("busy", fw.sgpio.out, pin, 1) + fw.gpio.release(pin) + fw.sgpio.out(pin, 0) + expect_error("busy", fw.gpio.cfg, pin, "in") + + +def test_errors(fw, board_cfg, sgpio_cfg): + out_pin = sgpio_cfg["out_pins"][0] + af = sgpio_cfg["af"] + multi = sgpio_cfg["multi"] + reserved = board_cfg.terminal.pins[0] + unbonded = board_cfg.param("system.unbonded_pins")[0] + for line, reason in ( + (f"sgpio.out {fw.pin(out_pin)} 2", "range"), + (f"sgpio.out {fw.pin(out_pin)} 1 od=2", "range"), + (f"sgpio.out {fw.pin(out_pin)} 1 speed=fastest", "usage"), + (f"sgpio.out {fw.pin(out_pin)}", "usage"), + (f"sgpio.out {fw.pin(out_pin)} 1 pull=up", "usage"), + (f"sgpio.out {reserved} 1", "busy"), + (f"sgpio.out {unbonded} 1", "pin"), + ("sgpio.out nosuchpin 1", "pin"), + (f"sgpio.latch {fw.pin(out_pin)}", "notopen"), + (f"sgpio.release {fw.pin(out_pin)}", "notopen"), + (f"sgpio.af {fw.pin(af['pin'])}", "usage"), + (f"sgpio.af {fw.pin(af['pin'])} timer={af['timer']} af=1", "usage"), + (f"sgpio.af {fw.pin(af['pin'])} af=1 ch=1", "usage"), + (f"sgpio.af {fw.pin(af['pin'])} timer=0", "range"), + (f"sgpio.af {fw.pin(af['pin'])} timer=18", "range"), + (f"sgpio.af {fw.pin(af['pin'])} timer={sgpio_cfg['missing_timer']}", "range"), + (f"sgpio.af {fw.pin(af['pin'])} timer={af['timer']} ch=5", "range"), + (f"sgpio.af {fw.pin(af['pin'])} af=16", "range"), + (f"sgpio.af {fw.pin(sgpio_cfg['foreign_pin'])} timer={af['timer']} ch={af['ch']}", "pin"), + (f"sgpio.af {unbonded} af=1", "pin"), + (f"sgpio.af {reserved} af=1", "busy"), + (f"sgpio.multi {fw.pin(multi['pins'][0])}", "usage"), + (f"sgpio.multi , timer={multi['timer']}", "usage"), + (f"sgpio.multi {fw.pin(multi['pins'][0])},{fw.pin(multi['pins'][0])} timer={multi['timer']}", "usage"), + (f"sgpio.multi {','.join([fw.pin(multi['pins'][0])] * 5)} timer={multi['timer']}", "usage"), + (f"sgpio.multi {fw.pin(multi['pins'][0])} timer={sgpio_cfg['missing_timer']}", "range"), + (f"sgpio.multi {fw.pin(sgpio_cfg['foreign_pin'])} timer={multi['timer']}", "pin"), + ): + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line diff --git a/validation/host/tests/hil/test_spi.py b/validation/host/tests/hil/test_spi.py index 314637b7..cbce3b56 100644 --- a/validation/host/tests/hil/test_spi.py +++ b/validation/host/tests/hil/test_spi.py @@ -1,10 +1,11 @@ """SPI master (`hal::SpiMasterStm`, `SpiMasterStmDma`, `SynchronousSpiMasterStm`), decoded from a logic-analyzer capture. -Wiring set `bundle1`: CLK, CS, MOSI and MISO of `tests.spi.instances` on DIOs. The AD3 SDK has no verified -SPI-slave mode, so the firmware master is observed with the logic analyzer: MISO is driven to a static level by -the AD3, or, with `--with loopback` and a MOSI-MISO jumper, only monitored (the firmware must then read back what -it sent). The clock is the fastest spiclk / 2^n not above `baud` (`expect.spi_clock`). +Wiring set `bundle1`: CLK, CS, MOSI and MISO of `tests.spi.instances` on DIOs (an instance with `option` is +reached only through that option's jumpers, e.g. WB55 SPI2 with `--with spiloop`). The firmware master is +observed with the logic analyzer: MISO is driven to a static level by the AD3, or, where the loopback jumper ties +the instance's MOSI to its MISO (`--with loopback`), only monitored (the firmware must then read back what it +sent). The clock is the fastest spiclk / 2^n not above `baud` (`expect.spi_clock`). """ from __future__ import annotations @@ -21,10 +22,19 @@ def spi_cfg(board_cfg): return board_cfg.param("spi") -def spi_dios(need, instance): +def spi_dios(need, wiring, instance): + option = instance.get("option") + if option and not wiring.has(option): + pytest.skip(f"{instance['name']} is wired with --with {option} only") return {key: need.dio(instance[key]) for key in ("clk", "cs", "mosi", "miso")} +def looped(wiring, board_cfg, instance): + """The loopback jumper ties this instance's MOSI to its MISO.""" + mosi, miso = (board_cfg.resolve_pin(instance[key]) for key in ("mosi", "miso")) + return miso in wiring.jumpered_to(mosi, {"loopback"}) + + def measured_clock(clk, rate, baud): """Clock frequency over each burst of edges (a gap of more than 1.5 periods separates bursts).""" edges = [edge.index for edge in analysis.edges(clk)] @@ -60,8 +70,8 @@ def expected_rx(payload, loopback, miso_level): return payload if loopback else bytes([0xFF if miso_level else 0x00] * len(payload)) -def prepare_miso(ad3, wiring, dios, miso_level): - loopback = wiring.has("loopback") +def prepare_miso(ad3, wiring, board_cfg, instance, dios, miso_level): + loopback = looped(wiring, board_cfg, instance) if loopback and miso_level: pytest.skip("MISO follows MOSI with the loopback jumper") if not loopback: @@ -70,12 +80,14 @@ def prepare_miso(ad3, wiring, dios, miso_level): @pytest.mark.ad3 +@pytest.mark.uses_option("loopback") +@pytest.mark.uses_option("spiloop") @pytest.mark.board_params("instance", "spi.instances") @pytest.mark.matrix("spi.transfer") @pytest.mark.board_params("miso_level", "spi.miso_levels") def test_transfer(fw, ad3, need, wiring, board_cfg, spi_cfg, instance, mode, baud, variant, cs, miso_level): - dios = spi_dios(need, instance) - loopback = prepare_miso(ad3, wiring, dios, miso_level) + dios = spi_dios(need, wiring, instance) + loopback = prepare_miso(ad3, wiring, board_cfg, instance, dios, miso_level) use_cs = cs == "gpio" open_spi(fw, spi_cfg, instance, variant, use_cs, baud=baud, mode=mode) clock = expect.spi_clock(board_cfg.clock("spi", instance["index"]), baud) @@ -106,11 +118,12 @@ def test_transfer(fw, ad3, need, wiring, board_cfg, spi_cfg, instance, mode, bau @pytest.mark.ad3 +@pytest.mark.uses_option("spiloop") @pytest.mark.board_params("instance", "spi.instances") @pytest.mark.matrix("spi.sessions") -def test_continued_session(fw, ad3, need, spi_cfg, instance, mode, variant): +def test_continued_session(fw, ad3, need, wiring, spi_cfg, instance, mode, variant): """`continue=1` keeps the chip select low for the next `spi.xfer`; the bytes of both arrive in one session.""" - dios = spi_dios(need, instance) + dios = spi_dios(need, wiring, instance) ad3.dio.drive(dios["miso"], 0) baud = spi_cfg["session_baud"] open_spi(fw, spi_cfg, instance, variant, baud=baud, mode=mode) @@ -129,22 +142,26 @@ def test_continued_session(fw, ad3, need, spi_cfg, instance, mode, variant): @pytest.mark.ad3 +@pytest.mark.uses_option("loopback") +@pytest.mark.uses_option("spiloop") @pytest.mark.board_params("instance", "spi.instances") @pytest.mark.matrix("spi.receive_only") -def test_receive_only_first(fw, ad3, need, wiring, spi_cfg, instance, variant): +def test_receive_only_first(fw, ad3, need, wiring, board_cfg, spi_cfg, instance, variant): """A receive-only transfer (`spi.xfer - rx=`) right after `spi.open` clocks out zeros and returns MISO.""" - dios = spi_dios(need, instance) - loopback = prepare_miso(ad3, wiring, dios, 0 if wiring.has("loopback") else 1) + dios = spi_dios(need, wiring, instance) + loopback = prepare_miso(ad3, wiring, board_cfg, instance, dios, 0 if looped(wiring, board_cfg, instance) else 1) open_spi(fw, spi_cfg, instance, variant, baud=1000000) assert fw.spi.xfer(instance["index"], b"", rx=4) == (b"\x00" * 4 if loopback else b"\xff" * 4) @pytest.mark.ad3 +@pytest.mark.uses_option("loopback") +@pytest.mark.uses_option("spiloop") @pytest.mark.board_params("instance", "spi.instances") @pytest.mark.matrix("spi.largest") -def test_largest_transfer(fw, ad3, need, wiring, spi_cfg, instance, variant): - dios = spi_dios(need, instance) - loopback = prepare_miso(ad3, wiring, dios, 0 if wiring.has("loopback") else 1) +def test_largest_transfer(fw, ad3, need, wiring, board_cfg, spi_cfg, instance, variant): + dios = spi_dios(need, wiring, instance) + loopback = prepare_miso(ad3, wiring, board_cfg, instance, dios, 0 if looped(wiring, board_cfg, instance) else 1) open_spi(fw, spi_cfg, instance, variant, baud=1000000) size = spi_cfg["max_transfer"] payload = bytes((i * 11 + 5) & 0xFF for i in range(size)) diff --git a/validation/host/tests/hil/test_spi_ext.py b/validation/host/tests/hil/test_spi_ext.py new file mode 100644 index 00000000..7df63f48 --- /dev/null +++ b/validation/host/tests/hil/test_spi_ext.py @@ -0,0 +1,210 @@ +"""SPI master extensions (PROTOCOL.md "SPI master"): the bit order (`lsb=1`), frame sizes (`bits=`, a +`hal::SpiDataSizeConfiguratorStm` on `hal::SpiMasterStmDma`), an 8-bit master reopened after a 16-bit one and the +hardware slave select (`nss=`). + +The master is observed with the logic analyzer (`tests.spi_ext.instances`; an entry with `option` is reached only +through that option's jumpers). Words are decoded with `spiwords` at the frame size and compared with the driver's +frame model (`spiwords.spi_frames`: frames above 8 bits take two buffer bytes). MISO is a static AD3 level, or +follows MOSI where the loopback jumper ties them (`--with loopback`). `nss=` is a known gap (DESIGN B.14): the +masters initialise SPI_NSS_SOFT, so NSS is never driven and `test_hardware_nss` is an expected failure. +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import expect +from hal_st_validation.spiwords import BITS_MAX, BITS_MIN, spi_bytes, spi_decode_words, spi_frames, spi_join_words + +pytestmark = [pytest.mark.uses_option("loopback"), pytest.mark.uses_option("spiloop")] + +VARIANTS = ["interrupt", "dma", "sync"] +LIMITED_BITS = (8, 16) +DEFAULT_BITS = 8 + + +@pytest.fixture +def ext_cfg(board_cfg): + return board_cfg.param("spi_ext") + + +def skip_without_option(wiring, entry): + option = entry.get("option") + if option and not wiring.has(option): + pytest.skip(f"{entry['name']} is wired with --with {option} only") + + +def looped(wiring, board_cfg, instance): + """MISO tied to MOSI by the loopback jumper.""" + mosi, miso = (board_cfg.resolve_pin(instance[key]) for key in ("mosi", "miso")) + return miso in wiring.jumpered_to(mosi, {"loopback"}) + + +def prepare(ad3, need, wiring, board_cfg, instance, select="cs"): + """The DIOs of the instance; MISO driven high unless the loopback jumper drives it.""" + skip_without_option(wiring, instance) + dios = {key: need.dio(instance[key]) for key in ("clk", "mosi", "miso", select)} + loop = looped(wiring, board_cfg, instance) + if not loop: + ad3.dio.drive(dios["miso"], 1) + return dios, loop + + +def open_master(fw, board_cfg, ext_cfg, instance, variant="dma", select="cs", **options): + driver = board_cfg.param("spi.variants")[variant] + fw.spi.open( + instance["index"], + clk=instance["clk"], + mosi=instance["mosi"], + miso=instance["miso"], + baud=ext_cfg["baud"], + dma=driver["dma"], + sync=driver["sync"], + **{select: instance[select]}, + **options, + ) + return expect.spi_clock(board_cfg.clock("spi", instance["index"]), ext_cfg["baud"]) + + +def capture_transfer(fw, ad3, index, payload, clock, trigger): + """`spi.xfer` of `payload` under the logic analyzer: the reply and the capture.""" + duration = len(payload) * 8 / clock * 4 + 200e-6 + rate = min(ad3.logic.clock_hz, 20 * clock, ad3.logic.buffer_size / duration) + if rate < 4 * clock: + pytest.skip("the transfer does not fit the logic analyzer buffer") + pending = ad3.logic.arm(rate, int(duration * rate), trigger=trigger, pretrigger=0.05) + received = fw.spi.xfer(index, payload) + return received, pending.wait(timeout=2.0) + + +def listed_size(values): + """The frame size is one the instance lists (`bits`).""" + return "instance" not in values or "bits" not in values or values["bits"] in values["instance"]["bits"] + + +def decode(result, dios, bits=DEFAULT_BITS, msb_first=True): + lines = [result.channel(dios[key]) for key in ("clk", "mosi", "miso", "cs")] + return spi_join_words(spi_decode_words(*lines, mode=0, bits=bits, msb_first=msb_first)) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spi_ext.instances") +@pytest.mark.board_params("lsb", "spi_ext.lsb") +@pytest.mark.board_params("variant", values=VARIANTS) +def test_bit_order(fw, ad3, need, wiring, board_cfg, ext_cfg, instance, lsb, variant): + """`lsb=1` sends and receives the least significant bit first (`Config::msbFirst`), on every driver.""" + dios, loop = prepare(ad3, need, wiring, board_cfg, instance) + clock = open_master(fw, board_cfg, ext_cfg, instance, variant, lsb=bool(lsb)) + payload = bytes.fromhex(ext_cfg["payload"]) + received, result = capture_transfer(fw, ad3, instance["index"], payload, clock, (dios["cs"], "falling")) + wanted = payload if loop else b"\xff" * len(payload) + mosi, miso = decode(result, dios, msb_first=not lsb) + assert bytes(mosi) == payload + assert bytes(miso) == wanted + assert received == wanted + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spi_ext.instances") +@pytest.mark.board_params("bits", values=list(range(BITS_MIN, BITS_MAX + 1))) +@pytest.mark.constraint(valid=listed_size) +def test_frame_sizes(fw, ad3, need, wiring, board_cfg, ext_cfg, instance, bits): + """`bits=` clocks n-bit frames (`spiwords.spi_frames`); the received frames come back right aligned.""" + dios, loop = prepare(ad3, need, wiring, board_cfg, instance) + clock = open_master(fw, board_cfg, ext_cfg, instance, bits=bits) + payload = bytes.fromhex(ext_cfg["payload"]) + frames = spi_frames(payload, bits) + received, result = capture_transfer(fw, ad3, instance["index"], payload, clock, (dios["cs"], "falling")) + wanted = frames if loop else [(1 << bits) - 1] * len(frames) + mosi, miso = decode(result, dios, bits) + assert mosi == frames + assert miso == wanted + assert received == spi_bytes(wanted, bits) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spi_ext.instances") +def test_8_bit_after_16_bit_reopen(fw, ad3, need, wiring, board_cfg, ext_cfg, instance): + """A master reopened at 8 bits after a 16-bit open runs 8-bit frames. Every open builds new DMA streams, whose + constructor resets the data widths, so this cannot catch a width that sticks on a live GPDMA channel (B.4a is + proved by review; its 32-bit encoding by test_dma.py::test_wave_32_bit).""" + if 16 not in instance["bits"]: + pytest.skip(f"{instance['name']} has no 16-bit frames") + dios, loop = prepare(ad3, need, wiring, board_cfg, instance) + open_master(fw, board_cfg, ext_cfg, instance, bits=16) + fw.spi.xfer(instance["index"], b"\x34\x12\x78\x56") + fw.spi.close(instance["index"]) + clock = open_master(fw, board_cfg, ext_cfg, instance) + payload = bytes.fromhex(ext_cfg["payload"]) + received, result = capture_transfer(fw, ad3, instance["index"], payload, clock, (dios["cs"], "falling")) + wanted = payload if loop else b"\xff" * len(payload) + mosi, miso = decode(result, dios) + assert bytes(mosi) == payload + assert received == wanted + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spi_ext.instances") +@pytest.mark.board_params("variant", values=VARIANTS) +def test_hardware_nss(fw, ad3, need, wiring, board_cfg, ext_cfg, instance, variant): + """`nss=`: NSS low while the master clocks, high after the transfer. Known gap (B.14): the masters + initialise SPI_NSS_SOFT, so the pin (pulled up by the AD3 here) never goes low.""" + dios, _ = prepare(ad3, need, wiring, board_cfg, instance, select="nss") + ad3.dio.pull(up=[dios["nss"]]) + clock = open_master(fw, board_cfg, ext_cfg, instance, variant, select="nss") + payload = bytes.fromhex(ext_cfg["payload"]) + _, result = capture_transfer(fw, ad3, instance["index"], payload, clock, (dios["clk"], "either")) + nss = result.channel(dios["nss"]) + edges = analysis.edges(result.channel(dios["clk"])) + assert edges, "no clock captured" + assert all(nss[edge.index] == 0 for edge in edges), "NSS must be low while the master clocks" + assert nss[-1] == 1, "NSS must be released after the transfer" + + +@pytest.mark.board_params("instance", "spi_ext.instances") +def test_open_frame_sizes(fw, instance): + """`bits` 4..16 needs `dma=1` (8 is the default and needs nothing); a limited instance (WBA55 SPI3, + `IS_SPI_LIMITED_INSTANCE`) takes 8 and 16 only; anything else is `ERR unsupported`, outside 4..16 `ERR range`.""" + index = instance["index"] + pins = {key: instance[key] for key in ("clk", "mosi", "miso")} + for bits in range(BITS_MIN, BITS_MAX + 1): + accepted = not instance.get("limited") or bits in LIMITED_BITS + try: + fw.spi.open(index, **pins, dma=True, bits=bits) + except FirmwareError as error: + assert error.reason == "unsupported" and not accepted, f"bits={bits}: ERR {error.reason}" + continue + assert accepted, f"bits={bits} accepted on a limited instance" + fw.spi.close(index) + for options, reason in ( + ({"dma": True, "bits": BITS_MIN - 1}, "range"), + ({"dma": True, "bits": BITS_MAX + 1}, "range"), + ({"bits": 16}, "unsupported"), + ({"sync": True, "bits": 16}, "unsupported"), + ({"lsb": 2}, "range"), + ): + with pytest.raises(FirmwareError) as error: + fw.spi.open(index, **pins, **options) + assert error.value.reason == reason, options + fw.spi.open(index, **pins, bits=DEFAULT_BITS, lsb=True) + fw.spi.close(index) + + +@pytest.mark.board_params("instance", "spi_ext.instances") +def test_open_slave_select(fw, instance): + """`nss` must offer the instance's slave select (`ERR pin`) and excludes the GPIO chip select `cs` (`ERR usage`).""" + index = instance["index"] + pins = {key: instance[key] for key in ("clk", "mosi", "miso")} + for options, reason in ( + ({"nss": instance["nss"], "cs": instance["cs"]}, "usage"), + ({"nss": instance["clk"]}, "pin"), + ): + with pytest.raises(FirmwareError) as error: + fw.spi.open(index, **pins, **options) + assert error.value.reason == reason, options + fw.spi.open(index, **pins, nss=instance["nss"], dma=True) + with pytest.raises(FirmwareError) as error: + fw.gpio.cfg(instance["nss"], "out") + assert error.value.reason == "busy", "the open master holds its NSS pin" diff --git a/validation/host/tests/hil/test_spi_slave.py b/validation/host/tests/hil/test_spi_slave.py new file mode 100644 index 00000000..bc0da0c8 --- /dev/null +++ b/validation/host/tests/hil/test_spi_slave.py @@ -0,0 +1,296 @@ +"""SPI slave (`hal::SpiSlaveStmDma`, PROTOCOL.md "SPI slave"), clocked by the AD3 SPI master and, with +`--with spiloop`, by the board's own SPI master on the other instance. + +The AD3 master runs mode 0, MSB first, with its chip select on the slave's NSS (`tests.spis.instances`; an entry +with `option` is reached only through that option's jumpers). Slave and master send different payloads, so each +side checks what the other received; results above 128 bytes are compared by CRC. Every case may run with the +spiloop jumpers fitted: they tie the pins to the other SPI instance, which stays unconfigured. +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation.firmware import quiesce, settle +from hal_st_validation.groups.spis import SPIS_HEX_MAX, SpisResult +from hal_st_validation.patterns import crc_text, generate + +pytestmark = pytest.mark.uses_option("spiloop") + +DIRECTIONS = ["duplex", "send", "receive"] +WORD = 4 + + +@pytest.fixture +def spis_cfg(board_cfg): + return board_cfg.param("spis") + + +def skip_without_option(wiring, entry): + option = entry.get("option") + if option and not wiring.has(option): + pytest.skip(f"{entry['name']} is wired with --with {option} only") + + +def open_slave(fw, instance): + fw.spis.open(instance["index"], instance["clk"], instance["miso"], instance["mosi"], instance["nss"]) + return instance["index"] + + +def ad3_master(ad3, need, wiring, instance, freq): + """The AD3 SPI master on the slave's pins.""" + skip_without_option(wiring, instance) + dios = {key: need.dio(instance[key]) for key in ("clk", "miso", "mosi", "nss")} + ad3.spi.configure(dios["clk"], dios["mosi"], dios["miso"], dios["nss"], freq, mode=0) + + +def expect_received(fw, index, data): + if len(data) > SPIS_HEX_MAX: + assert fw.spis.result(index, out="crc") == SpisResult(True, length=len(data), crc=crc_text(data)) + else: + assert fw.spis.result(index) == SpisResult(True, rx=data) + + +def exchange(fw, ad3, index, slave_tx, master_tx): + """One full-duplex transfer of equal lengths, checked on both sides.""" + fw.spis.arm(index, slave_tx) + assert ad3.spi.transfer(master_tx) == slave_tx + expect_received(fw, index, master_tx) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spis.instances") +@pytest.mark.board_params("freq", "spis.freqs") +@pytest.mark.board_params("length", "spis.lengths") +@pytest.mark.board_params("direction", values=DIRECTIONS) +def test_ad3_master(fw, ad3, need, wiring, instance, freq, length, direction): + """Full duplex, send only (`rx=0`) and receive only (`-` with `rx=`); the slave payload is generated in + firmware (`len=`, `pattern=prbs`).""" + ad3_master(ad3, need, wiring, instance, freq) + index = open_slave(fw, instance) + slave_tx = generate(length, "prbs", length) + master_tx = generate(length, "inc", 0x40) + if direction == "receive": + fw.spis.arm(index, rx=length) + else: + fw.spis.arm(index, len=length, pattern="prbs", seed=length, rx=0 if direction == "send" else None) + master_rx = ad3.spi.transfer(master_tx) + if direction != "receive": + assert master_rx == slave_tx + expect_received(fw, index, b"" if direction == "send" else master_tx) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spis.instances") +def test_unarmed_clocks_then_transfer(fw, ad3, need, wiring, spis_cfg, instance): + """Frames clocked while nothing is armed do not lead the next transfer (B.2: SPE off between transfers, the + STM32WB RX FIFO drained before arming).""" + ad3_master(ad3, need, wiring, instance, spis_cfg["freqs"][0]) + index = open_slave(fw, instance) + exchange(fw, ad3, index, b"\x10\x11\x12\x13", b"\xa0\xa1\xa2\xa3") + ad3.spi.transfer(b"\xde\xad\xbe\xef") + exchange(fw, ad3, index, b"\x20\x21\x22\x23", b"\xb0\xb1\xb2\xb3") + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spis.instances") +def test_master_clocks_more_than_armed(fw, ad3, need, wiring, spis_cfg, instance): + """Armed for 4 bytes, clocked for 8: done with the first 4 (B.2); the next transfer is exact.""" + ad3_master(ad3, need, wiring, instance, spis_cfg["freqs"][0]) + index = open_slave(fw, instance) + slave_tx = b"\x31\x32\x33\x34" + master_tx = bytes(range(0xC0, 0xC0 + 2 * WORD)) + fw.spis.arm(index, slave_tx) + assert ad3.spi.transfer(master_tx)[:WORD] == slave_tx + expect_received(fw, index, master_tx[:WORD]) + exchange(fw, ad3, index, b"\x41\x42\x43\x44", b"\xd0\xd1\xd2\xd3") + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spis.instances") +def test_underrun(fw, ad3, need, wiring, spis_cfg, instance): + """Clocks with no transfer armed complete nothing (`done=0` at once); an armed transfer afterwards is exact.""" + ad3_master(ad3, need, wiring, instance, spis_cfg["freqs"][0]) + index = open_slave(fw, instance) + ad3.spi.transfer(b"\x01\x02\x03\x04") + assert fw.spis.result(index) == SpisResult(False) + exchange(fw, ad3, index, b"\x51\x52\x53\x54", b"\xe0\xe1\xe2\xe3") + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spis.instances") +def test_short_transfer_and_cancel(fw, ad3, need, wiring, spis_cfg, instance): + """Armed for 8 bytes, clocked for 4: `done=0` after the wait; `spis.cancel` stops it (`cancelled=1`) and the + next transfer is exact.""" + ad3_master(ad3, need, wiring, instance, spis_cfg["freqs"][0]) + index = open_slave(fw, instance) + slave_tx = bytes(range(0x60, 0x60 + 2 * WORD)) + fw.spis.arm(index, slave_tx) + assert ad3.spi.transfer(b"\x71\x72\x73\x74") == slave_tx[:WORD] + assert fw.spis.result(index, wait=100) == SpisResult(False) + assert fw.spis.cancel(index) is True + assert fw.spis.result(index) == SpisResult(False) + assert fw.spis.cancel(index) is False + exchange(fw, ad3, index, b"\x81\x82\x83\x84", b"\xf0\xf1\xf2\xf3") + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spis.instances") +def test_result_waits_for_the_transfer(fw, ad3, need, wiring, spis_cfg, instance): + """`spis.result` armed and not done answers when the transfer completes.""" + ad3_master(ad3, need, wiring, instance, spis_cfg["freqs"][0]) + index = open_slave(fw, instance) + fw.spis.arm(index, b"\x91\x92") + pending = fw.spis.begin_result(index, wait=5000) + try: + assert ad3.spi.transfer(b"\x93\x94") == b"\x91\x92" + finally: + response = settle(pending) + assert response is not None and SpisResult.parse(response) == SpisResult(True, rx=b"\x93\x94") + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spis.instances") +def test_second_result_while_waiting_is_busy(fw, ad3, need, wiring, spis_cfg, instance): + """A `spis.result` while another waits answers `ERR busy` at once; the waiting one still answers `done=1`.""" + ad3_master(ad3, need, wiring, instance, spis_cfg["freqs"][0]) + index = open_slave(fw, instance) + fw.spis.arm(index, b"\xa1\xa2") + pending = fw.spis.begin_result(index, wait=5000) + fw.terminal.send_nowait(f"spis.result {index}") + busy = settle(pending) + fw.terminal.history.clear() + ad3.spi.transfer(b"\xa3\xa4") + quiesce(fw.terminal, quiet=0.3) + assert busy is not None and busy.reason == "busy" + assert "OK done=1 rx=a3a4" in fw.terminal.history + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spis.instances") +def test_rejected_arm_keeps_the_armed_payload(fw, ad3, need, wiring, spis_cfg, instance): + """An `spis.arm` while a transfer is armed answers `ERR busy` before it parses its payload: the transmit DMA + still reads the armed payload, longer than any SPI FIFO here, and the master receives it unchanged.""" + ad3_master(ad3, need, wiring, instance, spis_cfg["freqs"][0]) + index = open_slave(fw, instance) + length = 8 * WORD + slave_tx = generate(length, "prbs", 11) + master_tx = generate(length, "inc", 0x30) + fw.spis.arm(index, slave_tx) + for line in ( + f"spis.arm {index} {generate(length, 'inc', 0xA0).hex()}", + f"spis.arm {index} 0102 rx=1", + f"spis.arm {index} - len={length} pattern=const seed=255", + ): + assert fw.terminal.command(line, check=False).reason == "busy", line + assert ad3.spi.transfer(master_tx) == slave_tx + expect_received(fw, index, master_tx) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "spis.instances") +def test_reopen_after_close(fw, ad3, need, wiring, spis_cfg, instance): + ad3_master(ad3, need, wiring, instance, spis_cfg["freqs"][0]) + index = open_slave(fw, instance) + exchange(fw, ad3, index, b"\xb1\xb2\xb3\xb4", b"\x0b\x1b\x2b\x3b") + fw.spis.close(index) + open_slave(fw, instance) + exchange(fw, ad3, index, b"\xc1\xc2\xc3\xc4", b"\x0c\x1c\x2c\x3c") + + +@pytest.mark.requires_option("spiloop") +@pytest.mark.board_params("loop", "spis.loops") +@pytest.mark.board_params("variant", "spis.loop_variants") +@pytest.mark.board_params("length", "spis.loop_lengths") +def test_loop(fw, board_cfg, spis_cfg, loop, variant, length): + """The board's master (`spi.*`, the variant's driver) against the slave on the other instance; the master's + GPIO chip select drives the slave's NSS through the jumpers.""" + master, slave = loop["master"], loop["slave"] + driver = board_cfg.param("spi.variants")[variant] + open_slave(fw, slave) + fw.spi.open( + master["index"], + clk=master["clk"], + mosi=master["mosi"], + miso=master["miso"], + cs=master["cs"], + baud=spis_cfg["loop_baud"], + dma=driver["dma"], + sync=driver["sync"], + ) + slave_tx = generate(length, "prbs", 7) + master_tx = generate(length, "inc", 0x80) + fw.spis.arm(slave["index"], slave_tx) + assert fw.spi.xfer(master["index"], master_tx) == slave_tx + assert fw.spis.result(slave["index"]) == SpisResult(True, rx=master_tx) + + +@pytest.mark.board_params("instance", "spis.instances") +def test_open_errors(fw, instance): + index = instance["index"] + pins = {key: instance[key] for key in ("clk", "miso", "mosi", "nss")} + cases = [ + ({key: pins[key] for key in ("clk", "miso", "mosi")}, "usage"), + ({**pins, "clk": pins["miso"], "miso": pins["clk"]}, "pin"), + ({**pins, "nss": pins["clk"]}, "pin"), + ] + for options, reason in cases: + with pytest.raises(FirmwareError) as error: + fw.command("spis.open", index, **{key: fw.pin(pin) for key, pin in options.items()}) + assert error.value.reason == reason, options + for line in (f"spis.open {index} cs=PA0", f"spis.open {index} extra"): + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == "usage", line + open_slave(fw, instance) + with pytest.raises(FirmwareError) as error: + open_slave(fw, instance) + assert error.value.reason == "busy" + with pytest.raises(FirmwareError) as error: + fw.spi.open(index, clk=pins["clk"], mosi=pins["mosi"], miso=pins["miso"]) + assert error.value.reason == "busy", "the SPI master and slave share the instance" + fw.spis.close(index) + fw.spi.open(index, clk=pins["clk"], mosi=pins["mosi"], miso=pins["miso"]) + fw.spi.close(index) + + +@pytest.mark.board_params("instance", "spis.instances") +def test_arm_and_result_errors(fw, instance): + index = instance["index"] + with pytest.raises(FirmwareError) as error: + fw.spis.arm(index, b"\x01") + assert error.value.reason == "notopen" + open_slave(fw, instance) + for line, reason in ( + (f"spis.arm {index} -", "usage"), + (f"spis.arm {index} - rx=0", "usage"), + (f"spis.arm {index} 0102 rx=1", "usage"), + (f"spis.arm {index} 0102 len=2", "usage"), + (f"spis.arm {index} - seed=1", "usage"), + (f"spis.arm {index} - len=1025", "range"), + (f"spis.arm {index} - rx=1025", "range"), + (f"spis.result {index} out=bin", "usage"), + (f"spis.result {index} wait=10001", "range"), + (f"spis.cancel {index} 1", "usage"), + ): + with pytest.raises(FirmwareError) as error: + fw.terminal.command(line) + assert error.value.reason == reason, line + assert fw.spis.result(index) == SpisResult(False), "nothing armed: done=0 at once" + fw.spis.arm(index, rx=SPIS_HEX_MAX + 1) + with pytest.raises(FirmwareError) as error: + fw.spis.result(index, wait=0) + assert error.value.reason == "range", "hex output holds 128 bytes" + assert fw.spis.result(index, wait=0, out="crc") == SpisResult(False) + with pytest.raises(FirmwareError) as error: + fw.spis.arm(index, b"\x01") + assert error.value.reason == "busy" + with pytest.raises(FirmwareError) as error: + fw.terminal.command(f"spis.arm {index} 0102 rx=1") + assert error.value.reason == "busy", "busy before the payload: the armed DMA reads it" + assert fw.spis.result(index, wait=50, out="crc") == SpisResult(False) + assert fw.spis.cancel(index) is True + assert fw.spis.cancel(index) is False + fw.spis.arm(index, b"\x01\x02", rx=0) + fw.spis.close(index) diff --git a/validation/host/tests/hil/test_system.py b/validation/host/tests/hil/test_system.py index 19601b33..b36c7ac0 100644 --- a/validation/host/tests/hil/test_system.py +++ b/validation/host/tests/hil/test_system.py @@ -43,9 +43,10 @@ def test_aliases_are_generic(board_cfg): def test_every_alias_is_accepted_as_pin(fw, board_cfg): - """Each alias names its pin in commands; the terminal pins stay reserved.""" + """Each alias names its pin in commands; the terminal pins and the debug LED stay reserved.""" + reserved = {*board_cfg.terminal.pins, board_cfg.resolve_pin(board_cfg.param("system.debug_led"))} for alias, pin in board_cfg.pins.items(): - if pin in board_cfg.terminal.pins: + if pin in reserved: assert reason_of(fw, f"gpio.cfg {alias} in") == "busy", alias continue fw.command("gpio.cfg", alias, "in") diff --git a/validation/host/tests/hil/test_timer.py b/validation/host/tests/hil/test_timer.py new file mode 100644 index 00000000..c89ba2a4 --- /dev/null +++ b/validation/host/tests/hil/test_timer.py @@ -0,0 +1,239 @@ +"""Timers (`hal::FreeRunningTimerStm`, `hal::TimerWithInterruptStm`) through the `tim` group: update rate on the +marker pin, interrupt counts, the free-running counter up and down, stop, and the timer shared with pwm, tpwm, qei +and the timer-triggered ADC. + +Wiring set `bundle1`: `tests.timer.marker` (gpio0) on a DIO; the marker toggles once per update interrupt, so it runs +at half the update rate `timclk / ((prescaler + 1) (period + 1))`. +""" + +from __future__ import annotations + +import time + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import expect +from hal_st_validation.groups.timers import TIMER_PRESCALER_MAX, timer_period_max, timer_update_rate + + +@pytest.fixture +def timer_cfg(board_cfg): + return board_cfg.param("timer") + + +def dispatchable(values): + """Dispatched callbacks coalesce above 1 kHz: those rates run with `irq=immediate` only.""" + update = values.get("update") + return values.get("irq") != "dispatched" or update is None or update.get("dispatched", True) + + +def expect_error(reason, call, *args, **options): + with pytest.raises(FirmwareError) as error: + call(*args, **options) + assert error.value.reason == reason, (args, options) + + +def count_rate(fw, index, window): + """Counter and interrupt deltas over about `window` seconds, with the host time between the two readings.""" + start = time.monotonic() + before = fw.tim.count(index) + time.sleep(window) + after = fw.tim.count(index) + return after, before, time.monotonic() - start + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "timer.timers") +@pytest.mark.matrix("timer.interrupt") +@pytest.mark.constraint(valid=dispatchable) +def test_update_marker(fw, ad3, need, board_cfg, timer_cfg, timer, irq, update): + """The marker toggles on every update interrupt: it runs at half the update rate.""" + dio = need.dio(timer_cfg["marker"]) + index = timer["timer"] + timclk = fw.tim.open(index, prescaler=update["prescaler"], period=update["period"], irq=irq, pin=timer_cfg["marker"]) + assert timclk == board_cfg.clock("timer") + fw.tim.start(index) + marker = timer_update_rate(timclk, update["prescaler"], update["period"]) / 2 + periods = timer_cfg["record_periods"] + capture = ad3.logic.record_for(periods / marker, trigger=(dio, "rising"), timeout=periods / marker + 2) + assert capture.frequency(dio) == pytest.approx(marker, rel=timer_cfg["tolerance"]["frequency"]) + + +@pytest.mark.board_params("timer", "timer.timers") +@pytest.mark.matrix("timer.interrupt") +@pytest.mark.constraint(valid=dispatchable) +def test_interrupt_count(fw, timer_cfg, timer, irq, update): + """`irqs` follows the update rate; dispatched callbacks at or below 1 kHz are not lost.""" + index = timer["timer"] + timclk = fw.tim.open(index, prescaler=update["prescaler"], period=update["period"], irq=irq) + rate = timer_update_rate(timclk, update["prescaler"], update["period"]) + fw.tim.start(index) + after, before, elapsed = count_rate(fw, index, timer_cfg["window_s"]) + tolerance = timer_cfg["tolerance"] + expected = rate * elapsed + counted = after.irqs - before.irqs + assert counted >= expected * (1 - tolerance["count"]) - rate * tolerance["latency_s"], (counted, expected) + assert counted <= expected * (1 + tolerance["count"]) + 1, (counted, expected) + + +@pytest.mark.board_params("timer", "timer.timers") +@pytest.mark.board_params("mode", values=["up", "down"]) +def test_free_running_counter(fw, timer_cfg, timer, mode): + """`irq=none` counts at `timclk / (prescaler + 1)`, down from `period` with `mode=down`, without interrupts.""" + index = timer["timer"] + free = timer_cfg["free_running"] + if mode == "down" and index in timer_cfg["up_only"]: + expect_error("unsupported", fw.tim.open, index, prescaler=free["prescaler"], period=free["period"], irq="none", mode=mode) + return + timclk = fw.tim.open(index, prescaler=free["prescaler"], period=free["period"], irq="none", mode=mode) + fw.tim.start(index) + after, before, elapsed = count_rate(fw, index, timer_cfg["window_s"]) + delta = after.cnt - before.cnt if mode == "up" else before.cnt - after.cnt + tolerance = timer_cfg["tolerance"] + rate = timclk / (free["prescaler"] + 1) + assert 0 < delta <= rate * elapsed * (1 + tolerance["count"]) + 1, (delta, rate * elapsed) + assert delta >= rate * (elapsed - 2 * tolerance["latency_s"]) * (1 - tolerance["count"]), (delta, rate * elapsed) + assert (before.irqs, after.irqs) == (0, 0) + + +@pytest.mark.board_params("timer", "timer.timers") +@pytest.mark.board_params("irq", values=["immediate", "dispatched"]) +def test_no_callback_before_the_first_update(fw, timer_cfg, timer, irq): + """`tim.start` raises no update callback of its own: the update flag that `HAL_TIM_Base_Init` leaves on some HAL + versions is cleared, so a timer with an update period of many seconds has `irqs=0` right after it starts.""" + index = timer["timer"] + slow = timer_cfg["free_running"] + fw.tim.open(index, prescaler=slow["prescaler"], period=slow["period"], irq=irq) + fw.tim.start(index) + assert fw.tim.count(index).irqs == 0 + + +@pytest.mark.ad3 +@pytest.mark.board_params("encoder", "qei.instances") +@pytest.mark.board_params("mode", values=["up", "down"]) +def test_counts_the_requested_way_after_the_encoder(fw, ad3, need, board_cfg, timer_cfg, encoder, mode): + """An encoder leaves its timer in encoder mode with CR1.DIR at its last direction, where DIR ignores writes: + `tim.open` must still count the requested way. The encoder runs against `mode` before it closes.""" + index = encoder["index"] + if index not in [entry["timer"] for entry in timer_cfg["timers"]]: + pytest.skip(f"TIM{index} is not under test in `tests.timer.timers`") + a, b = need.dio(encoder["a"]), need.dio(encoder["b"]) + fw.qei.open(index, a=encoder["a"], b=encoder["b"], res=board_cfg.param("qei.resolution")) + ad3.pattern.quadrature(a, b, 1000, 10, "rev" if mode == "up" else "fwd") + ad3.pattern.wait_done(timeout=3) + fw.qei.close(index) + free = timer_cfg["free_running"] + fw.tim.open(index, prescaler=free["prescaler"], period=free["period"], irq="none", mode=mode) + fw.tim.start(index) + after, before, _ = count_rate(fw, index, timer_cfg["window_s"]) + delta = after.cnt - before.cnt if mode == "up" else before.cnt - after.cnt + assert delta > 0, (mode, before.cnt, after.cnt) + + +@pytest.mark.board_params("timer", "timer.timers") +def test_stop_holds_the_counter(fw, timer_cfg, timer): + """`tim.stop` freezes the counter and the interrupts; `tim.start` resumes; repeated start and stop are harmless.""" + index = timer["timer"] + update = timer_cfg["interrupt"]["update"][0] + fw.tim.open(index, prescaler=update["prescaler"], period=update["period"], irq="immediate") + fw.tim.start(index) + fw.tim.start(index) + time.sleep(timer_cfg["window_s"]) + fw.tim.stop(index) + fw.tim.stop(index) + stopped = fw.tim.count(index) + assert stopped.irqs > 0 + time.sleep(timer_cfg["window_s"]) + assert fw.tim.count(index) == stopped + fw.tim.start(index) + time.sleep(timer_cfg["window_s"]) + assert fw.tim.count(index).irqs > stopped.irqs + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "timer.timers") +def test_stop_stops_the_marker(fw, ad3, need, timer_cfg, timer): + dio = need.dio(timer_cfg["marker"]) + index = timer["timer"] + update = timer_cfg["interrupt"]["update"][1] + timclk = fw.tim.open(index, prescaler=update["prescaler"], period=update["period"], irq="immediate", pin=timer_cfg["marker"]) + fw.tim.start(index) + fw.tim.stop(index) + marker = timer_update_rate(timclk, update["prescaler"], update["period"]) / 2 + capture = ad3.logic.record_for(timer_cfg["record_periods"] / marker) + assert capture.frequency(dio) == 0.0 + assert len(set(capture.channel(dio))) == 1, "the marker moved after tim.stop" + + +@pytest.mark.board_params("timer", "timer.timers") +def test_marker_is_released_on_close(fw, need, timer_cfg, timer): + """The marker pin belongs to the timer while it is open and returns to the pool with `tim.close`.""" + index, marker = timer["timer"], timer_cfg["marker"] + need.unloaded(marker) + fw.tim.open(index, irq="immediate", pin=marker) + expect_error("busy", fw.gpio.cfg, marker, "in") + fw.tim.close(index) + expect_error("notopen", fw.tim.count, index) + fw.gpio.cfg(marker, "in") + expect_error("busy", fw.tim.open, index, irq="immediate", pin=marker) + + +def test_open_errors(fw, board_cfg, timer_cfg): + """Argument errors in the protocol order: usage, range, pin, unsupported, then the update interrupt rate (range).""" + first = timer_cfg["timers"][0]["timer"] + wide = next((entry["timer"] for entry in timer_cfg["timers"] if timer_period_max(entry["timer"]) > 0xFFFF), None) + narrow = next(entry["timer"] for entry in timer_cfg["timers"] if timer_period_max(entry["timer"]) == 0xFFFF) + marker = timer_cfg["marker"] + cases = [ + ((first,), {"irq": "nmi"}, "usage"), + ((first,), {"mode": "sideways"}, "usage"), + ((first,), {"irq": "none", "pin": marker}, "usage"), + ((first,), {"prescaler": TIMER_PRESCALER_MAX + 1}, "range"), + ((first,), {"period": 0}, "range"), + ((narrow,), {"period": 0x10000}, "range"), + ((first,), {"pin": board_cfg.param("system.unbonded_pins")[0]}, "pin"), + ((first,), {"mode": "down"}, "unsupported"), + ((first,), {"prescaler": 0, "period": 1}, "range"), + ((first,), {"prescaler": 0, "period": 1, "irq": "immediate"}, "range"), + ] + cases += [((timer,), {}, "range") for timer in timer_cfg["missing"]] + cases += [((timer,), {"irq": "none", "mode": "down"}, "unsupported") for timer in timer_cfg["up_only"]] + for args, options, reason in cases: + expect_error(reason, fw.tim.open, *args, **options) + if wide is not None: + assert fw.tim.open(wide, period=0xFFFFFFFF, irq="none") > 0 + fw.tim.close(wide) + assert fw.tim.open(first, prescaler=0, period=1, irq="none") > 0, "the update interrupt rate limit needs an interrupt" + fw.tim.close(first) + for command in ("start", "stop", "count", "close"): + expect_error("notopen", getattr(fw.tim, command), first) + for line in ("tim.count", f"tim.count {first} {first}", f"tim.start {first} ch=1"): + assert fw.terminal.command(line, check=False).reason == "usage", line + assert fw.terminal.command(f"tim.open {first} pin=nosuchalias", check=False).reason == "pin" + + +def test_one_timer_at_a_time(fw, timer_cfg): + first, second = (entry["timer"] for entry in timer_cfg["timers"][:2]) + fw.tim.open(first, irq="none") + expect_error("busy", fw.tim.open, second, irq="none") + expect_error("busy", fw.tim.open, first, irq="none") + + +@pytest.mark.board_params("timer", "timer.timers") +def test_timer_shared_with_other_groups(fw, board_cfg, timer): + """A timer serves one group: while `tim` holds it, pwm, tpwm, the encoder and the ADC trigger get `ERR busy`, + and `tim` gets it while pwm holds the timer.""" + index = timer["timer"] + fw.tim.open(index, irq="none") + expect_error("busy", fw.pwm.open, index, channels=[1]) + pwm_pin = next(entry for entry in board_cfg.param("timer_pwm.timers") if entry["timer"] == index)["pins"][0] + expect_error("busy", fw.tpwm.open, index, pins=[pwm_pin]) + encoder = next((entry for entry in board_cfg.param("qei.instances") if entry["index"] == index), None) + if encoder is not None: + expect_error("busy", fw.qei.open, index, a=encoder["a"], b=encoder["b"]) + if index in expect.ADC_TRIGGER_TIMERS: + expect_error("busy", fw.adc.open, board_cfg.param("adc.adc"), pins=[board_cfg.param("adc.inputs")[0]], timer=index) + fw.tim.close(index) + fw.pwm.open(index, channels=[1]) + expect_error("busy", fw.tim.open, index, irq="none") diff --git a/validation/host/tests/hil/test_timer_pwm.py b/validation/host/tests/hil/test_timer_pwm.py new file mode 100644 index 00000000..56e3b51d --- /dev/null +++ b/validation/host/tests/hil/test_timer_pwm.py @@ -0,0 +1,279 @@ +"""Timer PWM (`hal::TimerPwmWithChannels`, `hal::PwmChannelGpio`) through the `tpwm` group: frequency and duty of +every channel, per-channel duties, `SetPulse` rewriting the period of every channel, unused (`-`) channels, starting +and stopping one channel or all, and the argument checks. + +Wiring set `bundle1` (WBA55 TIM2 with CH3/CH4: `bundle2`): the channel pins of `tests.timer_pwm.timers` on DIOs. +`SetDuty` writes CCR = ARR * duty / 100 and the output is high while the counter is below CCR, so 100 % keeps one low +counter tick per period (`groups.timers.pwm_duty_fraction`); the YAML prescaler makes that tick 1 us long. +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench import analysis +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import expect +from hal_st_validation.groups.timers import TIMER_PRESCALER_MAX, pwm_duty_fraction, timer_period_max, timer_update_rate + + +@pytest.fixture +def tpwm_cfg(board_cfg): + return board_cfg.param("timer_pwm") + + +def expect_error(reason, call, *args, **options): + with pytest.raises(FirmwareError) as error: + call(*args, **options) + assert error.value.reason == reason, (args, options) + + +def wired(need, pins): + """DIO per channel position (None for `-`); skips when a channel pin is not wired.""" + return [None if pin is None else need.dio(pin) for pin in pins] + + +def several_channels(values): + timer = values.get("timer") + return timer is None or sum(pin is not None for pin in timer["pins"]) >= 2 + + +def record(ad3, cfg, frequency): + periods = cfg["record_periods"] + return ad3.logic.record_for(periods / frequency, timeout=periods / frequency + 2) + + +def check_channel(capture, dio, frequency, fraction, cfg): + """`fraction` 0 is a static low output; anything else a waveform with that high fraction.""" + bits = capture.channel(dio) + if fraction == 0: + assert set(bits) == {0}, f"DIO{dio} moved at 0 %" + return + assert capture.frequency(dio) == pytest.approx(frequency, rel=cfg["tolerance"]["frequency"]), f"DIO{dio} frequency" + quantisation = 2 * frequency / capture.rate + assert capture.duty(dio) == pytest.approx(fraction, abs=cfg["tolerance"]["duty"] + quantisation), f"DIO{dio} duty" + + +def is_stopped(capture, dio, cfg): + """No PWM on `dio`: a channel whose output is disabled is not driven (high impedance), so a few edges of a floating + line are tolerated, far fewer than the `record_periods` of a running channel.""" + return analysis.edge_count(capture.channel(dio), "rising") <= cfg["record_periods"] // 4 + + +def open_tpwm(fw, cfg, timer, pins=None): + """Opens with the YAML prescaler and period; returns `timclk`.""" + return fw.tpwm.open(timer["timer"], pins=timer["pins"] if pins is None else pins, prescaler=cfg["prescaler"], period=cfg["period"]) + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "timer_pwm.timers") +@pytest.mark.board_params("duty", "timer_pwm.duties") +def test_duty(fw, ad3, need, board_cfg, tpwm_cfg, timer, duty): + """One duty on every channel: frequency timclk / ((prescaler + 1) (period + 1)), high fraction of `SetDuty`.""" + dios = wired(need, timer["pins"]) + timclk = open_tpwm(fw, tpwm_cfg, timer) + assert timclk == board_cfg.clock("timer") + for channel, dio in enumerate(dios, 1): + if dio is not None: + fw.tpwm.duty(timer["timer"], channel, duty) + fw.tpwm.start(timer["timer"]) + frequency = timer_update_rate(timclk, tpwm_cfg["prescaler"], tpwm_cfg["period"]) + capture = record(ad3, tpwm_cfg, frequency) + for dio in dios: + if dio is not None: + check_channel(capture, dio, frequency, pwm_duty_fraction(tpwm_cfg["period"], duty), tpwm_cfg) + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "timer_pwm.timers") +@pytest.mark.constraint(valid=several_channels) +def test_channel_duties(fw, ad3, need, tpwm_cfg, timer): + """Each channel keeps its own duty: the channel number is the position in `pins`.""" + dios = wired(need, timer["pins"]) + timclk = open_tpwm(fw, tpwm_cfg, timer) + duties = tpwm_cfg["channel_duties"] + for channel in range(1, len(dios) + 1): + fw.tpwm.duty(timer["timer"], channel, duties[channel - 1]) + fw.tpwm.start(timer["timer"]) + frequency = timer_update_rate(timclk, tpwm_cfg["prescaler"], tpwm_cfg["period"]) + capture = record(ad3, tpwm_cfg, frequency) + for channel, dio in enumerate(dios, 1): + if dio is not None: + check_channel(capture, dio, frequency, pwm_duty_fraction(tpwm_cfg["period"], duties[channel - 1]), tpwm_cfg) + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "timer_pwm.timers") +def test_pulse_sets_the_period_of_every_channel(fw, ad3, need, tpwm_cfg, timer): + """`SetPulse(on, period)` writes the channel's compare value and the shared auto-reload: every channel changes + frequency, the others keep their compare value.""" + dios = wired(need, timer["pins"]) + index, pulse = timer["timer"], tpwm_cfg["pulse"] + timclk = open_tpwm(fw, tpwm_cfg, timer) + for channel in range(1, len(dios) + 1): + fw.tpwm.duty(index, channel, 50) + fw.tpwm.start(index) + fw.tpwm.pulse(index, 1, pulse["ccr"], pulse["period"]) + frequency = timer_update_rate(timclk, tpwm_cfg["prescaler"], pulse["period"]) + capture = record(ad3, tpwm_cfg, frequency) + for channel, dio in enumerate(dios, 1): + if dio is not None: + ccr = pulse["ccr"] if channel == 1 else tpwm_cfg["period"] * 50 // 100 + check_channel(capture, dio, frequency, ccr / (pulse["period"] + 1), tpwm_cfg) + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "timer_pwm.timers") +@pytest.mark.constraint(valid=several_channels) +def test_unused_channel(fw, ad3, need, tpwm_cfg, timer): + """A `-` channel gets a `DummyPinStm`: its pin stays free for other groups, the other channels run unchanged.""" + first = next(position for position, pin in enumerate(timer["pins"]) if pin is not None) + pins = list(timer["pins"]) + free = pins[first] + pins[first] = None + dios = wired(need, pins) + timclk = open_tpwm(fw, tpwm_cfg, timer, pins) + for channel in range(1, len(pins) + 1): + fw.tpwm.duty(timer["timer"], channel, 50) + fw.tpwm.start(timer["timer"]) + fw.gpio.cfg(free, "in") + frequency = timer_update_rate(timclk, tpwm_cfg["prescaler"], tpwm_cfg["period"]) + capture = record(ad3, tpwm_cfg, frequency) + for dio in dios: + if dio is not None: + check_channel(capture, dio, frequency, pwm_duty_fraction(tpwm_cfg["period"], 50), tpwm_cfg) + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "timer_pwm.timers") +@pytest.mark.constraint(valid=several_channels) +def test_start_and_stop_one_channel(fw, ad3, need, tpwm_cfg, timer): + """`ch=` starts or stops one channel; without it every channel; repeating either is harmless.""" + positions = [position for position, pin in enumerate(timer["pins"]) if pin is not None][:2] + dios = wired(need, timer["pins"]) + first, second = (dios[position] for position in positions) + channel = positions[0] + 1 + index = timer["timer"] + timclk = open_tpwm(fw, tpwm_cfg, timer) + for number in range(1, len(dios) + 1): + fw.tpwm.duty(index, number, 50) + frequency = timer_update_rate(timclk, tpwm_cfg["prescaler"], tpwm_cfg["period"]) + fraction = pwm_duty_fraction(tpwm_cfg["period"], 50) + fw.tpwm.start(index, ch=channel) + fw.tpwm.start(index, ch=channel) + capture = record(ad3, tpwm_cfg, frequency) + check_channel(capture, first, frequency, fraction, tpwm_cfg) + assert is_stopped(capture, second, tpwm_cfg), "a channel that was not started runs" + fw.tpwm.start(index) + fw.tpwm.stop(index, ch=channel) + capture = record(ad3, tpwm_cfg, frequency) + assert is_stopped(capture, first, tpwm_cfg), "the stopped channel runs" + check_channel(capture, second, frequency, fraction, tpwm_cfg) + fw.tpwm.stop(index) + fw.tpwm.stop(index) + capture = record(ad3, tpwm_cfg, frequency) + assert is_stopped(capture, first, tpwm_cfg) and is_stopped(capture, second, tpwm_cfg) + fw.tpwm.start(index) + check_channel(record(ad3, tpwm_cfg, frequency), second, frequency, fraction, tpwm_cfg) + + +@pytest.mark.ad3 +@pytest.mark.board_params("timer", "timer_pwm.timers") +def test_break_left_by_pwm_is_disabled(fw, ad3, need, board_cfg, tpwm_cfg, timer): + """`pwm.close` leaves BDTR with the break input enabled; with `brkpol=low` and the break pin back in analog mode + that break stays active, so `tpwm` on the same timer only drives its outputs because it rewrites BDTR.""" + index = timer["timer"] + pwm_timer = next((entry for entry in board_cfg.param("pwm.timers") if entry["timer"] == index and entry.get("brk")), None) + if pwm_timer is None: + pytest.skip(f"no pwm break input on TIM{index}") + dios = wired(need, timer["pins"]) + fw.pwm.open(index, pins=[pwm_timer["channels"][0]["pin"]], brk=pwm_timer["brk"], brkpol="low") + fw.pwm.close(index) + timclk = open_tpwm(fw, tpwm_cfg, timer) + for channel, dio in enumerate(dios, 1): + if dio is not None: + fw.tpwm.duty(index, channel, 50) + fw.tpwm.start(index) + frequency = timer_update_rate(timclk, tpwm_cfg["prescaler"], tpwm_cfg["period"]) + capture = record(ad3, tpwm_cfg, frequency) + for dio in dios: + if dio is not None: + check_channel(capture, dio, frequency, pwm_duty_fraction(tpwm_cfg["period"], 50), tpwm_cfg) + + +def test_open_errors(fw, tpwm_cfg, board_cfg): + """Argument errors in the protocol order: usage, range, pin, unsupported.""" + timer = next(entry for entry in tpwm_cfg["timers"] if entry["timer"] in expect.SINGLE_CHANNEL_TIMERS) + index, pin = timer["timer"], timer["pins"][0] + wide = max(tpwm_cfg["timers"], key=lambda entry: len(entry["pins"])) + cases = [ + ((index,), {"pins": [None]}, "usage"), + ((index,), {"pins": [pin] * 5}, "usage"), + ((index,), {"pins": [pin], "prescaler": TIMER_PRESCALER_MAX + 1}, "range"), + ((index,), {"pins": [pin], "period": 0}, "range"), + ((index,), {"pins": [pin], "period": timer_period_max(index) + 1}, "range"), + ((index,), {"pins": [tpwm_cfg["foreign_pin"]]}, "pin"), + ((index,), {"pins": [pin, None]}, "unsupported"), + ] + cases += [((missing,), {"pins": [pin]}, "range") for missing in board_cfg.param("timer.missing")] + for args, options, reason in cases: + expect_error(reason, fw.tpwm.open, *args, **options) + raw = {f"tpwm.open {index}": "usage", f"tpwm.open {index} pins=,": "usage", f"tpwm.open {index} pins=nosuchalias": "pin"} + for line, reason in raw.items(): + assert fw.terminal.command(line, check=False).reason == reason, line + if len(wide["pins"]) > 1: + reversed_pins = list(reversed(wide["pins"])) + if reversed_pins != wide["pins"] and reversed_pins[0] is not None: + expect_error("pin", fw.tpwm.open, wide["timer"], pins=reversed_pins) + for command, args in (("duty", (1, 50)), ("pulse", (1, 1, 2)), ("start", ()), ("stop", ()), ("close", ())): + expect_error("notopen", getattr(fw.tpwm, command), index, *args) + + +def test_channel_errors(fw, need, tpwm_cfg): + timer = max(tpwm_cfg["timers"], key=lambda entry: len(entry["pins"])) + need.unloaded(*(pin for pin in timer["pins"] if pin is not None)) + index, channels = timer["timer"], len(timer["pins"]) + maximum = timer_period_max(index) + fw.tpwm.open(index, pins=timer["pins"]) + cases = [ + ("duty", (0, 50), "range"), + ("duty", (channels + 1, 50), "range"), + ("duty", (1, 101), "range"), + ("pulse", (1, 0, 0), "range"), + ("pulse", (channels + 1, 1, 2), "range"), + ] + if maximum < 0xFFFFFFFF: + cases += [("pulse", (1, maximum + 1, 2), "range"), ("pulse", (1, 1, maximum + 1), "range")] + for command, args, reason in cases: + expect_error(reason, getattr(fw.tpwm, command), index, *args) + expect_error("range", fw.tpwm.start, index, ch=channels + 1) + expect_error("range", fw.tpwm.stop, index, ch=0) + for line in (f"tpwm.duty {index} 1", f"tpwm.start {index} ch=x", f"tpwm.stop {index} channel=1", f"tpwm.duty {index} 1 50 1"): + assert fw.terminal.command(line, check=False).reason == "usage", line + fw.tpwm.duty(index, channels, 100) + fw.tpwm.pulse(index, channels, maximum, maximum) + + +def test_timer_shared_with_pwm_and_tim(fw, need, tpwm_cfg): + """`tpwm` holds its timer against pwm and tim, and one timer at a time.""" + first, second = tpwm_cfg["timers"][:2] + need.unloaded(*(pin for pin in first["pins"] if pin is not None)) + fw.tpwm.open(first["timer"], pins=first["pins"]) + expect_error("busy", fw.pwm.open, first["timer"], channels=[1]) + expect_error("busy", fw.tim.open, first["timer"], irq="none") + expect_error("busy", fw.tpwm.open, second["timer"], pins=second["pins"]) + fw.tpwm.close(first["timer"]) + fw.tim.open(first["timer"], irq="none") + expect_error("busy", fw.tpwm.open, first["timer"], pins=first["pins"]) + + +def test_channel_pins_are_claimed(fw, need, tpwm_cfg): + """The channel pins belong to `tpwm` while it is open and return to the pool with `tpwm.close`.""" + timer = tpwm_cfg["timers"][0] + need.unloaded(*(pin for pin in timer["pins"] if pin is not None)) + pin = next(pin for pin in timer["pins"] if pin is not None) + fw.tpwm.open(timer["timer"], pins=timer["pins"]) + expect_error("busy", fw.gpio.cfg, pin, "in") + fw.tpwm.close(timer["timer"]) + fw.gpio.cfg(pin, "in") + expect_error("busy", fw.tpwm.open, timer["timer"], pins=timer["pins"]) diff --git a/validation/host/tests/hil/test_uart_sendonly.py b/validation/host/tests/hil/test_uart_sendonly.py new file mode 100644 index 00000000..17be55ab --- /dev/null +++ b/validation/host/tests/hil/test_uart_sendonly.py @@ -0,0 +1,148 @@ +"""Send-only UART (`hal::SynchronousUartStmSendOnly`, `uart.open ... sendonly=1`) against the AD3 protocol UART. + +Wiring set `bundle1`: TX, RTS and CTS of every `tests.uart_sendonly.instances` entry on DIOs; the AD3 UART also +needs a transmit DIO, the instance's `rx` pin, which the firmware leaves alone. STM32WB55 builds LPUART1 through the +`SyncLpUart` constructors; STM32WBA55 has none, so its LPUART answers `ERR unsupported` and USART2 is the instance. + +`SendData` returns once the last byte has left the data register (TXE), not the wire (TC): `uart.send` answers while +the last frame is still shifting out, and a `uart.close` that follows at once may cut that frame +(`test_close_right_after_send`). +""" + +from __future__ import annotations + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError + +from hal_st_validation import expect + + +@pytest.fixture +def sendonly_cfg(board_cfg): + cfg = board_cfg.param("uart_sendonly", None) + if cfg is None: + pytest.skip("tests.uart_sendonly not configured") + return cfg + + +def open_sendonly(fw, instance, **options): + fw.uart.open(instance["index"], lp=instance["lp"], tx=instance["tx"], sendonly=True, **options) + + +def listen(ad3, need, instance, baud): + """Point the AD3 UART at the firmware TX pin; returns its DIO.""" + rx = need.dio(instance["tx"]) + ad3.uart.configure(tx=need.dio(instance["rx"]), rx=rx, baud=baud) + ad3.uart.flush() + return rx + + +def send_and_receive(fw, ad3, index, payload, baud): + transfer = expect.uart_transfer_time(len(payload), baud) + fw.uart.send(index, payload, cmd_timeout=2.0 + transfer) + return ad3.uart.read(len(payload), timeout=transfer + 1.0) + + +def expect_error(reason, call, *args, **options): + with pytest.raises(FirmwareError) as error: + call(*args, **options) + assert error.value.reason == reason, (args, options) + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "uart_sendonly.instances") +@pytest.mark.board_params("baud", "uart_sendonly.bauds") +def test_transmit(fw, ad3, need, sendonly_cfg, instance, baud): + listen(ad3, need, instance, baud) + open_sendonly(fw, instance, baud=baud) + for text in sendonly_cfg["payloads"]: + payload = bytes.fromhex(text) + assert send_and_receive(fw, ad3, instance["index"], payload, baud) == payload, f"{baud} Bd" + assert ad3.uart.parity_errors == 0 + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "uart_sendonly.instances") +def test_receive_returns_nothing(fw, ad3, need, instance): + """`ReceiveData` of the send-only driver fails at once: bytes on the `rx` line never arrive.""" + listen(ad3, need, instance, 115200) + open_sendonly(fw, instance, rx=instance["rx"]) + ad3.uart.write(b"\x55\xaa") + assert fw.uart.recv(instance["index"], timeout=100, len=2) == b"" + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "uart_sendonly.instances") +def test_rts_only_mapping(fw, ad3, need, sendonly_cfg, instance): + """`flow=rts` muxes RTS and enables RTS flow control: the UART drives the RTS pin low (receiver ready) against both + AD3 pulls, and transmission is not held. CTSE cannot be observed: the send-only driver never muxes a CTS pin.""" + baud = 115200 + rts = need.dio(instance["rts"]) + listen(ad3, need, instance, baud) + open_sendonly(fw, instance, baud=baud, flow="rts", rts=instance["rts"]) + ad3.dio.pull(up=[rts]) + pulled_up = ad3.dio.read(rts) + ad3.dio.pull(down=[rts]) + pulled_down = ad3.dio.read(rts) + ad3.dio.pull() + assert pulled_up == pulled_down == 0, "RTS must be driven and asserted (receiver ready) by the UART" + payload = bytes.fromhex(sendonly_cfg["payloads"][-1]) + assert send_and_receive(fw, ad3, instance["index"], payload, baud) == payload, "flow=rts must not block transmission" + + +@pytest.mark.ad3 +@pytest.mark.board_params("instance", "uart_sendonly.instances") +def test_close_right_after_send(fw, ad3, need, sendonly_cfg, instance): + """`uart.close` right after `uart.send`: every frame but the last arrives intact; the last one may be cut (the + driver waits for TXE, not TC).""" + cut = sendonly_cfg["cut"] + baud = cut["baud"] + rx = listen(ad3, need, instance, baud) + ad3.dio.pull(up=[rx]) + open_sendonly(fw, instance, baud=baud) + payload = bytes((0x30 + i) & 0x7F for i in range(cut["payload"])) + fw.uart.send(instance["index"], payload, cmd_timeout=2.0 + expect.uart_transfer_time(len(payload), baud)) + fw.uart.close(instance["index"]) + received = ad3.uart.read(len(payload), timeout=expect.uart_transfer_time(len(payload), baud) + 1.0) + assert received[: len(payload) - 1] == payload[:-1] + assert len(received) <= len(payload) + + +@pytest.mark.board_params("instance", "uart_sendonly.instances") +def test_rx_is_optional(fw, instance): + open_sendonly(fw, instance) + assert fw.uart.recv(instance["index"]) == b"" + fw.uart.close(instance["index"]) + open_sendonly(fw, instance, rx=instance["rx"]) + + +@pytest.mark.board_params("instance", "uart_sendonly.instances") +def test_open_errors(fw, instance): + """`sendonly` excludes the other driver selections and the options `SynchronousUartStmSendOnly` lacks.""" + for options, reason in ( + ({"dma": True}, "usage"), + ({"duplex": True}, "usage"), + ({"sync": True}, "usage"), + ({"flow": "rts"}, "usage"), + ({"parity": "even"}, "unsupported"), + ({"swap": True}, "unsupported"), + ({"flow": "cts", "cts": instance["cts"]}, "unsupported"), + ({"flow": "rtscts", "rts": instance["rts"], "cts": instance["cts"]}, "unsupported"), + ({"rts": instance["tx"], "flow": "rts"}, "pin"), + ): + expect_error(reason, open_sendonly, fw, instance, **options) + expect_error("usage", fw.uart.open, instance["index"], lp=instance["lp"], sendonly=True, rx=instance["rx"]) + + +@pytest.mark.board_params("instance", "uart_sendonly.unsupported") +def test_unsupported_instance(fw, instance): + """LPUARTs without send-only constructors (STM32WBA).""" + expect_error("unsupported", open_sendonly, fw, instance) + + +@pytest.mark.board_params("instance", "uart_sendonly.instances") +def test_default_pins(fw, instance): + """Without pins the defaults of the instance apply (LPUART1), as for the other drivers.""" + if not instance.get("default_pins"): + pytest.skip("the instance has no default pins") + fw.uart.open(instance["index"], lp=instance["lp"], sendonly=True) diff --git a/validation/host/tests/hil/test_uid.py b/validation/host/tests/hil/test_uid.py new file mode 100644 index 00000000..d0a2265c --- /dev/null +++ b/validation/host/tests/hil/test_uid.py @@ -0,0 +1,41 @@ +"""Unique device ID (`hal::UniqueDeviceId`, `info uid=`): 96 bits read from UID_BASE, the same after a reset, not erased +flash (all 0x00 or all 0xFF), with the lot number bytes in printable ASCII (RM0434 / RM0493 "Unique device ID register +(96 bits)": bytes 0-3 wafer X/Y, byte 4 wafer number, bytes 5-11 lot number).""" + +from __future__ import annotations + +import pytest + +from hal_st_validation.groups.io import UID_BYTES, parse_uid + + +@pytest.fixture +def uid_cfg(board_cfg): + return board_cfg.param("uid", {}) + + +def read_uid(fw): + info = fw.system.info() + assert info.uid is not None, info.raw + return parse_uid(info.uid) + + +def test_uid_layout(fw, uid_cfg): + uid = read_uid(fw) + assert len(uid.raw) == UID_BYTES + assert uid.raw not in (bytes(UID_BYTES), b"\xff" * UID_BYTES), "the UID reads like erased or blank memory" + first, end = uid_cfg.get("lot", [5, 12]) + lot = uid.raw[first:end] + assert all(0x20 <= byte <= 0x7E for byte in lot), f"lot number {lot.hex()} is not printable ASCII" + + +def test_uid_is_stable(fw): + assert read_uid(fw) == read_uid(fw) + + +@pytest.mark.resets_board +def test_uid_survives_reset(fw, board_cfg): + before = read_uid(fw) + fw.system.reset(timeout=board_cfg.param("system.boot_timeout", 5.0)) + fw.system.ping() + assert read_uid(fw) == before diff --git a/validation/host/tests/hil/test_unsupported.py b/validation/host/tests/hil/test_unsupported.py index c93d0842..553574d5 100644 --- a/validation/host/tests/hil/test_unsupported.py +++ b/validation/host/tests/hil/test_unsupported.py @@ -1,4 +1,5 @@ -"""Groups hal-st has no driver for on these boards (comparator, CAN, EEPROM, Ethernet) answer `ERR unsupported`.""" +"""Groups hal-st has no driver for on these boards (comparator, CAN, Ethernet) and the groups the running MCU lacks +answer `ERR unsupported`.""" import pytest from ad3_waveforms_bench.terminal import FirmwareError diff --git a/validation/host/tests/hil/test_wiring.py b/validation/host/tests/hil/test_wiring.py new file mode 100644 index 00000000..6b9f2fbd --- /dev/null +++ b/validation/host/tests/hil/test_wiring.py @@ -0,0 +1,89 @@ +"""Self-check of the bench wiring with GPIO commands only: run it first after wiring the board +(`pytest tests/hil/test_wiring.py --port ... --wiring-set [--with ...]`). + +With the AD3 outputs and pulls off, the MCU drives the key pin of each `jumpered` entry of an enabled option and +reads the listed pins against the opposite MCU pull (continuity), reads the `pullups` pins against the MCU +pull-down (external pull-ups on a live 3V3 rail), looks for the jumpers of offered options that are not enabled +(fitted wiring the run does not know about), and checks that the pins of `tests.wiring.undriven` follow both MCU +pulls (nothing else, such as an ST-LINK line through a solder bridge, drives them). Skipped with `--fake`: the +fake firmware has no wiring. +""" + +from __future__ import annotations + +import pytest + +from hal_st_validation.config import Wiring +from hal_st_validation.firmware import Firmware + + +@pytest.fixture(autouse=True) +def _physical_wiring(request: pytest.FixtureRequest) -> None: + if request.config.getoption("--fake"): + pytest.skip("checks the physical wiring: nothing to check with --fake") + request.getfixturevalue("ad3_released") + + +def level(fw: Firmware, pin: str, pull: str) -> int: + fw.gpio.cfg(pin, "in", pull=pull) + try: + return fw.gpio.get(pin) + finally: + fw.gpio.release(pin) + + +def follows(fw: Firmware, key: str, pin: str) -> bool: + """`pin` reads 1 against its pull-down while `key` drives high, and 0 against its pull-up while `key` drives low.""" + fw.gpio.cfg(key, "out") + try: + results = [] + for driven, pull in ((1, "down"), (0, "up")): + fw.gpio.set(key, driven) + results.append(level(fw, pin, pull) == driven) + return all(results) + finally: + fw.gpio.release(key) + + +def jumper_pairs(wiring: Wiring, tag: str) -> list[tuple[str, str]]: + return [(key, pin) for key, pins in wiring.options[tag].jumpered.items() for pin in pins] + + +@pytest.mark.wiring_options("tag") +def test_option_continuity(fw, wiring, tag): + pairs = jumper_pairs(wiring, tag) + if not pairs: + pytest.skip(f"--with {tag} declares no jumpered pins") + broken = [f"{key}-{pin}" for key, pin in pairs if not follows(fw, key, pin)] + assert not broken, f"--with {tag}: no continuity on {', '.join(broken)}: check those jumpers" + + +@pytest.mark.wiring_options("tag") +def test_option_pullups(fw, wiring, tag): + pullups = wiring.options[tag].pullups + if not pullups: + pytest.skip(f"--with {tag} declares no pull-ups") + low = [pin for pin in pullups if level(fw, pin, "down") != 1] + assert not low, f"--with {tag}: {', '.join(low)} read 0 against the MCU pull-down: no pull-up to a live 3V3 rail" + + +@pytest.mark.wiring_options("tag", enabled=False) +def test_undeclared_options(fw, wiring, tag): + pairs = jumper_pairs(wiring, tag) + if not pairs: + pytest.skip(f"--with {tag} declares no jumpered pins to look for") + fitted = [f"{key}-{pin}" for key, pin in pairs if follows(fw, key, pin)] + assert not fitted, f"the wiring of --with {tag} is fitted ({', '.join(fitted)}): pass --with {tag} or remove the wiring" + + +@pytest.mark.board_params("pin", "wiring.undriven") +def test_no_foreign_drivers(fw, wiring, board_cfg, pin): + resolved = board_cfg.resolve_pin(pin) + loads = wiring.loaded(resolved) + if loads: + pytest.skip(f"pin {resolved} loaded by --with {loads[0]}") + levels = (level(fw, resolved, "up"), level(fw, resolved, "down")) + assert levels == (1, 0), ( + f"{resolved} reads {levels[0]} against the MCU pull-up and {levels[1]} against the pull-down: something drives it " + "(a solder bridge to an ST-LINK line, a jumper or an AD3 output)" + ) diff --git a/validation/host/tests/unit/test_config.py b/validation/host/tests/unit/test_config.py index 99c43d41..59faa94a 100644 --- a/validation/host/tests/unit/test_config.py +++ b/validation/host/tests/unit/test_config.py @@ -79,7 +79,41 @@ def test_bundles_wire_each_dio_once(name): def test_bundle_sets(): assert set(load_board("nucleo_wb55rg").wiring_sets) == {"bundle1", "bundle2"} - assert set(load_board("nucleo_wba55cg").wiring_sets) == {"bundle1"} + assert set(load_board("nucleo_wba55cg").wiring_sets) == {"bundle1", "bundle2"} + + +@pytest.mark.parametrize( + ("name", "offered"), + [ + ("nucleo_wb55rg", {"bundle1": {"loopback", "i2c", "spiloop"}, "bundle2": {"loopback"}}), + ("nucleo_wba55cg", {"bundle1": {"loopback"}, "bundle2": {"loopback", "i2c", "spiloop"}}), + ], +) +def test_bundle_options(name, offered): + """The options each set offers; loopback and spiloop share pins, so they exclude each other.""" + board = load_board(name) + assert {bundle: set(wiring_set.options) for bundle, wiring_set in board.wiring_sets.items()} == offered + for bundle, tags in offered.items(): + if {"loopback", "spiloop"} <= tags: + with pytest.raises(ConfigError): + board.wiring([bundle], ["loopback", "spiloop"]) + for tag in tags: + option = board.wiring([bundle], [tag]).options[tag] + assert option.loads and set(option.jumpered) <= set(option.loads), (bundle, tag) + assert set(option.pullups) <= set(option.loads), (bundle, tag) + + +def test_wba55_bundle2_moves_four_dios(): + board = load_board("nucleo_wba55cg") + bundle1, bundle2 = board.wiring(["bundle1"]), board.wiring(["bundle2"]) + assert [bundle1.dio(pin) for pin in ("PB5", "PA10", "PB15", "PA2")] == [8, 9, 11, 14] + assert [bundle2.dio(pin) for pin in ("PA7", "PA6", "PB8", "PA0")] == [8, 9, 11, 14] + assert bundle2.wavegen("PA7") is None and bundle2.scope("PB2") is None + with_i2c = board.wiring(["bundle2"], ["i2c"]) + assert with_i2c.scope("PB2") == 1 and with_i2c.scope("PA6", allowed=["i2c"]) == 1 + assert with_i2c.loaded("PA6") == ("i2c",) and with_i2c.scope("PA6") is None + with pytest.raises(ConfigError): + board.wiring(["bundle1", "bundle2"]) def test_wb55_bundle2_moves_the_encoder_inputs(): @@ -95,12 +129,18 @@ def test_wb55_bundle2_moves_the_encoder_inputs(): @pytest.mark.parametrize("name", BOARDS) def test_parameters_reference_wired_pins(name): - """Pins in the test parameters are wired in bundle1 (the LPTIM encoder in bundle2).""" + """Pins in the test parameters are wired in bundle1 (the LPTIM encoder in bundle2); PWM timers and SPI instances are + wired in some set, SPI instances through the jumpers of their `option` when they name one.""" board = load_board(name) def wired(pin, kind="dio", bundle="bundle1"): return board.wiring([bundle]).channel(kind, board.resolve_pin(pin)) is not None + def wired_in_a_set(pin, option=None): + tags = [option] if option else [] + sets = [set_name for set_name, wiring_set in board.wiring_sets.items() if option is None or option in wiring_set.options] + return any(board.wiring([bundle], tags).channel("dio", board.resolve_pin(pin), allowed=tags) is not None for bundle in sets) + for pin in board.param("gpio.loop_pins") + board.param("gpio.output_pins"): assert wired(pin), pin sharing = board.param("gpio.exti_sharing") @@ -109,13 +149,13 @@ def wired(pin, kind="dio", bundle="bundle1"): assert counting[2:] == other[2:] and counting[1] != other[1], "same EXTI line, other port" for timer in board.param("pwm.timers"): for channel in timer["channels"]: - assert wired(channel["pin"]), channel - assert channel.get("npin") is None or wired(channel["npin"]), channel - assert timer.get("brk") is None or wired(timer["brk"]), timer + assert wired_in_a_set(channel["pin"]), channel + assert channel.get("npin") is None or wired_in_a_set(channel["npin"]), channel + assert timer.get("brk") is None or wired_in_a_set(timer["brk"]), timer for instance in board.param("uart.instances"): assert all(wired(instance[key]) for key in ("tx", "rx", "rts", "cts")), instance for instance in board.param("spi.instances"): - assert all(wired(instance[key]) for key in ("clk", "cs", "mosi", "miso")), instance + assert all(wired_in_a_set(instance[key], instance.get("option")) for key in ("clk", "cs", "mosi", "miso")), instance for instance in board.param("qei.instances"): assert all(wired(instance[key]) for key in ("a", "b", "idx")), instance for instance in board.param("qei.lp_instances"): @@ -208,11 +248,11 @@ def test_matrices_load(name): @pytest.mark.parametrize("name", BOARDS) def test_unsupported_commands_cover_the_protocol(name): - """`tests.unsupported.commands` names every command PROTOCOL.md lists as unavailable.""" + """`tests.unsupported.commands` names every command PROTOCOL.md lists as unavailable on the board's MCU.""" from hal_st_validation.fake_firmware import UNSUPPORTED_COMMANDS board = load_board(name) - assert {line.split()[0] for line in board.param("unsupported.commands")} == set(UNSUPPORTED_COMMANDS) + assert {line.split()[0] for line in board.param("unsupported.commands")} == set(UNSUPPORTED_COMMANDS[board.family]) @pytest.mark.parametrize("name", BOARDS) @@ -295,7 +335,9 @@ def test_optional_connections_and_roles(): "board": "x", "family": "stm32wb55", "pins": {"gpio0": "PC6"}, - "wiring_sets": {"s": {"dio": {0: "gpio0", 1: {"pin": "PB0", "role": "probe", "requires": "extra"}}}}, + "wiring_sets": { + "s": {"options": {"extra": "a probe"}, "dio": {0: "gpio0", 1: {"pin": "PB0", "role": "probe", "requires": "extra"}}} + }, } board = parse_board(raw) assert board.wiring(["s"]).dio(role="probe") is None @@ -318,7 +360,7 @@ def test_params_and_overrides(): assert board.param("new.value") == 1.5 with pytest.raises(ConfigError): board.apply_overrides(["novalue"]) - assert board.aliases_of("PA8") == ["tim1ch1", "qei1a"] + assert board.aliases_of("PA8")[:2] == ["tim1ch1", "qei1a"], "in table order (PA8 is also mco)" assert load_board("nucleo_wb55rg").matrix("pwm.waveform")["freq"] != [20000], "overrides stay in one BoardConfig" diff --git a/validation/host/tests/unit/test_config_options.py b/validation/host/tests/unit/test_config_options.py new file mode 100644 index 00000000..3181aac3 --- /dev/null +++ b/validation/host/tests/unit/test_config_options.py @@ -0,0 +1,221 @@ +"""Wiring options (`--with`) as mappings, their validation and `--board-extra` merging; inline boards only.""" + +import textwrap + +import pytest +import yaml + +from hal_st_validation.config import ConfigError, WiringOption, load_board, merge_board, parse_board + +BOARD = """ +board: x +family: stm32wb55 +pins: + spi1mosi: PA7 + spi1miso: PA6 +wiring_sets: + one: + options: + loopback: + description: jumper PA7 to PA6 + jumpered: {spi1mosi: [spi1miso]} + loads: [PA6, PA7] + excludes: [spiloop] + spiloop: + jumpered: {PA5: [PB13], PA6: [PB14]} + loads: [PA5, PA6, PB13, PB14] + excludes: [loopback] + i2c: + jumpered: {PB8: [PC0], PB9: [PC1]} + loads: [PB8, PB9, PC0, PC1] + pullups: [PB8, PB9, PC0, PC1] + plain: a described option + dio: + 0: PA5 + 1: spi1mosi + 3: PB8 + 5: PB9 + 7: spi1miso + scope: + 1: {pin: PB8, requires: i2c} + two: + options: + loopback: jumper D11 to D12 + extra: + jumpered: {PB0: [PB1]} + dio: + 0: PA5 + 2: PB0 +""" + + +def board(text=BOARD): + return parse_board(yaml.safe_load(text)) + + +def test_options_parse_as_mappings_or_descriptions(): + options = board().wiring_sets["one"].options + assert options["loopback"] == WiringOption( + "loopback", "jumper PA7 to PA6", {"PA7": ("PA6",)}, ("PA6", "PA7"), (), frozenset({"spiloop"}) + ), "aliases resolve to pins" + assert options["i2c"].pullups == ("PB8", "PB9", "PC0", "PC1") + assert options["plain"] == WiringOption("plain", "a described option") + assert board().wiring_sets["two"].options["loopback"].description == "jumper D11 to D12" + + +def test_enabled_options_keep_their_jumpers_apart(): + wiring = board().wiring(["one"], ["i2c"]) + assert set(wiring.options) == {"loopback", "spiloop", "i2c", "plain"} + assert wiring.option_jumpers == {"i2c": {"PB8": ("PC0",), "PB9": ("PC1",)}} + assert all(connection.pins in (("PA5",), ("PA7",), ("PB8",), ("PB9",), ("PA6",)) for connection in wiring.connections) + assert wiring.scope("PB8") == 1, "the connection that requires i2c is enabled" + assert board().wiring(["one"]).scope("PB8") is None + + +def test_loaded_pins_are_those_of_enabled_options(): + wiring = board().wiring(["one"], ["i2c", "plain"]) + assert wiring.loaded("PC0") == ("i2c",) + assert wiring.loaded("PA6") == (), "loopback is offered, not enabled" + assert wiring.blocking("PC0") == ("i2c",) + assert wiring.blocking("PC0", {"i2c"}) == () + assert board().wiring(["one"], ["loopback"]).loaded("PA7") == ("loopback",) + + +def test_jumpered_pins_resolve_only_for_the_tag(): + wiring = board().wiring(["one"], ["i2c", "spiloop"]) + assert wiring.dio("PC0") is None + assert wiring.dio("PC0", allowed={"spiloop"}) is None + assert wiring.dio("PC0", allowed={"i2c"}) == 3 + assert wiring.dio("PC1", allowed=["i2c"]) == 5 + assert wiring.dio("PB14", allowed={"spiloop"}) == 7 + assert wiring.dio("PA6", allowed={"spiloop"}) == 7, "a pin's own channel comes first" + assert wiring.jumpered_to("PB13", {"spiloop"}) == {"PA5"} + assert board().wiring(["one"]).dio("PC0", allowed={"i2c"}) is None, "only enabled options tie pins" + + +def test_options_of_several_sets_merge(): + text = BOARD.replace( + " two:\n options:\n loopback: jumper D11 to D12\n", " two:\n options:\n loopback: {loads: [PB0]}\n" + ) + raw = yaml.safe_load(text) + raw["wiring_sets"]["two"]["dio"] = {0: "PA5"} + wiring = parse_board(raw).wiring(["one", "two"], ["loopback"]) + assert wiring.options["loopback"].loads == ("PA6", "PA7", "PB0") + assert wiring.options["loopback"].description == "jumper PA7 to PA6" + assert "--with loopback: PA7-PA6; loads PA6, PA7, PB0" in wiring.describe() + + +@pytest.mark.parametrize( + ("names", "enabled", "message"), + [ + (["one"], ["nosuch"], "offer no --with nosuch"), + (["two"], ["i2c"], "offered: extra, loopback"), + ([], ["i2c"], "needs the --wiring-set"), + (["one"], ["loopback", "spiloop"], "exclude each other"), + (["nosuch"], [], "unknown wiring set"), + ], +) +def test_invalid_tag_selections(names, enabled, message): + with pytest.raises(ConfigError, match=message): + board().wiring(names, enabled) + + +def test_valid_tag_selections(): + assert board().wiring([], []).enabled == frozenset() + assert board().wiring(["one"], ["i2c", "spiloop", "plain"]).has("spiloop") + assert board().wiring(["two"], ["extra"]).dio("PB1", allowed={"extra"}) == 2 + + +@pytest.mark.parametrize( + "option", + [ + "{jumpered: [PA5]}", + "{jumpered: {PA5: PB13}}", + "{jumpered: {PA5: [PZ1]}}", + "{loads: PA5}", + "{pullups: [nosuchalias]}", + "{excludes: spiloop}", + "{wires: [PA5]}", + "[PA5]", + ], +) +def test_invalid_options(option): + raw = yaml.safe_load(BOARD) + raw["wiring_sets"]["two"]["options"]["extra"] = yaml.safe_load(option) + with pytest.raises(ConfigError): + parse_board(raw) + + +def test_requires_names_an_offered_option(): + raw = yaml.safe_load(BOARD) + raw["wiring_sets"]["two"]["dio"][2] = {"pin": "PB0", "requires": "i2c"} + with pytest.raises(ConfigError, match="requires option 'i2c'"): + parse_board(raw) + + +def test_merge_board(): + base = {"a": {"b": [1, 2], "c": 1, "d": {"e": 1}}, "f": "x", "g": [1]} + extra = {"a": {"b": [3], "c": 2, "d": "flat", "h": {"i!": [9]}}, "f": {"now": "mapping"}, "g!": [5], "j": None} + assert merge_board(base, extra) == { + "a": {"b": [1, 2, 3], "c": 2, "d": "flat", "h": {"i": [9]}}, + "f": {"now": "mapping"}, + "g": [5], + "j": None, + } + assert base == {"a": {"b": [1, 2], "c": 1, "d": {"e": 1}}, "f": "x", "g": [1]}, "the base is not changed" + assert merge_board({"x": {"y": [1]}}, {"x": {"y!": [2]}}) == {"x": {"y": [2]}} + assert merge_board({1: "a"}, {1: "b", 2: "c"}) == {1: "b", 2: "c"}, "YAML integer keys (DIO numbers) merge too" + + +def write(path, text): + path.write_text(textwrap.dedent(text), encoding="utf-8") + return path + + +def test_load_board_with_extras(tmp_path): + base = write(tmp_path / "x.yaml", BOARD) + first = write( + tmp_path / "first.yaml", + """ + wiring_sets: + one: + options: + plain: + loads: [PB0] + tests: + gpio: + loop_pins: [PA5] + limit_pins: [PA8] + """, + ) + second = write( + tmp_path / "second.yaml", + """ + tests: + gpio: + loop_pins: [PB0] + limit_pins!: [PB8] + """, + ) + empty = write(tmp_path / "empty.yaml", "") + loaded = load_board(base, [first, str(second), empty]) + assert loaded.extras == (first, second, empty) + assert loaded.param("gpio.loop_pins") == ["PA5", "PB0"] + assert loaded.param("gpio.limit_pins") == ["PB8"] + assert loaded.wiring(["one"], ["plain"]).loaded("PB0") == ("plain",), "a description became a mapping" + assert load_board(base).wiring_sets["one"].options["plain"].loads == () + with pytest.raises(ConfigError, match="no board extra file"): + load_board(base, [tmp_path / "missing.yaml"]) + with pytest.raises(ConfigError, match="must be a mapping"): + load_board(base, [write(tmp_path / "list.yaml", "- a\n")]) + with pytest.raises(ConfigError): + load_board(base, [write(tmp_path / "bad.yaml", "a: [\n")]) + + +@pytest.mark.parametrize("name", ["nucleo_wb55rg", "nucleo_wba55cg"]) +def test_board_files_offer_their_options(name): + """Each option of the board files parses; every option a set offers can be enabled on its own.""" + loaded = load_board(name) + for set_name, wiring_set in loaded.wiring_sets.items(): + for tag in wiring_set.options: + assert loaded.wiring([set_name], [tag]).has(tag) diff --git a/validation/host/tests/unit/test_crypto_ref.py b/validation/host/tests/unit/test_crypto_ref.py new file mode 100644 index 00000000..463b238a --- /dev/null +++ b/validation/host/tests/unit/test_crypto_ref.py @@ -0,0 +1,125 @@ +"""`crypto_ref`: AES-128 against FIPS-197 and SP 800-38A, the data swapping model of the STM32 AES peripheral, and +P-256 arithmetic against known multiples of G and the NIST CAVP ECC CDH vector.""" + +import pytest + +from hal_st_validation import crypto_ref +from hal_st_validation.crypto_ref import P256 + +KEY = bytes(range(16)) + + +@pytest.mark.parametrize("vector", [crypto_ref.AES_FIPS197_C1, crypto_ref.AES_SP800_38A_F11], ids=lambda vector: vector.name) +def test_aes_vectors(vector): + assert crypto_ref.aes_ecb_encrypt(vector.key, vector.plaintext) == vector.ciphertext + assert crypto_ref.aes_ecb_decrypt(vector.key, vector.ciphertext) == vector.plaintext + + +def test_fips197_c1_literal(): + key = bytes.fromhex("000102030405060708090a0b0c0d0e0f") + plaintext = bytes.fromhex("00112233445566778899aabbccddeeff") + assert crypto_ref.aes_encrypt_block(key, plaintext).hex() == "69c4e0d86a7b0430d8cdb78070b4c55a" + + +def test_sbox_known_entries(): + """FIPS-197 Figure 7: S(00) = 63, S(53) = ed, S(ff) = 16; the inverse undoes it.""" + assert (crypto_ref.SBOX[0x00], crypto_ref.SBOX[0x53], crypto_ref.SBOX[0xFF]) == (0x63, 0xED, 0x16) + assert all(crypto_ref.INVERSE_SBOX[crypto_ref.SBOX[value]] == value for value in range(256)) + + +def test_ecb_needs_whole_blocks(): + with pytest.raises(ValueError): + crypto_ref.aes_ecb_encrypt(KEY, bytes(15)) + with pytest.raises(ValueError): + crypto_ref.aes_ecb_encrypt(bytes(15), bytes(16)) + + +def test_swap_words(): + data = bytes([0x01, 0x02, 0x03, 0x04, 0x80, 0x40, 0x20, 0x10]) + assert crypto_ref.swap_words(data, "byte") == data + assert crypto_ref.swap_words(data, "none") == bytes([0x04, 0x03, 0x02, 0x01, 0x10, 0x20, 0x40, 0x80]) + assert crypto_ref.swap_words(data, "half") == bytes([0x02, 0x01, 0x04, 0x03, 0x40, 0x80, 0x10, 0x20]) + assert crypto_ref.swap_words(data, "bit") == bytes([0x80, 0x40, 0xC0, 0x20, 0x01, 0x02, 0x04, 0x08]) + with pytest.raises(ValueError): + crypto_ref.swap_words(bytes(3), "none") + + +@pytest.mark.parametrize("swap", crypto_ref.SWAPS) +def test_swap_is_an_involution_and_round_trips(swap): + data = bytes(range(48)) + assert crypto_ref.swap_words(crypto_ref.swap_words(data, swap), swap) == data + ciphertext = crypto_ref.aes_stm(KEY, data, swap) + assert crypto_ref.aes_stm(KEY, ciphertext, swap, decrypt=True) == data + + +def test_byte_swap_is_standard_aes_and_the_others_differ(): + vector = crypto_ref.AES_FIPS197_C1 + assert crypto_ref.aes_stm(vector.key, vector.plaintext) == vector.ciphertext + others = {crypto_ref.aes_stm(vector.key, vector.plaintext, swap) for swap in ("none", "half", "bit")} + assert vector.ciphertext not in others and len(others) == 3 + + +def test_none_swap_reverses_each_word_around_aes(): + vector = crypto_ref.AES_FIPS197_C1 + swapped = crypto_ref.swap_words(vector.plaintext, "none") + assert crypto_ref.aes_stm(vector.key, swapped, "none") == crypto_ref.swap_words(vector.ciphertext, "none") + + +def test_generator_on_curve(): + assert P256.on_curve(P256.g) + assert not P256.on_curve((P256.gx, P256.gy + 1)) + assert not P256.on_curve((P256.p, 0)) + assert P256.on_curve(None) + + +@pytest.mark.parametrize( + ("k", "x", "y"), + [ + ( + 1, + 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296, + 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5, + ), + ( + 2, + 0x7CF27B188D034F7E8A52380304B51AC3C08969E277F21B35A60B48FC47669978, + 0x07775510DB8ED040293D9AC69F7430DBBA7DADE63CE982299E04B79D227873D1, + ), + ( + 3, + 0x5ECBE4D1A6330A44C8F7EF951D4BF165E6C6B721EFADA985FB41661BC6E7FD6C, + 0x8734640C4998FF7E374B06CE1A64A2ECD82AB036384FB83D9A79B127A27D5032, + ), + ], +) +def test_multiples_of_g(k, x, y): + assert crypto_ref.scalar_multiply(P256, k) == (x, y) + + +def test_group_order(): + assert crypto_ref.scalar_multiply(P256, P256.n) is None + assert crypto_ref.scalar_multiply(P256, P256.n - 1) == (P256.gx, P256.p - P256.gy) + assert crypto_ref.scalar_multiply(P256, 0) is None + + +def test_addition_rules(): + double = crypto_ref.point_double(P256, P256.g) + assert crypto_ref.point_add(P256, P256.g, P256.g) == double + assert crypto_ref.point_add(P256, None, P256.g) == P256.g + assert crypto_ref.point_add(P256, P256.g, None) == P256.g + assert crypto_ref.point_add(P256, P256.g, (P256.gx, P256.p - P256.gy)) is None + assert crypto_ref.point_add(P256, double, P256.g) == crypto_ref.scalar_multiply(P256, 3) + + +def test_cavp_cdh_vector(): + vector = crypto_ref.P256_CAVP_CDH + assert crypto_ref.scalar_multiply(P256, vector.d) == (vector.qx, vector.qy) + shared = crypto_ref.scalar_multiply(P256, vector.d, (vector.peer_x, vector.peer_y)) + assert shared is not None and shared[0] == vector.z + assert P256.on_curve((vector.peer_x, vector.peer_y)) and P256.on_curve(shared) + + +def test_bytes_conversion(): + assert crypto_ref.to_bytes(1) == bytes(31) + b"\x01" + assert crypto_ref.from_bytes(b"\x01\x00") == 256 + assert crypto_ref.to_bytes(0x0102, 4) == b"\x00\x00\x01\x02" diff --git a/validation/host/tests/unit/test_fake_analog.py b/validation/host/tests/unit/test_fake_analog.py new file mode 100644 index 00000000..2ceaf260 --- /dev/null +++ b/validation/host/tests/unit/test_fake_analog.py @@ -0,0 +1,284 @@ +"""The fake `ain` and `dma` groups (argument order and reasons of validation/firmware/AnalogInputGroup.cpp and +DmaGroup.cpp; PROTOCOL.md D.11, D.12), what they share with `adc`, `pwm` and the other TIM2 users, their deferred +final lines over a fake clock, and the `groups.analog` wrappers and expectations.""" + +import pytest +from ad3_waveforms_bench.protocol import parse_response +from ad3_waveforms_bench.terminal import FirmwareTerminal + +from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS, FakeFirmware, FakeSerial +from hal_st_validation.fakes import analog as fake_analog +from hal_st_validation.firmware import Firmware +from hal_st_validation.groups import analog + + +class Clock: + def __init__(self) -> None: + self.now = 100.0 + + def __call__(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.now += seconds + + +def make(family="stm32wb55"): + clock = Clock() + fake = FakeFirmware(family=family, clock=clock, sleep=clock.sleep) + terminal = FirmwareTerminal(serial=FakeSerial(fake), timeout=0.5) + pins = WB55_PINS if family == "stm32wb55" else WBA55_PINS + return terminal, fake, clock, Firmware(terminal, pins) + + +def ticking(fake, clock, step): + """Advance the fake clock by `step` whenever the host reads or writes, so deferred replies arrive.""" + original = fake.poll + + def poll(): + clock.now += step + original() + + fake.poll = poll + + +def reason(terminal, line): + response = terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("ain.read", "usage"), + ("ain.read 1", "usage"), + ("ain.read 1 ain4 extra", "usage"), + ("ain.read 1 ain4 speed=1", "usage"), + ("ain.read x ain4", "usage"), + ("ain.read 65536 ain4", "range"), + ("ain.read 1 ain4 sampling=3.5", "usage"), + ("ain.read 2 ain4", "range"), + ("ain.read 2 ain4 sampling=3.5", "usage"), + ("ain.read 1 PZ1", "pin"), + ("ain.read 1 gpio0", "pin"), + ("ain.read 1 PC7", "pin"), + ("ain.read 1 terminaltx", "pin"), + ("ain.read 1 ain4", "ok"), + ("ain.read 1 ain4 sampling=640.5", "ok"), + ("ain.read 1 temp sampling=640.5", "ok"), + ], +) +def test_read_reasons(line, expected): + terminal, *_ = make() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("ain.burst 1 ain4", "usage"), + ("ain.burst 1 ain4 n=16", "usage"), + ("ain.burst 1 ain4 rate=1000", "usage"), + ("ain.burst 1 n=16 rate=1000", "usage"), + ("ain.burst 1 ain4 n=16 rate=1000 speed=1", "usage"), + ("ain.burst 1 ain4 n=x rate=1000", "usage"), + ("ain.burst 1 ain4 n=0 rate=1000", "range"), + ("ain.burst 1 ain4 n=257 rate=1000", "range"), + ("ain.burst 1 ain4 n=16 rate=9", "range"), + ("ain.burst 1 ain4 n=16 rate=100001", "range"), + ("ain.burst 1 ain4 n=16 rate=1000 repeat=0", "range"), + ("ain.burst 1 ain4 n=16 rate=1000 repeat=3", "range"), + ("ain.burst 1 ain4 n=16 rate=1000 out=hex", "usage"), + ("ain.burst 1 ain4 n=65 rate=1000", "range"), + ("ain.burst 4 ain4 n=16 rate=1000", "range"), + ("ain.burst 4 gpio0 n=16 rate=1000", "range"), + ("ain.burst 1 gpio0 n=16 rate=1000", "pin"), + ("ain.burst 1 PZ1 n=16 rate=1000", "pin"), + ], +) +def test_burst_reasons(line, expected): + terminal, *_ = make() + assert reason(terminal, line) == expected + + +def test_read_codes_and_temperature(): + _, fake, _, fw = make("stm32wba55") + fake.adc_codes["PA7"] = 1234 + assert fw.ain.read(4, "ain2") == analog.AinReading(code=1234, mcelsius=None) + assert fake.received[-1] == "ain.read 4 PA7" + reading = fw.ain.read(4, "temp", sampling="814.5") + assert reading.mcelsius == fake_analog.TEMPERATURE_MCELSIUS + assert fake.received[-1] == "ain.read 4 temp sampling=814.5" + assert fake.resources == {} and fake.claims == {} + + +def test_burst_answers_after_n_over_rate_and_holds_its_claims_until_then(): + terminal, fake, clock, _ = make() + fake.adc_codes["PC3"] = 777 + started = clock.now + pending = terminal.begin("ain.burst 1 ain4 n=100 rate=1000 out=stats") + clock.now = started + 0.099 + terminal.pump() + assert fake.group("ain").burst is not None, "answered before n / rate" + owner = fake_analog.FakeAnalogInput.owner + assert fake.resources == {("adc", 1): owner, ("dma1", 7): owner} + assert fake.timer_owners == {2: owner} and fake.claims["PC3"] == (owner, True) + clock.now = started + 0.1 + run = analog.BurstRun.from_values(pending.wait()) + assert run == analog.BurstRun(n=100, minimum=777, maximum=777, mean=777, us=100_000, samples=None) + assert fake.resources == {} and fake.timer_owners == {} and fake.claims == {} + + +def test_burst_twice_reports_the_first_run_as_event(): + terminal, fake, clock, fw = make() + fake.adc_codes["PC3"] = 100 + pending = fw.ain.begin("burst", 1, "PC3", n=16, rate=1000, repeat=2) + clock.now += 0.016 + assert [event.as_int("run") for event in terminal.events("ain")] == [1] + clock.now += 0.016 + response = pending.wait() + first = terminal.drain_events("ain") + runs = [analog.BurstRun.from_values(first[0]), analog.BurstRun.from_values(response)] + assert [run.samples for run in runs] == [(100,) * 16] * 2 + assert [run.us for run in runs] == [16_000, 16_000] + + +def test_burst_wrapper_collects_every_run(): + _, fake, clock, fw = make() + ticking(fake, clock, 0.001) + fake.adc_codes["PC3"] = 5 + runs = fw.ain.burst(1, "ain4", n=16, rate=1000, repeat=2) + assert [run.n for run in runs] == [16, 16] + assert all(run.mean == 5 and run.samples == (5,) * 16 for run in runs) + assert fw.ain.burst(1, "ain4", n=200, rate=10000, out="stats")[0].n == 200 + + +def test_ain_shares_the_adc_tim2_and_the_dma_channel(): + terminal, fake, clock, fw = make() + fw.adc.open(1, pins=["ain4"]) + assert reason(terminal, "ain.read 1 ain3") == "busy" + assert reason(terminal, "ain.read 1 temp") == "busy" + assert reason(terminal, "ain.burst 1 ain3 n=16 rate=1000") == "busy" + fw.adc.close(1) + fw.pwm.open(2, pins=["tim2ch1"]) + assert reason(terminal, "ain.burst 1 ain3 n=16 rate=1000") == "busy" + assert reason(terminal, "ain.read 1 ain3") == "ok" + fw.pwm.close(2) + fake.resources[("dma1", 7)] = ("spis", "1") + assert reason(terminal, "ain.burst 1 ain3 n=16 rate=1000") == "busy" + assert reason(terminal, "ain.read 1 ain3") == "ok" + del fake.resources[("dma1", 7)] + pending = terminal.begin("ain.burst 1 ain4 n=16 rate=1000") + clock.now += 0.016 + assert pending.wait().ok + assert reason(terminal, "adc.open 1 pins=PC2 timer=2") == "ok" + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("dma.wave", "usage"), + ("dma.wave gpio0 rate=1000", "usage"), + ("dma.wave gpio0 pattern=a5", "usage"), + ("dma.wave gpio0 PA1 rate=1000 pattern=a5", "usage"), + ("dma.wave gpio0 rate=1000 pattern=a5 speed=1", "usage"), + ("dma.wave gpio0 rate=x pattern=a5", "usage"), + ("dma.wave gpio0 rate=0 pattern=a5", "range"), + ("dma.wave gpio0 rate=1000001 pattern=a5", "range"), + ("dma.wave gpio0 rate=1000 pattern=-", "usage"), + ("dma.wave gpio0 rate=1000 pattern=a", "usage"), + ("dma.wave gpio0 rate=1000 pattern=zz", "usage"), + ("dma.wave gpio0 rate=1000 pattern=" + "00" * 33, "range"), + ("dma.wave gpio0 rate=1000 pattern=a5 ms=0", "range"), + ("dma.wave gpio0 rate=1000 pattern=a5 ms=10001", "range"), + ("dma.wave gpio0 rate=0 pattern=zz", "range"), + ("dma.wave PZ1 rate=1000 pattern=a5", "pin"), + ("dma.wave PC7 rate=1000 pattern=a5", "pin"), + ("dma.wave terminaltx rate=1000 pattern=a5", "busy"), + ], +) +def test_wave_reasons(line, expected): + terminal, *_ = make() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize(("family", "resource"), [("stm32wb55", ("dma2", 4)), ("stm32wba55", ("dma1", 8))]) +def test_wave_holds_its_claims_for_ms(family, resource): + terminal, fake, clock, _ = make(family) + pin = "PC6" if family == "stm32wb55" else "PB14" + pending = terminal.begin("dma.wave gpio0 rate=1000000 pattern=" + "ff" * 32 + " ms=200") + owner = fake_analog.FakeDma.owner + clock.now += 0.199 + terminal.pump() + assert fake.group("dma").wave is not None, "answered before ms" + assert fake.resources == {resource: owner} and fake.timer_owners == {2: owner} and fake.claims == {pin: (owner, False)} + clock.now += 0.001 + assert pending.wait().ok + assert fake.resources == {} and fake.timer_owners == {} and fake.claims == {} + assert reason(terminal, "gpio.cfg gpio0 out") == "ok" + + +def test_wave_busy_with_tim2_users_and_the_shared_channel(): + terminal, fake, clock, fw = make("stm32wba55") + fw.pwm.open(2, pins=["tim2ch1"]) + assert reason(terminal, "dma.wave gpio1 rate=1000 pattern=a5") == "busy" + fw.pwm.close(2) + fake.resources[("dma1", 8)] = ("spis", "1") + assert reason(terminal, "dma.wave gpio1 rate=1000 pattern=a5") == "busy" + del fake.resources[("dma1", 8)] + fw.gpio.cfg("gpio1", "out") + assert reason(terminal, "dma.wave gpio1 rate=1000 pattern=a5") == "busy" + pending = terminal.begin("dma.wave gpio0 rate=1000 pattern=a5") + clock.now += 0.05 + assert pending.wait().ok + + +def test_wave_wrapper_sends_hex_and_waits(): + _, fake, clock, fw = make() + ticking(fake, clock, 0.01) + fw.dma.wave("gpio0", rate=100000, pattern=bytes([0xA5, 0x0F]), ms=20) + assert fake.received[-1] == "dma.wave PC6 rate=100000 pattern=a50f ms=20" + + +def test_reboot_drops_the_pending_commands(): + terminal, fake, *_ = make() + terminal.begin("dma.wave gpio0 rate=1000 pattern=a5 ms=10000") + fake.boot() + assert fake.group("dma").wave is None and fake.resources == {} + assert fake.group("ain").burst is None + + +def test_wave_bits_are_lsb_first(): + assert analog.wave_bits(bytes([0x01, 0x80])) == [1, 0, 0, 0, 0, 0, 0, 0] + [0, 0, 0, 0, 0, 0, 0, 1] + + +@pytest.mark.parametrize( + ("clock", "rate", "expected"), + [ + (64_000_000, 1_000_000, (0, 63)), + (64_000_000, 1000, (0, 63_999)), + (100_000_000, 300_000, (0, 332)), + (64_000_000, 32_000_000, (0, 1)), + ], +) +def test_trigger_timing_matches_the_firmware(clock, rate, expected): + assert analog.trigger_timing(clock, rate) == expected + + +def test_trigger_timing_on_16_bit_counters(): + prescaler, period = analog.trigger_timing(64_000_000, 10, counter_bits=16) + assert period < 1 << 16 + assert 64_000_000 / ((prescaler + 1) * (period + 1)) == pytest.approx(10, rel=1e-4) + + +def test_burst_seconds_follow_the_real_rate(): + assert analog.burst_seconds(100, 64_000_000, 1000) == pytest.approx(0.1) + assert analog.burst_seconds(3, 100_000_000, 300_000) == pytest.approx(3 * 333 / 100_000_000) + + +def test_stats_and_list_runs_agree(): + listed = analog.BurstRun.from_values(parse_response("OK samples=1,2,4 us=30")) + assert (listed.n, listed.minimum, listed.maximum, listed.mean, listed.us) == (3, 1, 4, 2, 30) + assert listed.samples == (1, 2, 4) + stats = analog.BurstRun.from_values(parse_response("OK n=3 min=1 max=4 mean=2 us=30")) + assert stats == analog.BurstRun(n=3, minimum=1, maximum=4, mean=2, us=30, samples=None) diff --git a/validation/host/tests/unit/test_fake_crypto.py b/validation/host/tests/unit/test_fake_crypto.py new file mode 100644 index 00000000..9b2549a0 --- /dev/null +++ b/validation/host/tests/unit/test_fake_crypto.py @@ -0,0 +1,237 @@ +"""The fake `rng`, `aes` and `pka` groups (argument order and reasons of validation/firmware/RngGroup.cpp, +AesGroup.cpp and PkaGroup.cpp; PROTOCOL.md D.16) and the `groups.crypto` wrappers.""" + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal + +from hal_st_validation import crypto_ref, rngstats +from hal_st_validation.crypto_ref import P256 +from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS, FakeFirmware, FakeSerial +from hal_st_validation.firmware import Firmware + +KEY = "000102030405060708090a0b0c0d0e0f" +DATA = "00112233445566778899aabbccddeeff" + + +def make(family="stm32wb55"): + fake = FakeFirmware(family=family) + terminal = FirmwareTerminal(serial=FakeSerial(fake), timeout=0.5) + return terminal, fake, Firmware(terminal, WB55_PINS if family == "stm32wb55" else WBA55_PINS) + + +def reason(terminal, line): + response = terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("rng.read", "usage"), + ("rng.read 4 5", "usage"), + ("rng.read 4 speed=1", "usage"), + ("rng.read x", "usage"), + ("rng.read 0", "range"), + ("rng.read 129", "range"), + ("rng.read 0 variant=x", "range"), + ("rng.read 4 variant=x", "usage"), + ("rng.read 4 lock5=2", "range"), + ("rng.read 4 lock5=1", "usage"), + ("rng.read 4 variant=sync lock5=1", "usage"), + ("rng.read 128 variant=async", "ok"), + ("rng.read 1 variant=hsem lock5=1", "ok"), + ("rng.stats", "usage"), + ("rng.stats 16 lock5=0", "usage"), + ("rng.stats 15", "range"), + ("rng.stats 65537", "range"), + ("rng.stats 16 variant=x", "usage"), + ("rng.stats 16 variant=hsem", "ok"), + ], +) +def test_rng_errors_wb55(line, expected): + terminal, _, _ = make() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("rng.read 4 variant=hsem", "unsupported"), + ("rng.read 4 variant=hsem lock5=1", "unsupported"), + ("rng.read 4 lock5=1", "unsupported"), + ("rng.read 4 lock5=0", "ok"), + ("rng.read 4 lock5=2", "range"), + ("rng.read 0 variant=hsem", "range"), + ("rng.stats 16 variant=hsem", "unsupported"), + ("rng.read 4 variant=async", "ok"), + ], +) +def test_rng_errors_wba55(line, expected): + terminal, _, _ = make("stm32wba55") + assert reason(terminal, line) == expected + + +def test_rng_reads_differ_and_have_the_length(): + _, _, fw = make() + first = fw.rng.read(16) + second = fw.rng.read(16, variant="async") + assert len(first.data) == len(second.data) == 16 and first.data != second.data + assert first.hsi48 is None + assert fw.rng.read(1, variant="hsem").hsi48 == 1 + + +def test_rng_hsem_reports_hsi48_of_the_clock_group(): + _, fake, fw = make() + fw.clock.hsi48(False) + assert fw.rng.read(4, variant="hsem").hsi48 == 0 + with pytest.raises(FirmwareError) as error: + fw.rng.read(4, variant="hsem", lock5=True) + assert error.value.reason == "failed" + fw.clock.hsi48(True) + assert fw.rng.read(4, variant="hsem", lock5=True).hsi48 == 1 + assert fake.group("rng") is not None + + +def test_rng_plain_variants_need_hsi48(): + """On STM32WB55 HSI48 is the RNG kernel clock: only `variant=hsem` starts it.""" + terminal, _, fw = make() + fw.clock.hsi48(False) + for line in ("rng.read 4", "rng.read 4 variant=async", "rng.stats 16", "rng.stats 16 variant=async"): + assert reason(terminal, line) == "failed", line + assert reason(terminal, "rng.read 4 lock5=1") == "usage" + assert reason(terminal, "rng.stats 16 variant=hsem") == "ok" + fw.clock.hsi48(True) + assert reason(terminal, "rng.read 4") == "ok" + + +def test_rng_hsem_refuses_semaphores_held_by_this_core(): + terminal, _, fw = make() + fw.hsem.take(0, procid=1) + assert reason(terminal, "rng.read 4 variant=hsem") == "busy" + assert reason(terminal, "rng.stats 16 variant=hsem") == "busy" + fw.hsem.release(0, procid=1) + fw.hsem.take(5, procid=1) + assert reason(terminal, "rng.read 4 variant=hsem") == "ok" + assert reason(terminal, "rng.read 4 variant=hsem lock5=1") == "busy" + fw.clock.hsi48(False) + assert reason(terminal, "rng.read 4 variant=hsem") == "failed" + assert reason(terminal, "rng.stats 16 variant=hsem") == "failed" + fw.clock.hsi48(True) + fw.hsem.release(5, procid=1) + assert reason(terminal, "rng.read 4 variant=hsem lock5=1") == "ok" + + +def test_rng_hsem_excludes_the_coordinated_flash(): + terminal, _, fw = make() + fw.flash.stack("starting") + assert reason(terminal, "rng.read 16 variant=hsem") == "busy" + assert reason(terminal, "rng.stats 16 variant=hsem") == "busy" + assert reason(terminal, "rng.read 16") == "ok" + fw.flash.stack("stopped") + assert reason(terminal, "rng.read 16 variant=hsem") == "ok" + + +def test_rng_stats_are_consistent_and_random(): + _, _, fw = make() + stats = fw.rng.stats(65536) + assert stats.n == 65536 and stats.us > 0 + assert rngstats.failures(stats.n, stats.ones, stats.runs, stats.chisq_x1000) == [] + assert fw.rng.stats(256).crc != fw.rng.stats(256).crc + + +def test_rng_restarts_at_boot(): + _, fake, fw = make() + first = fw.rng.read(8).data + fake.boot() + assert fw.rng.read(8).data == first + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("aes.enc", "usage"), + (f"aes.enc {KEY}", "usage"), + (f"aes.enc {KEY} {DATA} {DATA}", "usage"), + (f"aes.enc {KEY} {DATA} mode=ecb", "usage"), + (f"aes.enc {KEY[:-2]} {DATA}", "usage"), + (f"aes.enc {KEY}{KEY} {DATA}", "usage"), + (f"aes.enc - {DATA}", "usage"), + (f"aes.enc {KEY} -", "usage"), + (f"aes.enc {KEY} {DATA[:-1]}", "usage"), + (f"aes.enc {KEY} {DATA[:-2]}", "usage"), + (f"aes.enc {KEY} {DATA[:-2]}zz", "usage"), + (f"aes.enc {KEY} {DATA * 6}", "usage"), + (f"aes.enc {KEY} {DATA * 5}", "ok"), + (f"aes.enc {KEY} {DATA} swap=word", "usage"), + (f"aes.dec {KEY} {DATA} swap=none", "ok"), + ], +) +def test_aes_errors(line, expected): + terminal, _, _ = make() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize("swap", [None, *crypto_ref.SWAPS]) +def test_aes_follows_the_model(swap): + _, _, fw = make("stm32wba55") + vector = crypto_ref.AES_SP800_38A_F11 + ciphertext = fw.aes.enc(vector.key, vector.plaintext, swap=swap) + assert ciphertext == crypto_ref.aes_stm(vector.key, vector.plaintext, swap or "byte") + assert fw.aes.dec(vector.key, ciphertext, swap=swap) == vector.plaintext + if swap in (None, "byte"): + assert ciphertext == vector.ciphertext + + +OPERAND_33 = "01" * 33 + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("pka.mul 1", "usage"), + ("pka.mul z=01", "usage"), + ("pka.mul x=01", "usage"), + ("pka.mul y=01", "usage"), + ("pka.mul k=1", "usage"), + ("pka.mul k=-", "usage"), + ("pka.mul k=zz", "usage"), + (f"pka.mul k={OPERAND_33} x=01", "usage"), + (f"pka.mul k={OPERAND_33}", "range"), + (f"pka.mul k=01 x=01 y={OPERAND_33}", "range"), + ("pka.mul k=02", "ok"), + ("pka.check", "usage"), + ("pka.check x=01", "usage"), + (f"pka.check x={OPERAND_33} y=0", "usage"), + (f"pka.check x={OPERAND_33} y=01", "range"), + ("pka.check x=01 y=02", "ok"), + ("pka.cmp", "usage"), + ("pka.cmp a=00000001", "usage"), + ("pka.cmp a=00000001 b=0000000001", "usage"), + ("pka.cmp a=0001 b=000002", "usage"), + ("pka.cmp a=000001 b=000002", "range"), + ("pka.cmp a=00000001 b=00000002 c=1", "usage"), + (f"pka.cmp a={'01' * 60} b={'02' * 60}", "ok"), + ], +) +def test_pka_errors(line, expected): + terminal, _, _ = make() + assert reason(terminal, line) == expected + + +def test_pka_results(): + _, _, fw = make() + default = fw.pka.mul() + assert (crypto_ref.from_bytes(default.x), crypto_ref.from_bytes(default.y)) == P256.g + product = fw.pka.mul(k=b"\x02") + assert (crypto_ref.from_bytes(product.x), crypto_ref.from_bytes(product.y)) == crypto_ref.scalar_multiply(P256, 2) + assert len(product.x) == 32 and product.us > 0 + assert fw.pka.check(crypto_ref.to_bytes(P256.gx), crypto_ref.to_bytes(P256.gy)) + assert not fw.pka.check(b"\x01", b"\x02") + assert fw.pka.cmp(b"\x00\x00\x00\x01", b"\x00\x00\x00\x02") == "lt" + assert fw.pka.cmp(b"\x00\x00\x01\x00", b"\x00\x00\x00\x02") == "gt" + assert fw.pka.cmp(bytes(8), bytes(8)) == "eq" + + +def test_crypto_groups_are_attached(): + _, _, fw = make() + assert {"rng", "aes", "pka"} <= {name for name in vars(fw)} diff --git a/validation/host/tests/unit/test_fake_firmware.py b/validation/host/tests/unit/test_fake_firmware.py index 2472fe24..6dc0f936 100644 --- a/validation/host/tests/unit/test_fake_firmware.py +++ b/validation/host/tests/unit/test_fake_firmware.py @@ -125,13 +125,27 @@ def test_sync_clears_partial_input(): assert firmware.received[-1] == "ping" -@pytest.mark.parametrize("command", UNSUPPORTED_COMMANDS) -def test_unsupported_groups(command): - terminal, _ = make_terminal() +@pytest.mark.parametrize(("family", "command"), [(family, command) for family, names in UNSUPPORTED_COMMANDS.items() for command in names]) +def test_unsupported_groups(family, command): + terminal, _ = make_terminal(family=family) assert reason(terminal, command) == "unsupported" assert reason(terminal, f"{command} 0 key=1") == "unsupported" +def test_unsupported_names_follow_the_mcu(): + """HSEM, QUADSPI, the wireless-stack flash steps, MCO and HSI48 exist on the WB55 only, LPTIM PWM on the WBA55 + only; EMIL's EEPROM group is served on both.""" + assert {"hsem.take", "qspi.open", "flash.stack", "clock.mco", "clock.hsi48"} <= set(UNSUPPORTED_COMMANDS["stm32wba55"]) + assert {"lptpwm.open", "lptpwm.close"} <= set(UNSUPPORTED_COMMANDS["stm32wb55"]) + common = set(UNSUPPORTED_COMMANDS["stm32wb55"]) & set(UNSUPPORTED_COMMANDS["stm32wba55"]) + assert {name.split(".")[0] for name in common} == {"comp", "can", "eth"} + assert not any(name.startswith("eeprom.") for names in UNSUPPORTED_COMMANDS.values() for name in names) + terminal, _ = make_terminal() + assert reason(terminal, "hsem.take 0 procid=1") != "unsupported" + terminal, _ = wba() + assert reason(terminal, "lptpwm.close 1") != "unsupported" + + # board profiles against the board files @@ -156,10 +170,11 @@ def test_board_file_system_lines(family): @pytest.mark.parametrize("family", sorted(BOARDS)) def test_every_alias_is_a_pin(family): + """Every alias names a pin; the reserved ones (terminal, debug LED) answer busy.""" board = load_board(BOARDS[family]) - terminal, _ = make_terminal(family=family) + terminal, firmware = make_terminal(family=family) for alias, pin in board.pins.items(): - expected = "busy" if pin in board.terminal.pins else "ok" + expected = "busy" if pin in firmware.reserved() else "ok" assert reason(terminal, f"gpio.cfg {alias} in") == expected, alias if expected == "ok": assert reason(terminal, f"gpio.get {pin}") == "ok" @@ -618,7 +633,7 @@ def test_spi_wba55_instances_and_clock(): assert reason(terminal, "spi.open 1 clk=PB4 mosi=PA15 miso=PB3 baud=3000000") == "ok" assert firmware.opened[("spi", "1")]["clock"] == 1_562_500 terminal.command("spi.close 1") - assert reason(terminal, "spi.open 3 clk=PA0 mosi=PB8 miso=PB9") == "busy", "PB8 is the debug LED" + assert reason(terminal, "spi.open 3 clk=PA0 mosi=PB8 miso=PB9") == "ok", "PB8 is free since the debug LED moved to PA9" def test_spi_transfers(): @@ -780,7 +795,13 @@ def test_adc_pins_are_shared_only_with_analog_users(): ("qei.open 1 lp=1 a=PC0 b=PC2 invb=1", "unsupported"), ("qei.open 1 lp=1 a=PC0 b=PC2 offset=0", "unsupported"), ("qei.open 1 lp=1 a=PC0 b=PC2 invb=0", "unsupported"), - ("qei.open 1 lp=1 a=PC0 b=PC2 cap=ab", "unsupported"), + ("qei.open 1 lp=1 a=PC0 b=PC2 cap=ab", "ok"), + ("qei.open 1 lp=1 a=PC0 b=PC2 cap=rise", "ok"), + ("qei.open 1 lp=1 a=PC0 b=PC2 cap=fall", "ok"), + ("qei.open 1 lp=1 a=PC0 b=PC2 cap=b", "unsupported"), + ("qei.open 1 lp=1 a=PC0 b=PC2 cap=x", "usage"), + ("qei.open 2 cap=rise", "usage"), + ("qei.open 2 lp=1 a=PB1 b=PC2", "range"), ("qei.open 0 lp=1 a=PC0 b=PC2", "range"), ("qei.open 1 lp=1 a=PC0 b=PC2 inva=1 filter=8", "ok"), ("qei.open 1 lp=1 a=PC0 b=PC2 filter=3", "range"), @@ -800,9 +821,15 @@ def test_qei_open_validation_wb55(line, expected): ("qei.open 3 a=PA10 b=PA1", "ok"), ("qei.open 3", "usage"), ("qei.open 2 a=PA5 b=PA8", "busy"), - ("qei.open 1 lp=1 a=PA0 b=PB3", "unsupported"), - ("qei.open 0 lp=1", "unsupported"), - ("qei.open 17 lp=1 filter=3", "unsupported"), + ("qei.open 1 lp=1 a=PA0 b=PB3", "ok"), + ("qei.open 2 lp=1 a=PB9 b=PB0", "ok"), + ("qei.open 2 lp=1 a=lptim2in1 b=lptim2in2 cap=fall filter=8", "ok"), + ("qei.open 1 lp=1 a=PA0 b=PB3 cap=a", "unsupported"), + ("qei.open 1 lp=1 a=PB9 b=PB0", "pin"), + ("qei.open 1 lp=1", "usage"), + ("qei.open 0 lp=1", "range"), + ("qei.open 3 lp=1", "range"), + ("qei.open 17 lp=1 filter=3", "range"), ("qei.open 4", "range"), ], ) @@ -979,3 +1006,296 @@ def test_argument_errors_come_before_busy(): ] for line, expected in cases: assert reason(terminal, line) == expected, line + + +# board profiles of the coverage extensions + + +def test_wba55_bonding_and_debug_led(): + """PA3, PA4, PB10, PB11 and PB13 are not bonded on the UFQFPN48; the debug LED is the green LD2 on PA9.""" + terminal, firmware = wba() + for pin in ("PA3", "PA4", "PB10", "PB11", "PB13"): + assert reason(terminal, f"gpio.cfg {pin} in") == "pin", pin + assert reason(terminal, "gpio.cfg PA9 in") == "busy" + assert reason(terminal, "gpio.cfg led1 in") == "busy", "led1 names the debug LED pin" + assert reason(terminal, "gpio.cfg PB8 in") == "ok" + assert firmware.spec.debug_led == "PA9" + + +@pytest.mark.parametrize( + ("family", "function", "instance", "pin"), + [ + ("stm32wb55", "i2cScl", 1, "PB8"), + ("stm32wb55", "i2cSda", 1, "PB9"), + ("stm32wb55", "i2cScl", 3, "PC0"), + ("stm32wb55", "i2cSda", 3, "PC1"), + ("stm32wb55", "i2cScl", 3, "PA7"), + ("stm32wb55", "i2cSda", 3, "PB4"), + ("stm32wb55", "spiSlaveSelect", 1, "PA4"), + ("stm32wb55", "spiSlaveSelect", 2, "PB12"), + ("stm32wb55", "quadSpiClock", 0, "PA3"), + ("stm32wb55", "quadSpiSlaveSelect", 0, "PA2"), + ("stm32wb55", "quadSpiData0", 0, "PB9"), + ("stm32wb55", "quadSpiData3", 0, "PA6"), + ("stm32wb55", "lpTimerInput1", 2, "PB1"), + ("stm32wba55", "i2cScl", 1, "PB2"), + ("stm32wba55", "i2cSda", 1, "PB1"), + ("stm32wba55", "i2cScl", 1, "PA15"), + ("stm32wba55", "i2cSda", 1, "PB3"), + ("stm32wba55", "i2cScl", 3, "PA6"), + ("stm32wba55", "i2cSda", 3, "PA7"), + ("stm32wba55", "spiSlaveSelect", 3, "PA5"), + ("stm32wba55", "lpTimerChannel2", 1, "PA15"), + ("stm32wba55", "lpTimerChannel1", 2, "PA11"), + ("stm32wba55", "lpTimerChannel2", 2, "PA1"), + ("stm32wba55", "lpTimerInput1", 1, "PA0"), + ("stm32wba55", "lpTimerInput2", 2, "PB0"), + ("stm32wba55", "timerChannel1N", 16, "PB8"), + ], +) +def test_pin_functions_of_the_new_groups(family, function, instance, pin): + _, firmware = make_terminal(family=family) + assert firmware.supports(function, instance, pin) + + +def test_unbonded_table_pins_offer_nothing(): + _, firmware = wba() + assert not firmware.supports("lpTimerChannel1", 1, "PB11"), "LPTIM1 CH1 is on PB11 only, which is not bonded" + assert not firmware.supports("timerChannel1N", 16, "PA3") + assert firmware.first_function_pin("lpuartTx", 1) == "PA2" + + +@pytest.mark.parametrize(("family", "pins"), [("stm32wb55", WB55_PINS), ("stm32wba55", WBA55_PINS)]) +def test_new_aliases_resolve(family, pins): + terminal, _ = make_terminal(family=family) + for alias in ("i2c1scl", "i2c1sda", "i2c3scl", "i2c3sda", "spi1nss"): + assert reason(terminal, f"gpio.cfg {alias} in") == "ok", alias + assert reason(terminal, f"gpio.release {pins[alias]}") == "ok" + + +# spi extensions (D.4) + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 bits=8", "ok"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 bits=12", "unsupported"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 bits=12 dma=1", "ok"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 bits=4 dma=1", "ok"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 bits=16 dma=1", "ok"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 bits=16 sync=1", "unsupported"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 bits=3 dma=1", "range"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 bits=17 dma=1", "range"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 bits=wide", "usage"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 lsb=1", "ok"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 lsb=2", "range"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 nss=PA4", "ok"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 nss=spi1nss", "ok"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 nss=PA15", "ok"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 nss=PA4 cs=PB0", "usage"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 nss=PB12", "pin"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 nss=PB12 bits=12", "pin"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 nss=PA16", "pin"), + ("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 nss=PA5", "pin"), + ("spi.open 2 clk=spi2clk mosi=spi2mosi miso=spi2miso nss=spi2nss bits=16 dma=1 lsb=1", "ok"), + ], +) +def test_spi_open_extensions_wb55(line, expected): + terminal, _ = make_terminal() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("spi.open 3 clk=PA0 mosi=PB8 miso=PB9 bits=16 dma=1", "ok"), + ("spi.open 3 clk=PA0 mosi=PB8 miso=PB9 bits=8 dma=1", "ok"), + ("spi.open 3 clk=PA0 mosi=PB8 miso=PB9 bits=12 dma=1", "unsupported"), + ("spi.open 3 clk=PA0 mosi=PB8 miso=PB9 bits=4 dma=1", "unsupported"), + ("spi.open 3 clk=spi3clk mosi=spi3mosi miso=spi3miso nss=spi3nss", "ok"), + ("spi.open 3 clk=PA0 mosi=PB8 miso=PB9 nss=PA12", "pin"), + ("spi.open 1 clk=PB4 mosi=PA15 miso=PB3 bits=12 dma=1", "ok"), + ("spi.open 1 clk=PB4 mosi=PA15 miso=PB3 nss=PA12 lsb=1", "ok"), + ], +) +def test_spi_open_extensions_wba55(line, expected): + terminal, _ = wba() + assert reason(terminal, line) == expected + + +def test_spi_holds_its_peripheral(): + terminal, firmware = make_terminal() + terminal.command("spi.open 1 clk=PA5 mosi=PA7 miso=PA6 nss=PA4 bits=16 dma=1 lsb=1") + state = firmware.opened[("spi", "1")] + assert (state["bits"], state["lsb"], state["nss"]) == (16, True, "PA4") + assert firmware.resources == {("spi", 1): ("spi", "1")} + assert reason(terminal, "gpio.cfg spi1nss in") == "busy" + terminal.command("spi.close 1") + assert firmware.resources == {} + terminal.command("gpio.cfg PA15 in") + assert reason(terminal, "spi.open 1 clk=PA5 mosi=PA7 miso=PA6 nss=PA15") == "busy" + + +# pwm extensions (D.10) + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("pwm.open 1 channels=1 mode=edgedown", "ok"), + ("pwm.open 1 channels=1 mode=centerup freq=1000", "ok"), + ("pwm.open 1 channels=1 mode=centerboth freq=1000", "ok"), + ("pwm.open 1 channels=1 mode=centerup freq=488", "range"), + ("pwm.open 1 channels=1 mode=edgedown freq=976", "range"), + ("pwm.open 1 channels=1 mode=diagonal", "usage"), + ("pwm.open 16 channels=1 mode=edgedown", "unsupported"), + ("pwm.open 17 channels=1 mode=centerup", "unsupported"), + ("pwm.open 16 channels=1 mode=centerboth", "unsupported"), + ("pwm.open 1 channels=1 preload=0", "ok"), + ("pwm.open 1 channels=1 preload=2", "range"), + ("pwm.open 1 channels=1 brkfilter=3", "usage"), + ("pwm.open 1 channels=1 brkfilter=16", "range"), + ("pwm.open 1 channels=1 brk=PB12 brkfilter=15", "ok"), + ("pwm.open 1 channels=1 brk=PB12 brkfilter=0", "ok"), + ("pwm.open 1 channels=1 brk=PB12 brkfilter=16", "range"), + ("pwm.open 2 channels=1 brk=PB12 brkfilter=2", "unsupported"), + ("pwm.open 1 channels=1 trgo=update", "ok"), + ("pwm.open 1 channels=1 trgo=oc4ref", "ok"), + ("pwm.open 2 channels=1 trgo=oc1", "ok"), + ("pwm.open 1 channels=1 trgo=never", "usage"), + ("pwm.open 16 channels=1 trgo=update", "unsupported"), + ("pwm.open 17 channels=1 trgo=reset", "unsupported"), + ], +) +def test_pwm_open_extensions(line, expected): + terminal, _ = make_terminal() + assert reason(terminal, line) == expected + + +def test_pwm_open_extensions_keep_their_settings(): + terminal, firmware = make_terminal() + terminal.command("pwm.open 1 channels=1 mode=centerup preload=0 trgo=update brk=PB12 brkfilter=4 freq=1000") + state = firmware.opened[("pwm", "1")] + assert (state["mode"], state["alignment"], state["preload"], state["trgo"], state["brkfilter"]) == ( + "centerup", + "center", + False, + "update", + 4, + ) + assert reason(terminal, "pwm.freq 1 488") == "range", "centre aligned: 2 * ARR ticks per period" + + +# adc trgo (D.10) + + +def test_adc_trgo_runs_on_a_pwm_timer(): + terminal, firmware, clock = timed_terminal() + assert reason(terminal, "adc.open 1 pins=PC3 trgo=2") == "unsupported", "nobody drives TIM2" + assert reason(terminal, "adc.open 1 pins=PC3 trgo=3") == "range" + assert reason(terminal, "adc.open 1 pins=PC3 trgo=16") == "unsupported" + assert reason(terminal, "adc.open 1 pins=PC3 trgo=2 timer=2") == "usage" + assert reason(terminal, "adc.open 1 pins=PC3 trgo=2 rate=100") == "usage" + assert reason(terminal, "adc.open 1 pins=PC3 trgo=x") == "usage" + terminal.command("pwm.open 2 channels=1 freq=100 trgo=update") + assert reason(terminal, "adc.open 1 pins=PC3 trgo=2") == "ok" + assert firmware.timer_owners[2] == ("pwm", "2"), "the adc group does not take the timer" + firmware.adc_codes["PC3"] = 7 + start = clock.now + assert reason(terminal, "adc.measure 1 n=10") == "timeout", "the timer does not run before pwm.duty" + assert clock.now - start == pytest.approx(1.0) + terminal.command("pwm.duty 2 50") + start = clock.now + assert terminal.command("adc.measure 1 n=10").as_ints("samples") == [7] * 10 + reply = "OK samples=" + ",".join(["7"] * 10) + assert clock.now - start == pytest.approx(10 / 100 + expect.terminal_line_time(reply, 921600)) + + +def test_adc_trgo_on_a_timer_another_group_holds_is_busy(): + terminal, _ = make_terminal() + terminal.command("qei.open 1 a=PA8 b=PA9") + assert reason(terminal, "adc.open 1 pins=PC3 trgo=1") == "busy" + assert reason(terminal, "adc.open 1 pins=PB0 trgo=1") == "pin", "argument errors come first" + + +def test_adc_trgo_cannot_use_tim1_on_wba55(): + terminal, _ = wba() + terminal.command("pwm.open 1 channels=1 trgo=update") + assert reason(terminal, "adc.open 4 pins=PA7 trgo=1") == "unsupported", "ADC4 reaches TIM1 through TRGO2 only" + terminal.command("pwm.close 1") + terminal.command("pwm.open 2 channels=1 trgo=update") + assert reason(terminal, "adc.open 4 pins=PA7 trgo=2") == "ok" + + +def test_adc_holds_its_peripheral_and_dma_channel(): + terminal, firmware = wba() + terminal.command("adc.open 4 pins=PA7") + assert firmware.resources == {("adc", 4): ("adc", "4"), ("dma1", 7): ("adc", "4")} + terminal.command("adc.close 4") + assert firmware.resources == {} + + +# uart send-only (D.13) + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("uart.open 1 lp=1 sendonly=1", "ok"), + ("uart.open 1 lp=1 tx=PA2 sendonly=1", "ok"), + ("uart.open 1 lp=1 tx=PA2", "usage"), + ("uart.open 1 lp=1 rx=PA3 sendonly=1", "usage"), + ("uart.open 1 lp=1 sendonly=2", "range"), + ("uart.open 1 lp=1 sendonly=1 dma=1", "usage"), + ("uart.open 1 lp=1 sendonly=1 sync=1", "usage"), + ("uart.open 1 lp=1 sendonly=1 duplex=1", "usage"), + ("uart.open 1 lp=1 tx=PA2 sendonly=1 flow=rts rts=PB12", "ok"), + ("uart.open 1 lp=1 tx=PA2 sendonly=1 flow=cts cts=PA6", "unsupported"), + ("uart.open 1 lp=1 tx=PA2 sendonly=1 flow=rtscts rts=PB12 cts=PA6", "unsupported"), + ("uart.open 1 lp=1 tx=PA2 sendonly=1 parity=even", "unsupported"), + ("uart.open 1 lp=1 tx=PA2 sendonly=1 swap=1", "unsupported"), + ("uart.open 1 lp=1 tx=PA3 sendonly=1", "pin"), + ("uart.open 1 sendonly=1", "busy"), + ], +) +def test_uart_sendonly_wb55(line, expected): + terminal, _ = make_terminal() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("uart.open 2 tx=PB0 sendonly=1", "ok"), + ("uart.open 2 tx=usart2tx rts=usart2rts flow=rts sendonly=1", "ok"), + ("uart.open 1 lp=1 tx=PB5 sendonly=1", "unsupported"), + ("uart.open 2 tx=PA11 sendonly=1", "pin"), + ], +) +def test_uart_sendonly_wba55(line, expected): + terminal, _ = wba() + assert reason(terminal, line) == expected + + +def test_uart_sendonly_receives_nothing(): + terminal, firmware = make_terminal() + terminal.command("uart.open 1 lp=1 tx=PA2 sendonly=1") + assert reason(terminal, "gpio.cfg PA3 in") == "ok", "no RX pin is claimed" + firmware.uart_rx[1] += b"\x55" + assert terminal.command("uart.send 1 55").ok + assert terminal.command("uart.recv 1").raw == "OK data=-" + assert reason(terminal, "uart.recv 1 len=0") == "range", "the options are still checked" + + +# encoder on LPTIM (D.9) + + +def test_lp_encoder_holds_its_lptim(): + terminal, firmware = wba() + terminal.command("qei.open 2 lp=1 a=lptim2in1 b=lptim2in2 cap=rise") + assert firmware.resources == {("lpTimer", 2): ("qei", "2")} + assert firmware.opened[("qei", "2")]["cap"] == "rise" + assert reason(terminal, "pwm.open 2 channels=1") == "ok", "LPTIM2 is not TIM2" + terminal.command("qei.close 2") + assert firmware.resources == {} diff --git a/validation/host/tests/unit/test_fake_groups.py b/validation/host/tests/unit/test_fake_groups.py new file mode 100644 index 00000000..451c55d6 --- /dev/null +++ b/validation/host/tests/unit/test_fake_groups.py @@ -0,0 +1,209 @@ +"""Discovery of the area modules (`fakes/*.py` `FakeGroup`s, `groups/*.py` `GROUPS`) with dummy modules placed in a +temporary directory on the packages' paths, dispatch through the fake firmware and the state the groups share.""" + +import sys + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal + +from hal_st_validation import fakes, groups +from hal_st_validation.fake_firmware import FakeFirmware, FakeSerial +from hal_st_validation.fakes.base import FakeError, FakeGroup +from hal_st_validation.firmware import Firmware + +FAKE_MODULE = """ +from hal_st_validation.fakes.base import FakeGroup, _number, _shape + + +class Dummy(FakeGroup): + prefix = "dummy" + + def boot(self): + self.boots = getattr(self, "boots", 0) + 1 + self.polls = 0 + + def poll(self): + self.polls += 1 + + def cmd_open(self, args, options): + _shape(args, options, 1, 1, ("pin", "spi", "timer")) + index = _number(args[0], 1, 3) + pin = self.fw.pin(options.get("pin")) + resources = [("spi", _number(options["spi"], 1, 3))] if "spi" in options else [] + timer = _number(options["timer"], 1, 17) if "timer" in options else None + self.fw.open_instance(self.prefix, str(index), {"pin": pin}, [pin], timer=timer, resources=resources) + return "OK" + + def cmd_close(self, args, options): + _shape(args, options, 1, 1) + self.fw.find_instance(self.prefix, args[0]) + return self.fw.close_instance(self.prefix, args[0]) + + def cmd_event(self, args, options): + self.fw.event("EVT dummy index=1") + return ["EVT dummy line=2", "OK"] + + +class Helper: + prefix = "helper" +""" + +GROUP_MODULE = """ +from hal_st_validation.groups.base import Group + + +class Dummy(Group): + prefix = "dummy" + + def open(self, index, pin=None): + self._cmd("open", index, pin=self._pin(pin)) + self._fw.track(("dummy", index), "dummy.close", index) + + +GROUPS = {"dummy": Dummy} +""" + +MODULES = ("hal_st_validation.fakes.dummy_area", "hal_st_validation.groups.dummy_area", "hal_st_validation.groups.clash_area") + + +@pytest.fixture +def area(tmp_path, monkeypatch): + """Writes `dummy_area.py` modules on the paths of `fakes` and `groups`; returns the group directory for more.""" + fake_dir, group_dir = tmp_path / "fakes", tmp_path / "groups" + fake_dir.mkdir() + group_dir.mkdir() + (fake_dir / "dummy_area.py").write_text(FAKE_MODULE, encoding="utf-8") + (group_dir / "dummy_area.py").write_text(GROUP_MODULE, encoding="utf-8") + monkeypatch.setattr(fakes, "__path__", [*fakes.__path__, str(fake_dir)]) + monkeypatch.setattr(groups, "__path__", [*groups.__path__, str(group_dir)]) + yield group_dir + for name in MODULES: + sys.modules.pop(name, None) + + +def make(family="stm32wb55"): + firmware = FakeFirmware(family=family, style="line") + return FirmwareTerminal(serial=FakeSerial(firmware), timeout=0.5), firmware + + +def reason(terminal, line): + response = terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +def test_discovery(area): + found = {cls.prefix: cls for cls in fakes.discover()} + assert {"dummy", "qspi"} <= set(found) + assert "helper" not in found, "only FakeGroup subclasses count" + assert issubclass(found["dummy"], FakeGroup) + assert "dummy" in groups.discover() + + +def test_without_area_modules_nothing_is_added(): + assert all(cls.__module__.startswith("hal_st_validation.fakes.") for cls in fakes.discover()) + terminal, firmware = make() + assert "dummy" not in firmware.groups + assert reason(terminal, "dummy.open 1") == "usage", "an unknown command" + + +def test_dispatch_and_errors(area): + terminal, firmware = make() + assert isinstance(firmware.group("dummy"), FakeGroup) + assert reason(terminal, "dummy.open 1 pin=PA5") == "ok" + assert firmware.opened[("dummy", "1")] == {"pin": "PA5"} + assert reason(terminal, "dummy.open 4") == "range" + assert reason(terminal, "dummy.open 1 nosuchkey=1") == "usage" + assert reason(terminal, "dummy.nosuch 1") == "usage", "a verb without cmd_" + assert reason(terminal, "dummy.open 2") == "busy", "one instance per group" + assert reason(terminal, "dummy.close 2") == "notopen" + assert reason(terminal, "dummy.close 1") == "ok" + assert firmware.claims == {} + + +def test_claims_are_shared_with_the_built_in_groups(area): + terminal, firmware = make() + terminal.command("dummy.open 1 pin=spi1clk spi=1 timer=2") + assert reason(terminal, "spi.open 1 clk=PA5 mosi=PA7 miso=PA6") == "busy", "PA5 is held" + assert reason(terminal, "pwm.open 2 channels=1") == "busy", "TIM2 is held" + terminal.command("dummy.close 1") + terminal.command("dummy.open 1 spi=1") + assert reason(terminal, "spi.open 1 clk=PA5 mosi=PA7 miso=PA6") == "busy", "SPI1 is held" + assert reason(terminal, "spi.open 2 clk=PB13 mosi=PB15 miso=PB14") == "ok" + terminal.command("dummy.close 1") + assert reason(terminal, "dummy.open 1 spi=2") == "busy", "the spi group holds SPI2" + assert firmware.resources == {("spi", 2): ("spi", "2")} + + +def test_resources(area): + _, firmware = make() + firmware.claim_resource("dma1", 7, ("dummy", "1")) + firmware.claim_resource("dma1", 7, ("dummy", "1")) + with pytest.raises(FakeError) as error: + firmware.claim_resource("dma1", 7, ("adc", "1")) + assert error.value.reason == "busy" + with pytest.raises(ValueError): + firmware.claim_resource("dma3", 1, ("dummy", "1")) + with pytest.raises(ValueError): + firmware.claim_resource("dma1", 16, ("dummy", "1")) + firmware.release(("dummy", "1")) + assert firmware.resources == {} + + +def test_boot_poll_and_events(area): + terminal, firmware = make() + group = firmware.group("dummy") + assert group.boots == 1 + terminal.command("dummy.open 1 pin=PA5 spi=1") + terminal.pump(0.01) + assert group.polls > 0, "reads and writes poll the groups" + terminal.send_nowait("reset") + terminal.wait_boot(0.5) + assert group.boots == 2 + assert firmware.opened == {} and firmware.resources == {} and firmware.claims == {} + assert terminal.command("dummy.event").ok + assert [event["line"] for event in terminal.drain_events("dummy") if "line" in event.values] == ["2"] + + +def test_unsupported_names_come_first(area): + terminal, _ = make("stm32wba55") + assert reason(terminal, "qspi.close 1") == "unsupported" + terminal, _ = make("stm32wb55") + assert reason(terminal, "qspi.close 1") == "notopen", "the qspi group of fakes/qspi.py serves the STM32WB55" + + +def test_firmware_attaches_the_groups(area): + fake = FakeFirmware() + fw = Firmware(FirmwareTerminal(serial=FakeSerial(fake), timeout=0.5), fake.pins) + fw.dummy.open(1, pin="spi1clk") + assert fake.received[-1] == "dummy.open 1 pin=PA5" + assert fw.open_instances == [("dummy", 1)] + assert fw.close_all() == [] + assert fake.received[-1] == "dummy.close 1" + with pytest.raises(FirmwareError): + fw.dummy._cmd("close", 1) + + +def test_group_name_clashes(area): + (area / "clash_area.py").write_text(GROUP_MODULE.replace('{"dummy": Dummy}', '{"gpio": Dummy}'), encoding="utf-8") + with pytest.raises(ValueError, match="clashes with Firmware.gpio"): + Firmware(FirmwareTerminal(serial=FakeSerial(FakeFirmware()), timeout=0.5)) + (area / "clash_area.py").write_text(GROUP_MODULE, encoding="utf-8") + sys.modules.pop("hal_st_validation.groups.clash_area", None) + with pytest.raises(ValueError, match="exported by"): + groups.discover() + (area / "clash_area.py").write_text('GROUPS = {"other": object}\n', encoding="utf-8") + sys.modules.pop("hal_st_validation.groups.clash_area", None) + with pytest.raises(TypeError): + groups.discover() + + +def test_fake_group_prefix_clash(tmp_path, monkeypatch): + (tmp_path / "one_area.py").write_text(FAKE_MODULE, encoding="utf-8") + (tmp_path / "two_area.py").write_text(FAKE_MODULE, encoding="utf-8") + monkeypatch.setattr(fakes, "__path__", [*fakes.__path__, str(tmp_path)]) + try: + with pytest.raises(ValueError, match="both serve 'dummy'"): + fakes.discover() + finally: + for name in ("hal_st_validation.fakes.one_area", "hal_st_validation.fakes.two_area"): + sys.modules.pop(name, None) diff --git a/validation/host/tests/unit/test_fake_i2c.py b/validation/host/tests/unit/test_fake_i2c.py new file mode 100644 index 00000000..dba248d4 --- /dev/null +++ b/validation/host/tests/unit/test_fake_i2c.py @@ -0,0 +1,320 @@ +"""The fake `i2c`, `i2cs` and `eeprom` groups (argument order and reasons of validation/firmware/I2cGroup.cpp, +I2cTarget.cpp, EepromGroup.cpp and EMIL's HilEepromCommands; PROTOCOL.md D.1-D.3), their bus model and the +`groups.i2c` wrappers.""" + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal + +from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS, FakeFirmware, FakeSerial +from hal_st_validation.firmware import Firmware +from hal_st_validation.groups.i2c import DEFAULT_TIMING +from hal_st_validation.i2c import expected_timing +from hal_st_validation.patterns import crc_text, generate + + +def make(family="stm32wb55"): + fake = FakeFirmware(family=family) + terminal = FirmwareTerminal(serial=FakeSerial(fake), timeout=0.5) + pins = WB55_PINS if family == "stm32wb55" else WBA55_PINS + return terminal, fake, Firmware(terminal, pins) + + +def reason(terminal, line): + response = terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("i2c.open", "usage"), + ("i2c.open 1 2 scl=PB8 sda=PB9", "usage"), + ("i2c.open 1 scl=PB8 sda=PB9 speed=1", "usage"), + ("i2c.open x scl=PB8 sda=PB9", "usage"), + ("i2c.open 4 scl=PB8 sda=PB9", "range"), + ("i2c.open 1 sda=PB9", "usage"), + ("i2c.open 1 scl=PB8", "usage"), + ("i2c.open 1 scl=PB8 sda=PB9 freq=100000 timing=0x10", "usage"), + ("i2c.open 9 scl=PB8", "range"), + ("i2c.open 1 scl=PB8 sda=PB9 pull=down", "usage"), + ("i2c.open 2 scl=PB8 sda=PB9 pull=down", "usage"), + ("i2c.open 1 scl=PB8 sda=PB9 freq=x", "usage"), + ("i2c.open 2 scl=PB8 sda=PB9", "range"), + ("i2c.open 0 scl=PB8 sda=PB9", "range"), + ("i2c.open 1 scl=PB8 sda=PB9 freq=19999", "range"), + ("i2c.open 1 scl=PB8 sda=PB9 freq=400001", "range"), + ("i2c.open 2 scl=nope sda=PB9", "range"), + ("i2c.open 1 scl=nope sda=PB9", "pin"), + ("i2c.open 1 scl=PB9 sda=PB8", "pin"), + ("i2c.open 1 scl=PC0 sda=PC1", "pin"), + ("i2c.open 1 scl=PB6 sda=PB7", "busy"), + ("i2c.open 1 scl=PB8 sda=PB9", "ok"), + ("i2c.open 3 scl=i2c3scl sda=i2c3sda pull=up freq=400000", "ok"), + ("i2c.open 3 scl=PA7 sda=PB4 timing=0x10b0172f", "ok"), + ], +) +def test_open_reasons(line, expected): + terminal, *_ = make() + assert reason(terminal, line) == expected + + +def test_open_replies_timing_and_kernel(): + terminal, _, fw = make() + opened = fw.i2c.open(1, "i2c1scl", "i2c1sda") + assert (opened.timing, opened.kernel) == (DEFAULT_TIMING, 64_000_000) + assert fw.open_instances == [("i2c", 1)] + fw.i2c.close(1) + assert fw.i2c.open(1, "PB8", "PB9", freq=100_000).timing == expected_timing(64_000_000, 100_000) == 0x40B03943 + fw.i2c.close(1) + assert fw.i2c.open(1, "PB8", "PB9", timing=0x1FFFFFFF).timing == 0x10FFFFFF + assert reason(terminal, "i2c.open 3 scl=PC0 sda=PC1") == "busy" + + +def test_wba55_instances(): + terminal, _, fw = make("stm32wba55") + assert fw.i2c.open(3, "PB2", "PB1", freq=400_000).timing == 0x20B11931 + assert reason(terminal, "i2c.open 3 scl=PA6 sda=PA7") == "busy" + fw.i2c.close(3) + assert reason(terminal, "i2c.open 1 scl=PA15 sda=PB3") == "ok" + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("i2c.write 1 0x10", "usage"), + ("i2c.write 1 0x10 00 extra", "usage"), + ("i2c.write 1 0x10 00 stop=1", "usage"), + ("i2c.write 4 0x10 00", "range"), + ("i2c.write 3 0x10 00", "notopen"), + ("i2c.write 1 0x80 00", "range"), + ("i2c.write 1 x 00", "usage"), + ("i2c.write 1 0x10 00 next=later", "usage"), + ("i2c.write 1 0x10 0", "usage"), + ("i2c.write 1 0x10 00 len=2", "usage"), + ("i2c.write 1 0x10 - pattern=inc", "usage"), + ("i2c.write 1 0x10 - len=0", "range"), + ("i2c.write 1 0x10 - len=1025", "range"), + ("i2c.write 1 0x10 - len=4 pattern=noise", "usage"), + ("i2c.read 1 0x10", "usage"), + ("i2c.read 1 0x10 0", "range"), + ("i2c.read 1 0x10 1025", "range"), + ("i2c.read 1 0x10 129", "range"), + ("i2c.read 1 0x10 4 out=bin", "usage"), + ("i2c.read 1 0x10 4 len=4", "usage"), + ("i2c.read 3 0x10 4", "notopen"), + ("i2c.close 3", "notopen"), + ("i2c.close 4", "range"), + ], +) +def test_transfer_reasons(line, expected): + terminal, _, fw = make() + fw.i2c.open(1, "PB8", "PB9") + assert reason(terminal, line) == expected + + +def test_absent_device_nacks_with_the_hook(): + _, _, fw = make() + fw.i2c.open(1, "PB8", "PB9") + write = fw.i2c.write(1, 0x10, b"\x01\x02") + assert (write.sent, write.result) == (0, "nack") + assert fw.i2c.write(1, 0x10, b"").result == "nack" + read = fw.i2c.read(1, 0x10, 4) + assert (read.result, read.data) == ("nack", None) + assert fw.i2c.hooks(1) == ["notfound"] * 3 + assert fw.i2c.write(1, 0x00, b"\x06").result == "nack" + assert fw.i2c.write(1, 0x50, b"").result == "complete" + + +def test_target_registers_and_counters(): + terminal, _, fw = make() + fw.i2c.open(3, "PC0", "PC1") + assert fw.i2cs.open(1, "PB8", "PB9") == 0x42 + assert fw.i2c.write(3, 0x42, bytes([0x10, 1, 2, 3])).sent == 4 + assert fw.i2c.write(3, 0x42, b"\x10", next="restart").result == "complete" + assert fw.i2c.read(3, 0x42, 3).data == b"\x01\x02\x03" + assert fw.i2cs.dump(1, 0x10, 4) == b"\x01\x02\x03\x00" + status = fw.i2cs.status(1, clear=True) + assert (status.rx, status.tx, status.writes, status.reads, status.stops) == (5, 3, 2, 1, 2) + assert status.crc == crc_text(bytes([0x10, 1, 2, 3, 0x10])) + assert status.last == b"\x10" + assert fw.i2cs.status(1).rx == 0 + fw.i2cs.regs(1, 0xFE, b"\xaa\xbb") + assert fw.i2c.write(3, 0x42, b"\xfe").sent == 1 + assert fw.i2c.read(3, 0x42, 3).data == b"\xaa\xbb\x00" + assert fw.i2c.hooks(3) == [] + assert reason(terminal, "i2cs.regs 1 0xff 0102") == "range" + assert reason(terminal, "i2cs.dump 1 0xff 2") == "range" + + +def test_target_nack_sink_and_fault(): + _, _, fw = make("stm32wba55") + fw.i2c.open(3, "PA6", "PA7") + fw.i2cs.open(1, "PB2", "PB1", mode="sink") + fw.i2cs.cfg(1, nack=3) + write = fw.i2c.write(3, 0x42, len=8, pattern="prbs", seed=3) + assert (write.sent, write.result) == (2, "nack") + assert fw.i2cs.status(1).nacked == 1 + assert fw.i2c.hooks(3) == [] + fw.i2cs.cfg(1, pattern="prbs", seed=1) + assert fw.i2c.read(3, 0x42, 300, out="crc").crc == crc_text(generate(300, "prbs", 1)) + fw.i2cs.cfg(1, fault="stop", faultat=2) + assert fw.i2c.read(3, 0x42, 8).result == "buserror" + assert fw.i2c.hooks(3) == ["buserror"] + assert fw.i2c.read(3, 0x42, 2).data == generate(2) + fw.i2cs.cfg(1, addrnack=True) + assert fw.i2c.read(3, 0x42, 2).result == "nack" + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("i2cs.open 1", "usage"), + ("i2cs.open 1 scl=PB8", "usage"), + ("i2cs.open 1 scl=PB8 sda=PB9 mode=echo", "usage"), + ("i2cs.open 1 scl=PB8 sda=PB9 addr=0x07", "range"), + ("i2cs.open 1 scl=PB8 sda=PB9 addr=0x78", "range"), + ("i2cs.open 2 scl=PB8 sda=PB9", "range"), + ("i2cs.open 1 scl=PC0 sda=PC1", "pin"), + ("i2cs.open 1 scl=PB8 sda=PB9 addr=0x08 mode=sink timing=0x00500916", "ok"), + ], +) +def test_target_open_reasons(line, expected): + terminal, *_ = make() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("i2cs.cfg 1 nack=x", "usage"), + ("i2cs.cfg 1 stretch=10001", "range"), + ("i2cs.cfg 1 faultat=0", "range"), + ("i2cs.cfg 1 fault=start", "usage"), + ("i2cs.cfg 1 addrnack=2", "range"), + ("i2cs.cfg 1 pattern=noise", "usage"), + ("i2cs.cfg 1 speed=1", "usage"), + ("i2cs.cfg 3", "notopen"), + ("i2cs.status 1 clear=2", "range"), + ("i2cs.regs 1 0 -", "usage"), + ("i2cs.regs 1 256 00", "range"), + ("i2cs.dump 1 0 0", "range"), + ("i2cs.dump 1 0 129", "range"), + ("i2cs.cfg 1 nack=2 stretch=100 stretchat=0 fault=stop faultat=3 pattern=const seed=7", "ok"), + ], +) +def test_target_command_reasons(line, expected): + terminal, _, fw = make() + fw.i2cs.open(1, "PB8", "PB9") + assert reason(terminal, line) == expected + + +def test_one_owner_per_instance(): + """`Resource::i2c` keeps i2c, i2cs and eeprom off each other's instance; pins stay busy too.""" + terminal, _, fw = make() + fw.i2cs.open(1, "PB8", "PB9") + assert reason(terminal, "i2c.open 1 scl=PA9 sda=PA10") == "busy" + assert reason(terminal, "eeprom.attach 1 scl=PA9 sda=PA10") == "busy" + assert reason(terminal, "gpio.cfg PB8 in") == "busy" + fw.eeprom.attach(3, "PC0", "PC1") + assert reason(terminal, "i2c.open 3 scl=PA7 sda=PB4") == "busy" + assert reason(terminal, "eeprom.attach 3 scl=PC0 sda=PC1") == "busy" + fw.close_all() + assert reason(terminal, "i2c.open 1 scl=PB8 sda=PB9") == "ok" + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("eeprom.attach", "usage"), + ("eeprom.attach 3 sda=PC1", "usage"), + ("eeprom.attach 3 scl=PC0 sda=PC1 speed=1", "usage"), + ("eeprom.attach 4 scl=PC0 sda=PC1", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 size=x", "usage"), + ("eeprom.attach 2 scl=PC0 sda=PC1", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 addr=0x78", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 size=0", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 size=65537", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 page=4", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 page=48", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 page=512", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 abytes=3", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 abytes=1", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 wcycle=21", "range"), + ("eeprom.attach 3 scl=PC0 sda=PC1 freq=500000", "range"), + ("eeprom.attach 3 scl=PB8 sda=PB9", "pin"), + ("eeprom.attach 3 scl=PC0 sda=PC1 addr=0x42 size=256 page=16 abytes=1 wcycle=0 freq=100000", "ok"), + ("eeprom.detach", "notopen"), + ("eeprom.detach 1", "usage"), + ("eeprom.write 0 0102", "range"), + ("eeprom.read 0 1", "range"), + ("eeprom.erase", "ok"), + ("eeprom.erase 1", "usage"), + ], +) +def test_eeprom_reasons(line, expected): + terminal, *_ = make() + assert reason(terminal, line) == expected + + +def test_eeprom_pages_and_erase(): + terminal, fake, fw = make() + fw.eeprom.attach(3, "PC0", "PC1", size=8192) + data = generate(100, "prbs", 5) + fw.eeprom.write(30, data) + assert fw.eeprom.read(30, 100) == data + assert reason(terminal, "eeprom.write 8190 010203") == "range" + assert reason(terminal, "eeprom.read 8192 1") == "range" + fw.eeprom.erase() + assert fw.eeprom.read(0, 128) == b"\xff" * 128 + fake.boot() + fw.forget_open() + fw.eeprom.attach(3, "PC0", "PC1") + fw.eeprom.write(20000, b"\x5a") + fake.boot() + fw.forget_open() + fw.eeprom.attach(3, "PC0", "PC1") + assert fw.eeprom.read(20000, 1) == b"\x5a" + + +def test_eeprom_page_wrap_on_direct_writes(): + """A write through `i2c.write` that crosses a page wraps to the start of that page, as on a 24LC256.""" + _, _, fw = make() + fw.i2c.open(3, "PC0", "PC1") + assert fw.i2c.write(3, 0x50, bytes([0x00, 0x3E, 1, 2, 3, 4])).sent == 6 + assert fw.i2c.write(3, 0x50, b"\x00\x3e", next="restart").sent == 2 + assert fw.i2c.read(3, 0x50, 2).data == b"\x01\x02" + assert fw.i2c.write(3, 0x50, b"\x00\x00", next="restart").sent == 2 + assert fw.i2c.read(3, 0x50, 2).data == b"\x03\x04" + + +def test_eeprom_error_then_detach(): + terminal, _, fw = make() + fw.eeprom.attach(3, "PC0", "PC1", addr=0x57) + with pytest.raises(FirmwareError) as error: + fw.eeprom.write(0, b"\x01") + assert error.value.reason == "timeout" + assert [event.raw for event in fw.eeprom.errors()] == ["EVT eeprom error=nack address=0"] + assert reason(terminal, "eeprom.read 0 1") == "busy" + fw.eeprom.detach() + fw.eeprom.attach(3, "PC0", "PC1") + fw.eeprom.write(0, b"\x01") + + +def test_one_byte_addressing_on_the_target(): + _, _, fw = make() + fw.i2cs.open(1, "PB8", "PB9") + fw.eeprom.attach(3, "PC0", "PC1", addr=0x42, size=256, page=16, abytes=1, wcycle=0) + data = generate(40, "inc", 0x80) + fw.eeprom.write(10, data) + assert fw.eeprom.read(10, 40) == data + assert fw.i2cs.dump(1, 10, 40) == data + + +def test_state_is_lost_on_reset(): + terminal, fake, fw = make() + fw.i2c.open(1, "PB8", "PB9") + fw.i2cs.open(3, "PC0", "PC1") + fake.boot() + fw.forget_open() + assert reason(terminal, "i2cs.status 3") == "notopen" + assert reason(terminal, "i2c.open 1 scl=PB8 sda=PB9") == "ok" diff --git a/validation/host/tests/unit/test_fake_io.py b/validation/host/tests/unit/test_fake_io.py new file mode 100644 index 00000000..71bb5b2f --- /dev/null +++ b/validation/host/tests/unit/test_fake_io.py @@ -0,0 +1,281 @@ +"""The fake `sgpio` and `clock` groups (argument order and reasons of validation/firmware/SyncGpioGroup.cpp and +ClockGroup.cpp; PROTOCOL.md D.14, D.15), the send-only UART checks of `uart.open sendonly=1` (D.13), the +`groups.io` wrappers and the clock and UID expectations.""" + +import math + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal + +from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS, FakeFirmware, FakeSerial +from hal_st_validation.fakes.io import FAKE_UID +from hal_st_validation.firmware import Firmware +from hal_st_validation.groups.io import UID_BYTES, edge_fit_frequency, parse_uid + + +def make(family="stm32wb55", **fake_options): + fake = FakeFirmware(family=family, **fake_options) + terminal = FirmwareTerminal(serial=FakeSerial(fake), timeout=0.5) + pins = WB55_PINS if family == "stm32wb55" else WBA55_PINS + return terminal, fake, Firmware(terminal, pins) + + +def reason(terminal, line): + response = terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("sgpio.out", "usage"), + ("sgpio.out PA8", "usage"), + ("sgpio.out PA8 1 2", "usage"), + ("sgpio.out PA8 1 pull=up", "usage"), + ("sgpio.out PA8 x", "usage"), + ("sgpio.out PA8 2", "range"), + ("sgpio.out PA8 1 od=2", "range"), + ("sgpio.out PA8 1 speed=fastest", "usage"), + ("sgpio.out PZ8 2", "range"), + ("sgpio.out PZ8 1", "pin"), + ("sgpio.out PC7 1", "pin"), + ("sgpio.out terminaltx 1", "busy"), + ("sgpio.out PB5 1", "busy"), + ("sgpio.out PA8 1 od=1 speed=high", "ok"), + ("sgpio.latch PA8", "notopen"), + ("sgpio.latch", "usage"), + ("sgpio.release PA8", "notopen"), + ("sgpio.af PA15", "usage"), + ("sgpio.af PA15 timer=2 af=1", "usage"), + ("sgpio.af PA15 af=1 ch=1", "usage"), + ("sgpio.af PA15 timer=0", "range"), + ("sgpio.af PA15 timer=18", "range"), + ("sgpio.af PA15 timer=3", "range"), + ("sgpio.af PA15 timer=2 ch=5", "range"), + ("sgpio.af PA15 af=16", "range"), + ("sgpio.af PZ1 timer=2", "pin"), + ("sgpio.af PC6 timer=2", "pin"), + ("sgpio.af PA15 timer=2 ch=2", "pin"), + ("sgpio.af PC7 af=1", "pin"), + ("sgpio.af terminalrx af=7", "busy"), + ("sgpio.af PA15 timer=2", "ok"), + ("sgpio.af PA1 timer=2 ch=2", "ok"), + ("sgpio.af PA6 timer=16 ch=1", "ok"), + ("sgpio.af PA8 af=0", "ok"), + ("sgpio.multi PA15", "usage"), + ("sgpio.multi , timer=2", "usage"), + ("sgpio.multi PA15,PA15 timer=2", "usage"), + ("sgpio.multi PA15,PA5,PA0,PA15,PA5 timer=2", "usage"), + ("sgpio.multi PA15 timer=4", "range"), + ("sgpio.multi PA15 timer=2 ch=0", "range"), + ("sgpio.multi PA15,PZ1 timer=2", "pin"), + ("sgpio.multi PA15,PC6 timer=2", "pin"), + ("sgpio.multi PA15,PA5,PA0 timer=2", "ok"), + ], +) +def test_sgpio_reasons_wb55(line, expected): + terminal, _, _ = make() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("sgpio.af PA10 timer=3", "ok"), + ("sgpio.af PA10 timer=4", "range"), + ("sgpio.af PB14 timer=3", "pin"), + ("sgpio.af PB14 timer=3 ch=3", "ok"), + ("sgpio.out PA9 1", "busy"), + ("sgpio.out PB8 1", "ok"), + ("sgpio.out PA3 1", "pin"), + ("sgpio.multi PA10,PB5,PA2 timer=3", "ok"), + ], +) +def test_sgpio_reasons_wba55(line, expected): + terminal, _, _ = make("stm32wba55") + assert reason(terminal, line) == expected + + +def test_sgpio_af_reports_the_table_function(): + _, _, fw = make() + assert fw.sgpio.af("tim2ch1", timer=2) == 1 + assert fw.sgpio.af("tim16ch1", timer=16) == 14 + assert fw.sgpio.af("PA8", af=0) == 0 + _, _, fw = make("stm32wba55") + assert fw.sgpio.af("tim3ch1", timer=3) == 2 + + +def test_sgpio_outputs_latch_and_rebuild(): + terminal, _, fw = make() + fw.sgpio.out("PA8", 1) + assert fw.sgpio.latch("PA8") == 1 + fw.sgpio.out("PA8", 0, od=True) + fw.sgpio.out("PA8", 1) + assert fw.sgpio.latch("PA8") == 1 + assert terminal.command("sgpio.out PA8 0 speed=high").ok + assert fw.sgpio.latch("PA8") == 0 + fw.sgpio.release("PA8") + assert reason(terminal, "sgpio.latch PA8") == "notopen" + + +def test_sgpio_slots_and_pin_sharing(): + terminal, _, fw = make() + for pin in ("PC6", "PC10", "PC12", "PC13"): + fw.sgpio.out(pin, 0) + assert reason(terminal, "sgpio.out PE4 0") == "busy" + assert reason(terminal, "sgpio.af PC6 af=0") == "busy", "a pin serves one use" + for pin in ("PA15", "PA5", "PA0", "PA6"): + fw.sgpio.af(pin, af=1) + assert reason(terminal, "sgpio.af PA1 af=1") == "busy" + assert reason(terminal, "sgpio.af PA15 af=1") == "busy", "already muxed" + assert reason(terminal, "gpio.cfg PA15 in") == "busy" + assert reason(terminal, "sgpio.multi PA15 timer=2") == "busy" + fw.sgpio.release("PA15") + fw.sgpio.release("PA5") + fw.sgpio.multi(["PA15", "PA5"], timer=2) + assert reason(terminal, "sgpio.multi PA0 timer=2") == "busy" + fw.sgpio.release("PA5") + assert ("sgpio", "multi") not in fw.open_instances + fw.sgpio.multi(["PA15", "PA5"], timer=2) + + +def test_sgpio_close_all_releases_everything(): + terminal, fake, fw = make() + fw.sgpio.out("PA8", 1) + fw.sgpio.af("PA15", timer=2) + fw.sgpio.multi(["PA5", "PA0"], timer=2) + assert fw.close_all() == [] + assert fake.claims == {} + assert terminal.command("gpio.cfg PA5 in").ok + + +def test_sgpio_boot_forgets_everything(): + terminal, fake, fw = make() + fw.sgpio.out("PA8", 1) + fake.boot() + assert reason(terminal, "sgpio.latch PA8") == "notopen" + fw.sgpio.out("PA8", 0) + + +def test_clock_info_wb55(): + _, _, fw = make() + info = fw.clock.info() + assert (info.sysclk, info.hclk, info.pclk1, info.pclk2, info.pclk7) == (64_000_000,) * 4 + (None,) + assert info.flags == {"hse": 1, "lse": 1, "hsi": 1, "hsi48": 1, "pll": 1} + assert (info.rngsel, info.clk48) == ("clk48", "hsi48") + + +def test_clock_info_wba55(): + _, _, fw = make("stm32wba55") + info = fw.clock.info() + assert info.pclk7 == 100_000_000 + assert "hsi48" not in info.flags + assert (info.rngsel, info.clk48) == ("hsi", None) + + +def test_clock_scaffolding_tracks_its_undo(): + _, _, fw = make() + fw.clock.hsi48(False) + assert fw.clock.info().flags["hsi48"] == 0 + fw.clock.mco("lse") + assert set(fw.open_instances) == {("clock", "hsi48"), ("clock", "mco")} + assert fw.close_all() == [] + assert fw.clock.info().flags["hsi48"] == 1 + fw.clock.mco("sysclk", div=16) + fw.clock.mco("off") + assert fw.open_instances == [] + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("clock.info 1", "usage"), + ("clock.info x=1", "usage"), + ("clock.mco", "usage"), + ("clock.mco pll", "usage"), + ("clock.mco sysclk div=3", "usage"), + ("clock.mco sysclk div=16", "ok"), + ("clock.mco hsi48 div=1", "ok"), + ("clock.hsi48", "usage"), + ("clock.hsi48 2", "range"), + ("clock.hsi48 1", "ok"), + ], +) +def test_clock_reasons_wb55(line, expected): + terminal, _, _ = make() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize("line", ["clock.mco sysclk", "clock.hsi48 1"]) +def test_clock_scaffolding_unsupported_on_wba55(line): + terminal, _, _ = make("stm32wba55") + assert reason(terminal, line) == "unsupported" + + +@pytest.mark.parametrize( + ("family", "line", "expected"), + [ + ("stm32wb55", "uart.open 1 lp=1 sendonly=1", "ok"), + ("stm32wb55", "uart.open 1 lp=1 sendonly=1 tx=PA2", "ok"), + ("stm32wb55", "uart.open 1 lp=1 sendonly=1 rx=PA3", "usage"), + ("stm32wb55", "uart.open 1 lp=1 sendonly=1 dma=1", "usage"), + ("stm32wb55", "uart.open 1 lp=1 sendonly=1 sync=1", "usage"), + ("stm32wb55", "uart.open 1 lp=1 sendonly=1 tx=PA2 flow=rts", "usage"), + ("stm32wb55", "uart.open 1 lp=1 sendonly=1 tx=PA2 flow=rts rts=PB12", "ok"), + ("stm32wb55", "uart.open 1 lp=1 sendonly=1 tx=PA2 flow=rts rts=PA2", "pin"), + ("stm32wb55", "uart.open 1 lp=1 sendonly=1 tx=PA2 flow=cts cts=PA6", "unsupported"), + ("stm32wb55", "uart.open 1 lp=1 sendonly=1 tx=PA2 parity=even", "unsupported"), + ("stm32wba55", "uart.open 2 sendonly=1 tx=PB0", "ok"), + ("stm32wba55", "uart.open 2 sendonly=1 tx=PB0 swap=1", "unsupported"), + ("stm32wba55", "uart.open 1 lp=1 sendonly=1 tx=PB5", "unsupported"), + ], +) +def test_uart_sendonly_reasons(family, line, expected): + terminal, _, _ = make(family) + assert reason(terminal, line) == expected + + +def test_uart_sendonly_receives_nothing(): + _, _, fw = make() + fw.uart.open(1, lp=True, tx="lpuart1tx", sendonly=True) + assert fw.uart.recv(1) == b"" + fw.uart.send(1, b"\x55") + + +def test_fake_uid_has_the_real_layout(): + _, fake, fw = make() + assert fake.uid == FAKE_UID + uid = parse_uid(fw.system.info().uid or "") + assert len(uid.raw) == UID_BYTES + assert uid.lot == b"HALSTFK" + assert (uid.x, uid.y, uid.wafer) == (0x24, 0x51, 7) + _, fake, _ = make(uid="00112233445566778899aabb") + assert fake.uid == "00112233445566778899aabb", "an explicit UID is kept" + + +def test_parse_uid_rejects_other_lengths(): + with pytest.raises(ValueError): + parse_uid("0011") + + +def square(hz, rate, seconds, phase=0.3): + """A 50 % square wave sampled at `rate`.""" + return [1 if math.fmod((i / rate) * hz + phase, 1.0) < 0.5 else 0 for i in range(int(seconds * rate))] + + +@pytest.mark.parametrize(("hz", "rate"), [(32768.0, 1_000_000.0), (32771.3, 1_250_000.0), (4_000_000.0, 100_000_000.0)]) +def test_edge_fit_frequency(hz, rate): + bits = square(hz, rate, 1000 / hz) + assert edge_fit_frequency(bits, rate) == pytest.approx(hz, rel=5e-6) + + +def test_edge_fit_frequency_needs_edges(): + with pytest.raises(ValueError): + edge_fit_frequency([0, 1, 1, 0], 1e6) + + +def test_sgpio_wrapper_errors_raise(): + _, _, fw = make() + with pytest.raises(FirmwareError) as error: + fw.sgpio.af("PA15") + assert error.value.reason == "usage" diff --git a/validation/host/tests/unit/test_fake_qspi.py b/validation/host/tests/unit/test_fake_qspi.py new file mode 100644 index 00000000..9889faef --- /dev/null +++ b/validation/host/tests/unit/test_fake_qspi.py @@ -0,0 +1,375 @@ +"""The fake `qspi` group (argument order and reasons of validation/firmware/QuadSpiGroup.cpp; PROTOCOL.md D.20), the +pins and DMA channel it holds, its bus model, the `groups.qspi` wrappers and the decoding of QUADSPI captures.""" + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal + +from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS, FakeFirmware, FakeSerial +from hal_st_validation.fakes.qspi import FakeQuadSpi +from hal_st_validation.firmware import Firmware +from hal_st_validation.groups import qspi +from hal_st_validation.patterns import crc_text, generate + + +class Clock: + def __init__(self) -> None: + self.now = 100.0 + + def __call__(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.now += seconds + + +def make(family="stm32wb55"): + clock = Clock() + fake = FakeFirmware(family=family, clock=clock, sleep=clock.sleep) + terminal = FirmwareTerminal(serial=FakeSerial(fake), timeout=0.5) + pins = WB55_PINS if family == "stm32wb55" else WBA55_PINS + return terminal, fake, clock, Firmware(terminal, pins) + + +def reason(terminal, line): + response = terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +def bus(fake) -> FakeQuadSpi: + group = fake.group("qspi") + assert isinstance(group, FakeQuadSpi) + return group + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("qspi.open", "usage"), + ("qspi.open 1 2", "usage"), + ("qspi.open 1 speed=1", "usage"), + ("qspi.open x", "usage"), + ("qspi.open 2", "range"), + ("qspi.open 0", "range"), + ("qspi.open 0 variant=octal", "usage"), + ("qspi.open 1 variant=octal", "usage"), + ("qspi.open 1 prescaler=x", "usage"), + ("qspi.open 1 prescaler=256", "range"), + ("qspi.open 1 prescaler=256 variant=octal", "usage"), + ("qspi.open 1 size=0", "range"), + ("qspi.open 1 size=33", "range"), + ("qspi.open 1", "ok"), + ("qspi.open 1 variant=spi prescaler=255 size=32", "ok"), + ], +) +def test_open_reasons(line, expected): + terminal, *_ = make() + assert reason(terminal, line) == expected + + +def test_open_reports_the_clock_and_tracks(): + terminal, fake, _, fw = make() + assert fw.qspi.open(1, variant="dma", prescaler=7) == 8_000_000 + assert fake.received[-1] == "qspi.open 1 variant=dma prescaler=7" + assert fw.open_instances == [("qspi", 1)] + assert reason(terminal, "qspi.open 1") == "busy" + fw.close_all() + assert fake.received[-1] == "qspi.close 1" + assert fw.qspi.open(1) == qspi.qspi_clock(64_000_000, 2) == 21_333_333 + + +@pytest.mark.parametrize("pin", ["PA3", "PA2", "PB9", "PB8", "PA7", "PA6"]) +def test_open_holds_the_pins(pin): + terminal, _, _, fw = make() + fw.gpio.cfg(pin, "out") + assert reason(terminal, "qspi.open 1") == "busy" + fw.gpio.release(pin) + fw.qspi.open(1) + assert reason(terminal, f"gpio.cfg {pin} in") == "busy" + fw.qspi.close(1) + assert reason(terminal, f"gpio.cfg {pin} in") == "ok" + + +@pytest.mark.parametrize(("variant", "claimed"), [("poll", False), ("dma", True), ("spi", True)]) +def test_dma_channel_held_by_the_dma_variants(variant, claimed): + _, fake, _, fw = make() + fw.qspi.open(1, variant=variant) + assert (("dma2", 3) in fake.resources) == claimed + fw.qspi.close(1) + assert ("dma2", 3) not in fake.resources + + +def test_wba55_has_no_quadspi(): + terminal, *_ = make("stm32wba55") + for line in ("qspi.open 1", "qspi.cmd 1 instr=0x9f rx=3", "qspi.poll 1 match=1 mask=1", "qspi.xfer 1 9f", "qspi.close 1"): + assert reason(terminal, line) == "unsupported", line + + +def test_commands_need_an_open_instance(): + terminal, *_ = make() + for line in ("qspi.cmd 1 instr=0x06", "qspi.poll 1 match=1 mask=1", "qspi.xfer 1 9f", "qspi.close 1", "qspi.cmd 0"): + assert reason(terminal, line) == "notopen", line + for line in ("qspi.cmd 2", "qspi.close 2", "qspi.cmd x"): + assert reason(terminal, line) == ("usage" if line.endswith("x") else "range"), line + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("qspi.cmd 1", "ok"), + ("qspi.cmd 1 2", "usage"), + ("qspi.cmd 1 speed=1", "usage"), + ("qspi.cmd 1 tx=00 rx=1", "usage"), + ("qspi.cmd 1 tx=00 len=1", "usage"), + ("qspi.cmd 1 len=1 rx=1", "usage"), + ("qspi.cmd 1 pattern=inc", "usage"), + ("qspi.cmd 1 tx=00 seed=1", "usage"), + ("qspi.cmd 1 tx=00 out=crc", "usage"), + ("qspi.cmd 1 instr=0x03 rx=1 repeat=2", "usage"), + ("qspi.cmd 1 abytes=2", "usage"), + ("qspi.cmd 1 altbytes=2", "usage"), + ("qspi.cmd 1 lines=3 tx=00 rx=1", "usage"), + ("qspi.cmd 1 instr=x", "usage"), + ("qspi.cmd 1 instr=0x100", "range"), + ("qspi.cmd 1 instr=0x100 dummy=x", "range"), + ("qspi.cmd 1 addr=0x10000 abytes=2", "range"), + ("qspi.cmd 1 addr=0xffff abytes=2", "ok"), + ("qspi.cmd 1 addr=0xffffffff abytes=4", "ok"), + ("qspi.cmd 1 addr=0 abytes=5", "range"), + ("qspi.cmd 1 alt=0x100", "range"), + ("qspi.cmd 1 alt=0x100 altbytes=2", "ok"), + ("qspi.cmd 1 dummy=32", "range"), + ("qspi.cmd 1 dummy=31", "ok"), + ("qspi.cmd 1 lines=2", "range"), + ("qspi.cmd 1 lines=0", "range"), + ("qspi.cmd 1 lines=4 tx=00", "ok"), + ("qspi.cmd 1 tx=abc", "usage"), + ("qspi.cmd 1 tx=-", "ok"), + ("qspi.cmd 1 len=0", "range"), + ("qspi.cmd 1 len=257", "range"), + ("qspi.cmd 1 len=4 pattern=noise", "usage"), + ("qspi.cmd 1 len=4 seed=x pattern=noise", "usage"), + ("qspi.cmd 1 len=256 pattern=prbs seed=0xffffffff", "ok"), + ("qspi.cmd 1 rx=0", "range"), + ("qspi.cmd 1 rx=257 out=crc", "range"), + ("qspi.cmd 1 rx=256 out=crc", "ok"), + ("qspi.cmd 1 rx=129", "range"), + ("qspi.cmd 1 rx=128", "ok"), + ("qspi.cmd 1 rx=4 out=bin", "usage"), + ("qspi.cmd 1 tx=00 repeat=0", "range"), + ("qspi.cmd 1 tx=00 repeat=9", "range"), + ("qspi.cmd 1 len=8 repeat=8", "ok"), + ], +) +def test_command_reasons(line, expected): + terminal, _, _, fw = make() + fw.qspi.open(1, variant="dma") + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("qspi.poll 1", "usage"), + ("qspi.poll 1 match=1", "usage"), + ("qspi.poll 1 mask=1", "usage"), + ("qspi.poll 1 match=1 mask=1 tx=00", "usage"), + ("qspi.poll 1 match=1 mask=1 abytes=1", "usage"), + ("qspi.poll 1 match=x mask=1", "usage"), + ("qspi.poll 1 match=1 mask=1 size=0", "range"), + ("qspi.poll 1 match=1 mask=1 size=5", "range"), + ("qspi.poll 1 match=0x100 mask=1", "range"), + ("qspi.poll 1 match=1 mask=0x1ff size=1", "range"), + ("qspi.poll 1 match=0x1ff mask=0x1ff size=2", "ok"), + ("qspi.poll 1 match=1 mask=1 lines=2", "range"), + ("qspi.poll 1 match=1 mask=1 instr=0x05", "ok"), + ], +) +def test_poll_reasons(line, expected): + terminal, _, _, fw = make() + fw.qspi.open(1, variant="dma") + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("qspi.xfer 1", "usage"), + ("qspi.xfer 1 -", "usage"), + ("qspi.xfer 1 9f rx=1", "usage"), + ("qspi.xfer 1 - len=2 rx=1", "usage"), + ("qspi.xfer 1 - rx=1 repeat=2", "usage"), + ("qspi.xfer 1 9f tx=1", "usage"), + ("qspi.xfer 1 9f len=1", "usage"), + ("qspi.xfer 1 - pattern=inc rx=1", "usage"), + ("qspi.xfer 1 9", "usage"), + ("qspi.xfer 1 - rx=0", "range"), + ("qspi.xfer 1 - rx=129", "range"), + ("qspi.xfer 1 - len=257", "range"), + ("qspi.xfer 1 9f repeat=9", "range"), + ("qspi.xfer 1 9f", "ok"), + ("qspi.xfer 1 - len=256 pattern=const seed=0xa5 repeat=8", "ok"), + ("qspi.xfer 1 - rx=128", "ok"), + ], +) +def test_xfer_reasons(line, expected): + terminal, _, _, fw = make() + fw.qspi.open(1, variant="spi") + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize("variant", ["poll", "dma"]) +def test_xfer_needs_the_spi_variant(variant): + terminal, _, _, fw = make() + fw.qspi.open(1, variant=variant) + assert reason(terminal, "qspi.xfer 1 9f") == "unsupported" + assert reason(terminal, "qspi.xfer 1 9f rx=1") == "usage" + + +def test_wrappers_format_and_parse(): + _, fake, _, fw = make() + fw.qspi.open(1, variant="spi") + reply = fw.qspi.cmd(1, instr=0x38, addr=0x123456, abytes=3, alt=0xA55A, altbytes=2, lines=4, tx=b"\x00\xff") + assert fake.received[-1] == "qspi.cmd 1 instr=56 addr=1193046 abytes=3 alt=42330 altbytes=2 lines=4 tx=00ff" + assert reply == qspi.QuadSpiReply(flevel=0) + assert fw.qspi.cmd(1, len=8, pattern="prbs", seed=1, repeat=2) == qspi.QuadSpiReply(flevel=0) + assert fake.received[-1] == "qspi.cmd 1 len=8 pattern=prbs seed=1 repeat=2" + assert fw.qspi.cmd(1, instr=0x0B, rx=3) == qspi.QuadSpiReply(data=b"\xff\xff\xff") + assert fw.qspi.cmd(1, instr=0x0B, rx=256, out="crc") == qspi.QuadSpiReply(crc=crc_text(b"\xff" * 256)) + fw.qspi.poll(1, match=1, mask=1, instr=5, lines=1) + assert fake.received[-1] == "qspi.poll 1 instr=5 lines=1 match=1 mask=1" + assert fw.qspi.xfer(1, b"\x9f\x01") == qspi.QuadSpiReply(flevel=0) + assert fake.received[-1] == "qspi.xfer 1 9f01" + assert fw.qspi.xfer(1, len=4, repeat=2).flevel == 0 + assert fake.received[-1] == "qspi.xfer 1 - len=4 repeat=2" + assert fw.qspi.xfer(1, rx=2) == qspi.QuadSpiReply(data=b"\xff\xff") + assert fake.received[-1] == "qspi.xfer 1 - rx=2" + + +@pytest.mark.parametrize(("io", "lines", "byte"), [(0x0, 4, 0x00), (0x5, 4, 0x55), (0xA, 4, 0xAA), (0x2, 1, 0xFF), (0xD, 1, 0x00)]) +def test_reads_follow_the_io_levels(io, lines, byte): + _, fake, _, fw = make() + fw.qspi.open(1, variant="dma") + bus(fake).io = io + assert fw.qspi.cmd(1, lines=lines, dummy=2, rx=4).data == bytes([byte]) * 4 + + +def test_spi_reads_follow_io1(): + _, fake, _, fw = make() + fw.qspi.open(1, variant="spi") + bus(fake).io = 0b1101 + assert fw.qspi.xfer(1, rx=2).data == b"\x00\x00" + bus(fake).io = 0b0010 + assert fw.qspi.xfer(1, rx=2).data == b"\xff\xff" + + +@pytest.mark.parametrize("variant", ["poll", "dma"]) +def test_poll_timeout_keeps_the_group_busy_until_close(variant): + terminal, fake, clock, fw = make() + fw.qspi.open(1, variant=variant) + fw.qspi.poll(1, match=0x01, mask=0x01, lines=1) + bus(fake).io = 0b0000 + start = clock.now + with pytest.raises(FirmwareError) as error: + fw.qspi.poll(1, match=0x01, mask=0x01, lines=1) + assert error.value.reason == "timeout" + assert clock.now - start == qspi.QSPI_TIMEOUT_S + assert reason(terminal, "qspi.cmd 1 instr=0x06") == "busy" + assert reason(terminal, "qspi.cmd 1 instr=0x100") == "range" + assert reason(terminal, "qspi.cmd 1 tx=0011") == "busy" + assert reason(terminal, "qspi.cmd 1 tx=00zz") == "busy", "busy before the data: the command in flight uses the buffer" + assert reason(terminal, "qspi.cmd 1 len=0") == "busy" + assert reason(terminal, "qspi.cmd 1 tx=00 rx=1") == "usage" + assert reason(terminal, "qspi.xfer 1 zz") == "unsupported" + fw.qspi.close(1) + fw.qspi.open(1, variant=variant) + assert fw.qspi.cmd(1, instr=0x06).flevel == 0 + fw.qspi.poll(1, match=0x00, mask=0x01, size=2, lines=4) + + +def test_xfer_checks_busy_before_its_data(): + terminal, fake, _, fw = make() + fw.qspi.open(1, variant="spi") + bus(fake).io = 0b0000 + with pytest.raises(FirmwareError) as error: + fw.qspi.poll(1, match=0x01, mask=0x01, lines=1) + assert error.value.reason == "timeout" + assert reason(terminal, "qspi.xfer 1 9f") == "busy" + assert reason(terminal, "qspi.xfer 1 zz") == "busy" + assert reason(terminal, "qspi.xfer 1 - rx=0") == "busy" + assert reason(terminal, "qspi.xfer 1 9f rx=1") == "usage" + + +def test_boot_resets_the_bus_and_the_instance(): + terminal, fake, _, fw = make() + fw.qspi.open(1) + bus(fake).io = 0 + fw.system.reset() + fw.forget_open() + assert bus(fake).io == 0xF + assert reason(terminal, "qspi.cmd 1") == "notopen" + + +def test_qspi_clock_and_frame(): + assert qspi.qspi_clock(64_000_000, 0) == 64_000_000 + assert qspi.qspi_clock(64_000_000, 255) == 250_000 + assert qspi.qspi_frame(0x38, b"\x12\x34", b"\xa5", b"\x00") == b"\x38\x12\x34\xa5\x00" + assert qspi.qspi_frame(data=b"\x01") == b"\x01" + + +def synthesize(frames: list[bytes], lines: int, half: int = 3, gap: int = 7) -> tuple[list[int], list[int], list[list[int]]]: + """CLK, NCS and IO0-IO3 samples of QUADSPI writes in clock mode 0: each bit (1 line, on IO0) or nibble (4 lines) + is set while CLK is low and sampled on the rising edge; NCS is high for `gap` samples around each frame.""" + clk: list[int] = [] + ncs: list[int] = [] + ios: list[list[int]] = [[], [], [], []] + + def sample(clock: int, select: int, nibble: int) -> None: + clk.append(clock) + ncs.append(select) + for line in range(4): + ios[line].append(nibble >> line & 1) + + for frame in frames: + for _ in range(gap): + sample(0, 1, 0) + units = ( + [byte >> shift & 0xF for byte in frame for shift in (4, 0)] + if lines == 4 + else [byte >> (7 - bit) & 1 for byte in frame for bit in range(8)] + ) + for unit in units: + for _ in range(half): + sample(0, 0, unit) + for _ in range(half): + sample(1, 0, unit) + for _ in range(half): + sample(0, 0, 0) + for _ in range(gap): + sample(0, 1, 0) + return clk, ncs, ios + + +@pytest.mark.parametrize("lines", [1, 4]) +def test_decode_writes(lines): + frames = [qspi.qspi_frame(0x38, b"\x12\x34\x56", b"\xa5\x5a", b"\x00\xff\x5a\xa5\xc3"), generate(32, "prbs", 1)] + clk, ncs, ios = synthesize(frames, lines) + samples = qspi.qspi_samples(clk, ncs, ios) + assert len(samples) == 2 + assert [qspi.qspi_bytes(frame, lines) for frame in samples] == frames + + +def test_decode_one_line_on_io1_and_partial_bytes(): + nibbles = [0b0010] * 8 + [0b0000] * 8 + [0b0010] * 3 + assert qspi.qspi_bytes(nibbles, 1, line=1) == b"\xff\x00" + assert qspi.qspi_bytes(nibbles, 1, line=0) == b"\x00\x00" + assert qspi.qspi_bytes([0x5, 0xA, 0xF], 4) == b"\x5a" + with pytest.raises(ValueError): + qspi.qspi_bytes([0, 0], 2) + + +def test_decode_ignores_edges_outside_the_chip_select(): + clk = [0, 1, 0, 1, 0, 1, 0, 1, 0] + ncs = [1, 1, 1, 0, 0, 0, 0, 1, 1] + ios = [[1] * 9, [0] * 9, [0] * 9, [0] * 9] + assert qspi.qspi_samples(clk, ncs, ios) == [[1, 1]] + assert qspi.qspi_samples([0, 1, 0], [1, 1, 1], ios) == [] diff --git a/validation/host/tests/unit/test_fake_spis.py b/validation/host/tests/unit/test_fake_spis.py new file mode 100644 index 00000000..387cbc6a --- /dev/null +++ b/validation/host/tests/unit/test_fake_spis.py @@ -0,0 +1,337 @@ +"""The fake `spis` group (argument order and reasons of validation/firmware/SpiSlaveGroup.cpp; PROTOCOL.md D.5), the +pins, SPI instance and DMA channels it holds, its arm/result state, the SPI loop through `spi.xfer` and the +`groups.spis` wrappers.""" + +import pytest +from ad3_waveforms_bench.terminal import FirmwareTerminal + +from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS, FakeFirmware, FakeSerial +from hal_st_validation.firmware import Firmware, settle +from hal_st_validation.groups.spis import SpisResult +from hal_st_validation.patterns import crc_text, generate + +# (family, slave instance, its pins, the other instance and its pins) per board +BOARDS = { + "stm32wb55": (2, ("PB13", "PB14", "PB15", "PB12"), 1, ("PA5", "PA6", "PA7", "PA4")), + "stm32wba55": (3, ("PA0", "PB9", "PB8", "PA5"), 1, ("PB4", "PB3", "PA15", "PA12")), +} +SLAVE_DMA = {"stm32wb55": [("dma2", 1), ("dma2", 2)], "stm32wba55": [("dma1", 8), ("dma1", 7)]} + + +class Clock: + def __init__(self) -> None: + self.now = 100.0 + + def __call__(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.now += seconds + + +def make(family="stm32wb55"): + clock = Clock() + fake = FakeFirmware(family=family, clock=clock, sleep=clock.sleep) + terminal = FirmwareTerminal(serial=FakeSerial(fake), timeout=0.5) + pins = WB55_PINS if family == "stm32wb55" else WBA55_PINS + return terminal, fake, clock, Firmware(terminal, pins) + + +def reason(terminal, line): + response = terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +def open_line(index, pins): + clk, miso, mosi, nss = pins + return f"spis.open {index} clk={clk} miso={miso} mosi={mosi} nss={nss}" + + +def open_slave(fw, family, which=0): + index, pins, other, other_pins = BOARDS[family] + if which: + index, pins = other, other_pins + fw.spis.open(index, *pins) + return index + + +def open_master(fw, family, which=1): + index, pins, other, other_pins = BOARDS[family] + if which: + index, pins = other, other_pins + clk, miso, mosi, _ = pins + fw.spi.open(index, clk=clk, mosi=mosi, miso=miso) + return index + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("spis.open", "usage"), + ("spis.open 2 3", "usage"), + ("spis.open 2 cs=PB12", "usage"), + ("spis.open x", "usage"), + ("spis.open 4", "range"), + ("spis.open 0 clk=PB13", "range"), + ("spis.open 3 clk=PB13", "range"), + ("spis.open 2 clk=PZ1 miso=PB14 mosi=PB15 nss=PB12", "pin"), + ("spis.open 2 clk=PB13 miso=PB14 mosi=PB15", "usage"), + ("spis.open 2 clk=PB14 miso=PB13 mosi=PB15 nss=PB12", "pin"), + ("spis.open 2 clk=PB13 miso=PB14 mosi=PB15 nss=PA4", "pin"), + ("spis.open 2 clk=PB13 miso=PB14 mosi=PB15 nss=PB12", "ok"), + ("spis.open 1 clk=spi1clk miso=spi1miso mosi=spi1mosi nss=spi1nss", "ok"), + ("spis.open 2 clk=spi2clk miso=spi2miso mosi=spi2mosi nss=spi2nss", "ok"), + ], +) +def test_open_reasons_wb55(line, expected): + terminal, *_ = make() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("spis.open 2 clk=PA0 miso=PB9 mosi=PB8 nss=PA5", "range"), + ("spis.open 3 clk=PA0 miso=PB9 mosi=PB8 nss=PA12", "pin"), + ("spis.open 3 clk=spi3clk miso=spi3miso mosi=spi3mosi nss=spi3nss", "ok"), + ("spis.open 1 clk=PB4 miso=PB3 mosi=PA15 nss=PA12", "ok"), + ], +) +def test_open_reasons_wba55(line, expected): + terminal, *_ = make("stm32wba55") + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize("family", list(BOARDS)) +def test_open_holds_pins_spi_instance_and_dma_channels(family): + terminal, fake, _, fw = make(family) + index, pins, other, other_pins = BOARDS[family] + fw.spis.open(index, *pins) + owner = ("spis", str(index)) + assert all(fake.resources[key] == owner for key in [("spi", index), *SLAVE_DMA[family]]) + assert fw.open_instances == [("spis", index)] + assert reason(terminal, open_line(other, other_pins)) == "busy" + clk, miso, mosi, _ = pins + assert reason(terminal, f"spi.open {index} clk={clk} mosi={mosi} miso={miso}") == "busy" + assert reason(terminal, f"gpio.cfg {pins[3]} out") == "busy" + fw.close_all() + assert fake.resources == {} + assert reason(terminal, f"spi.open {index} clk={clk} mosi={mosi} miso={miso}") == "ok" + assert reason(terminal, open_line(index, pins)) == "busy" + + +@pytest.mark.parametrize("family", list(BOARDS)) +def test_shared_dma_channel_is_busy(family): + terminal, fake, *_ = make(family) + index, pins, *_ = BOARDS[family] + kind, channel = SLAVE_DMA[family][1] + fake.claim_resource(kind, channel, ("adc", "x")) + assert reason(terminal, open_line(index, pins)) == "busy" + fake.release_resources(("adc", "x")) + assert reason(terminal, open_line(index, pins)) == "ok" + + +@pytest.mark.parametrize( + ("arguments", "expected"), + [ + ("", "usage"), + ("0102 3", "usage"), + ("0102 cs=1", "usage"), + ("-", "usage"), + ("- rx=0", "usage"), + ("0102 rx=3", "usage"), + ("0102 rx=1", "usage"), + ("012", "usage"), + ("0102 len=2", "usage"), + ("- pattern=inc", "usage"), + ("- len=2 pattern=walk", "usage"), + ("- len=0", "range"), + ("- len=1025", "range"), + ("- rx=1025", "range"), + ("0102", "ok"), + ("0102 rx=2", "ok"), + ("0102 rx=0", "ok"), + ("- rx=1024", "ok"), + ("- len=1024 pattern=prbs seed=7", "ok"), + ], +) +def test_arm_reasons(arguments, expected): + terminal, _, _, fw = make() + open_slave(fw, "stm32wb55") + assert reason(terminal, f"spis.arm 2 {arguments}".rstrip()) == expected + + +def test_arm_needs_the_open_instance_and_answers_busy_while_armed(): + terminal, _, _, fw = make() + assert reason(terminal, "spis.arm 2 01") == "notopen" + open_slave(fw, "stm32wb55") + assert reason(terminal, "spis.arm 1 01") == "notopen" + assert reason(terminal, "spis.arm 4 01") == "range" + fw.spis.arm(2, b"\x01") + assert reason(terminal, "spis.arm 2 02") == "busy" + assert reason(terminal, "spis.arm 2 0203 rx=1") == "busy", "busy before the payload: the armed DMA reads it" + assert reason(terminal, "spis.arm 2 - len=1025") == "busy" + assert reason(terminal, "spis.arm 2 01 extra=1") == "usage" + + +@pytest.mark.parametrize( + ("arguments", "expected"), + [ + ("", "usage"), + ("2 3", "usage"), + ("2 rx=1", "usage"), + ("2 out=bin", "usage"), + ("2 wait=x", "usage"), + ("2 wait=10001", "range"), + ("1", "notopen"), + ("2 wait=0 out=crc", "ok"), + ], +) +def test_result_reasons(arguments, expected): + terminal, _, _, fw = make() + open_slave(fw, "stm32wb55") + assert reason(terminal, f"spis.result {arguments}".rstrip()) == expected + + +def test_result_waits_only_while_armed(): + _, _, clock, fw = make() + open_slave(fw, "stm32wb55") + start = clock.now + assert fw.spis.result(2) == SpisResult(False) + assert clock.now == start + fw.spis.arm(2, b"\x01\x02") + assert fw.spis.result(2, wait=250) == SpisResult(False) + assert clock.now == pytest.approx(start + 0.25) + assert fw.spis.result(2) == SpisResult(False) + assert clock.now == pytest.approx(start + 1.25) + assert fw.spis.result(2, wait=0) == SpisResult(False) + assert clock.now == pytest.approx(start + 1.25) + + +def test_hex_output_up_to_128_bytes(): + terminal, _, _, fw = make() + open_slave(fw, "stm32wb55") + fw.spis.arm(2, b"", rx=129) + assert reason(terminal, "spis.result 2 wait=0") == "range" + assert reason(terminal, "spis.result 2 wait=0 out=crc") == "ok" + fw.spis.cancel(2) + fw.spis.arm(2, b"", rx=128) + assert reason(terminal, "spis.result 2 wait=0") == "ok" + + +@pytest.mark.parametrize("family", list(BOARDS)) +def test_loop_full_duplex(family): + _, _, _, fw = make(family) + slave = open_slave(fw, family) + master = open_master(fw, family) + fw.spis.arm(slave, b"\x11\x22\x33\x44") + assert fw.spi.xfer(master, b"\xa1\xa2\xa3\xa4") == b"\x11\x22\x33\x44" + assert fw.spis.result(slave) == SpisResult(True, rx=b"\xa1\xa2\xa3\xa4") + assert fw.spis.result(slave, out="crc") == SpisResult(True, length=4, crc=crc_text(b"\xa1\xa2\xa3\xa4")) + assert fw.spis.cancel(slave) is False + + +def test_loop_in_the_other_direction_and_generated_payload(): + _, _, _, fw = make() + slave = open_slave(fw, "stm32wb55", which=1) + master = open_master(fw, "stm32wb55", which=0) + fw.spis.arm(slave, len=64, pattern="prbs", seed=3) + assert fw.spi.xfer(master, bytes(64)) == generate(64, "prbs", 3) + assert fw.spis.result(slave) == SpisResult(True, rx=bytes(64)) + + +def test_loop_send_only_and_receive_only(): + _, fake, _, fw = make() + slave = open_slave(fw, "stm32wb55") + master = open_master(fw, "stm32wb55") + fw.spis.arm(slave, b"\x5a\xa5", rx=0) + assert fw.spi.xfer(master, b"\x01\x02") == b"\x5a\xa5" + assert fw.spis.result(slave) == SpisResult(True, rx=b"") + fw.spis.arm(slave, rx=3) + assert fw.spi.xfer(master, b"\x07\x08\x09", rx=0) == b"" + assert fw.spis.result(slave) == SpisResult(True, rx=b"\x07\x08\x09") + fake.spi_miso = 0xFF + assert fw.spi.xfer(master, b"\x01") == b"\xff", "no transfer armed: MISO is the static level" + + +def test_master_clocks_more_or_less_than_armed(): + _, fake, _, fw = make() + fake.spi_miso = 0xEE + slave = open_slave(fw, "stm32wb55") + master = open_master(fw, "stm32wb55") + fw.spis.arm(slave, b"\x01\x02\x03\x04") + assert fw.spi.xfer(master, b"\x10\x20\x30\x40\x50\x60") == b"\x01\x02\x03\x04\xee\xee" + assert fw.spis.result(slave) == SpisResult(True, rx=b"\x10\x20\x30\x40") + fw.spis.arm(slave, b"\x0a\x0b\x0c\x0d") + assert fw.spi.xfer(master, b"\x11\x12") == b"\x0a\x0b" + assert fw.spis.result(slave, wait=0) == SpisResult(False) + assert fw.spi.xfer(master, b"\x13\x14") == b"\x0c\x0d" + assert fw.spis.result(slave) == SpisResult(True, rx=b"\x11\x12\x13\x14") + + +def test_cancel_and_close(): + terminal, _, _, fw = make() + assert reason(terminal, "spis.cancel 2") == "notopen" + slave = open_slave(fw, "stm32wb55") + master = open_master(fw, "stm32wb55") + assert reason(terminal, "spis.cancel 2 1") == "usage" + assert fw.spis.cancel(slave) is False + fw.spis.arm(slave, b"\x01\x02") + fw.spi.xfer(master, b"\x03") + assert fw.spis.cancel(slave) is True + assert fw.spis.result(slave) == SpisResult(False) + fw.spis.arm(slave, b"\x04") + assert fw.spi.xfer(master, b"\x05") == b"\x04" + fw.spis.close(slave) + assert fw.open_instances == [("spi", master)] + assert reason(terminal, "spis.close 2") == "notopen" + assert fw.spi.xfer(master, b"\x05") == b"\x00" + + +def test_spi_loop_mapping(): + _, fake, _, fw = make() + slave = open_slave(fw, "stm32wb55") + master = open_master(fw, "stm32wb55") + fw.spis.arm(slave, b"\x77") + fake.spi_loop = {} + assert fw.spi.xfer(master, b"\x01") == b"\x00" + fake.spi_loop = {master: slave} + assert fw.spi.xfer(master, b"\x01") == b"\x77" + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("spi.xfer 1", "usage"), + ("spi.xfer 1 00 cs=1", "usage"), + ("spi.xfer 5 00", "range"), + ("spi.xfer 2 00", "notopen"), + ("spi.xfer 1 0", "usage"), + ("spi.xfer 1 -", "usage"), + ("spi.xfer 1 " + "00" * 65, "range"), + ("spi.xfer 1 - rx=65", "range"), + ("spi.xfer 1 00 continue=2", "range"), + ("spi.xfer 1 00 continue=1", "ok"), + ], +) +def test_master_transfer_reasons_are_emil_s(line, expected): + terminal, _, _, fw = make() + open_master(fw, "stm32wb55", which=1) + assert reason(terminal, line) == expected + + +def test_pending_result_wrapper(): + _, _, _, fw = make() + slave = open_slave(fw, "stm32wb55") + fw.spis.arm(slave, b"\x01") + pending = fw.spis.begin_result(slave, wait=0) + assert SpisResult.parse(settle(pending)) == SpisResult(False) + + +def test_reset_forgets_the_slave(): + terminal, fake, _, fw = make() + open_slave(fw, "stm32wb55") + fake.boot() + fw.forget_open() + assert fake.resources == {} + assert reason(terminal, "spis.arm 2 01") == "notopen" diff --git a/validation/host/tests/unit/test_fake_system_ext.py b/validation/host/tests/unit/test_fake_system_ext.py new file mode 100644 index 00000000..928312cc --- /dev/null +++ b/validation/host/tests/unit/test_fake_system_ext.py @@ -0,0 +1,326 @@ +"""The fake `flash`, `hsem`, `bkp` and `lpm` groups (argument order and reasons of validation/firmware/FlashGroup.cpp, +HsemGroup.cpp, BackupRamGroup.cpp and LowPowerGroup.cpp; PROTOCOL.md D.17-D.19), the `wdt.start` refusal while the +coordinated flash borrows the watchdog, and the `groups.system_ext` wrappers and helpers.""" + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal + +from hal_st_validation import patterns +from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS, FakeFirmware, FakeSerial +from hal_st_validation.firmware import Firmware +from hal_st_validation.groups.system_ext import bkp_fill_value, flash_words, sector_addresses, table_sectors + +WB55_FIRST = 50 +WBA55_FIRST = 24 + + +class Clock: + def __init__(self) -> None: + self.now = 100.0 + + def __call__(self) -> float: + return self.now + + def advance(self, seconds: float) -> None: + self.now += seconds + + +def make(family="stm32wb55", clock=None): + # Final lines end with a line break (as the HIL conftest sets it up), so an `EVT` printed right after a final + # line cannot join it and the prompt. + fake = ( + FakeFirmware(family=family, style="line") + if clock is None + else FakeFirmware(family=family, style="line", clock=clock, sleep=clock.advance) + ) + terminal = FirmwareTerminal(serial=FakeSerial(fake), timeout=0.5) + return terminal, fake, Firmware(terminal, WB55_PINS if family == "stm32wb55" else WBA55_PINS) + + +def reason(terminal, line): + response = terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +def test_table_sectors(): + assert table_sectors(64) == [1] * 48 + [1, 1, 2, 4, 1, 1, 2, 4] + assert table_sectors(12) == [1] * 4 + [1, 1, 2, 4] + assert table_sectors(7) == [1] * 7 + assert sum(table_sectors(64)) == 64 + assert sector_addresses([1, 2, 4], 4096) == [0, 4096, 12288, 28672] + + +def test_flash_words_and_fill_value(): + assert flash_words(0, 8, 8) == range(0, 1) + assert flash_words(5, 4, 8) == range(0, 2) + assert flash_words(8187, 32, 16) == range(511, 514) + assert bkp_fill_value(0, 0) == 0x9E3779B9 + assert bkp_fill_value(0xFFFFFFFF, 1) == (0xFFFFFFFF ^ (2 * 0x9E3779B9)) & 0xFFFFFFFF + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("flash.info", "ok"), + ("flash.info 1", "usage"), + ("flash.info variant=fast", "usage"), + ("flash.info layout=table variant=coord", "ok"), + ("flash.erase", "usage"), + ("flash.erase 50", "usage"), + ("flash.erase x 51", "usage"), + ("flash.erase 50 51 variant=fast", "usage"), + ("flash.erase 49 50", "range"), + ("flash.erase 50 50", "range"), + ("flash.erase 63 65", "range"), + ("flash.erase 50 57 layout=table", "range"), + ("flash.erase 50 51", "ok"), + ("flash.write 204800 -", "usage"), + ("flash.write 204800 0011 len=2", "usage"), + ("flash.write 204800 - seed=1", "usage"), + ("flash.write 204800 001", "usage"), + ("flash.write 204800 - len=513", "range"), + ("flash.write 204799 00", "range"), + ("flash.write 262143 0011", "range"), + ("flash.read 262144 1", "range"), + ("flash.read 0 0", "range"), + ("flash.read 0 129", "range"), + ("flash.read 0 4 out=bin", "usage"), + ("flash.read 0 262144 out=crc", "ok"), + ("flash.stack", "usage"), + ("flash.stack running", "usage"), + ("flash.stack fus layout=uneven", "usage"), + ("flash.stack fus", "ok"), + ], +) +def test_flash_errors_wb55(line, expected): + terminal, _, _ = make() + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("flash.info variant=coord", "unsupported"), + ("flash.erase 24 25 variant=coord", "unsupported"), + ("flash.erase 23 24 variant=coord", "range"), + ("flash.read 0 129 variant=coord", "range"), + ("flash.read 0 4 variant=coord", "unsupported"), + ("flash.stack starting", "unsupported"), + ("flash.erase 24 25", "ok"), + ], +) +def test_flash_errors_wba55(line, expected): + terminal, _, _ = make("stm32wba55") + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("family", "first", "page", "base"), [("stm32wb55", WB55_FIRST, 4096, 0x08040000), ("stm32wba55", WBA55_FIRST, 8192, 0x08080000)] +) +def test_flash_geometry(family, first, page, base): + _, _, fw = make(family) + info = fw.flash.info() + assert (info.base, info.sectors, info.size, info.first, info.layout) == (base, 64, 64 * page, first, "homogeneous") + table = fw.flash.info(layout="table") + assert (table.sectors, table.first) == (56, first) + + +@pytest.mark.parametrize("variant", ["sync", "async", "coord"]) +@pytest.mark.parametrize("layout", ["homogeneous", "table"]) +def test_flash_memory_model(variant, layout): + """Erase gives FF over the sector's pages only; writes land where addressed; a programmed word is refused.""" + terminal, _, fw = make() + sizes = table_sectors(64) if layout == "table" else [1] * 64 + addresses = sector_addresses(sizes, 4096) + sector = 50 + start, end = addresses[sector], addresses[sector + 1] + fw.flash.erase(sector, sector + 2, variant=variant, layout=layout) + data = patterns.generate(20, "prbs", 3) + fw.flash.write(start + 3, data, variant=variant, layout=layout) + fw.flash.write(end, b"\x77", variant=variant, layout=layout) + assert fw.flash.read(start, 24, variant=variant, layout=layout) == b"\xff" * 3 + data + b"\xff" + assert reason(terminal, f"flash.write {start + 22} 00 layout={layout}") == "failed" + fw.flash.write(start + 24, length=300, pattern="inc", seed=9, variant=variant, layout=layout) + assert fw.flash.read_crc(start + 24, 300, variant=variant, layout=layout) == patterns.crc32(patterns.generate(300, "inc", 9)) + fw.flash.erase(sector, sector + 1, variant=variant, layout=layout) + assert fw.flash.read_crc(start, end - start) == patterns.crc32(b"\xff" * (end - start)) + assert fw.flash.read(end, 1) == b"\x77" + + +def test_coord_waits_for_semaphore_7(): + clock = Clock() + terminal, _, fw = make(clock=clock) + fw.flash.erase(WB55_FIRST, WB55_FIRST + 1) + fw.hsem.take(7, procid=1, hold=200) + pending = fw.flash.begin_write(WB55_FIRST * 4096, b"\x01\x02", variant="coord") + clock.advance(0.25) + response = pending.wait() + assert response.as_int("us") >= 200000 + assert not fw.hsem.status(7).locked + assert reason(terminal, "hsem.lock 3") == "ok" + + +def test_coord_held_while_stack_starting(): + """A held write answers `ERR timeout` after 2 s and completes after `fus` with `EVT flash`; meanwhile the + watchdog and HSEM 0 are borrowed and only synchronous reads and the stack command go through.""" + clock = Clock() + terminal, fake, fw = make(clock=clock) + start = WB55_FIRST * 4096 + fw.flash.erase(WB55_FIRST, WB55_FIRST + 1) + fw.flash.stack("starting") + assert reason(terminal, "flash.stack starting") == "busy" + assert reason(terminal, "wdt.start 0 timeout=100") == "busy" + assert reason(terminal, "wdt.start 0") == "usage" + assert reason(terminal, "hsem.lock 1") == "busy" + assert reason(terminal, f"flash.erase {WB55_FIRST} {WB55_FIRST + 1}") == "busy" + assert reason(terminal, f"flash.erase {WB55_FIRST} {WB55_FIRST + 1} variant=coord layout=table") == "busy" + pending = fw.flash.begin_write(start, b"\xab", variant="coord") + clock.advance(2.1) + response = pending.wait(check=False) + assert (response.ok, response.reason) == (False, "timeout") + assert fw.flash.read(start, 1) == b"\xff" + assert reason(terminal, f"flash.write {start + 8} 01 variant=coord") == "busy" + fw.flash.stack("fus") + event = fw.flash.wait_done(timeout=0.5) + assert event["op"] == "write" + assert fw.flash.read(start, 1) == b"\xab" + assert fake.watchdog is None + assert reason(terminal, "wdt.start 0 timeout=100") == "ok" + + +def test_coord_timeout_completes_once_semaphore_7_is_released(): + """A coordinated step stuck on HSEM 7 past its `ERR timeout` keeps the group busy and completes, with `EVT flash`, + once the semaphore is released.""" + clock = Clock() + terminal, _, fw = make(clock=clock) + start = WB55_FIRST * 4096 + fw.flash.erase(WB55_FIRST, WB55_FIRST + 1) + fw.hsem.take(7, procid=2) + pending = fw.flash.begin_write(start, b"\x01", variant="coord") + assert reason_after(pending, clock, 2.1) == "timeout" + assert reason(terminal, f"flash.erase {WB55_FIRST} {WB55_FIRST + 1} variant=async") == "busy" + assert reason(terminal, f"flash.write {start + 8} zz") == "busy" + assert fw.flash.read(start, 1) == b"\xff" + fw.hsem.release(7, procid=2) + assert fw.flash.wait_done(timeout=0.5)["op"] == "write" + assert fw.flash.read(start, 1) == b"\x01" + + +def reason_after(pending, clock, seconds): + clock.advance(seconds) + response = pending.wait(check=False) + return "ok" if response.ok else response.reason + + +def test_hsem_state(): + clock = Clock() + terminal, _, fw = make(clock=clock) + fw.hsem.take(4, procid=9) + status = fw.hsem.status(4) + assert (status.locked, status.core, status.procid) == (True, 4, 9) + assert fw.hsem.mine(4) + assert reason(terminal, "hsem.take 4 procid=8") == "busy" + assert reason(terminal, "hsem.take 4 procid=9") == "ok" + assert reason(terminal, "hsem.lock 4") == "busy" + fw.hsem.release(4, procid=9) + assert not fw.hsem.mine(4) + fw.hsem.take(5, procid=1, hold=100) + assert reason(terminal, "hsem.take 6 procid=1 hold=100") == "busy" + clock.advance(0.2) + assert not fw.hsem.status(5).locked + assert fw.hsem.lock(5, hold=1500) == 1500 + assert fw.hsem.lock(5) == 0 + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("hsem.take 1", "usage"), + ("hsem.take procid=1", "usage"), + ("hsem.take 32 procid=1", "range"), + ("hsem.take 1 procid=0", "range"), + ("hsem.take 1 procid=1 hold=60001", "range"), + ("hsem.release 1", "usage"), + ("hsem.status 32", "range"), + ("hsem.lock 1 hold=1", "range"), + ("hsem.lock 1 hold=65537", "range"), + ("hsem.lock 1 procid=1", "usage"), + ("hsem.mine", "usage"), + ], +) +def test_hsem_errors(line, expected): + terminal, _, _ = make() + assert reason(terminal, line) == expected + + +def test_hsem_lock_needs_scaffold_timer(): + terminal, fake, _ = make() + fake.timer_owners[17] = ("tim", "17") + assert reason(terminal, "hsem.lock 1 hold=100") == "busy" + assert reason(terminal, "hsem.lock 1") == "ok" + + +@pytest.mark.parametrize(("family", "words"), [("stm32wb55", 20), ("stm32wba55", 16)]) +def test_backup_ram(family, words): + terminal, _, fw = make(family) + assert fw.bkp.info() == words + fw.bkp.write(words - 1, 0x89ABCDEF) + assert fw.bkp.read(words - 1) == 0x89ABCDEF + assert terminal.command(f"bkp.read {words - 1}")["value"] == "89abcdef" + fw.bkp.fill(7) + assert fw.bkp.check(7) == 0 + assert fw.bkp.check(8) == words + fw.system.reset() + assert fw.bkp.check(7) == 0 + for line, expected in [(f"bkp.read {words}", "range"), ("bkp.write 0 0x100000000", "usage"), ("bkp.info 0", "usage")]: + assert reason(terminal, line) == expected + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("lpm.enter", "usage"), + ("lpm.enter nap", "usage"), + ("lpm.enter sleep extra", "usage"), + ("lpm.enter sleep edge=both", "usage"), + ("lpm.enter sleep timeout=0", "range"), + ("lpm.enter sleep timeout=10001", "range"), + ("lpm.enter sleep wake=PA16", "pin"), + ("lpm.enter sleep wake=gpio0 marker=gpio0", "usage"), + ("lpm.enter sleep wake=PC7", "pin"), + ("lpm.enter deep marker=PE0", "pin"), + ("lpm.enter deep", "ok"), + ], +) +def test_lpm_errors_wb55(line, expected): + terminal, _, _ = make() + assert reason(terminal, line) == expected + + +def test_lpm_wakes_and_checks_resources(): + terminal, fake, fw = make("stm32wba55") + wake = fw.lpm.enter("sleep") + assert (wake.woke, wake.restored, wake.sleeps) == ("exti", 0, 1) + fw.gpio.cfg("tim1bkin", "out") + assert reason(terminal, "lpm.enter sleep") == "busy" + fw.gpio.release("tim1bkin") + fake.timer_owners[17] = ("tim", "17") + assert reason(terminal, "lpm.enter sleep") == "busy" + + +def test_groups_track_their_undo(): + _, _, fw = make() + fw.flash.stack("starting") + fw.hsem.take(9, procid=4) + fw.hsem.take(10, procid=4, hold=1000) + assert ("flash", "stack") in fw.open_instances + assert ("hsem", 9, 4) in fw.open_instances + assert ("hsem", 10, 4) not in fw.open_instances + assert fw.close_all() == [] + assert not fw.hsem.status(9).locked + fw.flash.stack("starting", layout="table") + with pytest.raises(FirmwareError) as error: + fw.flash.stack("starting") + assert error.value.reason == "busy" + fw.flash.stack("stopped") + assert ("flash", "stack") not in fw.open_instances diff --git a/validation/host/tests/unit/test_fake_timers.py b/validation/host/tests/unit/test_fake_timers.py new file mode 100644 index 00000000..0c40f607 --- /dev/null +++ b/validation/host/tests/unit/test_fake_timers.py @@ -0,0 +1,288 @@ +"""The fake `tim`, `tpwm`, `lptim` and `lptpwm` groups (argument order and reasons of validation/firmware/TimerGroup.cpp, +TimerPwmGroup.cpp, LpTimerGroup.cpp and LpTimerPwmGroup.cpp; PROTOCOL.md D.6-D.9), the timers and LPTIMs they share +with the other groups, the counters over a fake clock, the `groups.timers` wrappers and expectations.""" + +import pytest +from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal + +from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS, FakeFirmware, FakeSerial +from hal_st_validation.firmware import Firmware +from hal_st_validation.groups import timers + + +class Clock: + def __init__(self) -> None: + self.now = 100.0 + + def __call__(self) -> float: + return self.now + + def sleep(self, seconds: float) -> None: + self.now += seconds + + +def make(family="stm32wb55"): + clock = Clock() + fake = FakeFirmware(family=family, clock=clock, sleep=clock.sleep) + terminal = FirmwareTerminal(serial=FakeSerial(fake), timeout=0.5) + pins = WB55_PINS if family == "stm32wb55" else WBA55_PINS + return terminal, fake, clock, Firmware(terminal, pins) + + +def reason(terminal, line): + response = terminal.command(line, check=False) + return "ok" if response.ok else response.reason + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("tim.open", "usage"), + ("tim.open 1 2", "usage"), + ("tim.open 1 speed=1", "usage"), + ("tim.open x", "usage"), + ("tim.open 18", "range"), + ("tim.open 3", "range"), + ("tim.open 1 irq=nmi", "usage"), + ("tim.open 1 mode=sideways", "usage"), + ("tim.open 1 prescaler=65536", "range"), + ("tim.open 1 period=0", "range"), + ("tim.open 1 period=65536", "range"), + ("tim.open 1 prescaler=65536 irq=nmi", "range"), + ("tim.open 1 irq=none pin=gpio0", "usage"), + ("tim.open 1 irq=none pin=PZ1", "pin"), + ("tim.open 1 pin=PC7", "pin"), + ("tim.open 1 mode=down", "unsupported"), + ("tim.open 16 irq=none mode=down", "unsupported"), + ("tim.open 1 irq=none mode=down", "ok"), + ("tim.open 2 period=0xFFFFFFFF irq=none", "ok"), + ("tim.open 17 pin=gpio0 irq=immediate", "ok"), + ("tim.open 1 pin=terminaltx", "busy"), + ("tim.open 2 prescaler=0 period=1", "range"), + ("tim.open 2 prescaler=0 period=1 irq=immediate", "range"), + ("tim.open 2 prescaler=0 period=638", "range"), + ("tim.open 2 prescaler=0 period=639", "ok"), + ("tim.open 2 prescaler=0 period=1 irq=none", "ok"), + ("tim.open 2 prescaler=0 period=1 mode=down", "unsupported"), + ], +) +def test_timer_open_reasons(line, expected): + terminal, *_ = make() + assert reason(terminal, line) == expected + + +def test_timer_open_reports_the_kernel_clock_and_tracks(): + terminal, fake, _, fw = make("stm32wba55") + assert fw.tim.open(3, irq="immediate", pin="gpio0") == 100_000_000 + assert fake.received[-1] == "tim.open 3 irq=immediate pin=PB14" + assert fw.open_instances == [("tim", 3)] + assert reason(terminal, "gpio.cfg gpio0 in") == "busy" + fw.close_all() + assert fake.received[-1] == "tim.close 3" + assert reason(terminal, "gpio.cfg gpio0 in") == "ok" + + +def test_timer_counts_over_the_clock(): + terminal, _, clock, fw = make() + fw.tim.open(1, prescaler=63, period=999, irq="dispatched") + assert fw.tim.count(1) == timers.TimerCount(0, 0) + fw.tim.start(1) + clock.sleep(0.5) + reading = fw.tim.count(1) + assert reading.irqs == 500 + fw.tim.stop(1) + clock.sleep(1) + assert fw.tim.count(1) == reading + fw.tim.start(1) + fw.tim.start(1) + clock.sleep(0.03125) + assert fw.tim.count(1) == timers.TimerCount(250, 531) + assert reason(terminal, "tim.count 2") == "notopen" + assert reason(terminal, "tim.count 1 ch=1") == "usage" + + +def test_free_running_counts_down_without_interrupts(): + _, _, clock, fw = make() + fw.tim.open(2, prescaler=63999, period=65535, irq="none", mode="down") + fw.tim.start(2) + clock.sleep(0.25) + assert fw.tim.count(2) == timers.TimerCount(65535 - 250, 0) + + +def test_timer_shared_with_pwm_qei_adc_and_tpwm(): + terminal, *_ = make() + terminal.command("tim.open 2 irq=none") + for line in ("pwm.open 2 channels=1", "qei.open 2", "adc.open 1 pins=ain1 timer=2", "tpwm.open 2 pins=tim2ch1", "tim.open 1"): + assert reason(terminal, line) == "busy", line + terminal.command("tim.close 2") + terminal.command("pwm.open 2 channels=1") + assert reason(terminal, "tim.open 2") == "busy" + assert reason(terminal, "tim.open 1") == "ok" + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("tpwm.open 1", "usage"), + ("tpwm.open 1 pins=-", "usage"), + ("tpwm.open 1 pins=-,-", "usage"), + ("tpwm.open 1 pins=,", "usage"), + ("tpwm.open 1 pins=PA8,PA9,PA10,PA11,PA8", "usage"), + ("tpwm.open 1 pins=PA8 prescaler=65536", "range"), + ("tpwm.open 1 pins=PA8 period=0", "range"), + ("tpwm.open 1 pins=PZ1 period=0", "range"), + ("tpwm.open 1 pins=PA8 period=65536", "range"), + ("tpwm.open 2 pins=PA15 period=0xFFFFFFFF", "ok"), + ("tpwm.open 1 pins=PZ1", "pin"), + ("tpwm.open 1 pins=PA9", "pin"), + ("tpwm.open 1 pins=-,PA8", "pin"), + ("tpwm.open 16 pins=PA6,-", "unsupported"), + ("tpwm.open 16 pins=PA6,PA7", "pin"), + ("tpwm.open 3 pins=PA8", "range"), + ("tpwm.open 1 pins=-,PA9,PA10", "ok"), + ("tpwm.open 1 pins=PA8,PA9,PA10,PA11", "ok"), + ], +) +def test_timer_pwm_open_reasons(line, expected): + terminal, *_ = make() + assert reason(terminal, line) == expected + + +def test_timer_pwm_commands(): + terminal, fake, _, fw = make() + assert fw.tpwm.open(1, pins=[None, "tim1ch2", "tim1ch3"], prescaler=63, period=99) == 64_000_000 + assert fake.received[-1] == "tpwm.open 1 pins=-,PA9,PA10 prescaler=63 period=99" + assert reason(terminal, "gpio.cfg PA8 in") == "ok", "a `-` channel claims no pin" + assert reason(terminal, "gpio.cfg PA9 in") == "busy" + fw.tpwm.duty(1, 2, 50) + fw.tpwm.pulse(1, 3, 10, 199) + fw.tpwm.start(1, ch=2) + fw.tpwm.stop(1) + assert fake.received[-4:] == ["tpwm.duty 1 2 50", "tpwm.pulse 1 3 10 199", "tpwm.start 1 ch=2", "tpwm.stop 1"] + state = fake.opened[("tpwm", "1")] + assert state["ccr"] == [0, 49, 10] and state["period"] == 199 + for line, expected in ( + ("tpwm.duty 1 0 50", "range"), + ("tpwm.duty 1 4 50", "range"), + ("tpwm.duty 1 1 101", "range"), + ("tpwm.duty 1 1", "usage"), + ("tpwm.pulse 1 1 65536 2", "range"), + ("tpwm.pulse 1 1 1 0", "range"), + ("tpwm.start 1 ch=4", "range"), + ("tpwm.start 1 ch=x", "usage"), + ("tpwm.stop 1 channel=1", "usage"), + ("tpwm.duty 2 1 50", "notopen"), + ("tpwm.open 2 pins=PA15", "busy"), + ("pwm.open 1 channels=1", "busy"), + ("tim.open 1", "busy"), + ): + assert reason(terminal, line) == expected, line + fw.tpwm.close(1) + assert reason(terminal, "tpwm.duty 1 1 50") == "notopen" + + +@pytest.mark.parametrize( + ("family", "line", "expected"), + [ + ("stm32wb55", "lptim.open 0", "range"), + ("stm32wb55", "lptim.open 3", "range"), + ("stm32wb55", "lptim.open 1 period=0", "range"), + ("stm32wb55", "lptim.open 1 period=65536", "range"), + ("stm32wb55", "lptim.open 1 prescaler=0", "range"), + ("stm32wb55", "lptim.open 1 prescaler=3", "range"), + ("stm32wb55", "lptim.open 1 prescaler=256", "range"), + ("stm32wb55", "lptim.open 1 prescaler=3 pin=PZ1", "pin"), + ("stm32wb55", "lptim.open 1 prescaler=3 irq=none pin=gpio0", "range"), + ("stm32wb55", "lptim.open 1 irq=nmi", "usage"), + ("stm32wb55", "lptim.open 1 irq=none pin=gpio0", "usage"), + ("stm32wb55", "lptim.open 1 pin=PC7", "pin"), + ("stm32wb55", "lptim.open 1 rep=256", "range"), + ("stm32wb55", "lptim.open 1 rep=0", "unsupported"), + ("stm32wb55", "lptim.open 2 prescaler=128 pin=gpio0", "ok"), + ("stm32wba55", "lptim.open 1 rep=255", "ok"), + ("stm32wba55", "lptim.open 2 prescaler=64 irq=immediate pin=gpio0", "ok"), + ("stm32wb55", "lptim.open 1 period=1", "range"), + ("stm32wb55", "lptim.open 1 period=638 irq=immediate", "range"), + ("stm32wb55", "lptim.open 1 period=639", "ok"), + ("stm32wb55", "lptim.open 1 period=1 irq=none", "ok"), + ("stm32wb55", "lptim.open 1 period=1 rep=0", "unsupported"), + ("stm32wba55", "lptim.open 1", "ok"), + ("stm32wba55", "lptim.open 1 period=998", "range"), + ("stm32wba55", "lptim.open 1 period=1 rep=255", "range"), + ], +) +def test_lptim_open_reasons(family, line, expected): + terminal, *_ = make(family) + assert reason(terminal, line) == expected + + +def test_lptim_counts_and_reports_its_clock(): + _, fake, clock, fw = make("stm32wba55") + assert fw.lptim.open(1, prescaler=128, period=799, rep=1, irq="immediate") == 100_000_000 + assert fake.received[-1] == "lptim.open 1 period=799 prescaler=128 irq=immediate rep=1" + fw.lptim.start(1) + clock.sleep(1.0) + assert fw.lptim.count(1).irqs == int(timers.lptim_update_rate(100_000_000, 128, 799, 1)) + + +def test_lptim_shared_with_the_encoder_and_lptpwm(): + terminal, *_ = make("stm32wba55") + terminal.command("qei.open 2 lp=1 a=lptim2in1 b=lptim2in2") + assert reason(terminal, "lptim.open 2") == "busy" + assert reason(terminal, "lptpwm.open 2 pins=PA11") == "busy" + assert reason(terminal, "lptim.open 1") == "ok", "LPTIM1 is free" + terminal.command("qei.close 2") + terminal.command("lptim.close 1") + terminal.command("lptpwm.open 2 pins=PA11,PA1") + assert reason(terminal, "qei.open 2 lp=1 a=lptim2in1 b=lptim2in2") == "busy" + assert reason(terminal, "lptim.open 2") == "busy" + assert reason(terminal, "tim.open 2") == "ok", "TIM2 is not LPTIM2" + + +@pytest.mark.parametrize( + ("line", "expected"), + [ + ("lptpwm.open 1", "usage"), + ("lptpwm.open 1 pins=-,-", "usage"), + ("lptpwm.open 1 pins=-,PA15,PA15", "usage"), + ("lptpwm.open 3 pins=PA11", "range"), + ("lptpwm.open 1 pins=-,PA15 prescaler=3", "range"), + ("lptpwm.open 1 pins=-,PA15 period=65536", "range"), + ("lptpwm.open 1 pins=PB11", "pin"), + ("lptpwm.open 1 pins=PA15", "pin"), + ("lptpwm.open 2 pins=PA1,PA11", "pin"), + ("lptpwm.open 1 pins=-,PA8", "busy"), + ("lptpwm.open 1 pins=-,PA15", "ok"), + ("lptpwm.open 2 pins=PA11,PA1 prescaler=4 period=999", "ok"), + ], +) +def test_lptim_pwm_open_reasons(line, expected): + terminal, *_ = make("stm32wba55") + assert reason(terminal, line) == expected + + +def test_lptim_pwm_is_unsupported_on_wb55_only(): + terminal, *_ = make() + assert reason(terminal, "lptpwm.open 1 pins=PA15") == "unsupported" + terminal, _, _, fw = make("stm32wba55") + assert fw.lptpwm.open(2, pins=["lptim2ch1", None]) == 100_000_000 + with pytest.raises(FirmwareError) as error: + fw.lptpwm.duty(2, 3, 50) + assert error.value.reason == "range" + + +def test_groups_are_attached(): + *_, fw = make() + assert isinstance(fw.tim, timers.Timer) and isinstance(fw.tpwm, timers.TimerPwm) + assert isinstance(fw.lptim, timers.LpTimer) and isinstance(fw.lptpwm, timers.LpTimerPwm) + + +def test_expectations(): + assert timers.timer_update_rate(64_000_000, 63, 999) == 1000 + assert timers.lptim_update_rate(64_000_000, 64, 999) == 1000 + assert timers.lptim_update_rate(100_000_000, 128, 799, 3) == pytest.approx(244.140625) + assert timers.pwm_high_ticks(99, 50) == 49 + assert timers.pwm_duty_fraction(99, 100) == 0.99, "100 % keeps one low tick" + assert timers.pwm_duty_fraction(99, 0) == 0 + assert timers.pwm_high_ticks(0xFFFFFFFF, 50) == ((0xFFFFFFFF * 50) & 0xFFFFFFFF) // 100, "SetDuty wraps in 32 bits" + assert (timers.timer_period_max(2), timers.timer_period_max(16)) == (0xFFFFFFFF, 0xFFFF) diff --git a/validation/host/tests/unit/test_firmware.py b/validation/host/tests/unit/test_firmware.py index c404ca01..0d757e8e 100644 --- a/validation/host/tests/unit/test_firmware.py +++ b/validation/host/tests/unit/test_firmware.py @@ -2,7 +2,7 @@ from ad3_waveforms_bench.terminal import FirmwareError, FirmwareTerminal from hal_st_validation.fake_firmware import WB55_PINS, WBA55_PINS, FakeFirmware, FakeSerial -from hal_st_validation.firmware import Firmware +from hal_st_validation.firmware import Firmware, settle @pytest.fixture @@ -84,6 +84,11 @@ def test_gpio_resolves_aliases_and_tracks_release(fw, fake): ({"channels": [1], "dead": "off", "sync": True}, "pwm.open 1 channels=1 dead=off sync=1"), ({"channels": [1], "inv": True, "invn": False, "idle": True, "idlen": False}, "pwm.open 1 channels=1 inv=1 invn=0 idle=1 idlen=0"), ({"channels": [1], "brk": "tim1bkin", "brkpol": "low", "brkauto": True}, "pwm.open 1 channels=1 brk=PB12 brkpol=low brkauto=1"), + ( + {"channels": [1], "mode": "centerup", "preload": False, "trgo": "update"}, + "pwm.open 1 channels=1 mode=centerup preload=0 trgo=update", + ), + ({"channels": [1], "brk": "tim1bkin", "brkfilter": 7}, "pwm.open 1 channels=1 brk=PB12 brkfilter=7"), ], ) def test_pwm_open_formatting(fw, fake, kwargs, line): @@ -202,3 +207,36 @@ def test_unsupported_groups_through_raw_commands(fw): with pytest.raises(FirmwareError) as error: fw.command("can.open", 1, bitrate=500000) assert error.value.reason == "unsupported" + + +def test_spi_extension_formatting(fw, fake): + fw.spi.open(1, clk="spi1clk", mosi="spi1mosi", miso="spi1miso", nss="spi1nss", dma=True, bits=12, lsb=True) + assert last(fake) == "spi.open 1 clk=PA5 mosi=PA7 miso=PA6 dma=1 bits=12 lsb=1 nss=PA4" + fw.spi.close(1) + fw.spi.open(2, clk="spi2clk", mosi="spi2mosi", miso="spi2miso", lsb=False) + assert last(fake) == "spi.open 2 clk=PB13 mosi=PB15 miso=PB14 lsb=0" + + +def test_adc_trgo_formatting(fw, fake): + fw.pwm.open(2, channels=[1], trgo="update") + fw.adc.open(1, pins=["ain4"], trgo=2) + assert last(fake) == "adc.open 1 pins=PC3 trgo=2" + assert fw.open_instances == [("pwm", 2), ("adc", 1)] + + +def test_uart_sendonly_formatting(fw, fake): + fw.uart.open(1, lp=True, tx="lpuart1tx", sendonly=True) + assert last(fake) == "uart.open 1 lp=1 tx=PA2 sendonly=1" + assert fw.uart.recv(1) == b"" + + +def test_qei_capture_on_edges_formatting(fw, fake): + fw.qei.open(1, lp=True, a="lptim1in1", b="lptim1in2", cap="rise") + assert last(fake) == "qei.open 1 lp=1 a=PC0 b=PC2 cap=rise" + + +def test_begin_sends_without_waiting(fw, fake): + pending = fw.gpio.begin("cfg", "led0", "out", drive="fast") + assert pending.wait().ok + assert last(fake) == "gpio.cfg led0 out drive=fast" + assert settle(fw.gpio.begin("get", "PB0")).as_int("value") == 0 diff --git a/validation/host/tests/unit/test_i2c_decode.py b/validation/host/tests/unit/test_i2c_decode.py new file mode 100644 index 00000000..ab61d26f --- /dev/null +++ b/validation/host/tests/unit/test_i2c_decode.py @@ -0,0 +1,205 @@ +"""`i2c.i2c_decode` and `i2c.scl_timing` on synthetic logic-analyzer captures: START, repeated START, STOP, NACK, +clock stretching, and the SCL figures the HIL tests compare with TIMINGR.""" + +import pytest +from ad3_waveforms_bench.instruments.fake import FakeDwfApi, fake_ad3 + +from hal_st_validation.i2c import I2cByte, arm_on_start, arm_scope, i2c_decode, inject_on_start, rise_times, scl_timing + + +class Bus: + """SCL/SDA samples: each bit is low/high/low thirds of `half` samples, with the data set while SCL is low.""" + + def __init__(self, half: int = 4) -> None: + self.half = half + self.scl = [1] * 8 + self.sda = [1] * 8 + + def put(self, scl: int, sda: int, count: int | None = None) -> None: + count = self.half if count is None else count + self.scl.extend([scl] * count) + self.sda.extend([sda] * count) + + def start(self) -> None: + self.put(1, 1) + self.put(1, 0) + self.put(0, 0) + + def restart(self) -> None: + self.put(0, 1) + self.start() + + def stop(self) -> None: + self.put(0, 0) + self.put(1, 0) + self.put(1, 1) + + def byte(self, value: int, ack: bool, stretch: int = 0) -> None: + for bit in [(value >> (7 - index)) & 1 for index in range(8)] + [0 if ack else 1]: + self.put(0, bit) + self.put(1, bit) + self.put(0, bit) + if stretch: + self.put(0, 0 if ack else 1, stretch) + + def idle(self) -> None: + self.put(1, 1) + + +def test_write_read_with_repeated_start(): + bus = Bus() + bus.start() + bus.byte(0x42 << 1, True) + bus.byte(0x10, True) + bus.restart() + bus.byte(0x42 << 1 | 1, True) + bus.byte(0xAB, True) + bus.byte(0xCD, False) + bus.stop() + bus.idle() + write, read = i2c_decode(bus.scl, bus.sda) + assert (write.restart, write.address, write.read, write.address_ack, write.stop) == (False, 0x42, False, True, False) + assert write.data == [I2cByte(0x10, True)] + assert (read.restart, read.address, read.read, read.stop) == (True, 0x42, True, True) + assert read.payload == b"\xab\xcd" + assert [byte.ack for byte in read.data] == [True, False] + assert write.start < write.end <= read.start < read.end + + +def test_address_nack_then_stop(): + bus = Bus() + bus.start() + bus.byte(0x50 << 1, False) + bus.stop() + bus.idle() + (transfer,) = i2c_decode(bus.scl, bus.sda) + assert (transfer.address, transfer.address_ack, transfer.data, transfer.stop) == (0x50, False, [], True) + + +def test_data_nack(): + bus = Bus() + bus.start() + bus.byte(0x42 << 1, True) + bus.byte(0x01, True) + bus.byte(0x02, False) + bus.stop() + (transfer,) = i2c_decode(bus.scl, bus.sda) + assert [(byte.value, byte.ack) for byte in transfer.data] == [(1, True), (2, False)] + + +def test_clock_stretch_keeps_the_bits(): + bus = Bus() + bus.start() + bus.byte(0x42 << 1, True, stretch=200) + bus.byte(0x5A, True, stretch=50) + bus.stop() + (transfer,) = i2c_decode(bus.scl, bus.sda) + assert transfer.payload == b"\x5a" + + +def test_two_transfers_and_no_restart_after_stop(): + bus = Bus() + for value in (0x11, 0x22): + bus.start() + bus.byte(0x42 << 1, True) + bus.byte(value, True) + bus.stop() + bus.idle() + transfers = i2c_decode(bus.scl, bus.sda) + assert [(t.restart, t.payload, t.stop) for t in transfers] == [(False, b"\x11", True), (False, b"\x22", True)] + + +def test_partial_address_is_dropped(): + bus = Bus() + bus.start() + bus.put(0, 1) + bus.put(1, 1) + bus.put(0, 1) + bus.stop() + assert i2c_decode(bus.scl, bus.sda) == [] + + +def test_unterminated_transfer_is_kept(): + bus = Bus() + bus.start() + bus.byte(0x42 << 1, True) + bus.byte(0x99, True) + (transfer,) = i2c_decode(bus.scl, bus.sda) + assert (transfer.payload, transfer.stop) == (b"\x99", False) + + +def test_scl_timing(): + """Each bit is one third low, one third high, one third low (half = 4 samples): 12-sample periods, 8-sample low + phases and 4-sample high phases; a stretch lengthens one low phase but not the median period.""" + bus = Bus(half=4) + bus.start() + bus.byte(0x42 << 1, True, stretch=100) + bus.byte(0x0F, True) + bus.stop() + bus.idle() + timing = scl_timing(bus.scl, rate=1_000_000) + assert timing.freq == pytest.approx(1e6 / 12) + assert timing.tlow_min == pytest.approx(8e-6) + assert timing.thigh_min == pytest.approx(4e-6) + + +def test_scl_timing_needs_clocks(): + with pytest.raises(ValueError): + scl_timing([1, 1, 0, 0, 1, 1], rate=1e6) + + +def test_rise_times(): + edge = [0.0] * 5 + [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.3] + [3.3] * 5 + [0.0] * 3 + [1.0, 2.0, 3.0] + assert rise_times(edge, rate=1e8, low=1.0, high=2.0) == [pytest.approx(20e-9), pytest.approx(10e-9)] + assert rise_times([0.0, 1.5, 0.0, 3.0], rate=1e8, low=1.0, high=2.0) == [pytest.approx(10e-9 / 3)] + assert rise_times([0.0, 1.5, 1.8], rate=1e8, low=1.0, high=2.0) == [] + + +def calls(api, name): + return api.calls_to(name) + + +def test_arm_on_start_triggers_on_sda_falling_with_scl_high(): + api = FakeDwfApi() + ad3 = fake_ad3(api) + ad3.open() + api.logic_samples = [0b1000, 0b0000] + pending = arm_on_start(ad3, scl_dio=3, sda_dio=5, rate=1e6, samples=1000, pretrigger=0.1) + assert calls(api, "FDwfDigitalInTriggerSet")[-1][1:] == (0, 1 << 3, 0, 1 << 5) + assert calls(api, "FDwfDigitalInTriggerSourceSet")[-1][1] == api.constants.trigsrcDetectorDigitalIn.value + assert calls(api, "FDwfDigitalInTriggerPositionSet")[-1][1] == 900 + capture = pending.wait(timeout=1.0) + assert (capture.rate, capture.trigger_index, len(capture.samples)) == (1e6, 100, 1000) + assert capture.channel(3)[:2] == [1, 0] + with pytest.raises(ValueError): + arm_on_start(ad3, 3, 5, rate=1e6, samples=10**6) + + +def test_inject_on_start_pulses_sda_open_drain_after_the_start(): + api = FakeDwfApi() + ad3 = fake_ad3(api) + ad3.open() + inject_on_start(ad3, scl_dio=3, sda_dio=5, delay_s=20e-6, width_s=10e-6) + assert calls(api, "FDwfDigitalInTriggerSet")[-1][1:] == (0, 1 << 3, 0, 1 << 5) + assert calls(api, "FDwfDigitalOutTriggerSourceSet")[-1][1] == api.constants.trigsrcDetectorDigitalIn.value + assert calls(api, "FDwfDigitalOutOutputSet")[-1][1:] == (5, api.constants.DwfDigitalOutOutputOpenDrain.value) + assert calls(api, "FDwfDigitalOutIdleSet")[-1][1:] == (5, api.constants.DwfDigitalOutIdleZet.value) + assert calls(api, "FDwfDigitalOutCounterInitSet")[-1][1:] == (5, 0, 1000) + assert calls(api, "FDwfDigitalOutWaitSet")[-1][1] == pytest.approx(20e-6) + assert calls(api, "FDwfDigitalOutRunSet")[-1][1] == pytest.approx(10e-6) + assert calls(api, "FDwfDigitalOutConfigure")[-1][1] == 1 + with pytest.raises(ValueError): + inject_on_start(ad3, 3, 5, delay_s=0.0, width_s=0.0) + + +def test_arm_scope_triggers_on_the_rising_edge(): + api = FakeDwfApi() + ad3 = fake_ad3(api) + ad3.open() + api.scope_levels = {0: 3.3, 1: 0.0} + pending = arm_scope(ad3, [1, 2], rate=1e8, samples=100, trigger=1, level=1.65) + assert calls(api, "FDwfAnalogInTriggerChannelSet")[-1][1] == 0 + assert calls(api, "FDwfAnalogInTriggerLevelSet")[-1][1] == pytest.approx(1.65) + assert calls(api, "FDwfAnalogInTriggerAutoTimeoutSet")[-1][1] == 0.0 + samples = pending.wait(timeout=1.0) + assert samples[1] == [3.3] * 100 and samples[2] == [0.0] * 100 diff --git a/validation/host/tests/unit/test_i2c_timing.py b/validation/host/tests/unit/test_i2c_timing.py new file mode 100644 index 00000000..82dc96d5 --- /dev/null +++ b/validation/host/tests/unit/test_i2c_timing.py @@ -0,0 +1,122 @@ +"""`i2c.expected_timing`, the host twin of validation/firmware/I2cTiming.cpp: the TIMINGR table of PROTOCOL.md (D.1) +and reference vectors of the design's `i2c_timing.py` `compute()`, copied as literals, plus the I2C limits every +result must meet with a zero rise time.""" + +import pytest + +from hal_st_validation.i2c import ( + BUS_MAX, + BUS_MIN, + FAST_MODE, + STANDARD_MODE, + arbitration_window, + bit_times, + expected_timing, + mode_limits, + timing_fields, +) + +PROTOCOL_TABLE = [ + (16_000_000, 20_000, 0x20408186), + (16_000_000, 50_000, 0x00E097A2), + (16_000_000, 100_000, 0x00E04752), + (16_000_000, 400_000, 0x00500916), + (32_000_000, 20_000, 0x80305659), + (32_000_000, 50_000, 0x2090656C), + (32_000_000, 100_000, 0x10E04853), + (32_000_000, 400_000, 0x00B0162E), + (64_000_000, 20_000, 0x90509CA1), + (64_000_000, 50_000, 0x80604348), + (64_000_000, 100_000, 0x40B03943), + (64_000_000, 400_000, 0x10B0172F), + (100_000_000, 20_000, 0xD060AEB4), + (100_000_000, 50_000, 0x70B0777F), + (100_000_000, 100_000, 0x50E04B57), + (100_000_000, 400_000, 0x20B11931), +] + +REFERENCE_GRID = [ + (16_000_000, 75_000, 0x00E0616D), + (16_000_000, 100_001, 0x00504552), + (16_000_000, 250_000, 0x00501522), + (24_000_000, 20_000, 0x2070C3C9), + (24_000_000, 75_000, 0x10A04952), + (24_000_000, 100_001, 0x00806A7C), + (24_000_000, 250_000, 0x00802234), + (24_000_000, 400_000, 0x00801022), + (48_000_000, 20_000, 0x70509297), + (48_000_000, 75_000, 0x30A04953), + (48_000_000, 100_001, 0x10806B7D), + (48_000_000, 250_000, 0x10802335), + (48_000_000, 400_000, 0x10801123), + (80_000_000, 20_000, 0x8070D9E0), + (80_000_000, 75_000, 0x40E0626F), + (80_000_000, 100_001, 0x10D0B3D1), + (80_000_000, 250_000, 0x10D03B59), + (80_000_000, 400_000, 0x10D01D3B), +] + + +@pytest.mark.parametrize(("kernel", "bus", "timing"), PROTOCOL_TABLE) +def test_protocol_table(kernel, bus, timing): + assert expected_timing(kernel, bus) == timing + + +@pytest.mark.parametrize(("kernel", "bus", "timing"), REFERENCE_GRID) +def test_reference_grid(kernel, bus, timing): + assert expected_timing(kernel, bus) == timing + + +@pytest.mark.parametrize("bus", [0, BUS_MIN - 1, BUS_MAX + 1, 1_000_000]) +def test_bus_outside_the_range(bus): + assert expected_timing(64_000_000, bus) is None + + +def test_no_kernel_clock(): + assert expected_timing(0, 100_000) is None + + +@pytest.mark.parametrize("kernel", [16_000_000, 32_000_000, 64_000_000, 100_000_000]) +@pytest.mark.parametrize("bus", [BUS_MIN, 33_333, 50_000, 99_999, 100_000, 100_001, 200_000, 333_333, BUS_MAX]) +def test_limits_with_zero_rise(kernel, bus): + """The `check(..., trise=0)` of the design: tLOW/tHIGH minima of the mode, SCLDEL covers rise + setup, SDADEL + stays below the valid-data limit, and the period with zero rise is never shorter than 1/bus.""" + timing = expected_timing(kernel, bus) + assert timing is not None + mode = mode_limits(bus) + fields = timing_fields(timing) + clock = 1e12 / kernel + prescaled = (fields["presc"] + 1) * clock + low, high = bit_times(timing, kernel) + assert low * 1e12 > mode.lscl_min + assert high * 1e12 >= mode.hscl_min - 1e-3 + assert (fields["scldel"] + 1) * prescaled >= mode.rise + mode.sudat_min + assert (fields["sdadel"] * (fields["presc"] + 1) + 1) * clock <= mode.vddat_max - mode.rise - 260_000 - 4 * clock + assert 1 / (low + high + mode.fall / 1e12) <= bus * 1.0001 + + +def test_mode_split(): + assert mode_limits(100_000) is STANDARD_MODE + assert mode_limits(100_001) is FAST_MODE + assert STANDARD_MODE.tlow_min == pytest.approx(4.7e-6) + assert FAST_MODE.thigh_min == pytest.approx(0.6e-6) + + +def test_timing_fields(): + assert timing_fields(0x10B0172F) == {"presc": 1, "scldel": 0xB, "sdadel": 0, "sclh": 0x17, "scll": 0x2F} + assert timing_fields(0x70B03D3D) == {"presc": 7, "scldel": 0xB, "sdadel": 0, "sclh": 0x3D, "scll": 0x3D} + + +def test_bit_times_of_the_default_timing(): + """0x70B03D3D at 64 MHz: 62 prescaled clocks of 125 ns each plus 81.25 ns of filter and sync per phase.""" + low, high = bit_times(0x70B03D3D, 64_000_000) + assert low == pytest.approx(62 * 125e-9 + 50e-9 + 2 / 64e6) + assert high == pytest.approx(low) + + +def test_arbitration_window_covers_the_second_address_bit(): + low, high = bit_times(0x40B03943, 64_000_000) + delay, width = arbitration_window(0x40B03943, 64_000_000) + second_high = (2 * high + 2 * low, 3 * high + 2 * low) + assert delay < second_high[0] and delay + width > second_high[1] + assert width == pytest.approx(low + high) diff --git a/validation/host/tests/unit/test_known_gaps.py b/validation/host/tests/unit/test_known_gaps.py index f383a9fe..60a52055 100644 --- a/validation/host/tests/unit/test_known_gaps.py +++ b/validation/host/tests/unit/test_known_gaps.py @@ -161,7 +161,9 @@ def test_run_known_gaps_turns_skips_into_expected_failures(tmp_path): def test_a_board_without_gaps_marks_nothing(tmp_path): - marks = collected_marks(tmp_path, "nucleo_wb55rg", "--port", "nosuchport") + no_gaps = tmp_path / "no_gaps.yaml" + no_gaps.write_text("known_gaps!: []\n", encoding="utf-8") + marks = collected_marks(tmp_path, "nucleo_wb55rg", "--port", "nosuchport", "--board-extra", str(no_gaps)) assert not any("known gap" in reason for found in marks.values() for _, reason in found) diff --git a/validation/host/tests/unit/test_load_gate.py b/validation/host/tests/unit/test_load_gate.py new file mode 100644 index 00000000..5ac74813 --- /dev/null +++ b/validation/host/tests/unit/test_load_gate.py @@ -0,0 +1,174 @@ +"""The load gate of `need` (tests/conftest.py), run in pytester sessions with the real conftest and an inline board: +a pin an enabled option loads resolves only for tests marked `uses_option`/`requires_option` of that option, and +a pin an option jumpers to a channel resolves only for them; `--with` is validated against the wiring sets.""" + +from pathlib import Path + +import pytest + +pytest_plugins = ["pytester"] + +CONFTEST = Path(__file__).resolve().parents[1] / "conftest.py" + +BOARD = """ +board: gate +family: stm32wb55 +pins: + spi1miso: PA6 +wiring_sets: + one: + options: + loopback: + loads: [PA6, PA7] + excludes: [spiloop] + spiloop: + jumpered: {PA5: [PB13]} + loads: [PA5, PB13] + excludes: [loopback] + i2c: + jumpered: {PB8: [PC0]} + loads: [PB8, PC0] + dio: + 0: PA5 + 1: PA7 + 3: PB8 + 7: spi1miso + scope: + 1: PA6 + two: + dio: + 0: PA5 +""" + +TESTS = """ +import pytest + + +def test_unmarked_loaded(need): + need.dio("PA6") + + +def test_unmarked_loaded_by_alias(need): + need.dio("spi1miso") + + +@pytest.mark.uses_option("loopback") +def test_marked_loaded(need): + assert need.dio("PA6") == 7 + assert need.scope("PA6") == 1 + + +def test_unloaded(need): + assert need.dio("PA5") == 0 + + +@pytest.mark.uses_option("i2c") +def test_jumpered_with_tag(need): + assert need.dio("PC0") == 3 + + +def test_jumpered_without_tag(need): + need.dio("PC0") + + +def test_optional_lookups_of_loaded_pins(need): + assert need.optional_dio("PA6") is None + assert need.optional_scope("PA6") is None + assert need.optional_dio("PA5") == 0 + + +@pytest.mark.requires_option("loopback") +def test_requires_implies_uses(need): + assert need.dio("PA7") == 1 + + +def test_unloaded_skips_explicitly(need): + need.unloaded("PA5", "spi1miso") + + +@pytest.mark.conflicts_option("loopback") +def test_conflicts(): + pass +""" + +INI = """ +[pytest] +markers = + uses_option(tag): x + requires_option(tag): x + conflicts_option(tag): x +""" + + +@pytest.fixture +def session(pytester): + pytester.makeconftest(CONFTEST.read_text(encoding="utf-8")) + pytester.makeini(INI) + board = pytester.makefile(".yaml", gate=BOARD) + pytester.makepyfile(test_gate=TESTS) + + def run(*options): + return pytester.runpytest_inprocess("--board", str(board), "-p", "no:cacheprovider", "-rs", *options) + + return run + + +def outcomes(result): + reports = result.reprec.getreports("pytest_runtest_logreport") + found = {} + for report in reports: + name = report.nodeid.split("::")[-1] + if report.when == "call" or report.outcome != "passed": + found[name] = (report.outcome, report.longrepr[2] if report.skipped else "") + return found + + +def test_gate_with_options_enabled(session): + result = session("--wiring-set", "one", "--with", "loopback", "--with", "i2c") + found = outcomes(result) + loaded = 'pin PA6 loaded by --with loopback (mark the test uses_option("loopback") if it handles that wiring)' + assert found["test_unmarked_loaded"] == ("skipped", f"Skipped: {loaded}") + assert found["test_unmarked_loaded_by_alias"] == ("skipped", f"Skipped: {loaded}") + assert found["test_marked_loaded"][0] == "passed" + assert found["test_unloaded"][0] == "passed" + assert found["test_jumpered_with_tag"][0] == "passed" + assert found["test_jumpered_without_tag"][0] == "skipped" + assert "DIO for PC0 is not wired" in found["test_jumpered_without_tag"][1] + assert found["test_optional_lookups_of_loaded_pins"][0] == "passed" + assert found["test_requires_implies_uses"][0] == "passed" + assert found["test_unloaded_skips_explicitly"] == ("skipped", f"Skipped: {loaded}") + assert found["test_conflicts"] == ("skipped", "Skipped: disconnect --with loopback") + + +def test_gate_without_options(session): + result = session("--wiring-set", "one") + found = outcomes(result) + assert found["test_unmarked_loaded"][0] == "passed" + assert found["test_marked_loaded"][0] == "passed" + assert found["test_jumpered_with_tag"][0] == "skipped", "PC0 is only reached through the i2c jumpers" + assert found["test_unloaded_skips_explicitly"][0] == "passed" + assert found["test_requires_implies_uses"] == ("skipped", "Skipped: enable with --with loopback") + assert found["test_conflicts"][0] == "passed" + + +@pytest.mark.parametrize( + ("options", "message"), + [ + (["--with", "loopback"], "needs the --wiring-set"), + (["--wiring-set", "two", "--with", "loopback"], "offer no --with loopback"), + (["--wiring-set", "one", "--with", "loopback", "--with", "spiloop"], "exclude each other"), + (["--wiring-set", "nosuch"], "unknown wiring set"), + ], +) +def test_invalid_wiring_is_a_usage_error(session, options, message): + result = session(*options) + assert result.ret == pytest.ExitCode.USAGE_ERROR + assert message in "\n".join(result.errlines) + + +def test_board_extra_from_the_environment(session, pytester, monkeypatch): + extra = pytester.makefile(".yaml", extra="wiring_sets:\n two:\n options:\n loopback: {loads: [PA5]}\n") + monkeypatch.setenv("HAL_ST_BOARD_EXTRA", str(extra)) + pytester.makeconftest(CONFTEST.read_text(encoding="utf-8")) + found = outcomes(session("--wiring-set", "two", "--with", "loopback")) + assert found["test_unloaded"][0] == "skipped", "the extra file made PA5 a loaded pin" diff --git a/validation/host/tests/unit/test_patterns.py b/validation/host/tests/unit/test_patterns.py new file mode 100644 index 00000000..d2a0cd53 --- /dev/null +++ b/validation/host/tests/unit/test_patterns.py @@ -0,0 +1,42 @@ +import pytest + +from hal_st_validation.patterns import PATTERNS, crc32, crc_text, generate + + +def test_reference_vectors(): + """The C.8 vectors the firmware's `Payload.cpp` is checked against.""" + assert generate(4, "inc", 0xFE) == bytes.fromhex("feff0001") + assert generate(8, "prbs", 1) == bytes.fromhex("2101c54fd1d01ab2") + assert crc_text(generate(256)) == "29058c73" + assert crc_text(generate(1024, "prbs", 1)) == "696138f0" + + +def test_patterns(): + assert generate(3, "const", 0x1A5) == b"\xa5\xa5\xa5" + assert generate(300)[255:258] == b"\xff\x00\x01" + assert generate(0, "prbs") == b"" + assert generate(8, "prbs", 0) == generate(8, "prbs", 1), "seed 0 starts the generator at 1" + assert generate(16, "prbs", 0xFFFFFFFF) != generate(16, "prbs", 1) + assert generate(64, "prbs", 7)[:10] == generate(10, "prbs", 7), "a prefix does not depend on the length" + assert set(PATTERNS) == {"inc", "const", "prbs"} + + +def test_prbs_is_xorshift32(): + state, expected = 0x12345678, bytearray() + for _ in range(32): + state ^= (state << 13) & 0xFFFFFFFF + state ^= state >> 17 + state ^= (state << 5) & 0xFFFFFFFF + expected.append(state & 0xFF) + assert generate(32, "prbs", 0x12345678) == bytes(expected) + + +def test_crc_is_the_firmware_crc32(): + assert crc32(b"123456789") == 0xCBF43926, "the CRC-32 check value" + assert crc_text(b"") == "00000000" + + +@pytest.mark.parametrize(("length", "pattern", "seed"), [(-1, "inc", 0), (1, "inc", -1), (1, "inc", 1 << 32), (1, "noise", 0)]) +def test_invalid_arguments(length, pattern, seed): + with pytest.raises(ValueError): + generate(length, pattern, seed) diff --git a/validation/host/tests/unit/test_protocol.py b/validation/host/tests/unit/test_protocol.py index b0ac4d06..c9534068 100644 --- a/validation/host/tests/unit/test_protocol.py +++ b/validation/host/tests/unit/test_protocol.py @@ -63,6 +63,18 @@ def test_pin_parts(): "gpio15", "sw1", "sw3", + "i2c1scl", + "i2c3sda", + "spi2nss", + "spi1nss", + "qspiclk", + "qspincs", + "qspiio0", + "qspiio3", + "lptim1ch2", + "lptim2ch1", + "tim16ch1n", + "mco", ], ) def test_generic_aliases(alias): @@ -97,6 +109,15 @@ def test_generic_aliases(alias): "can0rx", "phasea", "button1", + "i2c0scl", + "i2c4sda", + "i2c1sck", + "spi4nss", + "qspiio4", + "qspics", + "lptim1ch3", + "lptim3ch1", + "mco1", ], ) def test_not_aliases(name): @@ -104,8 +125,9 @@ def test_not_aliases(name): def test_alias_scheme_size(): - """terminal 2, ain 20, timers 17 * (4 + 3 + 1), qei 17 * 3, lptim 4, spi 12, usart 12, lpuart 4, led 8, gpio 16, sw 3.""" - assert len(PIN_ALIASES) == 2 + 20 + 17 * 8 + 17 * 3 + 4 + 12 + 12 + 4 + 8 + 16 + 3 + """terminal 2, ain 20, timers 17 * (4 + 3 + 1), qei 17 * 3, lptim 2 * (2 inputs + 2 channels), spi 3 * 5, i2c 3 * 2, + qspi 6, usart 12, lpuart 4, led 8, gpio 16, sw 3, mco 1.""" + assert len(PIN_ALIASES) == 2 + 20 + 17 * 8 + 17 * 3 + 8 + 15 + 6 + 6 + 12 + 4 + 8 + 16 + 3 + 1 @pytest.mark.parametrize("table", [WB55_PINS, WBA55_PINS]) diff --git a/validation/host/tests/unit/test_rngstats.py b/validation/host/tests/unit/test_rngstats.py new file mode 100644 index 00000000..78628405 --- /dev/null +++ b/validation/host/tests/unit/test_rngstats.py @@ -0,0 +1,75 @@ +"""`rngstats`: the `rng.stats` counts (most significant bit first, chi-square times 1000 in integer arithmetic, +zlib CRC-32) and the monobit, chi-square and runs bounds.""" + +import math +import random +import zlib + +import pytest + +from hal_st_validation import patterns, rngstats + + +def test_counts_of_small_streams(): + stats = rngstats.byte_stats(b"\x00\xff") + assert (stats.n, stats.ones, stats.runs) == (2, 8, 2) + assert rngstats.byte_stats(b"\x55").runs == 8 + assert rngstats.byte_stats(b"\x80\x01").runs == 3 + assert rngstats.byte_stats(b"\x01\x80").runs == 3 + assert rngstats.byte_stats(b"\x01").runs == 2 + assert rngstats.byte_stats(b"\x00").runs == 1 + + +def test_counts_match_a_bitwise_walk(): + data = bytes(random.Random(5).getrandbits(8) for _ in range(300)) + bits = [(byte >> (7 - position)) & 1 for byte in data for position in range(8)] + stats = rngstats.byte_stats(data) + assert stats.ones == sum(bits) + assert stats.runs == 1 + sum(1 for a, b in zip(bits, bits[1:]) if a != b) + assert stats.crc == zlib.crc32(data) + + +def test_chi_square(): + """A flat histogram is 0; all bytes alike is 255 n; the integer form truncates sum((c - E)^2 / E) * 1000.""" + assert rngstats.byte_stats(bytes(range(256)) * 4).chisq_x1000 == 0 + assert rngstats.byte_stats(bytes(64)).chisq_x1000 == 255 * 64 * 1000 + data = bytes(random.Random(1).getrandbits(8) for _ in range(4096)) + expected = 4096 / 256 + exact = sum((data.count(value) - expected) ** 2 / expected for value in range(256)) + assert rngstats.byte_stats(data).chisq_x1000 == math.floor(exact * 1000) + + +def test_chi_square_saturates_like_the_firmware(): + assert rngstats.byte_stats(bytes(65536)).chisq_x1000 == 0xFFFFFFFF + + +def test_random_data_passes(): + for seed in (1, 2, 3): + data = patterns.generate(65536, "prbs", seed) + stats = rngstats.byte_stats(data) + assert rngstats.failures(stats.n, stats.ones, stats.runs, stats.chisq_x1000) == [], seed + + +@pytest.mark.parametrize( + ("data", "failed"), + [ + (bytes([0x0F, 0xFF]) * 4096, "monobit"), + (bytes([0x55, 0xAA]) * 4096, "chi-square"), + (bytes([0x0F]) * 8192, "runs"), + ], +) +def test_biased_data_fails(data, failed): + stats = rngstats.byte_stats(data) + assert any(message.startswith(failed) for message in rngstats.failures(stats.n, stats.ones, stats.runs, stats.chisq_x1000)) + + +def test_bounds(): + bits = 8 * 65536 + sigma = math.sqrt(bits) / 2 + assert rngstats.monobit_ok(65536, bits // 2 + int(3.9 * sigma)) + assert not rngstats.monobit_ok(65536, bits // 2 + int(4.1 * sigma)) + assert rngstats.chi_square_ok(330499) + assert not rngstats.chi_square_ok(330500) + assert rngstats.runs_ok(65536, bits // 2, bits // 2) + assert not rngstats.runs_ok(65536, bits // 2, bits // 2 + int(4.1 * math.sqrt(bits) / 2)) + assert not rngstats.runs_ok(65536, bits // 2 + bits // 100, bits // 2) diff --git a/validation/host/tests/unit/test_spiwords.py b/validation/host/tests/unit/test_spiwords.py new file mode 100644 index 00000000..9edcbee9 --- /dev/null +++ b/validation/host/tests/unit/test_spiwords.py @@ -0,0 +1,126 @@ +"""`spiwords`: decoding words of 4..16 bits from sampled lines, and the frame model of `SpiMasterStmDma` with a +data size configurator (one frame per byte up to 8 bits, one per little-endian byte pair above).""" + +import pytest +from ad3_waveforms_bench import analysis + +from hal_st_validation.spiwords import BITS_MAX, BITS_MIN, spi_bytes, spi_decode_words, spi_frames, spi_join_words + + +def waveform(regions, bits, mode=0, msb_first=True, gap=3): + """Sampled clk, mosi, miso and cs for `regions` (lists of (mosi word, miso word)): four samples per bit, data + changing on the shift edge, chip select low around each region.""" + cpol, cpha = analysis.spi_mode_bits(mode) + idle, active = cpol, 1 - cpol + clk, mosi, miso, cs = [], [], [], [] + + def emit(count, clock, out, back, select): + clk.extend([clock] * count) + mosi.extend([out] * count) + miso.extend([back] * count) + cs.extend([select] * count) + + emit(gap, idle, 0, 0, 1) + for words in regions: + emit(gap, idle, 0, 0, 0) + for out_word, back_word in words: + order = range(bits - 1, -1, -1) if msb_first else range(bits) + for bit in order: + out, back = (out_word >> bit) & 1, (back_word >> bit) & 1 + first, second = (idle, active) if cpha == 0 else (active, idle) + emit(2, first, out, back, 0) + emit(2, second, out, back, 0) + emit(gap, idle, 0, 0, 0) + emit(gap, idle, 0, 0, 1) + return clk, mosi, miso, cs + + +@pytest.mark.parametrize("mode", [0, 1, 2, 3]) +@pytest.mark.parametrize("bits", [4, 7, 8, 9, 12, 16]) +@pytest.mark.parametrize("msb_first", [True, False]) +def test_decode_round_trip(mode, bits, msb_first): + mask = (1 << bits) - 1 + words = [(0xA5A5 & mask, 0x5A5A & mask), (mask, 0), (1, 1 << (bits - 1)), (0x1234 & mask, 0xFEDC & mask)] + clk, mosi, miso, cs = waveform([words], bits, mode, msb_first) + regions = spi_decode_words(clk, mosi, miso, cs, mode=mode, bits=bits, msb_first=msb_first) + assert len(regions) == 1 + assert regions[0].mosi == [out for out, _ in words] + assert regions[0].miso == [back for _, back in words] + assert regions[0].clock_edges == bits * len(words) + + +def test_words_are_not_masked_to_bytes(): + """The bench decoder keeps 8 bits of every word; `spi_decode_words` keeps all of them.""" + clk, mosi, miso, cs = waveform([[(0xABC, 0x123)]], 12) + assert spi_decode_words(clk, mosi, miso, cs, bits=12)[0].mosi == [0xABC] + assert analysis.spi_decode(clk, mosi, miso, cs, bits_per_word=12)[0].mosi == bytearray([0xBC]) + + +def test_regions_follow_the_chip_select_and_join(): + clk, mosi, miso, cs = waveform([[(1, 2), (3, 4)], [(5, 6)]], 16) + regions = spi_decode_words(clk, mosi, miso, cs, bits=16) + assert [region.mosi for region in regions] == [[1, 3], [5]] + assert spi_join_words(regions) == ([1, 3, 5], [2, 4, 6]) + + +def test_without_chip_select_the_capture_is_one_region_and_a_partial_word_is_dropped(): + clk, mosi, miso, _ = waveform([[(0x9, 0x6), (0xF, 0x0)]], 4) + regions = spi_decode_words(clk, mosi, miso, bits=8) + assert len(regions) == 1 + assert regions[0].mosi == [0x9F] + assert regions[0].clock_edges == 8 + assert spi_decode_words(clk, mosi, miso, bits=12)[0].mosi == [] + + +def test_miso_defaults_to_zero_without_a_line(): + clk, mosi, _, cs = waveform([[(0x81, 0xFF)]], 8) + assert spi_decode_words(clk, mosi, None, cs)[0].miso == [0] + + +def test_decode_rejects_an_empty_word(): + with pytest.raises(ValueError): + spi_decode_words([0, 1], [0, 0], bits=0) + + +@pytest.mark.parametrize( + ("data", "bits", "frames"), + [ + (b"\x12\xff\x00", 8, [0x12, 0xFF, 0x00]), + (b"\x12\xff\x00", 4, [0x2, 0xF, 0x0]), + (b"\xa5", 7, [0x25]), + (b"\x34\x12\xff\xff", 16, [0x1234, 0xFFFF]), + (b"\x34\x12\xff\xff", 12, [0x234, 0xFFF]), + (b"\xff\x01", 9, [0x1FF]), + (b"", 16, []), + ], +) +def test_frames(data, bits, frames): + assert spi_frames(data, bits) == frames + + +@pytest.mark.parametrize("bits", [9, 12, 16]) +def test_wide_frames_take_byte_pairs(bits): + with pytest.raises(ValueError): + spi_frames(b"\x01\x02\x03", bits) + + +@pytest.mark.parametrize("bits", [BITS_MIN - 1, BITS_MAX + 1]) +def test_frame_sizes_outside_the_driver_range(bits): + with pytest.raises(ValueError): + spi_frames(b"\x00\x00", bits) + with pytest.raises(ValueError): + spi_bytes([0], bits) + + +@pytest.mark.parametrize("bits", range(BITS_MIN, BITS_MAX + 1)) +def test_bytes_invert_frames(bits): + data = bytes((i * 37 + 11) & 0xFF for i in range(8)) + frames = spi_frames(data, bits) + assert spi_frames(spi_bytes(frames, bits), bits) == frames + assert len(spi_bytes(frames, bits)) == len(data) + + +def test_received_frames_are_right_aligned(): + assert spi_bytes([0xF, 0x3], 4) == b"\x0f\x03" + assert spi_bytes([0xFFF], 12) == b"\xff\x0f" + assert spi_bytes([0x1FFFF], 16) == b"\xff\xff"