diff --git a/.github/workflows/mambo.yml b/.github/workflows/mambo.yml new file mode 100644 index 00000000000..470a9060682 --- /dev/null +++ b/.github/workflows/mambo.yml @@ -0,0 +1,75 @@ +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/**' + - 'tools/mambo-flash/**' + - '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-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/CMakePresets.json b/CMakePresets.json index 19d30545ff5..279d62e4288 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -351,6 +351,12 @@ "displayName": "Senduwing H17", "inherits": "firmware", "cacheVariables": { "PCB": "H17" } + }, + { + "name": "mambo", + "displayName": "TBS Mambo experimental (external CRSF; internal RF disabled)", + "inherits": "firmware", + "cacheVariables": { "PCB": "MAMBO", "BOOTLOADER": false } } ], "buildPresets": [ 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 27042a30736..d245fe59cd1 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,9 @@ else() ) endif() -if(CPU_FAMILY STREQUAL STM32) +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/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..b287c1815cf --- /dev/null +++ b/radio/src/boards/hw_defs/mambo.json @@ -0,0 +1,310 @@ +{ + "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" + } + ], + "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..4230b4bb00a 100644 --- a/radio/src/gui/128x64/radio_diaganas.cpp +++ b/radio/src/gui/128x64/radio_diaganas.cpp @@ -21,6 +21,133 @@ #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" +#include "hal/module_port.h" +#include "targets/mambo/external_rf.h" + +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; + lcdDrawText( + 0, y, + modulePortPowered(EXTERNAL_MODULE) ? "Ext ON Cal:" : "Ext OFF Cal:"); + 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); +} +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 #define HOLDANAVALUEFRAMES 4 /* 4* 50ms = 200 ms update rate */ @@ -33,7 +160,15 @@ 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, + ANAVIEW_MAMBO_EXTERNAL, + ANAVIEW_LAST=ANAVIEW_MAMBO_EXTERNAL +#else ANAVIEW_LAST=ANAVIEW_RAWLOWFPS +#endif }; static int viewpage = ANAVIEW_FIRST; @@ -71,6 +206,21 @@ 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; + case ANAVIEW_MAMBO_EXTERNAL: + SIMPLE_SUBMENU("Mambo External", HEADER_LINE+1); + drawMamboExternal(); + return; +#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..28d35b21abb 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(RADIO_MAMBO) + // 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..c2899f18f73 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(RADIO_MAMBO) +#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(RADIO_MAMBO) + 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(RADIO_MAMBO) + 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(RADIO_MAMBO) + 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(RADIO_MAMBO) + 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(RADIO_MAMBO) + 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(RADIO_MAMBO) + 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..ce942db89d9 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(RADIO_MAMBO) + // 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..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,6 +27,9 @@ #include "stm32_hal_ll.h" #include +#if defined(RADIO_MAMBO) +#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(RADIO_MAMBO) + 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(RADIO_MAMBO) + 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..300d9a516fe --- /dev/null +++ b/radio/src/targets/mambo/CMakeLists.txt @@ -0,0 +1,11 @@ +# Experimental application-only Mambo target. The existing TBS bootloader and +# RF firmware are not built or included; internal Tracer remains disabled. +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 + targets/mambo/encoder.cpp targets/mambo/external_rf.cpp) diff --git a/radio/src/targets/mambo/README.txt b/radio/src/targets/mambo/README.txt new file mode 100644 index 00000000000..39a47ffd079 --- /dev/null +++ b/radio/src/targets/mambo/README.txt @@ -0,0 +1,95 @@ +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. 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 +(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 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, 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. + +Keep PR #7907 experimental/draft. Receiver failsafe, long-duration stability, +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 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. 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/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/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/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..e798e56aa4d --- /dev/null +++ b/radio/src/targets/mambo/hal.h @@ -0,0 +1,106 @@ +// 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) +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 +#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 +#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..f3b1e0c72cd --- /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() +{ + // 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); + 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..3afcaf2dd44 100644 --- a/radio/src/targets/taranis/CMakeLists.txt +++ b/radio/src/targets/taranis/CMakeLists.txt @@ -447,6 +447,23 @@ 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 YES) + set(MODULE_SIZE_SML YES) + add_definitions(-DPCBX7 -DPCBMAMBO -DSOFTWARE_VOLUME -DPWR_BUTTON_PRESS) endif() message("CPU_TYPE_FULL = ${CPU_TYPE_FULL}") @@ -473,7 +490,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 +516,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 +532,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 +673,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..c419fb3cb3a 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" @@ -126,6 +129,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) @@ -227,6 +233,9 @@ void boardInit() #if defined(ROTARY_ENCODER_NAVIGATION) rotaryEncoderInit(); +#if defined(RADIO_MAMBO) + mambo::encoderStartPolling(); +#endif #endif gimbalsDetect(); @@ -287,6 +296,10 @@ void boardInit() void boardOff() { +#if defined(RADIO_MAMBO) + // 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": { diff --git a/tools/mambo-flash/README.txt b/tools/mambo-flash/README.txt new file mode 100644 index 00000000000..a81a4fc125e --- /dev/null +++ b/tools/mambo-flash/README.txt @@ -0,0 +1,125 @@ +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. + +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 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. + +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 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 +------------------ +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..90dde8456e1 --- /dev/null +++ b/tools/mambo-flash/mambo_flash.py @@ -0,0 +1,519 @@ +# 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 +# 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") + + +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=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. + 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=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): + 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-flash/verify_firmware.py b/tools/mambo-flash/verify_firmware.py new file mode 100644 index 00000000000..444098a43e6 --- /dev/null +++ b/tools/mambo-flash/verify_firmware.py @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: GPL-2.0-only +"""Read-only verification of Mambo application ELF/bin memory boundaries.""" +import argparse +import hashlib +from pathlib import Path +import struct +import sys + +from elftools.elf.elffile import ELFFile + + +def inside(start, size, bounds): + return bounds[0] <= start and start + size <= bounds[1] + + +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) + ranges = [flash, ram, ccm] + with elf_path.open("rb") as handle: + elf = ELFFile(handle) + assert elf.elfclass == 32 and elf.little_endian + assert elf.header["e_machine"] == "EM_ARM" + file_ranges = [] + for segment in elf.iter_segments(): + if segment["p_type"] != "PT_LOAD": + continue + address, size = segment["p_vaddr"], segment["p_memsz"] + assert any(inside(address, size, bounds) for bounds in ranges), ( + "allocation outside Mambo application regions", hex(address), size) + if segment["p_filesz"]: + address, size = segment["p_paddr"], segment["p_filesz"] + assert inside(address, size, flash), ("load overlaps reserved flash", hex(address), size) + file_ranges.append((address, address + size)) + assert file_ranges and min(start for start, _ in file_ranges) == flash[0] + vector = elf.get_section_by_name(".isr_vector") + assert vector and vector["sh_addr"] == flash[0] + stack, reset = struct.unpack(") +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 new file mode 100644 index 00000000000..dad951c907f --- /dev/null +++ b/tools/mambo-tests/README.md @@ -0,0 +1,45 @@ +# 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 +``` + +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, + 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. +- 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. + +`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 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. +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/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/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/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/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; } 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("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_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]))]) + 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), \ + 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"]) + 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() diff --git a/tools/mambo-tests/trim_tests.cpp b/tools/mambo-tests/trim_tests.cpp new file mode 100644 index 00000000000..0118e0e28c2 --- /dev/null +++ b/tools/mambo-tests/trim_tests.cpp @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-2.0-only +#include "targets/mambo/inputs.h" + +// Check documented nominal resistor ratios and the original right-vertical +// trim orientation, including idle/shorted/ambiguous boundary readings. +static_assert(mambo::decodeTrimLadder(4095) == 0); +static_assert(mambo::decodeTrimLadder(0) == 0); +static_assert(mambo::decodeTrimLadder(3071) == 0); +static_assert(mambo::decodeTrimLadder(4095 / 2) == 0x01); +static_assert(mambo::decodeTrimLadder(4095 / 3) == 0x02); +static_assert(mambo::decodeTrimLadder(4095 / 4) == 0x04); +static_assert(mambo::decodeTrimLadder(4095 / 5) == 0x08); +static_assert(mambo::decodeTrimLadder(4095 / 6) == 0x20); +static_assert(mambo::decodeTrimLadder(4095 / 7) == 0x10); +static_assert(mambo::decodeTrimLadder(4095 / 8) == 0x40); +static_assert(mambo::decodeTrimLadder(4095 / 9) == 0x80); +int main() { return 0; }