From a4d91f12ef19702ac4ad79c31d3cd563db67edb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=AA=E6=9D=A5=E6=96=B9=E8=88=9F?= Date: Sat, 1 Aug 2026 18:57:45 +0800 Subject: [PATCH 1/4] Fix waterfall designer crash and also some linker script fixes (#3281) * init * _ --- firmware/application/CMakeLists.txt | 5 +- firmware/application/external/external.ld | 184 ++++----- .../ui_waterfall_designer.cpp | 352 +++++++++++------- .../ui_waterfall_designer.hpp | 15 +- firmware/application/ui/ui_menu.cpp | 6 +- .../tools/check_external_symbol_placement.py | 139 +++++++ firmware/tools/guru_lookup.py | 187 ++++++++++ firmware/tools/stack_usage.py | 214 +++++++++++ 8 files changed, 864 insertions(+), 238 deletions(-) create mode 100755 firmware/tools/check_external_symbol_placement.py create mode 100755 firmware/tools/guru_lookup.py create mode 100755 firmware/tools/stack_usage.py diff --git a/firmware/application/CMakeLists.txt b/firmware/application/CMakeLists.txt index dc642cfbb..2372f1dbd 100644 --- a/firmware/application/CMakeLists.txt +++ b/firmware/application/CMakeLists.txt @@ -493,7 +493,10 @@ add_executable(${PROJECT_NAME}.elf ${CSRC} ${CPPSRC} ${ASMSRC}) target_link_options(${PROJECT_NAME}.elf PRIVATE "LINKER:--defsym,LD_FLASH_SIZE=${FLASH_BYTES_LIMIT_SIZE}" ) -set_target_properties(${PROJECT_NAME}.elf PROPERTIES LINK_DEPENDS ${LDSCRIPT}) +# NB: LPC43xx_M0.ld does INCLUDE external.ld, so that one has to be listed too - +# otherwise editing the external app section rules silently does not relink. +set_target_properties(${PROJECT_NAME}.elf PROPERTIES + LINK_DEPENDS "${LDSCRIPT};${CMAKE_CURRENT_SOURCE_DIR}/external/external.ld") add_definitions(${DEFS}) include_directories(. ${INCDIR}) link_directories(${LLIBDIR}) diff --git a/firmware/application/external/external.ld b/firmware/application/external/external.ld index 6fc27a33a..d745d1da6 100644 --- a/firmware/application/external/external.ld +++ b/firmware/application/external/external.ld @@ -122,553 +122,553 @@ SECTIONS .external_app_afsk_rx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_afsk_rx.application_information)); - *(*ui*external_app*afsk_rx*); + */external/afsk_rx/*(*ui*external_app*afsk_rx*); } > ram_external_app_afsk_rx .external_app_calculator : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_calculator.application_information)); - *(*ui*external_app*calculator*); + */external/calculator/*(*ui*external_app*calculator*); } > ram_external_app_calculator .external_app_font_viewer : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_font_viewer.application_information)); - *(*ui*external_app*font_viewer*); + */external/font_viewer/*(*ui*external_app*font_viewer*); } > ram_external_app_font_viewer .external_app_blespam : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_blespam.application_information)); - *(*ui*external_app*blespam*); + */external/blespam/*(*ui*external_app*blespam*); } > ram_external_app_blespam .external_app_analogtv : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_analogtv.application_information)); - *(*ui*external_app*analogtv*); + */external/analogtv/*(*ui*external_app*analogtv*); } > ram_external_app_analogtv .external_app_nrf_rx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_nrf_rx.application_information)); - *(*ui*external_app*nrf_rx*); + */external/nrf_rx/*(*ui*external_app*nrf_rx*); } > ram_external_app_nrf_rx .external_app_coasterp : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_coasterp.application_information)); - *(*ui*external_app*coasterp*); + */external/coasterp/*(*ui*external_app*coasterp*); } > ram_external_app_coasterp .external_app_lge : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_lge.application_information)); - *(*ui*external_app*lge*); + */external/lge/*(*ui*external_app*lge*); } > ram_external_app_lge .external_app_lcr : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_lcr.application_information)); - *(*ui*external_app*lcr*); + */external/lcr/*(*ui*external_app*lcr*); } > ram_external_app_lcr .external_app_jammer : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_jammer.application_information)); - *(*ui*external_app*jammer*); + */external/jammer/*(*ui*external_app*jammer*); } > ram_external_app_jammer .external_app_gpssim : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_gpssim.application_information)); - *(*ui*external_app*gpssim*); + */external/gpssim/*(*ui*external_app*gpssim*); } > ram_external_app_gpssim .external_app_spainter : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_spainter.application_information)); - *(*ui*external_app*spainter*); + */external/spainter/*(*ui*external_app*spainter*); } > ram_external_app_spainter .external_app_keyfob : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_keyfob.application_information)); - *(*ui*external_app*keyfob*); + */external/keyfob/*(*ui*external_app*keyfob*); } > ram_external_app_keyfob .external_app_tetris : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_tetris.application_information)); - *(*ui*external_app*tetris*); + */external/tetris/*(*ui*external_app*tetris*); } > ram_external_app_tetris .external_app_extsensors : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_extsensors.application_information)); - *(*ui*external_app*extsensors*); + */external/extsensors/*(*ui*external_app*extsensors*); } > ram_external_app_extsensors .external_app_foxhunt_rx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_foxhunt_rx.application_information)); - *(*ui*external_app*foxhunt_rx*); + */external/foxhunt/*(*ui*external_app*foxhunt_rx*); } > ram_external_app_foxhunt_rx .external_app_audio_test : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_audio_test.application_information)); - *(*ui*external_app*audio_test*); + */external/audio_test/*(*ui*external_app*audio_test*); } > ram_external_app_audio_test .external_app_wardrivemap : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_wardrivemap.application_information)); - *(*ui*external_app*wardrivemap*); + */external/wardrivemap/*(*ui*external_app*wardrivemap*); } > ram_external_app_wardrivemap .external_app_tpmsrx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_tpmsrx.application_information)); - *(*ui*external_app*tpmsrx*); + */external/tpmsrx/*(*ui*external_app*tpmsrx*); } > ram_external_app_tpmsrx .external_app_tpmstx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_tpmstx.application_information)); - *(*ui*external_app*tpmstx*); + */external/tpmstx/*(*ui*external_app*tpmstx*); } > ram_external_app_tpmstx .external_app_protoview : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_protoview.application_information)); - *(*ui*external_app*protoview*); + */external/protoview/*(*ui*external_app*protoview*); } > ram_external_app_protoview .external_app_adsbtx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_adsbtx.application_information)); - *(*ui*external_app*adsbtx*); + */external/adsbtx/*(*ui*external_app*adsbtx*); } > ram_external_app_adsbtx .external_app_morse_tx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_morse_tx.application_information)); - *(*ui*external_app*morse_tx*); + */external/morse_tx/*(*ui*external_app*morse_tx*); } > ram_external_app_morse_tx .external_app_sstvtx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_sstvtx.application_information)); - *(*ui*external_app*sstvtx*); + */external/sstvtx/*(*ui*external_app*sstvtx*); } > ram_external_app_sstvtx .external_app_random_password : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_random_password.application_information)); - *(*ui*external_app*random_password*); + */external/random_password/*(*ui*external_app*random_password*); } > ram_external_app_random_password .external_app_acars_rx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_acars_rx.application_information)); - *(*ui*external_app*acars_rx*); + */external/acars_rx/*(*ui*external_app*acars_rx*); } > ram_external_app_acars_rx .external_app_shoppingcart_lock : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_shoppingcart_lock.application_information)); - *(*ui*external_app*shoppingcart_lock*); + */external/shoppingcart_lock/*(*ui*external_app*shoppingcart_lock*); } > ram_external_app_shoppingcart_lock .external_app_cvs_spam : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_cvs_spam.application_information)); - *(*ui*external_app*cvs_spam*); + */external/cvs_spam/*(*ui*external_app*cvs_spam*); } > ram_external_app_cvs_spam .external_app_ookbrute : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_ookbrute.application_information)); - *(*ui*external_app*ookbrute*); + */external/ookbrute/*(*ui*external_app*ookbrute*); } > ram_external_app_ookbrute .external_app_ook_editor : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_ook_editor.application_information)); - *(*ui*external_app*ook_editor*); + */external/ook_editor/*(*ui*external_app*ook_editor*); } > ram_external_app_ook_editor .external_app_flippertx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_flippertx.application_information)); - *(*ui*external_app*flippertx*); + */external/flippertx/*(*ui*external_app*flippertx*); } > ram_external_app_flippertx .external_app_remote : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_remote.application_information)); - *(*ui*external_app*remote*); + */external/remote/*(*ui*external_app*remote*); } > ram_external_app_remote .external_app_mcu_temperature : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_mcu_temperature.application_information)); - *(*ui*external_app*mcu_temperature*); + */external/mcu_temperature/*(*ui*external_app*mcu_temperature*); } > ram_external_app_mcu_temperature .external_app_fmradio : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_fmradio.application_information)); - *(*ui*external_app*fmradio*); + */external/fmradio/*(*ui*external_app*fmradio*); } > ram_external_app_fmradio .external_app_tuner : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_tuner.application_information)); - *(*ui*external_app*tuner*); + */external/tuner/*(*ui*external_app*tuner*); } > ram_external_app_tuner .external_app_metronome : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_metronome.application_information)); - *(*ui*external_app*metronome*); + */external/metronome/*(*ui*external_app*metronome*); } > ram_external_app_metronome .external_app_app_manager : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_app_manager.application_information)); - *(*ui*external_app*app_manager*); + */external/app_manager/*(*ui*external_app*app_manager*); } > ram_external_app_app_manager .external_app_hopper : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_hopper.application_information)); - *(*ui*external_app*hopper*); + */external/hopper/*(*ui*external_app*hopper*); } > ram_external_app_hopper .external_app_antenna_length : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_antenna_length.application_information)); - *(*ui*external_app*antenna_length*); + */external/antenna_length/*(*ui*external_app*antenna_length*); } > ram_external_app_antenna_length .external_app_view_wav : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_view_wav.application_information)); - *(*ui*external_app*view_wav*); + */external/wav_view/*(*ui*external_app*view_wav*); } > ram_external_app_view_wav .external_app_sd_wipe : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_sd_wipe.application_information)); - *(*ui*external_app*sd_wipe*); + */external/sd_wipe/*(*ui*external_app*sd_wipe*); } > ram_external_app_sd_wipe .external_app_playlist_editor : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_playlist_editor.application_information)); - *(*ui*external_app*playlist_editor*); + */external/playlist_editor/*(*ui*external_app*playlist_editor*); } > ram_external_app_playlist_editor .external_app_snake : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_snake.application_information)); - *(*ui*external_app*snake*); + */external/snake/*(*ui*external_app*snake*); } > ram_external_app_snake .external_app_stopwatch : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_stopwatch.application_information)); - *(*ui*external_app*stopwatch*); + */external/stopwatch/*(*ui*external_app*stopwatch*); } > ram_external_app_stopwatch .external_app_wefax_rx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_wefax_rx.application_information)); - *(*ui*external_app*wefax_rx*); + */external/wefax_rx/*(*ui*external_app*wefax_rx*); } > ram_external_app_wefax_rx .external_app_noaaapt_rx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_noaaapt_rx.application_information)); - *(*ui*external_app*noaaapt_rx*); + */external/noaaapt_rx/*(*ui*external_app*noaaapt_rx*); } > ram_external_app_noaaapt_rx .external_app_vor_rx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_vor_rx.application_information)); - *(*ui*external_app*vor_rx*); + */external/vor_rx/*(*ui*external_app*vor_rx*); } > ram_external_app_vor_rx .external_app_vor_tx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_vor_tx.application_information)); - *(*ui*external_app*vor_tx*); + */external/vor_tx/*(*ui*external_app*vor_tx*); } > ram_external_app_vor_tx .external_app_breakout : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_breakout.application_information)); - *(*ui*external_app*breakout*); + */external/breakout/*(*ui*external_app*breakout*); } > ram_external_app_breakout .external_app_doom : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_doom.application_information)); - *(*ui*external_app*doom*); + */external/doom/*(*ui*external_app*doom*); } > ram_external_app_doom .external_app_debug_pmem : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_debug_pmem.application_information)); - *(*ui*external_app*debug_pmem*); + */external/debug_pmem/*(*ui*external_app*debug_pmem*); } > ram_external_app_debug_pmem .external_app_scanner : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_scanner.application_information)); - *(*ui*external_app*scanner*); + */external/scanner/*(*ui*external_app*scanner*); } > ram_external_app_scanner .external_app_level : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_level.application_information)); - *(*ui*external_app*level*); + */external/level/*(*ui*external_app*level*); } > ram_external_app_level .external_app_gfxeq : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_gfxeq.application_information)); - *(*ui*external_app*gfxeq*); + */external/gfxeq/*(*ui*external_app*gfxeq*); } > ram_external_app_gfxeq .external_app_waterfall_designer : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_waterfall_designer.application_information)); - *(*ui*external_app*waterfall_designer*); + */external/waterfall_designer/*(*ui*external_app*waterfall_designer*); } > ram_external_app_waterfall_designer .external_app_detector_rx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_detector_rx.application_information)); - *(*ui*external_app*detector_rx*); + */external/detector_rx/*(*ui*external_app*detector_rx*); } > ram_external_app_detector_rx .external_app_dinogame : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_dinogame.application_information)); - *(*ui*external_app*dinogame*); + */external/dinogame/*(*ui*external_app*dinogame*); } > ram_external_app_dinogame .external_app_spaceinv : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_spaceinv.application_information)); - *(*ui*external_app*spaceinv*); + */external/spaceinv/*(*ui*external_app*spaceinv*); } > ram_external_app_spaceinv .external_app_blackjack : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_blackjack.application_information)); - *(*ui*external_app*blackjack*); + */external/blackjack/*(*ui*external_app*blackjack*); } > ram_external_app_blackjack .external_app_battleship : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_battleship.application_information)); - *(*ui*external_app*battleship*); + */external/battleship/*(*ui*external_app*battleship*); } > ram_external_app_battleship .external_app_ert : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_ert.application_information)); - *(*ui*external_app*ert*); + */external/ert/*(*ui*external_app*ert*); } > ram_external_app_ert .external_app_epirb_rx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_epirb_rx.application_information)); - *(*ui*external_app*epirb_rx*); + */external/epirb_rx/*(*ui*external_app*epirb_rx*); } > ram_external_app_epirb_rx .external_app_soundboard : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_soundboard.application_information)); - *(*ui*external_app*soundboard*); + */external/soundboard/*(*ui*external_app*soundboard*); } > ram_external_app_soundboard .external_app_game2048 : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_game2048.application_information)); - *(*ui*external_app*game2048*); + */external/game2048/*(*ui*external_app*game2048*); } > ram_external_app_game2048 .external_app_bht_tx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_bht_tx.application_information)); - *(*ui*external_app*bht_tx*); + */external/bht_tx/*(*ui*external_app*bht_tx*); } > ram_external_app_bht_tx .external_app_morse_practice : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_morse_practice.application_information)); - *(*ui*external_app*morse_practice*); + */external/morse_practice/*(*ui*external_app*morse_practice*); } > ram_external_app_morse_practice .external_app_adult_toys_controller : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_adult_toys_controller.application_information)); - *(*ui*external_app*adult_toys_controller*); + */external/adult_toys_controller/*(*ui*external_app*adult_toys_controller*); } > ram_external_app_adult_toys_controller .external_app_flex_rx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_flex_rx.application_information)); - *(*ui*external_app*flex_rx*); + */external/flex_rx/*(*ui*external_app*flex_rx*); } > ram_external_app_flex_rx .external_app_sstvrx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_sstvrx.application_information)); - *(*ui*external_app*sstvrx*); + */external/sstvrx/*(*ui*external_app*sstvrx*); } > ram_external_app_sstvrx .external_app_subcarrx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_subcarrx.application_information)); - *(*ui*external_app*subcarrx*); + */external/subcarrx/*(*ui*external_app*subcarrx*); } > ram_external_app_subcarrx .external_app_siggen : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_siggen.application_information)); - *(*ui*external_app*siggen*); + */external/siggen/*(*ui*external_app*siggen*); } > ram_external_app_siggen .external_app_sdusb : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_sdusb.application_information)); - *(*ui*external_app*sdusb*); + */external/sdusb/*(*ui*external_app*sdusb*); } > ram_external_app_sdusb .external_app_morse_radio : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_morse_radio.application_information)); - *(*ui*external_app*morse_radio*); + */external/morse_radio/*(*ui*external_app*morse_radio*); } > ram_external_app_morse_radio .external_app_morseradiotx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_morseradiotx.application_information)); - *(*ui*external_app*morseradiotx*); + */external/morseradiotx/*(*ui*external_app*morseradiotx*); } > ram_external_app_morseradiotx .external_app_keeloqtx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_keeloqtx.application_information)); - *(*ui*external_app*keeloqtx*); + */external/keeloqtx/*(*ui*external_app*keeloqtx*); } > ram_external_app_keeloqtx .external_app_rtty_rx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_rtty_rx.application_information)); - *(*ui*external_app*rtty_rx*); + */external/rtty_rx/*(*ui*external_app*rtty_rx*); } > ram_external_app_rtty_rx .external_app_rtty_tx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_rtty_tx.application_information)); - *(*ui*external_app*rtty_tx*); + */external/rtty_tx/*(*ui*external_app*rtty_tx*); } > ram_external_app_rtty_tx .external_app_pocsag_tx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_pocsag_tx.application_information)); - *(*ui*external_app*pocsag_tx*); + */external/pocsag_tx/*(*ui*external_app*pocsag_tx*); } > ram_external_app_pocsag_tx .external_app_time_sink : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_time_sink.application_information)); - *(*ui*external_app*time_sink*); + */external/time_sink/*(*ui*external_app*time_sink*); } > ram_external_app_time_sink .external_app_same_tx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_same_tx.application_information)); - *(*ui*external_app*same_tx*); + */external/same_tx/*(*ui*external_app*same_tx*); } > ram_external_app_same_tx .external_app_kiss_tnc : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_kiss_tnc.application_information)); - *(*ui*external_app*kiss_tnc*); + */external/kiss_tnc/*(*ui*external_app*kiss_tnc*); } > ram_external_app_kiss_tnc .external_app_mdc_tx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_mdc_tx.application_information)); - *(*ui*external_app*mdc_tx*); + */external/mdc_tx/*(*ui*external_app*mdc_tx*); } > ram_external_app_mdc_tx .external_app_epirb_tx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_epirb_tx.application_information)); - *(*ui*external_app*epirb_tx*); + */external/epirb_tx/*(*ui*external_app*epirb_tx*); } > ram_external_app_epirb_tx .external_app_fpv_detect : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_fpv_detect.application_information)); - *(*ui*external_app*fpv_detect*); + */external/fpv_detect/*(*ui*external_app*fpv_detect*); } > ram_external_app_fpv_detect .external_app_p25_tx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_p25_tx.application_information)); - *(*ui*external_app*p25_tx*); + */external/p25_tx/*(*ui*external_app*p25_tx*); } > ram_external_app_p25_tx .external_app_two_tone_pager : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_two_tone_pager.application_information)); - *(*ui*external_app*two_tone_pager*); + */external/two_tone_pager/*(*ui*external_app*two_tone_pager*); } > ram_external_app_two_tone_pager .external_app_two_tone_rx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_two_tone_rx.application_information)); - *(*ui*external_app*two_tone_rx*); + */external/two_tone_rx/*(*ui*external_app*two_tone_rx*); } > ram_external_app_two_tone_rx .external_app_flex_tx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_flex_tx.application_information)); - *(*ui*external_app*flex_tx*); + */external/flex_tx/*(*ui*external_app*flex_tx*); } > ram_external_app_flex_tx .external_app_hard_reset : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_hard_reset.application_information)); - *(*ui*external_app*hard_reset*); + */external/hard_reset/*(*ui*external_app*hard_reset*); } > ram_external_app_hard_reset .external_app_secplustx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_secplustx.application_information)); - *(*ui*external_app*secplustx*); + */external/secplustx/*(*ui*external_app*secplustx*); } > ram_external_app_secplustx .external_app_signal_hunter : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_signal_hunter.application_information)); - *(*ui*external_app*signal_hunter*); + */external/signal_hunter/*(*ui*external_app*signal_hunter*); } > ram_external_app_signal_hunter .external_app_tetra_rx : ALIGN(4) SUBALIGN(4) { KEEP(*(.external_app.app_tetra_rx.application_information)); - *(*ui*external_app*tetra_rx*); + */external/tetra_rx/*(*ui*external_app*tetra_rx*); } > ram_external_app_tetra_rx diff --git a/firmware/application/external/waterfall_designer/ui_waterfall_designer.cpp b/firmware/application/external/waterfall_designer/ui_waterfall_designer.cpp index f07272cce..34916ac44 100644 --- a/firmware/application/external/waterfall_designer/ui_waterfall_designer.cpp +++ b/firmware/application/external/waterfall_designer/ui_waterfall_designer.cpp @@ -28,11 +28,47 @@ #include "ui_fileman.hpp" #include "file_reader.hpp" #include "ui_textentry.hpp" +#include "convert.hpp" + +#include using namespace portapack; namespace ui::external_app::waterfall_designer { +namespace { + +/* Parses an "index,R,G,B" profile line. + * NB: std::stoi() must not be used here - the firmware is built with + * -fno-exceptions, so any malformed field in a hand-edited profile makes it + * call std::terminate() instead of throwing. parse_int() is what gradient.cpp + * uses to read the very same file format. */ +bool parse_level(std::string_view line, uint8_t& index, uint8_t& r, uint8_t& g, uint8_t& b) { + // Comments are skipped by Gradient::load_file() too; parse_int() ignores + // trailing junk, so "#0,0,0,0" would otherwise read back as a real level. + if (line.empty() || line.front() == '#') return false; + + auto cols = split_string(line, ','); + if (cols.size() != 4) return false; + + uint8_t* const out[4] = {&index, &r, &g, &b}; + + for (size_t i = 0; i < 4; i++) { + int32_t value; + if (!parse_int(cols[i], value) || value < 0 || value > 255) return false; + *out[i] = static_cast(value); + } + + return true; +} + +bool is_color_level(const std::string& line) { + uint8_t index, r, g, b; + return parse_level(line, index, r, g, b); +} + +} // namespace + WaterfallDesignerView::WaterfallDesignerView(NavigationView& nav) : nav_{nav} { baseband::run_prepared_image(portapack::memory::map::m4_code.base()); @@ -152,7 +188,11 @@ WaterfallDesignerView::WaterfallDesignerView( } WaterfallDesignerView::~WaterfallDesignerView() { - if (!if_apply_setting) restore_current_profile(); + if (!if_apply_setting) + restore_current_profile(); + else if (profile_backed_up_) + delete_file(waterfalls_dir / u"wtf_des_bk.bk"); // kept the edit, drop the backup + receiver_model.disable(); baseband::shutdown(); } @@ -176,35 +216,68 @@ void WaterfallDesignerView::on_open_profile() { auto open_view = nav_.push(".txt"); open_view->push_dir(waterfalls_dir); open_view->on_changed = [this](std::filesystem::path new_file_path) { - on_profile_changed(new_file_path); + // NB: FileLoadView calls this and then pops, same as text_prompt does. + // Defer for the same two reasons - see on_create_new_profile(). + pending_profile_path = std::move(new_file_path); + nav_.set_on_pop([this]() { + on_profile_changed(pending_profile_path); + }); }; } -void WaterfallDesignerView::on_profile_changed(std::filesystem::path new_profile_path) { - current_profile_path = new_profile_path; +bool WaterfallDesignerView::read_profile_file(const std::filesystem::path& path) { + File profile_file; + + if (profile_file.open(path)) return false; + profile_levels.clear(); - - File playlist_file; - auto error = playlist_file.open(new_profile_path.string()); - - if (error) return; - - menu_view.clear(); - auto reader = FileLineReader(playlist_file); + auto reader = FileLineReader(profile_file); for (const auto& line : reader) { - // remove empty lines - if (line == "\n" || line == "\r\n" || line == "\r") continue; - profile_levels.push_back(line); + auto entry = line; + + // Strip the whole line terminator; the old code only dropped one char, + // so CRLF files kept a stray '\r' in every entry. + while (!entry.empty() && (entry.back() == '\n' || entry.back() == '\r')) + entry.pop_back(); + + if (entry.empty()) continue; + profile_levels.push_back(std::move(entry)); } - for (auto& line : profile_levels) { - // remove line end \n etc - if (line.length() > 0 && (line[line.length() - 1] == '\n' || line[line.length() - 1] == '\r')) { - line = line.substr(0, line.length() - 1); - } + return true; +} + +bool WaterfallDesignerView::write_profile_file(const std::filesystem::path& path) { + File profile_file; + + if (profile_file.open(path, false, true)) return false; + + // FA_OPEN_ALWAYS leaves the position at end-of-file, so rewind before + // truncating or the existing contents are kept and appended to. + if (profile_file.seek(0).is_error()) return false; + if (profile_file.truncate().is_error()) return false; + + for (const auto& entry : profile_levels) + if (profile_file.write_line(entry)) return false; + + /* ~File() calls f_close(), which flushes, but its result is unreachable. + * Sync explicitly so a full or yanked card is reported to the caller + * instead of being announced as a successful save. */ + return !profile_file.sync(); +} + +void WaterfallDesignerView::on_profile_changed(const std::filesystem::path& new_profile_path) { + // read_profile_file() only touches profile_levels once the open succeeded, + // so a failure here leaves the previously loaded profile intact. + if (!read_profile_file(new_profile_path)) { + nav_.display_modal("Err", "open err"); + return; } + current_profile_path = new_profile_path; + highlighted_index_ = 0; + button_save.hidden(false); button_add_level.hidden(false); button_remove_level.hidden(false); @@ -219,53 +292,52 @@ void WaterfallDesignerView::refresh_menu_view() { menu_view.clear(); for (const auto& line : profile_levels) { - if (line.length() == 0 || line[0] == '#') { + uint8_t index, r, g, b; + + // index,R,G,B - anything else (comment, header, garbage) is shown greyed. + if (parse_level(line, index, r, g, b)) { + menu_view.add_item({line, + ui::Color(r, g, b), + &bitmap_icon_cwgen, + [this](KeyEvent) { + button_remove_level.focus(); + }}); + } else { menu_view.add_item({line, ui::Color::grey(), &bitmap_icon_notepad, [this](KeyEvent) { button_add_level.focus(); }}); - } else { - // index,R,G,B - size_t pos = 0; - size_t next_pos = 0; - - // pass index - next_pos = line.find(',', pos); - if (next_pos == std::string::npos) continue; - pos = next_pos + 1; - - // r - next_pos = line.find(',', pos); - if (next_pos == std::string::npos) continue; - uint8_t r = static_cast(std::stoi(line.substr(pos, next_pos - pos))); - pos = next_pos + 1; - - // g - next_pos = line.find(',', pos); - if (next_pos == std::string::npos) continue; - uint8_t g = static_cast(std::stoi(line.substr(pos, next_pos - pos))); - pos = next_pos + 1; - - // b - uint8_t b = static_cast(std::stoi(line.substr(pos))); - - ui::Color color = ui::Color(r, g, b); - menu_view.add_item({line, - color, - &bitmap_icon_cwgen, - [this](KeyEvent) { - button_remove_level.focus(); - }}); } } + + // menu_view.clear() resets its own highlight to 0 but does not fire + // on_highlight, so highlighted_index_ has to be re-synced by hand or the + // edit/remove buttons act on a different row than the one the user sees. + if (highlighted_index_ >= profile_levels.size()) + highlighted_index_ = profile_levels.empty() ? 0 : profile_levels.size() - 1; + + if (!profile_levels.empty()) + menu_view.set_highlighted(highlighted_index_); + set_dirty(); } void WaterfallDesignerView::on_apply_current_to_wtf() { - std::filesystem::path system_read_path = "waterfall.txt"; - copy_file(current_profile_path, system_read_path); + /* Take the backup lazily, right before the first overwrite of the live + * gradient. Doing it in the constructor meant copy_file() (~1.8kB of stack) + * ran underneath run_external_app(), which holds a 776 byte frame with a + * File of its own - about 3.6kB of the 4kB M0 process stack. It also did SD + * I/O even when the user just opened the app and backed straight out. */ + backup_current_profile(); + + /* NB: this used to copy_file(current_profile_path, "waterfall.txt"), which + * costs ~1.8kB of stack (two FILs + a 512 byte block buffer) out of the 4kB + * M0 process stack, on top of whatever UI callback we are nested under. + * profile_levels is the authoritative copy of the profile anyway, so write + * it straight out through a single File instead. */ + if (!write_profile_file(u"waterfall.txt")) return; waterfall.load_gradient(); @@ -281,71 +353,82 @@ void WaterfallDesignerView::on_save_profile() { return; } - File profile_file; - auto error = profile_file.open(current_profile_path.string(), false, false); - - if (error) { - nav_.display_modal("Err", "open err"); + // NB: not just "open err" any more - this now also covers a failed + // truncate/write/flush, i.e. the file may be partially written. + if (!write_profile_file(current_profile_path)) { + nav_.display_modal("Err", "write failed"); return; } - // clear file - profile_file.seek(0); - profile_file.truncate(); - - // write new data - for (const auto& entry : profile_levels) { - profile_file.write_line(entry); - } - nav_.display_modal("Save", "Saved profile\n" + current_profile_path.string()); } void WaterfallDesignerView::on_add_level() { - if (highlighted_index_ >= profile_levels.size()) return; - if (profile_levels[highlighted_index_].empty()) return; - if (profile_levels[highlighted_index_][0] == '#') return; - if (profile_levels[highlighted_index_].find(',') == std::string::npos) return; - size_t insert_pos = highlighted_index_; - std::string new_entry = "0,128,128,128"; - profile_levels.insert(profile_levels.begin() + insert_pos, new_entry); + if (current_profile_path.empty()) return; + + /* A freshly created profile has no rows at all, and the old guard + * (highlighted_index_ >= size()) then refused with no feedback - so the + * button looked dead on exactly the file you just made. Seed instead. */ + size_t insert_pos = std::min(highlighted_index_, profile_levels.size()); + + // Don't push a level above a comment/header row; drop it after instead. + if (insert_pos < profile_levels.size() && !is_color_level(profile_levels[insert_pos])) + insert_pos++; + + profile_levels.insert(profile_levels.begin() + insert_pos, "0,128,128,128"); + highlighted_index_ = insert_pos; + refresh_menu_view(); on_edit_color(); } void WaterfallDesignerView::on_remove_level() { if (highlighted_index_ >= profile_levels.size()) return; - if (profile_levels[highlighted_index_].empty()) return; - if (profile_levels[highlighted_index_][0] == '#') return; - if (profile_levels[highlighted_index_].find(',') == std::string::npos) return; + if (!is_color_level(profile_levels[highlighted_index_])) return; + profile_levels.erase(profile_levels.begin() + highlighted_index_); refresh_menu_view(); } void WaterfallDesignerView::on_edit_color() { if (highlighted_index_ >= profile_levels.size()) return; - if (profile_levels[highlighted_index_].empty()) return; - if (profile_levels[highlighted_index_][0] == '#') return; - if (profile_levels[highlighted_index_].find(',') == std::string::npos) return; + if (!is_color_level(profile_levels[highlighted_index_])) return; - auto color_picker_view = nav_.push(profile_levels[highlighted_index_]); - color_picker_view->on_save = [this](std::string new_color) { - profile_levels[highlighted_index_] = new_color; + // Capture the row by value: highlighted_index_ can move (or the list can + // shrink) while the picker is up, and the old code wrote to whatever + // highlighted_index_ happened to be at save time with no bounds check. + const size_t index = highlighted_index_; + + auto color_picker_view = nav_.push(profile_levels[index]); + color_picker_view->on_save = [this, index](std::string new_color) { + if (index >= profile_levels.size()) return; + + profile_levels[index] = std::move(new_color); refresh_menu_view(); on_apply_current_to_wtf(); }; } void WaterfallDesignerView::backup_current_profile() { - std::filesystem::path curren_wtf_path = "waterfall.txt"; - std::filesystem::path backup_path = waterfalls_dir / "wtf_des_bk.bk"; - copy_file(curren_wtf_path, backup_path); + if (backup_attempted_) return; + backup_attempted_ = true; + + std::filesystem::path current_wtf_path = u"waterfall.txt"; + std::filesystem::path backup_path = waterfalls_dir / u"wtf_des_bk.bk"; + + if (!file_exists(current_wtf_path)) return; + + profile_backed_up_ = copy_file(current_wtf_path, backup_path).ok(); } void WaterfallDesignerView::restore_current_profile() { - std::filesystem::path backup_path = waterfalls_dir / "wtf_des_bk.bk"; - std::filesystem::path put_back_path = "waterfall.txt"; - copy_file(backup_path, put_back_path); + // Only touch waterfall.txt if we actually took a backup on entry; otherwise + // this would silently do nothing and leave the edited gradient installed. + if (!profile_backed_up_) return; + + std::filesystem::path backup_path = waterfalls_dir / u"wtf_des_bk.bk"; + + copy_file(backup_path, u"waterfall.txt"); delete_file(backup_path); } @@ -365,16 +448,35 @@ void WaterfallDesignerView::on_create_new_profile() { buffer += ".txt"; } - File new_file; - auto error = new_file.create(waterfalls_dir / buffer); - if (error) { - nav_.display_modal("Err", "create file err"); + auto new_path = waterfalls_dir / buffer; + bool created; + + { + // Scoped so the FIL is closed (and its 512 byte sector cache is + // off the stack) before anything re-opens the same file - _FS_LOCK + // is 0, so FatFs will not stop us from double-opening it. + File new_file; + created = !new_file.create(new_path); + } + + /* NB: everything below has to run *after* the keyboard view is gone. + * text_prompt()'s caller invokes this handler and then pops, so: + * - pushing a modal from here would pop the modal, not the keyboard; + * - and running on_profile_changed() inline stacks its File plus the + * gradient write under the alphanum/button/dispatch frames, which + * is what blows the 4kB M0 process stack ("Stack Overflow" guru). + * set_on_pop() defers it to just after the pop, at a shallow depth. */ + if (!created) { + nav_.set_on_pop([this]() { + nav_.display_modal("Err", "create file err"); + }); return; } - profile_levels.clear(); - current_profile_path = waterfalls_dir / buffer; - on_profile_changed(current_profile_path); + pending_profile_path = new_path; + nav_.set_on_pop([this]() { + on_profile_changed(pending_profile_path); + }); }); } @@ -392,32 +494,7 @@ WaterfallDesignerColorPickerView::WaterfallDesignerColorPickerView(NavigationVie progressbar.set_max(UINT8_MAX); - size_t pos = 0; - size_t next_pos = 0; - - // index - next_pos = color_str.find(',', pos); - if (next_pos != std::string::npos) { - index_ = static_cast(std::stoi(color_str.substr(pos, next_pos - pos))); - pos = next_pos + 1; - } - - // r - next_pos = color_str.find(',', pos); - if (next_pos != std::string::npos) { - red_ = static_cast(std::stoi(color_str.substr(pos, next_pos - pos))); - pos = next_pos + 1; - } - - // g - next_pos = color_str.find(',', pos); - if (next_pos != std::string::npos) { - green_ = static_cast(std::stoi(color_str.substr(pos, next_pos - pos))); - pos = next_pos + 1; - } - - // b - blue_ = static_cast(std::stoi(color_str.substr(pos))); + parse_level(color_str_, index_, red_, green_, blue_); field_red.set_value(red_); field_green.set_value(green_); @@ -466,19 +543,12 @@ void WaterfallDesignerColorPickerView::update_color_index() { green_ = static_cast(field_green.value()); blue_ = static_cast(field_blue.value()); - const Rect preview_rect{screen_width - 48, 1 * 16, 40, 40}; - - Painter painter_instance_2; - painter_instance_2.fill_rectangle( - {preview_rect.left(), preview_rect.top(), preview_rect.width(), preview_rect.height()}, - ui::Color(red_, green_, blue_)); + // Repaint through the normal dirty pass instead of driving the display from + // inside an on_change handler; paint() below already draws the swatch. + set_dirty(); } void WaterfallDesignerColorPickerView::paint(Painter& painter) { - // this is not duplicated code. - // because need to display color when enter, - // but it is too early to call update_color() in the constructor. - const Rect preview_rect{screen_width - 48, 1 * 16, 40, 40}; painter.fill_rectangle( @@ -487,14 +557,12 @@ void WaterfallDesignerColorPickerView::paint(Painter& painter) { } std::string WaterfallDesignerColorPickerView::build_color_str() { - size_t index_pos = color_str_.find(','); - if (index_pos != std::string::npos) { - return color_str_.substr(0, index_pos + 1) + - to_string_dec_uint(red_) + "," + - to_string_dec_uint(green_) + "," + - to_string_dec_uint(blue_); - } - return to_string_dec_uint(index_) + "," + to_string_dec_uint(red_) + "," + to_string_dec_uint(green_) + "," + to_string_dec_uint(blue_); + // Always emit index_ - the old version kept the index substring from the + // input line, so edits made with field_index were silently discarded. + return to_string_dec_uint(index_) + "," + + to_string_dec_uint(red_) + "," + + to_string_dec_uint(green_) + "," + + to_string_dec_uint(blue_); } } /* namespace ui::external_app::waterfall_designer */ diff --git a/firmware/application/external/waterfall_designer/ui_waterfall_designer.hpp b/firmware/application/external/waterfall_designer/ui_waterfall_designer.hpp index e1fadf5fe..36cf2ebf4 100644 --- a/firmware/application/external/waterfall_designer/ui_waterfall_designer.hpp +++ b/firmware/application/external/waterfall_designer/ui_waterfall_designer.hpp @@ -217,18 +217,31 @@ class WaterfallDesignerView : public View { void restore_current_profile(); void on_create_new_profile(); void on_open_profile(); - void on_profile_changed(std::filesystem::path new_profile_path); + void on_profile_changed(const std::filesystem::path& new_profile_path); void on_save_profile(); void on_add_level(); void on_remove_level(); void on_edit_color(); + /* Each of these owns the only File in its call chain. Keeping them as + * separate frames matters: a File carries a 512 byte FIL sector cache + * (_FS_TINY is 0) and the M0 process stack is 4kB, so if read_profile_file + * merged into on_profile_changed its 784 byte frame would stay live across + * the copy_file() in on_apply_current_to_wtf() - 3.6kB of 4kB instead of + * 2.8kB. GCC does not inline them at -O2 today; noinline just pins that so + * a heuristic change can't quietly eat the headroom. */ + __attribute__((noinline)) bool read_profile_file(const std::filesystem::path& path); + __attribute__((noinline)) bool write_profile_file(const std::filesystem::path& path); + void refresh_menu_view(); void on_apply_current_to_wtf(); // will restore if didn't apple, when distruct void on_apply_setting(); // apply set bool if_apply_setting{false}; + bool backup_attempted_{false}; + bool profile_backed_up_{false}; + std::filesystem::path pending_profile_path{}; // set by the New dialog, consumed on_pop /*NB: this works as: each time you change color, it apply as file realtime diff --git a/firmware/application/ui/ui_menu.cpp b/firmware/application/ui/ui_menu.cpp index c775ee46a..d267e7265 100644 --- a/firmware/application/ui/ui_menu.cpp +++ b/firmware/application/ui/ui_menu.cpp @@ -344,7 +344,9 @@ bool MenuView::on_key(const KeyEvent key) { } [[fallthrough]]; case KeyEvent::Select: - if (menu_items[highlighted_item].on_select) { + // NB: a MenuView can legitimately be empty (e.g. after clear()) while + // still holding focus; indexing menu_items unchecked faults there. + if (highlighted_item < menu_items.size() && menu_items[highlighted_item].on_select) { menu_items[highlighted_item].on_select(key); } return true; @@ -364,7 +366,7 @@ bool MenuView::on_keyboard(const KeyboardEvent key) { if (key == '-') return set_highlighted(highlighted_item - 1); if (key == '+') return set_highlighted(highlighted_item + 1); if (key == 10) { - if (menu_items[highlighted_item].on_select) { + if (highlighted_item < menu_items.size() && menu_items[highlighted_item].on_select) { menu_items[highlighted_item].on_select(KeyEvent::Right); } return true; diff --git a/firmware/tools/check_external_symbol_placement.py b/firmware/tools/check_external_symbol_placement.py new file mode 100755 index 000000000..86a58f680 --- /dev/null +++ b/firmware/tools/check_external_symbol_placement.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 + +# +# copyleft 2026 zxkmm co author with AI +# +# This file is part of PortaPack. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2, or (at your option) +# any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, Inc., 51 Franklin Street, +# Boston, MA 02110-1301, USA. +# + +"""Check that every external app symbol landed in its own app's memory region. + +The rules in external.ld match input *section names*, so a glob like +`*(*ui*external_app*level*)` also matches + + .text._ZN2ui12external_app18waterfall_designer...12on_add_levelEv + ^^^^^ contains "level" + +Section assignment is first-match-wins, so a symbol whose mangled name merely +contains another app's name gets linked into that app's region. Only one +external app is resident at a time, so calling it jumps into unmapped memory +and hard faults. + +export_external_apps.py already warns about *data* words that point at another +app, but a `bl` is PC-relative: the target never appears as a literal, so that +check cannot see it. This one works on symbol addresses instead and does. + +Usage: + check_external_symbol_placement.py [build/firmware/application/application.elf] +""" + +import os +import re +import subprocess +import sys + +REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +DEFAULT_ELF = os.path.join(REPO, "build", "firmware", "application", "application.elf") +DEFAULT_LD = os.path.join(REPO, "firmware", "application", "external", "external.ld") + + +def find_toolchain(): + for prefix in (os.environ.get("ARM_TOOLCHAIN"), + os.path.join(REPO, "armbin", "bin", "arm-none-eabi-"), + "arm-none-eabi-"): + if not prefix: + continue + try: + subprocess.run([prefix + "nm", "--version"], capture_output=True, check=True) + return prefix + except (OSError, subprocess.CalledProcessError): + continue + sys.exit("error: no arm-none-eabi toolchain found (set $ARM_TOOLCHAIN)") + + +def parse_regions(ld_path): + with open(ld_path) as f: + ld = f.read() + regions = {} + for m in re.finditer(r'ram_external_app_(\w+)\s+\(rwx\)\s*:\s*org\s*=\s*' + r'(0x[0-9A-Fa-f]+),\s*len\s*=\s*(\d+)k', ld): + regions[m.group(1)] = (int(m.group(2), 16), int(m.group(3)) * 1024) + return regions + + +def main(): + elf = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_ELF + if not os.path.exists(elf): + print("skipping symbol placement check, no ELF at %s" % elf) + return 0 + + tc = find_toolchain() + regions = parse_regions(DEFAULT_LD) + if not regions: + print("skipping symbol placement check, could not parse external.ld") + return 0 + + def owner(addr): + for name, (base, size) in regions.items(): + if base <= addr < base + size: + return name + return None + + nm = subprocess.run([tc + "nm", "-C", "--defined-only", elf], + capture_output=True, text=True).stdout + + misplaced = [] + total = 0 + for line in nm.splitlines(): + parts = line.split(" ", 2) + if len(parts) < 3 or not re.fullmatch(r'[0-9a-f]{8}', parts[0]): + continue + addr, sym = int(parts[0], 16), parts[2] + if "_veneer" in sym: + continue + m = re.search(r'external_app::(\w+)::', sym) + if not m: + continue + ns, host = m.group(1), owner(addr) + total += 1 + if host is None: + continue # inlined into main firmware, harmless + # Namespace and region name need not be identical: ert/ert_app, + # keeloqtx/ui_keeloqtx, secplustx/ui_secplustx. Substring either way is fine. + if ns not in host and host not in ns: + misplaced.append((ns, host, sym)) + + print("\nchecking placement of %d external app symbols across %d regions" + % (total, len(regions))) + + if not misplaced: + print("all external app symbols are in their own app's region") + return 0 + + print("\nERROR: %d symbol(s) linked into the wrong app's region." % len(misplaced)) + print("These will hard fault when called - the owning app is not resident.\n") + for ns, host, sym in misplaced: + print(" %s -> landed in '%s' region" % (ns, host)) + print(" %s" % sym) + print("\nFix: make the rule in external.ld specific to the app's own sources, e.g.") + print(" */external//*(*ui*external_app**);") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/firmware/tools/guru_lookup.py b/firmware/tools/guru_lookup.py new file mode 100755 index 000000000..a5517a6cf --- /dev/null +++ b/firmware/tools/guru_lookup.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 + +# +# copyleft 2026 zxkmm co author with AI +# +# This file is part of PortaPack. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2, or (at your option) +# any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, Inc., 51 Franklin Street, +# Boston, MA 02110-1301, USA. +# + +"""Translate a guru meditation pc/lr into a symbol, source line and disassembly. + +Plain `arm-none-eabi-gdb -ex "x/3i 0xADDR" application.elf` only works for +addresses in the main M0 firmware. External apps are linked at a placeholder +0xADxxxxxx address but *run* from ~0x1008xxxx, so the guru shows a runtime +address that does not exist in the ELF. This script does the translation: + + link_addr = section_vma + (runtime_addr - memory_location) + +where memory_location is the first word of the app's .ppma header. + +Usage: + guru_lookup.py 0x10085CF9 0x0FF36284 + guru_lookup.py --app waterfall_designer 0x10085CF9 + guru_lookup.py --baseband adsbrx 0x10081234 +""" + +import argparse +import os +import re +import struct +import subprocess +import sys + +REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + + +def find_toolchain(explicit): + """Locate the arm-none-eabi- prefix: --toolchain, $ARM_TOOLCHAIN, ./armbin/bin, PATH.""" + candidates = [] + if explicit: + candidates.append(explicit) + if os.environ.get("ARM_TOOLCHAIN"): + candidates.append(os.environ["ARM_TOOLCHAIN"]) + candidates.append(os.path.join(REPO, "armbin", "bin", "arm-none-eabi-")) + candidates.append("arm-none-eabi-") + + for prefix in candidates: + try: + subprocess.run([prefix + "addr2line", "--version"], + capture_output=True, check=True) + return prefix + except (OSError, subprocess.CalledProcessError): + continue + sys.exit("error: no arm-none-eabi toolchain found (try --toolchain /path/to/arm-none-eabi-)") + + +def run(*cmd): + return subprocess.run(cmd, capture_output=True, text=True).stdout + + +def section_vmas(tc, elf): + """section name -> (vma, size) for every .external_app_* section.""" + out = run(tc + "objdump", "-h", elf) + vmas = {} + for m in re.finditer(r'\.external_app_(\S+)\s+([0-9a-f]{8})\s+([0-9a-f]{8})', out): + vmas[m.group(1)] = (int(m.group(3), 16), int(m.group(2), 16)) + return vmas + + +def app_load_addresses(build_dir): + """app name -> memory_location, read from the first word of each .ppma.""" + app_dir = os.path.join(build_dir, "firmware", "application") + loads = {} + if not os.path.isdir(app_dir): + return loads + for name in os.listdir(app_dir): + if not name.endswith(".ppma"): + continue + path = os.path.join(app_dir, name) + try: + with open(path, "rb") as f: + (mem,) = struct.unpack("> " if here else " ", l.strip())) + + +def main(): + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("addresses", nargs="+", help="pc / lr values from the guru screen") + p.add_argument("--build", default=os.path.join(REPO, "build"), help="build directory") + p.add_argument("--app", help="external app name, if auto-detection is ambiguous") + p.add_argument("--baseband", help="resolve against firmware/baseband/baseband_.elf") + p.add_argument("--toolchain", help="arm-none-eabi- prefix") + args = p.parse_args() + + tc = find_toolchain(args.toolchain) + app_elf = os.path.join(args.build, "firmware", "application", "application.elf") + + if args.baseband: + elf = os.path.join(args.build, "firmware", "baseband", + "baseband_%s.elf" % args.baseband) + if not os.path.exists(elf): + sys.exit("error: no such baseband image: " + elf) + for a in args.addresses: + addr = int(a, 16) & ~1 + print("\n%s (M4 baseband: %s)" % (a, args.baseband)) + describe(tc, elf, addr) + return + + if not os.path.exists(app_elf): + sys.exit("error: %s not found (pass --build)" % app_elf) + + vmas = section_vmas(tc, app_elf) + loads = app_load_addresses(args.build) + + for a in args.addresses: + addr = int(a, 16) & ~1 # drop the Thumb bit + print("\n=== %s ===" % a) + + # Main M0 firmware is linked at 0 (SPIFI shadow), so it resolves directly. + if addr < 0x00100000: + print(" main M0 firmware") + describe(tc, app_elf, addr) + continue + + # Otherwise it is an external app running from local SRAM. Every app has + # its own memory_location, so several can plausibly contain the address; + # list each candidate and let the caller pick the app they actually ran. + names = [args.app] if args.app else sorted(loads) + hits = 0 + for name in names: + if name not in loads or name not in vmas: + continue + off = addr - loads[name] + vma, size = vmas[name] + if not (0 <= off < size): + continue + hits += 1 + print(" candidate: %s load=0x%08X offset=0x%X" % (name, loads[name], off)) + describe(tc, app_elf, vma + off, "link ") + + if hits == 0: + print(" no external app contains this address.") + print(" If the guru header said M4, re-run with --baseband .") + print(" A wild pc with a sane lr usually means an indirect call through") + print(" a bad pointer - look up the lr instead, that is the caller.") + elif hits > 1 and not args.app: + print(" (%d candidates - narrow it with --app )" % hits) + + +if __name__ == "__main__": + main() diff --git a/firmware/tools/stack_usage.py b/firmware/tools/stack_usage.py new file mode 100755 index 000000000..8583b8871 --- /dev/null +++ b/firmware/tools/stack_usage.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 + +# +# copyleft 2026 zxkmm co author with AI +# +# This file is part of PortaPack. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2, or (at your option) +# any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; see the file COPYING. If not, write to +# the Free Software Foundation, Inc., 51 Franklin Street, +# Boston, MA 02110-1301, USA. +# + +"""Report per-function stack frame sizes for an ELF or object file. + +The M0 process stack is 4 kB total (__process_stack_size__ in LPC43xx_M0.ld), +shared by the whole UI thread including external apps. A guru that says +"Stack Overflow" is a hard fault where get_free_stack_space() < 16, i.e. the +4 kB really was consumed - it is not a heap problem. + +Watch out for anything holding a File: _FS_TINY is 0 and _MAX_SS is 512 in +ffconf.h, so every FIL carries a 512 byte sector cache and a File local costs +~560 bytes of stack. copy_file() holds two of them plus a 512 byte block +buffer, ~1.8 kB in one frame. + +Thumb-1 cannot encode `sub sp, #imm` above 508 bytes, so large frames appear as +a negative constant loaded from the literal pool: + + ldr r4, [pc, #728] ; (2e0 <...>) + add sp, r4 ; r4 = 0xfffffcfc = -772 + +Naive greps for `sub sp` miss exactly the frames that matter. This resolves the +literal. + +Usage: + stack_usage.py build/firmware/application/application.elf # top 40 + stack_usage.py --min 512 # everything >= 512 + stack_usage.py --grep copy_file # filter by name + stack_usage.py --chain main run_external_app copy_file # sum a call chain +""" + +import argparse +import os +import re +import subprocess +import sys + +REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) +M0_STACK_BYTES = 4096 + +REG_ORDER = ['r0', 'r1', 'r2', 'r3', 'r4', 'r5', 'r6', 'r7', + 'r8', 'r9', 'sl', 'fp', 'ip', 'sp', 'lr', 'pc'] + + +def find_toolchain(explicit): + for prefix in (explicit, os.environ.get("ARM_TOOLCHAIN"), + os.path.join(REPO, "armbin", "bin", "arm-none-eabi-"), + "arm-none-eabi-"): + if not prefix: + continue + try: + subprocess.run([prefix + "objdump", "--version"], + capture_output=True, check=True) + return prefix + except (OSError, subprocess.CalledProcessError): + continue + sys.exit("error: no arm-none-eabi toolchain found (set $ARM_TOOLCHAIN)") + + +def push_bytes(reglist): + n = 0 + for part in reglist.split(','): + part = part.strip() + if '-' in part: + a, b = part.split('-') + if a in REG_ORDER and b in REG_ORDER: + n += REG_ORDER.index(b) - REG_ORDER.index(a) + 1 + else: + n += 2 + else: + n += 1 + return n * 4 + + +def frame_sizes(tc, path): + """function name (mangled) -> stack bytes reserved in its prologue.""" + out = subprocess.run([tc + "objdump", "-d", path], + capture_output=True, text=True).stdout + + bodies, cur = {}, None + for line in out.splitlines(): + m = re.match(r'^[0-9a-f]+ <(.+)>:', line) + if m: + cur = m.group(1) + bodies[cur] = [] + elif cur is not None: + bodies[cur].append(line) + + sizes = {} + for func, body in bodies.items(): + # Literal pool words, so `add sp, rN` can be resolved. + literals = {} + for line in body: + m = re.match(r'\s*([0-9a-f]+):\s+([0-9a-f]{8})\s+\.word\s+0x([0-9a-f]+)', line) + if m: + literals[int(m.group(1), 16)] = int(m.group(3), 16) + + total, regval = 0, {} + for line in body: + m = re.search(r'ldr\s+(\w+), \[pc, #\d+\].*;\s*\(([0-9a-f]+)', line) + if m: + regval[m.group(1)] = literals.get(int(m.group(2), 16)) + + m = re.search(r'\bsub(?:\.w)?\s+sp, (?:sp, )?#(\d+)', line) + if m: + total += int(m.group(1)) + + # Thumb-1 large frame: add sp, rN where rN holds a negative literal. + # Bound it: the same register may instead hold a data address, and + # external app addresses (0xADxxxxxx) also look "negative" here. + m = re.search(r'\badd\s+sp, (r\d+|sl|fp|ip)\b', line) + if m: + v = regval.get(m.group(1)) + if v and v > 0x80000000: + adjust = 0x100000000 - v + if adjust <= 2 * M0_STACK_BYTES: + total += adjust + + m = re.search(r'\bpush(?:\.w)?\s+\{(.+)\}', line) + if m: + total += push_bytes(m.group(1)) + + sizes[func] = total + return sizes + + +def demangle(tc, names): + if not names: + return [] + out = subprocess.run([tc + "c++filt"], input="\n".join(names), + capture_output=True, text=True).stdout + return out.splitlines() + + +def main(): + p = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("path", help="ELF or .obj to analyse") + p.add_argument("--min", type=int, default=0, help="only show frames >= this many bytes") + p.add_argument("--top", type=int, default=40, help="max rows to print (0 = all)") + p.add_argument("--grep", help="only functions whose demangled name matches this regex") + p.add_argument("--chain", nargs="+", + help="sum the frames of these functions (substring match) as a call chain") + p.add_argument("--toolchain", help="arm-none-eabi- prefix") + args = p.parse_args() + + if not os.path.exists(args.path): + sys.exit("error: no such file: " + args.path) + + tc = find_toolchain(args.toolchain) + sizes = frame_sizes(tc, args.path) + names = list(sizes) + pretty = dict(zip(names, demangle(tc, names))) + + if args.chain: + print("\ncall chain, M0 process stack is %d bytes\n" % M0_STACK_BYTES) + total = 0 + for want in args.chain: + hit = None + for n in names: + if want in pretty[n] or want in n: + if hit is None or sizes[n] > sizes[hit]: + hit = n + if hit is None: + print(" %6s %s (not found)" % ("?", want)) + continue + total += sizes[hit] + print(" %6d %s" % (sizes[hit], pretty[hit][:96])) + pct = 100.0 * total / M0_STACK_BYTES + print(" " + "-" * 60) + print(" %6d total (%.0f%% of the 4 kB stack, %d bytes free)" + % (total, pct, M0_STACK_BYTES - total)) + if total > M0_STACK_BYTES: + print("\n OVERFLOWS - this chain cannot fit.") + elif pct > 75: + print("\n Tight. Anything the callees add on top may fault.") + return + + rows = [(v, pretty[k]) for k, v in sizes.items() if v >= args.min] + if args.grep: + rx = re.compile(args.grep) + rows = [r for r in rows if rx.search(r[1])] + rows.sort(key=lambda r: -r[0]) + if args.top: + rows = rows[:args.top] + + print("\n%6s %s" % ("bytes", "function")) + for v, n in rows: + flag = " <-- over half the stack" if v > M0_STACK_BYTES // 2 else "" + print("%6d %s%s" % (v, n[:100], flag)) + + +if __name__ == "__main__": + main() From de30cbfe1bb53dda813c86b3c5788e73945b3962 Mon Sep 17 00:00:00 2001 From: qwer123 <56971466+bumblebee-2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:09:55 +0800 Subject: [PATCH 2/4] fix ADSB RX weak signal on pro (#3282) --- firmware/application/apps/ui_adsb_rx.cpp | 12 + firmware/application/clock_manager.cpp | 22 +- firmware/application/radio.cpp | 88 ++-- firmware/application/radio.hpp | 4 + firmware/application/receiver_model.cpp | 68 +-- firmware/application/tuning.cpp | 431 ++++++++++++++---- firmware/application/tuning.hpp | 25 +- .../PORTAPACK_APPLICATION/fpga_bridge.c | 60 ++- .../PORTAPACK_APPLICATION/fpga_bridge.h | 128 +++--- 9 files changed, 571 insertions(+), 267 deletions(-) diff --git a/firmware/application/apps/ui_adsb_rx.cpp b/firmware/application/apps/ui_adsb_rx.cpp index 0adb8410a..c84e8fe40 100644 --- a/firmware/application/apps/ui_adsb_rx.cpp +++ b/firmware/application/apps/ui_adsb_rx.cpp @@ -532,6 +532,18 @@ ADSBRxView::ADSBRxView(NavigationView& nav) { logger = std::make_unique(); logger->append(logs_dir / u"ADSB.TXT"); + /* First run only: start from the configuration that is known to receive + * ADS-B on this hardware -- LNA 32, VGA 32, RF amp ON. The first two are + * already ReceiverModel's defaults; the amp is not, and running without it + * costs about 14 dB, which is the difference between a busy list and an + * empty one. Once the user has saved settings for this app their choice + * wins, so this only sets the starting point. + * Going through the field rather than receiver_model keeps the displayed + * value in step: RFAmpField snapshots rf_amp() in its own constructor, + * which has already run by the time we get here. */ + if (!settings_.loaded()) + field_rf_amp.set_value(1); + receiver_model.enable(); baseband::set_adsb(); diff --git a/firmware/application/clock_manager.cpp b/firmware/application/clock_manager.cpp index a4f9d5ad4..b93777b8c 100644 --- a/firmware/application/clock_manager.cpp +++ b/firmware/application/clock_manager.cpp @@ -907,12 +907,24 @@ void ClockManager::set_sampling_frequency(const uint32_t frequency) { // Set FPGA RX decimation register fpga_debug_register_write(FPGA_REG_DECIM, n); - /* RX Mode: Register 3 is FPGA_REG_RX_DIGITAL_GAIN. - * We shift up by (3 * n) to compensate for CIC bit-growth. + /* No RX digital-gain register is written here. + * + * Register 0x03 used to be programmed with (3 * n + 2) as a "CIC + * bit-growth" renormalisation. The gateware has no such register: the + * RX decimator is a chain of unity-gain half-band FIRs selected by + * rx_decim (fpga/top/standard.py), and 0x03 is rx_pstep, whose top two + * bits are the quarter-rate shift. Writing a gain here silently + * cancelled the shift that set_tuning_frequency() had programmed, which + * left the analogue passband offset with no matching rotation. + * + * The shift depends on the AFE rate we just chose, so re-apply it after + * the rate change. ReceiverModel::update_sampling_rate() calls + * update_tuning_frequency() straight after this, which does exactly + * that; the write below only keeps the register consistent in between. */ - uint8_t ds = (3 * n); - ds += 2; - fpga_debug_register_write(FPGA_REG_RX_DIGITAL_GAIN, ds); + fpga_debug_register_write( + FPGA_REG_RX_PSTEP, + (radio::debug::get_cached_quarter_shift() & 0b11) << FPGA_RX_QUARTER_SHIFT_SHIFT); // Re-enable FPGA processing with clean state === fpga_debug_register_write(1, 0x01); diff --git a/firmware/application/radio.cpp b/firmware/application/radio.cpp index f279c402c..a9877e90e 100644 --- a/firmware/application/radio.cpp +++ b/firmware/application/radio.cpp @@ -144,6 +144,10 @@ static rf::Direction cached_direction = rf::Direction::Receive; static bool cached_rf_amp = false; static int_fast8_t cached_lna_gain = 0; static int_fast8_t cached_vga_gain = 0; +/* FPGA quarter-rate shift mode currently programmed, in gateware encoding + * (0b00 none / 0b11 up / 0b01 down). The baseband filter width depends on it, + * so ReceiverModel reads it back through get_quarter_shift(). */ +static uint8_t cached_quarter_shift = 0; #endif void init() { @@ -182,18 +186,15 @@ void init() { fpga_set_mode(FPGA_MODE_RX); - // These FPGA registers control DC_BLOCK, Q-Inv, QUARTER SHIFT, and Decimation. - fpga_debug_register_write(FPGA_REG_CTRL, FPGA_CTRL_DC_BLOCK_EN); // DC_BLOCK=1, QUARTER_SHIFT=0, Q_INVERT=0 - fpga_debug_register_write(FPGA_REG_DECIM, 0x00); // RX_DECIM=No Decim - - // RX Mode: Register 3 is RX Digital Gain. Start with 0dB (no shift). - fpga_debug_register_write(FPGA_REG_RX_DIGITAL_GAIN, FPGA_RX_DEFAULT_DIGITAL_GAIN); - - /* RX Mode: Initialize DC Block parameters to standard Praline values. - * 0x04 Width and 0x08 Adapt Rate are typical for 40MHz stability. - */ - fpga_debug_register_write(FPGA_REG_RX_DC_BLOCK_WIDTH, FPGA_RX_DEFAULT_DC_WIDTH); - fpga_debug_register_write(FPGA_REG_RX_DC_ADAPT_RATE, FPGA_RX_DEFAULT_ADAPT_RATE); + /* Boot register state, matching fpga_init() in hackrf/firmware/common/fpga.c: + * DC block on, no PRBS, no external trigger, no quarter shift, TX NCO off. + * The decimation ratio and the quarter shift are programmed later by + * ClockManager::set_sampling_frequency() and set_tuning_frequency(). */ + fpga_debug_register_write(FPGA_REG_CTRL, FPGA_CTRL_DC_BLOCK_EN); + fpga_debug_register_write(FPGA_REG_DECIM, 0x00); // RX_DECIM = no decimation + fpga_debug_register_write(FPGA_REG_RX_PSTEP, 0x00); // quarter shift off + fpga_debug_register_write(FPGA_REG_TX_CONTROL, 0x00); + cached_quarter_shift = 0; ssp1_arbiter.invalidate(); chThdSleepMilliseconds(10); // Let FPGA registers settle @@ -224,13 +225,13 @@ void set_direction(const rf::Direction new_direction) { fpga_debug_register_write(FPGA_REG_TX_PHASE_STEP, 0x00); } else { fpga_set_mode(FPGA_MODE_RX); - // RX Mode: Ensure NCO is disabled and reset digital gain - fpga_debug_register_write(FPGA_REG_RX_DIGITAL_GAIN, FPGA_RX_DEFAULT_DIGITAL_GAIN); - /* RX Mode: Initialize DC Block parameters to standard Praline values. - * 0x04 Width and 0x08 Adapt Rate are typical for 40MHz stability. - */ - fpga_debug_register_write(FPGA_REG_RX_DC_BLOCK_WIDTH, FPGA_RX_DEFAULT_DC_WIDTH); - fpga_debug_register_write(FPGA_REG_RX_DC_ADAPT_RATE, FPGA_RX_DEFAULT_ADAPT_RATE); + /* RX Mode: DC block on, TX NCO off. The quarter shift is re-applied by + * set_tuning_frequency(); clear it here so a stale TX/RX transition + * cannot leave a rotation programmed with no matching LO offset. */ + fpga_debug_register_write(FPGA_REG_CTRL, FPGA_CTRL_DC_BLOCK_EN); + fpga_debug_register_write(FPGA_REG_TX_CONTROL, 0x00); + fpga_debug_register_write(FPGA_REG_RX_PSTEP, 0x00); + cached_quarter_shift = 0; } #endif @@ -309,7 +310,19 @@ bool set_tuning_frequency(const rf::Frequency frequency) { final_frequency = final_frequency + portapack::persistent_memory::config_freq_rx_correction(); } +#ifdef PRALINE + /* The PRALINE tuning tables offset the analogue passband by a quarter of + * the ADC sample rate and have the FPGA rotate it back to DC, so the + * planner needs to know the AFE rate. See tuning.cpp. */ + const uint32_t afe_rate = portapack::clock_manager.get_sampling_frequency() + << portapack::clock_manager.get_resampling_n(); + const auto tuning_config = tuning::config::create( + final_frequency, + afe_rate, + direction == rf::Direction::Transmit); +#else const auto tuning_config = tuning::config::create(final_frequency); +#endif if (tuning_config.is_valid()) { first_if.disable(); @@ -339,6 +352,22 @@ bool set_tuning_frequency(const rf::Frequency frequency) { LPC_GPIO->CLR[0] = (1 << 13); // SGPIO12 = 0 (Q normal) } + /* Program the FPGA's quarter-rate shift to match the offset the tuning + * table just applied to the analogue centre frequency. The gateware + * (hackrf/firmware/fpga/top/standard.py) takes both bits from the top + * of register 0x03 (rx_pstep): + * rx_pstep[6] -> quarter_shift.enable + * rx_pstep[7] -> quarter_shift.up + * which is exactly fpga_set_rx_quarter_shift_mode() in + * hackrf/firmware/common/fpga.c: write (mode & 0b11) << 6. + * + * These two settings MUST be programmed together. Tuning off-centre + * without the rotation puts the signal outside the decimation filter's + * passband and it disappears entirely; rotating without the offset + * moves the wanted signal off DC by the same amount. */ + cached_quarter_shift = tuning_config.quarter_shift; + fpga_debug_register_write(FPGA_REG_RX_PSTEP, (cached_quarter_shift & 0b11) << 6); + ssp1_arbiter.invalidate(); #else baseband_cpld.set_invert(mixer_invert ^ baseband_invert); @@ -469,6 +498,10 @@ int_fast8_t get_cached_lna_gain() { int_fast8_t get_cached_vga_gain() { return cached_vga_gain; } + +uint8_t get_cached_quarter_shift() { + return cached_quarter_shift; +} #endif namespace first_if { @@ -570,18 +603,11 @@ void register_write(const size_t register_number, uint32_t value) { void init() { fpga_set_mode(FPGA_MODE_RX); - // These FPGA registers control DC_BLOCK, Q-Inv, QUARTER SHIFT, and Decimation. - fpga_debug_register_write(FPGA_REG_CTRL, FPGA_CTRL_DC_BLOCK_EN); // DC_BLOCK=1, QUARTER_SHIFT=0, Q_INVERT=0 - fpga_debug_register_write(FPGA_REG_DECIM, 0x00); // RX_DECIM=No Decim - - // RX Mode: Register 3 is RX Digital Gain. Start with 0dB (no shift). - fpga_debug_register_write(FPGA_REG_RX_DIGITAL_GAIN, FPGA_RX_DEFAULT_DIGITAL_GAIN); - - /* RX Mode: Initialize DC Block parameters to standard Praline values. - * 0x04 Width and 0x08 Adapt Rate are typical for 40MHz stability. - */ - fpga_debug_register_write(FPGA_REG_RX_DC_BLOCK_WIDTH, FPGA_RX_DEFAULT_DC_WIDTH); - fpga_debug_register_write(FPGA_REG_RX_DC_ADAPT_RATE, FPGA_RX_DEFAULT_ADAPT_RATE); + /* Same boot state as fpga_init() in hackrf/firmware/common/fpga.c. */ + fpga_debug_register_write(FPGA_REG_CTRL, FPGA_CTRL_DC_BLOCK_EN); + fpga_debug_register_write(FPGA_REG_DECIM, 0x00); // RX_DECIM = no decimation + fpga_debug_register_write(FPGA_REG_RX_PSTEP, 0x00); // quarter shift off + fpga_debug_register_write(FPGA_REG_TX_CONTROL, 0x00); ssp1_arbiter.invalidate(); // Force arbiter to reconfigure on next transfer } diff --git a/firmware/application/radio.hpp b/firmware/application/radio.hpp index c68bef93d..724af9ba9 100644 --- a/firmware/application/radio.hpp +++ b/firmware/application/radio.hpp @@ -126,6 +126,10 @@ rf::Direction get_cached_direction(); bool get_cached_rf_amp(); int_fast8_t get_cached_lna_gain(); int_fast8_t get_cached_vga_gain(); + +/* FPGA RX quarter-rate shift currently programmed, in gateware encoding: + * 0b00 none, 0b11 up, 0b01 down. */ +uint8_t get_cached_quarter_shift(); #endif namespace sgpio { diff --git a/firmware/application/receiver_model.cpp b/firmware/application/receiver_model.cpp index 716286c16..87142016c 100644 --- a/firmware/application/receiver_model.cpp +++ b/firmware/application/receiver_model.cpp @@ -244,10 +244,24 @@ void ReceiverModel::enable() { radio::set_direction(rf::Direction::Receive); #ifdef PRALINE - /* Anchor the Common Mode Voltage (VCM) to 1.2V. - * This stabilizes the electrical floor of the I/Q signals. - */ - radio::set_rx_buff_vcm(1); + /* MAX2831 RX IQ common-mode voltage (register 15). + * + * 0 = 1.1 V, 1 = 1.2 V, 2 = 1.3 V, 3 = 1.45 V. + * + * The reference firmware leaves this alone: max2831.c's default register + * table has reg 15 = 0x0145 (1.1 V) and the line that would raise it is + * commented out ("maximum rx output common-mode voltage"). Mayhem used to + * force 1.2 V here on the theory that it "stabilises the electrical floor + * of the I/Q signals" -- plausible, but never measured, and it was the + * last remaining RF-path setting where this branch disagreed with the + * configuration that is proven to receive ADS-B on this board. + * + * Set to 0 to match the reference (writing 1.1 V is a no-op against the + * power-on default), or back to 1 to restore the old Mayhem behaviour. + * If reception measurably worsens, put it back to 1 and say so -- neither + * value has been verified on hardware. */ +#define PRALINE_RX_IQ_VCM 0 + radio::set_rx_buff_vcm(PRALINE_RX_IQ_VCM); #endif update_tuning_frequency(); @@ -326,47 +340,39 @@ void ReceiverModel::update_baseband_bandwidth() { if (enabled_) { #ifdef PRALINE /* - * PRALINE LPF bandwidth calculation from GSG hackrf_usb radio.c + * PRALINE LPF bandwidth, ported from auto_bandwidth() in + * hackrf/firmware/common/radio.c: * - * The LPF should be set to capture the desired signal bandwidth - * while the FPGA decimation filter handles anti-aliasing. + * bb_bandwidth = sample_rate * 3 / 4 + * lpf_bandwidth = bb_bandwidth + offset_hz * 2 * - * For most modes: LPF = (output_sample_rate * 3) / 8 - * For quarter-shift: add offset for shifted spectrum + * where offset_hz is the quarter-rate shift, i.e. afe_rate / 4 when a + * shift is in use. The doubling is because the wanted signal sits + * offset from the analogue centre, so the analogue filter has to stay + * open out to that offset on the far side too. * - * Note: MAX2831 minimum LPF is 11.6 MHz, so for narrow sample rates - * the hardware limit applies and FPGA filter does the real work. + * The previous version used /8 in both places and read the shift from + * bits 2-3 of FPGA register 1, which do not exist in the gateware, so + * it always took the no-shift branch. At the ADS-B rate that asked for + * 750 kHz, which is below the MAX2831's 1.75 MHz floor and therefore + * also switched in the external narrowband AA filter + * (MAX2831::set_lpf_rf_bandwidth_rx), squeezing the RX path shut. The + * reference asks for 17.5 MHz at the same rate and the AA filter stays + * out of circuit. */ uint32_t sample_rate = sampling_rate(); uint8_t resampling_n = portapack::clock_manager.get_resampling_n(); uint32_t afe_rate = sample_rate << resampling_n; - // Base LPF: enough to capture desired bandwidth - uint32_t lpf_bandwidth = (sample_rate * 3) / 8; - - // Check if quarter-shift is enabled (FPGA register 1, bits 2-3) - uint32_t fpga_ctrl = radio::debug::fpga::register_read(1); - uint8_t quarter_shift = (fpga_ctrl >> 2) & 0x03; + uint32_t lpf_bandwidth = (sample_rate * 3) / 4; + const uint8_t quarter_shift = radio::debug::get_cached_quarter_shift(); if (quarter_shift != 0) { - // Quarter-shift moves spectrum by AFE_rate/4, need wider LPF - uint32_t offset = afe_rate / 8; + const uint32_t offset = afe_rate / 4; lpf_bandwidth += offset * 2; } - // For best anti-alias performance, also consider AFE Nyquist - // If our calculated LPF is below MAX2831 minimum, it doesn't matter - // But if we can set LPF to just below AFE Nyquist, that's optimal - uint32_t afe_nyquist = afe_rate / 2; - - // Use the larger of: signal bandwidth requirement OR Nyquist protection - // (but MAX2831 driver will clamp to its available settings anyway) - if (lpf_bandwidth < afe_nyquist) { - // Set LPF close to Nyquist for maximum alias rejection - lpf_bandwidth = (afe_nyquist * 9) / 10; // 90% of Nyquist - } - radio::set_baseband_filter_bandwidth_rx(lpf_bandwidth); #else radio::set_baseband_filter_bandwidth_rx(baseband_bandwidth()); diff --git a/firmware/application/tuning.cpp b/firmware/application/tuning.cpp index e1b2d74ba..27186b2c5 100644 --- a/firmware/application/tuning.cpp +++ b/firmware/application/tuning.cpp @@ -27,8 +27,8 @@ namespace tuning { namespace config { // Forward declarations -Config low_band(const rf::Frequency target_frequency); -Config mid_band(const rf::Frequency target_frequency); +Config low_band(const rf::Frequency target_frequency, const uint32_t afe_rate, const bool transmit); +Config mid_band(const rf::Frequency target_frequency, const uint32_t afe_rate, const bool transmit); Config high_band(const rf::Frequency target_frequency); #ifdef PRALINE @@ -36,133 +36,366 @@ Config high_band(const rf::Frequency target_frequency); * PRALINE Tuning Configuration * ============================ * - * Reference: hackrf_usb/common/tune_config.h praline_tune_config_rx[] + * These tables are copied verbatim from the reference firmware, + * hackrf/firmware/common/tune_config.h (praline_tune_config_rx / + * praline_tune_config_tx), and the selection and offset maths below reproduce + * hackrf/firmware/common/radio.c radio_update_frequency() / + * analog_from_digital_rf() / compute_offset(). * - * The hackrf_usb firmware uses a table-driven approach where each entry - * specifies: - * - rf_range_end_mhz: Upper frequency limit for this config - * - if_mhz: IF frequency (what MAX2831 tunes to) - * - high_lo: true = high-side injection, false = low-side - * - shift: FPGA quarter-shift mode (not implemented in Mayhem yet) + * Each entry gives, for target frequencies up to rf_range_end_mhz: + * if_mhz the IF the MAX2831 tunes to (0 = mixer bypassed, IF = RF) + * high_lo true -> LO = IF + analogue RF (mixer inverts the spectrum) + * false -> LO = IF - analogue RF (no inversion) + * shift the FPGA quarter-rate shift mode used for this entry * - * Key insight: The IF frequency varies to keep the RFFC5072 VCO in a - * safe operating range (ideally 3500-5000 MHz, avoiding extremes). + * The quarter-rate shift is the part Mayhem was previously missing. RX entries + * deliberately place the analogue passband a quarter of the ADC rate away from + * the requested frequency (+8 MHz at the usual 32 Msps AFE rate) so that the + * wanted signal never sits on the DC offset / LO leakage, and then ask the FPGA + * to rotate it back down to DC. Both halves have to be programmed together: + * - tuning to target + offset without asking the FPGA to rotate leaves the + * signal 8 MHz out and the decimation filter deletes it; + * - tuning to target with no offset (what Mayhem did) parks the signal on DC, + * under the LO leakage and the gateware's adaptive DC block. * - * RFFC5072 VCO calculation: - * High-side injection: LO = IF + RF, VCO = LO × lodiv - * Low-side injection: LO = IF - RF, VCO = LO × lodiv - * Where lodiv = 2 for frequencies where VCO > 2700 MHz - * - * From hackrf_usb tune_config_rx (simplified): - * 0-2100 MHz: IF=2375, high_lo=true → VCO = (2375+RF)×2 - * 2105-2115: IF=2375, high_lo=false → VCO = (2375-RF)×2 - * 2115-2130: IF=2425, high_lo=false → VCO = (2425-RF)×2 - * ... (more entries for fine-grained control) - * 2320-2580: IF=0 (bypass mode, no mixer) - * 2580+: High-pass mode + * "up" and "down" are named for the direction the FPGA rotates, so + * FPGA_QUARTER_SHIFT_MODE_UP means the analogue centre is placed ABOVE the + * requested frequency, and DOWN below it (radio.c analog_from_digital_rf()). */ -// Simplified tune_config lookup for Mayhem -// Returns the IF frequency in Hz for a given target frequency -constexpr rf::Frequency praline_get_if_frequency(const rf::Frequency target_frequency) { - const uint32_t freq_mhz = target_frequency / 1'000'000; +namespace { - // Based on hackrf_usb tune_config_rx table - if (freq_mhz < 2100) { - // Most low-band frequencies: use 2375 MHz IF - // This keeps VCO around 4750-4950 MHz for FM band - return 2375'000'000; - } else if (freq_mhz < 2320) { - // Transition zone: use varying IF to avoid VCO edges - // These frequencies are tricky - near MAX2831 minimum - // Use 2425 MHz to give some margin - return 2425'000'000; - } else { - // Bypass mode or high-band - IF not used for mixer - return 0; +struct PralineTuneConfig { + uint16_t rf_range_end_mhz; + uint16_t if_mhz; + bool high_lo; + uint8_t shift; /* 0b00 none, 0b11 up, 0b01 down */ +}; + +/* The tables below are kept column-aligned to match the reference source, so + * they are exempt from reformatting. */ +// clang-format off + +/* tuning table optimized for RX */ +constexpr PralineTuneConfig praline_tune_config_rx[] = { + { 0, 2360, true, 0b00}, + { 50, 2320, true, 0b11}, + { 100, 2320, true, 0b01}, + { 140, 2320, true, 0b11}, + { 406, 2560, true, 0b11}, + { 511, 2380, true, 0b11}, + { 578, 2560, true, 0b01}, + { 741, 2340, true, 0b11}, + { 861, 2560, true, 0b01}, + { 921, 2560, true, 0b11}, + { 1049, 2340, true, 0b01}, + { 1169, 2380, true, 0b11}, + { 1360, 2340, true, 0b11}, + { 1544, 2560, true, 0b01}, + { 1675, 2560, true, 0b11}, + { 1992, 2380, true, 0b01}, + { 2070, 2340, true, 0b01}, + { 2150, 2360, true, 0b01}, + { 2168, 2560, false, 0b11}, + { 2185, 2580, false, 0b11}, + { 2202, 2580, false, 0b01}, + { 2205, 2520, false, 0b11}, + { 2216, 2560, false, 0b11}, + { 2223, 2540, false, 0b11}, + { 2234, 2580, false, 0b11}, + { 2240, 2560, false, 0b11}, + { 2251, 2580, false, 0b01}, + { 2258, 2580, false, 0b11}, + { 2265, 2540, false, 0b01}, + { 2271, 2580, false, 0b11}, + { 2273, 2560, false, 0b11}, + { 2275, 2580, false, 0b01}, + { 2280, 2500, false, 0b01}, + { 2284, 2540, false, 0b11}, + { 2289, 2580, false, 0b01}, + { 2293, 2540, false, 0b01}, + { 2298, 2520, false, 0b01}, + { 2300, 2580, false, 0b11}, + { 2302, 2540, false, 0b01}, + { 2309, 2560, false, 0b01}, + { 2311, 2580, false, 0b01}, + { 2314, 2540, false, 0b11}, + { 2315, 2540, false, 0b01}, + { 2320, 2580, false, 0b11}, + { 2380, 0, false, 0b11}, + { 2440, 0, false, 0b01}, + { 2500, 0, false, 0b11}, + { 2580, 0, false, 0b01}, + { 2583, 2360, false, 0b11}, + { 2584, 2380, false, 0b11}, + { 2587, 2340, false, 0b11}, + { 2593, 2340, false, 0b01}, + { 2607, 2340, false, 0b11}, + { 2609, 2360, false, 0b11}, + { 2615, 2360, false, 0b01}, + { 2627, 2340, false, 0b01}, + { 2629, 2360, false, 0b01}, + { 2631, 2380, false, 0b11}, + { 2644, 2340, false, 0b11}, + { 2649, 2380, false, 0b11}, + { 2651, 2380, false, 0b01}, + { 2654, 2500, false, 0b11}, + { 2665, 2360, false, 0b11}, + { 2669, 2380, false, 0b01}, + { 2672, 2360, false, 0b01}, + { 2682, 2340, false, 0b11}, + { 2687, 2380, false, 0b11}, + { 2692, 2340, false, 0b11}, + { 2695, 2500, false, 0b11}, + { 2705, 2360, false, 0b11}, + { 2707, 2380, false, 0b01}, + { 2712, 2340, false, 0b01}, + { 2717, 2520, false, 0b11}, + { 2728, 2380, false, 0b11}, + { 2730, 2560, false, 0b11}, + { 2734, 2500, false, 0b11}, + { 2758, 2340, false, 0b11}, + { 2780, 2360, false, 0b11}, + { 2787, 2520, false, 0b11}, + { 2802, 2380, false, 0b11}, + { 2809, 2540, false, 0b11}, + { 2822, 2380, false, 0b01}, + { 2831, 2560, false, 0b11}, + { 2854, 2340, false, 0b11}, + { 2875, 2360, false, 0b11}, + { 2898, 2380, false, 0b11}, + { 2918, 2380, false, 0b01}, + { 2936, 2520, false, 0b01}, + { 2944, 2380, false, 0b01}, + { 2959, 2560, false, 0b11}, + { 2976, 2340, false, 0b11}, + { 2985, 2500, false, 0b01}, + { 3003, 2340, false, 0b11}, + { 3009, 2540, false, 0b11}, + { 3027, 2380, false, 0b11}, + { 3034, 2560, false, 0b11}, + { 3050, 2380, false, 0b01}, + { 3069, 2500, false, 0b11}, + { 3094, 2520, false, 0b11}, + { 3119, 2540, false, 0b11}, + { 3144, 2560, false, 0b11}, + { 3169, 2560, false, 0b01}, + { 3180, 2500, false, 0b11}, + { 3204, 2340, false, 0b11}, + { 3232, 2360, false, 0b11}, + { 3292, 2340, false, 0b01}, + { 3340, 2380, false, 0b01}, + { 3369, 2340, false, 0b11}, + { 3399, 2360, false, 0b11}, + { 3429, 2380, false, 0b11}, + { 3464, 2500, false, 0b11}, + { 3489, 2520, false, 0b11}, + { 3512, 2540, false, 0b11}, + { 3551, 2500, false, 0b01}, + { 3582, 2540, false, 0b11}, + { 3611, 2560, false, 0b11}, + { 3639, 2520, false, 0b11}, + { 3729, 2340, false, 0b11}, + { 3817, 2380, false, 0b01}, + { 3942, 2360, false, 0b01}, + { 4049, 2540, false, 0b11}, + { 4134, 2500, false, 0b01}, + { 4194, 2560, false, 0b11}, + { 4353, 2520, false, 0b11}, + { 4449, 2360, false, 0b01}, + { 4562, 2500, false, 0b11}, + { 4672, 2560, false, 0b11}, + { 4769, 2540, false, 0b11}, + { 4849, 2560, false, 0b01}, + { 4889, 2560, false, 0b11}, + { 4929, 2560, false, 0b11}, + { 4969, 2560, false, 0b11}, + { 5009, 2560, false, 0b11}, + { 5049, 2560, false, 0b11}, + { 5092, 2360, false, 0b11}, + { 5209, 2340, false, 0b01}, + { 5298, 2380, false, 0b01}, + { 5468, 2340, false, 0b01}, + { 5582, 2520, false, 0b11}, + { 5702, 2340, false, 0b11}, + { 5888, 2520, false, 0b01}, + { 6092, 2340, false, 0b01}, + { 6240, 2560, false, 0b11}, + { 6609, 2340, false, 0b11}, + { 6752, 2380, false, 0b01}, + { 6930, 2520, false, 0b01}, + { 7000, 2560, false, 0b11}, + { 7070, 2560, false, 0b01}, + { 7251, 2580, false, 0b01}, + { 0, 0, false, 0b00}, +}; + +/* tuning table optimized for TX */ +constexpr PralineTuneConfig praline_tune_config_tx[] = { + { 2100, 2375, true, 0b00}, + { 2105, 2375, false, 0b00}, + { 2115, 2425, false, 0b00}, + { 2130, 2375, false, 0b00}, + { 2150, 2425, false, 0b00}, + { 2160, 2475, false, 0b00}, + { 2175, 2425, false, 0b00}, + { 2190, 2475, false, 0b00}, + { 2195, 2425, false, 0b00}, + { 2210, 2375, false, 0b00}, + { 2248, 2425, false, 0b00}, + { 2265, 2525, false, 0b00}, + { 2300, 2425, false, 0b00}, + { 2320, 2525, false, 0b00}, + { 2580, 0, false, 0b00}, + { 3000, 2325, false, 0b00}, + { 3140, 2375, false, 0b00}, + { 3200, 2425, false, 0b00}, + { 3280, 2375, false, 0b00}, + { 3340, 2425, false, 0b00}, + { 3420, 2475, false, 0b00}, + { 3480, 2525, false, 0b00}, + { 3500, 2475, false, 0b00}, + { 3595, 2425, false, 0b00}, + { 3625, 2375, false, 0b00}, + { 3670, 2475, false, 0b00}, + { 3710, 2425, false, 0b00}, + { 3760, 2525, false, 0b00}, + { 3790, 2475, false, 0b00}, + { 3860, 2425, false, 0b00}, + { 3915, 2375, false, 0b00}, + { 4000, 2425, false, 0b00}, + { 4055, 2375, false, 0b00}, + { 4125, 2425, false, 0b00}, + { 4700, 2375, false, 0b00}, + { 4800, 2425, false, 0b00}, + { 5000, 2375, false, 0b00}, + { 5260, 2475, false, 0b00}, + { 5465, 2525, false, 0b00}, + { 5560, 2375, false, 0b00}, + { 5720, 2425, false, 0b00}, + { 5860, 2475, false, 0b00}, + { 5970, 2575, false, 0b00}, + { 6000, 2375, false, 0b00}, + { 6500, 2325, false, 0b00}, + { 6750, 2375, false, 0b00}, + { 6850, 2425, false, 0b00}, + { 6950, 2475, false, 0b00}, + { 7000, 2525, false, 0b00}, + { 7251, 2575, false, 0b00}, + { 0, 0, false, 0b00}, +}; + +// clang-format on + +/* radio.c select_tune_config(): first entry whose range end is above the + * requested frequency. The list is terminated by an all-zero entry, which is + * also what a frequency past the end of the table lands on. */ +const PralineTuneConfig* select_tune_config(const rf::Frequency target_frequency, const bool transmit) { + const PralineTuneConfig* entry = transmit ? praline_tune_config_tx : praline_tune_config_rx; + const uint32_t freq_mhz = static_cast(target_frequency / 1'000'000); + + while ((entry->rf_range_end_mhz != 0) || (entry->if_mhz != 0)) { + if ((target_frequency == 0) || (entry->rf_range_end_mhz > freq_mhz)) + break; + entry++; } + return entry; } -// Returns true for high-side injection, false for low-side -constexpr bool praline_use_high_side_injection(const rf::Frequency target_frequency) { - const uint32_t freq_mhz = target_frequency / 1'000'000; - - // Based on hackrf_usb tune_config_rx table - if (freq_mhz < 2100) { - // Standard low-band: high-side injection - // LO = IF + RF, mixer inverts spectrum - return true; - } else if (freq_mhz < 2105) { - // Narrow transition: still high-side - return true; - } else if (freq_mhz < 2320) { - // Near MAX2831 minimum: use low-side injection - // LO = IF - RF, no spectrum inversion - return false; - } else { - // Bypass/high-band - doesn't matter, mixer bypassed - return false; - } +/* radio.c compute_offset(): a quarter of the AFE (ADC) sample rate, or zero if + * no shift is in use or the AFE rate isn't known yet. */ +constexpr uint32_t quarter_shift_offset(const uint8_t shift, const uint32_t afe_rate) { + return (shift == 0) ? 0 : (afe_rate / 4); } + +/* radio.c analog_from_digital_rf(). */ +rf::Frequency analog_from_digital_rf(const rf::Frequency target_frequency, const uint8_t shift, const uint32_t afe_rate) { + const rf::Frequency offset = quarter_shift_offset(shift, afe_rate); + + if (shift == 0b11) + return target_frequency + offset; + + if (shift == 0b01) + return (offset > target_frequency) ? (offset - target_frequency) + : (target_frequency - offset); + + return target_frequency; +} + +} // namespace #endif // PRALINE -// Low band <2170 Mhz (HackRF One) or <2320 MHz (PRALINE): -constexpr rf::Frequency low_band_second_lo_frequency(const rf::Frequency target_frequency) { +Config low_band(const rf::Frequency target_frequency, const uint32_t afe_rate, const bool transmit) { #ifdef PRALINE - // Use the tune_config lookup for PRALINE - return praline_get_if_frequency(target_frequency); -#else - return 2650'000'000 - (target_frequency / 7); -#endif -} + const PralineTuneConfig* entry = select_tune_config(target_frequency, transmit); -Config low_band(const rf::Frequency target_frequency) { - const rf::Frequency second_lo_frequency = low_band_second_lo_frequency(target_frequency); + /* Past the end of the table: no usable configuration. */ + if ((entry->rf_range_end_mhz == 0) && (entry->if_mhz == 0)) + return {}; -#ifdef PRALINE + /* afe_rate == 0 means the caller doesn't know the ADC rate, so no LO offset + * is applied. The FPGA rotation has to be dropped with it: rotating without + * the matching offset moves the wanted signal off DC by afe_rate / 4. */ + const uint8_t shift = (afe_rate == 0) ? 0 : entry->shift; + const rf::Frequency analog_rf = analog_from_digital_rf(target_frequency, shift, afe_rate); + + /* if_mhz == 0 means the mixer is bypassed and the transceiver tunes the RF + * directly; there is no first LO in that case. */ + const rf::Frequency second_lo_frequency = + (entry->if_mhz == 0) ? analog_rf : (static_cast(entry->if_mhz) * 1'000'000); + + if (entry->if_mhz == 0) + return {0, second_lo_frequency, rf::path::Band::Low, false, shift}; + + /* The low band always runs through the low-pass image-reject filter, so + * the spectrum is inverted exactly when the first LO ends up above the IF, + * i.e. for high-side injection. This is hackrf_usb.c radio_changed(): + * invert = (img_reject == RF_PATH_FILTER_LOW_PASS) && (freq_lo > freq_if) + */ rf::Frequency first_lo_frequency; bool mixer_invert; - if (praline_use_high_side_injection(target_frequency)) { - // High-side injection: LO = IF + RF - first_lo_frequency = second_lo_frequency + target_frequency; + if (entry->high_lo) { + first_lo_frequency = second_lo_frequency + analog_rf; mixer_invert = true; } else { - // Low-side injection: LO = IF - RF - first_lo_frequency = second_lo_frequency - target_frequency; + first_lo_frequency = second_lo_frequency - analog_rf; mixer_invert = false; } - return {first_lo_frequency, second_lo_frequency, rf::path::Band::Low, mixer_invert}; + return {first_lo_frequency, second_lo_frequency, rf::path::Band::Low, mixer_invert, shift}; #else + (void)afe_rate; + (void)transmit; + const rf::Frequency second_lo_frequency = 2650'000'000 - (target_frequency / 7); const rf::Frequency first_lo_frequency = target_frequency + second_lo_frequency; const bool mixer_invert = true; return {first_lo_frequency, second_lo_frequency, rf::path::Band::Low, mixer_invert}; #endif } -// Mid band 2170-2740 Mhz (HackRF One) or 2320-2580 MHz (PRALINE): -Config mid_band(const rf::Frequency target_frequency) { +// Mid band 2170-2740 Mhz (HackRF One) or 2320-2740 MHz (PRALINE): +Config mid_band(const rf::Frequency target_frequency, const uint32_t afe_rate, const bool transmit) { #ifdef PRALINE - // For Praline with MAX2831 (2.3-2.6 GHz range) - // Frequencies 2170-2300 MHz need upconversion since they're below MAX2831 minimum - if (target_frequency < 2300'000'000) { - // Treat as low band - need mixer - return low_band(target_frequency); - } - // Frequencies 2300-2600 MHz can go direct (no RFFC5072) - else if (target_frequency <= 2600'000'000) { - const rf::Frequency second_lo_frequency = target_frequency; - const rf::Frequency first_lo_frequency = 0; - const bool mixer_invert = false; - return {first_lo_frequency, second_lo_frequency, rf::path::Band::Mid, mixer_invert}; - } - // Frequencies 2600-2740 MHz need downconversion since they're above MAX2831 maximum - else { - // Treat as high band - return high_band(target_frequency); + /* radio.c select_img_reject() / tuning.c: on PRALINE the MAX2831 tunes + * direct (mixer bypassed) from 2320 to 2580 MHz. band_mid starts at + * TRANSITION = 2320 MHz, so everything below that already went to + * low_band(). */ + if (target_frequency <= 2580'000'000) { + const PralineTuneConfig* entry = select_tune_config(target_frequency, transmit); + const uint8_t shift = (afe_rate == 0) ? 0 : entry->shift; + const rf::Frequency analog_rf = analog_from_digital_rf(target_frequency, shift, afe_rate); + + /* Mixer bypassed: no first LO, the MAX2831 tunes the (offset) + * analogue RF directly and the FPGA rotates it back. */ + return {0, analog_rf, rf::path::Band::Mid, false, shift}; } + + /* 2580-2740 MHz: above the bypass window, downconvert. */ + return high_band(target_frequency); #else + (void)afe_rate; + (void)transmit; const rf::Frequency second_lo_frequency = target_frequency; const rf::Frequency first_lo_frequency = 0; const bool mixer_invert = false; @@ -203,12 +436,12 @@ Config high_band(const rf::Frequency target_frequency) { return {first_lo_frequency, second_lo_frequency, rf::path::Band::High, mixer_invert}; } -Config create(const rf::Frequency target_frequency) { +Config create(const rf::Frequency target_frequency, const uint32_t afe_rate, const bool transmit) { /* TODO: This is some lame code. */ if (rf::path::band_low.contains(target_frequency)) { - return low_band(target_frequency); + return low_band(target_frequency, afe_rate, transmit); } else if (rf::path::band_mid.contains(target_frequency)) { - return mid_band(target_frequency); + return mid_band(target_frequency, afe_rate, transmit); } else if (rf::path::band_high.contains(target_frequency)) { return high_band(target_frequency); } else { diff --git a/firmware/application/tuning.hpp b/firmware/application/tuning.hpp index b16b6c5a0..710127662 100644 --- a/firmware/application/tuning.hpp +++ b/firmware/application/tuning.hpp @@ -33,18 +33,21 @@ struct Config { : first_lo_frequency(0), second_lo_frequency(0), rf_path_band(rf::path::Band::Mid), - mixer_invert(false) { + mixer_invert(false), + quarter_shift(0) { } constexpr Config( rf::Frequency first_lo_frequency, rf::Frequency second_lo_frequency, rf::path::Band rf_path_band, - bool mixer_invert) + bool mixer_invert, + uint8_t quarter_shift = 0) : first_lo_frequency(first_lo_frequency), second_lo_frequency(second_lo_frequency), rf_path_band(rf_path_band), - mixer_invert(mixer_invert) { + mixer_invert(mixer_invert), + quarter_shift(quarter_shift) { } bool is_valid() const { @@ -55,9 +58,23 @@ struct Config { const rf::Frequency second_lo_frequency; const rf::path::Band rf_path_band; const bool mixer_invert; + + /* PRALINE only: FPGA RX quarter-rate shift mode, in the encoding the + * gateware expects in the top two bits of register 0x03 (rx_pstep): + * 0b00 = none, 0b11 = up, 0b01 = down. + * Matches fpga_quarter_shift_mode_t in hackrf/firmware/common/fpga.h. + * Always 0 on HackRF One (no FPGA). */ + const uint8_t quarter_shift; }; -Config create(const rf::Frequency target_frequency); +/* afe_rate is the ADC sample rate in Hz (output rate << decimation), needed on + * PRALINE to work out how far off centre the quarter-rate shift places the + * analogue passband. Pass 0 (or leave defaulted) to disable the shift. + * transmit selects the TX tuning table. Both are ignored on HackRF One. */ +Config create( + const rf::Frequency target_frequency, + const uint32_t afe_rate = 0, + const bool transmit = false); } /* namespace config */ } /* namespace tuning */ diff --git a/firmware/chibios-portapack/boards/PORTAPACK_APPLICATION/fpga_bridge.c b/firmware/chibios-portapack/boards/PORTAPACK_APPLICATION/fpga_bridge.c index 495b9f902..77fa49517 100644 --- a/firmware/chibios-portapack/boards/PORTAPACK_APPLICATION/fpga_bridge.c +++ b/firmware/chibios-portapack/boards/PORTAPACK_APPLICATION/fpga_bridge.c @@ -125,10 +125,11 @@ // ============================================================================ // Canonical Default Values - SINGLE SOURCE OF TRUTH // ============================================================================ -/* Define the canonical RX defaults in ONE place */ -#define FPGA_RX_DEFAULT_DC_WIDTH 0x04 /* Typical for 40MHz stability */ -#define FPGA_RX_DEFAULT_ADAPT_RATE 0x08 /* Typical for 40MHz stability */ -#define FPGA_RX_DEFAULT_DIGITAL_GAIN 0x00 /* No shift initially */ +/* Define the canonical RX defaults in ONE place. + * Matches fpga_init() in hackrf/firmware/common/fpga.c: the gateware only has + * rx_decim and rx_pstep on the RX side, both starting at zero. */ +#define FPGA_RX_DEFAULT_DECIM 0x00 /* No decimation initially */ +#define FPGA_RX_DEFAULT_PSTEP 0x00 /* No quarter-rate shift initially */ /* Define TX defaults */ #define FPGA_TX_DEFAULT_NCO_CTRL 0x00 /* NCO disabled */ @@ -221,12 +222,13 @@ static bool fpga_cdone_read(void) { // These functions allow reading/writing FPGA internal registers via SPI. // The FPGA bitstream implements a simple SPI register interface. // -// FPGA Register Map: -// Reg 1 (CTRL): DC_BLOCK(b0), QUARTER_SHIFT_EN(b1), QUARTER_SHIFT_UP(b2), PRBS(b6), TRIGGER_EN(b7) -// Reg 2 (RX_DECIM): Decimation ratio [2:0] -// Reg 3 (RX/TX): RX Digital Shift OR TX NCO Control -// Reg 4 (RX_DC_BLOCK_WIDTH/TX_INTERP) [2:0] -// Reg 5 (RX_DC_ADAPT_RATE/TX_PSTEP) [7:0] +// FPGA Register Map (hackrf/firmware/fpga/top/standard.py): +// Reg 1 (CTRL): DC_BLOCK(b0), PRBS(b6), TRIGGER_EN(b7) +// Reg 2 (RX_DECIM): Decimation ratio, log2 [2:0] +// Reg 3 (RX_PSTEP): QUARTER_SHIFT_EN(b6), QUARTER_SHIFT_UP(b7) +// Reg 4 (TX_CTRL): NCO enable (b0) +// Reg 5 (TX_INTRP): Interpolation ratio [2:0] +// Reg 6 (TX_PSTEP): NCO phase step [7:0] // // SPI Protocol: // Read: Send [reg & 0x7F, 0x00, 0x00] -> value in byte 3 @@ -369,22 +371,16 @@ void fpga_rx_enable_dc_block(bool enable) { } /* RX Functions with mode assertion */ -void fpga_rx_set_digital_gain(uint8_t shift) { + +/* Quarter-rate shift, register 0x03 bits [7:6]. Equivalent to + * fpga_set_rx_quarter_shift_mode() in hackrf/firmware/common/fpga.c. + * mode: 0b00 none, 0b11 up, 0b01 down. */ +void fpga_rx_set_quarter_shift_mode(uint8_t mode) { if (current_mode != FPGA_MODE_RX) { /* Log error or assert - wrong mode! */ return; } - fpga_register_write(FPGA_REG_SHARED_3, shift & FPGA_RX_GAIN_SHIFT_MASK); -} - -void fpga_rx_set_dc_block_width(uint8_t width) { - if (current_mode != FPGA_MODE_RX) return; - fpga_register_write(FPGA_REG_SHARED_4, width & FPGA_RX_DC_WIDTH_MASK); -} - -void fpga_rx_set_dc_adapt_rate(uint8_t rate) { - if (current_mode != FPGA_MODE_RX) return; - fpga_register_write(FPGA_REG_SHARED_5, rate); + fpga_register_write(FPGA_REG_RX_PSTEP, (uint8_t)((mode & 0x03) << FPGA_RX_QUARTER_SHIFT_SHIFT)); } // ============================================================================ @@ -394,12 +390,12 @@ void fpga_rx_set_dc_adapt_rate(uint8_t rate) { /* TX Functions with mode assertion */ void fpga_tx_set_nco_enable(bool enable) { if (current_mode != FPGA_MODE_TX) return; - uint8_t val = fpga_register_read(FPGA_REG3_TX_NCO_CTRL); + uint8_t val = fpga_register_read(FPGA_REG_TX_CONTROL); if (enable) val |= FPGA_TX_NCO_EN; else val &= ~FPGA_TX_NCO_EN; - fpga_register_write(FPGA_REG3_TX_NCO_CTRL, val); + fpga_register_write(FPGA_REG_TX_CONTROL, val); } void fpga_tx_set_interpolation(uint8_t ratio) { @@ -424,17 +420,17 @@ static void fpga_register_init(void) { current_mode = FPGA_MODE_RX; fpga_spi_write(FPGA_REG_CTRL, FPGA_CTRL_DC_BLOCK_EN); - fpga_spi_write(FPGA_REG_DECIM, 0x00); - fpga_spi_write(FPGA_REG_SHARED_3, FPGA_RX_DEFAULT_DIGITAL_GAIN); - fpga_spi_write(FPGA_REG_SHARED_4, FPGA_RX_DEFAULT_DC_WIDTH); - fpga_spi_write(FPGA_REG_SHARED_5, FPGA_RX_DEFAULT_ADAPT_RATE); + fpga_spi_write(FPGA_REG_DECIM, FPGA_RX_DEFAULT_DECIM); + fpga_spi_write(FPGA_REG_RX_PSTEP, FPGA_RX_DEFAULT_PSTEP); + fpga_spi_write(FPGA_REG_TX_CONTROL, FPGA_TX_DEFAULT_NCO_CTRL); + fpga_spi_write(FPGA_REG_TX_INTERP, FPGA_TX_DEFAULT_INTERP); /* Update cache */ fpga_reg_cache[1] = FPGA_CTRL_DC_BLOCK_EN; - fpga_reg_cache[2] = 0x00; - fpga_reg_cache[3] = FPGA_RX_DEFAULT_DIGITAL_GAIN; - fpga_reg_cache[4] = FPGA_RX_DEFAULT_DC_WIDTH; - fpga_reg_cache[5] = FPGA_RX_DEFAULT_ADAPT_RATE; + fpga_reg_cache[2] = FPGA_RX_DEFAULT_DECIM; + fpga_reg_cache[3] = FPGA_RX_DEFAULT_PSTEP; + fpga_reg_cache[4] = FPGA_TX_DEFAULT_NCO_CTRL; + fpga_reg_cache[5] = FPGA_TX_DEFAULT_INTERP; } // ============================================================================ diff --git a/firmware/chibios-portapack/boards/PORTAPACK_APPLICATION/fpga_bridge.h b/firmware/chibios-portapack/boards/PORTAPACK_APPLICATION/fpga_bridge.h index 16d0bb661..8835ded02 100644 --- a/firmware/chibios-portapack/boards/PORTAPACK_APPLICATION/fpga_bridge.h +++ b/firmware/chibios-portapack/boards/PORTAPACK_APPLICATION/fpga_bridge.h @@ -16,10 +16,23 @@ extern "C" { #ifdef PRALINE -/* RX path (legacy PRALINE software map kept for compatibility) */ -#define FPGA_REG_RX_DIGITAL_GAIN 0x03 /* Digital Shift / scaling (RX Mode) */ -#define FPGA_REG_RX_DC_BLOCK_WIDTH 0x04 /* Notch filter cutoff (RX Mode) */ -#define FPGA_REG_RX_DC_ADAPT_RATE 0x05 /* Settle time/Integration (RX Mode) */ +/* + * RX path. + * + * NOTE: the register map below is the one the loaded bitstream actually + * implements. Ground truth is hackrf/firmware/fpga/top/standard.py plus + * hackrf/firmware/common/fpga_regs.def; Mayhem, hackrf_usb and debug_for_adsb + * all load a byte-identical praline_fpga.bin, so that map applies here too. + * + * An earlier "legacy PRALINE software map" claimed register 0x03 was an RX + * digital gain and 0x04/0x05 were DC-block width / adaptation rate. The + * gateware has none of those: 0x03 is rx_pstep (whose top two bits are the + * quarter-rate shift) and 0x04/0x05 are TX registers. Writing the old "RX DC + * width" value of 0x01 to 0x04 actually set tx_ctrl[0] and switched the TX NCO + * on, and every write of the fictional digital gain to 0x03 cleared the + * quarter shift. + */ +#define FPGA_REG_RX_PSTEP 0x03 /* RX phase step; bits [7:6] = quarter shift */ /* * TX path must match the currently built PRALINE standard gateware in @@ -43,70 +56,55 @@ typedef enum { /* * FPGA Register Addresses - * NOTE: - * The currently loaded PRALINE standard gateware uses: - * 0x01 CTRL - * 0x02 RX_DECIM - * 0x03 RX_DIGITAL_GAIN (RX) / TX_NCO_CTRL (TX) - * 0x04 TX_CTRL - * 0x05 TX_INTRP - * 0x06 TX_PSTEP + * + * From hackrf/firmware/fpga/top/standard.py (spi_regs.add_register): + * 0x01 ctrl 8 bits + * 0x02 rx_decim 3 bits + * 0x03 rx_pstep 8 bits + * 0x04 tx_ctrl 1 bit + * 0x05 tx_intrp 3 bits + * 0x06 tx_pstep 8 bits + * + * There is no RX gain register: rx_decim selects half-band FIR stages + * (hbfir1..hbfir5), which are unity-gain, so there is no CIC bit growth to + * renormalise. */ -#define FPGA_REG_CTRL 0x01 /* Control register */ -#define FPGA_REG_DECIM 0x02 /* RX decimation */ -#define FPGA_REG_SHARED_3 0x03 /* Legacy shared register */ -#define FPGA_REG_SHARED_4 0x04 /* Legacy shared register */ -#define FPGA_REG_SHARED_5 0x05 /* Legacy shared register */ -#define FPGA_REG_SHARED_6 0x06 /* TX phase step */ +#define FPGA_REG_CTRL 0x01 /* Control register */ +#define FPGA_REG_DECIM 0x02 /* RX decimation (log2, bits [2:0]) */ +/* 0x03..0x06 are FPGA_REG_RX_PSTEP / FPGA_REG_TX_CONTROL / + * FPGA_REG_TX_INTERP / FPGA_REG_TX_PHASE_STEP, defined above. */ /* * Register 1 (CTRL) Bit Definitions + * standard.py: ctrl[0] -> dc_block.enable, ctrl[6] -> prbs, ctrl[7] -> trigger_en. + * Nothing else in this register is decoded. */ -#define FPGA_CTRL_DC_BLOCK_EN (1 << 0) /* DC block enable */ -#define FPGA_CTRL_QUARTER_SHIFT_EN (1 << 1) /* Quarter-rate shift enable */ -#define FPGA_CTRL_QUARTER_SHIFT_UP (1 << 2) /* Shift direction: 1=up, 0=down */ -#define FPGA_CTRL_TX_MODE (1 << 5) /* TX mode indicator (if applicable) */ -#define FPGA_CTRL_PRBS_EN (1 << 6) /* PRBS test mode */ -#define FPGA_CTRL_TRIGGER_EN (1 << 7) /* External trigger enable */ +#define FPGA_CTRL_DC_BLOCK_EN (1 << 0) /* DC block enable */ +#define FPGA_CTRL_PRBS_EN (1 << 6) /* PRBS test mode */ +#define FPGA_CTRL_TRIGGER_EN (1 << 7) /* External trigger enable */ /* - * Register 3 Dual-Purpose Definitions + * Register 3 (RX_PSTEP) Bit Definitions + * standard.py: rx_pstep[6] -> quarter_shift.enable, rx_pstep[7] -> quarter_shift.up. + * Same encoding as fpga_quarter_shift_mode_t << 6 in hackrf/firmware/common/fpga.c. */ -/* RX Mode: Digital gain/shift */ -#define FPGA_REG3_RX_DIGITAL_GAIN 0x03 -#define FPGA_RX_GAIN_SHIFT_MASK 0x0F /* Bits [3:0] - shift amount */ +/* Position of that 2-bit field inside rx_pstep, for direct register writes. */ +#define FPGA_RX_QUARTER_SHIFT_SHIFT 6 +#define FPGA_RX_QUARTER_SHIFT_MASK 0xC0 -/* TX Mode: NCO control */ -#define FPGA_REG3_TX_NCO_CTRL 0x03 -#define FPGA_TX_NCO_EN (1 << 0) /* NCO enable */ -#define FPGA_TX_NCO_INVERT (1 << 1) /* Invert spectrum */ +/* Mode values, matching fpga_quarter_shift_mode_t in + * hackrf/firmware/common/fpga.h. Pass these to + * fpga_rx_set_quarter_shift_mode(), which shifts them into place. */ +#define FPGA_QUARTER_SHIFT_MODE_NONE 0b00 +#define FPGA_QUARTER_SHIFT_MODE_UP 0b11 +#define FPGA_QUARTER_SHIFT_MODE_DOWN 0b01 /* - * Register 4 Dual-Purpose Definitions + * Register 4 (TX_CTRL) / 5 (TX_INTRP) / 6 (TX_PSTEP) Bit Definitions */ -/* RX Mode: DC block notch width */ -#define FPGA_REG4_RX_DC_WIDTH 0x04 -#define FPGA_RX_DC_WIDTH_MASK 0x07 /* Bits [2:0] */ - -/* TX Mode: Interpolation ratio */ -#define FPGA_REG4_TX_INTERP 0x05 -#define FPGA_TX_INTERP_MASK 0x07 /* Bits [2:0] */ - -/* - * Register 5 Dual-Purpose Definitions - */ -/* RX Mode: DC block adaptation rate */ -#define FPGA_REG5_RX_DC_RATE 0x05 -#define FPGA_RX_DC_RATE_MASK 0xFF /* Bits [7:0] */ - -/* TX Mode: NCO phase step (frequency) */ -#define FPGA_REG5_TX_PHASE_STEP 0x06 -#define FPGA_TX_PHASE_STEP_MASK 0xFF /* Bits [7:0] */ - -/* Export default values so other methods can use them */ -#define FPGA_RX_DEFAULT_DIGITAL_GAIN 0x00 -#define FPGA_RX_DEFAULT_DC_WIDTH 0x04 -#define FPGA_RX_DEFAULT_ADAPT_RATE 0x08 +#define FPGA_TX_NCO_EN (1 << 0) /* tx_ctrl[0]: NCO enable */ +#define FPGA_TX_INTERP_MASK 0x07 /* tx_intrp bits [2:0] */ +#define FPGA_TX_PHASE_STEP_MASK 0xFF /* tx_pstep bits [7:0] */ /* * Core Functions @@ -133,9 +131,8 @@ void fpga_register_write(uint8_t reg, uint8_t value); * RX Mode Functions (only valid when mode == FPGA_MODE_RX) */ void fpga_rx_set_decimation(uint8_t ratio); -void fpga_rx_set_digital_gain(uint8_t shift); -void fpga_rx_set_dc_block_width(uint8_t width); -void fpga_rx_set_dc_adapt_rate(uint8_t rate); +/* mode is the gateware encoding: 0b00 none, 0b11 up, 0b01 down. */ +void fpga_rx_set_quarter_shift_mode(uint8_t mode); void fpga_rx_enable_dc_block(bool enable); /* @@ -154,12 +151,13 @@ void fpga_tx_set_phase_step(uint8_t step); * reg: Register number (1-5) * Returns: Register value, or 0xFF if invalid register * - * FPGA Register Map: - * Reg 1 (CTRL): DC_BLOCK(b0), QUARTER_SHIFT_EN(b1), QUARTER_SHIFT_UP(b2), PRBS(b6), TRIGGER_EN(b7) - * Reg 2 (RX_DECIM): Decimation ratio [2:0] - * Reg 3 (RX/TX): RX Digital Shift OR TX NCO Control - * Reg 4 (RX_DC_BLOCK_WIDTH/TX_INTERP) [2:0] - * Reg 5 (RX_DC_ADAPT_RATE/TX_PSTEP) [7:0] + * FPGA Register Map (hackrf/firmware/fpga/top/standard.py): + * Reg 1 (CTRL): DC_BLOCK(b0), PRBS(b6), TRIGGER_EN(b7) + * Reg 2 (RX_DECIM): Decimation ratio, log2 [2:0] + * Reg 3 (RX_PSTEP): QUARTER_SHIFT_EN(b6), QUARTER_SHIFT_UP(b7) + * Reg 4 (TX_CTRL): NCO_EN(b0) + * Reg 5 (TX_INTRP): Interpolation ratio, log2 [2:0] + * Reg 6 (TX_PSTEP): NCO phase step [7:0] */ uint8_t fpga_debug_register_read(uint8_t reg); From 367eaf54c0f51f62448d9f2d9585fd3629f6b770 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 8 Aug 2026 00:40:35 +0200 Subject: [PATCH 3/4] Fix GeoPos DMS edge carry rollback at clamped degree limits (#3284) * Initial plan * Fix geomap DMS wrap carry at clamped bounds Co-authored-by: gullradriel <3157857+gullradriel@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: gullradriel <3157857+gullradriel@users.noreply.github.com> --- firmware/application/ui/ui_geomap.cpp | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/firmware/application/ui/ui_geomap.cpp b/firmware/application/ui/ui_geomap.cpp index 4da7239b2..dac0fadb1 100644 --- a/firmware/application/ui/ui_geomap.cpp +++ b/firmware/application/ui/ui_geomap.cpp @@ -83,19 +83,35 @@ GeoPos::GeoPos( field_lon_seconds.on_change = changed_fn; const auto wrapped_lat_seconds = [this](int32_t v) { + const auto old_minutes = field_lat_minutes.value(); field_lat_minutes.on_encoder(v); + if (field_lat_minutes.value() == old_minutes) { + field_lat_seconds.set_value((v > 0) ? 59 : 0); + } }; const auto wrapped_lat_minutes = [this](int32_t v) { - field_lat_degrees.on_encoder((field_lat_degrees.value() >= 0) ? v : -v); + const auto old_degrees = field_lat_degrees.value(); + field_lat_degrees.on_encoder((old_degrees >= 0) ? v : -v); + if (field_lat_degrees.value() == old_degrees) { + field_lat_minutes.set_value((v > 0) ? 59 : 0); + } }; const auto wrapped_lon_seconds = [this](int32_t v) { + const auto old_minutes = field_lon_minutes.value(); field_lon_minutes.on_encoder(v); + if (field_lon_minutes.value() == old_minutes) { + field_lon_seconds.set_value((v > 0) ? 59 : 0); + } }; const auto wrapped_lon_minutes = [this](int32_t v) { - field_lon_degrees.on_encoder((field_lon_degrees.value() >= 0) ? v : -v); + const auto old_degrees = field_lon_degrees.value(); + field_lon_degrees.on_encoder((old_degrees >= 0) ? v : -v); + if (field_lon_degrees.value() == old_degrees) { + field_lon_minutes.set_value((v > 0) ? 59 : 0); + } }; field_lat_seconds.on_wrap = wrapped_lat_seconds; From 9067e007b659a9adb2af62baa92b0aa02d1469c2 Mon Sep 17 00:00:00 2001 From: MsfPablo <129399053+MsfPablo@users.noreply.github.com> Date: Sat, 8 Aug 2026 21:18:18 +0200 Subject: [PATCH 4/4] Fix GeoPos losing the sign of coordinates between 0 and -1 (#3283) GeoPos stored a coordinate's sign only in its degrees NumberField, which is an int32_t and therefore has no negative zero. Any coordinate whose integer degrees part is 0 but which is negative (i.e. strictly between 0.0 and -1.0) could not be represented at all: - set_lat()/set_lon() passed the raw float to the degrees field, so -0.2933 truncated to 0 and the sign was gone before it reached the widget. - lat()/lon() then decided the sign with `field_lon_degrees.value() < 0`, which is false for 0, so the value came back positive. The reporter's airport is at longitude -0.2933, which was impossible to enter in the ADSB Tx app. Give each coordinate an explicit hemisphere field (N/S and E/W) and make the degrees field an unsigned magnitude: - The hemisphere OptionsField is the single source of the sign, so "negative with zero degrees" is now representable. - set_lat()/set_lon() derive the hemisphere from the sign of the input and feed the fields the magnitude. - lat()/lon() read the hemisphere instead of inferring the sign from the degrees value. - The minutes on_wrap handlers no longer need to flip the carry direction based on the degrees sign, since degrees is now a magnitude; the carry is the same in both hemispheres. - The degrees fields no longer loop, so carrying below 0 clamps at 0 instead of wrapping round to 90/180. The hemisphere indicator occupies the column that the 4-wide signed degrees field used for its minus sign, so the row layout, the degree symbol and the decimal readout all stay where they were. Fixes #3234 --- firmware/application/ui/ui_geomap.cpp | 46 ++++++++++++++++----------- firmware/application/ui/ui_geomap.hpp | 31 +++++++++++++----- 2 files changed, 51 insertions(+), 26 deletions(-) diff --git a/firmware/application/ui/ui_geomap.cpp b/firmware/application/ui/ui_geomap.cpp index dac0fadb1..0f13368d2 100644 --- a/firmware/application/ui/ui_geomap.cpp +++ b/firmware/application/ui/ui_geomap.cpp @@ -46,10 +46,12 @@ GeoPos::GeoPos( &field_speed, &text_alt_unit, &text_speed_unit, + &field_lat_hemisphere, &field_lat_degrees, &field_lat_minutes, &field_lat_seconds, &text_lat_decimal, + &field_lon_hemisphere, &field_lon_degrees, &field_lon_minutes, &field_lon_seconds, @@ -73,8 +75,14 @@ GeoPos::GeoPos( on_change(altitude(), lat_value, lon_value, speed()); }; + const auto changed_hemisphere_fn = [changed_fn](size_t, OptionsField::value_t) { + changed_fn(0); + }; + field_altitude.on_change = changed_fn; field_speed.on_change = changed_fn; + field_lat_hemisphere.on_change = changed_hemisphere_fn; + field_lon_hemisphere.on_change = changed_hemisphere_fn; field_lat_degrees.on_change = changed_fn; field_lat_minutes.on_change = changed_fn; field_lat_seconds.on_change = changed_fn; @@ -90,9 +98,11 @@ GeoPos::GeoPos( } }; + // Degrees now holds a magnitude, so a minutes wrap always carries in the + // same direction regardless of hemisphere. const auto wrapped_lat_minutes = [this](int32_t v) { const auto old_degrees = field_lat_degrees.value(); - field_lat_degrees.on_encoder((old_degrees >= 0) ? v : -v); + field_lat_degrees.on_encoder(v); if (field_lat_degrees.value() == old_degrees) { field_lat_minutes.set_value((v > 0) ? 59 : 0); } @@ -108,7 +118,7 @@ GeoPos::GeoPos( const auto wrapped_lon_minutes = [this](int32_t v) { const auto old_degrees = field_lon_degrees.value(); - field_lon_degrees.on_encoder((old_degrees >= 0) ? v : -v); + field_lon_degrees.on_encoder(v); if (field_lon_degrees.value() == old_degrees) { field_lon_minutes.set_value((v > 0) ? 59 : 0); } @@ -162,31 +172,31 @@ void GeoPos::set_speed(int32_t speed) { } void GeoPos::set_lat(float lat) { - field_lat_degrees.set_value(lat); - field_lat_minutes.set_value((uint32_t)abs(lat / (1.0 / 60)) % 60); - field_lat_seconds.set_value((uint32_t)abs(lat / (1.0 / 3600)) % 60); + bool south = lat < 0; + float magnitude = south ? -lat : lat; + field_lat_hemisphere.set_by_value(south ? 1 : 0); + field_lat_degrees.set_value((int32_t)magnitude); + field_lat_minutes.set_value((uint32_t)(magnitude * 60) % 60); + field_lat_seconds.set_value((uint32_t)(magnitude * 3600) % 60); } void GeoPos::set_lon(float lon) { - field_lon_degrees.set_value(lon); - field_lon_minutes.set_value((uint32_t)abs(lon / (1.0 / 60)) % 60); - field_lon_seconds.set_value((uint32_t)abs(lon / (1.0 / 3600)) % 60); + bool west = lon < 0; + float magnitude = west ? -lon : lon; + field_lon_hemisphere.set_by_value(west ? 1 : 0); + field_lon_degrees.set_value((int32_t)magnitude); + field_lon_minutes.set_value((uint32_t)(magnitude * 60) % 60); + field_lon_seconds.set_value((uint32_t)(magnitude * 3600) % 60); } float GeoPos::lat() { - if (field_lat_degrees.value() < 0) { - return -1 * (-1 * field_lat_degrees.value() + (field_lat_minutes.value() / 60.0) + (field_lat_seconds.value() / 3600.0)); - } else { - return field_lat_degrees.value() + (field_lat_minutes.value() / 60.0) + (field_lat_seconds.value() / 3600.0); - } + float magnitude = field_lat_degrees.value() + (field_lat_minutes.value() / 60.0) + (field_lat_seconds.value() / 3600.0); + return (field_lat_hemisphere.selected_index_value() != 0) ? -magnitude : magnitude; }; float GeoPos::lon() { - if (field_lon_degrees.value() < 0) { - return -1 * (-1 * field_lon_degrees.value() + (field_lon_minutes.value() / 60.0) + (field_lon_seconds.value() / 3600.0)); - } else { - return field_lon_degrees.value() + (field_lon_minutes.value() / 60.0) + (field_lon_seconds.value() / 3600.0); - } + float magnitude = field_lon_degrees.value() + (field_lon_minutes.value() / 60.0) + (field_lon_seconds.value() / 3600.0); + return (field_lon_hemisphere.selected_index_value() != 0) ? -magnitude : magnitude; }; int32_t GeoPos::altitude() { diff --git a/firmware/application/ui/ui_geomap.hpp b/firmware/application/ui/ui_geomap.hpp index 5dfbbaa8e..30759d5cc 100644 --- a/firmware/application/ui/ui_geomap.hpp +++ b/firmware/application/ui/ui_geomap.hpp @@ -239,12 +239,21 @@ class GeoPos : public View { {25 * 8, UI_POS_Y(0), 5 * 8, 16}, ""}; - NumberField field_lat_degrees{ + // Sign is held by the hemisphere field, not by the degrees field: an + // int32_t degrees field has no negative zero, so a coordinate in + // (-1, 0) could not otherwise be represented (see issue #3234). + OptionsField field_lat_hemisphere{ {5 * 8, 1 * 16}, - 4, - {-90, 90}, 1, - ' '}; + {{"N", 0}, + {"S", 1}}}; + NumberField field_lat_degrees{ + {6 * 8, 1 * 16}, + 3, + {0, 90}, + 1, + ' ', + false}; NumberField field_lat_minutes{ {10 * 8, 1 * 16}, 2, @@ -263,12 +272,18 @@ class GeoPos : public View { {17 * 8, 1 * 16, 13 * 8, 1 * 16}, ""}; - NumberField field_lon_degrees{ + OptionsField field_lon_hemisphere{ {5 * 8, 2 * 16}, - 4, - {-180, 180}, 1, - ' '}; + {{"E", 0}, + {"W", 1}}}; + NumberField field_lon_degrees{ + {6 * 8, 2 * 16}, + 3, + {0, 180}, + 1, + ' ', + false}; NumberField field_lon_minutes{ {10 * 8, 2 * 16}, 2,