From 030605094de701e0e4522e6a9af6c23a511c83b8 Mon Sep 17 00:00:00 2001 From: Ilya Date: Tue, 13 Oct 2026 01:08:17 +0100 Subject: [PATCH 01/11] feat: add experimental TBS Mambo target and external CRSF support --- CMakePresets.json | 12 + radio/src/CMakeLists.txt | 6 +- .../boards/generic_stm32/analog_inputs.cpp | 12 + .../linker/mambo/extra_sections.ld | 1 + .../generic_stm32/linker/mambo/layout.ld | 21 ++ .../src/boards/generic_stm32/module_ports.cpp | 5 + radio/src/boards/hw_defs/mambo.json | 326 ++++++++++++++++++ radio/src/edgetx.cpp | 1 + radio/src/gui/128x64/radio_diaganas.cpp | 164 +++++++++ radio/src/gui/gui_common.cpp | 5 + radio/src/pulses/crossfire.cpp | 24 ++ radio/src/pulses/pulses.cpp | 7 + radio/src/sdcard.cpp | 6 + radio/src/storage/yaml/CMakeLists.txt | 2 + .../common/arm/stm32/f4/system_clock.c | 7 + .../targets/common/arm/stm32/pwr_driver.cpp | 4 + .../common/arm/stm32/stm32_usart_driver.cpp | 10 + radio/src/targets/mambo/CMakeLists.txt | 17 + radio/src/targets/mambo/calibration.cpp | 238 +++++++++++++ radio/src/targets/mambo/calibration.h | 37 ++ radio/src/targets/mambo/diagnostics.h | 26 ++ radio/src/targets/mambo/external_rf.cpp | 67 ++++ radio/src/targets/mambo/external_rf.h | 18 + radio/src/targets/mambo/gimbals.cpp | 191 ++++++++++ radio/src/targets/mambo/gimbals.h | 62 ++++ radio/src/targets/mambo/gimbals_stm32.cpp | 187 ++++++++++ radio/src/targets/mambo/hal.h | 109 ++++++ radio/src/targets/mambo/inputs.cpp | 30 ++ radio/src/targets/mambo/inputs.h | 24 ++ radio/src/targets/taranis/CMakeLists.txt | 41 ++- radio/src/targets/taranis/board.cpp | 7 + radio/src/targets/taranis/board.h | 17 + radio/src/targets/taranis/hal.h | 6 + radio/src/targets/taranis/lcd_driver_spi.cpp | 21 ++ radio/util/hw_defs/json_index.py | 3 +- radio/util/hw_defs/legacy_names.py | 2 +- tools/hwdef_schema.json | 2 +- 37 files changed, 1707 insertions(+), 11 deletions(-) create mode 100644 radio/src/boards/generic_stm32/linker/mambo/extra_sections.ld create mode 100644 radio/src/boards/generic_stm32/linker/mambo/layout.ld create mode 100644 radio/src/boards/hw_defs/mambo.json create mode 100644 radio/src/targets/mambo/CMakeLists.txt create mode 100644 radio/src/targets/mambo/calibration.cpp create mode 100644 radio/src/targets/mambo/calibration.h create mode 100644 radio/src/targets/mambo/diagnostics.h create mode 100644 radio/src/targets/mambo/external_rf.cpp create mode 100644 radio/src/targets/mambo/external_rf.h create mode 100644 radio/src/targets/mambo/gimbals.cpp create mode 100644 radio/src/targets/mambo/gimbals.h create mode 100644 radio/src/targets/mambo/gimbals_stm32.cpp create mode 100644 radio/src/targets/mambo/hal.h create mode 100644 radio/src/targets/mambo/inputs.cpp create mode 100644 radio/src/targets/mambo/inputs.h diff --git a/CMakePresets.json b/CMakePresets.json index 19d30545ff5..f11bef21d4c 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -351,6 +351,18 @@ "displayName": "Senduwing H17", "inherits": "firmware", "cacheVariables": { "PCB": "H17" } + }, + { + "name": "mambo", + "displayName": "TBS Mambo experimental (RF disabled)", + "inherits": "firmware", + "cacheVariables": { "PCB": "MAMBO", "BOOTLOADER": false } + }, + { + "name": "mambo-external-crsf", + "displayName": "TBS Mambo experimental external Crossfire (Tracer disabled)", + "inherits": "mambo", + "cacheVariables": { "MAMBO_EXTERNAL_CRSF": true } } ], "buildPresets": [ diff --git a/radio/src/CMakeLists.txt b/radio/src/CMakeLists.txt index 27042a30736..dc0198521ea 100644 --- a/radio/src/CMakeLists.txt +++ b/radio/src/CMakeLists.txt @@ -1,7 +1,7 @@ include(CMakeForceCompiler) include(Bitmaps) -set(PCB_TYPES X9LITE X9LITES X7 XLITE XLITES X9D X9D+ X9E X10 X12S PL18 T15PRO T15H7 T18H7 ST16 PA01 TX15 GX15 TX16SMK3 T22 V12 H17 V15) +set(PCB_TYPES X9LITE X9LITES X7 XLITE XLITES X9D X9D+ X9E X10 X12S PL18 T15PRO T15H7 T18H7 ST16 PA01 TX15 GX15 TX16SMK3 T22 V12 H17 V15 MAMBO) set(RADIO_LANGUAGES CN CZ DA DE EN ES FI FR HE HU IT JP KO NL PL PT RU SE SK TW UA) set(TTS_LANGUAGES CN CZ DA DE EN ES FI FR HE HU IT JP KO NL PL PT RU SE SK TW UA) @@ -116,6 +116,8 @@ elseif(PCB STREQUAL STM32H7S78_DK) include(targets/stm32h7s78-dk/CMakeLists.txt) elseif(PCB STREQUAL X9E OR PCB STREQUAL X9D+ OR PCB STREQUAL X9D OR PCB STREQUAL X7 OR PCB STREQUAL X9LITE OR PCB STREQUAL X9LITES OR PCB STREQUAL XLITE OR PCB STREQUAL XLITES) include(targets/taranis/CMakeLists.txt) +elseif(PCB STREQUAL MAMBO) + include(targets/mambo/CMakeLists.txt) else() message(FATAL_ERROR "Unknown PCB '${PCB}'") endif() @@ -819,7 +821,7 @@ else() ) endif() -if(CPU_FAMILY STREQUAL STM32) +if(CPU_FAMILY STREQUAL STM32 AND NOT PCB STREQUAL MAMBO) # FIRMWARE_QSPI boards can only be firmware-flashed via flash-uf2 below; # the bootloader doesn't expose external QSPI/XSPI flash as a DFU segment. if(NOT FIRMWARE_QSPI) diff --git a/radio/src/boards/generic_stm32/analog_inputs.cpp b/radio/src/boards/generic_stm32/analog_inputs.cpp index 09f8606e19f..ab7d9d4c92f 100644 --- a/radio/src/boards/generic_stm32/analog_inputs.cpp +++ b/radio/src/boards/generic_stm32/analog_inputs.cpp @@ -26,6 +26,9 @@ #include "flysky_gimbal_driver.h" #include "hal.h" +#if defined(RADIO_MAMBO) + #include "targets/mambo/gimbals.h" +#endif #if defined(USE_ADS79XX) #include "ads79xx.h" @@ -59,6 +62,9 @@ static_assert(n_inputs <= MAX_ANALOG_INPUTS, "Too many analog inputs"); static bool adc_init() { bool success = stm32_hal_adc_init(_ADC_adc, n_ADC, _ADC_inputs, _ADC_GPIOs, n_GPIO); +#if defined(RADIO_MAMBO) + mamboGimbalsInit(); +#endif #if defined(USE_ADS79XX) if (n_ADC_spi > 0) ads79xx_init(&_ADC_spi[0]); #endif @@ -67,6 +73,9 @@ static bool adc_init() static void adc_deinit() { +#if defined(RADIO_MAMBO) + mamboGimbalsDeinit(); +#endif stm32_hal_adc_deinit(_ADC_adc, n_ADC); } @@ -89,6 +98,9 @@ static void adc_wait_completion() if (n_ADC_spi > 0) ads79xx_adc_wait_completion(&_ADC_spi[0], _ADC_inputs); #endif stm32_hal_adc_wait_completion(_ADC_adc, n_ADC, _ADC_inputs, n_inputs); +#if defined(RADIO_MAMBO) + mamboGimbalsRead(); +#endif #if defined(SIXPOS_SWITCH_INDEX) sixPosUpdateFromAdc(); #endif diff --git a/radio/src/boards/generic_stm32/linker/mambo/extra_sections.ld b/radio/src/boards/generic_stm32/linker/mambo/extra_sections.ld new file mode 100644 index 00000000000..3a8cae1b529 --- /dev/null +++ b/radio/src/boards/generic_stm32/linker/mambo/extra_sections.ld @@ -0,0 +1 @@ +INCLUDE stm32f40x/extra_sections.ld diff --git a/radio/src/boards/generic_stm32/linker/mambo/layout.ld b/radio/src/boards/generic_stm32/linker/mambo/layout.ld new file mode 100644 index 00000000000..cc2516cb461 --- /dev/null +++ b/radio/src/boards/generic_stm32/linker/mambo/layout.ld @@ -0,0 +1,21 @@ +/* Application-only allocation. Never include original bootloader/RF bytes. */ +STACK_ADDRESS = ORIGIN(CCM) + LENGTH(CCM); +MAIN_STACK_SIZE = 1024; +BOOTLOADER_SIZE = 0; +MIN_HEAP_SIZE = 0; +HEAP_ADDRESS = ORIGIN(RAM) + LENGTH(RAM); + +MEMORY +{ + FLASH (rx) : ORIGIN = 0x0800C000, LENGTH = 720K + RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 84K + CCM (xrw) : ORIGIN = 0x10000400, LENGTH = 63K +} + +REGION_ALIAS("REGION_BOOTLOADER", FLASH); +REGION_ALIAS("REGION_ISR_VECT", FLASH); +REGION_ALIAS("REGION_TEXT", FLASH); +REGION_ALIAS("REGION_TEXT_STORAGE", FLASH); +REGION_ALIAS("REGION_DATA", CCM); +REGION_ALIAS("REGION_BSS", CCM); +REGION_ALIAS("REGION_RAM", RAM); diff --git a/radio/src/boards/generic_stm32/module_ports.cpp b/radio/src/boards/generic_stm32/module_ports.cpp index 981da714115..28615a7a090 100644 --- a/radio/src/boards/generic_stm32/module_ports.cpp +++ b/radio/src/boards/generic_stm32/module_ports.cpp @@ -349,10 +349,15 @@ static void _sport_set_inverted(uint8_t enable) stm32_usart_rx_inversion(&sportUSART, !enable); stm32_usart_tx_inversion(&sportUSART, !enable); #endif +#else +#if defined(TELEMETRY_INVERTER_ACTIVE_HIGH) + gpio_write(TELEMETRY_TX_REV_GPIO, enable); + gpio_write(TELEMETRY_RX_REV_GPIO, enable); #else gpio_write(TELEMETRY_TX_REV_GPIO, !enable); gpio_write(TELEMETRY_RX_REV_GPIO, !enable); #endif +#endif } static void _sport_init_inverter() diff --git a/radio/src/boards/hw_defs/mambo.json b/radio/src/boards/hw_defs/mambo.json new file mode 100644 index 00000000000..27c90a50b56 --- /dev/null +++ b/radio/src/boards/hw_defs/mambo.json @@ -0,0 +1,326 @@ +{ + "identity": { + "manufacturer": "TBS", + "model": "Mambo (experimental)", + "usb_manufacturer": "TBS", + "usb_product": "Mambo" + }, + "adc_inputs": { + "adcs": [ + { + "name": "MAIN", + "adc": "ADC1", + "dma": "DMA2", + "dma_channel": "LL_DMA_CHANNEL_0", + "dma_stream": "LL_DMA_STREAM_4", + "dma_stream_irq": "DMA2_Stream4_IRQn", + "dma_stream_irq_handler": "DMA2_Stream4_IRQHandler", + "sample_time": "LL_ADC_SAMPLINGTIME_28CYCLES" + } + ], + "inputs": [ + { + "name": "LH", + "type": "STICK", + "adc": null + }, + { + "name": "LV", + "type": "STICK", + "adc": null + }, + { + "name": "RV", + "type": "STICK", + "adc": null + }, + { + "name": "RH", + "type": "STICK", + "adc": null + }, + { + "name": "P1", + "type": "FLEX", + "adc": "MAIN", + "gpio": "GPIOC", + "pin": "LL_GPIO_PIN_4", + "channel": "LL_ADC_CHANNEL_14", + "inverted": true, + "default": "POT_CENTER", + "label": "S1", + "short_label": "1" + }, + { + "name": "P2", + "type": "FLEX", + "adc": "MAIN", + "gpio": "GPIOA", + "pin": "LL_GPIO_PIN_2", + "channel": "LL_ADC_CHANNEL_2", + "inverted": true, + "default": "POT_CENTER", + "label": "S2", + "short_label": "2" + }, + { + "name": "RAW1", + "type": "RAW", + "adc": "MAIN", + "gpio": "GPIOC", + "pin": "LL_GPIO_PIN_1", + "channel": "LL_ADC_CHANNEL_11" + }, + { + "name": "VBAT", + "type": "VBAT", + "adc": "MAIN", + "gpio": "GPIOB", + "pin": "LL_GPIO_PIN_1", + "channel": "LL_ADC_CHANNEL_9" + }, + { + "name": "SWA", + "type": "SWITCH", + "adc": "MAIN", + "gpio": "GPIOA", + "pin": "LL_GPIO_PIN_6", + "channel": "LL_ADC_CHANNEL_6" + }, + { + "name": "SWB", + "type": "SWITCH", + "adc": "MAIN", + "gpio": "GPIOA", + "pin": "LL_GPIO_PIN_7", + "channel": "LL_ADC_CHANNEL_7" + }, + { + "name": "SWC", + "type": "SWITCH", + "adc": "MAIN", + "gpio": "GPIOA", + "pin": "LL_GPIO_PIN_5", + "channel": "LL_ADC_CHANNEL_5" + }, + { + "name": "SWD", + "type": "SWITCH", + "adc": "MAIN", + "gpio": "GPIOA", + "pin": "LL_GPIO_PIN_3", + "channel": "LL_ADC_CHANNEL_3" + } + ] + }, + "switches": [ + { + "name": "SA", + "type": "ADC", + "default": "3POS", + "is_cfs": false, + "inverted": false, + "display": [ + 0, + 0 + ], + "adc_input": "SWA" + }, + { + "name": "SB", + "type": "ADC", + "default": "3POS", + "is_cfs": false, + "inverted": false, + "display": [ + 1, + 0 + ], + "adc_input": "SWB" + }, + { + "name": "SC", + "type": "ADC", + "default": "3POS", + "is_cfs": false, + "inverted": false, + "display": [ + 0, + 1 + ], + "adc_input": "SWC" + }, + { + "name": "SD", + "type": "ADC", + "default": "3POS", + "is_cfs": false, + "inverted": false, + "display": [ + 1, + 1 + ], + "adc_input": "SWD" + }, + { + "name": "SE", + "type": "2POS", + "default": "2POS", + "is_cfs": false, + "inverted": false, + "display": [ + 0, + 2 + ], + "gpio": "GPIOE", + "pin": "LL_GPIO_PIN_5" + }, + { + "name": "SF", + "type": "2POS", + "default": "TOGGLE", + "is_cfs": false, + "inverted": false, + "display": [ + 1, + 2 + ], + "gpio": "GPIOE", + "pin": "LL_GPIO_PIN_4" + } + ], + "keys": [ + { + "gpio": "GPIOD", + "pin": "LL_GPIO_PIN_13", + "active_low": true, + "key": "KEY_MENU", + "name": "MENU", + "label": "MENU" + }, + { + "gpio": "GPIOD", + "pin": "LL_GPIO_PIN_14", + "active_low": true, + "key": "KEY_EXIT", + "name": "EXIT", + "label": "EXIT" + }, + { + "gpio": "GPIOD", + "pin": "LL_GPIO_PIN_4", + "active_low": true, + "key": "KEY_ENTER", + "name": "ENTER", + "label": "ENTER" + }, + { + "gpio": "GPIOD", + "pin": "LL_GPIO_PIN_12", + "active_low": true, + "key": "KEY_PAGEDN", + "name": "PAGE", + "label": "PAGE" + }, + { + "gpio": null, + "pin": null, + "active_low": true, + "key": "KEY_PLUS", + "name": "PLUS", + "label": "PLUS" + }, + { + "gpio": null, + "pin": null, + "active_low": true, + "key": "KEY_MINUS", + "name": "MINUS", + "label": "MINUS" + } + ], + "trims": [ + { + "name": "T1" + }, + { + "name": "T2" + }, + { + "name": "T3" + }, + { + "name": "T4" + } + ], + "display": { + "lcd_w": 128, + "lcd_h": 64, + "lcd_phys_w": 128, + "lcd_phys_h": 64, + "lcd_depth": 1, + "lcd_vertical_invert": true, + "lcd_horizontal_invert": true + }, + "hardware": { + "has_audio_mute": true, + "has_ext_module_support": true, + "has_int_module_support": false, + "sport_max_baudrate": 400000, + "cpu": "STM32F407xG", + "cpu_type": "STM32F4" + }, + "timers": { + "cpu_freq": 168000000, + "peri1_frequency": 42000000, + "peri2_frequency": 84000000, + "timer_mult_apb1": 2, + "timer_mult_apb2": 2, + "ms_timer": "TIM14", + "ms_timer_irqn": "TIM8_TRG_COM_TIM14_IRQn", + "ms_timer_irqhandler": "TIM8_TRG_COM_TIM14_IRQHandler", + "mixer_scheduler_timer": "TIM12", + "mixer_scheduler_timer_freq": "(PERI1_FREQUENCY * TIMER_MULT_APB1)", + "mixer_scheduler_timer_irqn": "TIM8_BRK_TIM12_IRQn", + "mixer_scheduler_timer_irqhandler": "TIM8_BRK_TIM12_IRQHandler" + }, + "rotenc": { + "rotary_encoder_inverted": true, + "rotary_encoder_gpio": "GPIOA", + "rotary_encoder_gpio_pin_a": "LL_GPIO_PIN_8", + "rotary_encoder_gpio_pin_b": "LL_GPIO_PIN_10", + "rotary_encoder_position": "(((ROTARY_ENCODER_GPIO->IDR >> 9) & 0x02) + ((ROTARY_ENCODER_GPIO->IDR >> 8) & 0x01))", + "rotary_encoder_exti_line1": "LL_EXTI_LINE_8", + "rotary_encoder_exti_line2": "LL_EXTI_LINE_10", + "exti": [ + { + "irq": "EXTI9_5_IRQ", + "priority": 5 + }, + { + "irq": "EXTI15_10_IRQ", + "priority": 5 + } + ], + "rotary_encoder_exti_port": "LL_SYSCFG_EXTI_PORTA", + "rotary_encoder_exti_sys_line1": "LL_SYSCFG_EXTI_LINE8", + "rotary_encoder_exti_sys_line2": "LL_SYSCFG_EXTI_LINE10", + "rotary_encoder_timer": "TIM10", + "rotary_encoder_timer_irqn": "TIM1_UP_TIM10_IRQn", + "rotary_encoder_timer_irqhandler": "TIM1_UP_TIM10_IRQHandler" + }, + "haptic": { + "haptic_pwm": false, + "haptic_gpio": "GPIO_PIN(GPIOB, 0)" + }, + "usb": { + "usb_gpio_vbus": "GPIO_PIN(GPIOA, 9)", + "usb_gpio_dm": "GPIO_PIN(GPIOA, 11)", + "usb_gpio_dp": "GPIO_PIN(GPIOA, 12)", + "usb_gpio_af": "GPIO_AF10" + }, + "key_lock_combo": [ + "KEY_MENU", + "KEY_ENTER" + ] +} diff --git a/radio/src/edgetx.cpp b/radio/src/edgetx.cpp index 66da92e42ba..1851f67ddd0 100644 --- a/radio/src/edgetx.cpp +++ b/radio/src/edgetx.cpp @@ -799,6 +799,7 @@ void checkMultiLowPower() static void checkRTCBattery() { + if (adcGetMaxInputs(ADC_INPUT_RTC_BAT) < 1) return; if (!mixerTaskRunning()) getADC(); if (getRTCBatteryVoltage() < 200) { ALERT(STR_BATTERY, STR_WARN_RTC_BATTERY_LOW, AU_ERROR); diff --git a/radio/src/gui/128x64/radio_diaganas.cpp b/radio/src/gui/128x64/radio_diaganas.cpp index fbe422e458f..8b03f866e55 100644 --- a/radio/src/gui/128x64/radio_diaganas.cpp +++ b/radio/src/gui/128x64/radio_diaganas.cpp @@ -21,6 +21,141 @@ #include "edgetx.h" #include "hal/adc_driver.h" +#if defined(RADIO_MAMBO) +#include "targets/mambo/diagnostics.h" +#include "tasks.h" +#include "tasks/mixer_task.h" +#include "timers_driver.h" +#if defined(MAMBO_EXTERNAL_CRSF) +#include "hal/module_port.h" +#include "targets/mambo/external_rf.h" +#endif + +static const char* mamboCalibrationLabel(MamboCalibrationStatus status) +{ + switch (status) { + case MamboCalibrationStatus::Loaded: + return "SD OK"; + case MamboCalibrationStatus::Invalid: + return "INVALID"; + default: + return "MISSING"; + } +} + +static void drawMamboHall(unsigned sensor) +{ + auto data = mamboBenchDiagnostics(); + const auto& sample = data.sensors[sensor]; + const char* health = "WAIT"; + switch (mambo::hallHealth(sample, timersGetUsTick())) { + case mambo::HallHealth::Fresh: + health = "OK"; + break; + case mambo::HallHealth::Stale: + health = "OLD"; + break; + case mambo::HallHealth::Error: + health = "ERR"; + break; + default: + break; + } + coord_t y = MENU_HEADER_HEIGHT + 1; + lcdDrawText(0, y, "Cal:"); + lcdDrawText(24, y, mamboCalibrationLabel(data.calibration)); + lcdDrawText(96, y, health); + const int32_t values[3] = {sample.x, sample.y, sample.z}; + const char* labels[3] = {"X:", "Y:", "Z:"}; + for (unsigned i = 0; i < 3; ++i) { + y += FH; + lcdDrawText(0, y, labels[i]); + lcdDrawNumber(60, y, values[i], RIGHT); + if (i == 0) { + lcdDrawText(66, y, "Age"); + uint32_t age = (sample.count || sample.valid) + ? uint32_t(timersGetUsTick() - sample.timestamp) / 1000 + : 0; + lcdDrawNumber(128, y, age > 9999 ? 9999 : age, RIGHT); + } else if (i == 1) { + lcdDrawText(66, y, "Err"); + lcdDrawNumber(128, y, sample.errors > 9999 ? 9999 : sample.errors, RIGHT); + } else { + lcdDrawText(66, y, "St"); + lcdDrawHexNumber(99, y, sample.status); + } + } + y += FH; + lcdDrawText(0, y, "S:"); + lcdDrawNumber(60, y, sample.count > 999999 ? 999999 : sample.count, RIGHT); + lcdDrawText(66, y, "F:"); + lcdDrawHexNumber(99, y, + (uint16_t(sample.lastFault) << 8) | sample.faultStatus); + y += FH; + lcdDrawText(0, y, "H:"); + lcdDrawNumber(60, y, data.sticks[sensor ? 3 : 0], RIGHT); + lcdDrawText(66, y, "V:"); + lcdDrawNumber(128, y, data.sticks[sensor ? 2 : 1], RIGHT); +} + +static void drawMamboBench() +{ + auto data = mamboBenchDiagnostics(); + coord_t y = MENU_HEADER_HEIGHT + 1; +#if defined(MAMBO_EXTERNAL_CRSF) + lcdDrawText( + 0, y, + modulePortPowered(EXTERNAL_MODULE) ? "Ext ON Cal:" : "Ext OFF Cal:"); +#else + lcdDrawText(0, y, "RF OFF Cal:"); +#endif + lcdDrawText(78, y, mamboCalibrationLabel(data.calibration)); + y += FH; + lcdDrawText(0, y, "Hall us:"); + lcdDrawNumber(128, y, data.lastDurationUs, RIGHT); + y += FH; + lcdDrawText(0, y, "Max us:"); + lcdDrawNumber(128, y, data.maxDurationUs, RIGHT); + y += FH; + lcdDrawText(0, y, "Frames:"); + lcdDrawNumber(128, y, data.frames, RIGHT); + y += FH; + lcdDrawText(0, y, "Free M/U:"); + lcdDrawNumber(87, y, task_get_stack_usage(&mixerTaskId), RIGHT); + lcdDrawText(89, y, "/"); + lcdDrawNumber(128, y, task_get_stack_usage(&menusTaskId), RIGHT); + y += FH; + lcdDrawText(0, y, "Reset mask:"); + lcdDrawHexNumber(99, y, data.resetFlags >> 24); +} +#if defined(MAMBO_EXTERNAL_CRSF) +static void drawMamboExternal() +{ + auto data = mamboExternalDiagnostics(); + coord_t y = MENU_HEADER_HEIGHT + 1; + lcdDrawText(0, y, data.powered ? "Ext ON" : "Ext OFF"); + lcdDrawText(66, y, (data.pins & 4) ? "DIR OUT" : "DIR IN"); + const uint32_t left[] = {data.txFrames, data.uartErrors, data.parseEvents, + data.crcErrors}; + const uint32_t right[] = {data.rxBytes, data.idleEvents, data.validFrames, + data.headerErrors}; + const char* leftLabels[] = {"TX:", "Err:", "Parse:", "CRC:"}; + const char* rightLabels[] = {"RX:", "Idle:", "Good:", "Hdr:"}; + for (unsigned i = 0; i < 4; ++i) { + y += FH; + lcdDrawText(0, y, leftLabels[i]); + lcdDrawNumber(60, y, left[i] > 99999 ? 99999 : left[i], RIGHT); + lcdDrawText(66, y, rightLabels[i]); + lcdDrawNumber(128, y, right[i] > 99999 ? 99999 : right[i], RIGHT); + } + y += FH; + lcdDrawText(0, y, "T/R:"); + lcdDrawHexNumber(30, y, (uint16_t(data.lastTxType) << 8) | data.lastRxType); + lcdDrawText(66, y, "Pins:"); + lcdDrawNumber(128, y, data.pins, RIGHT); +} +#endif +#endif #define HOLDANAVALUEFRAMES 4 /* 4* 50ms = 200 ms update rate */ @@ -33,7 +168,19 @@ void menuRadioDiagAnalogs(event_t event) ANAVIEW_FIRST, ANAVIEW_CALIB=ANAVIEW_FIRST, ANAVIEW_RAWLOWFPS, +#if defined(RADIO_MAMBO) + ANAVIEW_MAMBO_LEFT, + ANAVIEW_MAMBO_RIGHT, + ANAVIEW_MAMBO_BENCH, +#if defined(MAMBO_EXTERNAL_CRSF) + ANAVIEW_MAMBO_EXTERNAL, + ANAVIEW_LAST=ANAVIEW_MAMBO_EXTERNAL +#else + ANAVIEW_LAST=ANAVIEW_MAMBO_BENCH +#endif +#else ANAVIEW_LAST=ANAVIEW_RAWLOWFPS +#endif }; static int viewpage = ANAVIEW_FIRST; @@ -71,6 +218,23 @@ void menuRadioDiagAnalogs(event_t event) SIMPLE_SUBMENU(STR_MENU_RADIO_ANALOGS_RAWLOWFPS, HEADER_LINE+1); break; +#if defined(RADIO_MAMBO) + case ANAVIEW_MAMBO_LEFT: + case ANAVIEW_MAMBO_RIGHT: + SIMPLE_SUBMENU(viewpage == ANAVIEW_MAMBO_LEFT ? "Mambo Hall L" : "Mambo Hall R", HEADER_LINE+1); + drawMamboHall(viewpage == ANAVIEW_MAMBO_LEFT ? 0 : 1); + return; + case ANAVIEW_MAMBO_BENCH: + SIMPLE_SUBMENU("Mambo Bench", HEADER_LINE+1); + drawMamboBench(); + return; +#if defined(MAMBO_EXTERNAL_CRSF) + case ANAVIEW_MAMBO_EXTERNAL: + SIMPLE_SUBMENU("Mambo External", HEADER_LINE+1); + drawMamboExternal(); + return; +#endif +#endif } coord_t y = MENU_HEADER_HEIGHT + 1; diff --git a/radio/src/gui/gui_common.cpp b/radio/src/gui/gui_common.cpp index 35fcd789568..82a0ae8cef5 100644 --- a/radio/src/gui/gui_common.cpp +++ b/radio/src/gui/gui_common.cpp @@ -1041,6 +1041,11 @@ bool isInternalModuleAvailable(int moduleType) #if defined(HARDWARE_EXTERNAL_MODULE) bool isExternalModuleAvailable(int moduleType) { +#if defined(MAMBO_EXTERNAL_CRSF) + // Only the stock CRSF telemetry path is implemented on this target. + if (moduleType != MODULE_TYPE_NONE && moduleType != MODULE_TYPE_CROSSFIRE) + return false; +#endif #if defined(MUTUALLY_EXCLUSIVE_MODULES) if (!isModuleNone(INTERNAL_MODULE)) diff --git a/radio/src/pulses/crossfire.cpp b/radio/src/pulses/crossfire.cpp index e4914624dfa..355f9f8fb2b 100644 --- a/radio/src/pulses/crossfire.cpp +++ b/radio/src/pulses/crossfire.cpp @@ -31,6 +31,9 @@ #include "crossfire.h" #include "telemetry/crossfire.h" +#if defined(MAMBO_EXTERNAL_CRSF) +#include "targets/mambo/external_rf.h" +#endif #define CROSSFIRE_CH_BITS 11 #define CROSSFIRE_CENTER 0x3E0 @@ -238,6 +241,10 @@ static void crossfireSendPulses(void* ctx, uint8_t* buffer, int16_t* channels, u auto drv = modulePortGetSerialDrv(mod_st->tx); auto drv_ctx = modulePortGetCtx(mod_st->tx); drv->sendBuffer(drv_ctx, buffer, p_buf - buffer); +#if defined(MAMBO_EXTERNAL_CRSF) + if (module == EXTERNAL_MODULE && p_buf - buffer >= MIN_FRAME_LEN) + mamboExternalTxObserved(buffer[2]); +#endif } static bool _lenIsSane(uint8_t len) @@ -255,6 +262,10 @@ static bool _validHdr(uint8_t* buf) static uint8_t* _processFrames(void* ctx, uint8_t* buf, uint8_t& len) { +#if defined(MAMBO_EXTERNAL_CRSF) + bool mamboExternal = modulePortGetModule((etx_module_state_t*)ctx) == EXTERNAL_MODULE; + if (mamboExternal) mamboExternalParseObserved(); +#endif uint8_t* p_buf = buf; while (len >= MIN_FRAME_LEN) { // Bad telemetry packets frequently are just missing a byte, with the start @@ -263,6 +274,9 @@ static uint8_t* _processFrames(void* ctx, uint8_t* buf, uint8_t& len) // the buffer by one instead of pkt_len or throwing it all out uint8_t declared_len = p_buf[1]; if (!_validHdr(p_buf) || !_lenIsSane(declared_len)) { +#if defined(MAMBO_EXTERNAL_CRSF) + if (mamboExternal) mamboExternalHeaderErrorObserved(); +#endif p_buf++; len--; continue; } @@ -274,6 +288,9 @@ static uint8_t* _processFrames(void* ctx, uint8_t* buf, uint8_t& len) } if (!_checkFrameCRC(p_buf)) { +#if defined(MAMBO_EXTERNAL_CRSF) + if (mamboExternal) mamboExternalCrcErrorObserved(); +#endif TRACE("[XF] CRC error"); p_buf++; len--; continue; @@ -289,6 +306,9 @@ static uint8_t* _processFrames(void* ctx, uint8_t* buf, uint8_t& len) auto mod_st = (etx_module_state_t*)ctx; auto module = modulePortGetModule(mod_st); lastAlive[module] = get_tmr10ms(); // valid frame received, note timestamp +#if defined(MAMBO_EXTERNAL_CRSF) + if (mamboExternal) mamboExternalFrameObserved(p_buf[2]); +#endif processCrossfireTelemetryFrame(module, p_buf, pkt_len); p_buf += pkt_len; @@ -307,6 +327,10 @@ static void crossfireProcessFrame(void* ctx, uint8_t* frame, uint8_t frame_len, if (frame_len == 0) return; if (!_validHdr(frame)) { +#if defined(MAMBO_EXTERNAL_CRSF) + if (modulePortGetModule((etx_module_state_t*)ctx) == EXTERNAL_MODULE) + mamboExternalHeaderErrorObserved(); +#endif TRACE("[XF] invalid frame start"); return; } diff --git a/radio/src/pulses/pulses.cpp b/radio/src/pulses/pulses.cpp index 93406ac076f..154f502667f 100644 --- a/radio/src/pulses/pulses.cpp +++ b/radio/src/pulses/pulses.cpp @@ -288,6 +288,13 @@ uint8_t getModuleType(uint8_t module) uint8_t getRequiredProtocol(uint8_t module) { uint8_t protocol = PROTOCOL_CHANNELS_UNINITIALIZED; +#if defined(MAMBO_EXTERNAL_CRSF) + // Reject unsupported protocols in persisted/imported models as well as UI. + if (module != EXTERNAL_MODULE || + (getModuleType(module) != MODULE_TYPE_NONE && + getModuleType(module) != MODULE_TYPE_CROSSFIRE)) + return PROTOCOL_CHANNELS_NONE; +#endif switch (getModuleType(module)) { case MODULE_TYPE_PPM: diff --git a/radio/src/sdcard.cpp b/radio/src/sdcard.cpp index c3bb222cc42..e0adbf0174e 100644 --- a/radio/src/sdcard.cpp +++ b/radio/src/sdcard.cpp @@ -27,6 +27,9 @@ #include "edgetx.h" #include "lib_file.h" +#if defined(RADIO_MAMBO) + #include "targets/mambo/calibration.h" +#endif #if FF_MAX_SS != FF_MIN_SS #error "Variable sector size is not supported" @@ -517,6 +520,9 @@ void sdMount() // call sdGetFreeSectors() now because f_getfree() takes a long time first time it's called _g_FATFS_init = true; sdGetFreeSectors(); +#if defined(RADIO_MAMBO) + mamboGimbalsLoadCalibration(); +#endif #if defined(LOG_TELEMETRY) f_open(&g_telemetryFile, LOGS_PATH "/telemetry.log", FA_OPEN_ALWAYS | FA_WRITE); diff --git a/radio/src/storage/yaml/CMakeLists.txt b/radio/src/storage/yaml/CMakeLists.txt index c2d8d3b261c..2e45a50cd6e 100644 --- a/radio/src/storage/yaml/CMakeLists.txt +++ b/radio/src/storage/yaml/CMakeLists.txt @@ -58,6 +58,8 @@ elseif(PCB STREQUAL TX16SMK3) set(YAML_GEN_OUTPUT storage/yaml/yaml_datastructs_tx16smk3.cpp) elseif(PCB STREQUAL H17) set(YAML_GEN_OUTPUT storage/yaml/yaml_datastructs_h17.cpp) +elseif(PCB STREQUAL MAMBO) + set(YAML_GEN_OUTPUT storage/yaml/yaml_datastructs_128x64.cpp) elseif(PCB STREQUAL X7) if(PCBREV STREQUAL TPRO) set(YAML_GEN_OUTPUT storage/yaml/yaml_datastructs_tpro.cpp) diff --git a/radio/src/targets/common/arm/stm32/f4/system_clock.c b/radio/src/targets/common/arm/stm32/f4/system_clock.c index 60f89b4613b..2e7c4b853bd 100644 --- a/radio/src/targets/common/arm/stm32/f4/system_clock.c +++ b/radio/src/targets/common/arm/stm32/f4/system_clock.c @@ -24,8 +24,15 @@ #include "stm32f4xx_ll_system.h" #include "stm32f4xx_ll_bus.h" +#if HSE_VALUE == 12000000 #define PLL_M LL_RCC_PLLM_DIV_12 #define PLL_N 336 +#elif HSE_VALUE == 8000000 +#define PLL_M LL_RCC_PLLM_DIV_4 +#define PLL_N 168 +#else +#error "Unsupported STM32F4 HSE frequency" +#endif #define PLL_Q LL_RCC_PLLQ_DIV_7 // #define PLL_P LL_RCC_PLLP_DIV_2 diff --git a/radio/src/targets/common/arm/stm32/pwr_driver.cpp b/radio/src/targets/common/arm/stm32/pwr_driver.cpp index 356e2ddc812..4c627c68dcb 100644 --- a/radio/src/targets/common/arm/stm32/pwr_driver.cpp +++ b/radio/src/targets/common/arm/stm32/pwr_driver.cpp @@ -139,7 +139,11 @@ bool pwrPressed() #endif #elif defined(PWR_SWITCH_GPIO) +#if defined(PWR_SWITCH_PIN_ACTIVE_HIGH) + return gpio_read(PWR_SWITCH_GPIO); +#else return !gpio_read(PWR_SWITCH_GPIO); +#endif #else return true; #endif diff --git a/radio/src/targets/common/arm/stm32/stm32_usart_driver.cpp b/radio/src/targets/common/arm/stm32/stm32_usart_driver.cpp index e4d394e06fb..7fb6de5de22 100644 --- a/radio/src/targets/common/arm/stm32/stm32_usart_driver.cpp +++ b/radio/src/targets/common/arm/stm32/stm32_usart_driver.cpp @@ -27,6 +27,9 @@ #include "stm32_hal_ll.h" #include +#if defined(MAMBO_EXTERNAL_CRSF) +#include "targets/mambo/external_rf.h" +#endif #if defined(STM32H7) || defined(STM32H7RS) #define IS_HALF_DUPLEX(usart) ((usart)->set_input || (usart)->rxGPIO == GPIO_UNDEF) @@ -774,6 +777,10 @@ void stm32_usart_isr(const stm32_usart_t* usart, etx_serial_callbacks_t* cb) // This will clear the RXNE/error bits in USART_SR register uint8_t data = LL_USART_ReceiveData8(usart->USARTx); +#if defined(MAMBO_EXTERNAL_CRSF) + if (usart->USARTx == USART2) + mamboExternalRxObserved(data, (status & USART_FLAG_ERRORS) != 0); +#endif if (status & USART_FLAG_ERRORS) { #if defined(STM32H7) || defined(STM32H7RS) @@ -804,6 +811,9 @@ void stm32_usart_isr(const stm32_usart_t* usart, etx_serial_callbacks_t* cb) } if (LL_USART_IsEnabledIT_IDLE(usart->USARTx) && idle) { +#if defined(MAMBO_EXTERNAL_CRSF) + if (usart->USARTx == USART2) mamboExternalIdleObserved(); +#endif // ISR clear sequence status = LL_USART_ReceiveData8(usart->USARTx); LL_USART_ClearFlag_IDLE(usart->USARTx); diff --git a/radio/src/targets/mambo/CMakeLists.txt b/radio/src/targets/mambo/CMakeLists.txt new file mode 100644 index 00000000000..49ad307f728 --- /dev/null +++ b/radio/src/targets/mambo/CMakeLists.txt @@ -0,0 +1,17 @@ +# Experimental application-only Mambo target. The existing TBS bootloader and +# RF firmware are not built or included; internal Tracer remains disabled. +option(MAMBO_EXTERNAL_CRSF "Experimental Mambo external Crossfire bay" OFF) +if(MAMBO_EXTERNAL_CRSF) + add_definitions(-DMAMBO_EXTERNAL_CRSF) +endif() +set(BOOTLOADER OFF CACHE BOOL "Preserve original TBS bootloader" FORCE) +set(FWDRIVE OFF CACHE BOOL "Do not expose a firmware-write USB drive" FORCE) +set(FIRMWARE_STRIP_BOOTLOADER YES) +set(RTC_BACKUP_RAM OFF CACHE BOOL "Preserve TBS bootloader backup SRAM metadata" FORCE) +set(TEST_BUILD_WARNING ON CACHE BOOL "Experimental Mambo bench build" FORCE) +include(targets/taranis/CMakeLists.txt) +target_sources(board PRIVATE targets/mambo/inputs.cpp targets/mambo/gimbals.cpp + targets/mambo/gimbals_stm32.cpp targets/mambo/calibration.cpp) +if(MAMBO_EXTERNAL_CRSF) + target_sources(board PRIVATE targets/mambo/external_rf.cpp) +endif() diff --git a/radio/src/targets/mambo/calibration.cpp b/radio/src/targets/mambo/calibration.cpp new file mode 100644 index 00000000000..0e100fe7ead --- /dev/null +++ b/radio/src/targets/mambo/calibration.cpp @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: GPL-2.0-only +#include "calibration.h" + +#include +#include +#include + +namespace mambo +{ +namespace +{ +uint16_t word(const uint8_t* data, unsigned index) +{ + return uint16_t(data[index * 2]) | (uint16_t(data[index * 2 + 1]) << 8); +} +int signed16(uint16_t value) +{ + return value < 32768 ? value : int(value) - 65536; +} +int signed8(uint8_t value) { return value < 128 ? value : int(value) - 256; } +uint32_t reflectedCrc(const uint8_t* data, size_t size) +{ + uint32_t crc = 0xffffffff; + for (size_t i = 0; i < size; ++i) { + crc ^= data[i]; + for (unsigned bit = 0; bit < 8; ++bit) + crc = (crc >> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + return crc; // no final XOR, as used for corner records +} +uint32_t reverse32(uint32_t value) +{ + uint32_t result = 0; + for (unsigned i = 0; i < 32; ++i) { + result = (result << 1) | (value & 1); + value >>= 1; + } + return result; +} +uint32_t legacyCrc(const uint8_t* data, size_t size) +{ + uint32_t crc = 0xffffffff; + for (size_t i = 0; i < size; ++i) { + uint8_t byte = reverse32(data[i]) >> 24; + for (unsigned bit = 0; bit < 8; ++bit) { + bool condition = (crc & 0x80000000) || (byte & 0x80); + crc = (crc << 1) ^ (condition ? 0x04c11db7 : 0); + byte <<= 1; + } + } + return reverse32(crc) ^ 0xffffffff; +} +uint16_t angleCrc(const uint8_t* data, unsigned base) +{ + uint16_t crc = 0xffff; + for (unsigned i = 0; i < 10; ++i) { + auto value = word(data, base + i); + for (unsigned byte : {unsigned(value >> 8), unsigned(value & 255)}) { + crc ^= byte; + for (unsigned bit = 0; bit < 8; ++bit) + crc = (crc >> 1) ^ ((crc & 1) ? 0x8408 : 0); + } + } + return crc; +} +int cross(CalibrationPoint a, CalibrationPoint b) +{ + return int(a.y) * b.x - int(a.x) * b.y; +} +CalibrationPoint relative(CalibrationPoint point, CalibrationPoint origin) +{ + return {int16_t(point.x - origin.x), int16_t(point.y - origin.y)}; +} +bool buildMap(GimbalCorrection& correction, unsigned q) +{ + constexpr unsigned indices[4][3] = { + {1, 2, 3}, {5, 4, 3}, {5, 6, 7}, {1, 8, 7}}; + const int sx = (q == 1 || q == 2) ? -1 : 1; + const int sy = q >= 2 ? -1 : 1; + float p[3][2]; + for (unsigned i = 0; i < 3; ++i) { + auto point = + relative(correction.points[indices[q][i]], correction.points[0]); + p[i][0] = float(point.x) * sx; + p[i][1] = float(point.y) * sy; + } + // Homography fixing the origin and mapping side/diagonal/side to + // (1,0)/(1,1)/(0,1), equivalent to the stock inverse-matrix construction. + float det = p[0][0] * p[2][1] - p[0][1] * p[2][0]; + if (det <= 1.0f) return false; + float u = (p[1][0] * p[2][1] - p[1][1] * p[2][0]) / det; + float v = (p[0][0] * p[1][1] - p[0][1] * p[1][0]) / det; + float sum = u + v - 1.0f; + if (u <= 0.01f || v <= 0.01f || sum <= 0.01f) return false; + float a = sum / u, b = sum / v; + auto m = correction.maps[q]; + float ix = p[2][1] / det, iy = -p[2][0] / det; + float jx = -p[0][1] / det, jy = p[0][0] / det; + m[0] = a * ix; + m[1] = a * iy; + m[2] = b * jx; + m[3] = b * jy; + m[4] = (a - 1) * ix + (b - 1) * jx; + m[5] = (a - 1) * iy + (b - 1) * jy; + for (unsigned i = 0; i < 6; ++i) + if (!std::isfinite(m[i])) return false; + return true; +} +float axisAngle(float primary, float secondary, float z, + const AxisCorrection& axis) +{ + float side = (secondary - axis.denominatorCrossZ * z) * axis.ratio; + float numerator = primary - axis.numeratorCrossZ * z; + // Stock atan2f receives sqrt(...) as its first argument, the corrected + // primary component as its second. The neutral angle is near 90 degrees. + return std::atan2(std::sqrt(z * z + side * side), numerator) * 180.0f / + 3.14159265358979323846f; +} +bool scaledField(int32_t raw, float scale, float& value) +{ + if (raw < -32768 || raw > 32767) return false; + value = std::trunc(raw * scale); + // Stock narrows to int16. Reject overflow rather than wrap a magnetic field. + return value >= -32768 && value <= 32767; +} +} // namespace + +bool decodeFactoryCalibration(const uint8_t* data, size_t size, + FactoryCalibration& output) +{ + if (!data || size < 604) return false; + uint32_t stored = word(data, 300) | (uint32_t(word(data, 301)) << 16); + if (stored != (reflectedCrc(data, 600) ^ 0xffffffff) && + stored != legacyCrc(data, 600)) + return false; + FactoryCalibration candidate{}; + for (unsigned sensor = 0; sensor < 2; ++sensor) { + unsigned base = 0x78 + sensor * 11, corner = 0xa0 + sensor * 20; + if (word(data, base + 10) != angleCrc(data, base)) return false; + uint32_t cornerStored = + (uint32_t(word(data, corner + 18)) << 16) | word(data, corner + 19); + if (cornerStored != reflectedCrc(data + corner * 2, 36)) return false; + auto& correction = candidate.gimbals[sensor]; + for (unsigned i = 0; i < 2; ++i) { + unsigned axisBase = base + i * 4; + auto& axis = correction.axes[i]; + auto packed = word(data, axisBase + 1); + axis = {signed16(word(data, axisBase)) / 1000.0f, + signed8(packed >> 8) / 256.0f, + signed8(packed & 255) / 256.0f, + signed16(word(data, axisBase + 2)) / 1000.0f, + signed16(word(data, axisBase + 3)) / 1000.0f, + word(data, base + 8 + i) / 100.0f}; + if (axis.ratio <= 0 || axis.positiveGain == 0 || axis.negativeGain == 0 || + axis.centre > 180) + return false; + } + for (unsigned i = 0; i < 9; ++i) { + correction.points[i] = { + int16_t(signed16(word(data, corner + 2 * i))), + int16_t(signed16(word(data, corner + 2 * i + 1)))}; + // Subtraction and stock cross products must stay within their ranges. + if (std::abs(int(correction.points[i].x)) > 16000 || + std::abs(int(correction.points[i].y)) > 16000) + return false; + } + for (unsigned q = 0; q < 4; ++q) + if (!buildMap(correction, q)) return false; + } + output = candidate; + return true; +} + +bool correctGimbalPlane(const GimbalCorrection& correction, int16_t x, + int16_t y, float& correctedX, float& correctedY) +{ + if (std::abs(int(x)) > 16000 || std::abs(int(y)) > 16000) return false; + auto point = relative({x, y}, correction.points[0]); + auto side1 = relative(correction.points[1], correction.points[0]); + auto side3 = relative(correction.points[3], correction.points[0]); + auto side5 = relative(correction.points[5], correction.points[0]); + auto side7 = relative(correction.points[7], correction.points[0]); + unsigned q; + // Preserve stock boundary comparisons at 0x080c46a0, including equality. + if (cross(point, side1) >= 0) { + if (cross(point, side3) <= 0) + q = 0; + else + q = cross(point, side5) <= 0 ? 1 : 2; + } else if (cross(point, side7) >= 0) + q = 3; + else + q = cross(point, side5) >= 0 ? 2 : 1; + int sx = (q == 1 || q == 2) ? -1 : 1, sy = q >= 2 ? -1 : 1; + float px = float(point.x) * sx, py = float(point.y) * sy; + const auto m = correction.maps[q]; + float denominator = m[4] * px + m[5] * py + 1; + if (!std::isfinite(denominator) || denominator <= 0.0001f) return false; + correctedX = sx * 10000.0f * (m[0] * px + m[1] * py) / denominator; + correctedY = sy * 10000.0f * (m[2] * px + m[3] * py) / denominator; + return std::isfinite(correctedX) && std::isfinite(correctedY); +} + +bool calibratedHall(const HallSample& sample, + const GimbalCorrection& correction, uint16_t& x, + uint16_t& y) +{ + if (!sample.valid || (!sample.x && !sample.y && !sample.z)) return false; + float bx, by, bz; + if (!scaledField(sample.x, 1.573f, bx) || + !scaledField(sample.y, 1.573f, by) || !scaledField(sample.z, 2.534f, bz)) + return false; + float angles[2] = {axisAngle(bx, by, bz, correction.axes[0]), + axisAngle(by, bx, bz, correction.axes[1])}; + int16_t plane[2]; + for (unsigned i = 0; i < 2; ++i) { + const auto& axis = correction.axes[i]; + float delta = angles[i] - axis.centre; + float value = + delta * (delta > 0 ? axis.positiveGain : axis.negativeGain) * 100; + if (!std::isfinite(value) || value < -16000 || value > 16000) return false; + plane[i] = int16_t(value); // truncate, as in captured VCVT/STRH sequence + } + float correctedX, correctedY; + if (!correctGimbalPlane(correction, plane[0], plane[1], correctedX, + correctedY)) + return false; + // Match stock truncation before adapting its +/-10000 range to EdgeTX ADC. + auto adc = [](float value) { + if (value > 10000) value = 10000; + if (value < -10000) value = -10000; + return uint16_t(std::lround(2047.5f + int(value) * (4095.0f / 20000.0f))); + }; + x = adc(correctedX); + y = adc(correctedY); + return true; +} +} // namespace mambo diff --git a/radio/src/targets/mambo/calibration.h b/radio/src/targets/mambo/calibration.h new file mode 100644 index 00000000000..148dc78f720 --- /dev/null +++ b/radio/src/targets/mambo/calibration.h @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once +#include "gimbals.h" + +namespace mambo +{ +struct AxisCorrection { + float ratio, denominatorCrossZ, numeratorCrossZ; + float positiveGain, negativeGain, centre; +}; +struct CalibrationPoint { + int16_t x, y; +}; +struct GimbalCorrection { + AxisCorrection axes[2]; + CalibrationPoint points[9]; + // Four projective maps, each taking signed local X/Y to a unit square. + float maps[4][6]; +}; +struct FactoryCalibration { + GimbalCorrection gimbals[2]; +}; + +// Captured Tracer 6.19 profile only. Strict current format: file CRC and both +// angle/quadrant CRCs must validate. On failure, output is left unchanged. +bool decodeFactoryCalibration(const uint8_t* data, size_t size, + FactoryCalibration& output); +bool correctGimbalPlane(const GimbalCorrection& correction, int16_t x, + int16_t y, float& correctedX, float& correctedY); +bool calibratedHall(const HallSample& sample, + const GimbalCorrection& correction, uint16_t& x, + uint16_t& y); +} // namespace mambo + +enum class MamboCalibrationStatus { Missing, Invalid, Loaded }; +void mamboGimbalsLoadCalibration(); +MamboCalibrationStatus mamboGimbalsCalibrationStatus(); diff --git a/radio/src/targets/mambo/diagnostics.h b/radio/src/targets/mambo/diagnostics.h new file mode 100644 index 00000000000..950ed972e6d --- /dev/null +++ b/radio/src/targets/mambo/diagnostics.h @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once +#include "calibration.h" + +namespace mambo +{ +enum class HallHealth { Waiting, Fresh, Stale, Error }; +inline HallHealth hallHealth(const HallSample& sample, uint32_t nowUs) +{ + if (!sample.valid) + return sample.errors ? HallHealth::Error : HallHealth::Waiting; + return uint32_t(nowUs - sample.timestamp) < 20000 ? HallHealth::Fresh + : HallHealth::Stale; +} +struct BenchDiagnostics { + HallSample sensors[2]; + uint16_t sticks[4] = {2048, 2048, 2048, 2048}; + uint32_t frames = 0; + uint32_t lastDurationUs = 0, maxDurationUs = 0; + uint32_t resetFlags = 0; + MamboCalibrationStatus calibration = MamboCalibrationStatus::Missing; +}; +} // namespace mambo +// Stable published snapshot, safe for the menu task. It never initiates SPI, +// SD I/O or changes calibration. Freshness is evaluated using the current time. +mambo::BenchDiagnostics mamboBenchDiagnostics(); diff --git a/radio/src/targets/mambo/external_rf.cpp b/radio/src/targets/mambo/external_rf.cpp new file mode 100644 index 00000000000..2e1974ba03b --- /dev/null +++ b/radio/src/targets/mambo/external_rf.cpp @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-2.0-only +#include "external_rf.h" + +#include "edgetx.h" +#include "hal/gpio.h" +#include "stm32_gpio.h" + +namespace +{ +bool shutdownInhibited = false; +MamboExternalDiagnostics diagnostics; +} // namespace + +void mamboExternalTxObserved(uint8_t type) +{ + ++diagnostics.txFrames; + diagnostics.lastTxType = type; +} +void mamboExternalRxObserved(uint8_t, bool error) +{ + ++diagnostics.rxBytes; + if (error) ++diagnostics.uartErrors; +} +void mamboExternalIdleObserved() { ++diagnostics.idleEvents; } +void mamboExternalParseObserved() { ++diagnostics.parseEvents; } +void mamboExternalFrameObserved(uint8_t type) +{ + ++diagnostics.validFrames; + diagnostics.lastRxType = type; +} +void mamboExternalCrcErrorObserved() { ++diagnostics.crcErrors; } +void mamboExternalHeaderErrorObserved() { ++diagnostics.headerErrors; } + +MamboExternalDiagnostics mamboExternalDiagnostics() +{ + uint32_t mask = __get_PRIMASK(); + __disable_irq(); + auto result = diagnostics; + result.powered = gpio_read(EXTMODULE_PWR_GPIO); + result.pins = (gpio_read(TELEMETRY_TX_REV_GPIO) ? 1 : 0) | + (gpio_read(TELEMETRY_RX_REV_GPIO) ? 2 : 0) | + (gpio_read(TELEMETRY_DIR_GPIO) ? 4 : 0); + __set_PRIMASK(mask); + return result; +} + +void mamboExternalModulePower(bool enable) +{ + // Keep the bay off for Off and unsupported persisted/imported protocols. + uint32_t mask = __get_PRIMASK(); + __disable_irq(); + bool allowed = + enable && !shutdownInhibited && + g_model.moduleData[EXTERNAL_MODULE].type == MODULE_TYPE_CROSSFIRE; + gpio_write(EXTMODULE_PWR_GPIO, allowed); + __set_PRIMASK(mask); +} + +void mamboExternalModuleShutdown() +{ + // Nonblocking emergency inhibition: no task locks, waits or protocol I/O. + uint32_t mask = __get_PRIMASK(); + __disable_irq(); + shutdownInhibited = true; + gpio_clear(EXTMODULE_PWR_GPIO); + __set_PRIMASK(mask); +} diff --git a/radio/src/targets/mambo/external_rf.h b/radio/src/targets/mambo/external_rf.h new file mode 100644 index 00000000000..ae3a8bd75e2 --- /dev/null +++ b/radio/src/targets/mambo/external_rf.h @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once +#include + +struct MamboExternalDiagnostics { + uint32_t txFrames = 0, rxBytes = 0, uartErrors = 0, idleEvents = 0; + uint32_t parseEvents = 0, validFrames = 0, crcErrors = 0, headerErrors = 0; + uint8_t lastTxType = 0, lastRxType = 0, pins = 0; + bool powered = false; +}; +void mamboExternalTxObserved(uint8_t type); +void mamboExternalRxObserved(uint8_t byte, bool error); +void mamboExternalIdleObserved(); +void mamboExternalParseObserved(); +void mamboExternalFrameObserved(uint8_t type); +void mamboExternalCrcErrorObserved(); +void mamboExternalHeaderErrorObserved(); +MamboExternalDiagnostics mamboExternalDiagnostics(); diff --git a/radio/src/targets/mambo/gimbals.cpp b/radio/src/targets/mambo/gimbals.cpp new file mode 100644 index 00000000000..f5beffd3a92 --- /dev/null +++ b/radio/src/targets/mambo/gimbals.cpp @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: GPL-2.0-only +#include "gimbals.h" + +#include + +namespace mambo +{ +namespace +{ +constexpr uint8_t registers[] = {0, 1, 2, 4, 5, 6}; +constexpr uint16_t values[] = {0, 0x8400, 0x02a4, 0x8000, 0x8000, 0x8000}; +constexpr uint32_t staleUs = 20000; +uint16_t be16(const uint8_t* p) { return (uint16_t(p[0]) << 8) | p[1]; } +bool accepted(uint8_t status) { return !(status & 0x14); } // ERROR or reset +} // namespace + +void HallSensor::begin(uint32_t now) +{ + state = State::Exit; + changed = now; + delay = 0; + reg = 0; + data = {}; +} + +void HallSensor::failed(uint32_t now, HallFault fault, uint8_t command, + uint8_t status) +{ + ++data.errors; + data.lastFault = fault; + data.faultCommand = command; + data.faultStatus = status; + data.valid = false; + state = State::Exit; + changed = now; + delay = 100000; // bounded retries for missing or faulty sensors + reg = 0; +} + +bool HallSensor::fresh(uint32_t now) const +{ + return data.valid && uint32_t(now - data.timestamp) < staleUs; +} + +bool HallSensor::startMeasurement(unsigned sensor, uint32_t now, + HallTransfer transfer) +{ + // Match Tracer's explicit SM(TXYZ) command rather than depending on burst + // mode. Each successful read immediately arms the next conversion. + const uint8_t command = 0x3f; + uint8_t response = 0; + if (!transfer(sensor, &command, 1, &response, 1)) { + failed(now, HallFault::Transport, command); + return false; + } + if (!accepted(response) || (response & 0xe0) != 0x20) { + failed(now, HallFault::StartStatus, command, response); + return false; + } + state = State::Read; + changed = now; + delay = 2000; + return true; +} + +void HallSensor::poll(unsigned sensor, uint32_t now, bool ready, + HallTransfer transfer) +{ + if (uint32_t(now - changed) < delay) return; + uint8_t command[4] = {}; + uint8_t response[9] = {}; + switch (state) { + case State::Exit: + command[0] = 0x80; + // EX may reject an already faulty memory; RT must still be attempted. + if (!transfer(sensor, command, 1, response, 1)) { + failed(now, HallFault::Transport, command[0]); + return; + } + state = State::Reset; + delay = 1000; + break; + case State::Reset: + command[0] = 0xf0; + if (!transfer(sensor, command, 1, response, 1)) { + failed(now, HallFault::Transport, command[0]); + return; + } + if (response[0] & 0x10) { + failed(now, HallFault::ResetStatus, command[0], response[0]); + return; + } + state = State::ClearReset; + delay = 2000; + break; + case State::ClearReset: + // Consume the reset indication before checking configuration replies. + command[0] = 0x80; + if (!transfer(sensor, command, 1, response, 1)) { + failed(now, HallFault::Transport, command[0]); + return; + } + if (response[0] & 0x10) { + failed(now, HallFault::ResetStatus, command[0], response[0]); + return; + } + state = State::Configure; + delay = 0; + break; + case State::Configure: { + auto value = values[reg]; + command[0] = 0x60; + command[1] = value >> 8; + command[2] = value; + command[3] = registers[reg] << 2; + if (!transfer(sensor, command, 4, response, 1)) { + failed(now, HallFault::Transport, command[0]); + return; + } + if (!accepted(response[0])) { + failed(now, HallFault::ConfigurationStatus, command[0], response[0]); + return; + } + command[0] = 0x50; + command[1] = command[3]; + if (!transfer(sensor, command, 2, response, 3)) { + failed(now, HallFault::Transport, command[0]); + return; + } + if (!accepted(response[0]) || (response[0] & 3) != 0) { + failed(now, HallFault::ReadbackStatus, command[0], response[0]); + return; + } + if (be16(response + 1) != value) { + failed(now, HallFault::ReadbackMismatch, command[0], response[0]); + return; + } + if (++reg == sizeof(registers)) state = State::Start; + delay = 0; + break; + } + case State::Start: + startMeasurement(sensor, now, transfer); + return; + case State::Read: + if (!ready) { + if (uint32_t(now - changed) >= staleUs) + failed(now, HallFault::ReadyTimeout, 0x4f); + return; + } + command[0] = 0x4f; + if (!transfer(sensor, command, 1, response, 9)) { + failed(now, HallFault::Transport, command[0]); + return; + } + // SM may already be clear after conversion. Require eight data bytes, + // no ERROR/reset, and no unexpected burst/wake-on-change mode. + if (!accepted(response[0]) || (response[0] & 0xc3) != 0x03) { + failed(now, HallFault::MeasurementStatus, command[0], response[0]); + return; + } + data.status = response[0]; + data.temperature = be16(response + 1); + // TCMP_EN + RES=1 and OFFSET=0x8000: captured RF subtracts 32768. + data.x = int32_t(be16(response + 3)) - 32768; + data.y = int32_t(be16(response + 5)) - 32768; + data.z = int32_t(be16(response + 7)) - 32768; + data.timestamp = now; + ++data.count; + data.valid = true; + startMeasurement(sensor, now, transfer); + return; + } + changed = now; +} + +bool hallAngles(const HallSample& sample, uint16_t& x, uint16_t& y) +{ + if (!sample.valid || (!sample.x && !sample.y && !sample.z)) return false; + // Captured constants at 0x080c4ed8/4edc, Hall gain=0, resolution=1. + float bx = sample.x * 1.573f; + float by = sample.y * 1.573f; + float bz = sample.z * 2.534f; + constexpr float scale = 4095.0f / 3.14159265358979323846f; + x = uint16_t(std::lround( + 2047.5f + scale * std::atan2(bx, std::sqrt(by * by + bz * bz)))); + y = uint16_t(std::lround( + 2047.5f + scale * std::atan2(by, std::sqrt(bx * bx + bz * bz)))); + return true; +} +} // namespace mambo diff --git a/radio/src/targets/mambo/gimbals.h b/radio/src/targets/mambo/gimbals.h new file mode 100644 index 00000000000..3c552088eb1 --- /dev/null +++ b/radio/src/targets/mambo/gimbals.h @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once +#include +#include + +namespace mambo +{ +// Transport keeps CS asserted across command and response. Response starts +// with the status byte; command-phase MISO is discarded. +using HallTransfer = bool (*)(unsigned, const uint8_t*, size_t, uint8_t*, + size_t); +enum class HallFault : uint8_t { + None, + Transport, + ResetStatus, + ConfigurationStatus, + ReadbackStatus, + ReadbackMismatch, + StartStatus, + ReadyTimeout, + MeasurementStatus +}; +struct HallSample { + uint16_t temperature = 0; + int32_t x = 0, y = 0, z = 0; + uint32_t timestamp = 0, count = 0, errors = 0; + uint8_t status = 0; + HallFault lastFault = HallFault::None; + uint8_t faultCommand = 0, faultStatus = 0; + bool valid = false; +}; + +class HallSensor +{ + public: + void begin(uint32_t now); + void poll(unsigned sensor, uint32_t now, bool ready, HallTransfer transfer); + bool fresh(uint32_t now) const; + const HallSample& sample() const { return data; } + + private: + enum class State { Exit, Reset, ClearReset, Configure, Start, Read }; + State state = State::Exit; + uint32_t changed = 0; + uint32_t delay = 0; + unsigned reg = 0; + HallSample data; + void failed(uint32_t now, HallFault fault, uint8_t command, + uint8_t status = 0); + bool startMeasurement(unsigned sensor, uint32_t now, HallTransfer transfer); +}; + +// Provisional projection used when factory calibration is unavailable. +// Output feeds EdgeTX's ordinary centre/travel calibration. +bool hallAngles(const HallSample& sample, uint16_t& x, uint16_t& y); +} // namespace mambo + +void mamboGimbalsInit(); +void mamboGimbalsDeinit(); +void mamboGimbalsRead(); +// Read-only bench diagnostics; only call from the acquisition task. +const mambo::HallSample& mamboGimbalSample(unsigned sensor); diff --git a/radio/src/targets/mambo/gimbals_stm32.cpp b/radio/src/targets/mambo/gimbals_stm32.cpp new file mode 100644 index 00000000000..9a19602be2d --- /dev/null +++ b/radio/src/targets/mambo/gimbals_stm32.cpp @@ -0,0 +1,187 @@ +// SPDX-License-Identifier: GPL-2.0-only +#include "board.h" +#include "calibration.h" +#include "diagnostics.h" +#include "ff.h" +#include "gimbals.h" +#include "hal/adc_driver.h" +#include "hal/gpio.h" +#include "stm32_gpio.h" +#include "stm32_hal_ll.h" +#include "timers_driver.h" + +namespace +{ +mambo::HallSensor sensors[2]; +mambo::FactoryCalibration factoryCalibration; +volatile MamboCalibrationStatus calibrationStatus = + MamboCalibrationStatus::Missing; +mambo::BenchDiagnostics benchDiagnostics; +// Tracer 6.19 transfer routine 0x080c4974 selects PE8 for sensor 0 and +// PD7 for sensor 1. Keep this order aligned with calibration and DRDY. +const gpio_t selectPins[] = {GPIO_PIN(GPIOE, 8), GPIO_PIN(GPIOD, 7)}; +const gpio_t readyPins[] = {GPIO_PIN(GPIOC, 5), GPIO_PIN(GPIOB, 9)}; + +void busInit() +{ + LL_APB1_GRP1_EnableClock(LL_APB1_GRP1_PERIPH_SPI2); + LL_APB1_GRP1_ForceReset(LL_APB1_GRP1_PERIPH_SPI2); + LL_APB1_GRP1_ReleaseReset(LL_APB1_GRP1_PERIPH_SPI2); + gpio_init_af(GPIO_PIN(GPIOB, 10), GPIO_AF5, GPIO_PIN_SPEED_HIGH); + gpio_init_af(GPIO_PIN(GPIOC, 2), GPIO_AF5, GPIO_PIN_SPEED_HIGH); + gpio_init_af(GPIO_PIN(GPIOC, 3), GPIO_AF5, GPIO_PIN_SPEED_HIGH); + // APB1=42 MHz / 16 = 2.625 MHz. Mode 3, 8-bit, software NSS, master. + SPI2->CR1 = SPI_CR1_MSTR | SPI_CR1_SSM | SPI_CR1_SSI | SPI_CR1_CPOL | + SPI_CR1_CPHA | SPI_CR1_BR_1 | SPI_CR1_BR_0; + SPI2->CR1 |= SPI_CR1_SPE; +} + +bool transfer(unsigned sensor, const uint8_t* command, size_t commandSize, + uint8_t* response, size_t responseSize) +{ + // One deadline bounds the entire transaction, including BSY. DWT runs even + // if a peripheral stalls; no unbounded loops or conversion-delay sleeps. + const uint32_t start = DWT->CYCCNT; + const uint32_t budget = CPU_FREQ / 10000; // 100 us + gpio_clear(selectPins[sensor]); + bool ok = true; + for (size_t i = 0; i < commandSize + responseSize; ++i) { + while (!(SPI2->SR & SPI_SR_TXE)) { + if (uint32_t(DWT->CYCCNT - start) >= budget) { + ok = false; + break; + } + } + if (!ok) break; + *reinterpret_cast(&SPI2->DR) = + i < commandSize ? command[i] : 0; + while (!(SPI2->SR & SPI_SR_RXNE)) { + if (uint32_t(DWT->CYCCNT - start) >= budget) { + ok = false; + break; + } + } + if (!ok) break; + uint8_t byte = *reinterpret_cast(&SPI2->DR); + if (i >= commandSize) response[i - commandSize] = byte; + } + while (ok && (SPI2->SR & SPI_SR_BSY)) { + if (uint32_t(DWT->CYCCNT - start) >= budget) ok = false; + } + gpio_set(selectPins[sensor]); + if (!ok) busInit(); // discard partial transfer/overrun before another sensor + return ok; +} +} // namespace + +void mamboGimbalsInit() +{ + benchDiagnostics = {}; + benchDiagnostics.resetFlags = RCC->CSR & 0xfe000000u; + // Timeout clock is deliberately independent of FreeRTOS and timer IRQs. + CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; + DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; + for (unsigned i = 0; i < 2; ++i) { + gpio_set(selectPins[i]); + gpio_init(selectPins[i], GPIO_OUT, GPIO_PIN_SPEED_HIGH); + gpio_init(readyPins[i], GPIO_IN_PD, GPIO_PIN_SPEED_LOW); + sensors[i].begin(timersGetUsTick()); + } + busInit(); +} + +void mamboGimbalsDeinit() +{ + for (unsigned i = 0; i < 2; ++i) { + gpio_set(selectPins[i]); + sensors[i].begin(timersGetUsTick()); + } + SPI2->CR1 &= ~SPI_CR1_SPE; +} + +void mamboGimbalsRead() +{ + uint32_t now = timersGetUsTick(); + uint16_t axes[4] = {2048, 2048, 2048, 2048}; + for (unsigned i = 0; i < 2; ++i) { + sensors[i].poll(i, now, gpio_read(readyPins[i]), transfer); + uint16_t x, y; + mambo::GimbalCorrection correction; + // SD mounting runs outside the acquisition task. Snapshot publication and + // reads under a short IRQ mask; no file I/O or math with IRQs masked. + uint32_t mask = __get_PRIMASK(); + __disable_irq(); + bool loaded = calibrationStatus == MamboCalibrationStatus::Loaded; + if (loaded) correction = factoryCalibration.gimbals[i]; + __set_PRIMASK(mask); + bool valid = + sensors[i].fresh(now) && + (loaded ? mambo::calibratedHall(sensors[i].sample(), correction, x, y) + : mambo::hallAngles(sensors[i].sample(), x, y)); + if (valid) { + // RF publishes its intermediate X/Y/X/Y as LH/LV/RV/RH. + axes[i ? 3 : 0] = x; + axes[i ? 2 : 1] = y; + } + } + for (unsigned i = 0; i < 4; ++i) setAnalogValue(i, axes[i]); + uint32_t duration = timersGetUsTick() - now; + uint32_t mask = __get_PRIMASK(); + __disable_irq(); + for (unsigned i = 0; i < 2; ++i) + benchDiagnostics.sensors[i] = sensors[i].sample(); + for (unsigned i = 0; i < 4; ++i) benchDiagnostics.sticks[i] = axes[i]; + ++benchDiagnostics.frames; + benchDiagnostics.lastDurationUs = duration; + if (duration > benchDiagnostics.maxDurationUs) + benchDiagnostics.maxDurationUs = duration; + benchDiagnostics.calibration = calibrationStatus; + __set_PRIMASK(mask); +} + +const mambo::HallSample& mamboGimbalSample(unsigned sensor) +{ + return sensors[sensor < 2 ? sensor : 0].sample(); +} + +void mamboGimbalsLoadCalibration() +{ + FIL file; + uint8_t bytes[604]; + UINT count = 0; + mambo::FactoryCalibration candidate; + auto status = MamboCalibrationStatus::Missing; + // FA_READ only. Never create, update or repair the original Tracer backup. + auto result = f_open(&file, "/CROSSFIRE/EEPROM.bin", FA_READ); + if (result == FR_OK) { + result = f_read(&file, bytes, sizeof(bytes), &count); + auto closeResult = f_close(&file); + status = result == FR_OK && closeResult == FR_OK && + count == sizeof(bytes) && + mambo::decodeFactoryCalibration(bytes, count, candidate) + ? MamboCalibrationStatus::Loaded + : MamboCalibrationStatus::Invalid; + } else if (result != FR_NO_FILE && result != FR_NO_PATH) { + status = MamboCalibrationStatus::Invalid; + } + uint32_t mask = __get_PRIMASK(); + __disable_irq(); + if (status == MamboCalibrationStatus::Loaded) factoryCalibration = candidate; + calibrationStatus = status; + __set_PRIMASK(mask); +} + +MamboCalibrationStatus mamboGimbalsCalibrationStatus() +{ + return calibrationStatus; +} + +mambo::BenchDiagnostics mamboBenchDiagnostics() +{ + uint32_t mask = __get_PRIMASK(); + __disable_irq(); + auto result = benchDiagnostics; + result.calibration = calibrationStatus; + __set_PRIMASK(mask); + return result; +} diff --git a/radio/src/targets/mambo/hal.h b/radio/src/targets/mambo/hal.h new file mode 100644 index 00000000000..6259a52da2d --- /dev/null +++ b/radio/src/targets/mambo/hal.h @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once +#include + +// Keep the original bootloader and Tracer application outside the UI image. +#define FLASHSIZE 0xC0000 +#define BOOTLOADER_SIZE 0xC000 + +// Two parallel 18650 cells form a 1S battery. +#define BATTERY_WARN 34 +#define BATTERY_MIN 33 +#define BATTERY_MAX 42 +#define BATT_SCALE 128 + +#define LCD_CONTRAST_MIN 0 +#define LCD_CONTRAST_MAX 255 +#define LCD_CONTRAST_DEFAULT 159 + +// Pin mapping from public FreedomTX target at revision 4528db71. +#define PWR_SWITCH_GPIO GPIO_PIN(GPIOB, 14) +#define PWR_SWITCH_PIN_ACTIVE_HIGH +#define PWR_ON_GPIO GPIO_PIN(GPIOB, 12) + +#define EXTMODULE_PWR_GPIO GPIO_PIN(GPIOE, 2) +#if defined(MAMBO_EXTERNAL_CRSF) +void mamboExternalModulePower(bool enable); +void mamboExternalModuleShutdown(); +#define EXTERNAL_MODULE_PWR_ON() mamboExternalModulePower(true) +#define EXTERNAL_MODULE_PWR_OFF() mamboExternalModulePower(false) +#define IS_EXTERNAL_MODULE_ON() gpio_read(EXTMODULE_PWR_GPIO) +// CRSF uses the stock bidirectional telemetry path, not USART6 on PPM. +#define TELEMETRY_USART USART2 +#define TELEMETRY_TX_GPIO GPIO_PIN(GPIOD, 5) +#define TELEMETRY_RX_GPIO GPIO_PIN(GPIOD, 6) +#define TELEMETRY_USART_IRQn USART2_IRQn +#define TELEMETRY_USART_IRQHandler USART2_IRQHandler +#define TELEMETRY_DMA DMA1 +#define TELEMETRY_DMA_Stream_TX LL_DMA_STREAM_6 +#define TELEMETRY_DMA_Channel_TX LL_DMA_CHANNEL_4 +#define TELEMETRY_DMA_TX_Stream_IRQ DMA1_Stream6_IRQn +#define TELEMETRY_DMA_TX_IRQHandler DMA1_Stream6_IRQHandler +#define TELEMETRY_DIR_GPIO GPIO_PIN(GPIOB, 13) +#define TELEMETRY_SET_INPUT 0 +#define TELEMETRY_TX_REV_GPIO GPIO_PIN(GPIOB, 2) +#define TELEMETRY_RX_REV_GPIO GPIO_PIN(GPIOB, 11) +// Tested hardware: normal CRSF needs both controls low. The historical stock +// high/high setting broke channel output; retain the working configuration. +#define TELEMETRY_INVERTER_ACTIVE_HIGH +// Receive parsing is deferred from USART/DMA to this software EXTI interrupt. +#define TELEMETRY_RX_FRAME_EXTI_LINE LL_EXTI_LINE_4 +#define USE_EXTI4_IRQ +#define EXTI4_IRQ_Priority 5 +#else +#define EXTERNAL_MODULE_PWR_ON() do {} while (0) +#define EXTERNAL_MODULE_PWR_OFF() gpio_clear(EXTMODULE_PWR_GPIO) +#define IS_EXTERNAL_MODULE_ON() false +#endif +#define TELEMETRY_EXTI_PRIO 0 + +#define BACKLIGHT_GPIO GPIO_PIN(GPIOD, 15) +#define BACKLIGHT_GPIO_AF GPIO_AF2 +#define BACKLIGHT_TIMER TIM4 +#define BACKLIGHT_TIMER_FREQ (PERI1_FREQUENCY * TIMER_MULT_APB1) +#define BACKLIGHT_COUNTER_REGISTER BACKLIGHT_TIMER->CCR4 +#define BACKLIGHT_CCMR2 (TIM_CCMR2_OC4M_1 | TIM_CCMR2_OC4M_2) +#define BACKLIGHT_CCER TIM_CCER_CC4E + +#define LCD_SPI SPI3 +#define LCD_SPI_PRESCALER SPI_CR1_BR_1 +#define LCD_GPIO_AF GPIO_AF6 +#define LCD_MOSI_GPIO GPIO_PIN(GPIOB, 5) +#define LCD_CLK_GPIO GPIO_PIN(GPIOB, 3) +#define LCD_A0_GPIO GPIO_PIN(GPIOD, 0) +#define LCD_NCS_GPIO GPIO_PIN(GPIOD, 1) +#define LCD_RST_GPIO GPIO_PIN(GPIOD, 3) +#define LCD_DMA DMA1 +#define LCD_DMA_Stream DMA1_Stream7 +#define LCD_DMA_Stream_IRQn DMA1_Stream7_IRQn +#define LCD_DMA_Stream_IRQHandler DMA1_Stream7_IRQHandler +#define LCD_DMA_FLAGS (DMA_HIFCR_CTCIF7 | DMA_HIFCR_CHTIF7 | DMA_HIFCR_CTEIF7 | DMA_HIFCR_CDMEIF7 | DMA_HIFCR_CFEIF7) +#define LCD_DMA_FLAG_INT DMA_HIFCR_CTCIF7 + +// SDIO, not the SPI2 storage connection used by most monochrome targets. +#define STORAGE_USE_SDIO +#define SD_SDIO_DMA DMA2 +#define SD_SDIO_DMA_STREAM DMA2_Stream3 +#define SD_SDIO_DMA_CHANNEL LL_DMA_CHANNEL_4 +#define SD_SDIO_DMA_IRQn DMA2_Stream3_IRQn +#define SD_SDIO_DMA_IRQHANDLER DMA2_Stream3_IRQHandler +#define SD_SDIO_CLK_DIV(fq) ((48000000 / (fq)) - 2) +#define SD_SDIO_TRANSFER_CLK_DIV SD_SDIO_CLK_DIV(24000000) + +#define AUDIO_OUTPUT_GPIO GPIO_PIN(GPIOA, 4) +#define AUDIO_MUTE_GPIO GPIO_PIN(GPIOE, 0) +#define AUDIO_DMA DMA1 +#define AUDIO_DMA_Stream DMA1_Stream5 +#define AUDIO_DMA_Stream_IRQn DMA1_Stream5_IRQn +#define AUDIO_DMA_Stream_IRQHandler DMA1_Stream5_IRQHandler +#define AUDIO_TIMER TIM6 + +// FreedomTX anaIn(VBAT) / 4.55 returns hundredths of a volt. The generic +// BATT_SCALE branch returns counts * BATT_SCALE * 128 / BATTERY_DIVIDER. +// Include both scale factors so its nominal result matches FreedomTX. +#define ADC_VREF_PREC2 300 +#define BATTERY_DIVIDER (4.55f * BATT_SCALE * 128.0f) + +// Direct Hall acquisition owns SPI2; internal SX1281 reset stays asserted. +#define MAMBO_RF_RESET_GPIO GPIO_PIN(GPIOC, 13) +void mamboEarlyInit(); diff --git a/radio/src/targets/mambo/inputs.cpp b/radio/src/targets/mambo/inputs.cpp new file mode 100644 index 00000000000..9a3e36ef725 --- /dev/null +++ b/radio/src/targets/mambo/inputs.cpp @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-2.0-only +#include "inputs.h" + +#include "board.h" +#include "hal/adc_driver.h" +#include "hal/gpio.h" +#include "stm32_gpio.h" + +void mamboEarlyInit() +{ + // RF and gimbals share SPI2. No RF startup or module power is permitted in + // this bench target; hold the SX1281 reset and de-select both Hall sensors. + gpio_init(MAMBO_RF_RESET_GPIO, GPIO_OUT, GPIO_PIN_SPEED_LOW); + gpio_clear(MAMBO_RF_RESET_GPIO); + gpio_init(GPIO_PIN(GPIOE, 3), GPIO_OUT, GPIO_PIN_SPEED_LOW); + gpio_set(GPIO_PIN(GPIOE, 3)); + gpio_init(EXTMODULE_PWR_GPIO, GPIO_OUT, GPIO_PIN_SPEED_LOW); + gpio_clear(EXTMODULE_PWR_GPIO); + gpio_init(GPIO_PIN(GPIOD, 7), GPIO_OUT, GPIO_PIN_SPEED_LOW); + gpio_set(GPIO_PIN(GPIOD, 7)); + gpio_init(GPIO_PIN(GPIOE, 8), GPIO_OUT, GPIO_PIN_SPEED_LOW); + gpio_set(GPIO_PIN(GPIOE, 8)); +} + +uint32_t readTrims() +{ + // RAW1 is the trim resistor ladder, after four virtual sticks and two pots. + constexpr unsigned trimInput = 6; + return mambo::decodeTrimLadder(getAnalogValue(trimInput)); +} diff --git a/radio/src/targets/mambo/inputs.h b/radio/src/targets/mambo/inputs.h new file mode 100644 index 00000000000..5a18cd0c786 --- /dev/null +++ b/radio/src/targets/mambo/inputs.h @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once +#include + +namespace mambo +{ + +constexpr uint32_t decodeTrimLadder(uint16_t value) +{ + // Single-key resistor-ladder decoder from the matching FreedomTX driver. + constexpr uint16_t levels[] = {4095 / 2, 4095 / 3, 4095 / 4, 4095 / 5, + 4095 / 6, 4095 / 7, 4095 / 8, 4095 / 9}; + for (unsigned i = 0; i < 8; ++i) { + const uint16_t hi = ((i ? levels[i - 1] : 4095) + levels[i]) / 2; + const uint16_t lo = (levels[i] + (i == 7 ? 0 : levels[i + 1])) / 2; + if (value < hi && value > lo) { + const unsigned bit = i == 4 ? 5 : i == 5 ? 4 : i; + return 1u << bit; + } + } + return 0; +} + +} // namespace mambo diff --git a/radio/src/targets/taranis/CMakeLists.txt b/radio/src/targets/taranis/CMakeLists.txt index d0f720e2d8e..7709772e79c 100644 --- a/radio/src/targets/taranis/CMakeLists.txt +++ b/radio/src/targets/taranis/CMakeLists.txt @@ -447,6 +447,26 @@ elseif(PCB STREQUAL XLITES) set(PXX2 ON) set(BLUETOOTH YES) set(IMU YES) +elseif(PCB STREQUAL MAMBO) + set(CPU_TYPE_FULL STM32F407xG) + set(FLAVOUR mambo) + set(HSE_VALUE 8000000) + # Avoid reinitialising the original TBS backup domain in the bench target. + set(USE_RTC_CLOCK NO) + set(HAPTIC YES) + set(ROTARY_ENCODER YES) + set(PWR_BUTTON "PRESS") + set(GUI_DIR 128x64) + set(BITMAPS_DIR 128x64) + set(BITMAPS_TARGET 9x_bitmaps) + set(FONTS_TARGET 9x_fonts_1bit) + set(LCD_DRIVER lcd_driver_spi.cpp) + set(HARDWARE_EXTERNAL_MODULE NO) + if(MAMBO_EXTERNAL_CRSF) + set(HARDWARE_EXTERNAL_MODULE YES) + set(MODULE_SIZE_SML YES) + endif() + add_definitions(-DPCBX7 -DPCBMAMBO -DSOFTWARE_VOLUME -DPWR_BUTTON_PRESS) endif() message("CPU_TYPE_FULL = ${CPU_TYPE_FULL}") @@ -473,7 +493,12 @@ else() message(FATAL_ERROR "Unknown CPU_TYPE_FULL" ) endif() -if ((CPU_TYPE_FULL STREQUAL STM32F407xG) AND (NOT PCBREV STREQUAL MT12)) +if(PCB STREQUAL MAMBO) + set(TARGET_LINKER_DIR mambo) + unset(TARGET_FLASH_SIZE) +endif() + +if ((CPU_TYPE_FULL STREQUAL STM32F407xG) AND (NOT PCBREV STREQUAL MT12) AND (NOT PCB STREQUAL MAMBO)) set(RTC_BACKUP_RAM YES) endif() @@ -494,7 +519,7 @@ if(ENABLE_SERIAL_PASSTHROUGH) set(CLI ON "Enable CLI") endif() -if(INTERNAL_MODULE_SERIAL) +if(INTERNAL_MODULE_SERIAL AND NOT PCB STREQUAL MAMBO) # defines default internal modules if(DEFAULT_INTERNAL_MODULE STREQUAL XJT_PXX1) @@ -510,6 +535,8 @@ if(INTERNAL_MODULE_SERIAL) add_definitions(-DPXX_FREQUENCY_HIGH) endif() +elseif(PCB STREQUAL MAMBO) + set(INTERNAL_MODULES "" CACHE STRING "Internal Tracer is not supported" FORCE) else() # defines existing internal modules @@ -649,9 +676,11 @@ if(PCB STREQUAL XLITE OR PCB STREQUAL XLITES OR PCBREV STREQUAL COMMANDO8) targets/common/arm/stm32/sticks_pwm_driver.cpp) endif() -set(FIRMWARE_SRC - ${FIRMWARE_SRC} - bootloader/loadboot.cpp - ) +if(BOOTLOADER) + set(FIRMWARE_SRC + ${FIRMWARE_SRC} + bootloader/loadboot.cpp + ) +endif() set(RADIO_DEPENDENCIES ${RADIO_DEPENDENCIES} ${BITMAPS_TARGET}) diff --git a/radio/src/targets/taranis/board.cpp b/radio/src/targets/taranis/board.cpp index 5093742dae7..e88b6921723 100644 --- a/radio/src/targets/taranis/board.cpp +++ b/radio/src/targets/taranis/board.cpp @@ -126,6 +126,9 @@ static void gyroInit() void boardInit() { +#if defined(RADIO_MAMBO) + mamboEarlyInit(); +#endif LL_APB2_GRP1_EnableClock(LL_APB2_GRP1_PERIPH_SYSCFG); #if (defined(RADIO_V14) || defined(RADIO_V14LCD)) && defined(AUDIO_MUTE_GPIO) @@ -287,6 +290,10 @@ void boardInit() void boardOff() { +#if defined(MAMBO_EXTERNAL_CRSF) + // Cover emergency shutdown as well as the ordinary protocol-stop path. + mamboExternalModuleShutdown(); +#endif #if defined(LED_STRIP_GPIO) && !defined(BOOT) rgbLedStop(); rgbLedClearAll(); diff --git a/radio/src/targets/taranis/board.h b/radio/src/targets/taranis/board.h index 4920714dce8..18bee04a886 100644 --- a/radio/src/targets/taranis/board.h +++ b/radio/src/targets/taranis/board.h @@ -36,13 +36,17 @@ void rotaryEncoderInit(); void rotaryEncoderCheck(); #endif +#if !defined(FLASHSIZE) #if defined(STM32F413xx) || defined(STM32F407xG) #define FLASHSIZE 0x100000 // 1M #else #define FLASHSIZE 0x80000 // 512k #endif +#endif #define FLASH_PAGESIZE 256 +#if !defined(BOOTLOADER_SIZE) #define BOOTLOADER_SIZE 0x8000 +#endif #define FIRMWARE_ADDRESS 0x08000000 #define FIRMWARE_LEN(fsize) (fsize - BOOTLOADER_SIZE) #define FIRMWARE_MAX_LEN (FLASHSIZE - BOOTLOADER_SIZE) @@ -76,8 +80,13 @@ enum { }; // Pulses driver +#if defined(INTMODULE_PWR_GPIO) #define INTERNAL_MODULE_ON() gpio_set(INTMODULE_PWR_GPIO) #define INTERNAL_MODULE_OFF() gpio_clear(INTMODULE_PWR_GPIO) +#else +#define INTERNAL_MODULE_ON() do {} while (0) +#define INTERNAL_MODULE_OFF() do {} while (0) +#endif #if (defined(INTERNAL_MODULE_PXX1) || defined(INTERNAL_MODULE_PXX2)) && (!defined(PCBX9LITE) || defined(PCBX9LITES)) #define HARDWARE_INTERNAL_RAS @@ -119,6 +128,7 @@ PACK(typedef struct { extern HardwareOptions hardwareOptions; // Battery driver +#if !defined(BATTERY_WARN) #if defined(PCBX9E) // NI-MH 9.6V #define BATTERY_WARN 87 // 8.7V @@ -144,7 +154,9 @@ extern HardwareOptions hardwareOptions; #define BATTERY_MIN 60 // 6.0V #define BATTERY_MAX 80 // 8.0V #endif +#endif +#if !defined(BATT_SCALE) #if defined(PCBXLITE) #define BATT_SCALE 131 #elif defined(PCBX7) @@ -156,6 +168,7 @@ extern HardwareOptions hardwareOptions; #else #define BATT_SCALE 150 #endif +#endif #if defined(__cplusplus) && !defined(SIMU) extern "C" { @@ -246,6 +259,7 @@ void ledBlue(); void ledBoot(); // LCD driver +#if !defined(LCD_CONTRAST_MIN) #if defined(PCBX9D) || defined(PCBX9DP) || defined(PCBX9E) #define LCD_CONTRAST_MIN 0 #define LCD_CONTRAST_MAX 45 @@ -280,6 +294,7 @@ void ledBoot(); #define LCD_W_OFFSET 0x02 #endif #endif +#endif #if defined(PCBX9D) || defined(PCBX9E) || (defined(PCBX9DP) && PCBREV < 2019) #define IS_LCD_RESET_NEEDED() (!WAS_RESET_BY_WATCHDOG_OR_SOFTWARE()) @@ -345,6 +360,8 @@ void setTopBatteryValue(uint32_t volts); #else #define VBAT_MOSFET_DROP 25 // * 10mV #endif +#elif defined(RADIO_MAMBO) + // Battery scaling is defined by the Mambo HAL; there is no MOSFET offset. #else #if defined (RADIO_T8) || defined(RADIO_COMMANDO8) #define BATTERY_DIVIDER 50000 diff --git a/radio/src/targets/taranis/hal.h b/radio/src/targets/taranis/hal.h index d60d4120486..e6e8e60557d 100644 --- a/radio/src/targets/taranis/hal.h +++ b/radio/src/targets/taranis/hal.h @@ -23,6 +23,8 @@ #include "hal_settings.h" +#if !defined(RADIO_MAMBO) + #define TELEMETRY_EXTI_PRIO 0 // required for soft serial // Keys @@ -908,3 +910,7 @@ #define STORAGE_BLUETOOTH #endif #endif + +#else +#include "../mambo/hal.h" +#endif // RADIO_MAMBO diff --git a/radio/src/targets/taranis/lcd_driver_spi.cpp b/radio/src/targets/taranis/lcd_driver_spi.cpp index 9a19a2ffe6c..69e0e16da17 100644 --- a/radio/src/targets/taranis/lcd_driver_spi.cpp +++ b/radio/src/targets/taranis/lcd_driver_spi.cpp @@ -39,6 +39,8 @@ #define LCD_CONTRAST_OFFSET 0 #elif defined(RADIO_FAMILY_JUMPER_T12) || defined(MANUFACTURER_RADIOMASTER) || defined(RADIO_COMMANDO8) || defined(RADIO_TPRO) || defined(RADIO_T12MAX) || defined(RADIO_V14LCD) #define LCD_CONTRAST_OFFSET -10 +#elif defined(RADIO_MAMBO) + #define LCD_CONTRAST_OFFSET 127 #else #define LCD_CONTRAST_OFFSET 160 #endif @@ -181,6 +183,17 @@ void lcdStart() delay_ms(100); gpio_set(OLED_VCC_CS); #endif + +#elif defined(RADIO_MAMBO) + // Mambo panel sequence from FreedomTX, not the generic inverted X7 panel. + const uint8_t commands[] = {0xAE, 0xA2, 0xA0, 0xC8, 0x24, 0x81, 0x1F, + 0x24, 0x2F, 0xA6, 0xA4, 0x40, 0xB0, 0x10, 0x01, 0xAF}; + for (uint8_t command : commands) lcdWriteCommand(command); +#if defined(BOOT) + lcdSetRefVolt(LCD_CONTRAST_DEFAULT); +#else + lcdSetRefVolt(g_eeGeneral.contrast); +#endif #else #if LCD_VERTICAL_INVERT // T12 and TX12 have the screen inverted. @@ -304,6 +317,9 @@ void lcdRefresh(bool wait) lcdColumnSet(0); #else lcdWriteCommand(0x10); // Column addr 0 +#if defined(RADIO_MAMBO) + lcdWriteCommand(0x00); +#endif lcdWriteCommand(0xB0 | y); // Page addr y #if !LCD_VERTICAL_INVERT lcdWriteCommand(0x04); @@ -469,6 +485,11 @@ void lcdSetRefVolt(uint8_t val) #if defined(RADIO_V14LCD) lcdWriteCommand(0x81); // Set Vop lcdWriteCommand(val+LCD_CONTRAST_OFFSET+20);// 0-255 +#elif defined(RADIO_MAMBO) + const uint16_t value = val + LCD_CONTRAST_OFFSET; + lcdWriteCommand(0x81); + lcdWriteCommand(value & 0x3F); + lcdWriteCommand(0x20 | (value >> 6)); #else lcdWriteCommand(0x81); // Set Vop lcdWriteCommand(val+LCD_CONTRAST_OFFSET); // 0-255 diff --git a/radio/util/hw_defs/json_index.py b/radio/util/hw_defs/json_index.py index 739aa3e7ed6..5b3dee03ada 100644 --- a/radio/util/hw_defs/json_index.py +++ b/radio/util/hw_defs/json_index.py @@ -87,6 +87,7 @@ def index_contact(gpios, contact): def build_key_gpio_port_index(keys): gpios = {} for key in keys: - append_to_index(gpios, key.gpio, key.pin) + if key.gpio is not None and key.pin is not None: + append_to_index(gpios, key.gpio, key.pin) return gpios diff --git a/radio/util/hw_defs/legacy_names.py b/radio/util/hw_defs/legacy_names.py index 4e77667ba8f..c976cf93bd1 100644 --- a/radio/util/hw_defs/legacy_names.py +++ b/radio/util/hw_defs/legacy_names.py @@ -749,7 +749,7 @@ "tx12", "tx12mk2", "x7", "x7access", "xlite", "xlites", - "zorro" + "zorro", "mambo" }, "inputs": { "LH": { diff --git a/tools/hwdef_schema.json b/tools/hwdef_schema.json index 5961e146eef..b04f9b63d56 100644 --- a/tools/hwdef_schema.json +++ b/tools/hwdef_schema.json @@ -111,7 +111,7 @@ "type": "string", "enum": ["STICK", "FLEX", "VBAT", "RTC_BAT", "SWITCH", "LUX", "RAW"] }, - "adc": { "type": "string" }, + "adc": { "type": ["string", "null"] }, "gpio": { "anyOf": [ { "$ref": "#/$defs/gpio_port" }, { "type": "null" } ] }, "pin": { "anyOf": [ { "$ref": "#/$defs/gpio_pin" }, { "type": "null" } ] }, "channel": { From ddbbd6043c3c313afe415ef30e00af40fefad2f7 Mon Sep 17 00:00:00 2001 From: Ilya Date: Tue, 13 Oct 2026 01:13:53 +0100 Subject: [PATCH 02/11] fix(mambo): export rotary navigation events to Lua --- radio/src/boards/hw_defs/mambo.json | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/radio/src/boards/hw_defs/mambo.json b/radio/src/boards/hw_defs/mambo.json index 27c90a50b56..b287c1815cf 100644 --- a/radio/src/boards/hw_defs/mambo.json +++ b/radio/src/boards/hw_defs/mambo.json @@ -221,22 +221,6 @@ "key": "KEY_PAGEDN", "name": "PAGE", "label": "PAGE" - }, - { - "gpio": null, - "pin": null, - "active_low": true, - "key": "KEY_PLUS", - "name": "PLUS", - "label": "PLUS" - }, - { - "gpio": null, - "pin": null, - "active_low": true, - "key": "KEY_MINUS", - "name": "MINUS", - "label": "MINUS" } ], "trims": [ From b618ccbe89c36247505ac93c239f9815c5b241d4 Mon Sep 17 00:00:00 2001 From: Ilya Date: Sat, 17 Oct 2026 15:21:18 +0100 Subject: [PATCH 03/11] fix(mambo): enable standalone APB2 encoder polling --- .gitignore | 3 + radio/src/targets/mambo/CMakeLists.txt | 3 +- radio/src/targets/mambo/encoder.cpp | 22 ++++++ radio/src/targets/mambo/encoder.h | 7 ++ radio/src/targets/mambo/hal.h | 3 + radio/src/targets/taranis/board.cpp | 6 ++ tools/mambo-tests/CMakeLists.txt | 14 ++++ tools/mambo-tests/README.md | 20 +++++ tools/mambo-tests/encoder_tests.cpp | 100 +++++++++++++++++++++++++ tools/mambo-tests/stubs/board.h | 8 ++ tools/mambo-tests/stubs/hal.h | 3 + tools/mambo-tests/stubs/hal_settings.h | 16 ++++ tools/mambo-tests/stubs/stm32_hal_ll.h | 47 ++++++++++++ 13 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 radio/src/targets/mambo/encoder.cpp create mode 100644 radio/src/targets/mambo/encoder.h create mode 100644 tools/mambo-tests/CMakeLists.txt create mode 100644 tools/mambo-tests/README.md create mode 100644 tools/mambo-tests/encoder_tests.cpp create mode 100644 tools/mambo-tests/stubs/board.h create mode 100644 tools/mambo-tests/stubs/hal.h create mode 100644 tools/mambo-tests/stubs/hal_settings.h create mode 100644 tools/mambo-tests/stubs/stm32_hal_ll.h diff --git a/.gitignore b/.gitignore index d57c92c4df1..0375783eef6 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ __pycache__/ output/ *.dmg .devcontainer/devcontainer-lock.json + +# Private radio recovery artifacts must remain local across branch switches. +/tools/mambo-runtime-harness/local-recovery/ diff --git a/radio/src/targets/mambo/CMakeLists.txt b/radio/src/targets/mambo/CMakeLists.txt index 49ad307f728..2e9c13e8e4f 100644 --- a/radio/src/targets/mambo/CMakeLists.txt +++ b/radio/src/targets/mambo/CMakeLists.txt @@ -11,7 +11,8 @@ set(RTC_BACKUP_RAM OFF CACHE BOOL "Preserve TBS bootloader backup SRAM metadata" set(TEST_BUILD_WARNING ON CACHE BOOL "Experimental Mambo bench build" FORCE) include(targets/taranis/CMakeLists.txt) target_sources(board PRIVATE targets/mambo/inputs.cpp targets/mambo/gimbals.cpp - targets/mambo/gimbals_stm32.cpp targets/mambo/calibration.cpp) + targets/mambo/gimbals_stm32.cpp targets/mambo/calibration.cpp + targets/mambo/encoder.cpp) if(MAMBO_EXTERNAL_CRSF) target_sources(board PRIVATE targets/mambo/external_rf.cpp) endif() diff --git a/radio/src/targets/mambo/encoder.cpp b/radio/src/targets/mambo/encoder.cpp new file mode 100644 index 00000000000..4286ab941a1 --- /dev/null +++ b/radio/src/targets/mambo/encoder.cpp @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-2.0-only +#include "encoder.h" +#include "board.h" +#include "hal/rotary_encoder.h" +#include "stm32_exti_driver.h" +#include "stm32_hal_ll.h" + +void mambo::encoderStartPolling() +{ + const uint32_t mask = __get_PRIMASK(); + __disable_irq(); + // Sample both phases independently of the shared EXTI routing. + stm32_exti_disable(ROTARY_ENCODER_EXTI_LINE1); + stm32_exti_disable(ROTARY_ENCODER_EXTI_LINE2); + LL_EXTI_ClearFlag_0_31(ROTARY_ENCODER_EXTI_LINE1 | + ROTARY_ENCODER_EXTI_LINE2); + // TIM10 uses APB2, not the APB1 clock assumed by the generic initializer. + ROTARY_ENCODER_TIMER->PSC = + (PERI2_FREQUENCY * TIMER_MULT_APB2) / 1000000 - 1; + rotaryEncoderStartPolling(); + __set_PRIMASK(mask); +} diff --git a/radio/src/targets/mambo/encoder.h b/radio/src/targets/mambo/encoder.h new file mode 100644 index 00000000000..bc1c226ff5d --- /dev/null +++ b/radio/src/targets/mambo/encoder.h @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once + +namespace mambo { +// Call after rotaryEncoderInit has configured the GPIO and timer interrupt. +void encoderStartPolling(); +} // namespace mambo diff --git a/radio/src/targets/mambo/hal.h b/radio/src/targets/mambo/hal.h index 6259a52da2d..1847c06a25e 100644 --- a/radio/src/targets/mambo/hal.h +++ b/radio/src/targets/mambo/hal.h @@ -57,6 +57,9 @@ void mamboExternalModuleShutdown(); #endif #define TELEMETRY_EXTI_PRIO 0 +// Use continuous TIM10 sampling for the Mambo wheel in every board profile. +#define ROTARY_ENCODER_POLLING + #define BACKLIGHT_GPIO GPIO_PIN(GPIOD, 15) #define BACKLIGHT_GPIO_AF GPIO_AF2 #define BACKLIGHT_TIMER TIM4 diff --git a/radio/src/targets/taranis/board.cpp b/radio/src/targets/taranis/board.cpp index e88b6921723..d440f046282 100644 --- a/radio/src/targets/taranis/board.cpp +++ b/radio/src/targets/taranis/board.cpp @@ -33,6 +33,9 @@ #include "hal/rgbleds.h" #include "board.h" +#if defined(RADIO_MAMBO) +#include "targets/mambo/encoder.h" +#endif #include "boards/generic_stm32/module_ports.h" #include "boards/generic_stm32/intmodule_heartbeat.h" #include "boards/generic_stm32/analog_inputs.h" @@ -230,6 +233,9 @@ void boardInit() #if defined(ROTARY_ENCODER_NAVIGATION) rotaryEncoderInit(); +#if defined(RADIO_MAMBO) + mambo::encoderStartPolling(); +#endif #endif gimbalsDetect(); diff --git a/tools/mambo-tests/CMakeLists.txt b/tools/mambo-tests/CMakeLists.txt new file mode 100644 index 00000000000..b4e6906114e --- /dev/null +++ b/tools/mambo-tests/CMakeLists.txt @@ -0,0 +1,14 @@ +cmake_minimum_required(VERSION 3.20) +project(mambo_board_tests LANGUAGES CXX) +enable_testing() + +add_executable(mambo_encoder encoder_tests.cpp + ../../radio/src/targets/mambo/encoder.cpp + ../../radio/src/targets/common/arm/stm32/rotary_encoder_driver.cpp) +target_compile_features(mambo_encoder PRIVATE cxx_std_17) +target_include_directories(mambo_encoder PRIVATE stubs ../../radio/src + ../../radio/src/targets/common/arm/stm32 ../../radio/src/targets/simu) +target_compile_definitions(mambo_encoder PRIVATE BOOT SIMU) +target_compile_options(mambo_encoder PRIVATE -Wall -Wextra -Werror + -include ${CMAKE_CURRENT_SOURCE_DIR}/stubs/stm32_hal_ll.h) +add_test(NAME mambo_encoder COMMAND mambo_encoder) diff --git a/tools/mambo-tests/README.md b/tools/mambo-tests/README.md new file mode 100644 index 00000000000..0da9dd6898b --- /dev/null +++ b/tools/mambo-tests/README.md @@ -0,0 +1,20 @@ +# Standalone Mambo board tests + +Run without the radio, RF firmware, an RTOS or private calibration files: + +```sh +cmake -S tools/mambo-tests -B /tmp/mambo-board-tests +cmake --build /tmp/mambo-board-tests +ctest --test-dir /tmp/mambo-board-tests --output-on-failure +``` + +The production encoder decoder and board polling helper are exercised against +register fakes: APB2 timer rate, EXTI release, interrupt-mask restoration, +direction, bounce, stable samples, ENTER suppression and off-detent startup. +These are host tests, not physical timing measurements. + +`mambo-support` retains the standalone board and external CRSF support, direct +Hall input/calibration, Lua rotary navigation events and encoder polling fix. +Internal stock-RF integration, its startup/SD/IRQ handling, runtime harness and +diagnostics live on the separate `mambo-tracer` branch. Branch separation does +not change the firmware currently installed on the radio. diff --git a/tools/mambo-tests/encoder_tests.cpp b/tools/mambo-tests/encoder_tests.cpp new file mode 100644 index 00000000000..f6bc401ff1a --- /dev/null +++ b/tools/mambo-tests/encoder_tests.cpp @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: GPL-2.0-only +#include "board.h" +#include "hal/key_driver.h" +#include "hal/rotary_encoder.h" +#include "stm32_exti_driver.h" +#include "targets/mambo/encoder.h" +#include +#include +#include + +#define CHECK(x) do { if (!(x)) { std::fprintf(stderr, "line %d: %s\n", __LINE__, #x); std::exit(1); } } while (0) +GPIO_TypeDef encoderGpio; +TIM_TypeDef encoderTimer; +ExtiRegisters exti; +uint32_t interruptMask = 0, extiPort[16]{}; +bool irqEnabled[3]{}; +static stm32_exti_handler_t handlers[16]{}; +static uint32_t pressedKeys = 0; +uint32_t readKeys() { return pressedKeys; } +void stm32_gpio_enable_clock(GPIO_TypeDef*) {} +void stm32_timer_enable_clock(TIM_TypeDef*) {} +void stm32_exti_enable(uint32_t line, uint8_t, stm32_exti_handler_t handler) +{ + const unsigned bit = __builtin_ctz(line); + handlers[bit] = handler; + exti.IMR |= line; + NVIC_EnableIRQ(bit < 10 ? EXTI9_5_IRQn : EXTI15_10_IRQn); +} +void stm32_exti_disable(uint32_t line) +{ + const unsigned bit = __builtin_ctz(line); + handlers[bit] = nullptr; + exti.IMR &= ~line; + bool any = false; + for (unsigned i = bit < 10 ? 5 : 10; i <= (bit < 10 ? 9u : 15u); ++i) + any = any || handlers[i]; + if (!any) NVIC_DisableIRQ(bit < 10 ? EXTI9_5_IRQn : EXTI15_10_IRQn); +} +extern "C" void TIM1_UP_TIM10_IRQHandler(); +static void sample(unsigned pins) +{ + encoderGpio.IDR = ((pins & 1) << 8) | ((pins & 2) << 9); + CHECK(encoderTimer.CR1 & TIM_CR1_CEN); + encoderTimer.SR |= TIM_SR_UIF; + TIM1_UP_TIM10_IRQHandler(); + CHECK(!(encoderTimer.SR & TIM_SR_UIF)); +} +static void turn(bool reverse, unsigned detents) +{ + for (unsigned i = 0; i < detents; i += 2) { + const unsigned a = reverse ? 2 : 1, b = reverse ? 1 : 2; + for (unsigned state : {a, 0u, a, a, 3u, b, 3u, b, 0u}) sample(state); + } +} +static void otherExti() {} + +int main() +{ + rotaryEncoderInit(); + CHECK(handlers[8] && handlers[10]); + handlers[8](); sample(0); + CHECK(!(encoderTimer.CR1 & TIM_CR1_CEN)); + rotaryEncoderInit(); + stm32_exti_enable(1u << 14, 3, otherExti); + exti.PR = LL_EXTI_LINE_8 | LL_EXTI_LINE_10 | (1u << 14); + extiPort[10] = 4; // Another peripheral may own this shared route. + mambo::encoderStartPolling(); + CHECK(interruptMask == 0 && !handlers[8] && !handlers[10]); + CHECK(exti.IMR == (1u << 14) && exti.PR == (1u << 14)); + CHECK(encoderTimer.PSC == 167 && encoderTimer.ARR == 499); + CHECK(irqEnabled[TIM1_UP_TIM10_IRQn] && irqEnabled[EXTI15_10_IRQn]); + const auto before = rotaryEncoderGetValue(); + turn(false, 20); + CHECK(rotaryEncoderGetValue() == before - 20); + turn(true, 20); + CHECK(rotaryEncoderGetValue() == before); + sample(0); + CHECK(rotaryEncoderGetValue() == before); + pressedKeys = 1u << KEY_ENTER; + turn(false, 4); + CHECK(rotaryEncoderGetValue() == before); + pressedKeys = 0; + turn(false, 4); + CHECK(rotaryEncoderGetValue() == before - 4); + CHECK(extiPort[10] == 4); + + interruptMask = 1; + mambo::encoderStartPolling(); + CHECK(interruptMask == 1); + interruptMask = 0; + encoderGpio.IDR = LL_GPIO_PIN_8; // Start between detents. + rotaryEncoderInit(); + mambo::encoderStartPolling(); + const auto offDetent = rotaryEncoderGetValue(); + sample(1); sample(3); + CHECK(rotaryEncoderGetValue() == offDetent); + turn(false, 4); + CHECK(encoderTimer.CR1 & TIM_CR1_CEN); + std::puts("Standalone Mambo polling: timer clock, EXTI ownership, mask, bounce, directions, ENTER and startup passed."); +} diff --git a/tools/mambo-tests/stubs/board.h b/tools/mambo-tests/stubs/board.h new file mode 100644 index 00000000000..5f58ebdf0ce --- /dev/null +++ b/tools/mambo-tests/stubs/board.h @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once +#include "hal.h" +#include "hal_settings.h" +#define PERI1_FREQUENCY 42000000u +#define TIMER_MULT_APB1 2u +#define PERI2_FREQUENCY 84000000u +#define TIMER_MULT_APB2 2u diff --git a/tools/mambo-tests/stubs/hal.h b/tools/mambo-tests/stubs/hal.h new file mode 100644 index 00000000000..185aa8c3edf --- /dev/null +++ b/tools/mambo-tests/stubs/hal.h @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once +#include "targets/mambo/hal.h" diff --git a/tools/mambo-tests/stubs/hal_settings.h b/tools/mambo-tests/stubs/hal_settings.h new file mode 100644 index 00000000000..52f349cd8a1 --- /dev/null +++ b/tools/mambo-tests/stubs/hal_settings.h @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once +#define ROTARY_ENCODER_GRANULARITY 2 +#define ROTARY_ENCODER_INVERTED +#define ROTARY_ENCODER_GPIO GPIOA +#define ROTARY_ENCODER_GPIO_PIN_A LL_GPIO_PIN_8 +#define ROTARY_ENCODER_GPIO_PIN_B LL_GPIO_PIN_10 +#define ROTARY_ENCODER_POSITION (((GPIOA->IDR >> 9) & 2) | ((GPIOA->IDR >> 8) & 1)) +#define ROTARY_ENCODER_EXTI_LINE1 LL_EXTI_LINE_8 +#define ROTARY_ENCODER_EXTI_LINE2 LL_EXTI_LINE_10 +#define ROTARY_ENCODER_EXTI_PORT LL_SYSCFG_EXTI_PORTA +#define ROTARY_ENCODER_EXTI_SYS_LINE1 LL_SYSCFG_EXTI_LINE8 +#define ROTARY_ENCODER_EXTI_SYS_LINE2 LL_SYSCFG_EXTI_LINE10 +#define ROTARY_ENCODER_TIMER TIM10 +#define ROTARY_ENCODER_TIMER_IRQn TIM1_UP_TIM10_IRQn +#define ROTARY_ENCODER_TIMER_IRQHandler TIM1_UP_TIM10_IRQHandler diff --git a/tools/mambo-tests/stubs/stm32_hal_ll.h b/tools/mambo-tests/stubs/stm32_hal_ll.h new file mode 100644 index 00000000000..490603bc630 --- /dev/null +++ b/tools/mambo-tests/stubs/stm32_hal_ll.h @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once +#include + +struct GPIO_TypeDef { uint32_t IDR = 0; }; +struct TIM_TypeDef { + uint32_t CR1 = 0, ARR = 0, PSC = 0, CCER = 0, CCMR1 = 0; + uint32_t EGR = 0, DIER = 0, SR = 0; +}; +struct ExtiRegisters { uint32_t IMR = 0, PR = 0; }; +struct LL_GPIO_InitTypeDef { uint32_t Mode = 0, Pull = 0, Pin = 0; }; +enum IRQn_Type { EXTI9_5_IRQn, EXTI15_10_IRQn, TIM1_UP_TIM10_IRQn }; +extern GPIO_TypeDef encoderGpio; +extern TIM_TypeDef encoderTimer; +extern ExtiRegisters exti; +extern uint32_t interruptMask, extiPort[16]; +extern bool irqEnabled[3]; +#define GPIOA (&encoderGpio) +#define TIM10 (&encoderTimer) +#define EXTI (&exti) +#define LL_GPIO_PIN_8 (1u << 8) +#define LL_GPIO_PIN_10 (1u << 10) +#define LL_EXTI_LINE_8 (1u << 8) +#define LL_EXTI_LINE_10 (1u << 10) +#define LL_GPIO_MODE_INPUT 0u +#define LL_GPIO_PULL_UP 1u +#define LL_EXTI_TRIGGER_RISING_FALLING 3u +#define LL_APB2_GRP1_PERIPH_SYSCFG 1u +#define LL_SYSCFG_EXTI_PORTA 0u +#define LL_SYSCFG_EXTI_LINE8 8u +#define LL_SYSCFG_EXTI_LINE10 10u +#define TIM_CR1_CEN 1u +#define TIM_CR1_URS 4u +#define TIM_DIER_UIE 1u +#define TIM_SR_UIF 1u +#define TIM_EGR_UG 1u +inline uint32_t __get_PRIMASK() { return interruptMask; } +inline void __disable_irq() { interruptMask = 1; } +inline void __set_PRIMASK(uint32_t value) { interruptMask = value; } +inline void NVIC_EnableIRQ(IRQn_Type irq) { irqEnabled[irq] = true; } +inline void NVIC_DisableIRQ(IRQn_Type irq) { irqEnabled[irq] = false; } +inline void NVIC_SetPriority(IRQn_Type, unsigned) {} +inline void LL_GPIO_StructInit(LL_GPIO_InitTypeDef* init) { *init = {}; } +inline void LL_GPIO_Init(GPIO_TypeDef*, LL_GPIO_InitTypeDef*) {} +inline void LL_APB2_GRP1_EnableClock(unsigned) {} +inline void LL_SYSCFG_SetEXTISource(unsigned port, unsigned line) { extiPort[line] = port; } +inline void LL_EXTI_ClearFlag_0_31(uint32_t lines) { EXTI->PR &= ~lines; } From 532df0cfcfb1f8dde8973cf317760d65a94d96f2 Mon Sep 17 00:00:00 2001 From: Ilya Date: Sat, 17 Oct 2026 16:52:29 +0100 Subject: [PATCH 04/11] feat(mambo): include external CRSF in the default firmware --- CMakePresets.json | 8 +------- radio/src/gui/128x64/radio_diaganas.cpp | 14 -------------- radio/src/gui/gui_common.cpp | 2 +- radio/src/pulses/crossfire.cpp | 14 +++++++------- radio/src/pulses/pulses.cpp | 2 +- .../common/arm/stm32/stm32_usart_driver.cpp | 6 +++--- radio/src/targets/mambo/CMakeLists.txt | 9 +-------- radio/src/targets/mambo/hal.h | 6 ------ radio/src/targets/mambo/inputs.cpp | 4 ++-- radio/src/targets/taranis/CMakeLists.txt | 7 ++----- radio/src/targets/taranis/board.cpp | 2 +- 11 files changed, 19 insertions(+), 55 deletions(-) diff --git a/CMakePresets.json b/CMakePresets.json index f11bef21d4c..279d62e4288 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -354,15 +354,9 @@ }, { "name": "mambo", - "displayName": "TBS Mambo experimental (RF disabled)", + "displayName": "TBS Mambo experimental (external CRSF; internal RF disabled)", "inherits": "firmware", "cacheVariables": { "PCB": "MAMBO", "BOOTLOADER": false } - }, - { - "name": "mambo-external-crsf", - "displayName": "TBS Mambo experimental external Crossfire (Tracer disabled)", - "inherits": "mambo", - "cacheVariables": { "MAMBO_EXTERNAL_CRSF": true } } ], "buildPresets": [ diff --git a/radio/src/gui/128x64/radio_diaganas.cpp b/radio/src/gui/128x64/radio_diaganas.cpp index 8b03f866e55..4230b4bb00a 100644 --- a/radio/src/gui/128x64/radio_diaganas.cpp +++ b/radio/src/gui/128x64/radio_diaganas.cpp @@ -26,10 +26,8 @@ #include "tasks.h" #include "tasks/mixer_task.h" #include "timers_driver.h" -#if defined(MAMBO_EXTERNAL_CRSF) #include "hal/module_port.h" #include "targets/mambo/external_rf.h" -#endif static const char* mamboCalibrationLabel(MamboCalibrationStatus status) { @@ -102,13 +100,9 @@ static void drawMamboBench() { auto data = mamboBenchDiagnostics(); coord_t y = MENU_HEADER_HEIGHT + 1; -#if defined(MAMBO_EXTERNAL_CRSF) lcdDrawText( 0, y, modulePortPowered(EXTERNAL_MODULE) ? "Ext ON Cal:" : "Ext OFF Cal:"); -#else - lcdDrawText(0, y, "RF OFF Cal:"); -#endif lcdDrawText(78, y, mamboCalibrationLabel(data.calibration)); y += FH; lcdDrawText(0, y, "Hall us:"); @@ -128,7 +122,6 @@ static void drawMamboBench() lcdDrawText(0, y, "Reset mask:"); lcdDrawHexNumber(99, y, data.resetFlags >> 24); } -#if defined(MAMBO_EXTERNAL_CRSF) static void drawMamboExternal() { auto data = mamboExternalDiagnostics(); @@ -155,7 +148,6 @@ static void drawMamboExternal() lcdDrawNumber(128, y, data.pins, RIGHT); } #endif -#endif #define HOLDANAVALUEFRAMES 4 /* 4* 50ms = 200 ms update rate */ @@ -172,12 +164,8 @@ void menuRadioDiagAnalogs(event_t event) ANAVIEW_MAMBO_LEFT, ANAVIEW_MAMBO_RIGHT, ANAVIEW_MAMBO_BENCH, -#if defined(MAMBO_EXTERNAL_CRSF) ANAVIEW_MAMBO_EXTERNAL, ANAVIEW_LAST=ANAVIEW_MAMBO_EXTERNAL -#else - ANAVIEW_LAST=ANAVIEW_MAMBO_BENCH -#endif #else ANAVIEW_LAST=ANAVIEW_RAWLOWFPS #endif @@ -228,12 +216,10 @@ void menuRadioDiagAnalogs(event_t event) SIMPLE_SUBMENU("Mambo Bench", HEADER_LINE+1); drawMamboBench(); return; -#if defined(MAMBO_EXTERNAL_CRSF) case ANAVIEW_MAMBO_EXTERNAL: SIMPLE_SUBMENU("Mambo External", HEADER_LINE+1); drawMamboExternal(); return; -#endif #endif } diff --git a/radio/src/gui/gui_common.cpp b/radio/src/gui/gui_common.cpp index 82a0ae8cef5..28d35b21abb 100644 --- a/radio/src/gui/gui_common.cpp +++ b/radio/src/gui/gui_common.cpp @@ -1041,7 +1041,7 @@ bool isInternalModuleAvailable(int moduleType) #if defined(HARDWARE_EXTERNAL_MODULE) bool isExternalModuleAvailable(int moduleType) { -#if defined(MAMBO_EXTERNAL_CRSF) +#if defined(RADIO_MAMBO) // Only the stock CRSF telemetry path is implemented on this target. if (moduleType != MODULE_TYPE_NONE && moduleType != MODULE_TYPE_CROSSFIRE) return false; diff --git a/radio/src/pulses/crossfire.cpp b/radio/src/pulses/crossfire.cpp index 355f9f8fb2b..c2899f18f73 100644 --- a/radio/src/pulses/crossfire.cpp +++ b/radio/src/pulses/crossfire.cpp @@ -31,7 +31,7 @@ #include "crossfire.h" #include "telemetry/crossfire.h" -#if defined(MAMBO_EXTERNAL_CRSF) +#if defined(RADIO_MAMBO) #include "targets/mambo/external_rf.h" #endif @@ -241,7 +241,7 @@ static void crossfireSendPulses(void* ctx, uint8_t* buffer, int16_t* channels, u auto drv = modulePortGetSerialDrv(mod_st->tx); auto drv_ctx = modulePortGetCtx(mod_st->tx); drv->sendBuffer(drv_ctx, buffer, p_buf - buffer); -#if defined(MAMBO_EXTERNAL_CRSF) +#if defined(RADIO_MAMBO) if (module == EXTERNAL_MODULE && p_buf - buffer >= MIN_FRAME_LEN) mamboExternalTxObserved(buffer[2]); #endif @@ -262,7 +262,7 @@ static bool _validHdr(uint8_t* buf) static uint8_t* _processFrames(void* ctx, uint8_t* buf, uint8_t& len) { -#if defined(MAMBO_EXTERNAL_CRSF) +#if defined(RADIO_MAMBO) bool mamboExternal = modulePortGetModule((etx_module_state_t*)ctx) == EXTERNAL_MODULE; if (mamboExternal) mamboExternalParseObserved(); #endif @@ -274,7 +274,7 @@ static uint8_t* _processFrames(void* ctx, uint8_t* buf, uint8_t& len) // the buffer by one instead of pkt_len or throwing it all out uint8_t declared_len = p_buf[1]; if (!_validHdr(p_buf) || !_lenIsSane(declared_len)) { -#if defined(MAMBO_EXTERNAL_CRSF) +#if defined(RADIO_MAMBO) if (mamboExternal) mamboExternalHeaderErrorObserved(); #endif p_buf++; len--; @@ -288,7 +288,7 @@ static uint8_t* _processFrames(void* ctx, uint8_t* buf, uint8_t& len) } if (!_checkFrameCRC(p_buf)) { -#if defined(MAMBO_EXTERNAL_CRSF) +#if defined(RADIO_MAMBO) if (mamboExternal) mamboExternalCrcErrorObserved(); #endif TRACE("[XF] CRC error"); @@ -306,7 +306,7 @@ static uint8_t* _processFrames(void* ctx, uint8_t* buf, uint8_t& len) auto mod_st = (etx_module_state_t*)ctx; auto module = modulePortGetModule(mod_st); lastAlive[module] = get_tmr10ms(); // valid frame received, note timestamp -#if defined(MAMBO_EXTERNAL_CRSF) +#if defined(RADIO_MAMBO) if (mamboExternal) mamboExternalFrameObserved(p_buf[2]); #endif processCrossfireTelemetryFrame(module, p_buf, pkt_len); @@ -327,7 +327,7 @@ static void crossfireProcessFrame(void* ctx, uint8_t* frame, uint8_t frame_len, if (frame_len == 0) return; if (!_validHdr(frame)) { -#if defined(MAMBO_EXTERNAL_CRSF) +#if defined(RADIO_MAMBO) if (modulePortGetModule((etx_module_state_t*)ctx) == EXTERNAL_MODULE) mamboExternalHeaderErrorObserved(); #endif diff --git a/radio/src/pulses/pulses.cpp b/radio/src/pulses/pulses.cpp index 154f502667f..ce942db89d9 100644 --- a/radio/src/pulses/pulses.cpp +++ b/radio/src/pulses/pulses.cpp @@ -288,7 +288,7 @@ uint8_t getModuleType(uint8_t module) uint8_t getRequiredProtocol(uint8_t module) { uint8_t protocol = PROTOCOL_CHANNELS_UNINITIALIZED; -#if defined(MAMBO_EXTERNAL_CRSF) +#if defined(RADIO_MAMBO) // Reject unsupported protocols in persisted/imported models as well as UI. if (module != EXTERNAL_MODULE || (getModuleType(module) != MODULE_TYPE_NONE && diff --git a/radio/src/targets/common/arm/stm32/stm32_usart_driver.cpp b/radio/src/targets/common/arm/stm32/stm32_usart_driver.cpp index 7fb6de5de22..2db8722be17 100644 --- a/radio/src/targets/common/arm/stm32/stm32_usart_driver.cpp +++ b/radio/src/targets/common/arm/stm32/stm32_usart_driver.cpp @@ -27,7 +27,7 @@ #include "stm32_hal_ll.h" #include -#if defined(MAMBO_EXTERNAL_CRSF) +#if defined(RADIO_MAMBO) #include "targets/mambo/external_rf.h" #endif @@ -777,7 +777,7 @@ void stm32_usart_isr(const stm32_usart_t* usart, etx_serial_callbacks_t* cb) // This will clear the RXNE/error bits in USART_SR register uint8_t data = LL_USART_ReceiveData8(usart->USARTx); -#if defined(MAMBO_EXTERNAL_CRSF) +#if defined(RADIO_MAMBO) if (usart->USARTx == USART2) mamboExternalRxObserved(data, (status & USART_FLAG_ERRORS) != 0); #endif @@ -811,7 +811,7 @@ void stm32_usart_isr(const stm32_usart_t* usart, etx_serial_callbacks_t* cb) } if (LL_USART_IsEnabledIT_IDLE(usart->USARTx) && idle) { -#if defined(MAMBO_EXTERNAL_CRSF) +#if defined(RADIO_MAMBO) if (usart->USARTx == USART2) mamboExternalIdleObserved(); #endif // ISR clear sequence diff --git a/radio/src/targets/mambo/CMakeLists.txt b/radio/src/targets/mambo/CMakeLists.txt index 2e9c13e8e4f..300d9a516fe 100644 --- a/radio/src/targets/mambo/CMakeLists.txt +++ b/radio/src/targets/mambo/CMakeLists.txt @@ -1,9 +1,5 @@ # Experimental application-only Mambo target. The existing TBS bootloader and # RF firmware are not built or included; internal Tracer remains disabled. -option(MAMBO_EXTERNAL_CRSF "Experimental Mambo external Crossfire bay" OFF) -if(MAMBO_EXTERNAL_CRSF) - add_definitions(-DMAMBO_EXTERNAL_CRSF) -endif() set(BOOTLOADER OFF CACHE BOOL "Preserve original TBS bootloader" FORCE) set(FWDRIVE OFF CACHE BOOL "Do not expose a firmware-write USB drive" FORCE) set(FIRMWARE_STRIP_BOOTLOADER YES) @@ -12,7 +8,4 @@ set(TEST_BUILD_WARNING ON CACHE BOOL "Experimental Mambo bench build" FORCE) include(targets/taranis/CMakeLists.txt) target_sources(board PRIVATE targets/mambo/inputs.cpp targets/mambo/gimbals.cpp targets/mambo/gimbals_stm32.cpp targets/mambo/calibration.cpp - targets/mambo/encoder.cpp) -if(MAMBO_EXTERNAL_CRSF) - target_sources(board PRIVATE targets/mambo/external_rf.cpp) -endif() + targets/mambo/encoder.cpp targets/mambo/external_rf.cpp) diff --git a/radio/src/targets/mambo/hal.h b/radio/src/targets/mambo/hal.h index 1847c06a25e..e798e56aa4d 100644 --- a/radio/src/targets/mambo/hal.h +++ b/radio/src/targets/mambo/hal.h @@ -22,7 +22,6 @@ #define PWR_ON_GPIO GPIO_PIN(GPIOB, 12) #define EXTMODULE_PWR_GPIO GPIO_PIN(GPIOE, 2) -#if defined(MAMBO_EXTERNAL_CRSF) void mamboExternalModulePower(bool enable); void mamboExternalModuleShutdown(); #define EXTERNAL_MODULE_PWR_ON() mamboExternalModulePower(true) @@ -50,11 +49,6 @@ void mamboExternalModuleShutdown(); #define TELEMETRY_RX_FRAME_EXTI_LINE LL_EXTI_LINE_4 #define USE_EXTI4_IRQ #define EXTI4_IRQ_Priority 5 -#else -#define EXTERNAL_MODULE_PWR_ON() do {} while (0) -#define EXTERNAL_MODULE_PWR_OFF() gpio_clear(EXTMODULE_PWR_GPIO) -#define IS_EXTERNAL_MODULE_ON() false -#endif #define TELEMETRY_EXTI_PRIO 0 // Use continuous TIM10 sampling for the Mambo wheel in every board profile. diff --git a/radio/src/targets/mambo/inputs.cpp b/radio/src/targets/mambo/inputs.cpp index 9a3e36ef725..f3b1e0c72cd 100644 --- a/radio/src/targets/mambo/inputs.cpp +++ b/radio/src/targets/mambo/inputs.cpp @@ -8,8 +8,8 @@ void mamboEarlyInit() { - // RF and gimbals share SPI2. No RF startup or module power is permitted in - // this bench target; hold the SX1281 reset and de-select both Hall sensors. + // Internal RF and gimbals share SPI2. Hold the internal SX1281 in reset and + // de-select both Hall sensors. The model can enable the external bay later. gpio_init(MAMBO_RF_RESET_GPIO, GPIO_OUT, GPIO_PIN_SPEED_LOW); gpio_clear(MAMBO_RF_RESET_GPIO); gpio_init(GPIO_PIN(GPIOE, 3), GPIO_OUT, GPIO_PIN_SPEED_LOW); diff --git a/radio/src/targets/taranis/CMakeLists.txt b/radio/src/targets/taranis/CMakeLists.txt index 7709772e79c..3afcaf2dd44 100644 --- a/radio/src/targets/taranis/CMakeLists.txt +++ b/radio/src/targets/taranis/CMakeLists.txt @@ -461,11 +461,8 @@ elseif(PCB STREQUAL MAMBO) set(BITMAPS_TARGET 9x_bitmaps) set(FONTS_TARGET 9x_fonts_1bit) set(LCD_DRIVER lcd_driver_spi.cpp) - set(HARDWARE_EXTERNAL_MODULE NO) - if(MAMBO_EXTERNAL_CRSF) - set(HARDWARE_EXTERNAL_MODULE YES) - set(MODULE_SIZE_SML YES) - endif() + set(HARDWARE_EXTERNAL_MODULE YES) + set(MODULE_SIZE_SML YES) add_definitions(-DPCBX7 -DPCBMAMBO -DSOFTWARE_VOLUME -DPWR_BUTTON_PRESS) endif() diff --git a/radio/src/targets/taranis/board.cpp b/radio/src/targets/taranis/board.cpp index d440f046282..c419fb3cb3a 100644 --- a/radio/src/targets/taranis/board.cpp +++ b/radio/src/targets/taranis/board.cpp @@ -296,7 +296,7 @@ void boardInit() void boardOff() { -#if defined(MAMBO_EXTERNAL_CRSF) +#if defined(RADIO_MAMBO) // Cover emergency shutdown as well as the ordinary protocol-stop path. mamboExternalModuleShutdown(); #endif From 8773d9a5f7a40f881e4f328df8a33a757932c42f Mon Sep 17 00:00:00 2001 From: Ilya Date: Sat, 17 Oct 2026 16:54:07 +0100 Subject: [PATCH 05/11] test(mambo): include standalone board coverage and PR validation --- .github/workflows/mambo.yml | 56 +++++ radio/src/targets/mambo/README.txt | 72 +++++++ tools/mambo-tests/CMakeLists.txt | 41 ++++ tools/mambo-tests/README.md | 28 ++- tools/mambo-tests/battery_stubs/board.h | 5 + tools/mambo-tests/battery_stubs/edgetx.h | 5 + .../battery_stubs/hal/adc_driver.h | 9 + tools/mambo-tests/battery_tests.cpp | 43 ++++ tools/mambo-tests/calibration_fixtures.py | 62 ++++++ tools/mambo-tests/calibration_probe.cpp | 49 +++++ tools/mambo-tests/external_power_tests.cpp | 48 +++++ tools/mambo-tests/external_stubs/edgetx.h | 9 + tools/mambo-tests/external_stubs/hal/gpio.h | 6 + tools/mambo-tests/external_stubs/stm32_gpio.h | 8 + tools/mambo-tests/hall_tests.cpp | 197 ++++++++++++++++++ tools/mambo-tests/power_stubs/board.h | 10 + .../power_stubs/hal/abnormal_reboot.h | 3 + tools/mambo-tests/power_stubs/hal/gpio.h | 8 + .../mambo-tests/power_stubs/hal/usb_driver.h | 2 + tools/mambo-tests/power_tests.cpp | 51 +++++ tools/mambo-tests/test_calibration.py | 156 ++++++++++++++ tools/mambo-tests/trim_tests.cpp | 17 ++ tools/mambo-tests/verify_firmware.py | 85 ++++++++ 23 files changed, 966 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/mambo.yml create mode 100644 radio/src/targets/mambo/README.txt create mode 100644 tools/mambo-tests/battery_stubs/board.h create mode 100644 tools/mambo-tests/battery_stubs/edgetx.h create mode 100644 tools/mambo-tests/battery_stubs/hal/adc_driver.h create mode 100644 tools/mambo-tests/battery_tests.cpp create mode 100644 tools/mambo-tests/calibration_fixtures.py create mode 100644 tools/mambo-tests/calibration_probe.cpp create mode 100644 tools/mambo-tests/external_power_tests.cpp create mode 100644 tools/mambo-tests/external_stubs/edgetx.h create mode 100644 tools/mambo-tests/external_stubs/hal/gpio.h create mode 100644 tools/mambo-tests/external_stubs/stm32_gpio.h create mode 100644 tools/mambo-tests/hall_tests.cpp create mode 100644 tools/mambo-tests/power_stubs/board.h create mode 100644 tools/mambo-tests/power_stubs/hal/abnormal_reboot.h create mode 100644 tools/mambo-tests/power_stubs/hal/gpio.h create mode 100644 tools/mambo-tests/power_stubs/hal/usb_driver.h create mode 100644 tools/mambo-tests/power_tests.cpp create mode 100644 tools/mambo-tests/test_calibration.py create mode 100644 tools/mambo-tests/trim_tests.cpp create mode 100644 tools/mambo-tests/verify_firmware.py diff --git a/.github/workflows/mambo.yml b/.github/workflows/mambo.yml new file mode 100644 index 00000000000..7568d7b961d --- /dev/null +++ b/.github/workflows/mambo.yml @@ -0,0 +1,56 @@ +name: Experimental Mambo checks +on: + pull_request: + paths: + - '.github/workflows/mambo.yml' + - 'CMakeLists.txt' + - 'CMakePresets.json' + - 'cmake/**' + - 'radio/**' + - 'tools/hwdef_schema.json' + - 'tools/mambo-tests/**' + - 'pyproject.toml' + - 'uv.lock' + workflow_dispatch: + +permissions: + contents: read + +jobs: + host: + name: Standalone Mambo board tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - run: cmake -S tools/mambo-tests -B build/mambo-tests + - run: cmake --build build/mambo-tests --parallel 2 + - run: ctest --test-dir build/mambo-tests --output-on-failure + + firmware: + name: Build default Mambo with external CRSF + runs-on: ubuntu-latest + timeout-minutes: 20 + if: | + github.event_name != 'pull_request' || + !contains(github.event.pull_request.labels.*.name, 'ci: skip-fw') + container: + image: ghcr.io/edgetx/edgetx-dev:latest + volumes: + - ${{ github.workspace }}:/src + steps: + - uses: actions/checkout@v7 + with: + submodules: recursive + - uses: astral-sh/setup-uv@v10.0.1 + - run: uv sync --locked + - name: Configure the default Mambo build with Lua enabled + run: >- + uv run cmake --preset mambo + -DPython3_EXECUTABLE="$GITHUB_WORKSPACE/.venv/bin/python" + -DLUA=ON -DLUA_COMPILER=ON -DLUA_MIXER=ON + - run: cmake --build build/mambo --target firmware --parallel 4 + - name: Check memory boundaries and external CRSF interrupt vectors + run: >- + uv run python tools/mambo-tests/verify_firmware.py + build/mambo/firmware.elf build/mambo/firmware.bin diff --git a/radio/src/targets/mambo/README.txt b/radio/src/targets/mambo/README.txt new file mode 100644 index 00000000000..a9ce8d8320f --- /dev/null +++ b/radio/src/targets/mambo/README.txt @@ -0,0 +1,72 @@ +Experimental TBS Mambo support +============================= + +This is an application-only STM32F407 target using the original TBS bootloader. +Internal Tracer RF is not started, linked or updated on mambo-support. Its +experimental integration belongs to the separate mambo-tracer branch. + +Default build +------------- +Use the normal EdgeTX build dependencies and ARM toolchain. From the repo root: + + cmake --preset mambo -DLUA=ON -DLUA_COMPILER=ON -DLUA_MIXER=ON + cmake --build build/mambo --target firmware + +External-bay CRSF support is always included; no additional build option or +separate firmware profile is required. Set Model Setup -> External RF to CRSF +to enable the bay, or Off to leave it unpowered. Only Off and CRSF are supported; +other persisted/imported protocols are rejected and cannot enable bay power. The +working external path is USART2, DMA1 Stream6, with both inverter controls low +for normal CRSF. The built-in Tracer remains disabled. + +Installation and calibration +---------------------------- +The application starts at 0x0800C000 and must stay below 0x080C0000. The linker +also reserves shared SRAM and the first 1 KiB of CCM. Firmware images must not +contain a bootloader or internal RF payload. Generic DFU flashing and a USB +firmware-write drive are deliberately not enabled for this target. Installation +and rollback require a separately verified, original-bootloader-compatible +application uploader; no uploader or stock firmware is included in this PR. + +Preserve the original firmware, SD contents and the radio's own calibration +backup before testing. The direct Hall backend can READ /CROSSFIRE/EEPROM.bin +(604 bytes) and validates its file, angle and corner checksums before using the +factory correction. It never creates, repairs or overwrites that file. Only +the currently supported format has been checked; arbitrary/other-version +backups are not guaranteed compatible. Without valid factory calibration, +the provisional projection is not proof of correct full-travel geometry. + +Run ordinary EdgeTX stick calibration and select the correct stick Mode. Mode 2 +uses the left vertical axis for throttle; default TAER channel order does not +select stick Mode. A throttle-idle warning was resolved on the test radio by +selecting Mode 2; the warning was not disabled or relaxed in firmware. + +Radio Settings -> Analogs: PAGE cycles through Mambo Hall L/R and Mambo Bench. +The default build also has Mambo External (power, TX/RX, parsed frames and +errors). Cal SD OK means the factory backup passed validation, not that the +user's EdgeTX calibration or radio Mode is necessarily correct. + +Validation and limits (2026-10-17) +-------------------------------- +On one physical Mambo, the owner confirmed normal startup, working encoder and +Hall sticks with the RF-disabled b618ccbe89 build. Its separate external-CRSF +build was subsequently flashed with a matching terminal checksum and the +owner confirmed that the external bay works. That already-working bay path +is now included in the default Mambo build, with model-level Off/CRSF control. +The new default build has not yet been flashed/tested on hardware. Direct Hall +acquisition and continuous APB2/TIM10 encoder polling are retained. Earlier +repeated cold-boot/centering/direction reports for an internal-RF diagnostic image must +not be presented as testing of these standalone applications. + +Local verification covers the default Mambo firmware, application memory +boundaries and bay UART/DMA vectors, eight standalone host suites, hardware +schema/template generation, and a TX12 Mark II firmware regression using its +normal USE_FW_LTO=YES build setting. See tools/mambo-tests/README.md for the +reproducible tests; no private calibration files or captured firmware required. + +Keep PR #7907 experimental/draft. Receiver failsafe, long-duration stability, +charging/off-state behavior, physical emergency/power-off bay checks, faster +CRSF negotiation/interoperability and Companion/release support remain open. +This is bench evidence, not flight validation. No nightly or release target +is added, and no private calibration, captured binaries or RF research tooling +belongs in the patch. diff --git a/tools/mambo-tests/CMakeLists.txt b/tools/mambo-tests/CMakeLists.txt index b4e6906114e..7b717e12315 100644 --- a/tools/mambo-tests/CMakeLists.txt +++ b/tools/mambo-tests/CMakeLists.txt @@ -2,6 +2,14 @@ cmake_minimum_required(VERSION 3.20) project(mambo_board_tests LANGUAGES CXX) enable_testing() +function(mambo_test target) + add_executable(${target} ${ARGN}) + target_compile_features(${target} PRIVATE cxx_std_17) + target_include_directories(${target} PRIVATE ../../radio/src) + target_compile_options(${target} PRIVATE -Wall -Wextra -Werror) + add_test(NAME ${target} COMMAND ${target}) +endfunction() + add_executable(mambo_encoder encoder_tests.cpp ../../radio/src/targets/mambo/encoder.cpp ../../radio/src/targets/common/arm/stm32/rotary_encoder_driver.cpp) @@ -12,3 +20,36 @@ target_compile_definitions(mambo_encoder PRIVATE BOOT SIMU) target_compile_options(mambo_encoder PRIVATE -Wall -Wextra -Werror -include ${CMAKE_CURRENT_SOURCE_DIR}/stubs/stm32_hal_ll.h) add_test(NAME mambo_encoder COMMAND mambo_encoder) + +mambo_test(mambo_hall hall_tests.cpp ../../radio/src/targets/mambo/gimbals.cpp) +mambo_test(mambo_trims trim_tests.cpp) +mambo_test(mambo_battery battery_tests.cpp + ../../radio/src/boards/generic_stm32/battery_voltage.cpp) +target_include_directories(mambo_battery BEFORE PRIVATE battery_stubs) +target_compile_options(mambo_battery PRIVATE -U__weak) + +mambo_test(mambo_external external_power_tests.cpp + ../../radio/src/targets/mambo/external_rf.cpp) +target_include_directories(mambo_external BEFORE PRIVATE external_stubs) +target_compile_definitions(mambo_external PRIVATE RADIO_MAMBO) + +foreach(polarity IN ITEMS high low) + mambo_test(mambo_power_${polarity} power_tests.cpp + ../../radio/src/targets/common/arm/stm32/pwr_driver.cpp) + target_include_directories(mambo_power_${polarity} BEFORE PRIVATE power_stubs + ../../radio/src/targets/common/arm/stm32) + if(polarity STREQUAL "high") + target_compile_definitions(mambo_power_${polarity} PRIVATE + PWR_SWITCH_PIN_ACTIVE_HIGH PWR_BUTTON_PRESS) + endif() +endforeach() + +add_executable(mambo_calibration_probe calibration_probe.cpp + ../../radio/src/targets/mambo/calibration.cpp) +target_compile_features(mambo_calibration_probe PRIVATE cxx_std_17) +target_compile_options(mambo_calibration_probe PRIVATE -Wall -Wextra -Werror) +target_include_directories(mambo_calibration_probe PRIVATE ../../radio/src) +find_package(Python3 REQUIRED COMPONENTS Interpreter) +add_test(NAME mambo_calibration COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/test_calibration.py + $) diff --git a/tools/mambo-tests/README.md b/tools/mambo-tests/README.md index 0da9dd6898b..3c9d6465beb 100644 --- a/tools/mambo-tests/README.md +++ b/tools/mambo-tests/README.md @@ -8,10 +8,30 @@ cmake --build /tmp/mambo-board-tests ctest --test-dir /tmp/mambo-board-tests --output-on-failure ``` -The production encoder decoder and board polling helper are exercised against -register fakes: APB2 timer rate, EXTI release, interrupt-mask restoration, -direction, bounce, stable samples, ENTER suppression and off-detent startup. -These are host tests, not physical timing measurements. +Requires a C++17 compiler, CMake 3.20+ and Python 3. The eight suites exercise +production code using only synthetic data and register/GPIO fakes: + +- Encoder: APB2 timer rate, EXTI release, interrupt-mask restoration, + direction, bounce, stable samples, ENTER suppression and off-detent startup. +- Hall sensors: initialization/readback, conversion rearming, data-ready and + transport failures, freshness, timestamp wrapping and projection bounds. +- Calibration: both file checksums, signed parameters, record corruption, + invalid geometry, and comparisons with independent double-precision math. +- Trim ladder: nominal resistor ratios, idle, shorted and boundary inputs. +- External bay: all 256 persisted protocol values, model-controlled Off/CRSF, + power-off and emergency-shutdown inhibition. +- Power button: active-high Mambo and unchanged active-low behavior. +- Battery: full filtered-ADC range, calibration and absent-input behavior. + +These are host tests, not physical timing measurements. The calibration +fixtures are artificial and must never be installed on a radio. + +`verify_firmware.py` checks an application ELF and its matching binary against +the flash/SRAM/CCM reservations and verifies the real external CRSF UART/DMA +interrupt vectors and board/bay symbols in the default Mambo build. It requires +`pyelftools` (included in the repository's Python dependencies). It never opens +USB or writes to a radio. `.github/workflows/mambo.yml` runs the host tests and +builds/checks default Mambo without adding a nightly/release target. `mambo-support` retains the standalone board and external CRSF support, direct Hall input/calibration, Lua rotary navigation events and encoder polling fix. diff --git a/tools/mambo-tests/battery_stubs/board.h b/tools/mambo-tests/battery_stubs/board.h new file mode 100644 index 00000000000..29dc2715604 --- /dev/null +++ b/tools/mambo-tests/battery_stubs/board.h @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once +#define STM32F407xx +#define BATT_SCALE 128 +#include "targets/mambo/hal.h" diff --git a/tools/mambo-tests/battery_stubs/edgetx.h b/tools/mambo-tests/battery_stubs/edgetx.h new file mode 100644 index 00000000000..059514abe1a --- /dev/null +++ b/tools/mambo-tests/battery_stubs/edgetx.h @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once +#include +struct BatteryTestSettings { int8_t txVoltageCalibration = 0; }; +extern BatteryTestSettings g_eeGeneral; diff --git a/tools/mambo-tests/battery_stubs/hal/adc_driver.h b/tools/mambo-tests/battery_stubs/hal/adc_driver.h new file mode 100644 index 00000000000..5a40b642e89 --- /dev/null +++ b/tools/mambo-tests/battery_stubs/hal/adc_driver.h @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once +#include +#define ADC_MAX_FILTERED 2047 +enum { ADC_INPUT_VBAT, ADC_INPUT_RTC_BAT }; +unsigned adcGetMaxInputs(unsigned type); +unsigned adcGetInputOffset(unsigned type); +uint16_t anaIn(unsigned input); +uint16_t getBatteryVoltage(); diff --git a/tools/mambo-tests/battery_tests.cpp b/tools/mambo-tests/battery_tests.cpp new file mode 100644 index 00000000000..9f22402a621 --- /dev/null +++ b/tools/mambo-tests/battery_tests.cpp @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-2.0-only +#include "hal/adc_driver.h" +#include "edgetx.h" +#include +#include +#include + +BatteryTestSettings g_eeGeneral; +static uint16_t counts; +static bool available = true; +unsigned adcGetMaxInputs(unsigned type) { return type == ADC_INPUT_VBAT && available ? 1 : 0; } +unsigned adcGetInputOffset(unsigned) { return 7; } +uint16_t anaIn(unsigned input) { if (input != 7) std::abort(); return counts; } +static bool nearVoltage(int expected) +{ + int actual = getBatteryVoltage(); + if (std::abs(actual - expected) <= 1) return true; + std::fprintf(stderr, "counts=%u, calibration=%d: got %d, expected %d\n", + counts, g_eeGeneral.txVoltageCalibration, actual, expected); + return false; +} + +int main() +{ + // Exercise the production conversion over the full filtered ADC domain, + // independently comparing its result to the stock counts / 4.55 formula. + for (counts = 0; counts <= ADC_MAX_FILTERED; ++counts) { + int expected = int(counts / 4.55); + if (std::abs(int(getBatteryVoltage()) - expected) > 1) { + std::fprintf(stderr, "counts=%u: got %u, expected %d\n", counts, getBatteryVoltage(), expected); + return 1; + } + } + counts = 1820; // 4.00 V nominal + if (!nearVoltage(400)) return 1; + g_eeGeneral.txVoltageCalibration = 8; + if (!nearVoltage(425)) return 1; + g_eeGeneral.txVoltageCalibration = -8; + if (!nearVoltage(375)) return 1; + available = false; + if (getBatteryVoltage() != 0) return 1; + std::puts("Battery conversion matches stock across 2048 ADC values; calibration and absent-input checks pass."); +} diff --git a/tools/mambo-tests/calibration_fixtures.py b/tools/mambo-tests/calibration_fixtures.py new file mode 100644 index 00000000000..70eca25ae79 --- /dev/null +++ b/tools/mambo-tests/calibration_fixtures.py @@ -0,0 +1,62 @@ +# SPDX-License-Identifier: GPL-2.0-only +"""Synthetic Mambo factory-calibration records; never use them on a radio.""" +import struct +import zlib + + +def crc16(data): + value = 0xFFFF + for byte in data: + value ^= byte + for _ in range(8): + value = (value >> 1) ^ (0x8408 if value & 1 else 0) + return value + + +def legacy_crc32(data): + # This historical file checksum uses OR, not the standard CRC XOR step. + value = 0xFFFFFFFF + for byte in data: + reflected = int(f"{byte:08b}"[::-1], 2) + for _ in range(8): + condition = (value & 0x80000000) or (reflected & 0x80) + value = ((value << 1) ^ (0x04C11DB7 if condition else 0)) & 0xFFFFFFFF + reflected <<= 1 + return int(f"{value:032b}"[::-1], 2) ^ 0xFFFFFFFF + + +POINTS = [(0, 0), (10000, 0), (10000, 10000), (0, 10000), + (-10000, 10000), (-10000, 0), (-10000, -10000), + (0, -10000), (10000, -10000)] + + +def fixture(legacy_backup=False, additive_angles=False): + words = [0] * 300 + for sensor in range(2): + base = 0x78 + 11 * sensor + values = [1200, 0xE040, 2500, 3000, 900, 0x2080, + 2000, 2200, 8900, 9100] + words[base:base + 10] = values + words[base + 10] = (sum(values) & 0xFFFF) if additive_angles else \ + crc16(struct.pack(">10H", *values)) + corner = 0xA0 + 20 * sensor + raw = struct.pack("<18h", *(v for point in POINTS for v in point)) + words[corner:corner + 18] = struct.unpack("<18H", raw) + checksum = zlib.crc32(raw) ^ 0xFFFFFFFF + words[corner + 18], words[corner + 19] = checksum >> 16, checksum & 0xFFFF + body = struct.pack("<300H", *words) + checksum = legacy_crc32(body) if legacy_backup else zlib.crc32(body) + return body + struct.pack(" +#include + +int main(int argc, char** argv) +{ + if (argc != 2) return 2; + auto file = std::fopen(argv[1], "rb"); + if (!file) return 2; + uint8_t data[604]; + auto size = std::fread(data, 1, sizeof(data), file); + std::fclose(file); + mambo::FactoryCalibration calibration{}; + mambo::FactoryCalibration original = calibration; + if (!mambo::decodeFactoryCalibration(data, size, calibration)) { + if (std::memcmp(&calibration, &original, sizeof(calibration))) return 3; + std::puts("rejected"); + return 1; + } + for (unsigned sensor = 0; sensor < 2; ++sensor) { + const auto& correction = calibration.gimbals[sensor]; + for (unsigned i = 0; i < 9; ++i) { + float x, y; + auto point = correction.points[i]; + if (!mambo::correctGimbalPlane(correction, point.x, point.y, x, y)) return 4; + std::printf("point %u %u %.6f %.6f\n", sensor, i, double(x), double(y)); + } + for (int px = -4000; px <= 4000; px += 2000) { + for (int py = -4000; py <= 4000; py += 2000) { + mambo::HallSample sample; + sample.valid = true; sample.x = px; sample.y = py; sample.z = 4000; + uint16_t x, y; + bool ok = mambo::calibratedHall(sample, correction, x, y); + if (!ok) return 5; + std::printf("sample %u %d %d %u %u\n", sensor, px, py, unsigned(x), unsigned(y)); + } + } + mambo::HallSample sample; + uint16_t x = 123, y = 456; + if (mambo::calibratedHall(sample, correction, x, y)) return 6; + sample.valid = true; + if (mambo::calibratedHall(sample, correction, x, y)) return 7; + sample.x = 32767; + if (mambo::calibratedHall(sample, correction, x, y)) return 8; + if (x != 123 || y != 456) return 9; + } +} diff --git a/tools/mambo-tests/external_power_tests.cpp b/tools/mambo-tests/external_power_tests.cpp new file mode 100644 index 00000000000..91ec2a8ea8d --- /dev/null +++ b/tools/mambo-tests/external_power_tests.cpp @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-2.0-only +#include "edgetx.h" +#include "hal/gpio.h" +#include "stm32_gpio.h" +#include "targets/mambo/external_rf.h" +#include +#include +TestModel g_model; +static bool powered = false; +void gpio_write(uintptr_t pin, bool value) +{ + if (pin != EXTMODULE_PWR_GPIO) std::abort(); + powered = value; +} +void gpio_clear(uintptr_t pin) { gpio_write(pin, false); } +bool gpio_read(uintptr_t pin) +{ + if (pin == GPIO_PIN(GPIOB, 2) || pin == GPIO_PIN(GPIOB, 11) || + pin == GPIO_PIN(GPIOB, 13)) return false; + if (pin != EXTMODULE_PWR_GPIO) std::abort(); + return powered; +} +int main() +{ + // Test actual power control against every protocol, including a persisted + // unsupported model. Off must always cut power after an enabled CRSF model. + for (unsigned type = 0; type < 256; ++type) { + g_model.moduleData[EXTERNAL_MODULE].type = MODULE_TYPE_CROSSFIRE; + EXTERNAL_MODULE_PWR_ON(); + if (!powered) return 1; + g_model.moduleData[EXTERNAL_MODULE].type = type; + EXTERNAL_MODULE_PWR_ON(); + if (powered != (type == MODULE_TYPE_CROSSFIRE)) return 1; + EXTERNAL_MODULE_PWR_OFF(); + if (powered) return 1; + } + g_model.moduleData[EXTERNAL_MODULE].type = MODULE_TYPE_CROSSFIRE; + EXTERNAL_MODULE_PWR_ON(); + if (!powered) return 1; + if (!mamboExternalDiagnostics().powered) return 1; + mamboExternalModuleShutdown(); + if (powered) return 1; + if (mamboExternalDiagnostics().powered) return 1; + // A late protocol-start request after emergency shutdown cannot undo it. + EXTERNAL_MODULE_PWR_ON(); + if (powered) return 1; + std::puts("All 256 persisted module types checked; power-off always succeeds."); +} diff --git a/tools/mambo-tests/external_stubs/edgetx.h b/tools/mambo-tests/external_stubs/edgetx.h new file mode 100644 index 00000000000..2182b4ce277 --- /dev/null +++ b/tools/mambo-tests/external_stubs/edgetx.h @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once +#include +#include "pulses/modules_constants.h" +#include "targets/mambo/hal.h" +constexpr unsigned EXTERNAL_MODULE = 1; +struct TestModuleData { uint8_t type = MODULE_TYPE_NONE; }; +struct TestModel { TestModuleData moduleData[2]; }; +extern TestModel g_model; diff --git a/tools/mambo-tests/external_stubs/hal/gpio.h b/tools/mambo-tests/external_stubs/hal/gpio.h new file mode 100644 index 00000000000..54b99044a38 --- /dev/null +++ b/tools/mambo-tests/external_stubs/hal/gpio.h @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once +#include +void gpio_write(uintptr_t pin, bool value); +void gpio_clear(uintptr_t pin); +bool gpio_read(uintptr_t pin); diff --git a/tools/mambo-tests/external_stubs/stm32_gpio.h b/tools/mambo-tests/external_stubs/stm32_gpio.h new file mode 100644 index 00000000000..987ae02b2b6 --- /dev/null +++ b/tools/mambo-tests/external_stubs/stm32_gpio.h @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once +#define GPIOE 0x40021000u +#define GPIOB 0x40020400u +#define GPIO_PIN(port, number) (uintptr_t(port) | (number)) +inline uint32_t __get_PRIMASK() { return 0; } +inline void __disable_irq() {} +inline void __set_PRIMASK(uint32_t) {} diff --git a/tools/mambo-tests/hall_tests.cpp b/tools/mambo-tests/hall_tests.cpp new file mode 100644 index 00000000000..435d30bc023 --- /dev/null +++ b/tools/mambo-tests/hall_tests.cpp @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: GPL-2.0-only +#include "targets/mambo/gimbals.h" +#include "targets/mambo/diagnostics.h" +#include +#include +#include +#include +#include + +#define CHECK(x) do { if (!(x)) { std::fprintf(stderr, "line %d: %s\n", __LINE__, #x); std::exit(1); } ++checks; } while (0) +unsigned checks = 0; +unsigned calls = 0; +bool timeout = false, corrupt = false; +uint8_t measurementStatus = 0x03; +bool conversionPending[2] = {}; +uint16_t memory[2][64] = {}; +uint16_t raw[4] = {0x1234, 0x8000, 0x8001, 0x7fff}; +uint8_t lastCommand = 0; + +bool fakeTransfer(unsigned sensor, const uint8_t* command, size_t nc, + uint8_t* response, size_t nr) +{ + ++calls; + lastCommand = command[0]; + if (timeout) return false; + std::memset(response, 0, nr); + CHECK(sensor < 2); + switch (command[0]) { + case 0x80: case 0xf0: + CHECK(nc == 1 && nr == 1); + if (command[0] == 0xf0) response[0] = 4; + break; + case 0x60: + CHECK(nc == 4 && nr == 1); + CHECK(!(command[3] & 3)); + memory[sensor][command[3] >> 2] = (uint16_t(command[1]) << 8) | command[2]; + break; + case 0x50: { + CHECK(nc == 2 && nr == 3); + uint16_t value = memory[sensor][command[1] >> 2] ^ (corrupt ? 1 : 0); + response[1] = value >> 8; response[2] = value; + break; + } + case 0x3f: + CHECK(nc == 1 && nr == 1); + response[0] = 0x20; + conversionPending[sensor] = true; + break; + case 0x4f: + CHECK(nc == 1 && nr == 9); + CHECK(conversionPending[sensor]); + conversionPending[sensor] = false; + response[0] = measurementStatus; + for (unsigned i = 0; i < 4; ++i) { + response[1 + i * 2] = raw[i] >> 8; + response[2 + i * 2] = raw[i]; + } + break; + default: CHECK(false); + } + return true; +} + +uint32_t initialise(mambo::HallSensor& sensor, unsigned index, uint32_t now) +{ + sensor.begin(now); + sensor.poll(index, now, false, fakeTransfer); + CHECK(lastCommand == 0x80); + unsigned before = calls; + sensor.poll(index, now + 999, false, fakeTransfer); + CHECK(calls == before); + sensor.poll(index, now + 1000, false, fakeTransfer); + CHECK(lastCommand == 0xf0); + before = calls; + sensor.poll(index, now + 2999, false, fakeTransfer); + CHECK(calls == before); + sensor.poll(index, now + 3000, false, fakeTransfer); + CHECK(lastCommand == 0x80); + for (unsigned i = 0; i < 6; ++i) + sensor.poll(index, now + 3000 + i, false, fakeTransfer); + CHECK(memory[index][0] == 0); + CHECK(memory[index][1] == 0x8400); + CHECK(memory[index][2] == 0x2a4); + CHECK(memory[index][4] == 0x8000 && memory[index][5] == 0x8000 && memory[index][6] == 0x8000); + sensor.poll(index, now + 3010, false, fakeTransfer); + CHECK(lastCommand == 0x3f); + return now + 5010; +} + +int main() +{ + mambo::HallSensor left, right; + uint32_t now = initialise(left, 0, 0); + CHECK(!left.fresh(now)); + unsigned before = calls; + left.poll(0, now, false, fakeTransfer); + CHECK(calls == before); + left.poll(0, now, true, fakeTransfer); + CHECK(left.fresh(now)); + CHECK(left.sample().x == 0 && left.sample().y == 1 && left.sample().z == -1); + CHECK(left.sample().temperature == 0x1234 && left.sample().count == 1); + CHECK(left.fresh(now + 19999) && !left.fresh(now + 20000)); + left.poll(0, now + 20000, false, fakeTransfer); + CHECK(!left.sample().valid && left.sample().errors == 1); + CHECK(left.sample().lastFault == mambo::HallFault::ReadyTimeout); + CHECK(left.sample().faultCommand == 0x4f && left.sample().faultStatus == 0); + before = calls; + left.poll(0, now + 119999, true, fakeTransfer); + CHECK(calls == before); + left.poll(0, now + 120000, true, fakeTransfer); + CHECK(lastCommand == 0x80); + + // Tick wrapping must preserve delays and freshness. + now = initialise(right, 1, std::numeric_limits::max() - 4000); + raw[1] = 0; raw[2] = 0xffff; raw[3] = 0x8000; + right.poll(1, now, true, fakeTransfer); + CHECK(right.fresh(now + 1)); + CHECK(right.sample().x == -32768 && right.sample().y == 32767); + + // ERROR, reset indication, wrong byte count, and unexpected burst invalidate + // readings. SED is informational and does not reject a measurement. + for (uint8_t status : {uint8_t(0x13), uint8_t(0x07), uint8_t(0x02), uint8_t(0x83)}) { + now = initialise(left, 0, 0); + measurementStatus = status; + left.poll(0, now, true, fakeTransfer); + CHECK(!left.fresh(now) && left.sample().errors == 1); + CHECK(left.sample().lastFault == mambo::HallFault::MeasurementStatus); + CHECK(left.sample().faultCommand == 0x4f && left.sample().faultStatus == status); + } + now = initialise(left, 0, 0); + measurementStatus = 0x0b; + left.poll(0, now, true, fakeTransfer); + CHECK(left.fresh(now)); + timeout = true; + left.poll(0, now + 2000, true, fakeTransfer); + CHECK(!left.fresh(now + 2000) && left.sample().errors == 1); + CHECK(left.sample().lastFault == mambo::HallFault::Transport); + CHECK(left.sample().faultCommand == 0x4f && left.sample().faultStatus == 0); + timeout = false; + left.begin(0); + left.poll(0, 0, false, fakeTransfer); + left.poll(0, 1000, false, fakeTransfer); + left.poll(0, 3000, false, fakeTransfer); + corrupt = true; + left.poll(0, 3001, false, fakeTransfer); + CHECK(left.sample().errors == 1 && !left.sample().valid); + CHECK(left.sample().lastFault == mambo::HallFault::ReadbackMismatch); + CHECK(left.sample().faultCommand == 0x50); + + // A single conversion has no autonomous follow-up. Every accepted read must + // issue another SM, and never read early while the ready line is low. + corrupt = false; + measurementStatus = 0x23; + now = initialise(left, 0, 0); + for (unsigned i = 0; i < 100; ++i) { + left.poll(0, now, true, fakeTransfer); + CHECK(left.sample().count == i + 1 && left.fresh(now)); + CHECK(conversionPending[0] && lastCommand == 0x3f); + before = calls; + left.poll(0, now + 1000, false, fakeTransfer); + CHECK(calls == before); + now += 4000; + } + CHECK(left.sample().errors == 0); + + // Geometry invariants: origin rejected, polarity, endpoint bounds, Z sign + // symmetry, and scale invariance. X/Y/Z sensitivity is not interchangeable. + uint16_t x, y; + mambo::HallSample sample; + CHECK(!mambo::hallAngles(sample, x, y)); + sample.valid = true; + CHECK(!mambo::hallAngles(sample, x, y)); + sample.z = 10000; + CHECK(mambo::hallAngles(sample, x, y) && x == 2048 && y == 2048); + sample.x = 10000; + CHECK(mambo::hallAngles(sample, x, y) && x > 2700 && x < 2800 && y == 2048); + uint16_t original = x; + sample.z = -10000; + CHECK(mambo::hallAngles(sample, x, y) && x == original); + sample.x *= 2; sample.z *= 2; + CHECK(mambo::hallAngles(sample, x, y) && x == original); + sample.x = -10000; sample.z = 0; + CHECK(mambo::hallAngles(sample, x, y) && x == 0 && y == 2048); + sample.x = 10000; + CHECK(mambo::hallAngles(sample, x, y) && x == 4095 && y == 2048); + sample.x = 0; sample.y = -10000; + CHECK(mambo::hallAngles(sample, x, y) && x == 2048 && y == 0); + sample = {}; + CHECK(mambo::hallHealth(sample,0) == mambo::HallHealth::Waiting); + sample.errors = 1; + CHECK(mambo::hallHealth(sample,0) == mambo::HallHealth::Error); + sample.valid = true; + sample.timestamp = 0xfffffff0; + CHECK(mambo::hallHealth(sample,100) == mambo::HallHealth::Fresh); + CHECK(mambo::hallHealth(sample,uint32_t(sample.timestamp + 20000)) == mambo::HallHealth::Stale); + std::printf("Mambo Hall backend: %u checks passed\n", checks); +} diff --git a/tools/mambo-tests/power_stubs/board.h b/tools/mambo-tests/power_stubs/board.h new file mode 100644 index 00000000000..fa5814adc92 --- /dev/null +++ b/tools/mambo-tests/power_stubs/board.h @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once +#define PWR_SWITCH_GPIO 42u +#define PWR_ON_GPIO 43u +void pwrInit(); +void pwrOn(); +void pwrOff(); +bool pwrPressed(); +bool pwrOffPressed(); +void pwrResetHandler(); diff --git a/tools/mambo-tests/power_stubs/hal/abnormal_reboot.h b/tools/mambo-tests/power_stubs/hal/abnormal_reboot.h new file mode 100644 index 00000000000..ee6073a8d1e --- /dev/null +++ b/tools/mambo-tests/power_stubs/hal/abnormal_reboot.h @@ -0,0 +1,3 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once +#define WAS_RESET_BY_WATCHDOG_OR_SOFTWARE() false diff --git a/tools/mambo-tests/power_stubs/hal/gpio.h b/tools/mambo-tests/power_stubs/hal/gpio.h new file mode 100644 index 00000000000..f5c460e8097 --- /dev/null +++ b/tools/mambo-tests/power_stubs/hal/gpio.h @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once +typedef unsigned gpio_t; +struct GPIO_TypeDef; +void gpio_init(unsigned pin, unsigned mode, unsigned speed); +int gpio_read(unsigned pin); +void gpio_set(unsigned pin); +void gpio_clear(unsigned pin); diff --git a/tools/mambo-tests/power_stubs/hal/usb_driver.h b/tools/mambo-tests/power_stubs/hal/usb_driver.h new file mode 100644 index 00000000000..197a4d31b58 --- /dev/null +++ b/tools/mambo-tests/power_stubs/hal/usb_driver.h @@ -0,0 +1,2 @@ +// SPDX-License-Identifier: GPL-2.0-only +#pragma once diff --git a/tools/mambo-tests/power_tests.cpp b/tools/mambo-tests/power_tests.cpp new file mode 100644 index 00000000000..ee168d73f25 --- /dev/null +++ b/tools/mambo-tests/power_tests.cpp @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-2.0-only +// Compiles the actual shared driver against a GPIO fake for both polarities. +#include "board.h" +#include "stm32_gpio.h" +#include +#include + +static bool level = false, latch = false; +static unsigned inputMode = 0, reads = 0; +#define CHECK(x) do { if (!(x)) { std::fprintf(stderr,"line %d: %s\n",__LINE__,#x); std::exit(1); } } while(0) +void gpio_init(unsigned pin, unsigned mode, unsigned speed) +{ + CHECK(speed == GPIO_PIN_SPEED_LOW); + if (pin == PWR_SWITCH_GPIO) inputMode = mode; + else CHECK(pin == PWR_ON_GPIO && mode == GPIO_OUT); +} +int gpio_read(unsigned pin) { CHECK(pin == PWR_SWITCH_GPIO); ++reads; return level; } +void gpio_set(unsigned pin) { CHECK(pin == PWR_ON_GPIO); latch = true; } +void gpio_clear(unsigned pin) { CHECK(pin == PWR_ON_GPIO); latch = false; } + +int main() +{ + pwrInit(); + CHECK(inputMode == GPIO_IN_PU); // same pull configuration as stock Mambo +#if defined(PWR_SWITCH_PIN_ACTIVE_HIGH) + constexpr bool pressedLevel = true; +#else + constexpr bool pressedLevel = false; +#endif + level = !pressedLevel; + CHECK(!pwrPressed()); +#if defined(PWR_BUTTON_PRESS) + CHECK(!pwrOffPressed()); +#else + CHECK(pwrOffPressed()); +#endif + level = pressedLevel; + CHECK(pwrPressed()); +#if defined(PWR_BUTTON_PRESS) + CHECK(pwrOffPressed()); +#else + CHECK(!pwrOffPressed()); +#endif + level = !pressedLevel; + CHECK(!pwrPressed()); + CHECK(reads == 5); + pwrOn(); CHECK(latch); + pwrOff(); CHECK(!latch); + pwrResetHandler(); CHECK(!latch); + std::printf("Actual power driver: %s polarity passed\n",pressedLevel ? "active-high" : "active-low"); +} diff --git a/tools/mambo-tests/test_calibration.py b/tools/mambo-tests/test_calibration.py new file mode 100644 index 00000000000..bfa67775a2f --- /dev/null +++ b/tools/mambo-tests/test_calibration.py @@ -0,0 +1,156 @@ +# SPDX-License-Identifier: GPL-2.0-only +"""Compare the ARM-portable C++ backend with independent double-precision math. + +The reference solves a full eight-variable homography, rather than copying +the C++ closed-form map construction. All inputs are synthetic. +""" +import math +from pathlib import Path +import struct +import subprocess +import sys +import tempfile +import zlib + +from calibration_fixtures import fixture, crc16, legacy_crc32, reference_parameters + +TARGETS = [(0,0), (10000,0), (10000,10000), (0,10000), (-10000,10000), + (-10000,0), (-10000,-10000), (0,-10000), (10000,-10000)] +checks = 0 + + +def check(condition, message): + global checks + if not condition: + raise AssertionError(message) + checks += 1 + + +def solve(rows): + n = len(rows) + for column in range(n): + pivot = max(range(column, n), key=lambda i: abs(rows[i][column])) + rows[pivot], rows[column] = rows[column], rows[pivot] + divisor = rows[column][column] + if abs(divisor) < 1e-12: + raise ValueError("singular reference homography") + rows[column] = [value / divisor for value in rows[column]] + for i in range(n): + if i != column: + multiple = rows[i][column] + rows[i] = [a - multiple*b for a, b in zip(rows[i], rows[column])] + return [row[-1] for row in rows] + + +def map_for(points, quadrant): + indices = [(0,1,2,3), (0,5,4,3), (0,5,6,7), (0,1,8,7)][quadrant] + rows = [] + for index, (u, v) in zip(indices, [(0,0), (1,0), (1,1), (0,1)]): + x, y = points[index] + sx = -1 if quadrant in (1,2) else 1 + sy = -1 if quadrant in (2,3) else 1 + x = (x - points[0][0]) * sx + y = (y - points[0][1]) * sy + rows.extend([[x,y,1,0,0,0,-u*x,-u*y,u], + [0,0,0,x,y,1,-v*x,-v*y,v]]) + return solve(rows) + + +def plane_reference(points, x, y): + x -= points[0][0] + y -= points[0][1] + def cross(index): + bx, by = points[index] + return y * (bx - points[0][0]) - x * (by - points[0][1]) + if cross(1) >= 0: + q = 0 if cross(3) <= 0 else (1 if cross(5) <= 0 else 2) + else: + q = 3 if cross(7) >= 0 else (2 if cross(5) >= 0 else 1) + sx = -1 if q in (1,2) else 1 + sy = -1 if q in (2,3) else 1 + m = map_for(points, q) + x *= sx + y *= sy + denominator = m[6]*x + m[7]*y + 1 + return (10000*sx*(m[0]*x + m[1]*y + m[2])/denominator, + 10000*sy*(m[3]*x + m[4]*y + m[5])/denominator) + + +def raw_reference(sensor, px, py): + bx, by, bz = math.trunc(px*1.573), math.trunc(py*1.573), math.trunc(4000*2.534) + plane = [] + for axis, primary, secondary in zip(sensor["axes"], (bx,by), (by,bx)): + denominator = (secondary - axis["denominator_cross_z"]*bz) * axis["ratio"] + numerator = primary - axis["numerator_cross_z"]*bz + angle = math.degrees(math.atan2(math.sqrt(bz*bz + denominator*denominator), numerator)) + delta = angle - axis["centre_degrees"] + gain = axis["positive_gain"] if delta > 0 else axis["negative_gain"] + plane.append(math.trunc(delta * gain * 100)) + corrected = plane_reference(sensor["corner_points"], *plane) + return [math.floor(2047.5 + math.trunc(max(-10000,min(10000,v)))*4095/20000 + 0.5) + for v in corrected] + + +def verify(probe, path): + report = reference_parameters() + result = subprocess.run([str(probe), str(path)], capture_output=True, text=True) + check(result.returncode == 0, f"probe rejected valid fixture: {result.returncode}, {result.stdout}") + points, samples = 0, 0 + for line in result.stdout.splitlines(): + fields = line.split() + sensor = report["sensors"][int(fields[1])] + if fields[0] == "point": + target = TARGETS[int(fields[2])] + values = [float(v) for v in fields[3:5]] + check(all(abs(v-t) <= 2 for v,t in zip(values,target)), f"point mismatch: {line}") + points += 1 + else: + target = raw_reference(sensor, int(fields[2]), int(fields[3])) + values = [int(v) for v in fields[4:6]] + # Float/FMA/truncation differences can shift a few stock units. + check(all(abs(v-t) <= 3 for v,t in zip(values,target)), f"sample mismatch: {line}, {target}") + samples += 1 + check(points == 18 and samples == 50, "probe output incomplete") + + +def mutate(data, index, value, fix_inner=False): + words = list(struct.unpack("<300H", data[:600])) + words[index] = value + if fix_inner: + for base in (0x78, 0x83): + words[base + 10] = crc16(struct.pack(">10H", *words[base:base + 10])) + for base in (0xA0, 0xB4): + checksum = zlib.crc32(struct.pack("<18H", *words[base:base + 18])) ^ 0xFFFFFFFF + words[base + 18], words[base + 19] = checksum >> 16, checksum & 0xFFFF + body = struct.pack("<300H", *words) + return body + struct.pack(" Date: Sat, 17 Oct 2026 17:31:16 +0100 Subject: [PATCH 06/11] docs(mambo): record unified default hardware bench validation --- radio/src/targets/mambo/README.txt | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/radio/src/targets/mambo/README.txt b/radio/src/targets/mambo/README.txt index a9ce8d8320f..2256419eefd 100644 --- a/radio/src/targets/mambo/README.txt +++ b/radio/src/targets/mambo/README.txt @@ -53,10 +53,14 @@ Hall sticks with the RF-disabled b618ccbe89 build. Its separate external-CRSF build was subsequently flashed with a matching terminal checksum and the owner confirmed that the external bay works. That already-working bay path is now included in the default Mambo build, with model-level Off/CRSF control. -The new default build has not yet been flashed/tested on hardware. Direct Hall -acquisition and continuous APB2/TIM10 encoder polling are retained. Earlier -repeated cold-boot/centering/direction reports for an internal-RF diagnostic image must -not be presented as testing of these standalone applications. +The unified default 8773d9a5f7 build was then flashed successfully (478368 bytes, +matching terminal checksum, zero retries). After receiving a regression checklist +covering battery-only startup, encoder/Hall inputs, model Off/CRSF bay behavior, +receiver channels, shutdown power and repeated cold boots, the owner reported +"Everything looks good". This is an owner-reported overall bench pass; individual +measurements and an exact cold-boot count were not provided. Direct Hall and +APB2/TIM10 polling are retained. Earlier internal-RF diagnostic results are +separate evidence, not testing of these standalone applications. Local verification covers the default Mambo firmware, application memory boundaries and bay UART/DMA vectors, eight standalone host suites, hardware @@ -65,7 +69,7 @@ normal USE_FW_LTO=YES build setting. See tools/mambo-tests/README.md for the reproducible tests; no private calibration files or captured firmware required. Keep PR #7907 experimental/draft. Receiver failsafe, long-duration stability, -charging/off-state behavior, physical emergency/power-off bay checks, faster +charging/off-state behavior, physical emergency-shutdown bay checks, faster CRSF negotiation/interoperability and Companion/release support remain open. This is bench evidence, not flight validation. No nightly or release target is added, and no private calibration, captured binaries or RF research tooling From d615745f0a96b7a20da1d0b6a20f7d4ed0af655b Mon Sep 17 00:00:00 2001 From: Ilya Date: Sat, 17 Oct 2026 17:40:31 +0100 Subject: [PATCH 07/11] chore: keep private recovery exclusions repository-local --- .gitignore | 3 --- 1 file changed, 3 deletions(-) diff --git a/.gitignore b/.gitignore index 0375783eef6..d57c92c4df1 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,3 @@ __pycache__/ output/ *.dmg .devcontainer/devcontainer-lock.json - -# Private radio recovery artifacts must remain local across branch switches. -/tools/mambo-runtime-harness/local-recovery/ From 7a544243e47643bdcda1558c54973f26afb9e436 Mon Sep 17 00:00:00 2001 From: Ilya Date: Sat, 17 Oct 2026 18:23:58 +0100 Subject: [PATCH 08/11] feat(mambo): add verified HID flashing targets and tester bundle --- .github/workflows/mambo.yml | 21 +- cmake/MamboFlash.cmake | 29 + cmake/RunMamboFlash.cmake | 42 ++ radio/src/CMakeLists.txt | 4 +- radio/src/targets/mambo/README.txt | 22 +- tools/mambo-flash/README.txt | 117 ++++ tools/mambo-flash/mambo_flash.py | 516 ++++++++++++++++++ .../verify_firmware.py | 2 + tools/mambo-tests/CMakeLists.txt | 3 + tools/mambo-tests/README.md | 13 +- tools/mambo-tests/test_mambo_flash.py | 463 ++++++++++++++++ 11 files changed, 1221 insertions(+), 11 deletions(-) create mode 100644 cmake/MamboFlash.cmake create mode 100644 cmake/RunMamboFlash.cmake create mode 100644 tools/mambo-flash/README.txt create mode 100644 tools/mambo-flash/mambo_flash.py rename tools/{mambo-tests => mambo-flash}/verify_firmware.py (97%) create mode 100644 tools/mambo-tests/test_mambo_flash.py diff --git a/.github/workflows/mambo.yml b/.github/workflows/mambo.yml index 7568d7b961d..470a9060682 100644 --- a/.github/workflows/mambo.yml +++ b/.github/workflows/mambo.yml @@ -9,6 +9,7 @@ on: - 'radio/**' - 'tools/hwdef_schema.json' - 'tools/mambo-tests/**' + - 'tools/mambo-flash/**' - 'pyproject.toml' - 'uv.lock' workflow_dispatch: @@ -52,5 +53,23 @@ jobs: - run: cmake --build build/mambo --target firmware --parallel 4 - name: Check memory boundaries and external CRSF interrupt vectors run: >- - uv run python tools/mambo-tests/verify_firmware.py + uv run python tools/mambo-flash/verify_firmware.py build/mambo/firmware.elf build/mambo/firmware.bin + - name: Validate the offline flashing target (no USB access) + run: cmake --build build/mambo --target flash-plan + - name: Assemble the verified bench-test bundle + run: | + mkdir -p build/mambo/tester-bundle + cp build/mambo/firmware.bin build/mambo/firmware.elf LICENSE build/mambo/tester-bundle/ + cp tools/mambo-flash/mambo_flash.py tools/mambo-flash/verify_firmware.py tools/mambo-flash/README.txt build/mambo/tester-bundle/ + git rev-parse HEAD > build/mambo/tester-bundle/SOURCE_REVISION.txt + uv run python tools/mambo-flash/mambo_flash.py \ + --image build/mambo/firmware.bin --elf build/mambo/firmware.elf \ + > build/mambo/tester-bundle/flash-plan.json + - name: Upload the Mambo bench-test bundle + uses: actions/upload-artifact@v7 + with: + name: mambo-bench-${{ github.sha }} + path: build/mambo/tester-bundle/ + if-no-files-found: error + retention-days: 14 diff --git a/cmake/MamboFlash.cmake b/cmake/MamboFlash.cmake new file mode 100644 index 00000000000..331a952cab7 --- /dev/null +++ b/cmake/MamboFlash.cmake @@ -0,0 +1,29 @@ +# Original TBS controller bootloader: USB HID, not STM32 DFU. The application +# allocation and its ELF are checked before the uploader can open USB. +set(MAMBO_FLASH_SCRIPT "${CMAKE_SOURCE_DIR}/tools/mambo-flash/mambo_flash.py") +set(MAMBO_FLASH_RUNNER "${CMAKE_SOURCE_DIR}/cmake/RunMamboFlash.cmake") + +foreach(mode IN ITEMS PLAN PROBE FLASH) + if(mode STREQUAL "PLAN") + set(target_name flash-plan) + elseif(mode STREQUAL "PROBE") + set(target_name flash-probe) + else() + set(target_name flash) + endif() + add_custom_target(${target_name} + COMMAND ${CMAKE_COMMAND} + "-DMAMBO_FLASH_MODE=${mode}" + "-DMAMBO_FLASH_PYTHON=${PYTHON_EXECUTABLE}" + "-DMAMBO_FLASH_SCRIPT=${MAMBO_FLASH_SCRIPT}" + "-DMAMBO_FLASH_IMAGE=${CMAKE_BINARY_DIR}/firmware.bin" + "-DMAMBO_FLASH_ELF=${CMAKE_BINARY_DIR}/firmware.elf" + "-DMAMBO_FLASH_LOG_ROOT=${CMAKE_BINARY_DIR}/mambo-flash-logs" + -P "${MAMBO_FLASH_RUNNER}" + WORKING_DIRECTORY "${CMAKE_BINARY_DIR}" + USES_TERMINAL + VERBATIM) + if(NOT mode STREQUAL "PROBE") + add_dependencies(${target_name} firmware) + endif() +endforeach() diff --git a/cmake/RunMamboFlash.cmake b/cmake/RunMamboFlash.cmake new file mode 100644 index 00000000000..630644710da --- /dev/null +++ b/cmake/RunMamboFlash.cmake @@ -0,0 +1,42 @@ +cmake_minimum_required(VERSION 3.20) + +if(NOT MAMBO_FLASH_MODE MATCHES "^(PLAN|PROBE|FLASH)$") + message(FATAL_ERROR "Unknown Mambo flashing mode '${MAMBO_FLASH_MODE}'") +endif() +if(NOT EXISTS "${MAMBO_FLASH_PYTHON}" OR NOT EXISTS "${MAMBO_FLASH_SCRIPT}") + message(FATAL_ERROR "Mambo flashing requires the build Python and HID uploader") +endif() + +if(MAMBO_FLASH_MODE STREQUAL "PROBE") + set(args --probe) +else() + set(args --image "${MAMBO_FLASH_IMAGE}" --elf "${MAMBO_FLASH_ELF}") +endif() + +if(MAMBO_FLASH_MODE STREQUAL "FLASH") + # Read at build time: setting these must not reconfigure/rebuild the firmware + # after its SHA256 was approved with flash-plan. + set(serial "$ENV{MAMBO_FLASH_SERIAL}") + set(sha256 "$ENV{MAMBO_FLASH_SHA256}") + if(serial STREQUAL "") + message(FATAL_ERROR + "Set MAMBO_FLASH_SERIAL to the controller serial shown by flash-probe. USB not opened.") + endif() + string(LENGTH "${sha256}" hash_length) + if(NOT hash_length EQUAL 64 OR NOT sha256 MATCHES "^[0-9a-fA-F]+$") + message(FATAL_ERROR + "Set MAMBO_FLASH_SHA256 to the approved SHA256 shown by flash-plan. USB not opened.") + endif() + list(APPEND args --execute --expected-serial "${serial}" + --expected-sha256 "${sha256}" --log-root "${MAMBO_FLASH_LOG_ROOT}") +endif() + +if(DEFINED ENV{MAMBO_HID_LIBRARY} AND NOT "$ENV{MAMBO_HID_LIBRARY}" STREQUAL "") + list(APPEND args --hid-library "$ENV{MAMBO_HID_LIBRARY}") +endif() + +execute_process(COMMAND "${MAMBO_FLASH_PYTHON}" "${MAMBO_FLASH_SCRIPT}" ${args} + RESULT_VARIABLE result) +if(NOT result EQUAL 0) + message(FATAL_ERROR "Mambo ${MAMBO_FLASH_MODE} stopped (exit code ${result}); no automatic retry") +endif() diff --git a/radio/src/CMakeLists.txt b/radio/src/CMakeLists.txt index dc0198521ea..d245fe59cd1 100644 --- a/radio/src/CMakeLists.txt +++ b/radio/src/CMakeLists.txt @@ -821,7 +821,9 @@ else() ) endif() -if(CPU_FAMILY STREQUAL STM32 AND NOT PCB STREQUAL MAMBO) +if(CPU_FAMILY STREQUAL STM32 AND PCB STREQUAL MAMBO) + include(${CMAKE_SOURCE_DIR}/cmake/MamboFlash.cmake) +elseif(CPU_FAMILY STREQUAL STM32) # FIRMWARE_QSPI boards can only be firmware-flashed via flash-uf2 below; # the bootloader doesn't expose external QSPI/XSPI flash as a DFU segment. if(NOT FIRMWARE_QSPI) diff --git a/radio/src/targets/mambo/README.txt b/radio/src/targets/mambo/README.txt index 2256419eefd..3a3b20edbe2 100644 --- a/radio/src/targets/mambo/README.txt +++ b/radio/src/targets/mambo/README.txt @@ -24,9 +24,19 @@ Installation and calibration The application starts at 0x0800C000 and must stay below 0x080C0000. The linker also reserves shared SRAM and the first 1 KiB of CCM. Firmware images must not contain a bootloader or internal RF payload. Generic DFU flashing and a USB -firmware-write drive are deliberately not enabled for this target. Installation -and rollback require a separately verified, original-bootloader-compatible -application uploader; no uploader or stock firmware is included in this PR. +firmware-write drive are deliberately not enabled for this target. The Mambo +flash target instead uses the original TBS USB HID controller bootloader: + + cmake --build build/mambo --target flash-plan + cmake --build build/mambo --target flash-probe + +The first command builds and verifies an offline image plan without opening +USB; the second queries controller identity after PAGE-held USB connection. +Then set MAMBO_FLASH_SERIAL and MAMBO_FLASH_SHA256 from those results and run +the flash target. See tools/mambo-flash/README.txt for complete instructions, +dependencies, local transfer logs and stock-recovery precautions. The tool +checks the selected controller and image before application erase and never +updates the bootloader/internal RF. No stock firmware or private data is bundled. Preserve the original firmware, SD contents and the radio's own calibration backup before testing. The direct Hall backend can READ /CROSSFIRE/EEPROM.bin @@ -63,7 +73,7 @@ APB2/TIM10 polling are retained. Earlier internal-RF diagnostic results are separate evidence, not testing of these standalone applications. Local verification covers the default Mambo firmware, application memory -boundaries and bay UART/DMA vectors, eight standalone host suites, hardware +boundaries and bay UART/DMA vectors, nine standalone host suites, hardware schema/template generation, and a TX12 Mark II firmware regression using its normal USE_FW_LTO=YES build setting. See tools/mambo-tests/README.md for the reproducible tests; no private calibration files or captured firmware required. @@ -73,4 +83,6 @@ charging/off-state behavior, physical emergency-shutdown bay checks, faster CRSF negotiation/interoperability and Companion/release support remain open. This is bench evidence, not flight validation. No nightly or release target is added, and no private calibration, captured binaries or RF research tooling -belongs in the patch. +belongs in the patch. The new public application-only flash target is separate +from internal-RF research tooling; its exact entry point still needs a live +hardware test even though the underlying transfer logic was used previously. diff --git a/tools/mambo-flash/README.txt b/tools/mambo-flash/README.txt new file mode 100644 index 00000000000..2d4cf03b11d --- /dev/null +++ b/tools/mambo-flash/README.txt @@ -0,0 +1,117 @@ +Mambo controller-application flashing +==================================== + +This uploader uses the ORIGINAL TBS USB HID bootloader. It updates only the +EdgeTX controller application at 0x0800C000..0x080C0000; it never sends a +bootloader/internal-RF update, reset, flash-unlock or readout command. + +Back up your SD card, models/settings and your radio's own +/CROSSFIRE/EEPROM.bin before testing. Know how to reinstall stock FreedomTX +using TBS Agent before the first installation. This tool does not back up +installed firmware, bundle stock firmware or provide an arbitrary-image +restore mode. Do not replace the TBS bootloader or update the internal RF +as part of this application test. + +Requirements +------------ +- Python 3.10+ and pyelftools (included in the repo's `uv sync` dependencies). +- A native HIDAPI 0.14+ shared library for live probe/flash. On macOS it can + be installed with `brew install hidapi`. Use MAMBO_HID_LIBRARY (CMake) or + --hid-library (direct CLI) to select an explicit library path if needed. +- A matching Mambo firmware.bin AND symbol-bearing firmware.elf. Both are + generated by the firmware build; the binary alone is insufficient. +- A USB data cable and the radio in its controller bootloader mode. + +Earlier versions of this transfer logic have been hardware-tested on macOS +with one Mambo. The public extraction is covered by simulated tests; live +hardware testing of this exact entry point is still required. Linux requires +native hidraw access permissions, and Windows a matching HIDAPI DLL. Neither +Windows nor Linux is hardware-qualified here. Do not detach/replace USB +drivers, change option bytes or disable readout protection to use this tool. + +Repo build and offline preview (no USB access) +--------------------------------------------- +Using the ordinary EdgeTX build environment, from the repo root: + + cmake --preset mambo -DLUA=ON -DLUA_COMPILER=ON -DLUA_MIXER=ON + cmake --build build/mambo --target flash-plan + +flash-plan builds the default Mambo firmware, verifies ELF/bin correspondence, +flash/SRAM/CCM reservations and external-bay interrupt vectors, and prints +the image SHA256. It does NOT open HID, query devices, erase, upload or create +transfer logs. Review the image/build before approving the displayed hash. + +Find the controller serial (USB discovery only) +---------------------------------------------- +Close Agent/other USB tools. Turn the radio OFF, disconnect USB, hold PAGE, +then connect USB while holding PAGE until bootloader mode is established. +Use PAGE, not MENU (the latter selects the internal RF updater). + + cmake --build build/mambo --target flash-probe + +This explicitly opens HID and sends only the device-discovery ping. It checks +the HID descriptor, TBS BOOTLOADER name, hardware 00041001 and protocol 1, then +prints the controller's numeric serial. It never sends an erase header. +The tool refuses zero/multiple matching HID interfaces; connect one radio. + +Flash the reviewed build (explicit application erase) +---------------------------------------------------- +Set the serial from flash-probe and hash from flash-plan for this invocation. +The following are placeholders: replace BOTH values before running. + +macOS/Linux shell: + + MAMBO_FLASH_SERIAL=SERIAL_FROM_PROBE \ + MAMBO_FLASH_SHA256=SHA256_FROM_PLAN \ + cmake --build build/mambo --target flash + +PowerShell: + + $env:MAMBO_FLASH_SERIAL = 'SERIAL_FROM_PROBE' + $env:MAMBO_FLASH_SHA256 = 'SHA256_FROM_PLAN' + cmake --build build/mambo --target flash + +These are build-time environment variables, not configure-time options. A +changed/rebuilt image must get a new preview and SHA256 approval. Missing or +invalid pins fail before HID is opened. Device identity is queried/checked +again before the one erase-triggering application header is sent. There is +no automatic serial selection, hash approval, re-erase, reconnect or resume. + +Keep USB connected until BOTH the terminal checksum and DownloadFinished are +received. Success is protocol completion, NOT flash readback, a cryptographic +signature check or proof of startup/flight safety. No reboot command is sent. +Disconnect USB, boot on batteries and check startup, encoder/Hall inputs, +external-bay Off/CRSF behavior and shutdown. Use a bench, not a flying model. + +Each live run gets a fresh directory below build/mambo/mambo-flash-logs with +plan.json, events.jsonl, usb.jsonl and, only on completion, result.json. These +are local build outputs; do not commit them. Existing log directories are +never overwritten. The updater requires 64 MiB free for logs before USB access. + +Standalone CLI / downloaded CI bundle +------------------------------------ +CI packages firmware.bin, firmware.elf, the uploader/verifier, this guide, +LICENSE, SOURCE_REVISION.txt and flash-plan.json. Extract the bundle and run +the following from its directory (Python/pyelftools/HIDAPI are still needed): + + python3 mambo_flash.py --image firmware.bin --elf firmware.elf + python3 mambo_flash.py --probe + python3 mambo_flash.py --image firmware.bin --elf firmware.elf \ + --expected-serial SERIAL_FROM_PROBE --expected-sha256 SHA256_FROM_PLAN \ + --log-dir logs/test-1 --execute + +Without --execute or --probe, the CLI is an offline verifier only. Probe and +execute modes cannot be combined. No firmware/RF profile selector exists. +The default data pacing is 20 ms per chunk; --chunk-delay-ms accepts 10..100. +The CMake targets deliberately use the default pacing. + +Failure / recovery +------------------ +If an error occurs after an erase-triggering write was attempted, the +application may be incomplete. Do NOT try normal startup. Keep the radio in +the controller bootloader and preserve the logs. Fix the connection/disk +problem, and cold-reconnect using PAGE before deliberately starting another +full, verified application transfer or using the stock TBS recovery workflow. +The tool never changes bootloader/RF flash to attempt recovery. An interrupted +transfer is not automatically resumed or erased again. There is no guarantee +of recovery from every possible hardware/bootloader fault. diff --git a/tools/mambo-flash/mambo_flash.py b/tools/mambo-flash/mambo_flash.py new file mode 100644 index 00000000000..cacc4abaa7b --- /dev/null +++ b/tools/mambo-flash/mambo_flash.py @@ -0,0 +1,516 @@ +# SPDX-License-Identifier: GPL-2.0-only +"""Mambo controller-application uploader over the original TBS HID bootloader. + +Default mode validates and prints an offline plan. --probe sends discovery only. +--execute requires an approved SHA256 and explicit controller serial. No RF or +bootloader updates, raw flash operations, resets, resume, or automatic re-erase. +""" +import argparse +import ctypes as c +import ctypes.util +from datetime import datetime, timezone +import hashlib +import json +import math +from pathlib import Path +import re +import shutil +import struct +import sys +import tempfile +import time + +HARDWARE = 0x00041001 +FIRMWARE = 0x0300 # Observed TBS version encoding for this EdgeTX 3.0 bench port. +TARGET = 0xEA # Controller application; never the internal RF device. +ORIGIN = 0x10 +CHUNK = 48 +MAX_RANGE = 128 * CHUNK +REPORT_DESCRIPTOR = bytes.fromhex("0600ff0901a1011901294015012540750895408100190129409100c0") + + +def legacy_crc32(data): + # Historical updater checksum: OR, not XOR, in the polynomial condition. + value = 0xFFFFFFFF + for byte in data: + reflected = int(f"{byte:08b}"[::-1], 2) + for _ in range(8): + condition = (value & 0x80000000) or (reflected & 0x80) + value = ((value << 1) ^ (0x04C11DB7 if condition else 0)) & 0xFFFFFFFF + reflected <<= 1 + return int(f"{value:032b}"[::-1], 2) ^ 0xFFFFFFFF + + +def crc8(data): + value = 0 + for byte in data: + value ^= byte + for _ in range(8): + value = ((value << 1) ^ (0xD5 if value & 0x80 else 0)) & 255 + return value + + +def frame(payload, destination=TARGET, origin=ORIGIN, kind=0x38): + body = bytes([kind, destination, origin]) + payload + if len(body) > 61: + raise ValueError("CRSF frame exceeds 64 bytes") + return bytes([0xC8, len(body) + 1]) + body + bytes([crc8(body)]) + + +def parse_report(raw): + if len(raw) < 4 or raw[0] != 0xC8 or not 2 <= raw[1] <= 62: + raise ValueError("invalid CRSF report header") + size = raw[1] + 2 + if len(raw) < size: + raise ValueError("truncated CRSF report") + packet = raw[:size] + # The captured bootloader emits DownloadFinished with an invalid outer CRC; + # Agent explicitly exempts this one message. It is usable only after the + # independent terminal AskForBytes checksum has matched the image. + legacy_finish = len(packet) == 7 and packet[2] == 0x38 and packet[5] == 6 + if not legacy_finish and crc8(packet[2:-1]) != packet[-1]: + raise ValueError("invalid CRSF CRC8") + return packet + + +def load_image(path, elf_path, expected_sha=None): + if sys.flags.optimize: + raise ValueError("ELF verification requires Python assertions enabled") + image = path.read_bytes() + if not 8 <= len(image) <= 0xB4000: + raise ValueError("image exceeds the application-only flash allocation") + digest = hashlib.sha256(image).hexdigest() + if expected_sha is not None: + if not re.fullmatch(r"[0-9a-fA-F]{64}", expected_sha): + raise ValueError("expected SHA256 must contain exactly 64 hexadecimal characters") + if digest != expected_sha.lower(): + raise ValueError("image differs from the approved SHA256; run flash-plan again") + # Lazy import: protocol tests and discovery need no ELF/Python packages. + from verify_firmware import verify + try: + size, verified_digest = verify(elf_path, path) + except Exception as error: + # Malformed ELF files can raise parser-specific errors as well as + # contract assertions. All must fail closed before opening USB. + raise ValueError(f"application ELF/bin verification failed: {error}") from error + if size != len(image) or verified_digest != digest: + raise ValueError("artifacts changed during verification") + return image + + +def header(image): + return frame(struct.pack(">BBIHII", 1, 0, HARDWARE, FIRMWARE, + len(image), legacy_crc32(image))) + + +def chunks(image, start, end, indexes=None): + if not 0 <= start < end <= len(image) or end - start > MAX_RANGE or start % CHUNK or \ + (end != len(image) and end % CHUNK): + raise ValueError("bootloader requested an unsupported image range") + count = math.ceil((end - start) / CHUNK) + for index in range(count) if indexes is None else indexes: + if not 0 <= index < count: + raise ValueError("chunk index outside requested range") + begin = start + index * CHUNK + yield frame(bytes([3, index, count]) + image[begin:min(begin + CHUNK, end)]) + + +def device_info(packet): + if len(packet) < 7 or packet[2] != 0x29: + return None + payload = packet[5:-1] + end = payload.find(b"\0") + if end < 0 or len(payload) < end + 15: + raise ValueError("malformed device-information reply") + serial, hardware, firmware, parameters, protocol = struct.unpack(">IIIBB", payload[end+1:end+15]) + return {"name": payload[:end].decode("ascii"), "address": packet[4], + "serial": serial, "hardware_id": hardware, "firmware_id": firmware, + "parameter_count": parameters, "protocol": protocol} + + +class HidInfo(c.Structure): + pass + + +HidInfo._fields_ = [("path", c.c_char_p), ("vendor_id", c.c_ushort), + ("product_id", c.c_ushort), ("serial_number", c.c_wchar_p), + ("release_number", c.c_ushort), ("manufacturer_string", c.c_wchar_p), + ("product_string", c.c_wchar_p), ("usage_page", c.c_ushort), + ("usage", c.c_ushort), ("interface_number", c.c_int), + ("next", c.POINTER(HidInfo)), ("bus_type", c.c_int)] + + +class NativeHid: + def __init__(self, library=None): + library = library or ctypes_library() + self.lib = c.CDLL(library) + if not hasattr(self.lib, "hid_get_report_descriptor"): + raise OSError("HIDAPI 0.14+ is required (missing report descriptor API)") + signatures = { + "hid_init": ([], c.c_int), "hid_exit": ([], c.c_int), + "hid_enumerate": ([c.c_ushort, c.c_ushort], c.POINTER(HidInfo)), + "hid_free_enumeration": ([c.POINTER(HidInfo)], None), + "hid_open_path": ([c.c_char_p], c.c_void_p), + "hid_close": ([c.c_void_p], None), + "hid_error": ([c.c_void_p], c.c_wchar_p), + "hid_get_report_descriptor": ([c.c_void_p, c.c_void_p, c.c_size_t], c.c_int), + "hid_write": ([c.c_void_p, c.c_void_p, c.c_size_t], c.c_int), + "hid_read_timeout": ([c.c_void_p, c.c_void_p, c.c_size_t, c.c_int], c.c_int), + } + for name, (arguments, result) in signatures.items(): + function = getattr(self.lib, name, None) + if function is None: + raise OSError(f"unsupported HIDAPI library: missing {name}") + function.argtypes, function.restype = arguments, result + self.handle = None + self.initialized = False + if self.lib.hid_init(): + self.lib.hid_exit() + raise OSError("HID initialization failed") + self.initialized = True + # macOS native HIDAPI supports exclusive opening without a USB reset + # or kernel-driver detach. Make that behavior explicit for live tests. + if hasattr(self.lib,"hid_darwin_set_open_exclusive"): + self.lib.hid_darwin_set_open_exclusive.argtypes = [c.c_int] + self.lib.hid_darwin_set_open_exclusive.restype = None + self.lib.hid_darwin_set_open_exclusive(1) + listing = self.lib.hid_enumerate(0x04D8, 0xF94C) + try: + paths = [] + item = listing + while item: + info = item.contents + if info.interface_number == 0 and info.usage_page == 0xFF00 and info.usage == 1: + paths.append(info.path) + item = info.next + if len(paths) != 1: + raise OSError(f"expected one Mambo HID interface; found {len(paths)}") + self.handle = self.lib.hid_open_path(paths[0]) + if not self.handle: + raise OSError(self.lib.hid_error(None) or "cannot open Mambo HID") + descriptor = c.create_string_buffer(1024) + size = self.lib.hid_get_report_descriptor(self.handle, descriptor, 1024) + if size <= 0 or descriptor.raw[:size] != REPORT_DESCRIPTOR: + raise OSError("report descriptor differs from the verified bootloader") + except Exception: + self.close() + raise + finally: + self.lib.hid_free_enumeration(listing) + + def send(self, packet): + data = bytes([0]) + packet + buffer = c.create_string_buffer(data, len(data)) + size = self.lib.hid_write(self.handle, buffer, len(data)) + if size != len(data): + raise OSError(self.lib.hid_error(self.handle) or "short HID write") + + def receive(self, timeout_ms): + buffer = c.create_string_buffer(64) + size = self.lib.hid_read_timeout(self.handle, buffer, 64, timeout_ms) + if size < 0: + raise OSError(self.lib.hid_error(self.handle) or "HID read failed") + return buffer.raw[:size] if size else None + + def close(self): + if self.handle: + self.lib.hid_close(self.handle) + self.handle = None + if self.initialized: + self.lib.hid_exit() + self.initialized = False + + +def ctypes_library(): + # Prefer native HID/hidraw, not a raw libusb claim or driver detach. + names = ("hidapi-hidraw", "hidapi") if sys.platform.startswith("linux") else ("hidapi",) + for name in names: + path = c.util.find_library(name) + if path: + return path + for candidate in ("/opt/homebrew/lib/libhidapi.dylib", + "/usr/local/lib/libhidapi.dylib"): + if Path(candidate).is_file(): + return candidate + raise OSError("native HIDAPI 0.14+ unavailable; install it or use --hid-library") + + +class LoggedTransport: + def __init__(self, transport, log): + self.transport, self.log = transport, log + self.header_attempted = False + + def record(self, direction, packet): + self.log.write(json.dumps({"monotonic":time.monotonic(), + "direction":direction,"hex":packet.hex()})+"\n") + self.log.flush() + + def send(self, packet): + # Set before the write: an error can leave delivery uncertain. + if packet[2] == 0x38 and packet[5] == 1: + self.header_attempted = True + self.transport.send(packet) + self.record("OUT",packet) + + def receive(self, timeout_ms): + packet = self.transport.receive(timeout_ms) + if packet: + self.record("IN",packet) + return packet + + +def discover(transport, expected_serial=None, event=lambda **kw: None): + transport.send(bytes.fromhex("c80428000e7c")) + stop = time.monotonic() + 3 + while time.monotonic() < stop: + raw = transport.receive(200) + if not raw: + continue + packet = parse_report(raw) + info = device_info(packet) + if info and info["address"] == TARGET: + event(action="device_info", info=info) + if info["name"] != "TBS BOOTLOADER" or info["hardware_id"] != HARDWARE or \ + (expected_serial is not None and info["serial"] != expected_serial) or info["protocol"] != 1: + raise ValueError("target identity/mode differs from the verified Mambo bootloader") + return info + raise TimeoutError("no controller bootloader device-information reply") + + +def upload(transport, image, event=lambda **kw: None, pause=time.sleep, + chunk_delay=0.020): + if not 0.010 <= chunk_delay <= 0.100: + raise ValueError("chunk delay must be between 10 and 100 ms") + # One application header initiates erasure. No automatic restart or resume. + checksum = legacy_crc32(image) + transport.send(header(image)) + event(action="header_sent", bytes=len(image), checksum=f"0x{checksum:08x}") + started = time.monotonic() + phase_deadline = started + 45 + current = None + expected_start = 0 + complete_checksum = False + retries = 0 + while time.monotonic() - started < 600: + if time.monotonic() > phase_deadline: + raise TimeoutError("bootloader progress timed out; no automatic re-erase attempted") + raw = transport.receive(200) + if not raw: + continue + packet = parse_report(raw) + if packet[2] != 0x38 or len(packet) < 7 or packet[3:5] != bytes([ORIGIN, TARGET]): + continue + payload = packet[5:-1] + command = payload[0] + if command == 7: + if len(payload) != 2 or payload[1] > 100 or current is not None: + raise ValueError("unexpected erase-progress response") + event(action="erase_progress", percent=payload[1]) + elif command == 2: + if len(payload) != 13: + raise ValueError("malformed range request") + start, end, reported_crc = struct.unpack(">III", payload[1:]) + if start == end == 0: + if expected_start != len(image) or reported_crc != checksum: + raise ValueError("terminal image checksum/length verification failed") + complete_checksum = True + phase_deadline = time.monotonic() + 15 + event(action="image_checksum_verified", checksum=f"0x{reported_crc:08x}") + continue + if complete_checksum: + raise ValueError("new data requested after terminal verification") + if current == (start, end): + retries += 1 + if retries > 3: + raise ValueError("repeated range request retry limit exceeded") + # The capture contains a duplicate final-range request. Poll + # for its bitmap instead of blindly sending the range twice. + transport.send(frame(bytes([4]))) + event(action="duplicate_range_polled",start=start,end=end) + phase_deadline = time.monotonic() + 15 + continue + else: + if start != expected_start: + raise ValueError("nonsequential bootloader range request") + current = (start, end) + retries = 0 + packets = list(chunks(image, start, end)) + for data_packet in packets: + transport.send(data_packet) + pause(chunk_delay) + transport.send(frame(bytes([4]))) + expected_start = end + phase_deadline = time.monotonic() + 15 + event(action="range_sent", start=start, end=end, chunks=len(packets), + percent=round(100*end/len(image),1)) + elif command == 5: + if len(payload) != 26: + raise ValueError("unexpected/malformed range report") + bitmap, status = payload[1:17], payload[17] + start, end = struct.unpack(">II", payload[18:26]) + if current is None: + raise ValueError("unexpected range report without an active transfer") + if (start, end) != current: + # Delayed duplicate report for an earlier range is benign. + if end <= current[0]: + continue + raise ValueError("range report does not match the current transfer") + if status != 0: + raise ValueError(f"unsupported range report status {status}") + count = math.ceil((end - start)/CHUNK) + missing = [i for i in range(count) if not bitmap[i//8] & (1 << (i%8))] + if missing: + retries += 1 + if retries > 3: + raise ValueError("missing-chunk retry limit exceeded") + for data_packet in chunks(image,start,end,missing): + transport.send(data_packet) + pause(chunk_delay) + transport.send(frame(bytes([4]))) + event(action="chunks_retried", start=start, count=len(missing)) + phase_deadline = time.monotonic() + 15 + elif command == 6: + if len(payload) != 1 or not complete_checksum: + raise ValueError("finished response before image checksum verification") + event(action="download_finished", elapsed_seconds=round(time.monotonic()-started,2)) + return + else: + raise ValueError(f"unsupported firmware response 0x{command:02x}; transfer stopped") + raise TimeoutError("overall transfer deadline exceeded") + + +def serial_number(value): + try: + number = int(value, 0) + except ValueError as error: + raise argparse.ArgumentTypeError("serial must be decimal or 0x-prefixed hex") from error + if not 0 < number <= 0xFFFFFFFF: + raise argparse.ArgumentTypeError("serial must be between 1 and 0xffffffff") + return number + + +def make_plan(image): + return { + "target": "Mambo controller application only; external CRSF included", + "bytes": len(image), + "sha256": hashlib.sha256(image).hexdigest(), + "legacy_checksum": f"0x{legacy_crc32(image):08x}", + "hardware_id": f"0x{HARDWARE:08x}", + "firmware_id": f"0x{FIRMWARE:04x}", + "application_flash": ["0x0800c000", "0x080c0000"], + "elf_memory_and_vectors_verified": True, + "bootloader_update": False, + "internal_rf_update": False, + "reset_command": False, + "usb_access": False, + } + + +def execute(args, image, plan): + # Make logs before USB access. Never overwrite an existing evidence folder. + if args.log_dir: + log_dir = args.log_dir + log_dir.mkdir(parents=True, exist_ok=False) + else: + args.log_root.mkdir(parents=True, exist_ok=True) + prefix = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ-") + log_dir = Path(tempfile.mkdtemp(prefix=prefix, dir=args.log_root)) + if shutil.disk_usage(log_dir).free < 64 * 1024 * 1024: + raise OSError("less than 64 MiB free for transfer logs; USB not opened") + plan = dict(plan, expected_serial=args.expected_serial, + chunk_delay_ms=args.chunk_delay_ms) + (log_dir / "plan.json").write_text(json.dumps(plan, indent=2) + "\n") + print(f"Transfer logs: {log_dir.resolve()}", flush=True) + transport = logged = None + try: + with (log_dir / "events.jsonl").open("x") as events, \ + (log_dir / "usb.jsonl").open("x") as packets: + def event(**data): + data["utc"] = datetime.now(timezone.utc).isoformat() + events.write(json.dumps(data) + "\n") + events.flush() + print(json.dumps(data), flush=True) + + transport = NativeHid(args.hid_library) + logged = LoggedTransport(transport, packets) + info = discover(logged, args.expected_serial, event) + upload(logged, image, event, chunk_delay=args.chunk_delay_ms / 1000) + result = { + "application_transfer_completed": True, + "image_sha256": plan["sha256"], + "device": info, + "terminal_checksum_verified": True, + "flash_readback_performed": False, + "application_startup_verified": False, + "reset_command_sent": False, + } + (log_dir / "result.json").write_text(json.dumps(result, indent=2) + "\n") + print("Application transfer completed; terminal checksum matched. " + "No reboot command sent. Disconnect USB and bench-test on batteries.") + except (Exception, KeyboardInterrupt): + if logged and logged.header_attempted: + print("Transfer stopped after an erase-triggering write was attempted. " + "The application may be incomplete: keep bootloader mode active; " + "do not boot it. No automatic retry or re-erase.", file=sys.stderr) + raise + finally: + if transport: + transport.close() + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--probe", action="store_true", + help="query controller identity over HID; never erase or upload") + mode.add_argument("--execute", action="store_true", + help="flash the verified application; initiates application erase") + parser.add_argument("--image", type=Path, help="raw application firmware.bin") + parser.add_argument("--elf", type=Path, help="matching firmware.elf with symbols") + parser.add_argument("--expected-sha256", help="approved binary SHA256 from the offline plan") + parser.add_argument("--expected-serial", type=serial_number, + help="controller serial from --probe (decimal or 0x-prefixed hex)") + logs = parser.add_mutually_exclusive_group() + logs.add_argument("--log-dir", type=Path, help="new transfer log directory; never overwritten") + logs.add_argument("--log-root", type=Path, + help="create a fresh transfer directory under this path") + parser.add_argument("--hid-library", help="explicit native HIDAPI 0.14+ library path") + parser.add_argument("--chunk-delay-ms", type=int, default=20, + help="data pacing, 10..100 ms (default: 20)") + args = parser.parse_args(argv) + if args.probe and (args.image or args.elf or args.expected_sha256 or + args.log_dir or args.log_root): + parser.error("--probe cannot be combined with image or log arguments") + if not args.probe and (not args.image or not args.elf): + parser.error("offline plan and live flash require --image and --elf") + if args.execute and (args.expected_serial is None or not args.expected_sha256 or + not (args.log_dir or args.log_root)): + parser.error("--execute requires --expected-serial, --expected-sha256 and a log location") + if not 10 <= args.chunk_delay_ms <= 100: + parser.error("--chunk-delay-ms must be between 10 and 100") + try: + if args.probe: + transport = NativeHid(args.hid_library) + try: + info = discover(transport, args.expected_serial) + print(json.dumps(dict(info, application_erase_sent=False), indent=2)) + finally: + transport.close() + return 0 + image = load_image(args.image, args.elf, args.expected_sha256) + plan = make_plan(image) + if not args.execute: + print(json.dumps(plan, indent=2)) + return 0 + execute(args, image, plan) + return 0 + except (OSError, ValueError, TimeoutError, ImportError, struct.error) as error: + print(f"Uploader stopped: {error}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + print("Uploader interrupted; no automatic retry.", file=sys.stderr) + return 130 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/mambo-tests/verify_firmware.py b/tools/mambo-flash/verify_firmware.py similarity index 97% rename from tools/mambo-tests/verify_firmware.py rename to tools/mambo-flash/verify_firmware.py index a03bce2f92a..444098a43e6 100644 --- a/tools/mambo-tests/verify_firmware.py +++ b/tools/mambo-flash/verify_firmware.py @@ -14,6 +14,8 @@ def inside(start, size, bounds): def verify(elf_path, binary_path): + if sys.flags.optimize: + raise ValueError("Verification requires Python assertions enabled") flash = (0x0800C000, 0x080C0000) ram = (0x20000000, 0x20015000) ccm = (0x10000400, 0x10010000) diff --git a/tools/mambo-tests/CMakeLists.txt b/tools/mambo-tests/CMakeLists.txt index 7b717e12315..267573bd846 100644 --- a/tools/mambo-tests/CMakeLists.txt +++ b/tools/mambo-tests/CMakeLists.txt @@ -53,3 +53,6 @@ find_package(Python3 REQUIRED COMPONENTS Interpreter) add_test(NAME mambo_calibration COMMAND ${Python3_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/test_calibration.py $) +add_test(NAME mambo_flash_protocol COMMAND ${Python3_EXECUTABLE} + ${CMAKE_CURRENT_SOURCE_DIR}/test_mambo_flash.py) +set_tests_properties(mambo_flash_protocol PROPERTIES TIMEOUT 30) diff --git a/tools/mambo-tests/README.md b/tools/mambo-tests/README.md index 3c9d6465beb..dad951c907f 100644 --- a/tools/mambo-tests/README.md +++ b/tools/mambo-tests/README.md @@ -8,7 +8,7 @@ cmake --build /tmp/mambo-board-tests ctest --test-dir /tmp/mambo-board-tests --output-on-failure ``` -Requires a C++17 compiler, CMake 3.20+ and Python 3. The eight suites exercise +Requires a C++17 compiler, CMake 3.20+ and Python 3. The nine suites exercise production code using only synthetic data and register/GPIO fakes: - Encoder: APB2 timer rate, EXTI release, interrupt-mask restoration, @@ -22,16 +22,21 @@ production code using only synthetic data and register/GPIO fakes: power-off and emergency-shutdown inhibition. - Power button: active-high Mambo and unchanged active-low behavior. - Battery: full filtered-ADC range, calibration and absent-input behavior. +- Flashing: mocked HID, controller identity before erase, image/hash gates, + chunk retries/timeouts, terminal verification, logging failures and CMake + argument/mode handling. All flashing tests are synthetic and never open USB. These are host tests, not physical timing measurements. The calibration fixtures are artificial and must never be installed on a radio. -`verify_firmware.py` checks an application ELF and its matching binary against -the flash/SRAM/CCM reservations and verifies the real external CRSF UART/DMA +`tools/mambo-flash/verify_firmware.py` checks an application ELF and its matching +binary against the flash/SRAM/CCM reservations and verifies the external CRSF UART/DMA interrupt vectors and board/bay symbols in the default Mambo build. It requires `pyelftools` (included in the repository's Python dependencies). It never opens USB or writes to a radio. `.github/workflows/mambo.yml` runs the host tests and -builds/checks default Mambo without adding a nightly/release target. +builds/checks default Mambo and its offline `flash-plan` target without adding +a nightly/release target. The verified firmware and standalone uploader are +packaged as a CI artifact for bench testers; no private files are included. `mambo-support` retains the standalone board and external CRSF support, direct Hall input/calibration, Lua rotary navigation events and encoder polling fix. diff --git a/tools/mambo-tests/test_mambo_flash.py b/tools/mambo-tests/test_mambo_flash.py new file mode 100644 index 00000000000..4b87b95fed9 --- /dev/null +++ b/tools/mambo-tests/test_mambo_flash.py @@ -0,0 +1,463 @@ +# SPDX-License-Identifier: GPL-2.0-only +"""Uploader state-machine tests. Synthetic transport; never opens USB.""" +from pathlib import Path +import contextlib +import ctypes as c +import hashlib +import io +import json +import os +import shutil +import subprocess +import types +from unittest import mock +import struct +import sys +import tempfile +import unittest + +TOOLS = Path(__file__).resolve().parents[1] +ROOT = TOOLS.parent +sys.path.insert(0, str(TOOLS / "mambo-flash")) +import mambo_flash as flash + + +def reply(payload, kind=0x38, origin=flash.TARGET, destination=flash.ORIGIN): + return flash.frame(payload,destination,origin,kind) + + +class MockTransport: + def __init__(self, replies): + self.replies = list(replies) + self.sent = [] + self.closed = False + + def send(self, data): + self.sent.append(data) + + def receive(self, _timeout): + if not self.replies: + raise AssertionError("unexpected receive beyond the mock transcript") + return self.replies.pop(0) + def close(self): + self.closed = True + + +def ask(start,end,checksum=0): + return reply(bytes([2])+struct.pack(">III",start,end,checksum)) + + +def report(start,end,bits=255,status=0): + return reply(bytes([5,bits])+bytes([255])*15+bytes([status])+struct.pack(">II",start,end)) + + +class FlashTests(unittest.TestCase): + def test_capture_discovery_golden_crc(self): + self.assertEqual(flash.frame(b"",0,0x0e,0x28),bytes.fromhex("c80428000e7c")) + + def test_complete_transfer_and_terminal_verification(self): + image=bytes(range(96)) + crc=flash.legacy_crc32(image) + # Actual bootloader's legacy DownloadFinished has a zero/invalid CRC. + finished=bytes.fromhex("c8053810ea0600") + transport=MockTransport([reply(bytes([7,0])),reply(bytes([7,100])), + ask(0,96),report(0,96),ask(0,0,crc),finished]) + events=[] + flash.upload(transport,image,event=lambda **kw:events.append(kw),pause=lambda _:None) + self.assertEqual(transport.sent[0],flash.header(image)) + self.assertEqual(transport.sent[1:3],list(flash.chunks(image,0,96))) + self.assertEqual(transport.sent[3],flash.frame(bytes([4]))) + self.assertEqual([e['action'] for e in events][-2:], + ['image_checksum_verified','download_finished']) + + def test_missing_chunk_is_retried_and_duplicate_request_polled(self): + image=bytes(range(96)) + transport=MockTransport([ask(0,96),ask(0,96),report(0,96,bits=1), + report(0,96),ask(0,0,flash.legacy_crc32(image)),reply(bytes([6]))]) + flash.upload(transport,image,pause=lambda _:None) + data=[p for p in transport.sent if p[5]==3] + self.assertEqual([p[6] for p in data],[0,1,1]) + self.assertEqual(len([p for p in transport.sent if p[5]==1]),1) + + def test_invalid_ranges_and_early_completion_stop(self): + image=bytes(range(96)) + for incoming in (ask(48,96),ask(0,97),ask(0,6145),ask(1,96),reply(bytes([6])), + ask(0,0,flash.legacy_crc32(image))): + with self.subTest(incoming=incoming.hex()): + transport=MockTransport([incoming]) + with self.assertRaises(ValueError): + flash.upload(transport,image,pause=lambda _:None) + self.assertEqual(transport.sent,[flash.header(image)]) + + def test_bad_terminal_crc_is_not_success(self): + image=bytes(range(96)) + transport=MockTransport([ask(0,96),ask(0,0,flash.legacy_crc32(image)^1)]) + with self.assertRaisesRegex(ValueError,'terminal'): + flash.upload(transport,image,pause=lambda _:None) + + def test_report_range_status_and_retry_limit(self): + image=bytes(range(96)) + for responses in ([report(0,48)], [report(0,96,status=1)], + [report(0,96,bits=0)]*4): + transport=MockTransport([ask(0,96)]+responses) + with self.assertRaises(ValueError): + flash.upload(transport,image,pause=lambda _:None) + self.assertEqual(len([p for p in transport.sent if p[5]==1]),1) + + def test_frame_validation_and_hid_padding(self): + packet=ask(0,96) + self.assertEqual(flash.parse_report(packet+bytes(64-len(packet))),packet) + for raw in (b"", b"\xc8\xff\x38\x00",packet[:-1],packet[:-1]+bytes([packet[-1]^1])): + with self.assertRaises(ValueError): + flash.parse_report(raw) + + def test_bootloader_identity_before_erase(self): + body=b"TBS BOOTLOADER\0"+struct.pack('>IIIBB',0x12345,flash.HARDWARE,0,0,1) + packet=reply(body,kind=0x29,destination=0x0e) + transport=MockTransport([packet]) + info=flash.discover(transport,0x12345) + self.assertEqual(info['hardware_id'],flash.HARDWARE) + self.assertEqual(transport.sent,[bytes.fromhex('c80428000e7c')]) + transport=MockTransport([packet]) + with self.assertRaisesRegex(ValueError,'identity'): + flash.discover(transport,0x54321) + self.assertTrue(all(p[2]==0x28 for p in transport.sent)) + + def test_last_short_chunk_not_padded_into_firmware(self): + image=bytes(range(100)) + packets=list(flash.chunks(image,0,100)) + self.assertEqual(len(packets),3) + self.assertEqual(packets[-1][8:-1],image[96:]) + self.assertEqual(packets[-1][6:8],bytes([2,3])) + + def test_bench_header_version_and_upload_metadata(self): + image=bytes(range(96)) + transport=MockTransport([ask(0,96),ask(0,0,flash.legacy_crc32(image)),reply(bytes([6]))]) + flash.upload(transport,image,pause=lambda _:None) + values=struct.unpack('>BBIHII',transport.sent[0][5:-1]) + self.assertEqual(values[:4],(1,0,flash.HARDWARE,0x0300)) + self.assertEqual(values[4:],(96,flash.legacy_crc32(image))) + with self.assertRaises(TypeError): + flash.header(image, 0xFFFF) + + def test_checksum_vectors(self): + self.assertEqual(flash.legacy_crc32(b"123456789"), 0xA47AA28F) + self.assertEqual(flash.legacy_crc32(bytes(600)), 0x77EDA223) + self.assertEqual(flash.legacy_crc32(bytes(range(256))), 0x492ED94E) + + def test_multirange_transfer_and_stale_bitmap(self): + image = bytes(range(256)) * 25 + transport = MockTransport([ask(0, 6144), report(0, 6144), + ask(6144, len(image)), report(0, 6144), + ask(0, 0, flash.legacy_crc32(image)), reply(bytes([6]))]) + flash.upload(transport, image, pause=lambda _: None) + data = [p[8:-1] for p in transport.sent if p[5] == 3] + self.assertEqual(b"".join(data), image) + self.assertEqual(sum(p[5] == 1 for p in transport.sent), 1) + + def test_pacing_range_and_timeouts_never_reerase(self): + image = bytes(range(96)) + for delay in (0.009, 0.101): + transport = MockTransport([]) + with self.assertRaises(ValueError): + flash.upload(transport, image, chunk_delay=delay) + self.assertEqual(transport.sent, []) + for clock, message in (([0, 1, 46], "progress"), ([0, 601], "overall")): + transport = MockTransport([]) + with mock.patch.object(flash.time, "monotonic", side_effect=clock): + with self.assertRaisesRegex(TimeoutError, message): + flash.upload(transport, image) + self.assertEqual(transport.sent, [flash.header(image)]) + for start, end in ((0, 95), (1, 96), (0, 6145), (0, 6401)): + with self.assertRaises(ValueError): + list(flash.chunks(bytes(6400), start, end)) + + def test_malformed_commands_and_data_after_terminal_are_rejected(self): + image = bytes(range(96)) + for responses in ([reply(b"\x07\x65")], [reply(b"\x02")], + [reply(b"\x05")], [reply(b"\x09")], + [report(0, 96)], [ask(0, 96)] * 5, + [ask(0, 96), ask(0, 0, flash.legacy_crc32(image)), ask(0, 96)]): + with self.assertRaises(ValueError): + flash.upload(MockTransport(responses), image, pause=lambda _: None) + + +def identity(serial=0x12345, hardware=flash.HARDWARE, protocol=1, name="TBS BOOTLOADER"): + return reply(name.encode() + b"\0" + struct.pack(">IIIBB", serial, hardware, 0, 0, protocol), + kind=0x29, destination=0x0e) + + +class CliTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory(prefix="mambo-flash-test-") + self.addCleanup(self.directory.cleanup) + self.path = Path(self.directory.name) + self.image = bytes(range(96)) + self.binary = self.path / "firmware.bin" + self.binary.write_bytes(self.image) + self.elf = self.path / "firmware.elf" + self.elf.write_bytes(b"synthetic ELF placeholder") + self.sha = hashlib.sha256(self.image).hexdigest() + self.args = ["--image", str(self.binary), "--elf", str(self.elf)] + self.live = self.args + ["--execute", "--expected-serial", "0x12345", + "--expected-sha256", self.sha, "--log-dir", str(self.path / "logs")] + + def run_main(self, args, transport=None): + output, error = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(error), \ + mock.patch.object(flash, "NativeHid", return_value=transport) as hid: + result = flash.main(args) + return result, output.getvalue(), error.getvalue(), hid + + def test_offline_plan_never_opens_usb_or_creates_logs(self): + with mock.patch.object(flash, "load_image", return_value=self.image): + result, output, _, hid = self.run_main(self.args + ["--log-dir", str(self.path / "unused")]) + self.assertEqual(result, 0) + self.assertEqual(json.loads(output)["sha256"], self.sha) + self.assertFalse(json.loads(output)["usb_access"]) + hid.assert_not_called() + self.assertFalse((self.path / "unused").exists()) + + def test_probe_is_only_discovery_and_closes_transport(self): + transport = MockTransport([identity()]) + result, output, _, _ = self.run_main(["--probe"], transport) + self.assertEqual(result, 0) + self.assertEqual(json.loads(output)["serial"], 0x12345) + self.assertEqual(transport.sent, [bytes.fromhex("c80428000e7c")]) + self.assertTrue(transport.closed) + + def test_wrong_identity_is_rejected_before_erase(self): + for packet in (identity(serial=1), identity(hardware=1), identity(protocol=2), + identity(name="Mambo application")): + transport = MockTransport([packet]) + with mock.patch.object(flash, "load_image", return_value=self.image): + args = self.live[:-1] + [str(self.path / f"logs-{len(list(self.path.iterdir()))}")] + result, _, error, _ = self.run_main(args, transport) + self.assertEqual(result, 1) + self.assertIn("identity/mode", error) + self.assertEqual(transport.sent, [bytes.fromhex("c80428000e7c")]) + self.assertTrue(transport.closed) + + def test_execute_requires_pins_and_rejects_resume_stock_and_mixed_modes(self): + for args in (self.args + ["--execute"], ["--probe", "--execute"], + ["--probe", "--image", str(self.binary)], self.args + ["--resume"], + self.args + ["--execute-stock"], self.args + ["--chunk-delay-ms", "9"], + self.args + ["--expected-serial", "-1"], + self.args + ["--expected-serial", "0x100000000"]): + with self.subTest(args=args), mock.patch.object(flash, "NativeHid") as hid, \ + contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit): + flash.main(args) + hid.assert_not_called() + + def test_wrong_hash_and_invalid_image_never_open_usb(self): + args = list(self.live) + args[args.index(self.sha)] = "0" * 64 + result, _, error, hid = self.run_main(args) + self.assertEqual(result, 1) + self.assertIn("SHA256", error) + hid.assert_not_called() + self.assertFalse((self.path / "logs").exists()) + for data in (b"", bytes(0xB4001)): + self.binary.write_bytes(data) + result, _, error, hid = self.run_main(self.live) + self.assertEqual(result, 1) + self.assertIn("allocation", error) + hid.assert_not_called() + + def test_image_verification_and_change_detection(self): + verifier = mock.Mock(return_value=(len(self.image), self.sha)) + with mock.patch.dict(sys.modules, {"verify_firmware": types.SimpleNamespace(verify=verifier)}): + self.assertEqual(flash.load_image(self.binary, self.elf, self.sha), self.image) + verifier.assert_called_once_with(self.elf, self.binary) + verifier.return_value = (len(self.image), "0" * 64) + with self.assertRaisesRegex(ValueError, "changed"): + flash.load_image(self.binary, self.elf) + verifier.side_effect = AssertionError("out of bounds") + with self.assertRaisesRegex(ValueError, "ELF/bin"): + flash.load_image(self.binary, self.elf) + with self.assertRaisesRegex(ValueError, "64 hexadecimal"): + flash.load_image(self.binary, self.elf, "not a hash") + + def test_completed_transfer_writes_evidence_without_reset(self): + transport = MockTransport([identity(), ask(0, 96), report(0, 96), + ask(0, 0, flash.legacy_crc32(self.image)), bytes.fromhex("c8053810ea0600")]) + with mock.patch.object(flash, "load_image", return_value=self.image): + result, _, error, _ = self.run_main(self.live, transport) + self.assertEqual((result, error), (0, "")) + result_data = json.loads((self.path / "logs/result.json").read_text()) + self.assertTrue(result_data["terminal_checksum_verified"]) + self.assertFalse(result_data["reset_command_sent"]) + self.assertFalse(result_data["flash_readback_performed"]) + rows = [json.loads(line) for line in (self.path / "logs/usb.jsonl").read_text().splitlines()] + self.assertEqual(sum(row["direction"] == "OUT" for row in rows), 5) + self.assertTrue(transport.closed) + + def test_partial_transfer_and_logging_error_warn_without_retry(self): + for logging_failure in (False, True): + transport = MockTransport([identity(), ask(0, 97)]) + args = self.live[:-1] + [str(self.path / f"failure-{logging_failure}")] + original = flash.LoggedTransport.record + def record(logged, direction, packet): + if logging_failure and direction == "OUT" and packet[5] == 1: + raise OSError("no space left on device") + return original(logged, direction, packet) + with mock.patch.object(flash, "load_image", return_value=self.image), \ + mock.patch.object(flash.LoggedTransport, "record", record): + result, _, error, _ = self.run_main(args, transport) + self.assertEqual(result, 1) + self.assertIn("may be incomplete", error) + self.assertEqual(sum(p[2] == 0x38 and p[5] == 1 for p in transport.sent), 1) + self.assertFalse((Path(args[-1]) / "result.json").exists()) + self.assertTrue(transport.closed) + + def test_existing_logs_and_low_disk_fail_before_usb(self): + (self.path / "logs").mkdir() + with mock.patch.object(flash, "load_image", return_value=self.image): + result, _, _, hid = self.run_main(self.live) + self.assertEqual(result, 1) + hid.assert_not_called() + with mock.patch.object(flash.shutil, "disk_usage", return_value=types.SimpleNamespace(free=1)): + result, _, error, hid = self.run_main(self.live[:-1] + [str(self.path / "low-disk")]) + self.assertEqual(result, 1) + self.assertIn("64 MiB", error) + hid.assert_not_called() + + +class NativeHidTests(unittest.TestCase): + def library(self, count=1, descriptor=flash.REPORT_DESCRIPTOR): + entries = [] + for i in range(count): + entry = flash.HidInfo() + entry.path = f"synthetic-device-{i}".encode() + entry.interface_number = 0 + entry.usage_page, entry.usage = 0xFF00, 1 + if entries: + entries[-1].next = c.pointer(entry) + entries.append(entry) + lib = types.SimpleNamespace(**{name: mock.Mock() for name in ( + "hid_init", "hid_exit", "hid_enumerate", "hid_free_enumeration", + "hid_open_path", "hid_close", "hid_error", "hid_get_report_descriptor", + "hid_write", "hid_read_timeout")}) + lib.hid_init.return_value = 0 + lib.hid_enumerate.return_value = c.pointer(entries[0]) if entries else None + lib.hid_open_path.return_value = 1 + lib.hid_error.return_value = None + def get_descriptor(_handle, buffer, _size): + c.memmove(buffer, descriptor, len(descriptor)) + return len(descriptor) + lib.hid_get_report_descriptor.side_effect = get_descriptor + lib.entries = entries # Keep the fake linked list alive. + return lib + + def test_descriptor_enumeration_and_unnumbered_reports(self): + lib = self.library() + with mock.patch.object(flash.c, "CDLL", return_value=lib): + transport = flash.NativeHid("synthetic-library") + lib.hid_enumerate.assert_called_once_with(0x04D8, 0xF94C) + lib.hid_free_enumeration.assert_called_once() + packet = flash.frame(b"\x04") + def write(_handle, buffer, size): + self.assertEqual(buffer.raw[:size], b"\0" + packet) + return size + lib.hid_write.side_effect = write + transport.send(packet) + def read(_handle, buffer, _size, _timeout): + c.memmove(buffer, packet, len(packet)) + return len(packet) + lib.hid_read_timeout.side_effect = read + self.assertEqual(transport.receive(200), packet) + transport.close() + transport.close() + lib.hid_close.assert_called_once_with(1) + lib.hid_exit.assert_called_once() + + def test_refuses_multiple_devices_and_wrong_descriptor(self): + for count, descriptor in ((0, flash.REPORT_DESCRIPTOR), + (2, flash.REPORT_DESCRIPTOR), (1, b"different")): + lib = self.library(count, descriptor) + with mock.patch.object(flash.c, "CDLL", return_value=lib): + with self.assertRaises(OSError): + flash.NativeHid("synthetic-library") + lib.hid_exit.assert_called_once() + lib.hid_free_enumeration.assert_called_once() + if count != 1: + lib.hid_open_path.assert_not_called() + else: + lib.hid_close.assert_called_once() + + def test_missing_api_and_initialization_fail_before_open(self): + lib = self.library() + del lib.hid_get_report_descriptor + with mock.patch.object(flash.c, "CDLL", return_value=lib): + with self.assertRaisesRegex(OSError, "0.14"): + flash.NativeHid("synthetic-library") + lib.hid_init.assert_not_called() + lib = self.library() + lib.hid_init.return_value = -1 + with mock.patch.object(flash.c, "CDLL", return_value=lib): + with self.assertRaisesRegex(OSError, "initialization"): + flash.NativeHid("synthetic-library") + lib.hid_enumerate.assert_not_called() + lib.hid_exit.assert_called_once() + + def test_short_write_and_failed_read_are_errors(self): + lib = self.library() + with mock.patch.object(flash.c, "CDLL", return_value=lib): + transport = flash.NativeHid("synthetic-library") + lib.hid_write.return_value = 1 + with self.assertRaisesRegex(OSError, "short HID write"): + transport.send(flash.frame(b"\x04")) + lib.hid_read_timeout.return_value = -1 + with self.assertRaisesRegex(OSError, "HID read failed"): + transport.receive(200) + lib.hid_read_timeout.return_value = 0 + self.assertIsNone(transport.receive(200)) + transport.close() + + +class CMakeTests(unittest.TestCase): + @unittest.skipUnless(shutil.which("cmake"), "CMake unavailable") + def test_runner_modes_guards_paths_and_error_propagation(self): + with tempfile.TemporaryDirectory(prefix="mambo cmake ") as directory: + path = Path(directory) + script = path / "stub.py" + script.write_text("import json,sys; print(json.dumps(sys.argv[1:]))\n") + common = [shutil.which("cmake"), f"-DMAMBO_FLASH_PYTHON={sys.executable}", + f"-DMAMBO_FLASH_SCRIPT={script}", f"-DMAMBO_FLASH_IMAGE={path / 'firmware.bin'}", + f"-DMAMBO_FLASH_ELF={path / 'firmware.elf'}", f"-DMAMBO_FLASH_LOG_ROOT={path / 'logs'}"] + env = dict(os.environ) + for name in ("MAMBO_FLASH_SERIAL", "MAMBO_FLASH_SHA256", "MAMBO_HID_LIBRARY"): + env.pop(name, None) + def run(mode): + return subprocess.run(common + [f"-DMAMBO_FLASH_MODE={mode}", "-P", + str(ROOT / "cmake/RunMamboFlash.cmake")], capture_output=True, text=True, env=env) + result = run("PLAN") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout), ["--image", str(path / "firmware.bin"), + "--elf", str(path / "firmware.elf")]) + result = run("PROBE") + self.assertEqual(json.loads(result.stdout), ["--probe"]) + result = run("FLASH") + self.assertNotEqual(result.returncode, 0) + self.assertIn("MAMBO_FLASH_SERIAL", result.stderr) + self.assertEqual(result.stdout, "") + env["MAMBO_FLASH_SERIAL"] = "0x12345" + result = run("FLASH") + self.assertNotEqual(result.returncode, 0) + self.assertIn("MAMBO_FLASH_SHA256", result.stderr) + env["MAMBO_FLASH_SHA256"] = "a" * 64 + result = run("FLASH") + self.assertEqual(result.returncode, 0, result.stderr) + args = json.loads(result.stdout) + self.assertIn("--execute", args) + self.assertEqual(args[args.index("--expected-serial") + 1], "0x12345") + self.assertEqual(args[args.index("--expected-sha256") + 1], "a" * 64) + self.assertEqual(json.loads(run("PLAN").stdout)[:1], ["--image"]) + self.assertNotEqual(run("UNKNOWN").returncode, 0) + script.write_text("import sys; sys.exit(7)\n") + self.assertNotEqual(run("FLASH").returncode, 0) + + +if __name__ == "__main__": + unittest.main() From d1277a6043f241b04a508731610e65c7ad7cce42 Mon Sep 17 00:00:00 2001 From: Ilya Date: Sat, 17 Oct 2026 19:00:19 +0100 Subject: [PATCH 09/11] fix(mambo): use hardware-tested application upload pacing --- tools/mambo-flash/README.txt | 7 +++++-- tools/mambo-flash/mambo_flash.py | 9 ++++++--- tools/mambo-tests/test_mambo_flash.py | 14 +++++++++++++- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/tools/mambo-flash/README.txt b/tools/mambo-flash/README.txt index 2d4cf03b11d..2876041bb78 100644 --- a/tools/mambo-flash/README.txt +++ b/tools/mambo-flash/README.txt @@ -102,8 +102,11 @@ the following from its directory (Python/pyelftools/HIDAPI are still needed): Without --execute or --probe, the CLI is an offline verifier only. Probe and execute modes cannot be combined. No firmware/RF profile selector exists. -The default data pacing is 20 ms per chunk; --chunk-delay-ms accepts 10..100. -The CMake targets deliberately use the default pacing. +The default data pacing is 15 ms per chunk, matching successful full recovery +transfers; --chunk-delay-ms accepts 10..100 for diagnostics. Other pacing is +not hardware-qualified. Slower sending is not necessarily safer: a live test +with the former 20 ms default exhausted its missing-chunk retries. The CMake +targets deliberately use the same default as the direct CLI. Failure / recovery ------------------ diff --git a/tools/mambo-flash/mambo_flash.py b/tools/mambo-flash/mambo_flash.py index cacc4abaa7b..90dde8456e1 100644 --- a/tools/mambo-flash/mambo_flash.py +++ b/tools/mambo-flash/mambo_flash.py @@ -26,6 +26,9 @@ ORIGIN = 0x10 CHUNK = 48 MAX_RANGE = 128 * CHUNK +# Match the pacing used by successful full application/recovery transfers. +# The former 20 ms default failed a live transfer; slower is not proven safer. +DEFAULT_CHUNK_DELAY_MS = 15 REPORT_DESCRIPTOR = bytes.fromhex("0600ff0901a1011901294015012540750895408100190129409100c0") @@ -278,7 +281,7 @@ def discover(transport, expected_serial=None, event=lambda **kw: None): def upload(transport, image, event=lambda **kw: None, pause=time.sleep, - chunk_delay=0.020): + chunk_delay=DEFAULT_CHUNK_DELAY_MS / 1000): if not 0.010 <= chunk_delay <= 0.100: raise ValueError("chunk delay must be between 10 and 100 ms") # One application header initiates erasure. No automatic restart or resume. @@ -475,8 +478,8 @@ def main(argv=None): logs.add_argument("--log-root", type=Path, help="create a fresh transfer directory under this path") parser.add_argument("--hid-library", help="explicit native HIDAPI 0.14+ library path") - parser.add_argument("--chunk-delay-ms", type=int, default=20, - help="data pacing, 10..100 ms (default: 20)") + parser.add_argument("--chunk-delay-ms", type=int, default=DEFAULT_CHUNK_DELAY_MS, + help=f"data pacing, 10..100 ms (default: {DEFAULT_CHUNK_DELAY_MS})") args = parser.parse_args(argv) if args.probe and (args.image or args.elf or args.expected_sha256 or args.log_dir or args.log_root): diff --git a/tools/mambo-tests/test_mambo_flash.py b/tools/mambo-tests/test_mambo_flash.py index 4b87b95fed9..091d111dfc4 100644 --- a/tools/mambo-tests/test_mambo_flash.py +++ b/tools/mambo-tests/test_mambo_flash.py @@ -130,6 +130,14 @@ def test_last_short_chunk_not_padded_into_firmware(self): self.assertEqual(packets[-1][8:-1],image[96:]) self.assertEqual(packets[-1][6:8],bytes([2,3])) + def test_default_upload_pacing_matches_successful_full_transfer(self): + image = bytes(range(96)) + transport = MockTransport([ask(0, 96), + ask(0, 0, flash.legacy_crc32(image)), reply(bytes([6]))]) + delays = [] + flash.upload(transport, image, pause=delays.append) + self.assertEqual(delays, [0.015, 0.015]) + def test_bench_header_version_and_upload_metadata(self): image=bytes(range(96)) transport=MockTransport([ask(0,96),ask(0,0,flash.legacy_crc32(image)),reply(bytes([6]))]) @@ -282,9 +290,13 @@ def test_image_verification_and_change_detection(self): def test_completed_transfer_writes_evidence_without_reset(self): transport = MockTransport([identity(), ask(0, 96), report(0, 96), ask(0, 0, flash.legacy_crc32(self.image)), bytes.fromhex("c8053810ea0600")]) - with mock.patch.object(flash, "load_image", return_value=self.image): + with mock.patch.object(flash, "load_image", return_value=self.image), \ + mock.patch.object(flash, "upload", wraps=flash.upload) as upload: result, _, error, _ = self.run_main(self.live, transport) self.assertEqual((result, error), (0, "")) + self.assertEqual(upload.call_args.kwargs["chunk_delay"], 0.015) + plan_data = json.loads((self.path / "logs/plan.json").read_text()) + self.assertEqual(plan_data["chunk_delay_ms"], 15) result_data = json.loads((self.path / "logs/result.json").read_text()) self.assertTrue(result_data["terminal_checksum_verified"]) self.assertFalse(result_data["reset_command_sent"]) From 441cf439bd1b01edcb2d4a8367de1fb48ec0816e Mon Sep 17 00:00:00 2001 From: Ilya Date: Sat, 17 Oct 2026 19:04:50 +0100 Subject: [PATCH 10/11] docs(mambo): record default HID flashing hardware test --- radio/src/targets/mambo/README.txt | 9 ++++++--- tools/mambo-flash/README.txt | 8 +++++--- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/radio/src/targets/mambo/README.txt b/radio/src/targets/mambo/README.txt index 3a3b20edbe2..6507ee32ad7 100644 --- a/radio/src/targets/mambo/README.txt +++ b/radio/src/targets/mambo/README.txt @@ -83,6 +83,9 @@ charging/off-state behavior, physical emergency-shutdown bay checks, faster CRSF negotiation/interoperability and Companion/release support remain open. This is bench evidence, not flight validation. No nightly or release target is added, and no private calibration, captured binaries or RF research tooling -belongs in the patch. The new public application-only flash target is separate -from internal-RF research tooling; its exact entry point still needs a live -hardware test even though the underlying transfer logic was used previously. +belongs in the patch. The public application-only flash target is separate +from internal-RF research tooling. Its default CMake path completed a full +controller application transfer on one macOS/Mambo bench at d1277a6043, using +15 ms pacing with no retries and a matching terminal checksum. This verifies +transfer completion, not flash readback or startup; Windows/Linux flashing +remains unqualified. See tools/mambo-flash/README.txt for the test scope. diff --git a/tools/mambo-flash/README.txt b/tools/mambo-flash/README.txt index 2876041bb78..ed0fb8864ea 100644 --- a/tools/mambo-flash/README.txt +++ b/tools/mambo-flash/README.txt @@ -22,9 +22,11 @@ Requirements generated by the firmware build; the binary alone is insufficient. - A USB data cable and the radio in its controller bootloader mode. -Earlier versions of this transfer logic have been hardware-tested on macOS -with one Mambo. The public extraction is covered by simulated tests; live -hardware testing of this exact entry point is still required. Linux requires +The public flash-plan, flash-probe and default CMake flash path were tested +on macOS with one Mambo at revision d1277a6043. A full 478368-byte application +transfer completed at the default 15 ms pacing with no retries, a matching +terminal checksum and DownloadFinished. This is not flash readback or proof +of startup/flight safety; perform the battery-only checks below. Linux needs native hidraw access permissions, and Windows a matching HIDAPI DLL. Neither Windows nor Linux is hardware-qualified here. Do not detach/replace USB drivers, change option bytes or disable readout protection to use this tool. From b3555d28e67df884235586b52ccd8a7f8c9ee031 Mon Sep 17 00:00:00 2001 From: Ilya Date: Sat, 17 Oct 2026 19:06:34 +0100 Subject: [PATCH 11/11] docs(mambo): record post-flash owner bench confirmation --- radio/src/targets/mambo/README.txt | 8 ++++++-- tools/mambo-flash/README.txt | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/radio/src/targets/mambo/README.txt b/radio/src/targets/mambo/README.txt index 6507ee32ad7..39a47ffd079 100644 --- a/radio/src/targets/mambo/README.txt +++ b/radio/src/targets/mambo/README.txt @@ -87,5 +87,9 @@ belongs in the patch. The public application-only flash target is separate from internal-RF research tooling. Its default CMake path completed a full controller application transfer on one macOS/Mambo bench at d1277a6043, using 15 ms pacing with no retries and a matching terminal checksum. This verifies -transfer completion, not flash readback or startup; Windows/Linux flashing -remains unqualified. See tools/mambo-flash/README.txt for the test scope. +transfer completion, not flash readback. After being asked to check battery-only +startup, encoder, sticks and external bay, the owner reported "Everything looks +good" for that flashed build. This is an overall post-flash bench report, not +individual measurements, repeated-boot evidence or flight validation. +Windows/Linux flashing remains unqualified. See tools/mambo-flash/README.txt +for the test scope. diff --git a/tools/mambo-flash/README.txt b/tools/mambo-flash/README.txt index ed0fb8864ea..a81a4fc125e 100644 --- a/tools/mambo-flash/README.txt +++ b/tools/mambo-flash/README.txt @@ -26,8 +26,11 @@ The public flash-plan, flash-probe and default CMake flash path were tested on macOS with one Mambo at revision d1277a6043. A full 478368-byte application transfer completed at the default 15 ms pacing with no retries, a matching terminal checksum and DownloadFinished. This is not flash readback or proof -of startup/flight safety; perform the battery-only checks below. Linux needs -native hidraw access permissions, and Windows a matching HIDAPI DLL. Neither +of flight safety. After being asked to check battery-only startup, encoder, +sticks and external bay, the owner reported "Everything looks good"; no +individual measurements or repeat-boot count were supplied. Perform the +battery-only checks below on each installation. Linux needs native hidraw +access permissions, and Windows a matching HIDAPI DLL. Neither Windows nor Linux is hardware-qualified here. Do not detach/replace USB drivers, change option bytes or disable readout protection to use this tool.