From 914c354da0a30ecbb12a62c0aa5b54d4f17fb762 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Thu, 2 Jul 2026 00:57:02 +0200 Subject: [PATCH 01/37] Update donors --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9babb7d7..5617c0ab 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Anyway, have fun. # Donations -Special thanks to Jean-Cyrille F6IWW (3 times), Fabrice 14RC123, David F4BPP, Olivier 14RC206, Frédéric F4ESO, Stéphane F5LGW (2 times), Jorge Ornelas (4 times), Laurent F4AXK, Christophe Morel, Clayton W0LED, Pierre Antoine F6FWB, Jean-Claude 14FRS3306, Thierry F4GVO, Eric F1NOU, PricelessToolkit, Ady M6NYJ, Tom McGovern (4 times), Joseph Roth, Pierre-Yves Colin, Frank DJ7FG, Marcel Testaz, Brian Frobisher, Yannick F4JFO, Paolo Bussola, Dirk DL8DF, Levente Szőke (2 times), Bernard-Michel Herrera, Jérôme Saintespes, Paul Davies, RS (3 times), Johan F4WAT, Robert Wörle, Rafael Sundorf, Paul Harker, Peter Fintl, Pascal F4ICR (2 times), Mike DL2MF (3 times), Eric KI1C / F4WFS (3 times), Phil G0ELM, Jérôme Lambert, Eliot Vedel, Alfonso EA7KDF, Jean-François F1EVM, Robert DC1RDB (2 times), Ian KE2CHJ, Daryl VK3AWA, Roberto Brunelli, Robert Boardman, Stephen Oliver, Nicolas F4INE, William Bruno, Daniel OK2VLK, Tayler Chew, Peter DL7RFP, Philippe Kopp, Rune LA6YMA, Jeremy Luna, Steef Wagenaar (2 times), Zhuo BG7SGA, Jamie M0JLB, Antoine LIBERT, Vince K0DKR, Julia DF7JA, Ken 2E0UMK, Victor TI2SYS, Tobi DG9LAY, Deaglan K4DFQ, Catherine PALMER, Brian WA6JFK, Stéphane Hintzy, Roger F1HCN, Marcin Kusaj, Flavio Cottarelli and Bob N1MLZ for their [donations](https://www.paypal.com/paypalme/F4HWN). That’s so kind of them. Thanks so much 🙏🏻 +Special thanks to Jean-Cyrille F6IWW (3 times), Fabrice 14RC123, David F4BPP, Olivier 14RC206, Frédéric F4ESO, Stéphane F5LGW (2 times), Jorge Ornelas (4 times), Laurent F4AXK, Christophe Morel, Clayton W0LED, Pierre Antoine F6FWB, Jean-Claude 14FRS3306, Thierry F4GVO, Eric F1NOU, PricelessToolkit, Ady M6NYJ, Tom McGovern (4 times), Joseph Roth, Pierre-Yves Colin, Frank DJ7FG, Marcel Testaz, Brian Frobisher, Yannick F4JFO, Paolo Bussola, Dirk DL8DF, Levente Szőke (2 times), Bernard-Michel Herrera, Jérôme Saintespes, Paul Davies, RS (3 times), Johan F4WAT, Robert Wörle, Rafael Sundorf, Paul Harker, Peter Fintl, Pascal F4ICR (2 times), Mike DL2MF (3 times), Eric KI1C / F4WFS (3 times), Phil G0ELM, Jérôme Lambert, Eliot Vedel, Alfonso EA7KDF, Jean-François F1EVM, Robert DC1RDB (2 times), Ian KE2CHJ, Daryl VK3AWA, Roberto Brunelli, Robert Boardman, Stephen Oliver, Nicolas F4INE, William Bruno, Daniel OK2VLK, Tayler Chew, Peter DL7RFP, Philippe Kopp, Rune LA6YMA, Jeremy Luna, Steef Wagenaar (2 times), Zhuo BG7SGA, Jamie M0JLB, Antoine LIBERT, Vince K0DKR, Julia DF7JA, Ken 2E0UMK, Victor TI2SYS, Tobi DG9LAY, Deaglan K4DFQ, Catherine PALMER, Brian WA6JFK, Stéphane Hintzy, Roger F1HCN, Marcin Kusaj, Flavio Cottarelli, Bob N1MLZ and Carlos EA1IJ for their [donations](https://www.paypal.com/paypalme/F4HWN). That’s so kind of them. Thanks so much 🙏🏻 ## Table of Contents From 0187c05e654c4aa54a8d2e07e73c27e0ea16df86 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 5 Jul 2026 17:59:58 +0200 Subject: [PATCH 02/37] Add RX/TX history log stored in external flash --- App/CMakeLists.txt | 3 + App/app/action.c | 6 + App/app/action.h | 3 + App/app/app.c | 19 + App/app/rxtx_log.c | 824 +++++++++++++++++++++++++++++++++++++ App/app/rxtx_log.h | 49 +++ App/driver/eeprom_compat.c | 4 + App/functions.c | 28 ++ App/main.c | 7 + App/radio.c | 7 + App/settings.h | 3 + App/ui/menu.c | 3 + App/ui/status.c | 19 + App/ui/ui.c | 7 + App/ui/ui.h | 4 + CMakePresets.json | 2 + 16 files changed, 988 insertions(+) create mode 100644 App/app/rxtx_log.c create mode 100644 App/app/rxtx_log.h diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index df9ffccc..6124dab2 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -211,6 +211,9 @@ enable_feature(ENABLE_FEAT_F4HWN_MEM) enable_feature(ENABLE_FEAT_F4HWN_BEAM app/beam.c ) +enable_feature(ENABLE_FEAT_F4HWN_RXTX_LOG + app/rxtx_log.c +) if(ENABLE_FEAT_F4HWN_BEAM AND NOT ENABLE_AIRCOPY) message(FATAL_ERROR "ENABLE_FEAT_F4HWN_BEAM requires ENABLE_AIRCOPY (it reuses g_FSK_Buffer, AIRCOPY_Obfuscate and the FSK packet plumbing).") diff --git a/App/app/action.c b/App/app/action.c index af2fcab2..9715f43f 100644 --- a/App/app/action.c +++ b/App/app/action.c @@ -48,6 +48,9 @@ #ifdef ENABLE_FEAT_F4HWN_BEAM #include "app/beam.h" #endif +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + #include "app/rxtx_log.h" +#endif #if defined(ENABLE_FMRADIO) static void ACTION_Scan_FM(bool bRestart); @@ -138,6 +141,9 @@ void (*action_opt_table[])(void) = { #ifdef ENABLE_FEAT_F4HWN_BEAM [ACTION_OPT_BEAM] = &ACTION_Beam, #endif +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + [ACTION_OPT_RXTX_LOG] = &ACTION_RxTxLog, +#endif }; static_assert(ARRAY_SIZE(action_opt_table) == ACTION_OPT_LEN); diff --git a/App/app/action.h b/App/app/action.h index 6ce81ca8..d70c0efd 100644 --- a/App/app/action.h +++ b/App/app/action.h @@ -50,6 +50,9 @@ void ACTION_SwitchDemodul(void); void ACTION_Power_High(void); void ACTION_Remove_Offset(void); #endif + #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + void ACTION_RxTxLog(void); + #endif #endif void ACTION_Handle(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld); diff --git a/App/app/app.c b/App/app/app.c index 5b9d74d4..8a73eafe 100644 --- a/App/app/app.c +++ b/App/app/app.c @@ -39,6 +39,9 @@ #include "app/generic.h" #include "app/main.h" #include "app/menu.h" +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + #include "app/rxtx_log.h" +#endif #include "app/scanner.h" #if defined(ENABLE_UART) || defined(ENABLE_USB) #include "app/uart.h" @@ -116,6 +119,10 @@ void (*ProcessKeysFunctions[])(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) #ifdef ENABLE_AIRCOPY [DISPLAY_AIRCOPY] = &AIRCOPY_ProcessKeys, #endif + +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + [DISPLAY_RXTX_LOG] = &RXTX_LOG_ProcessKeys, +#endif }; #ifdef ENABLE_REGA @@ -772,6 +779,10 @@ void APP_StartListening(FUNCTION_Type_t function) FUNCTION_Select(function); +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + RXTX_LOG_BeginRx(gRxVfo, function); +#endif + #ifdef ENABLE_FMRADIO if (function == FUNCTION_MONITOR || gFmRadioMode) #else @@ -1576,6 +1587,10 @@ void APP_TimeSlice10ms(void) SETTINGS_SaveVfoIndicesFlush(); +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + RXTX_LOG_Task10ms(); +#endif + BACKLIGHT_Update(); gFlashLightBlinkCounter++; @@ -1806,6 +1821,10 @@ void APP_TimeSlice500ms(void) if (--gKeypadLocked == 0) gUpdateDisplay = true; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + RXTX_LOG_Tick500ms(); +#endif + #ifdef ENABLE_FEAT_F4HWN_RX_TX_TIMER if (gSetting_set_tmr && (gCurrentFunction == FUNCTION_TRANSMIT || FUNCTION_IsRx())) { const uint16_t timerCountdown = (gCurrentFunction == FUNCTION_TRANSMIT) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c new file mode 100644 index 00000000..39f624eb --- /dev/null +++ b/App/app/rxtx_log.c @@ -0,0 +1,824 @@ +/* Copyright 2026 + * + * Licensed under the Apache License, Version 2.0. + */ + +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + +#include +#include + +#include "app/generic.h" +#include "app/rxtx_log.h" +#include "audio.h" +#include "driver/py25q16.h" +#include "driver/st7565.h" +#include "external/printf/printf.h" +#include "frequencies.h" +#include "misc.h" +#include "ui/helper.h" +#include "ui/ui.h" + +#define RXTX_LOG_FLASH_BASE 0x1E0000u +#define RXTX_LOG_FLASH_SECTOR_SIZE 0x1000u +#define RXTX_LOG_FLASH_SECTOR_COUNT 8u +#define RXTX_LOG_FLASH_SIZE (RXTX_LOG_FLASH_SECTOR_SIZE * RXTX_LOG_FLASH_SECTOR_COUNT) +#define RXTX_LOG_FLASH_END (RXTX_LOG_FLASH_BASE + RXTX_LOG_FLASH_SIZE) +#define RXTX_LOG_SLOT_COUNT (RXTX_LOG_FLASH_SIZE / sizeof(RXTX_LogFlashEntry_t)) +#define RXTX_LOG_VIEW_CACHE_COUNT 7u +#define RXTX_LOG_VIEW_SCAN_BUDGET 8u +#define RXTX_LOG_VIEW_ANCHOR_STRIDE 8u +#define RXTX_LOG_VIEW_ANCHOR_COUNT ((RXTX_LOG_VISIBLE_COUNT + RXTX_LOG_VIEW_ANCHOR_STRIDE - 1u) / RXTX_LOG_VIEW_ANCHOR_STRIDE) +#define RXTX_LOG_ENTRY_COMMIT 0xA5u +#define RXTX_LOG_CHANNEL_NONE 0xFFFFu +#define RXTX_LOG_FLAG_TX (1u << 0) +#define RXTX_LOG_FLAG_NAMED (1u << 1) +#define RXTX_LOG_FLAG_MONITOR (1u << 2) +#define RXTX_LOG_FLAG_SESSION (1u << 3) +#define RXTX_LOG_FILTER_ALL 0u +#define RXTX_LOG_FILTER_RX 1u +#define RXTX_LOG_FILTER_TX 2u + +typedef struct __attribute__((packed)) { + uint32_t sequence; + uint32_t frequency; + uint16_t durationSeconds; + uint16_t channel; + uint8_t flags; + char name[RXTX_LOG_NAME_LEN]; + uint8_t reserved[7]; + uint8_t crc; + uint8_t commit; +} RXTX_LogFlashEntry_t; + +static_assert(sizeof(RXTX_LogFlashEntry_t) == 32); +static_assert(RXTX_LOG_VIEW_ANCHOR_COUNT <= 32); + +static RXTX_LogEntry_t gViewCache[RXTX_LOG_VIEW_CACHE_COUNT]; +static uint16_t gViewCacheStart; +static uint8_t gViewCacheCount; +static uint8_t gViewCacheFilter; +static bool gViewCacheHasOlder; +static bool gViewCacheComplete; +static bool gViewScanActive; +static uint16_t gViewScanSlot; +static uint16_t gViewScanScanned; +static uint16_t gViewScanSkip; +static uint16_t gViewScanIndex; +static uint16_t gViewAnchorSlots[RXTX_LOG_VIEW_ANCHOR_COUNT]; +static uint32_t gViewAnchorMask; +static uint8_t gViewAnchorFilter; +static bool gClearActive; +static uint8_t gClearSector; +static bool gMenuClearHandled; +static bool gLogHasTraffic; +static uint32_t gNextSequence; +static uint32_t gNextFlashAddress; + +static bool gSessionActive; +static uint8_t gSessionFlags; +static uint32_t gSessionFrequency; +static uint16_t gSessionChannel; +static uint16_t gSessionTicks500ms; +static char gSessionName[RXTX_LOG_NAME_LEN + 1]; + +static uint16_t gLogCursor; +static uint8_t gLogFilter; + +static uint8_t RXTX_LOG_Crc8(const void *data, uint16_t size) +{ + const uint8_t *p = (const uint8_t *)data; + uint8_t crc = 0x5Au; + + while (size-- > 0) { + crc ^= *p++; + for (uint8_t bit = 0; bit < 8; bit++) { + crc = (crc & 0x80u) ? (uint8_t)((crc << 1) ^ 0x31u) : (uint8_t)(crc << 1); + } + } + + return crc; +} + +static bool RXTX_LOG_IsValidFlashEntry(const RXTX_LogFlashEntry_t *entry) +{ + if (entry->commit != RXTX_LOG_ENTRY_COMMIT || + entry->sequence == 0xFFFFFFFFu || + entry->frequency == 0xFFFFFFFFu) + return false; + + return entry->crc == RXTX_LOG_Crc8(entry, sizeof(*entry) - 2); +} + +static bool RXTX_LOG_IsTx(const RXTX_LogEntry_t *entry) +{ + return (entry->flags & RXTX_LOG_FLAG_TX) != 0; +} + +static bool RXTX_LOG_IsSessionMarker(const RXTX_LogEntry_t *entry) +{ + return (entry->flags & RXTX_LOG_FLAG_SESSION) != 0; +} + +static bool RXTX_LOG_IsTrafficFlags(uint8_t flags) +{ + return (flags & RXTX_LOG_FLAG_SESSION) == 0; +} + +static bool RXTX_LOG_MatchesFlags(uint8_t flags) +{ + if ((flags & RXTX_LOG_FLAG_SESSION) != 0) + return gLogFilter == RXTX_LOG_FILTER_ALL; + + if (gLogFilter == RXTX_LOG_FILTER_RX) + return (flags & RXTX_LOG_FLAG_TX) == 0; + + if (gLogFilter == RXTX_LOG_FILTER_TX) + return (flags & RXTX_LOG_FLAG_TX) != 0; + + return true; +} + +const char *RXTX_LOG_GetFilterName(void) +{ + if (gLogFilter == RXTX_LOG_FILTER_RX) + return "RX"; + if (gLogFilter == RXTX_LOG_FILTER_TX) + return "TX"; + return "ALL"; +} + +static void RXTX_LOG_SanitizeName(char *dst, const char *src) +{ + uint8_t i; + + for (i = 0; i < RXTX_LOG_NAME_LEN; i++) { + char c = src ? src[i] : 0; + if (c < 32 || c > 126) + break; + dst[i] = c; + } + + while (i > 0 && dst[i - 1] == ' ') + i--; + + dst[i] = 0; +} + +static void RXTX_LOG_CopyFromFlash(RXTX_LogEntry_t *dst, const RXTX_LogFlashEntry_t *src) +{ + dst->sequence = src->sequence; + dst->frequency = src->frequency; + dst->durationSeconds = src->durationSeconds; + dst->channel = src->channel; + dst->flags = src->flags; + memcpy(dst->name, src->name, RXTX_LOG_NAME_LEN); + dst->name[RXTX_LOG_NAME_LEN] = 0; +} + +static uint32_t RXTX_LOG_SlotToAddress(uint16_t slot) +{ + return RXTX_LOG_FLASH_BASE + ((uint32_t)slot * sizeof(RXTX_LogFlashEntry_t)); +} + +static uint16_t RXTX_LOG_AddressToSlot(uint32_t address) +{ + return (uint16_t)((address - RXTX_LOG_FLASH_BASE) / sizeof(RXTX_LogFlashEntry_t)); +} + +static uint16_t RXTX_LOG_PreviousSlot(uint16_t slot) +{ + return slot == 0 ? (uint16_t)(RXTX_LOG_SLOT_COUNT - 1u) : (uint16_t)(slot - 1u); +} + +static uint16_t RXTX_LOG_NextSlot(uint16_t slot) +{ + return (uint16_t)(slot + 1u) >= RXTX_LOG_SLOT_COUNT ? 0 : (uint16_t)(slot + 1u); +} + +static void RXTX_LOG_InvalidateViewAnchors(void) +{ + gViewAnchorMask = 0; + gViewAnchorFilter = 0xFFu; +} + +static void RXTX_LOG_EnsureViewAnchors(void) +{ + if (gViewAnchorFilter == gLogFilter) + return; + + gViewAnchorMask = 0; + gViewAnchorFilter = gLogFilter; +} + +static void RXTX_LOG_RecordViewAnchor(uint16_t indexFromNewest, uint16_t slot) +{ + if ((indexFromNewest % RXTX_LOG_VIEW_ANCHOR_STRIDE) != 0) + return; + + const uint16_t anchor = indexFromNewest / RXTX_LOG_VIEW_ANCHOR_STRIDE; + if (anchor >= RXTX_LOG_VIEW_ANCHOR_COUNT) + return; + + gViewAnchorSlots[anchor] = slot; + gViewAnchorMask |= (uint32_t)(1u << anchor); +} + +static bool RXTX_LOG_FindViewAnchor(uint16_t indexFromNewest, uint16_t *anchorIndex, uint16_t *slot) +{ + uint16_t anchor = indexFromNewest / RXTX_LOG_VIEW_ANCHOR_STRIDE; + if (anchor >= RXTX_LOG_VIEW_ANCHOR_COUNT) + anchor = RXTX_LOG_VIEW_ANCHOR_COUNT - 1u; + + do { + if ((gViewAnchorMask & (uint32_t)(1u << anchor)) != 0) { + *anchorIndex = (uint16_t)(anchor * RXTX_LOG_VIEW_ANCHOR_STRIDE); + *slot = gViewAnchorSlots[anchor]; + return true; + } + } while (anchor-- > 0); + + return false; +} + +static void RXTX_LOG_InvalidateViewCache(void) +{ + gViewCacheStart = 0xFFFFu; + gViewCacheCount = 0; + gViewCacheFilter = 0xFFu; + gViewCacheHasOlder = false; + gViewCacheComplete = false; + gViewScanActive = false; + RXTX_LOG_InvalidateViewAnchors(); +} + +static void RXTX_LOG_NextFilter(void) +{ + gLogFilter++; + if (gLogFilter > RXTX_LOG_FILTER_TX) + gLogFilter = RXTX_LOG_FILTER_ALL; + + gLogCursor = 0; + RXTX_LOG_InvalidateViewCache(); + gUpdateStatus = true; + gUpdateDisplay = true; +} + +static void RXTX_LOG_StartClear(void) +{ + if (gClearActive) + return; + + gClearActive = true; + gClearSector = 0; + gSessionActive = false; + gLogCursor = 0; + gLogHasTraffic = false; + gNextSequence = 0; + gNextFlashAddress = RXTX_LOG_FLASH_BASE; + RXTX_LOG_InvalidateViewCache(); +} + +static void RXTX_LOG_StepClear(void) +{ + if (!gClearActive) + return; + + PY25Q16_SectorErase(RXTX_LOG_FLASH_BASE + ((uint32_t)gClearSector * RXTX_LOG_FLASH_SECTOR_SIZE)); + + gClearSector++; + if (gClearSector >= RXTX_LOG_FLASH_SECTOR_COUNT) { + gClearActive = false; + gClearSector = 0; + gLogHasTraffic = false; + gNextSequence = 0; + gNextFlashAddress = RXTX_LOG_FLASH_BASE; + RXTX_LOG_InvalidateViewCache(); + } +} + +static uint16_t RXTX_LOG_PageStart(uint16_t indexFromNewest) +{ + if (indexFromNewest >= RXTX_LOG_VISIBLE_COUNT) + indexFromNewest = RXTX_LOG_VISIBLE_COUNT - 1u; + + return indexFromNewest; +} + +static void RXTX_LOG_StepViewCacheScan(void) +{ + uint8_t budget = RXTX_LOG_VIEW_SCAN_BUDGET; + + while (gViewScanActive && budget-- > 0 && gViewScanScanned < RXTX_LOG_SLOT_COUNT) { + RXTX_LogFlashEntry_t flashEntry; + + gViewScanSlot = RXTX_LOG_PreviousSlot(gViewScanSlot); + gViewScanScanned++; + + PY25Q16_ReadBuffer(RXTX_LOG_SlotToAddress(gViewScanSlot), &flashEntry, sizeof(flashEntry)); + if (!RXTX_LOG_IsValidFlashEntry(&flashEntry) || + !RXTX_LOG_MatchesFlags(flashEntry.flags)) + continue; + + RXTX_LOG_RecordViewAnchor(gViewScanIndex, gViewScanSlot); + + if (gViewScanSkip > 0) { + gViewScanSkip--; + gViewScanIndex++; + continue; + } + + if (gViewCacheCount >= RXTX_LOG_VIEW_CACHE_COUNT) { + gViewCacheHasOlder = true; + gViewScanActive = false; + gViewCacheComplete = true; + break; + } + + RXTX_LOG_CopyFromFlash(&gViewCache[gViewCacheCount], &flashEntry); + gViewCacheCount++; + gViewScanIndex++; + if (gViewCacheCount >= RXTX_LOG_VIEW_CACHE_COUNT) { + gViewCacheHasOlder = (uint16_t)(gViewCacheStart + gViewCacheCount) < RXTX_LOG_VISIBLE_COUNT; + gViewScanActive = false; + gViewCacheComplete = true; + break; + } + } + + if (gViewScanScanned >= RXTX_LOG_SLOT_COUNT) { + gViewScanActive = false; + gViewCacheComplete = true; + } +} + +static void RXTX_LOG_StartViewCacheScan(uint16_t start) +{ + uint16_t anchorIndex; + uint16_t anchorSlot; + + start = RXTX_LOG_PageStart(start); + RXTX_LOG_EnsureViewAnchors(); + + gViewCacheStart = start; + gViewCacheCount = 0; + gViewCacheFilter = gLogFilter; + gViewCacheHasOlder = false; + gViewCacheComplete = false; + gViewScanActive = false; + + if (gNextSequence == 0) { + gViewCacheComplete = true; + return; + } + + if (RXTX_LOG_FindViewAnchor(start, &anchorIndex, &anchorSlot)) { + gViewScanSlot = RXTX_LOG_NextSlot(anchorSlot); + gViewScanSkip = start - anchorIndex; + gViewScanIndex = anchorIndex; + } else { + gViewScanSlot = RXTX_LOG_AddressToSlot(gNextFlashAddress); + gViewScanSkip = start; + gViewScanIndex = 0; + } + + gViewScanScanned = 0; + gViewScanActive = true; +} + +static bool RXTX_LOG_ViewCacheCovers(uint16_t indexFromNewest) +{ + return gViewCacheFilter == gLogFilter && + indexFromNewest >= gViewCacheStart && + indexFromNewest < (uint16_t)(gViewCacheStart + gViewCacheCount); +} + +static bool RXTX_LOG_SlotIsBlank(uint32_t address) +{ + RXTX_LogFlashEntry_t entry; + + PY25Q16_ReadBuffer(address, &entry, sizeof(entry)); + + const uint8_t *p = (const uint8_t *)&entry; + for (uint8_t i = 0; i < sizeof(entry); i++) { + if (p[i] != 0xFFu) + return false; + } + + return true; +} + +static void RXTX_LOG_PrepareNextSlot(void) +{ + if (gNextFlashAddress >= RXTX_LOG_FLASH_END) + gNextFlashAddress = RXTX_LOG_FLASH_BASE; + + if (!RXTX_LOG_SlotIsBlank(gNextFlashAddress)) { + if ((gNextFlashAddress % RXTX_LOG_FLASH_SECTOR_SIZE) != 0) { + gNextFlashAddress += RXTX_LOG_FLASH_SECTOR_SIZE - (gNextFlashAddress % RXTX_LOG_FLASH_SECTOR_SIZE); + if (gNextFlashAddress >= RXTX_LOG_FLASH_END) + gNextFlashAddress = RXTX_LOG_FLASH_BASE; + } + + if (!RXTX_LOG_SlotIsBlank(gNextFlashAddress)) { + const uint32_t sector = gNextFlashAddress - (gNextFlashAddress % RXTX_LOG_FLASH_SECTOR_SIZE); + PY25Q16_SectorErase(sector); + } + } +} + +static void RXTX_LOG_AdvanceFlashAddress(void) +{ + gNextFlashAddress += sizeof(RXTX_LogFlashEntry_t); + if (gNextFlashAddress >= RXTX_LOG_FLASH_END) + gNextFlashAddress = RXTX_LOG_FLASH_BASE; +} + +static uint32_t RXTX_LOG_WriteEntry(const RXTX_LogEntry_t *src) +{ + RXTX_LogFlashEntry_t entry; + uint8_t commit = RXTX_LOG_ENTRY_COMMIT; + + memset(&entry, 0xFF, sizeof(entry)); + entry.sequence = src->sequence; + entry.frequency = src->frequency; + entry.durationSeconds = src->durationSeconds; + entry.channel = src->channel; + entry.flags = src->flags; + memcpy(entry.name, src->name, RXTX_LOG_NAME_LEN); + entry.crc = RXTX_LOG_Crc8(&entry, sizeof(entry) - 2); + + RXTX_LOG_PrepareNextSlot(); + + const uint32_t address = gNextFlashAddress; + PY25Q16_WriteBuffer(gNextFlashAddress, &entry, sizeof(entry), false); + PY25Q16_WriteBuffer(gNextFlashAddress + sizeof(entry) - 1u, &commit, 1, false); + RXTX_LOG_AdvanceFlashAddress(); + + return address; +} + +static void RXTX_LOG_WriteSessionMarker(void) +{ + RXTX_LogEntry_t entry; + + memset(&entry, 0, sizeof(entry)); + entry.sequence = gNextSequence++; + entry.channel = RXTX_LOG_CHANNEL_NONE; + entry.flags = RXTX_LOG_FLAG_SESSION; + + RXTX_LOG_WriteEntry(&entry); + RXTX_LOG_InvalidateViewCache(); +} + +static void RXTX_LOG_EnsureViewCache(void) +{ + const uint16_t pageStart = RXTX_LOG_PageStart(gLogCursor); + + if (RXTX_LOG_ViewCacheCovers(gLogCursor) && + gViewCacheStart == pageStart) + return; + + if (gViewScanActive && + gViewCacheFilter == gLogFilter && + gViewCacheStart == pageStart) + return; + + if (gViewCacheComplete && + gViewCacheFilter == gLogFilter && + gViewCacheStart == pageStart) + return; + + RXTX_LOG_StartViewCacheScan(gLogCursor); +} + +static bool RXTX_LOG_GetFilteredEntry(uint16_t indexFromNewest, RXTX_LogEntry_t *entry) +{ + if (indexFromNewest >= RXTX_LOG_VISIBLE_COUNT) + return false; + + if (!RXTX_LOG_ViewCacheCovers(indexFromNewest)) + return false; + + *entry = gViewCache[indexFromNewest - gViewCacheStart]; + return true; +} + +static void RXTX_LOG_CaptureSession(uint8_t flags, const VFO_Info_t *vfo) +{ + if (gClearActive) + return; + + const uint32_t frequency = (flags & RXTX_LOG_FLAG_TX) ? vfo->pTX->Frequency : vfo->pRX->Frequency; + const bool isMemoryChannel = IS_MR_CHANNEL(vfo->CHANNEL_SAVE); + const uint16_t channel = isMemoryChannel ? vfo->CHANNEL_SAVE : RXTX_LOG_CHANNEL_NONE; + char name[RXTX_LOG_NAME_LEN + 1]; + + RXTX_LOG_SanitizeName(name, isMemoryChannel ? vfo->Name : NULL); + + if (gSessionActive && + gSessionFlags == flags && + gSessionFrequency == frequency && + gSessionChannel == channel) + return; + + RXTX_LOG_EndActive(); + + gSessionActive = true; + gSessionFlags = flags; + gSessionFrequency = frequency; + gSessionChannel = channel; + gSessionTicks500ms = 0; + strcpy(gSessionName, name); +} + +void RXTX_LOG_Init(void) +{ + uint32_t maxSequence = 0; + uint32_t maxAddress = RXTX_LOG_FLASH_BASE; + bool found = false; + + gLogCursor = 0; + gLogFilter = RXTX_LOG_FILTER_ALL; + gSessionActive = false; + gClearActive = false; + gClearSector = 0; + gMenuClearHandled = false; + gLogHasTraffic = false; + gNextFlashAddress = RXTX_LOG_FLASH_BASE; + RXTX_LOG_InvalidateViewCache(); + + for (uint32_t address = RXTX_LOG_FLASH_BASE; address < RXTX_LOG_FLASH_END; address += sizeof(RXTX_LogFlashEntry_t)) { + RXTX_LogFlashEntry_t flashEntry; + + PY25Q16_ReadBuffer(address, &flashEntry, sizeof(flashEntry)); + if (!RXTX_LOG_IsValidFlashEntry(&flashEntry)) + continue; + + if (RXTX_LOG_IsTrafficFlags(flashEntry.flags)) + gLogHasTraffic = true; + + if (!found || flashEntry.sequence > maxSequence) { + found = true; + maxSequence = flashEntry.sequence; + maxAddress = address; + } + } + + if (found) { + gNextSequence = maxSequence + 1u; + gNextFlashAddress = maxAddress + sizeof(RXTX_LogFlashEntry_t); + if (gNextFlashAddress >= RXTX_LOG_FLASH_END) + gNextFlashAddress = RXTX_LOG_FLASH_BASE; + } else { + gNextSequence = 0; + } + + RXTX_LOG_WriteSessionMarker(); +} + +void RXTX_LOG_BeginRx(const VFO_Info_t *vfo, FUNCTION_Type_t function) +{ + if (vfo == NULL) + return; + + uint8_t flags = 0; + if (function == FUNCTION_MONITOR) + flags |= RXTX_LOG_FLAG_MONITOR; + + RXTX_LOG_CaptureSession(flags, vfo); +} + +void RXTX_LOG_BeginTx(const VFO_Info_t *vfo) +{ + if (vfo == NULL) + return; + + RXTX_LOG_CaptureSession(RXTX_LOG_FLAG_TX, vfo); +} + +void RXTX_LOG_EndActive(void) +{ + if (!gSessionActive) + return; + + if (gClearActive) { + gSessionActive = false; + return; + } + + RXTX_LogEntry_t entry; + memset(&entry, 0, sizeof(entry)); + + entry.sequence = gNextSequence++; + entry.frequency = gSessionFrequency; + entry.durationSeconds = (gSessionTicks500ms + 1u) / 2u; + entry.channel = gSessionChannel; + entry.flags = gSessionFlags; + strcpy(entry.name, gSessionName); + + if (entry.name[0] != 0) + entry.flags |= RXTX_LOG_FLAG_NAMED; + + if (entry.durationSeconds == 0) + entry.durationSeconds = 1; + + RXTX_LOG_WriteEntry(&entry); + gLogHasTraffic = true; + RXTX_LOG_InvalidateViewCache(); + + gSessionActive = false; +} + +void RXTX_LOG_Tick500ms(void) +{ + if (gSessionActive && gSessionTicks500ms < 0xFFFEu) + gSessionTicks500ms++; +} + +void RXTX_LOG_Task10ms(void) +{ + if (gClearActive) { + RXTX_LOG_StepClear(); + } else if (gViewScanActive) { + RXTX_LOG_StepViewCacheScan(); + } else { + return; + } + + if (gScreenToDisplay == DISPLAY_RXTX_LOG) + gUpdateDisplay = true; +} + +void ACTION_RxTxLog(void) +{ + gLogCursor = 0; + RXTX_LOG_InvalidateViewCache(); + gUpdateStatus = true; + GUI_SelectNextDisplay(DISPLAY_RXTX_LOG); +} + +void RXTX_LOG_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) +{ + if (Key == KEY_PTT) { + GENERIC_Key_PTT(bKeyPressed); + return; + } + + if (!bKeyPressed && !bKeyHeld && Key != KEY_MENU) + return; + + if (gClearActive) { + if (Key == KEY_MENU && !bKeyPressed) + gMenuClearHandled = false; + gUpdateDisplay = true; + return; + } + + switch (Key) { + case KEY_UP: + if (gLogCursor > 0) { + gLogCursor--; + RXTX_LOG_EnsureViewCache(); + } + gUpdateDisplay = true; + break; + + case KEY_DOWN: + RXTX_LOG_EnsureViewCache(); + if (gViewCacheCount > 0) { + const uint16_t next = gLogCursor + 1u; + if (next < (uint16_t)(gViewCacheStart + gViewCacheCount)) { + gLogCursor = next; + } else if (gViewCacheHasOlder && + (uint16_t)(gViewCacheStart + gViewCacheCount) < RXTX_LOG_VISIBLE_COUNT) { + gLogCursor = gViewCacheStart + gViewCacheCount; + RXTX_LOG_StartViewCacheScan(gLogCursor); + } + } + gUpdateDisplay = true; + break; + + case KEY_STAR: + if (!bKeyHeld && bKeyPressed) { + RXTX_LOG_NextFilter(); + } + break; + + case KEY_MENU: + if (bKeyHeld) { + if (bKeyPressed && !gMenuClearHandled) { + gMenuClearHandled = true; + RXTX_LOG_StartClear(); + gBeepToPlay = BEEP_1KHZ_60MS_OPTIONAL; + gUpdateStatus = true; + gUpdateDisplay = true; + } + } else if (!bKeyPressed) { + if (!gMenuClearHandled) + RXTX_LOG_NextFilter(); + gMenuClearHandled = false; + } + break; + + case KEY_EXIT: + gRequestDisplayScreen = DISPLAY_MAIN; + gUpdateStatus = true; + break; + + default: + if (!bKeyHeld) + gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL; + break; + } +} + +static void RXTX_LOG_FormatFrequency(uint32_t frequency, char *buffer) +{ + sprintf(buffer, "%u.%05u", frequency / 100000u, frequency % 100000u); +} + +static void RXTX_LOG_FormatTitle(const RXTX_LogEntry_t *entry, char *buffer) +{ + if ((entry->flags & RXTX_LOG_FLAG_NAMED) != 0) { + strcpy(buffer, entry->name); + } else { + RXTX_LOG_FormatFrequency(entry->frequency, buffer); + } +} + +static void RXTX_LOG_DrawIndexBadge(uint16_t indexFromNewest, uint8_t line) +{ + char label[4]; + + sprintf(label, "%03u", indexFromNewest + 1u); + GUI_DisplaySmallestInverse(label, 2, line, false, true, 14); +} + +static void RXTX_LOG_DrawSessionMarker(uint8_t line) +{ + const int16_t y = (int16_t)(line * 8u) + 3; + + UI_DrawLineBuffer(gFrameBuffer, 4, y, 123, y, true); +} + +void UI_DisplayRxTxLog(void) +{ + char duration[8]; + char title[16]; + RXTX_LogEntry_t entry; + + UI_DisplayClear(); + + if (gClearActive) { + UI_PrintStringSmallBold("NO LOG", 0, 127, 3); + ST7565_BlitFullScreen(); + return; + } + + RXTX_LOG_EnsureViewCache(); + + if (gLogFilter == RXTX_LOG_FILTER_ALL && !gLogHasTraffic) { + UI_PrintStringSmallBold("NO LOG", 0, 127, 3); + ST7565_BlitFullScreen(); + return; + } + + if (gViewCacheCount == 0) { + if (!gViewScanActive) + UI_PrintStringSmallBold("NO LOG", 0, 127, 3); + ST7565_BlitFullScreen(); + return; + } + + for (uint8_t row = 0; row < RXTX_LOG_VIEW_CACHE_COUNT; row++) { + const uint16_t index = gLogCursor + row; + + if (!RXTX_LOG_GetFilteredEntry(index, &entry)) + break; + + if (RXTX_LOG_IsSessionMarker(&entry)) { + RXTX_LOG_DrawSessionMarker(row); + continue; + } + + const bool isTx = RXTX_LOG_IsTx(&entry); + + RXTX_LOG_FormatTitle(&entry, title); + RXTX_LOG_DrawIndexBadge(index, row); + + if (isTx) + UI_PrintStringSmallBold(title, 17, 0, row); + else + UI_PrintStringSmallNormal(title, 17, 0, row); + + GUI_DisplaySmallest(isTx ? "TX" : "RX", 96, (uint8_t)((row * 8u) + 1u), false, true); + + sprintf(duration, "%02u:%02u", entry.durationSeconds / 60u, entry.durationSeconds % 60u); + GUI_DisplaySmallestInverse(duration, 107, row, false, true, 127); + } + + ST7565_BlitFullScreen(); +} + +#endif diff --git a/App/app/rxtx_log.h b/App/app/rxtx_log.h new file mode 100644 index 00000000..bbfcfe57 --- /dev/null +++ b/App/app/rxtx_log.h @@ -0,0 +1,49 @@ +/* Copyright 2026 + * + * Licensed under the Apache License, Version 2.0. + */ + +#ifndef APP_RXTX_LOG_H +#define APP_RXTX_LOG_H + +#include +#include + +#include "driver/keyboard.h" +#include "functions.h" +#include "radio.h" + +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + +#define RXTX_LOG_VISIBLE_COUNT 250 +#define RXTX_LOG_NAME_LEN 10 + +typedef enum { + RXTX_LOG_DIR_RX = 0, + RXTX_LOG_DIR_TX = 1, +} RXTX_LogDirection_t; + +typedef struct { + uint32_t frequency; + uint32_t sequence; + uint16_t durationSeconds; + uint16_t channel; + uint8_t flags; + char name[RXTX_LOG_NAME_LEN + 1]; +} RXTX_LogEntry_t; + +void RXTX_LOG_Init(void); +void RXTX_LOG_BeginRx(const VFO_Info_t *vfo, FUNCTION_Type_t function); +void RXTX_LOG_BeginTx(const VFO_Info_t *vfo); +void RXTX_LOG_EndActive(void); +void RXTX_LOG_Task10ms(void); +void RXTX_LOG_Tick500ms(void); +const char *RXTX_LOG_GetFilterName(void); + +void ACTION_RxTxLog(void); +void RXTX_LOG_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld); +void UI_DisplayRxTxLog(void); + +#endif + +#endif diff --git a/App/driver/eeprom_compat.c b/App/driver/eeprom_compat.c index b4cf7bf3..ea8139cc 100644 --- a/App/driver/eeprom_compat.c +++ b/App/driver/eeprom_compat.c @@ -72,6 +72,10 @@ static const AddrMapping_t ADDR_MAPPINGS[] = { // [0x00..0x07] 8-byte header (reserved) // [0x08..0x407] 128x64 monochrome bitmap, 1024 Bytes // ST7565-native: 8 pages * 128 columns, column-major LSB-top + +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + _MK_MAPPING(0x1E0000, 0x00D000, 0x015000), // RX/TX append-only log * 32 KB / 8 sectors +#endif }; static void AddrTranslate(uint16_t EEPROM_Addr, uint16_t Size, uint32_t *PY25Q16_Addr_out, uint16_t *Size_out, bool *End_out); diff --git a/App/functions.c b/App/functions.c index f4794f91..ecca939c 100644 --- a/App/functions.c +++ b/App/functions.c @@ -33,6 +33,9 @@ #include "frequencies.h" #include "functions.h" #include "helper/battery.h" +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + #include "app/rxtx_log.h" +#endif #include "misc.h" #include "radio.h" #include "settings.h" @@ -239,8 +242,33 @@ void FUNCTION_Select(FUNCTION_Type_t Function) const FUNCTION_Type_t PreviousFunction = gCurrentFunction; const bool bWasPowerSave = PreviousFunction == FUNCTION_POWER_SAVE; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + const bool previousWasActive = + PreviousFunction == FUNCTION_TRANSMIT || + PreviousFunction == FUNCTION_MONITOR || + PreviousFunction == FUNCTION_INCOMING || + PreviousFunction == FUNCTION_RECEIVE; + const bool previousWasTx = PreviousFunction == FUNCTION_TRANSMIT; + const bool nextIsActive = + Function == FUNCTION_TRANSMIT || + Function == FUNCTION_MONITOR || + Function == FUNCTION_INCOMING || + Function == FUNCTION_RECEIVE; + const bool nextIsTx = Function == FUNCTION_TRANSMIT; + + if (previousWasActive && + (!nextIsActive || + (previousWasTx != nextIsTx))) + RXTX_LOG_EndActive(); +#endif + gCurrentFunction = Function; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + if (nextIsActive && !nextIsTx && (!previousWasActive || previousWasTx)) + RXTX_LOG_BeginRx(gRxVfo, Function); +#endif + if (bWasPowerSave && Function != FUNCTION_POWER_SAVE) { BK4819_Conditional_RX_TurnOn_and_GPIO6_Enable(); gRxIdleMode = false; diff --git a/App/main.c b/App/main.c index d9ed489f..a80d20af 100644 --- a/App/main.c +++ b/App/main.c @@ -24,6 +24,9 @@ #include "audio.h" #include "board.h" +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + #include "app/rxtx_log.h" +#endif #include "misc.h" #include "radio.h" #include "settings.h" @@ -99,6 +102,10 @@ void Main(void) SETTINGS_InitEEPROM(); +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + RXTX_LOG_Init(); +#endif + #ifdef ENABLE_FEAT_F4HWN gDW = gEeprom.DUAL_WATCH; gCB = gEeprom.CROSS_BAND_RX_TX; diff --git a/App/radio.c b/App/radio.c index ddec33a0..d1cc3cd3 100644 --- a/App/radio.c +++ b/App/radio.c @@ -19,6 +19,9 @@ #include "am_fix.h" #include "app/dtmf.h" +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + #include "app/rxtx_log.h" +#endif #ifdef ENABLE_FMRADIO #include "app/fm.h" #endif @@ -1272,6 +1275,10 @@ void RADIO_PrepareTX(void) FUNCTION_Select(FUNCTION_TRANSMIT); +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + RXTX_LOG_BeginTx(gCurrentVfo); +#endif + gTxTimerCountdown_500ms = 0; // no timeout #if defined(ENABLE_ALARM) || defined(ENABLE_TX1750) diff --git a/App/settings.h b/App/settings.h index 07884bae..e5bff534 100644 --- a/App/settings.h +++ b/App/settings.h @@ -133,6 +133,9 @@ enum ACTION_OPT_t { #endif #ifdef ENABLE_FEAT_F4HWN_BEAM ACTION_OPT_BEAM, +#endif +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + ACTION_OPT_RXTX_LOG, #endif ACTION_OPT_LEN }; diff --git a/App/ui/menu.c b/App/ui/menu.c index 595f61bc..a1bcae5b 100644 --- a/App/ui/menu.c +++ b/App/ui/menu.c @@ -524,6 +524,9 @@ const t_sidefunction gSubMenu_SIDEFUNCTIONS[] = #ifdef ENABLE_FEAT_F4HWN_BEAM {"BEAM", ACTION_OPT_BEAM}, #endif + #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + {"RF LOG", ACTION_OPT_RXTX_LOG}, + #endif #endif }; diff --git a/App/ui/status.c b/App/ui/status.c index ff44bd0f..56a4d237 100644 --- a/App/ui/status.c +++ b/App/ui/status.c @@ -18,6 +18,9 @@ #include "app/app.h" #include "app/chFrScanner.h" +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG +#include "app/rxtx_log.h" +#endif #ifdef ENABLE_FMRADIO #include "app/fm.h" #endif @@ -66,9 +69,25 @@ void UI_DisplayStatus() char str[8] = ""; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + const bool isRxTxLogScreen = gScreenToDisplay == DISPLAY_RXTX_LOG; + if (isRxTxLogScreen) { + const char *filter = RXTX_LOG_GetFilterName(); + const uint8_t end = (filter[2] == 0) ? 10 : 14; + + GUI_DisplaySmallestInverse(filter, 2, 0, true, true, end); + x = 18; + x1 = x; + } +#endif + #if defined(ENABLE_FEAT_F4HWN_RX_TX_TIMER) && !defined(ENABLE_FEAT_F4HWN_DEBUG) bool isTransmit = gCurrentFunction == FUNCTION_TRANSMIT; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + if (!isRxTxLogScreen && gSetting_set_tmr && (isTransmit || FUNCTION_IsRx())) { +#else if (gSetting_set_tmr && (isTransmit || FUNCTION_IsRx())) { +#endif convertTime(line, !isTransmit); x += 39; } else { diff --git a/App/ui/ui.c b/App/ui/ui.c index 387a2000..33d3d9e4 100644 --- a/App/ui/ui.c +++ b/App/ui/ui.c @@ -33,6 +33,9 @@ #ifdef ENABLE_REGA #include "app/rega.h" #endif +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + #include "app/rxtx_log.h" +#endif #include "ui/inputbox.h" #include "ui/main.h" #include "ui/menu.h" @@ -61,6 +64,10 @@ void (*UI_DisplayFunctions[])(void) = { [DISPLAY_AIRCOPY] = &UI_DisplayAircopy, #endif +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + [DISPLAY_RXTX_LOG] = &UI_DisplayRxTxLog, +#endif + #ifdef ENABLE_REGA [DISPLAY_REGA] = &UI_DisplayREGA, #endif diff --git a/App/ui/ui.h b/App/ui/ui.h index 0f1fea42..c8d0f646 100644 --- a/App/ui/ui.h +++ b/App/ui/ui.h @@ -34,6 +34,10 @@ enum GUI_DisplayType_t DISPLAY_AIRCOPY, #endif +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + DISPLAY_RXTX_LOG, +#endif + #ifdef ENABLE_REGA DISPLAY_REGA, #endif diff --git a/CMakePresets.json b/CMakePresets.json index 1efcdbcf..27e29850 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -73,6 +73,7 @@ "ENABLE_FEAT_F4HWN_SCAN_RSSI": false, "ENABLE_FEAT_F4HWN_SCAN_SUBAUDIBLE": false, "ENABLE_FEAT_F4HWN_BEAM": false, + "ENABLE_FEAT_F4HWN_RXTX_LOG": false, "ENABLE_FEAT_F4HWN_LOGO": false, "ENABLE_FEAT_F4HWN_LOGO_SAV": false, "ENABLE_AGC_SHOW_DATA": false, @@ -204,6 +205,7 @@ "ENABLE_FEAT_F4HWN_SCAN_RSSI": true, "ENABLE_FEAT_F4HWN_SCAN_SUBAUDIBLE": true, "ENABLE_FEAT_F4HWN_BEAM": true, + "ENABLE_FEAT_F4HWN_RXTX_LOG": true, "ENABLE_FEAT_F4HWN_QRCODE": true, "ENABLE_FEAT_F4HWN_MEM": true, "ENABLE_FEAT_F4HWN_LOGO": true, From 9ab5542326de445e27312c60f6e580596a5bd3ef Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 5 Jul 2026 18:16:49 +0200 Subject: [PATCH 03/37] Fix duplicate session marker on repeated reboots without traffic --- App/app/rxtx_log.c | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index 39f624eb..e39ec3c9 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -536,6 +536,7 @@ void RXTX_LOG_Init(void) { uint32_t maxSequence = 0; uint32_t maxAddress = RXTX_LOG_FLASH_BASE; + uint8_t lastEntryFlags = 0; bool found = false; gLogCursor = 0; @@ -562,6 +563,7 @@ void RXTX_LOG_Init(void) found = true; maxSequence = flashEntry.sequence; maxAddress = address; + lastEntryFlags = flashEntry.flags; } } @@ -574,7 +576,10 @@ void RXTX_LOG_Init(void) gNextSequence = 0; } - RXTX_LOG_WriteSessionMarker(); + // Skip the marker if the log already ends with one (e.g. repeated + // reboots with no RX/TX in between) to avoid stacking empty separators. + if (!found || (lastEntryFlags & RXTX_LOG_FLAG_SESSION) == 0) + RXTX_LOG_WriteSessionMarker(); } void RXTX_LOG_BeginRx(const VFO_Info_t *vfo, FUNCTION_Type_t function) From f36c524fa7f73cd7ead24c52e51e3dc0fca10883 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 5 Jul 2026 18:39:40 +0200 Subject: [PATCH 04/37] Expose 512 entries in RX/TX log view instead of 250 --- App/app/rxtx_log.c | 2 +- App/app/rxtx_log.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index e39ec3c9..e24ec00a 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -27,7 +27,7 @@ #define RXTX_LOG_SLOT_COUNT (RXTX_LOG_FLASH_SIZE / sizeof(RXTX_LogFlashEntry_t)) #define RXTX_LOG_VIEW_CACHE_COUNT 7u #define RXTX_LOG_VIEW_SCAN_BUDGET 8u -#define RXTX_LOG_VIEW_ANCHOR_STRIDE 8u +#define RXTX_LOG_VIEW_ANCHOR_STRIDE 16u #define RXTX_LOG_VIEW_ANCHOR_COUNT ((RXTX_LOG_VISIBLE_COUNT + RXTX_LOG_VIEW_ANCHOR_STRIDE - 1u) / RXTX_LOG_VIEW_ANCHOR_STRIDE) #define RXTX_LOG_ENTRY_COMMIT 0xA5u #define RXTX_LOG_CHANNEL_NONE 0xFFFFu diff --git a/App/app/rxtx_log.h b/App/app/rxtx_log.h index bbfcfe57..15920c62 100644 --- a/App/app/rxtx_log.h +++ b/App/app/rxtx_log.h @@ -15,7 +15,7 @@ #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG -#define RXTX_LOG_VISIBLE_COUNT 250 +#define RXTX_LOG_VISIBLE_COUNT 512 #define RXTX_LOG_NAME_LEN 10 typedef enum { From f1df96f1f6c1fb6b4d2deb7a0900e593c2d04ecb Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 5 Jul 2026 18:44:32 +0200 Subject: [PATCH 05/37] Keep RX/TX log numbering continuous across sessions --- App/app/rxtx_log.c | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index e24ec00a..a18962eb 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -56,6 +56,7 @@ static_assert(RXTX_LOG_VIEW_ANCHOR_COUNT <= 32); static RXTX_LogEntry_t gViewCache[RXTX_LOG_VIEW_CACHE_COUNT]; static uint16_t gViewCacheStart; +static uint16_t gViewCacheTrafficIndex; static uint8_t gViewCacheCount; static uint8_t gViewCacheFilter; static bool gViewCacheHasOlder; @@ -65,7 +66,9 @@ static uint16_t gViewScanSlot; static uint16_t gViewScanScanned; static uint16_t gViewScanSkip; static uint16_t gViewScanIndex; +static uint16_t gViewScanTrafficIndex; static uint16_t gViewAnchorSlots[RXTX_LOG_VIEW_ANCHOR_COUNT]; +static uint16_t gViewAnchorTrafficIndexes[RXTX_LOG_VIEW_ANCHOR_COUNT]; static uint32_t gViewAnchorMask; static uint8_t gViewAnchorFilter; static bool gClearActive; @@ -211,7 +214,7 @@ static void RXTX_LOG_EnsureViewAnchors(void) gViewAnchorFilter = gLogFilter; } -static void RXTX_LOG_RecordViewAnchor(uint16_t indexFromNewest, uint16_t slot) +static void RXTX_LOG_RecordViewAnchor(uint16_t indexFromNewest, uint16_t slot, uint16_t trafficIndex) { if ((indexFromNewest % RXTX_LOG_VIEW_ANCHOR_STRIDE) != 0) return; @@ -221,10 +224,11 @@ static void RXTX_LOG_RecordViewAnchor(uint16_t indexFromNewest, uint16_t slot) return; gViewAnchorSlots[anchor] = slot; + gViewAnchorTrafficIndexes[anchor] = trafficIndex; gViewAnchorMask |= (uint32_t)(1u << anchor); } -static bool RXTX_LOG_FindViewAnchor(uint16_t indexFromNewest, uint16_t *anchorIndex, uint16_t *slot) +static bool RXTX_LOG_FindViewAnchor(uint16_t indexFromNewest, uint16_t *anchorIndex, uint16_t *slot, uint16_t *trafficIndex) { uint16_t anchor = indexFromNewest / RXTX_LOG_VIEW_ANCHOR_STRIDE; if (anchor >= RXTX_LOG_VIEW_ANCHOR_COUNT) @@ -234,6 +238,7 @@ static bool RXTX_LOG_FindViewAnchor(uint16_t indexFromNewest, uint16_t *anchorIn if ((gViewAnchorMask & (uint32_t)(1u << anchor)) != 0) { *anchorIndex = (uint16_t)(anchor * RXTX_LOG_VIEW_ANCHOR_STRIDE); *slot = gViewAnchorSlots[anchor]; + *trafficIndex = gViewAnchorTrafficIndexes[anchor]; return true; } } while (anchor-- > 0); @@ -320,11 +325,14 @@ static void RXTX_LOG_StepViewCacheScan(void) !RXTX_LOG_MatchesFlags(flashEntry.flags)) continue; - RXTX_LOG_RecordViewAnchor(gViewScanIndex, gViewScanSlot); + const bool isTraffic = RXTX_LOG_IsTrafficFlags(flashEntry.flags); + RXTX_LOG_RecordViewAnchor(gViewScanIndex, gViewScanSlot, gViewScanTrafficIndex); if (gViewScanSkip > 0) { gViewScanSkip--; gViewScanIndex++; + if (isTraffic) + gViewScanTrafficIndex++; continue; } @@ -336,8 +344,12 @@ static void RXTX_LOG_StepViewCacheScan(void) } RXTX_LOG_CopyFromFlash(&gViewCache[gViewCacheCount], &flashEntry); + if (gViewCacheCount == 0) + gViewCacheTrafficIndex = gViewScanTrafficIndex; gViewCacheCount++; gViewScanIndex++; + if (isTraffic) + gViewScanTrafficIndex++; if (gViewCacheCount >= RXTX_LOG_VIEW_CACHE_COUNT) { gViewCacheHasOlder = (uint16_t)(gViewCacheStart + gViewCacheCount) < RXTX_LOG_VISIBLE_COUNT; gViewScanActive = false; @@ -356,6 +368,7 @@ static void RXTX_LOG_StartViewCacheScan(uint16_t start) { uint16_t anchorIndex; uint16_t anchorSlot; + uint16_t anchorTrafficIndex; start = RXTX_LOG_PageStart(start); RXTX_LOG_EnsureViewAnchors(); @@ -372,14 +385,16 @@ static void RXTX_LOG_StartViewCacheScan(uint16_t start) return; } - if (RXTX_LOG_FindViewAnchor(start, &anchorIndex, &anchorSlot)) { + if (RXTX_LOG_FindViewAnchor(start, &anchorIndex, &anchorSlot, &anchorTrafficIndex)) { gViewScanSlot = RXTX_LOG_NextSlot(anchorSlot); gViewScanSkip = start - anchorIndex; gViewScanIndex = anchorIndex; + gViewScanTrafficIndex = anchorTrafficIndex; } else { gViewScanSlot = RXTX_LOG_AddressToSlot(gNextFlashAddress); gViewScanSkip = start; gViewScanIndex = 0; + gViewScanTrafficIndex = 0; } gViewScanScanned = 0; @@ -796,6 +811,7 @@ void UI_DisplayRxTxLog(void) return; } + uint16_t displayIndex = gViewCacheTrafficIndex; for (uint8_t row = 0; row < RXTX_LOG_VIEW_CACHE_COUNT; row++) { const uint16_t index = gLogCursor + row; @@ -810,7 +826,8 @@ void UI_DisplayRxTxLog(void) const bool isTx = RXTX_LOG_IsTx(&entry); RXTX_LOG_FormatTitle(&entry, title); - RXTX_LOG_DrawIndexBadge(index, row); + RXTX_LOG_DrawIndexBadge(displayIndex, row); + displayIndex++; if (isTx) UI_PrintStringSmallBold(title, 17, 0, row); From 9b8493b450157a5cc0246a9a01ef7b1b7d18a147 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 5 Jul 2026 19:16:35 +0200 Subject: [PATCH 06/37] Fix RX/TX log end-of-list scrolling --- App/app/rxtx_log.c | 75 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 56 insertions(+), 19 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index a18962eb..ce49118a 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -27,8 +27,8 @@ #define RXTX_LOG_SLOT_COUNT (RXTX_LOG_FLASH_SIZE / sizeof(RXTX_LogFlashEntry_t)) #define RXTX_LOG_VIEW_CACHE_COUNT 7u #define RXTX_LOG_VIEW_SCAN_BUDGET 8u -#define RXTX_LOG_VIEW_ANCHOR_STRIDE 16u -#define RXTX_LOG_VIEW_ANCHOR_COUNT ((RXTX_LOG_VISIBLE_COUNT + RXTX_LOG_VIEW_ANCHOR_STRIDE - 1u) / RXTX_LOG_VIEW_ANCHOR_STRIDE) +#define RXTX_LOG_VIEW_ANCHOR_STRIDE 32u +#define RXTX_LOG_VIEW_ANCHOR_COUNT ((RXTX_LOG_SLOT_COUNT + RXTX_LOG_VIEW_ANCHOR_STRIDE - 1u) / RXTX_LOG_VIEW_ANCHOR_STRIDE) #define RXTX_LOG_ENTRY_COMMIT 0xA5u #define RXTX_LOG_CHANNEL_NONE 0xFFFFu #define RXTX_LOG_FLAG_TX (1u << 0) @@ -199,6 +199,8 @@ static uint16_t RXTX_LOG_NextSlot(uint16_t slot) return (uint16_t)(slot + 1u) >= RXTX_LOG_SLOT_COUNT ? 0 : (uint16_t)(slot + 1u); } +static void RXTX_LOG_StartViewCacheScan(uint16_t start); + static void RXTX_LOG_InvalidateViewAnchors(void) { gViewAnchorMask = 0; @@ -304,12 +306,37 @@ static void RXTX_LOG_StepClear(void) static uint16_t RXTX_LOG_PageStart(uint16_t indexFromNewest) { - if (indexFromNewest >= RXTX_LOG_VISIBLE_COUNT) - indexFromNewest = RXTX_LOG_VISIBLE_COUNT - 1u; + if (indexFromNewest >= RXTX_LOG_SLOT_COUNT) + indexFromNewest = RXTX_LOG_SLOT_COUNT - 1u; return indexFromNewest; } +static bool RXTX_LOG_AlignLastViewPage(void) +{ + if (gViewScanActive || + !gViewCacheComplete || + gViewCacheHasOlder || + gViewCacheCount == 0) + return false; + + if (gViewCacheCount >= RXTX_LOG_VIEW_CACHE_COUNT) { + if (gLogCursor == gViewCacheStart) + return false; + gLogCursor = gViewCacheStart; + return true; + } + + const uint16_t missingRows = RXTX_LOG_VIEW_CACHE_COUNT - gViewCacheCount; + const uint16_t start = gViewCacheStart > missingRows ? (uint16_t)(gViewCacheStart - missingRows) : 0; + if (start == gViewCacheStart) + return false; + + gLogCursor = start; + RXTX_LOG_StartViewCacheScan(start); + return true; +} + static void RXTX_LOG_StepViewCacheScan(void) { uint8_t budget = RXTX_LOG_VIEW_SCAN_BUDGET; @@ -325,6 +352,12 @@ static void RXTX_LOG_StepViewCacheScan(void) !RXTX_LOG_MatchesFlags(flashEntry.flags)) continue; + if (gViewScanTrafficIndex >= RXTX_LOG_VISIBLE_COUNT) { + gViewScanActive = false; + gViewCacheComplete = true; + break; + } + const bool isTraffic = RXTX_LOG_IsTrafficFlags(flashEntry.flags); RXTX_LOG_RecordViewAnchor(gViewScanIndex, gViewScanSlot, gViewScanTrafficIndex); @@ -336,32 +369,31 @@ static void RXTX_LOG_StepViewCacheScan(void) continue; } - if (gViewCacheCount >= RXTX_LOG_VIEW_CACHE_COUNT) { + if (gViewCacheCount < RXTX_LOG_VIEW_CACHE_COUNT) { + RXTX_LOG_CopyFromFlash(&gViewCache[gViewCacheCount], &flashEntry); + if (gViewCacheCount == 0) + gViewCacheTrafficIndex = gViewScanTrafficIndex; + gViewCacheCount++; + } else { gViewCacheHasOlder = true; gViewScanActive = false; gViewCacheComplete = true; break; } - RXTX_LOG_CopyFromFlash(&gViewCache[gViewCacheCount], &flashEntry); - if (gViewCacheCount == 0) - gViewCacheTrafficIndex = gViewScanTrafficIndex; - gViewCacheCount++; gViewScanIndex++; if (isTraffic) gViewScanTrafficIndex++; - if (gViewCacheCount >= RXTX_LOG_VIEW_CACHE_COUNT) { - gViewCacheHasOlder = (uint16_t)(gViewCacheStart + gViewCacheCount) < RXTX_LOG_VISIBLE_COUNT; - gViewScanActive = false; - gViewCacheComplete = true; - break; - } } - if (gViewScanScanned >= RXTX_LOG_SLOT_COUNT) { + if (gViewScanScanned >= RXTX_LOG_SLOT_COUNT || + gViewScanTrafficIndex >= RXTX_LOG_VISIBLE_COUNT) { gViewScanActive = false; gViewCacheComplete = true; } + + if (RXTX_LOG_AlignLastViewPage()) + return; } static void RXTX_LOG_StartViewCacheScan(uint16_t start) @@ -509,7 +541,7 @@ static void RXTX_LOG_EnsureViewCache(void) static bool RXTX_LOG_GetFilteredEntry(uint16_t indexFromNewest, RXTX_LogEntry_t *entry) { - if (indexFromNewest >= RXTX_LOG_VISIBLE_COUNT) + if (indexFromNewest >= RXTX_LOG_SLOT_COUNT) return false; if (!RXTX_LOG_ViewCacheCovers(indexFromNewest)) @@ -707,11 +739,16 @@ void RXTX_LOG_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) case KEY_DOWN: RXTX_LOG_EnsureViewCache(); if (gViewCacheCount > 0) { + if (gViewScanActive) + break; + + if (gViewCacheComplete && !gViewCacheHasOlder) + break; + const uint16_t next = gLogCursor + 1u; if (next < (uint16_t)(gViewCacheStart + gViewCacheCount)) { gLogCursor = next; - } else if (gViewCacheHasOlder && - (uint16_t)(gViewCacheStart + gViewCacheCount) < RXTX_LOG_VISIBLE_COUNT) { + } else if (gViewCacheHasOlder) { gLogCursor = gViewCacheStart + gViewCacheCount; RXTX_LOG_StartViewCacheScan(gLogCursor); } From 5fcc1598dd9647dafaeae2fb022736fe19fd5d6e Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 5 Jul 2026 20:02:35 +0200 Subject: [PATCH 07/37] Fix RX/TX log wrap upward before total is known --- App/app/rxtx_log.c | 165 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 142 insertions(+), 23 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index ce49118a..23296b19 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -61,7 +61,10 @@ static uint8_t gViewCacheCount; static uint8_t gViewCacheFilter; static bool gViewCacheHasOlder; static bool gViewCacheComplete; +static bool gViewCacheCircular; static bool gViewScanActive; +static bool gViewScanDiscoverTotal; +static bool gViewScanWrapped; static uint16_t gViewScanSlot; static uint16_t gViewScanScanned; static uint16_t gViewScanSkip; @@ -71,6 +74,10 @@ static uint16_t gViewAnchorSlots[RXTX_LOG_VIEW_ANCHOR_COUNT]; static uint16_t gViewAnchorTrafficIndexes[RXTX_LOG_VIEW_ANCHOR_COUNT]; static uint32_t gViewAnchorMask; static uint8_t gViewAnchorFilter; +static bool gViewTotalKnown; +static bool gViewWrapPending; +static uint16_t gViewTotalRows; +static uint16_t gViewTotalTraffic; static bool gClearActive; static uint8_t gClearSector; static bool gMenuClearHandled; @@ -113,6 +120,18 @@ static bool RXTX_LOG_IsValidFlashEntry(const RXTX_LogFlashEntry_t *entry) return entry->crc == RXTX_LOG_Crc8(entry, sizeof(*entry) - 2); } +static bool RXTX_LOG_IsBlankFlashEntry(const RXTX_LogFlashEntry_t *entry) +{ + const uint8_t *p = (const uint8_t *)entry; + + for (uint8_t i = 0; i < sizeof(*entry); i++) { + if (p[i] != 0xFFu) + return false; + } + + return true; +} + static bool RXTX_LOG_IsTx(const RXTX_LogEntry_t *entry) { return (entry->flags & RXTX_LOG_FLAG_TX) != 0; @@ -199,7 +218,19 @@ static uint16_t RXTX_LOG_NextSlot(uint16_t slot) return (uint16_t)(slot + 1u) >= RXTX_LOG_SLOT_COUNT ? 0 : (uint16_t)(slot + 1u); } -static void RXTX_LOG_StartViewCacheScan(uint16_t start); +static void RXTX_LOG_StartViewCacheScan(uint16_t start, bool circular, bool discoverTotal); + +static void RXTX_LOG_StartCursorView(uint16_t cursor) +{ + gViewWrapPending = false; + gLogCursor = cursor; + RXTX_LOG_StartViewCacheScan(cursor, + gViewTotalKnown && + gViewTotalRows > 0 && + cursor < gViewTotalRows && + (uint16_t)(cursor + RXTX_LOG_VIEW_CACHE_COUNT) > gViewTotalRows, + false); +} static void RXTX_LOG_InvalidateViewAnchors(void) { @@ -255,7 +286,12 @@ static void RXTX_LOG_InvalidateViewCache(void) gViewCacheFilter = 0xFFu; gViewCacheHasOlder = false; gViewCacheComplete = false; + gViewCacheCircular = false; + gViewTotalKnown = false; + gViewWrapPending = false; gViewScanActive = false; + gViewScanDiscoverTotal = false; + gViewScanWrapped = false; RXTX_LOG_InvalidateViewAnchors(); } @@ -333,7 +369,25 @@ static bool RXTX_LOG_AlignLastViewPage(void) return false; gLogCursor = start; - RXTX_LOG_StartViewCacheScan(start); + RXTX_LOG_StartViewCacheScan(start, false, false); + return true; +} + +static bool RXTX_LOG_TryWrapViewCacheScan(void) +{ + if (!gViewCacheCircular || + gViewScanWrapped || + !gViewTotalKnown || + gViewCacheCount >= RXTX_LOG_VIEW_CACHE_COUNT || + gViewScanIndex < gViewTotalRows) + return false; + + gViewScanWrapped = true; + gViewScanSlot = RXTX_LOG_AddressToSlot(gNextFlashAddress); + gViewScanScanned = 0; + gViewScanSkip = 0; + gViewScanIndex = 0; + gViewScanTrafficIndex = 0; return true; } @@ -348,12 +402,18 @@ static void RXTX_LOG_StepViewCacheScan(void) gViewScanScanned++; PY25Q16_ReadBuffer(RXTX_LOG_SlotToAddress(gViewScanSlot), &flashEntry, sizeof(flashEntry)); + if (RXTX_LOG_IsBlankFlashEntry(&flashEntry)) { + gViewScanScanned = RXTX_LOG_SLOT_COUNT; + break; + } + if (!RXTX_LOG_IsValidFlashEntry(&flashEntry) || !RXTX_LOG_MatchesFlags(flashEntry.flags)) continue; if (gViewScanTrafficIndex >= RXTX_LOG_VISIBLE_COUNT) { gViewScanActive = false; + gViewScanDiscoverTotal = false; gViewCacheComplete = true; break; } @@ -376,27 +436,50 @@ static void RXTX_LOG_StepViewCacheScan(void) gViewCacheCount++; } else { gViewCacheHasOlder = true; - gViewScanActive = false; - gViewCacheComplete = true; - break; + if (!gViewScanDiscoverTotal) { + gViewScanActive = false; + gViewScanDiscoverTotal = false; + gViewCacheComplete = true; + break; + } } gViewScanIndex++; if (isTraffic) gViewScanTrafficIndex++; + if (RXTX_LOG_TryWrapViewCacheScan()) + continue; } if (gViewScanScanned >= RXTX_LOG_SLOT_COUNT || gViewScanTrafficIndex >= RXTX_LOG_VISIBLE_COUNT) { + if (!gViewScanWrapped) { + gViewTotalRows = gViewScanIndex; + gViewTotalTraffic = gViewScanTrafficIndex; + gViewTotalKnown = gViewTotalRows > 0; + } + + if (gViewWrapPending) { + gViewWrapPending = false; + if (gViewTotalRows > 1u) { + RXTX_LOG_StartCursorView(gViewTotalRows - 1u); + return; + } + } + + if (RXTX_LOG_TryWrapViewCacheScan()) + return; + gViewScanActive = false; + gViewScanDiscoverTotal = false; gViewCacheComplete = true; } - if (RXTX_LOG_AlignLastViewPage()) + if (!gViewCacheCircular && RXTX_LOG_AlignLastViewPage()) return; } -static void RXTX_LOG_StartViewCacheScan(uint16_t start) +static void RXTX_LOG_StartViewCacheScan(uint16_t start, bool circular, bool discoverTotal) { uint16_t anchorIndex; uint16_t anchorSlot; @@ -410,9 +493,14 @@ static void RXTX_LOG_StartViewCacheScan(uint16_t start) gViewCacheFilter = gLogFilter; gViewCacheHasOlder = false; gViewCacheComplete = false; - gViewScanActive = false; + gViewCacheCircular = circular; + gViewScanActive = false; + gViewScanDiscoverTotal = discoverTotal; + gViewScanWrapped = false; if (gNextSequence == 0) { + gViewWrapPending = false; + gViewScanDiscoverTotal = false; gViewCacheComplete = true; return; } @@ -433,6 +521,21 @@ static void RXTX_LOG_StartViewCacheScan(uint16_t start) gViewScanActive = true; } +static void RXTX_LOG_RequestWrapToLast(void) +{ + gViewWrapPending = true; + + if (gViewScanActive && + gViewCacheFilter == gLogFilter && + gViewCacheStart == 0 && + !gViewCacheCircular) { + gViewScanDiscoverTotal = true; + return; + } + + RXTX_LOG_StartViewCacheScan(0, false, true); +} + static bool RXTX_LOG_ViewCacheCovers(uint16_t indexFromNewest) { return gViewCacheFilter == gLogFilter && @@ -446,13 +549,7 @@ static bool RXTX_LOG_SlotIsBlank(uint32_t address) PY25Q16_ReadBuffer(address, &entry, sizeof(entry)); - const uint8_t *p = (const uint8_t *)&entry; - for (uint8_t i = 0; i < sizeof(entry); i++) { - if (p[i] != 0xFFu) - return false; - } - - return true; + return RXTX_LOG_IsBlankFlashEntry(&entry); } static void RXTX_LOG_PrepareNextSlot(void) @@ -536,7 +633,7 @@ static void RXTX_LOG_EnsureViewCache(void) gViewCacheStart == pageStart) return; - RXTX_LOG_StartViewCacheScan(gLogCursor); + RXTX_LOG_StartCursorView(gLogCursor); } static bool RXTX_LOG_GetFilteredEntry(uint16_t indexFromNewest, RXTX_LogEntry_t *entry) @@ -730,18 +827,31 @@ void RXTX_LOG_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) switch (Key) { case KEY_UP: if (gLogCursor > 0) { - gLogCursor--; - RXTX_LOG_EnsureViewCache(); + RXTX_LOG_StartCursorView(gLogCursor - 1u); + } else if (gViewTotalKnown && gViewTotalRows > 1u) { + RXTX_LOG_StartCursorView(gViewTotalRows - 1u); + } else { + RXTX_LOG_RequestWrapToLast(); } gUpdateDisplay = true; break; case KEY_DOWN: + gViewWrapPending = false; RXTX_LOG_EnsureViewCache(); if (gViewCacheCount > 0) { if (gViewScanActive) break; + if (gViewTotalKnown && gViewTotalRows > 1u) { + uint16_t next = gLogCursor + 1u; + if (next >= gViewTotalRows) + next = 0; + RXTX_LOG_StartCursorView(next); + gUpdateDisplay = true; + break; + } + if (gViewCacheComplete && !gViewCacheHasOlder) break; @@ -750,7 +860,7 @@ void RXTX_LOG_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) gLogCursor = next; } else if (gViewCacheHasOlder) { gLogCursor = gViewCacheStart + gViewCacheCount; - RXTX_LOG_StartViewCacheScan(gLogCursor); + RXTX_LOG_StartCursorView(gLogCursor); } } gUpdateDisplay = true; @@ -850,10 +960,15 @@ void UI_DisplayRxTxLog(void) uint16_t displayIndex = gViewCacheTrafficIndex; for (uint8_t row = 0; row < RXTX_LOG_VIEW_CACHE_COUNT; row++) { - const uint16_t index = gLogCursor + row; - - if (!RXTX_LOG_GetFilteredEntry(index, &entry)) - break; + if (gViewCacheCircular) { + if (row >= gViewCacheCount) + break; + entry = gViewCache[row]; + } else { + const uint16_t index = gLogCursor + row; + if (!RXTX_LOG_GetFilteredEntry(index, &entry)) + break; + } if (RXTX_LOG_IsSessionMarker(&entry)) { RXTX_LOG_DrawSessionMarker(row); @@ -863,6 +978,10 @@ void UI_DisplayRxTxLog(void) const bool isTx = RXTX_LOG_IsTx(&entry); RXTX_LOG_FormatTitle(&entry, title); + if (gViewCacheCircular && + gViewTotalTraffic > 0 && + displayIndex >= gViewTotalTraffic) + displayIndex = 0; RXTX_LOG_DrawIndexBadge(displayIndex, row); displayIndex++; From ff096e12e568548b272a83215400a701e89d8e70 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 5 Jul 2026 21:01:31 +0200 Subject: [PATCH 08/37] Simplify RX/TX log badge numbering and dedupe repeated reset/stop code --- App/app/rxtx_log.c | 116 ++++++++++++++++++++++----------------------- App/app/rxtx_log.h | 1 + 2 files changed, 58 insertions(+), 59 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index 23296b19..c72c7851 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -42,11 +42,12 @@ typedef struct __attribute__((packed)) { uint32_t sequence; uint32_t frequency; + uint32_t trafficSeq; uint16_t durationSeconds; uint16_t channel; uint8_t flags; char name[RXTX_LOG_NAME_LEN]; - uint8_t reserved[7]; + uint8_t reserved[3]; uint8_t crc; uint8_t commit; } RXTX_LogFlashEntry_t; @@ -56,7 +57,6 @@ static_assert(RXTX_LOG_VIEW_ANCHOR_COUNT <= 32); static RXTX_LogEntry_t gViewCache[RXTX_LOG_VIEW_CACHE_COUNT]; static uint16_t gViewCacheStart; -static uint16_t gViewCacheTrafficIndex; static uint8_t gViewCacheCount; static uint8_t gViewCacheFilter; static bool gViewCacheHasOlder; @@ -69,20 +69,18 @@ static uint16_t gViewScanSlot; static uint16_t gViewScanScanned; static uint16_t gViewScanSkip; static uint16_t gViewScanIndex; -static uint16_t gViewScanTrafficIndex; static uint16_t gViewAnchorSlots[RXTX_LOG_VIEW_ANCHOR_COUNT]; -static uint16_t gViewAnchorTrafficIndexes[RXTX_LOG_VIEW_ANCHOR_COUNT]; static uint32_t gViewAnchorMask; static uint8_t gViewAnchorFilter; static bool gViewTotalKnown; static bool gViewWrapPending; static uint16_t gViewTotalRows; -static uint16_t gViewTotalTraffic; static bool gClearActive; static uint8_t gClearSector; static bool gMenuClearHandled; static bool gLogHasTraffic; static uint32_t gNextSequence; +static uint32_t gNextTrafficSequence; static uint32_t gNextFlashAddress; static bool gSessionActive; @@ -191,6 +189,7 @@ static void RXTX_LOG_CopyFromFlash(RXTX_LogEntry_t *dst, const RXTX_LogFlashEntr { dst->sequence = src->sequence; dst->frequency = src->frequency; + dst->trafficSeq = src->trafficSeq; dst->durationSeconds = src->durationSeconds; dst->channel = src->channel; dst->flags = src->flags; @@ -247,7 +246,7 @@ static void RXTX_LOG_EnsureViewAnchors(void) gViewAnchorFilter = gLogFilter; } -static void RXTX_LOG_RecordViewAnchor(uint16_t indexFromNewest, uint16_t slot, uint16_t trafficIndex) +static void RXTX_LOG_RecordViewAnchor(uint16_t indexFromNewest, uint16_t slot) { if ((indexFromNewest % RXTX_LOG_VIEW_ANCHOR_STRIDE) != 0) return; @@ -257,11 +256,10 @@ static void RXTX_LOG_RecordViewAnchor(uint16_t indexFromNewest, uint16_t slot, u return; gViewAnchorSlots[anchor] = slot; - gViewAnchorTrafficIndexes[anchor] = trafficIndex; gViewAnchorMask |= (uint32_t)(1u << anchor); } -static bool RXTX_LOG_FindViewAnchor(uint16_t indexFromNewest, uint16_t *anchorIndex, uint16_t *slot, uint16_t *trafficIndex) +static bool RXTX_LOG_FindViewAnchor(uint16_t indexFromNewest, uint16_t *anchorIndex, uint16_t *slot) { uint16_t anchor = indexFromNewest / RXTX_LOG_VIEW_ANCHOR_STRIDE; if (anchor >= RXTX_LOG_VIEW_ANCHOR_COUNT) @@ -271,7 +269,6 @@ static bool RXTX_LOG_FindViewAnchor(uint16_t indexFromNewest, uint16_t *anchorIn if ((gViewAnchorMask & (uint32_t)(1u << anchor)) != 0) { *anchorIndex = (uint16_t)(anchor * RXTX_LOG_VIEW_ANCHOR_STRIDE); *slot = gViewAnchorSlots[anchor]; - *trafficIndex = gViewAnchorTrafficIndexes[anchor]; return true; } } while (anchor-- > 0); @@ -307,19 +304,25 @@ static void RXTX_LOG_NextFilter(void) gUpdateDisplay = true; } +static void RXTX_LOG_ResetLogCounters(void) +{ + gClearSector = 0; + gLogHasTraffic = false; + gNextSequence = 0; + gNextTrafficSequence = 0; + gNextFlashAddress = RXTX_LOG_FLASH_BASE; + RXTX_LOG_InvalidateViewCache(); +} + static void RXTX_LOG_StartClear(void) { if (gClearActive) return; - gClearActive = true; - gClearSector = 0; - gSessionActive = false; - gLogCursor = 0; - gLogHasTraffic = false; - gNextSequence = 0; - gNextFlashAddress = RXTX_LOG_FLASH_BASE; - RXTX_LOG_InvalidateViewCache(); + gClearActive = true; + gSessionActive = false; + gLogCursor = 0; + RXTX_LOG_ResetLogCounters(); } static void RXTX_LOG_StepClear(void) @@ -331,12 +334,8 @@ static void RXTX_LOG_StepClear(void) gClearSector++; if (gClearSector >= RXTX_LOG_FLASH_SECTOR_COUNT) { - gClearActive = false; - gClearSector = 0; - gLogHasTraffic = false; - gNextSequence = 0; - gNextFlashAddress = RXTX_LOG_FLASH_BASE; - RXTX_LOG_InvalidateViewCache(); + gClearActive = false; + RXTX_LOG_ResetLogCounters(); } } @@ -373,6 +372,13 @@ static bool RXTX_LOG_AlignLastViewPage(void) return true; } +static void RXTX_LOG_StopViewScan(void) +{ + gViewScanActive = false; + gViewScanDiscoverTotal = false; + gViewCacheComplete = true; +} + static bool RXTX_LOG_TryWrapViewCacheScan(void) { if (!gViewCacheCircular || @@ -387,13 +393,13 @@ static bool RXTX_LOG_TryWrapViewCacheScan(void) gViewScanScanned = 0; gViewScanSkip = 0; gViewScanIndex = 0; - gViewScanTrafficIndex = 0; return true; } static void RXTX_LOG_StepViewCacheScan(void) { uint8_t budget = RXTX_LOG_VIEW_SCAN_BUDGET; + bool capReached = false; while (gViewScanActive && budget-- > 0 && gViewScanScanned < RXTX_LOG_SLOT_COUNT) { RXTX_LogFlashEntry_t flashEntry; @@ -411,52 +417,44 @@ static void RXTX_LOG_StepViewCacheScan(void) !RXTX_LOG_MatchesFlags(flashEntry.flags)) continue; - if (gViewScanTrafficIndex >= RXTX_LOG_VISIBLE_COUNT) { - gViewScanActive = false; - gViewScanDiscoverTotal = false; - gViewCacheComplete = true; + // Traffic entries carry their own absolute rank (trafficSeq); the + // distance from the newest one bounds how far back browsing goes, + // no running counter needed to reconstruct it during the scan. + if (RXTX_LOG_IsTrafficFlags(flashEntry.flags) && + (gNextTrafficSequence - 1u - flashEntry.trafficSeq) >= RXTX_LOG_VISIBLE_COUNT) { + capReached = true; + RXTX_LOG_StopViewScan(); break; } - const bool isTraffic = RXTX_LOG_IsTrafficFlags(flashEntry.flags); - RXTX_LOG_RecordViewAnchor(gViewScanIndex, gViewScanSlot, gViewScanTrafficIndex); + RXTX_LOG_RecordViewAnchor(gViewScanIndex, gViewScanSlot); if (gViewScanSkip > 0) { gViewScanSkip--; gViewScanIndex++; - if (isTraffic) - gViewScanTrafficIndex++; continue; } if (gViewCacheCount < RXTX_LOG_VIEW_CACHE_COUNT) { RXTX_LOG_CopyFromFlash(&gViewCache[gViewCacheCount], &flashEntry); - if (gViewCacheCount == 0) - gViewCacheTrafficIndex = gViewScanTrafficIndex; gViewCacheCount++; } else { gViewCacheHasOlder = true; if (!gViewScanDiscoverTotal) { - gViewScanActive = false; - gViewScanDiscoverTotal = false; - gViewCacheComplete = true; + RXTX_LOG_StopViewScan(); break; } } gViewScanIndex++; - if (isTraffic) - gViewScanTrafficIndex++; if (RXTX_LOG_TryWrapViewCacheScan()) continue; } - if (gViewScanScanned >= RXTX_LOG_SLOT_COUNT || - gViewScanTrafficIndex >= RXTX_LOG_VISIBLE_COUNT) { + if (gViewScanScanned >= RXTX_LOG_SLOT_COUNT || capReached) { if (!gViewScanWrapped) { - gViewTotalRows = gViewScanIndex; - gViewTotalTraffic = gViewScanTrafficIndex; - gViewTotalKnown = gViewTotalRows > 0; + gViewTotalRows = gViewScanIndex; + gViewTotalKnown = gViewTotalRows > 0; } if (gViewWrapPending) { @@ -470,9 +468,7 @@ static void RXTX_LOG_StepViewCacheScan(void) if (RXTX_LOG_TryWrapViewCacheScan()) return; - gViewScanActive = false; - gViewScanDiscoverTotal = false; - gViewCacheComplete = true; + RXTX_LOG_StopViewScan(); } if (!gViewCacheCircular && RXTX_LOG_AlignLastViewPage()) @@ -483,7 +479,6 @@ static void RXTX_LOG_StartViewCacheScan(uint16_t start, bool circular, bool disc { uint16_t anchorIndex; uint16_t anchorSlot; - uint16_t anchorTrafficIndex; start = RXTX_LOG_PageStart(start); RXTX_LOG_EnsureViewAnchors(); @@ -505,16 +500,14 @@ static void RXTX_LOG_StartViewCacheScan(uint16_t start, bool circular, bool disc return; } - if (RXTX_LOG_FindViewAnchor(start, &anchorIndex, &anchorSlot, &anchorTrafficIndex)) { + if (RXTX_LOG_FindViewAnchor(start, &anchorIndex, &anchorSlot)) { gViewScanSlot = RXTX_LOG_NextSlot(anchorSlot); gViewScanSkip = start - anchorIndex; gViewScanIndex = anchorIndex; - gViewScanTrafficIndex = anchorTrafficIndex; } else { gViewScanSlot = RXTX_LOG_AddressToSlot(gNextFlashAddress); gViewScanSkip = start; gViewScanIndex = 0; - gViewScanTrafficIndex = 0; } gViewScanScanned = 0; @@ -586,6 +579,7 @@ static uint32_t RXTX_LOG_WriteEntry(const RXTX_LogEntry_t *src) memset(&entry, 0xFF, sizeof(entry)); entry.sequence = src->sequence; entry.frequency = src->frequency; + entry.trafficSeq = src->trafficSeq; entry.durationSeconds = src->durationSeconds; entry.channel = src->channel; entry.flags = src->flags; @@ -680,8 +674,10 @@ void RXTX_LOG_Init(void) { uint32_t maxSequence = 0; uint32_t maxAddress = RXTX_LOG_FLASH_BASE; + uint32_t maxTrafficSeq = 0; uint8_t lastEntryFlags = 0; bool found = false; + bool foundTraffic = false; gLogCursor = 0; gLogFilter = RXTX_LOG_FILTER_ALL; @@ -700,8 +696,13 @@ void RXTX_LOG_Init(void) if (!RXTX_LOG_IsValidFlashEntry(&flashEntry)) continue; - if (RXTX_LOG_IsTrafficFlags(flashEntry.flags)) + if (RXTX_LOG_IsTrafficFlags(flashEntry.flags)) { gLogHasTraffic = true; + if (!foundTraffic || flashEntry.trafficSeq > maxTrafficSeq) { + foundTraffic = true; + maxTrafficSeq = flashEntry.trafficSeq; + } + } if (!found || flashEntry.sequence > maxSequence) { found = true; @@ -720,6 +721,8 @@ void RXTX_LOG_Init(void) gNextSequence = 0; } + gNextTrafficSequence = foundTraffic ? maxTrafficSeq + 1u : 0; + // Skip the marker if the log already ends with one (e.g. repeated // reboots with no RX/TX in between) to avoid stacking empty separators. if (!found || (lastEntryFlags & RXTX_LOG_FLAG_SESSION) == 0) @@ -760,6 +763,7 @@ void RXTX_LOG_EndActive(void) memset(&entry, 0, sizeof(entry)); entry.sequence = gNextSequence++; + entry.trafficSeq = gNextTrafficSequence++; entry.frequency = gSessionFrequency; entry.durationSeconds = (gSessionTicks500ms + 1u) / 2u; entry.channel = gSessionChannel; @@ -958,7 +962,6 @@ void UI_DisplayRxTxLog(void) return; } - uint16_t displayIndex = gViewCacheTrafficIndex; for (uint8_t row = 0; row < RXTX_LOG_VIEW_CACHE_COUNT; row++) { if (gViewCacheCircular) { if (row >= gViewCacheCount) @@ -978,12 +981,7 @@ void UI_DisplayRxTxLog(void) const bool isTx = RXTX_LOG_IsTx(&entry); RXTX_LOG_FormatTitle(&entry, title); - if (gViewCacheCircular && - gViewTotalTraffic > 0 && - displayIndex >= gViewTotalTraffic) - displayIndex = 0; - RXTX_LOG_DrawIndexBadge(displayIndex, row); - displayIndex++; + RXTX_LOG_DrawIndexBadge((uint16_t)(gNextTrafficSequence - 1u - entry.trafficSeq), row); if (isTx) UI_PrintStringSmallBold(title, 17, 0, row); diff --git a/App/app/rxtx_log.h b/App/app/rxtx_log.h index 15920c62..20b3a1d4 100644 --- a/App/app/rxtx_log.h +++ b/App/app/rxtx_log.h @@ -26,6 +26,7 @@ typedef enum { typedef struct { uint32_t frequency; uint32_t sequence; + uint32_t trafficSeq; uint16_t durationSeconds; uint16_t channel; uint8_t flags; From c24592cbe805272d15f014554741a2e47f3d9058 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 5 Jul 2026 22:33:33 +0200 Subject: [PATCH 09/37] Factor RX/TX log empty screen rendering --- App/app/rxtx_log.c | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index c72c7851..2b27b7a8 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -933,6 +933,13 @@ static void RXTX_LOG_DrawSessionMarker(uint8_t line) UI_DrawLineBuffer(gFrameBuffer, 4, y, 123, y, true); } +static void RXTX_LOG_ShowEmpty(bool showMessage) +{ + if (showMessage) + UI_PrintStringSmallBold("NO LOG", 0, 127, 3); + ST7565_BlitFullScreen(); +} + void UI_DisplayRxTxLog(void) { char duration[8]; @@ -942,23 +949,19 @@ void UI_DisplayRxTxLog(void) UI_DisplayClear(); if (gClearActive) { - UI_PrintStringSmallBold("NO LOG", 0, 127, 3); - ST7565_BlitFullScreen(); + RXTX_LOG_ShowEmpty(true); return; } RXTX_LOG_EnsureViewCache(); if (gLogFilter == RXTX_LOG_FILTER_ALL && !gLogHasTraffic) { - UI_PrintStringSmallBold("NO LOG", 0, 127, 3); - ST7565_BlitFullScreen(); + RXTX_LOG_ShowEmpty(true); return; } if (gViewCacheCount == 0) { - if (!gViewScanActive) - UI_PrintStringSmallBold("NO LOG", 0, 127, 3); - ST7565_BlitFullScreen(); + RXTX_LOG_ShowEmpty(!gViewScanActive); return; } From c02aac265acebb37c12069fe9feba3579c9223a9 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 5 Jul 2026 22:38:36 +0200 Subject: [PATCH 10/37] Remove unused KEY_STAR --- App/app/rxtx_log.c | 6 ------ 1 file changed, 6 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index 2b27b7a8..23dc7fe3 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -870,12 +870,6 @@ void RXTX_LOG_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) gUpdateDisplay = true; break; - case KEY_STAR: - if (!bKeyHeld && bKeyPressed) { - RXTX_LOG_NextFilter(); - } - break; - case KEY_MENU: if (bKeyHeld) { if (bKeyPressed && !gMenuClearHandled) { From a14127867eb0ab6ab4e9930ffb0452f73d451ada Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 5 Jul 2026 22:48:40 +0200 Subject: [PATCH 11/37] Remove redundant name sanitization already done by SETTINGS_FetchChannelName --- App/app/rxtx_log.c | 27 ++++++--------------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index 23dc7fe3..5f68ff1e 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -168,23 +168,6 @@ const char *RXTX_LOG_GetFilterName(void) return "ALL"; } -static void RXTX_LOG_SanitizeName(char *dst, const char *src) -{ - uint8_t i; - - for (i = 0; i < RXTX_LOG_NAME_LEN; i++) { - char c = src ? src[i] : 0; - if (c < 32 || c > 126) - break; - dst[i] = c; - } - - while (i > 0 && dst[i - 1] == ' ') - i--; - - dst[i] = 0; -} - static void RXTX_LOG_CopyFromFlash(RXTX_LogEntry_t *dst, const RXTX_LogFlashEntry_t *src) { dst->sequence = src->sequence; @@ -650,9 +633,6 @@ static void RXTX_LOG_CaptureSession(uint8_t flags, const VFO_Info_t *vfo) const uint32_t frequency = (flags & RXTX_LOG_FLAG_TX) ? vfo->pTX->Frequency : vfo->pRX->Frequency; const bool isMemoryChannel = IS_MR_CHANNEL(vfo->CHANNEL_SAVE); const uint16_t channel = isMemoryChannel ? vfo->CHANNEL_SAVE : RXTX_LOG_CHANNEL_NONE; - char name[RXTX_LOG_NAME_LEN + 1]; - - RXTX_LOG_SanitizeName(name, isMemoryChannel ? vfo->Name : NULL); if (gSessionActive && gSessionFlags == flags && @@ -667,7 +647,12 @@ static void RXTX_LOG_CaptureSession(uint8_t flags, const VFO_Info_t *vfo) gSessionFrequency = frequency; gSessionChannel = channel; gSessionTicks500ms = 0; - strcpy(gSessionName, name); + // vfo->Name is already truncated/trimmed/null-terminated by its sole + // writer, SETTINGS_FetchChannelName (settings.c), so a plain copy is enough. + if (isMemoryChannel) + strcpy(gSessionName, vfo->Name); + else + gSessionName[0] = 0; } void RXTX_LOG_Init(void) From 4fccdb78b9dc67ad7e033713ccab4d2d5a74f89b Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 5 Jul 2026 23:01:27 +0200 Subject: [PATCH 12/37] Dedupe RX/TX log entry copies by aligning RAM and flash struct layouts --- App/app/rxtx_log.c | 47 +++++++++++++++------------------------------- App/app/rxtx_log.h | 4 +++- 2 files changed, 18 insertions(+), 33 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index 5f68ff1e..558f9b51 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -6,6 +6,7 @@ #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG #include +#include #include #include "app/generic.h" @@ -54,6 +55,9 @@ typedef struct __attribute__((packed)) { static_assert(sizeof(RXTX_LogFlashEntry_t) == 32); static_assert(RXTX_LOG_VIEW_ANCHOR_COUNT <= 32); +// RXTX_LogEntry_t (RAM) and RXTX_LogFlashEntry_t must stay byte-identical up +// to the end of name[] so entries can be copied with a single memcpy. +static_assert(offsetof(RXTX_LogEntry_t, name) == offsetof(RXTX_LogFlashEntry_t, name)); static RXTX_LogEntry_t gViewCache[RXTX_LOG_VIEW_CACHE_COUNT]; static uint16_t gViewCacheStart; @@ -161,22 +165,14 @@ static bool RXTX_LOG_MatchesFlags(uint8_t flags) const char *RXTX_LOG_GetFilterName(void) { - if (gLogFilter == RXTX_LOG_FILTER_RX) - return "RX"; - if (gLogFilter == RXTX_LOG_FILTER_TX) - return "TX"; - return "ALL"; + static const char *const filterNames[] = {"ALL", "RX", "TX"}; + + return filterNames[gLogFilter]; } static void RXTX_LOG_CopyFromFlash(RXTX_LogEntry_t *dst, const RXTX_LogFlashEntry_t *src) { - dst->sequence = src->sequence; - dst->frequency = src->frequency; - dst->trafficSeq = src->trafficSeq; - dst->durationSeconds = src->durationSeconds; - dst->channel = src->channel; - dst->flags = src->flags; - memcpy(dst->name, src->name, RXTX_LOG_NAME_LEN); + memcpy(dst, src, offsetof(RXTX_LogFlashEntry_t, reserved)); dst->name[RXTX_LOG_NAME_LEN] = 0; } @@ -560,14 +556,8 @@ static uint32_t RXTX_LOG_WriteEntry(const RXTX_LogEntry_t *src) uint8_t commit = RXTX_LOG_ENTRY_COMMIT; memset(&entry, 0xFF, sizeof(entry)); - entry.sequence = src->sequence; - entry.frequency = src->frequency; - entry.trafficSeq = src->trafficSeq; - entry.durationSeconds = src->durationSeconds; - entry.channel = src->channel; - entry.flags = src->flags; - memcpy(entry.name, src->name, RXTX_LOG_NAME_LEN); - entry.crc = RXTX_LOG_Crc8(&entry, sizeof(entry) - 2); + memcpy(&entry, src, offsetof(RXTX_LogFlashEntry_t, reserved)); + entry.crc = RXTX_LOG_Crc8(&entry, sizeof(entry) - 2); RXTX_LOG_PrepareNextSlot(); @@ -596,18 +586,11 @@ static void RXTX_LOG_EnsureViewCache(void) { const uint16_t pageStart = RXTX_LOG_PageStart(gLogCursor); - if (RXTX_LOG_ViewCacheCovers(gLogCursor) && - gViewCacheStart == pageStart) - return; - - if (gViewScanActive && - gViewCacheFilter == gLogFilter && - gViewCacheStart == pageStart) - return; - - if (gViewCacheComplete && - gViewCacheFilter == gLogFilter && - gViewCacheStart == pageStart) + if (gViewCacheFilter == gLogFilter && + gViewCacheStart == pageStart && + (gViewScanActive || + gViewCacheComplete || + RXTX_LOG_ViewCacheCovers(gLogCursor))) return; RXTX_LOG_StartCursorView(gLogCursor); diff --git a/App/app/rxtx_log.h b/App/app/rxtx_log.h index 20b3a1d4..384ff5dc 100644 --- a/App/app/rxtx_log.h +++ b/App/app/rxtx_log.h @@ -23,9 +23,11 @@ typedef enum { RXTX_LOG_DIR_TX = 1, } RXTX_LogDirection_t; +// Field order mirrors RXTX_LogFlashEntry_t (rxtx_log.c) so both layouts +// match byte-for-byte up to the end of name[] and can be copied in one pass. typedef struct { - uint32_t frequency; uint32_t sequence; + uint32_t frequency; uint32_t trafficSeq; uint16_t durationSeconds; uint16_t channel; From e908eda1da122fb71d7a81d2242108e083f920fb Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 5 Jul 2026 23:19:00 +0200 Subject: [PATCH 13/37] Make RX/TX log wrap-around scrolling optional via ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP --- App/CMakeLists.txt | 1 + App/app/rxtx_log.c | 69 ++++++++++++++++++++++++++++++++++++++-------- CMakePresets.json | 2 ++ 3 files changed, 61 insertions(+), 11 deletions(-) diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index 6124dab2..d5350fac 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -214,6 +214,7 @@ enable_feature(ENABLE_FEAT_F4HWN_BEAM enable_feature(ENABLE_FEAT_F4HWN_RXTX_LOG app/rxtx_log.c ) +enable_feature(ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP) if(ENABLE_FEAT_F4HWN_BEAM AND NOT ENABLE_AIRCOPY) message(FATAL_ERROR "ENABLE_FEAT_F4HWN_BEAM requires ENABLE_AIRCOPY (it reuses g_FSK_Buffer, AIRCOPY_Obfuscate and the FSK packet plumbing).") diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index 558f9b51..c9a847bf 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -65,10 +65,7 @@ static uint8_t gViewCacheCount; static uint8_t gViewCacheFilter; static bool gViewCacheHasOlder; static bool gViewCacheComplete; -static bool gViewCacheCircular; static bool gViewScanActive; -static bool gViewScanDiscoverTotal; -static bool gViewScanWrapped; static uint16_t gViewScanSlot; static uint16_t gViewScanScanned; static uint16_t gViewScanSkip; @@ -76,9 +73,16 @@ static uint16_t gViewScanIndex; static uint16_t gViewAnchorSlots[RXTX_LOG_VIEW_ANCHOR_COUNT]; static uint32_t gViewAnchorMask; static uint8_t gViewAnchorFilter; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP +// Wrap-around scrolling state: cycling UP past the first row (or DOWN past +// the last) requires discovering the total row count with a dedicated scan. +static bool gViewCacheCircular; +static bool gViewScanDiscoverTotal; +static bool gViewScanWrapped; static bool gViewTotalKnown; static bool gViewWrapPending; static uint16_t gViewTotalRows; +#endif static bool gClearActive; static uint8_t gClearSector; static bool gMenuClearHandled; @@ -200,14 +204,18 @@ static void RXTX_LOG_StartViewCacheScan(uint16_t start, bool circular, bool disc static void RXTX_LOG_StartCursorView(uint16_t cursor) { - gViewWrapPending = false; gLogCursor = cursor; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP + gViewWrapPending = false; RXTX_LOG_StartViewCacheScan(cursor, gViewTotalKnown && gViewTotalRows > 0 && cursor < gViewTotalRows && (uint16_t)(cursor + RXTX_LOG_VIEW_CACHE_COUNT) > gViewTotalRows, false); +#else + RXTX_LOG_StartViewCacheScan(cursor, false, false); +#endif } static void RXTX_LOG_InvalidateViewAnchors(void) @@ -262,12 +270,14 @@ static void RXTX_LOG_InvalidateViewCache(void) gViewCacheFilter = 0xFFu; gViewCacheHasOlder = false; gViewCacheComplete = false; + gViewScanActive = false; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP gViewCacheCircular = false; gViewTotalKnown = false; gViewWrapPending = false; - gViewScanActive = false; gViewScanDiscoverTotal = false; gViewScanWrapped = false; +#endif RXTX_LOG_InvalidateViewAnchors(); } @@ -353,11 +363,14 @@ static bool RXTX_LOG_AlignLastViewPage(void) static void RXTX_LOG_StopViewScan(void) { - gViewScanActive = false; + gViewScanActive = false; + gViewCacheComplete = true; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP gViewScanDiscoverTotal = false; - gViewCacheComplete = true; +#endif } +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP static bool RXTX_LOG_TryWrapViewCacheScan(void) { if (!gViewCacheCircular || @@ -374,6 +387,7 @@ static bool RXTX_LOG_TryWrapViewCacheScan(void) gViewScanIndex = 0; return true; } +#endif static void RXTX_LOG_StepViewCacheScan(void) { @@ -419,18 +433,26 @@ static void RXTX_LOG_StepViewCacheScan(void) gViewCacheCount++; } else { gViewCacheHasOlder = true; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP if (!gViewScanDiscoverTotal) { RXTX_LOG_StopViewScan(); break; } +#else + RXTX_LOG_StopViewScan(); + break; +#endif } gViewScanIndex++; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP if (RXTX_LOG_TryWrapViewCacheScan()) continue; +#endif } if (gViewScanScanned >= RXTX_LOG_SLOT_COUNT || capReached) { +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP if (!gViewScanWrapped) { gViewTotalRows = gViewScanIndex; gViewTotalKnown = gViewTotalRows > 0; @@ -446,12 +468,18 @@ static void RXTX_LOG_StepViewCacheScan(void) if (RXTX_LOG_TryWrapViewCacheScan()) return; +#endif RXTX_LOG_StopViewScan(); } +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP if (!gViewCacheCircular && RXTX_LOG_AlignLastViewPage()) return; +#else + if (RXTX_LOG_AlignLastViewPage()) + return; +#endif } static void RXTX_LOG_StartViewCacheScan(uint16_t start, bool circular, bool discoverTotal) @@ -467,14 +495,21 @@ static void RXTX_LOG_StartViewCacheScan(uint16_t start, bool circular, bool disc gViewCacheFilter = gLogFilter; gViewCacheHasOlder = false; gViewCacheComplete = false; - gViewCacheCircular = circular; - gViewScanActive = false; + gViewScanActive = false; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP + gViewCacheCircular = circular; gViewScanDiscoverTotal = discoverTotal; gViewScanWrapped = false; +#else + (void)circular; + (void)discoverTotal; +#endif if (gNextSequence == 0) { +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP gViewWrapPending = false; gViewScanDiscoverTotal = false; +#endif gViewCacheComplete = true; return; } @@ -493,6 +528,7 @@ static void RXTX_LOG_StartViewCacheScan(uint16_t start, bool circular, bool disc gViewScanActive = true; } +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP static void RXTX_LOG_RequestWrapToLast(void) { gViewWrapPending = true; @@ -507,6 +543,7 @@ static void RXTX_LOG_RequestWrapToLast(void) RXTX_LOG_StartViewCacheScan(0, false, true); } +#endif static bool RXTX_LOG_ViewCacheCovers(uint16_t indexFromNewest) { @@ -800,21 +837,27 @@ void RXTX_LOG_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) case KEY_UP: if (gLogCursor > 0) { RXTX_LOG_StartCursorView(gLogCursor - 1u); - } else if (gViewTotalKnown && gViewTotalRows > 1u) { + } +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP + else if (gViewTotalKnown && gViewTotalRows > 1u) { RXTX_LOG_StartCursorView(gViewTotalRows - 1u); } else { RXTX_LOG_RequestWrapToLast(); } +#endif gUpdateDisplay = true; break; case KEY_DOWN: +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP gViewWrapPending = false; +#endif RXTX_LOG_EnsureViewCache(); if (gViewCacheCount > 0) { if (gViewScanActive) break; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP if (gViewTotalKnown && gViewTotalRows > 1u) { uint16_t next = gLogCursor + 1u; if (next >= gViewTotalRows) @@ -823,6 +866,7 @@ void RXTX_LOG_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) gUpdateDisplay = true; break; } +#endif if (gViewCacheComplete && !gViewCacheHasOlder) break; @@ -928,11 +972,14 @@ void UI_DisplayRxTxLog(void) } for (uint8_t row = 0; row < RXTX_LOG_VIEW_CACHE_COUNT; row++) { +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP if (gViewCacheCircular) { if (row >= gViewCacheCount) break; entry = gViewCache[row]; - } else { + } else +#endif + { const uint16_t index = gLogCursor + row; if (!RXTX_LOG_GetFilteredEntry(index, &entry)) break; diff --git a/CMakePresets.json b/CMakePresets.json index 27e29850..7730dee5 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -74,6 +74,7 @@ "ENABLE_FEAT_F4HWN_SCAN_SUBAUDIBLE": false, "ENABLE_FEAT_F4HWN_BEAM": false, "ENABLE_FEAT_F4HWN_RXTX_LOG": false, + "ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP": false, "ENABLE_FEAT_F4HWN_LOGO": false, "ENABLE_FEAT_F4HWN_LOGO_SAV": false, "ENABLE_AGC_SHOW_DATA": false, @@ -206,6 +207,7 @@ "ENABLE_FEAT_F4HWN_SCAN_SUBAUDIBLE": true, "ENABLE_FEAT_F4HWN_BEAM": true, "ENABLE_FEAT_F4HWN_RXTX_LOG": true, + "ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP": false, "ENABLE_FEAT_F4HWN_QRCODE": true, "ENABLE_FEAT_F4HWN_MEM": true, "ENABLE_FEAT_F4HWN_LOGO": true, From 3e6e7c87b5a78e7235e47398ae0e5d2da9b72c62 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 5 Jul 2026 23:28:31 +0200 Subject: [PATCH 14/37] Add F + UP/DOWN shortcuts to jump to first/last RX/TX log entry --- App/app/rxtx_log.c | 54 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index c9a847bf..c65660a8 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -471,6 +471,18 @@ static void RXTX_LOG_StepViewCacheScan(void) #endif RXTX_LOG_StopViewScan(); + +#ifndef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP + // A jump-to-end aims past the last row on purpose: when the whole + // scan was spent skipping, the leftover skip locates the actual + // last row, so retarget the view there. + if (gViewCacheCount == 0 && + gViewScanSkip > 0 && + gViewScanSkip < gViewCacheStart) { + RXTX_LOG_StartCursorView((uint16_t)(gViewCacheStart - gViewScanSkip - 1u)); + return; + } +#endif } #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP @@ -545,6 +557,20 @@ static void RXTX_LOG_RequestWrapToLast(void) } #endif +static void RXTX_LOG_GoToLastRow(void) +{ +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP + if (gViewTotalKnown && gViewTotalRows > 1u) + RXTX_LOG_StartCursorView(gViewTotalRows - 1u); + else + RXTX_LOG_RequestWrapToLast(); +#else + // Aim past the end; the scan-completion retarget in StepViewCacheScan + // snaps the cursor back to the last existing row. + RXTX_LOG_StartCursorView(RXTX_LOG_SLOT_COUNT - 1u); +#endif +} + static bool RXTX_LOG_ViewCacheCovers(uint16_t indexFromNewest) { return gViewCacheFilter == gLogFilter && @@ -834,21 +860,39 @@ void RXTX_LOG_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) } switch (Key) { + case KEY_F: + // GENERIC_Key_F only toggles the flag on the MAIN screen, so + // handle it here: F arms a go-to-first/last modifier for UP/DOWN. + if (bKeyPressed && !bKeyHeld) { + gWasFKeyPressed = !gWasFKeyPressed; + if (gWasFKeyPressed) + gKeyInputCountdown = key_input_timeout_500ms; + gUpdateStatus = true; + } + break; + case KEY_UP: - if (gLogCursor > 0) { + if (gWasFKeyPressed) { + HideFKeyIcon(); + RXTX_LOG_StartCursorView(0); + } else if (gLogCursor > 0) { RXTX_LOG_StartCursorView(gLogCursor - 1u); } #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP - else if (gViewTotalKnown && gViewTotalRows > 1u) { - RXTX_LOG_StartCursorView(gViewTotalRows - 1u); - } else { - RXTX_LOG_RequestWrapToLast(); + else { + RXTX_LOG_GoToLastRow(); } #endif gUpdateDisplay = true; break; case KEY_DOWN: + if (gWasFKeyPressed) { + HideFKeyIcon(); + RXTX_LOG_GoToLastRow(); + gUpdateDisplay = true; + break; + } #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP gViewWrapPending = false; #endif From 98ac57b032e44e5a9793e3a92f8b402dab1e5bcf Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 6 Jul 2026 00:11:49 +0200 Subject: [PATCH 15/37] Keep status bar layout stable when RX/TX log filter label is shown --- App/ui/status.c | 47 ++++++++++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/App/ui/status.c b/App/ui/status.c index 56a4d237..94b2e0f4 100644 --- a/App/ui/status.c +++ b/App/ui/status.c @@ -70,14 +70,15 @@ void UI_DisplayStatus() char str[8] = ""; #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + // The filter label reuses the leftmost slot (pixels 2..16) normally + // reserved by the power-save and scan indicators; those two are skipped + // on the log screen so the rest of the bar keeps its usual layout. const bool isRxTxLogScreen = gScreenToDisplay == DISPLAY_RXTX_LOG; if (isRxTxLogScreen) { const char *filter = RXTX_LOG_GetFilterName(); const uint8_t end = (filter[2] == 0) ? 10 : 14; GUI_DisplaySmallestInverse(filter, 2, 0, true, true, end); - x = 18; - x1 = x; } #endif @@ -93,23 +94,27 @@ void UI_DisplayStatus() } else { #endif -#ifdef ENABLE_NOAA - // NOAA indicator - if (!(gScanStateDir != SCAN_OFF || SCANNER_IsScanning()) && gIsNoaaMode) { // NOASS SCAN indicator - memcpy(line + x, BITMAP_NOAA, sizeof(BITMAP_NOAA)); - } - // Power Save indicator - else if (gCurrentFunction == FUNCTION_POWER_SAVE) { - memcpy(line + x, gFontPowerSave, sizeof(gFontPowerSave)); - } - x += 8; -#else - // Power Save indicator - if (gCurrentFunction == FUNCTION_POWER_SAVE) { - memcpy(line + x, gFontPowerSave, sizeof(gFontPowerSave)); - } - x += 8; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + if (!isRxTxLogScreen) #endif + { +#ifdef ENABLE_NOAA + // NOAA indicator + if (!(gScanStateDir != SCAN_OFF || SCANNER_IsScanning()) && gIsNoaaMode) { // NOASS SCAN indicator + memcpy(line + x, BITMAP_NOAA, sizeof(BITMAP_NOAA)); + } + // Power Save indicator + else if (gCurrentFunction == FUNCTION_POWER_SAVE) { + memcpy(line + x, gFontPowerSave, sizeof(gFontPowerSave)); + } +#else + // Power Save indicator + if (gCurrentFunction == FUNCTION_POWER_SAVE) { + memcpy(line + x, gFontPowerSave, sizeof(gFontPowerSave)); + } +#endif + } + x += 8; x1 = x; @@ -121,7 +126,11 @@ void UI_DisplayStatus() else #endif { // SCAN indicator - if (gScanStateDir != SCAN_OFF || SCANNER_IsScanning()) { + if ((gScanStateDir != SCAN_OFF || SCANNER_IsScanning()) +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + && !isRxTxLogScreen +#endif + ) { if (IS_MR_CHANNEL(gNextMrChannel) && !SCANNER_IsScanning()) { // channel mode uint8_t end = 0; From 42aeac3469e00f9a66cf888d5713ce3eaa3660e5 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 6 Jul 2026 03:18:22 +0200 Subject: [PATCH 16/37] Move left by 1 px --- App/app/rxtx_log.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index c65660a8..1f19c0e8 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -1044,7 +1044,7 @@ void UI_DisplayRxTxLog(void) else UI_PrintStringSmallNormal(title, 17, 0, row); - GUI_DisplaySmallest(isTx ? "TX" : "RX", 96, (uint8_t)((row * 8u) + 1u), false, true); + GUI_DisplaySmallest(isTx ? "TX" : "RX", 95, (uint8_t)((row * 8u) + 1u), false, true); sprintf(duration, "%02u:%02u", entry.durationSeconds / 60u, entry.durationSeconds % 60u); GUI_DisplaySmallestInverse(duration, 107, row, false, true, 127); From 374e52c4d1f3e28c4ba461e3fafa05658285ae39 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 6 Jul 2026 03:35:54 +0200 Subject: [PATCH 17/37] Resolve RX/TX log channel names at display time instead of storing them --- App/app/rxtx_log.c | 47 ++++++++++++++++++---------------------------- App/app/rxtx_log.h | 6 +++--- 2 files changed, 21 insertions(+), 32 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index 1f19c0e8..8dbdf1b1 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -17,6 +17,7 @@ #include "external/printf/printf.h" #include "frequencies.h" #include "misc.h" +#include "settings.h" #include "ui/helper.h" #include "ui/ui.h" @@ -33,7 +34,7 @@ #define RXTX_LOG_ENTRY_COMMIT 0xA5u #define RXTX_LOG_CHANNEL_NONE 0xFFFFu #define RXTX_LOG_FLAG_TX (1u << 0) -#define RXTX_LOG_FLAG_NAMED (1u << 1) +// (1u << 1) was FLAG_NAMED, retired: names are resolved from the channel. #define RXTX_LOG_FLAG_MONITOR (1u << 2) #define RXTX_LOG_FLAG_SESSION (1u << 3) #define RXTX_LOG_FILTER_ALL 0u @@ -47,17 +48,20 @@ typedef struct __attribute__((packed)) { uint16_t durationSeconds; uint16_t channel; uint8_t flags; - char name[RXTX_LOG_NAME_LEN]; - uint8_t reserved[3]; + uint8_t reserved[13]; uint8_t crc; uint8_t commit; } RXTX_LogFlashEntry_t; static_assert(sizeof(RXTX_LogFlashEntry_t) == 32); static_assert(RXTX_LOG_VIEW_ANCHOR_COUNT <= 32); -// RXTX_LogEntry_t (RAM) and RXTX_LogFlashEntry_t must stay byte-identical up -// to the end of name[] so entries can be copied with a single memcpy. -static_assert(offsetof(RXTX_LogEntry_t, name) == offsetof(RXTX_LogFlashEntry_t, name)); + +#define RXTX_LOG_ENTRY_COPY_SIZE offsetof(RXTX_LogFlashEntry_t, reserved) + +// RXTX_LogEntry_t (RAM) and RXTX_LogFlashEntry_t must stay byte-identical for +// the copied prefix. +static_assert(RXTX_LOG_ENTRY_COPY_SIZE == offsetof(RXTX_LogEntry_t, flags) + sizeof(((RXTX_LogEntry_t *)0)->flags)); +static_assert(sizeof(RXTX_LogEntry_t) >= RXTX_LOG_ENTRY_COPY_SIZE); static RXTX_LogEntry_t gViewCache[RXTX_LOG_VIEW_CACHE_COUNT]; static uint16_t gViewCacheStart; @@ -96,7 +100,6 @@ static uint8_t gSessionFlags; static uint32_t gSessionFrequency; static uint16_t gSessionChannel; static uint16_t gSessionTicks500ms; -static char gSessionName[RXTX_LOG_NAME_LEN + 1]; static uint16_t gLogCursor; static uint8_t gLogFilter; @@ -174,12 +177,6 @@ const char *RXTX_LOG_GetFilterName(void) return filterNames[gLogFilter]; } -static void RXTX_LOG_CopyFromFlash(RXTX_LogEntry_t *dst, const RXTX_LogFlashEntry_t *src) -{ - memcpy(dst, src, offsetof(RXTX_LogFlashEntry_t, reserved)); - dst->name[RXTX_LOG_NAME_LEN] = 0; -} - static uint32_t RXTX_LOG_SlotToAddress(uint16_t slot) { return RXTX_LOG_FLASH_BASE + ((uint32_t)slot * sizeof(RXTX_LogFlashEntry_t)); @@ -429,7 +426,7 @@ static void RXTX_LOG_StepViewCacheScan(void) } if (gViewCacheCount < RXTX_LOG_VIEW_CACHE_COUNT) { - RXTX_LOG_CopyFromFlash(&gViewCache[gViewCacheCount], &flashEntry); + memcpy(&gViewCache[gViewCacheCount], &flashEntry, RXTX_LOG_ENTRY_COPY_SIZE); gViewCacheCount++; } else { gViewCacheHasOlder = true; @@ -619,7 +616,7 @@ static uint32_t RXTX_LOG_WriteEntry(const RXTX_LogEntry_t *src) uint8_t commit = RXTX_LOG_ENTRY_COMMIT; memset(&entry, 0xFF, sizeof(entry)); - memcpy(&entry, src, offsetof(RXTX_LogFlashEntry_t, reserved)); + memcpy(&entry, src, RXTX_LOG_ENTRY_COPY_SIZE); entry.crc = RXTX_LOG_Crc8(&entry, sizeof(entry) - 2); RXTX_LOG_PrepareNextSlot(); @@ -693,12 +690,6 @@ static void RXTX_LOG_CaptureSession(uint8_t flags, const VFO_Info_t *vfo) gSessionFrequency = frequency; gSessionChannel = channel; gSessionTicks500ms = 0; - // vfo->Name is already truncated/trimmed/null-terminated by its sole - // writer, SETTINGS_FetchChannelName (settings.c), so a plain copy is enough. - if (isMemoryChannel) - strcpy(gSessionName, vfo->Name); - else - gSessionName[0] = 0; } void RXTX_LOG_Init(void) @@ -799,10 +790,6 @@ void RXTX_LOG_EndActive(void) entry.durationSeconds = (gSessionTicks500ms + 1u) / 2u; entry.channel = gSessionChannel; entry.flags = gSessionFlags; - strcpy(entry.name, gSessionName); - - if (entry.name[0] != 0) - entry.flags |= RXTX_LOG_FLAG_NAMED; if (entry.durationSeconds == 0) entry.durationSeconds = 1; @@ -961,11 +948,13 @@ static void RXTX_LOG_FormatFrequency(uint32_t frequency, char *buffer) static void RXTX_LOG_FormatTitle(const RXTX_LogEntry_t *entry, char *buffer) { - if ((entry->flags & RXTX_LOG_FLAG_NAMED) != 0) { - strcpy(buffer, entry->name); - } else { + buffer[0] = 0; + + if (entry->channel != RXTX_LOG_CHANNEL_NONE) + SETTINGS_FetchChannelName(buffer, entry->channel); + + if (buffer[0] == 0) RXTX_LOG_FormatFrequency(entry->frequency, buffer); - } } static void RXTX_LOG_DrawIndexBadge(uint16_t indexFromNewest, uint8_t line) diff --git a/App/app/rxtx_log.h b/App/app/rxtx_log.h index 384ff5dc..a0843a43 100644 --- a/App/app/rxtx_log.h +++ b/App/app/rxtx_log.h @@ -16,7 +16,6 @@ #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG #define RXTX_LOG_VISIBLE_COUNT 512 -#define RXTX_LOG_NAME_LEN 10 typedef enum { RXTX_LOG_DIR_RX = 0, @@ -24,7 +23,9 @@ typedef enum { } RXTX_LogDirection_t; // Field order mirrors RXTX_LogFlashEntry_t (rxtx_log.c) so both layouts -// match byte-for-byte up to the end of name[] and can be copied in one pass. +// match byte-for-byte up to and including flags, copied in one pass. +// The channel name is not stored: it is resolved from `channel` at display +// time via SETTINGS_FetchChannelName. typedef struct { uint32_t sequence; uint32_t frequency; @@ -32,7 +33,6 @@ typedef struct { uint16_t durationSeconds; uint16_t channel; uint8_t flags; - char name[RXTX_LOG_NAME_LEN + 1]; } RXTX_LogEntry_t; void RXTX_LOG_Init(void); From fb1360cce2404a9907038ade9cb683c5c2e898a1 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 6 Jul 2026 03:52:57 +0200 Subject: [PATCH 18/37] Replace unreachable RX/TX log EEPROM mapping with documentation comment --- App/app/rxtx_log.c | 6 +----- App/app/rxtx_log.h | 5 ----- App/driver/eeprom_compat.c | 9 ++++++--- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index 8dbdf1b1..f61532d6 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -15,7 +15,6 @@ #include "driver/py25q16.h" #include "driver/st7565.h" #include "external/printf/printf.h" -#include "frequencies.h" #include "misc.h" #include "settings.h" #include "ui/helper.h" @@ -610,7 +609,7 @@ static void RXTX_LOG_AdvanceFlashAddress(void) gNextFlashAddress = RXTX_LOG_FLASH_BASE; } -static uint32_t RXTX_LOG_WriteEntry(const RXTX_LogEntry_t *src) +static void RXTX_LOG_WriteEntry(const RXTX_LogEntry_t *src) { RXTX_LogFlashEntry_t entry; uint8_t commit = RXTX_LOG_ENTRY_COMMIT; @@ -621,12 +620,9 @@ static uint32_t RXTX_LOG_WriteEntry(const RXTX_LogEntry_t *src) RXTX_LOG_PrepareNextSlot(); - const uint32_t address = gNextFlashAddress; PY25Q16_WriteBuffer(gNextFlashAddress, &entry, sizeof(entry), false); PY25Q16_WriteBuffer(gNextFlashAddress + sizeof(entry) - 1u, &commit, 1, false); RXTX_LOG_AdvanceFlashAddress(); - - return address; } static void RXTX_LOG_WriteSessionMarker(void) diff --git a/App/app/rxtx_log.h b/App/app/rxtx_log.h index a0843a43..821995cc 100644 --- a/App/app/rxtx_log.h +++ b/App/app/rxtx_log.h @@ -17,11 +17,6 @@ #define RXTX_LOG_VISIBLE_COUNT 512 -typedef enum { - RXTX_LOG_DIR_RX = 0, - RXTX_LOG_DIR_TX = 1, -} RXTX_LogDirection_t; - // Field order mirrors RXTX_LogFlashEntry_t (rxtx_log.c) so both layouts // match byte-for-byte up to and including flags, copied in one pass. // The channel name is not stored: it is resolved from `channel` at display diff --git a/App/driver/eeprom_compat.c b/App/driver/eeprom_compat.c index ea8139cc..d880e97c 100644 --- a/App/driver/eeprom_compat.c +++ b/App/driver/eeprom_compat.c @@ -73,9 +73,12 @@ static const AddrMapping_t ADDR_MAPPINGS[] = { // [0x08..0x407] 128x64 monochrome bitmap, 1024 Bytes // ST7565-native: 8 pages * 128 columns, column-major LSB-top -#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG - _MK_MAPPING(0x1E0000, 0x00D000, 0x015000), // RX/TX append-only log * 32 KB / 8 sectors -#endif + // Not mapped, for documentation only (the EEPROM API uses 16-bit + // addresses and could not reach a 32 KB window anyway): + // + // 0x1E0000 -> 0x1E8000: RX/TX append-only log * 32 KB / 8 sectors + // (ENABLE_FEAT_F4HWN_RXTX_LOG, accessed directly + // by app/rxtx_log.c, not through this layer) }; static void AddrTranslate(uint16_t EEPROM_Addr, uint16_t Size, uint32_t *PY25Q16_Addr_out, uint16_t *Size_out, bool *End_out); From ae7bf653832aa98a1d4e1ab3fdb01c06bfa16a04 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 6 Jul 2026 12:22:50 +0200 Subject: [PATCH 19/37] Ignore squelch-only incoming state in RX/TX log --- App/functions.c | 2 -- 1 file changed, 2 deletions(-) diff --git a/App/functions.c b/App/functions.c index ecca939c..32be4045 100644 --- a/App/functions.c +++ b/App/functions.c @@ -246,13 +246,11 @@ void FUNCTION_Select(FUNCTION_Type_t Function) const bool previousWasActive = PreviousFunction == FUNCTION_TRANSMIT || PreviousFunction == FUNCTION_MONITOR || - PreviousFunction == FUNCTION_INCOMING || PreviousFunction == FUNCTION_RECEIVE; const bool previousWasTx = PreviousFunction == FUNCTION_TRANSMIT; const bool nextIsActive = Function == FUNCTION_TRANSMIT || Function == FUNCTION_MONITOR || - Function == FUNCTION_INCOMING || Function == FUNCTION_RECEIVE; const bool nextIsTx = Function == FUNCTION_TRANSMIT; From 9c1b1cb063f008536587b7f2db44bb345e85a676 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 6 Jul 2026 15:25:29 +0200 Subject: [PATCH 20/37] Remove unused REGA emergency alarm feature (ENABLE_REGA) --- App/CMakeLists.txt | 4 - App/app/action.c | 7 -- App/app/app.c | 5 -- App/app/rega.c | 187 --------------------------------------------- App/app/rega.h | 65 ---------------- App/settings.h | 4 - App/ui/menu.c | 4 - App/ui/ui.c | 7 -- App/ui/ui.h | 4 - CMakePresets.json | 1 - 10 files changed, 288 deletions(-) delete mode 100644 App/app/rega.c delete mode 100644 App/app/rega.h diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index d5350fac..4fc9cde4 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -171,10 +171,6 @@ enable_feature(ENABLE_SCAN_RANGES) # ---- CONTRIB MODS ---- -# Thank you @markusb -enable_feature(ENABLE_REGA - app/rega.c -) # Thank you @reppad enable_feature(ENABLE_EXTRA_UART_CMD) diff --git a/App/app/action.c b/App/app/action.c index 9715f43f..bc8893a5 100644 --- a/App/app/action.c +++ b/App/app/action.c @@ -42,9 +42,6 @@ #include "ui/inputbox.h" #include "ui/main.h" #include "ui/ui.h" -#ifdef ENABLE_REGA - #include "app/rega.h" -#endif #ifdef ENABLE_FEAT_F4HWN_BEAM #include "app/beam.h" #endif @@ -134,10 +131,6 @@ void (*action_opt_table[])(void) = { #else [ACTION_OPT_RXMODE] = &FUNCTION_NOP, #endif -#ifdef ENABLE_REGA - [ACTION_OPT_REGA_ALARM] = &ACTION_RegaAlarm, - [ACTION_OPT_REGA_TEST] = &ACTION_RegaTest, -#endif #ifdef ENABLE_FEAT_F4HWN_BEAM [ACTION_OPT_BEAM] = &ACTION_Beam, #endif diff --git a/App/app/app.c b/App/app/app.c index 8a73eafe..84890f2a 100644 --- a/App/app/app.c +++ b/App/app/app.c @@ -125,12 +125,7 @@ void (*ProcessKeysFunctions[])(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) #endif }; -#ifdef ENABLE_REGA -// This is a hack for REGA as I need a special display element only for it with no key -static_assert(ARRAY_SIZE(ProcessKeysFunctions) == DISPLAY_N_ELEM-1); -#else static_assert(ARRAY_SIZE(ProcessKeysFunctions) == DISPLAY_N_ELEM); -#endif #ifdef ENABLE_FEAT_F4HWN_LOGO_SAV static uint8_t ScreenSaverRandom(void) diff --git a/App/app/rega.c b/App/app/rega.c deleted file mode 100644 index c01470d0..00000000 --- a/App/app/rega.c +++ /dev/null @@ -1,187 +0,0 @@ -/* - * REGA Alam and Test - * ================== - * - * This bit of code is to implement the REGA test and alarm functions. - * This function sends out a fixed ZVEI tone sequence for testing or alarm purposes. - * Further information: https://www.rega.ch/en/our-missions/sites-and-infrastructure/emergency-radio - * - * There are two REGA ACTIONS: Test and Alarm. - * The Test action sends out a fixed ZVEI tone sequence for testing purposes. - * The Alarm action sends out a fixed ZVEI tone sequence for alarm purposes. - * - * These actions can be assigned to a key in the settings menu. - * - * The Test/Alarm function will perform the following actions: - * - Set the radio to transmit mode - * - Wait 100ms to allow the radio to switch to transmit mode and stabilize the tramsmitter - * - Send out the ZVEI tone sequence - * - Wait 100ms to allow the radio to finish transmitting - * - Set the radio back to receive mode - * - * The ZVEI tone squence for alarm is: 21414 - * The ZVEI tone squence for test is: 21301 - * - * To save space the two tone sequences are stored as precalculated register values. - * This avoids the need to calculate the register values at runtime. - * They are hardcoded as array of 32bit integers in the rega.h file. - * - * Copyright 2025 Markus Bärtschi - * https://github.com/markusb - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#include -#include -#include - -#include "rega.h" -#include "radio.h" -#include "action.h" -#include "misc.h" -#include "settings.h" -#include "functions.h" -#include "driver/bk4819.h" -#include "driver/st7565.h" -#include "driver/system.h" -#include "driver/gpio.h" -#include "bsp/dp32g030/gpio.h" -#include "ui/helper.h" -#include "ui/ui.h" - - -const uint16_t rega_alarm_tones[5] = { - 1160, // 2 1160Hz - 1060, // 1 1060Hz - 1400, // 4 1400Hz - 1060, // 1 1060Hz - 1400, // 4 1400Hz -}; -const uint16_t rega_test_tones[5] = { - 1160, // 2 1160Hz - 1060, // 1 1060Hz - 1260, // 3 1260Hz - 2400, // 0 2400Hz - 1060, // 1 1060Hz -}; - -// Global variable to pass the message to the display -char rega_message[16]; - -// Transmit the ZVEI tone sequence for alarm -void ACTION_RegaAlarm() -{ - const char message[16] = "REGA Alarm"; - REGA_TransmitZvei(rega_alarm_tones,message); -} - -// Tranmit the ZVEI tone sequence for test -void ACTION_RegaTest() -{ - const char message[16] = "REGA Test"; - REGA_TransmitZvei(rega_test_tones,message); -} - -// Display the REGA message on the screen -void UI_DisplayREGA() -{ - UI_DisplayClear(); - UI_DisplayPopup(rega_message); - ST7565_BlitFullScreen(); -} - -// Transmit a ZVEI tone sequence on the REGA frequency -// Configures the radio on VFO A with the required parameters -// tones: array of 5 ZVEI tones -// message: message to display on the screen -void REGA_TransmitZvei(const uint16_t tones[],const char message[]) -{ - // Copy the message text - strncpy(rega_message,message,16); - // Trigger the display - gScreenToDisplay = DISPLAY_REGA; - - // Flash the green LED twice - BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, true); - SYSTEM_DelayMs(70); - BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, false); - SYSTEM_DelayMs(50); - BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, true); - SYSTEM_DelayMs(70); - BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, false); - - // Select VFO A - gEeprom.TX_VFO = 0; - gEeprom.ScreenChannel[gEeprom.TX_VFO] = gEeprom.FreqChannel[gEeprom.TX_VFO]; - gRxVfo = gTxVfo; - gRequestSaveVFO = true; - gVfoConfigureMode = VFO_CONFIGURE_RELOAD; - - // Set the REGA frequency, no offset - gTxVfo->freq_config_RX.Frequency = REGA_FREQUENCY; - gTxVfo->freq_config_TX = gTxVfo->freq_config_RX; - gTxVfo->TX_OFFSET_FREQUENCY = 0; - - // Set the modulation to FM narrow - gTxVfo->Modulation = MODULATION_FM; - gTxVfo->CHANNEL_BANDWIDTH = BK4819_FILTER_BW_NARROW; - - // Set Tx Squelch Tone - gTxVfo->freq_config_TX.CodeType = CODE_TYPE_CONTINUOUS_TONE; - gTxVfo->freq_config_TX.Code = REGA_CTCSS_FREQ_INDEX; - BK4819_SetCTCSSFrequency(CTCSS_Options[gTxVfo->freq_config_TX.Code]); - // Turn Rx squelch tone off - gTxVfo->freq_config_RX.CodeType = CODE_TYPE_OFF; - - // Set the transmit power to high - gTxVfo->OUTPUT_POWER = OUTPUT_POWER_HIGH; - - // Lock the keyboard - gEeprom.KEY_LOCK = true; - - gRequestSaveChannel = 1; - gRequestSaveSettings = true; - - // Configure the receiver - BK4819_RX_TurnOn(); - - // Set the radio to transmit mode - RADIO_PrepareCssTX(); - FUNCTION_Select(FUNCTION_TRANSMIT); - - // Wait to allow the radio to switch to transmit mode and stabilize the transmitter - SYSTEM_DelayMs(ZVEI_PRE_LENGTH_MS); - - // Send out the ZVEI2 tone sequence - for (int i = 0; i < ZVEI_NUM_TONES; i++) - { - BK4819_PlaySingleTone(tones[i], ZVEI_TONE_LENGTH_MS, 100, true); - SYSTEM_DelayMs(ZVEI_PAUSE_LENGTH_MS); - } - - // Wait to allow the radio to finish transmitting - SYSTEM_DelayMs(ZVEI_POST_LENGTH_MS); - - // Flash the green LED twice - BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, true); - SYSTEM_DelayMs(70); - BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, false); - SYSTEM_DelayMs(70); - BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, true); - SYSTEM_DelayMs(70); - BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, false); - - // Display the normal screen - gRequestDisplayScreen = DISPLAY_MAIN; -} diff --git a/App/app/rega.h b/App/app/rega.h deleted file mode 100644 index 68f53c14..00000000 --- a/App/app/rega.h +++ /dev/null @@ -1,65 +0,0 @@ -/* - * REGA Alam and Test - * ================== - * - * This function sends out a fixed ZVEI tone sequence for testing or alarm purposes for the Alpine REGA alarm channel. - * Further information: https://www.rega.ch/en/our-missions/sites-and-infrastructure/emergency-radio - * - * There are two REGA ACTIONS: Test and Alarm. - * The Test action sends out a fixed ZVEI tone sequence for testing purposes. - * The Alarm action sends out a fixed ZVEI tone sequence for alarm purposes. - * - * These actions can be assigned to a key in the settings menu. - * - * The Test/Alarm function will perform the following actions: - * - Set the radio configuration to the REGA frequency, CTCSS, FM, full power etc. - * - Start transmitting - * - Wait 100ms to allow the radio to switch to transmit mode and stabilize the transmitter - * - Send out the ZVEI tone sequence - * - Wait 100ms to allow the radio to finish transmitting - * - Set the radio back to receive mode - * - * The REGA frequency is 161.300 Mhz - * - * The ZVEI tone squence for alarm is: 21414 - * The ZVEI tone squence for test is: 21301 - * - * The two tone sequences are hardcoded in two arrays - * - * Copyright 2025 Markus Bärtschi - * - * https://github.com/markusb - * - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#ifndef REGA_H -#define REGA_H - -#include - -#define ZVEI_NUM_TONES 5 -#define ZVEI_TONE_LENGTH_MS 70 // 70 -#define ZVEI_PAUSE_LENGTH_MS 10 // 10 -#define ZVEI_PRE_LENGTH_MS 300 -#define ZVEI_POST_LENGTH_MS 100 -#define REGA_CTCSS_FREQ_INDEX 18 // dcs.c: CTCSS_Options[18] = 1230 -#define REGA_FREQUENCY 16130000 // 43370000 16130000 - -void ACTION_RegaAlarm(void); -void ACTION_RegaTest(void); -void UI_DisplayREGA(void); -void REGA_TransmitZvei(const uint16_t[], const char[]); - -#endif diff --git a/App/settings.h b/App/settings.h index e5bff534..da1b04fd 100644 --- a/App/settings.h +++ b/App/settings.h @@ -127,10 +127,6 @@ enum ACTION_OPT_t { ACTION_OPT_REMOVE_OFFSET, #endif #endif -#ifdef ENABLE_REGA - ACTION_OPT_REGA_ALARM, - ACTION_OPT_REGA_TEST, -#endif #ifdef ENABLE_FEAT_F4HWN_BEAM ACTION_OPT_BEAM, #endif diff --git a/App/ui/menu.c b/App/ui/menu.c index a1bcae5b..445e4ebd 100644 --- a/App/ui/menu.c +++ b/App/ui/menu.c @@ -496,10 +496,6 @@ const t_sidefunction gSubMenu_SIDEFUNCTIONS[] = #endif #ifdef ENABLE_TX1750 {"1750Hz", ACTION_OPT_1750}, -#endif -#ifdef ENABLE_REGA - {"REGA\nALARM", ACTION_OPT_REGA_ALARM}, - {"REGA\nTEST", ACTION_OPT_REGA_TEST}, #endif {"LOCK\nKEYPAD", ACTION_OPT_KEYLOCK}, {"VFO A\nVFO B", ACTION_OPT_A_B}, diff --git a/App/ui/ui.c b/App/ui/ui.c index 33d3d9e4..116fa962 100644 --- a/App/ui/ui.c +++ b/App/ui/ui.c @@ -30,9 +30,6 @@ #ifdef ENABLE_FMRADIO #include "ui/fmradio.h" #endif -#ifdef ENABLE_REGA - #include "app/rega.h" -#endif #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG #include "app/rxtx_log.h" #endif @@ -67,10 +64,6 @@ void (*UI_DisplayFunctions[])(void) = { #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG [DISPLAY_RXTX_LOG] = &UI_DisplayRxTxLog, #endif - -#ifdef ENABLE_REGA - [DISPLAY_REGA] = &UI_DisplayREGA, -#endif }; static_assert(ARRAY_SIZE(UI_DisplayFunctions) == DISPLAY_N_ELEM); diff --git a/App/ui/ui.h b/App/ui/ui.h index c8d0f646..94b1ede1 100644 --- a/App/ui/ui.h +++ b/App/ui/ui.h @@ -38,10 +38,6 @@ enum GUI_DisplayType_t DISPLAY_RXTX_LOG, #endif -#ifdef ENABLE_REGA - DISPLAY_REGA, -#endif - DISPLAY_N_ELEM, DISPLAY_INVALID = 0xFFu }; diff --git a/CMakePresets.json b/CMakePresets.json index 7730dee5..cf7a5797 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -44,7 +44,6 @@ "ENABLE_BYP_RAW_DEMODULATORS": false, "ENABLE_BLMIN_TMP_OFF": false, "ENABLE_SCAN_RANGES": true, - "ENABLE_REGA": false, "ENABLE_EXTRA_UART_CMD": false, "ENABLE_FEAT_F4HWN": true, "ENABLE_FEAT_F4HWN_GAME": false, From 6a556e06017c614210ff9a20c733463d131b6877 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Tue, 7 Jul 2026 00:13:50 +0200 Subject: [PATCH 21/37] Add S-meter to RX/TX log --- App/app/rxtx_log.c | 67 +++++++++++++++++++++++++++++++++++++++++----- App/app/rxtx_log.h | 3 ++- 2 files changed, 62 insertions(+), 8 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index f61532d6..c6948eb9 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -12,6 +12,7 @@ #include "app/generic.h" #include "app/rxtx_log.h" #include "audio.h" +#include "driver/bk4819.h" #include "driver/py25q16.h" #include "driver/st7565.h" #include "external/printf/printf.h" @@ -39,6 +40,7 @@ #define RXTX_LOG_FILTER_ALL 0u #define RXTX_LOG_FILTER_RX 1u #define RXTX_LOG_FILTER_TX 2u +#define RXTX_LOG_SMETER_UNKNOWN 0xFFu typedef struct __attribute__((packed)) { uint32_t sequence; @@ -47,7 +49,8 @@ typedef struct __attribute__((packed)) { uint16_t durationSeconds; uint16_t channel; uint8_t flags; - uint8_t reserved[13]; + uint8_t sMeter; + uint8_t reserved[12]; uint8_t crc; uint8_t commit; } RXTX_LogFlashEntry_t; @@ -59,7 +62,7 @@ static_assert(RXTX_LOG_VIEW_ANCHOR_COUNT <= 32); // RXTX_LogEntry_t (RAM) and RXTX_LogFlashEntry_t must stay byte-identical for // the copied prefix. -static_assert(RXTX_LOG_ENTRY_COPY_SIZE == offsetof(RXTX_LogEntry_t, flags) + sizeof(((RXTX_LogEntry_t *)0)->flags)); +static_assert(RXTX_LOG_ENTRY_COPY_SIZE == offsetof(RXTX_LogEntry_t, sMeter) + sizeof(((RXTX_LogEntry_t *)0)->sMeter)); static_assert(sizeof(RXTX_LogEntry_t) >= RXTX_LOG_ENTRY_COPY_SIZE); static RXTX_LogEntry_t gViewCache[RXTX_LOG_VIEW_CACHE_COUNT]; @@ -99,9 +102,11 @@ static uint8_t gSessionFlags; static uint32_t gSessionFrequency; static uint16_t gSessionChannel; static uint16_t gSessionTicks500ms; +static uint8_t gSessionSMeter; static uint16_t gLogCursor; static uint8_t gLogFilter; +static bool gLogShowRssi; static uint8_t RXTX_LOG_Crc8(const void *data, uint16_t size) { @@ -176,6 +181,22 @@ const char *RXTX_LOG_GetFilterName(void) return filterNames[gLogFilter]; } +static void RXTX_LOG_UpdateSessionRssi(void) +{ + if (!gSessionActive || (gSessionFlags & RXTX_LOG_FLAG_TX) != 0) + return; + + const int16_t rssiDbm = + BK4819_GetRSSI_dBm() + + dBmCorrTable[gRxVfo->Band]; + const uint8_t sMeter = rssiDbm >= -93 + ? (uint8_t)(9u + MIN((uint8_t)(rssiDbm + 93), 40u)) + : (rssiDbm < -141 ? 0 : (uint8_t)((rssiDbm + 147) / 6)); + + if (gSessionSMeter == RXTX_LOG_SMETER_UNKNOWN || sMeter > gSessionSMeter) + gSessionSMeter = sMeter; +} + static uint32_t RXTX_LOG_SlotToAddress(uint16_t slot) { return RXTX_LOG_FLASH_BASE + ((uint32_t)slot * sizeof(RXTX_LogFlashEntry_t)); @@ -633,6 +654,7 @@ static void RXTX_LOG_WriteSessionMarker(void) entry.sequence = gNextSequence++; entry.channel = RXTX_LOG_CHANNEL_NONE; entry.flags = RXTX_LOG_FLAG_SESSION; + entry.sMeter = RXTX_LOG_SMETER_UNKNOWN; RXTX_LOG_WriteEntry(&entry); RXTX_LOG_InvalidateViewCache(); @@ -686,6 +708,8 @@ static void RXTX_LOG_CaptureSession(uint8_t flags, const VFO_Info_t *vfo) gSessionFrequency = frequency; gSessionChannel = channel; gSessionTicks500ms = 0; + gSessionSMeter = RXTX_LOG_SMETER_UNKNOWN; + RXTX_LOG_UpdateSessionRssi(); } void RXTX_LOG_Init(void) @@ -700,9 +724,11 @@ void RXTX_LOG_Init(void) gLogCursor = 0; gLogFilter = RXTX_LOG_FILTER_ALL; gSessionActive = false; + gSessionSMeter = RXTX_LOG_SMETER_UNKNOWN; gClearActive = false; gClearSector = 0; gMenuClearHandled = false; + gLogShowRssi = false; gLogHasTraffic = false; gNextFlashAddress = RXTX_LOG_FLASH_BASE; RXTX_LOG_InvalidateViewCache(); @@ -774,9 +800,12 @@ void RXTX_LOG_EndActive(void) if (gClearActive) { gSessionActive = false; + gSessionSMeter = RXTX_LOG_SMETER_UNKNOWN; return; } + RXTX_LOG_UpdateSessionRssi(); + RXTX_LogEntry_t entry; memset(&entry, 0, sizeof(entry)); @@ -786,6 +815,7 @@ void RXTX_LOG_EndActive(void) entry.durationSeconds = (gSessionTicks500ms + 1u) / 2u; entry.channel = gSessionChannel; entry.flags = gSessionFlags; + entry.sMeter = gSessionSMeter; if (entry.durationSeconds == 0) entry.durationSeconds = 1; @@ -795,12 +825,16 @@ void RXTX_LOG_EndActive(void) RXTX_LOG_InvalidateViewCache(); gSessionActive = false; + gSessionSMeter = RXTX_LOG_SMETER_UNKNOWN; } void RXTX_LOG_Tick500ms(void) { - if (gSessionActive && gSessionTicks500ms < 0xFFFEu) - gSessionTicks500ms++; + if (gSessionActive) { + RXTX_LOG_UpdateSessionRssi(); + if (gSessionTicks500ms < 0xFFFEu) + gSessionTicks500ms++; + } } void RXTX_LOG_Task10ms(void) @@ -820,6 +854,7 @@ void RXTX_LOG_Task10ms(void) void ACTION_RxTxLog(void) { gLogCursor = 0; + gLogShowRssi = false; RXTX_LOG_InvalidateViewCache(); gUpdateStatus = true; GUI_SelectNextDisplay(DISPLAY_RXTX_LOG); @@ -925,6 +960,13 @@ void RXTX_LOG_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) } break; + case KEY_STAR: + if (bKeyPressed && !bKeyHeld) { + gLogShowRssi = !gLogShowRssi; + gUpdateDisplay = true; + } + break; + case KEY_EXIT: gRequestDisplayScreen = DISPLAY_MAIN; gUpdateStatus = true; @@ -953,6 +995,14 @@ static void RXTX_LOG_FormatTitle(const RXTX_LogEntry_t *entry, char *buffer) RXTX_LOG_FormatFrequency(entry->frequency, buffer); } +static void RXTX_LOG_FormatSMeter(uint8_t sMeter, char *buffer) +{ + if (sMeter > 9) + sprintf(buffer, "S9+%u", sMeter - 9u); + else + sprintf(buffer, "S%u", sMeter); +} + static void RXTX_LOG_DrawIndexBadge(uint16_t indexFromNewest, uint8_t line) { char label[4]; @@ -977,7 +1027,7 @@ static void RXTX_LOG_ShowEmpty(bool showMessage) void UI_DisplayRxTxLog(void) { - char duration[8]; + char detail[8]; char title[16]; RXTX_LogEntry_t entry; @@ -1031,8 +1081,11 @@ void UI_DisplayRxTxLog(void) GUI_DisplaySmallest(isTx ? "TX" : "RX", 95, (uint8_t)((row * 8u) + 1u), false, true); - sprintf(duration, "%02u:%02u", entry.durationSeconds / 60u, entry.durationSeconds % 60u); - GUI_DisplaySmallestInverse(duration, 107, row, false, true, 127); + if (gLogShowRssi && !isTx) + RXTX_LOG_FormatSMeter(entry.sMeter, detail); + else + sprintf(detail, "%02u:%02u", entry.durationSeconds / 60u, entry.durationSeconds % 60u); + GUI_DisplaySmallestInverse(detail, 107, row, false, true, 127); } ST7565_BlitFullScreen(); diff --git a/App/app/rxtx_log.h b/App/app/rxtx_log.h index 821995cc..d5a397c2 100644 --- a/App/app/rxtx_log.h +++ b/App/app/rxtx_log.h @@ -18,7 +18,7 @@ #define RXTX_LOG_VISIBLE_COUNT 512 // Field order mirrors RXTX_LogFlashEntry_t (rxtx_log.c) so both layouts -// match byte-for-byte up to and including flags, copied in one pass. +// match byte-for-byte up to and including sMeter, copied in one pass. // The channel name is not stored: it is resolved from `channel` at display // time via SETTINGS_FetchChannelName. typedef struct { @@ -28,6 +28,7 @@ typedef struct { uint16_t durationSeconds; uint16_t channel; uint8_t flags; + uint8_t sMeter; } RXTX_LogEntry_t; void RXTX_LOG_Init(void); From 8d3754b42fb4c5b93f2b1e39f8737c2fa33c6ed1 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Tue, 7 Jul 2026 02:18:58 +0200 Subject: [PATCH 22/37] Center S-meter text in the RX/TX log detail badge --- App/app/rxtx_log.c | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index c6948eb9..d5f8291c 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -998,7 +998,7 @@ static void RXTX_LOG_FormatTitle(const RXTX_LogEntry_t *entry, char *buffer) static void RXTX_LOG_FormatSMeter(uint8_t sMeter, char *buffer) { if (sMeter > 9) - sprintf(buffer, "S9+%u", sMeter - 9u); + sprintf(buffer, "S9+%02u", sMeter - 9u); else sprintf(buffer, "S%u", sMeter); } @@ -1085,7 +1085,12 @@ void UI_DisplayRxTxLog(void) RXTX_LOG_FormatSMeter(entry.sMeter, detail); else sprintf(detail, "%02u:%02u", entry.durationSeconds / 60u, entry.durationSeconds % 60u); - GUI_DisplaySmallestInverse(detail, 107, row, false, true, 127); + // Draw the fixed-width badge first, then punch the text out of it + // centered: text length varies (Sn vs S9+XX vs MM:SS), the badge + // must not. Each glyph cell is 4 px wide, 5 cells fill the badge. + GUI_DisplaySmallestInverse("", 107, row, false, true, 127); + GUI_DisplaySmallest(detail, (uint8_t)(107u + (5u - strlen(detail)) * 2u), + (uint8_t)((row * 8u) + 1u), false, false); } ST7565_BlitFullScreen(); From fbd7b37c211a766f878e042fead10aa23a39ef7f Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Tue, 7 Jul 2026 02:30:15 +0200 Subject: [PATCH 23/37] Log minimum battery voltage per session in RX/TX log --- App/app/rxtx_log.c | 67 ++++++++++++++++++++++++++++++++++------------ App/app/rxtx_log.h | 3 ++- 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index d5f8291c..dca67e0c 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -16,6 +16,7 @@ #include "driver/py25q16.h" #include "driver/st7565.h" #include "external/printf/printf.h" +#include "helper/battery.h" #include "misc.h" #include "settings.h" #include "ui/helper.h" @@ -41,6 +42,12 @@ #define RXTX_LOG_FILTER_RX 1u #define RXTX_LOG_FILTER_TX 2u #define RXTX_LOG_SMETER_UNKNOWN 0xFFu +// battVolt stores centivolts above 6.00 V, saturating at 8.54 V (254). +#define RXTX_LOG_BATT_UNKNOWN 0xFFu +#define RXTX_LOG_BATT_OFFSET 600u +#define RXTX_LOG_DETAIL_DURATION 0u +#define RXTX_LOG_DETAIL_SMETER 1u +#define RXTX_LOG_DETAIL_BATT 2u typedef struct __attribute__((packed)) { uint32_t sequence; @@ -50,7 +57,8 @@ typedef struct __attribute__((packed)) { uint16_t channel; uint8_t flags; uint8_t sMeter; - uint8_t reserved[12]; + uint8_t battVolt; + uint8_t reserved[11]; uint8_t crc; uint8_t commit; } RXTX_LogFlashEntry_t; @@ -62,7 +70,7 @@ static_assert(RXTX_LOG_VIEW_ANCHOR_COUNT <= 32); // RXTX_LogEntry_t (RAM) and RXTX_LogFlashEntry_t must stay byte-identical for // the copied prefix. -static_assert(RXTX_LOG_ENTRY_COPY_SIZE == offsetof(RXTX_LogEntry_t, sMeter) + sizeof(((RXTX_LogEntry_t *)0)->sMeter)); +static_assert(RXTX_LOG_ENTRY_COPY_SIZE == offsetof(RXTX_LogEntry_t, battVolt) + sizeof(((RXTX_LogEntry_t *)0)->battVolt)); static_assert(sizeof(RXTX_LogEntry_t) >= RXTX_LOG_ENTRY_COPY_SIZE); static RXTX_LogEntry_t gViewCache[RXTX_LOG_VIEW_CACHE_COUNT]; @@ -103,10 +111,11 @@ static uint32_t gSessionFrequency; static uint16_t gSessionChannel; static uint16_t gSessionTicks500ms; static uint8_t gSessionSMeter; +static uint8_t gSessionBattVolt; static uint16_t gLogCursor; static uint8_t gLogFilter; -static bool gLogShowRssi; +static uint8_t gLogDetailMode; static uint8_t RXTX_LOG_Crc8(const void *data, uint16_t size) { @@ -181,9 +190,21 @@ const char *RXTX_LOG_GetFilterName(void) return filterNames[gLogFilter]; } -static void RXTX_LOG_UpdateSessionRssi(void) +static void RXTX_LOG_UpdateSessionMeters(void) { - if (!gSessionActive || (gSessionFlags & RXTX_LOG_FLAG_TX) != 0) + if (!gSessionActive) + return; + + // Track the lowest battery voltage seen during the session: under TX + // load this is the sag, which rebounds as soon as the PA keys off. + const uint16_t volt = gBatteryVoltageAverage; + const uint8_t battVolt = volt <= RXTX_LOG_BATT_OFFSET + ? 0 + : (uint8_t)MIN(volt - RXTX_LOG_BATT_OFFSET, 254u); + if (battVolt < gSessionBattVolt) + gSessionBattVolt = battVolt; + + if ((gSessionFlags & RXTX_LOG_FLAG_TX) != 0) return; const int16_t rssiDbm = @@ -655,6 +676,7 @@ static void RXTX_LOG_WriteSessionMarker(void) entry.channel = RXTX_LOG_CHANNEL_NONE; entry.flags = RXTX_LOG_FLAG_SESSION; entry.sMeter = RXTX_LOG_SMETER_UNKNOWN; + entry.battVolt = RXTX_LOG_BATT_UNKNOWN; RXTX_LOG_WriteEntry(&entry); RXTX_LOG_InvalidateViewCache(); @@ -709,7 +731,8 @@ static void RXTX_LOG_CaptureSession(uint8_t flags, const VFO_Info_t *vfo) gSessionChannel = channel; gSessionTicks500ms = 0; gSessionSMeter = RXTX_LOG_SMETER_UNKNOWN; - RXTX_LOG_UpdateSessionRssi(); + gSessionBattVolt = RXTX_LOG_BATT_UNKNOWN; + RXTX_LOG_UpdateSessionMeters(); } void RXTX_LOG_Init(void) @@ -725,10 +748,11 @@ void RXTX_LOG_Init(void) gLogFilter = RXTX_LOG_FILTER_ALL; gSessionActive = false; gSessionSMeter = RXTX_LOG_SMETER_UNKNOWN; + gSessionBattVolt = RXTX_LOG_BATT_UNKNOWN; gClearActive = false; gClearSector = 0; gMenuClearHandled = false; - gLogShowRssi = false; + gLogDetailMode = RXTX_LOG_DETAIL_DURATION; gLogHasTraffic = false; gNextFlashAddress = RXTX_LOG_FLASH_BASE; RXTX_LOG_InvalidateViewCache(); @@ -799,12 +823,13 @@ void RXTX_LOG_EndActive(void) return; if (gClearActive) { - gSessionActive = false; - gSessionSMeter = RXTX_LOG_SMETER_UNKNOWN; + gSessionActive = false; + gSessionSMeter = RXTX_LOG_SMETER_UNKNOWN; + gSessionBattVolt = RXTX_LOG_BATT_UNKNOWN; return; } - RXTX_LOG_UpdateSessionRssi(); + RXTX_LOG_UpdateSessionMeters(); RXTX_LogEntry_t entry; memset(&entry, 0, sizeof(entry)); @@ -816,6 +841,7 @@ void RXTX_LOG_EndActive(void) entry.channel = gSessionChannel; entry.flags = gSessionFlags; entry.sMeter = gSessionSMeter; + entry.battVolt = gSessionBattVolt; if (entry.durationSeconds == 0) entry.durationSeconds = 1; @@ -824,14 +850,15 @@ void RXTX_LOG_EndActive(void) gLogHasTraffic = true; RXTX_LOG_InvalidateViewCache(); - gSessionActive = false; - gSessionSMeter = RXTX_LOG_SMETER_UNKNOWN; + gSessionActive = false; + gSessionSMeter = RXTX_LOG_SMETER_UNKNOWN; + gSessionBattVolt = RXTX_LOG_BATT_UNKNOWN; } void RXTX_LOG_Tick500ms(void) { if (gSessionActive) { - RXTX_LOG_UpdateSessionRssi(); + RXTX_LOG_UpdateSessionMeters(); if (gSessionTicks500ms < 0xFFFEu) gSessionTicks500ms++; } @@ -854,7 +881,7 @@ void RXTX_LOG_Task10ms(void) void ACTION_RxTxLog(void) { gLogCursor = 0; - gLogShowRssi = false; + gLogDetailMode = RXTX_LOG_DETAIL_DURATION; RXTX_LOG_InvalidateViewCache(); gUpdateStatus = true; GUI_SelectNextDisplay(DISPLAY_RXTX_LOG); @@ -962,7 +989,9 @@ void RXTX_LOG_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) case KEY_STAR: if (bKeyPressed && !bKeyHeld) { - gLogShowRssi = !gLogShowRssi; + gLogDetailMode = gLogDetailMode >= RXTX_LOG_DETAIL_BATT + ? RXTX_LOG_DETAIL_DURATION + : (uint8_t)(gLogDetailMode + 1u); gUpdateDisplay = true; } break; @@ -1081,10 +1110,14 @@ void UI_DisplayRxTxLog(void) GUI_DisplaySmallest(isTx ? "TX" : "RX", 95, (uint8_t)((row * 8u) + 1u), false, true); - if (gLogShowRssi && !isTx) + if (gLogDetailMode == RXTX_LOG_DETAIL_SMETER && !isTx) { RXTX_LOG_FormatSMeter(entry.sMeter, detail); - else + } else if (gLogDetailMode == RXTX_LOG_DETAIL_BATT) { + const uint16_t volt = RXTX_LOG_BATT_OFFSET + entry.battVolt; + sprintf(detail, "%u.%02u", volt / 100u, volt % 100u); + } else { sprintf(detail, "%02u:%02u", entry.durationSeconds / 60u, entry.durationSeconds % 60u); + } // Draw the fixed-width badge first, then punch the text out of it // centered: text length varies (Sn vs S9+XX vs MM:SS), the badge // must not. Each glyph cell is 4 px wide, 5 cells fill the badge. diff --git a/App/app/rxtx_log.h b/App/app/rxtx_log.h index d5a397c2..54e4f70d 100644 --- a/App/app/rxtx_log.h +++ b/App/app/rxtx_log.h @@ -18,7 +18,7 @@ #define RXTX_LOG_VISIBLE_COUNT 512 // Field order mirrors RXTX_LogFlashEntry_t (rxtx_log.c) so both layouts -// match byte-for-byte up to and including sMeter, copied in one pass. +// match byte-for-byte up to and including battVolt, copied in one pass. // The channel name is not stored: it is resolved from `channel` at display // time via SETTINGS_FetchChannelName. typedef struct { @@ -29,6 +29,7 @@ typedef struct { uint16_t channel; uint8_t flags; uint8_t sMeter; + uint8_t battVolt; } RXTX_LogEntry_t; void RXTX_LOG_Init(void); From 7306db5f8ff65e713bc0a7fb2821a12c5736efdc Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Tue, 7 Jul 2026 02:36:16 +0200 Subject: [PATCH 24/37] Show TX power level for TX rows in RX/TX log S-meter view --- App/app/rxtx_log.c | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index dca67e0c..5d420a5a 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -20,6 +20,7 @@ #include "misc.h" #include "settings.h" #include "ui/helper.h" +#include "ui/menu.h" #include "ui/ui.h" #define RXTX_LOG_FLASH_BASE 0x1E0000u @@ -730,7 +731,9 @@ static void RXTX_LOG_CaptureSession(uint8_t flags, const VFO_Info_t *vfo) gSessionFrequency = frequency; gSessionChannel = channel; gSessionTicks500ms = 0; - gSessionSMeter = RXTX_LOG_SMETER_UNKNOWN; + // TX sessions repurpose the sMeter byte to store the TX power level + // (OUTPUT_POWER, indexes gSubMenu_TXP); RX sessions track the S-meter. + gSessionSMeter = (flags & RXTX_LOG_FLAG_TX) ? vfo->OUTPUT_POWER : RXTX_LOG_SMETER_UNKNOWN; gSessionBattVolt = RXTX_LOG_BATT_UNKNOWN; RXTX_LOG_UpdateSessionMeters(); } @@ -1110,8 +1113,11 @@ void UI_DisplayRxTxLog(void) GUI_DisplaySmallest(isTx ? "TX" : "RX", 95, (uint8_t)((row * 8u) + 1u), false, true); - if (gLogDetailMode == RXTX_LOG_DETAIL_SMETER && !isTx) { - RXTX_LOG_FormatSMeter(entry.sMeter, detail); + if (gLogDetailMode == RXTX_LOG_DETAIL_SMETER) { + if (isTx) + strcpy(detail, gSubMenu_TXP[MIN(entry.sMeter, ARRAY_SIZE(gSubMenu_TXP) - 1u)]); + else + RXTX_LOG_FormatSMeter(entry.sMeter, detail); } else if (gLogDetailMode == RXTX_LOG_DETAIL_BATT) { const uint16_t volt = RXTX_LOG_BATT_OFFSET + entry.battVolt; sprintf(detail, "%u.%02u", volt / 100u, volt % 100u); From 43533c838b0f9846f52f1e0e43ff54f240dca899 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Tue, 7 Jul 2026 02:56:22 +0200 Subject: [PATCH 25/37] Reorder RX/TX log flash layout to shrink the view cache --- App/app/rxtx_log.c | 17 ++++++++++++----- App/app/rxtx_log.h | 7 ++++--- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index 5d420a5a..5339f4d1 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -51,7 +51,9 @@ #define RXTX_LOG_DETAIL_BATT 2u typedef struct __attribute__((packed)) { - uint32_t sequence; + // Display fields come first: they form the prefix mirrored by + // RXTX_LogEntry_t and copied to the view cache in one pass. Scan-only + // fields (sequence) sit past the prefix so RAM does not carry them. uint32_t frequency; uint32_t trafficSeq; uint16_t durationSeconds; @@ -59,15 +61,21 @@ typedef struct __attribute__((packed)) { uint8_t flags; uint8_t sMeter; uint8_t battVolt; - uint8_t reserved[11]; + // Padding byte, written as 0xFF like reserved. It keeps sequence 32-bit + // aligned: Cortex-M0+ forbids unaligned word access, so without it the + // compiler falls back to byte-wise loads/stores that cost flash. + uint8_t pad; + uint32_t sequence; + uint8_t reserved[10]; uint8_t crc; uint8_t commit; } RXTX_LogFlashEntry_t; static_assert(sizeof(RXTX_LogFlashEntry_t) == 32); +static_assert(offsetof(RXTX_LogFlashEntry_t, sequence) % 4 == 0); static_assert(RXTX_LOG_VIEW_ANCHOR_COUNT <= 32); -#define RXTX_LOG_ENTRY_COPY_SIZE offsetof(RXTX_LogFlashEntry_t, reserved) +#define RXTX_LOG_ENTRY_COPY_SIZE offsetof(RXTX_LogFlashEntry_t, pad) // RXTX_LogEntry_t (RAM) and RXTX_LogFlashEntry_t must stay byte-identical for // the copied prefix. @@ -659,6 +667,7 @@ static void RXTX_LOG_WriteEntry(const RXTX_LogEntry_t *src) memset(&entry, 0xFF, sizeof(entry)); memcpy(&entry, src, RXTX_LOG_ENTRY_COPY_SIZE); + entry.sequence = gNextSequence++; entry.crc = RXTX_LOG_Crc8(&entry, sizeof(entry) - 2); RXTX_LOG_PrepareNextSlot(); @@ -673,7 +682,6 @@ static void RXTX_LOG_WriteSessionMarker(void) RXTX_LogEntry_t entry; memset(&entry, 0, sizeof(entry)); - entry.sequence = gNextSequence++; entry.channel = RXTX_LOG_CHANNEL_NONE; entry.flags = RXTX_LOG_FLAG_SESSION; entry.sMeter = RXTX_LOG_SMETER_UNKNOWN; @@ -837,7 +845,6 @@ void RXTX_LOG_EndActive(void) RXTX_LogEntry_t entry; memset(&entry, 0, sizeof(entry)); - entry.sequence = gNextSequence++; entry.trafficSeq = gNextTrafficSequence++; entry.frequency = gSessionFrequency; entry.durationSeconds = (gSessionTicks500ms + 1u) / 2u; diff --git a/App/app/rxtx_log.h b/App/app/rxtx_log.h index 54e4f70d..94d47f1f 100644 --- a/App/app/rxtx_log.h +++ b/App/app/rxtx_log.h @@ -17,12 +17,13 @@ #define RXTX_LOG_VISIBLE_COUNT 512 -// Field order mirrors RXTX_LogFlashEntry_t (rxtx_log.c) so both layouts -// match byte-for-byte up to and including battVolt, copied in one pass. +// Field order mirrors the leading fields of RXTX_LogFlashEntry_t +// (rxtx_log.c) so both layouts match byte-for-byte up to and including +// battVolt, copied in one pass. Scan-only fields (sequence) sit past the +// copied prefix in the flash layout and are not cached in RAM. // The channel name is not stored: it is resolved from `channel` at display // time via SETTINGS_FetchChannelName. typedef struct { - uint32_t sequence; uint32_t frequency; uint32_t trafficSeq; uint16_t durationSeconds; From 9447eaada988e3fdf2afe3734461c250759be871 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Tue, 7 Jul 2026 14:15:02 +0200 Subject: [PATCH 26/37] Add secure clear log confirmation flow --- App/app/rxtx_log.c | 80 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 20 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index 5339f4d1..cb13ff0d 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -107,6 +107,7 @@ static bool gViewWrapPending; static uint16_t gViewTotalRows; #endif static bool gClearActive; +static bool gClearConfirmActive; static uint8_t gClearSector; static bool gMenuClearHandled; static bool gLogHasTraffic; @@ -355,12 +356,22 @@ static void RXTX_LOG_StartClear(void) if (gClearActive) return; - gClearActive = true; - gSessionActive = false; - gLogCursor = 0; + gClearActive = true; + gClearConfirmActive = false; + gSessionActive = false; + gLogCursor = 0; RXTX_LOG_ResetLogCounters(); } +static void RXTX_LOG_CancelClearConfirm(void) +{ + if (!gClearConfirmActive) + return; + + gClearConfirmActive = false; + gUpdateDisplay = true; +} + static void RXTX_LOG_StepClear(void) { if (!gClearActive) @@ -760,12 +771,13 @@ void RXTX_LOG_Init(void) gSessionActive = false; gSessionSMeter = RXTX_LOG_SMETER_UNKNOWN; gSessionBattVolt = RXTX_LOG_BATT_UNKNOWN; - gClearActive = false; - gClearSector = 0; - gMenuClearHandled = false; - gLogDetailMode = RXTX_LOG_DETAIL_DURATION; - gLogHasTraffic = false; - gNextFlashAddress = RXTX_LOG_FLASH_BASE; + gClearActive = false; + gClearConfirmActive = false; + gClearSector = 0; + gMenuClearHandled = false; + gLogDetailMode = RXTX_LOG_DETAIL_DURATION; + gLogHasTraffic = false; + gNextFlashAddress = RXTX_LOG_FLASH_BASE; RXTX_LOG_InvalidateViewCache(); for (uint32_t address = RXTX_LOG_FLASH_BASE; address < RXTX_LOG_FLASH_END; address += sizeof(RXTX_LogFlashEntry_t)) { @@ -892,6 +904,8 @@ void ACTION_RxTxLog(void) { gLogCursor = 0; gLogDetailMode = RXTX_LOG_DETAIL_DURATION; + gClearConfirmActive = false; + gMenuClearHandled = false; RXTX_LOG_InvalidateViewCache(); gUpdateStatus = true; GUI_SelectNextDisplay(DISPLAY_RXTX_LOG); @@ -900,6 +914,7 @@ void ACTION_RxTxLog(void) void RXTX_LOG_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) { if (Key == KEY_PTT) { + RXTX_LOG_CancelClearConfirm(); GENERIC_Key_PTT(bKeyPressed); return; } @@ -919,6 +934,7 @@ void RXTX_LOG_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) // GENERIC_Key_F only toggles the flag on the MAIN screen, so // handle it here: F arms a go-to-first/last modifier for UP/DOWN. if (bKeyPressed && !bKeyHeld) { + RXTX_LOG_CancelClearConfirm(); gWasFKeyPressed = !gWasFKeyPressed; if (gWasFKeyPressed) gKeyInputCountdown = key_input_timeout_500ms; @@ -927,6 +943,7 @@ void RXTX_LOG_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) break; case KEY_UP: + RXTX_LOG_CancelClearConfirm(); if (gWasFKeyPressed) { HideFKeyIcon(); RXTX_LOG_StartCursorView(0); @@ -942,6 +959,7 @@ void RXTX_LOG_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) break; case KEY_DOWN: + RXTX_LOG_CancelClearConfirm(); if (gWasFKeyPressed) { HideFKeyIcon(); RXTX_LOG_GoToLastRow(); @@ -982,23 +1000,28 @@ void RXTX_LOG_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) break; case KEY_MENU: - if (bKeyHeld) { - if (bKeyPressed && !gMenuClearHandled) { - gMenuClearHandled = true; - RXTX_LOG_StartClear(); - gBeepToPlay = BEEP_1KHZ_60MS_OPTIONAL; - gUpdateStatus = true; - gUpdateDisplay = true; - } - } else if (!bKeyPressed) { - if (!gMenuClearHandled) + if (!bKeyPressed) { + if (!bKeyHeld && !gMenuClearHandled) { + RXTX_LOG_CancelClearConfirm(); RXTX_LOG_NextFilter(); + } gMenuClearHandled = false; + } else if (bKeyHeld && !gMenuClearHandled) { + gMenuClearHandled = true; + if (gClearConfirmActive) { + RXTX_LOG_StartClear(); + } else { + gClearConfirmActive = true; + } + gBeepToPlay = BEEP_1KHZ_60MS_OPTIONAL; + gUpdateStatus = true; + gUpdateDisplay = true; } break; case KEY_STAR: if (bKeyPressed && !bKeyHeld) { + RXTX_LOG_CancelClearConfirm(); gLogDetailMode = gLogDetailMode >= RXTX_LOG_DETAIL_BATT ? RXTX_LOG_DETAIL_DURATION : (uint8_t)(gLogDetailMode + 1u); @@ -1007,6 +1030,11 @@ void RXTX_LOG_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) break; case KEY_EXIT: + if (gClearConfirmActive) { + RXTX_LOG_CancelClearConfirm(); + break; + } + RXTX_LOG_CancelClearConfirm(); gRequestDisplayScreen = DISPLAY_MAIN; gUpdateStatus = true; break; @@ -1060,7 +1088,14 @@ static void RXTX_LOG_DrawSessionMarker(uint8_t line) static void RXTX_LOG_ShowEmpty(bool showMessage) { if (showMessage) - UI_PrintStringSmallBold("NO LOG", 0, 127, 3); + UI_PrintString("NO LOG", 0, 127, 1, 8); + ST7565_BlitFullScreen(); +} + +static void RXTX_LOG_ShowClearConfirm(void) +{ + UI_PrintString("CLEAR LOG", 0, 127, 1, 8); + UI_PrintString("SURE?", 0, 127, 3, 8); ST7565_BlitFullScreen(); } @@ -1077,6 +1112,11 @@ void UI_DisplayRxTxLog(void) return; } + if (gClearConfirmActive) { + RXTX_LOG_ShowClearConfirm(); + return; + } + RXTX_LOG_EnsureViewCache(); if (gLogFilter == RXTX_LOG_FILTER_ALL && !gLogHasTraffic) { From f8e1b7a436462a689c34c22294bc298440f02049 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Wed, 8 Jul 2026 03:41:27 +0200 Subject: [PATCH 27/37] Increase the scanrange skip list from 32 to 64 entries --- App/app/chFrScanner.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/App/app/chFrScanner.c b/App/app/chFrScanner.c index 7c13d690..0d6a2758 100644 --- a/App/app/chFrScanner.c +++ b/App/app/chFrScanner.c @@ -29,7 +29,7 @@ static uint8_t scanRangeCssCandidate = 0xFF; static uint8_t scanRangeCssHitCount = 0; #endif -#define SCAN_RANGE_SKIP_MAX 32 +#define SCAN_RANGE_SKIP_MAX 64 #if (SCAN_RANGE_SKIP_MAX & (SCAN_RANGE_SKIP_MAX - 1)) != 0 #error SCAN_RANGE_SKIP_MAX must be a power of two #endif From e2d21c61c2ef792571c6103eeddb82a9e99cc00f Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Thu, 9 Jul 2026 05:12:57 +0200 Subject: [PATCH 28/37] Add optional RX/TX log streaming to K5Viewer --- App/CMakeLists.txt | 5 ++ App/app/rxtx_log.c | 126 ++++++++++++++++++++++++++++++++++++++++++ App/app/rxtx_log.h | 33 ++++++++++- App/driver/keyboard.c | 34 +++++++++++- App/driver/keyboard.h | 12 +++- App/driver/vcp.c | 14 +++-- App/screenshot.c | 120 +++++++++++++++++++++++++++++++--------- CMakePresets.json | 2 + 8 files changed, 309 insertions(+), 37 deletions(-) diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index 4fc9cde4..e5060527 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -211,6 +211,11 @@ enable_feature(ENABLE_FEAT_F4HWN_RXTX_LOG app/rxtx_log.c ) enable_feature(ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP) +enable_feature(ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER) + +if(ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER AND NOT (ENABLE_FEAT_F4HWN_RXTX_LOG AND ENABLE_FEAT_F4HWN_SCREENSHOT)) + message(FATAL_ERROR "ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER requires ENABLE_FEAT_F4HWN_RXTX_LOG (log data) and ENABLE_FEAT_F4HWN_SCREENSHOT (serial transport).") +endif() if(ENABLE_FEAT_F4HWN_BEAM AND NOT ENABLE_AIRCOPY) message(FATAL_ERROR "ENABLE_FEAT_F4HWN_BEAM requires ENABLE_AIRCOPY (it reuses g_FSK_Buffer, AIRCOPY_Obfuscate and the FSK packet plumbing).") diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index cb13ff0d..d8d08005 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -81,6 +81,13 @@ static_assert(RXTX_LOG_VIEW_ANCHOR_COUNT <= 32); // the copied prefix. static_assert(RXTX_LOG_ENTRY_COPY_SIZE == offsetof(RXTX_LogEntry_t, battVolt) + sizeof(((RXTX_LogEntry_t *)0)->battVolt)); static_assert(sizeof(RXTX_LogEntry_t) >= RXTX_LOG_ENTRY_COPY_SIZE); +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER +// Both sizes are hardcoded in k5viewer.js (RF_LOG_ROW_SIZE and +// RF_LOG_PACKET_SIZE): pin them so a struct change breaks the build +// instead of the viewer. +static_assert(sizeof(RXTX_LogK5ViewerRow_t) == 25); +static_assert(RXTX_LOG_K5VIEWER_PACKET_SIZE == 1629); +#endif static RXTX_LogEntry_t gViewCache[RXTX_LOG_VIEW_CACHE_COUNT]; static uint16_t gViewCacheStart; @@ -728,6 +735,125 @@ static bool RXTX_LOG_GetFilteredEntry(uint16_t indexFromNewest, RXTX_LogEntry_t return true; } +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER +static uint32_t RXTX_LOG_K5ViewerMix(uint32_t hash, uint32_t value) +{ + hash ^= value & 0xFFu; + hash *= 16777619u; + hash ^= (value >> 8) & 0xFFu; + hash *= 16777619u; + hash ^= (value >> 16) & 0xFFu; + hash *= 16777619u; + hash ^= (value >> 24) & 0xFFu; + hash *= 16777619u; + return hash; +} + +uint32_t RXTX_LOG_K5ViewerSignature(void) +{ + uint32_t hash = 2166136261u; + + hash = RXTX_LOG_K5ViewerMix(hash, gSessionActive); + hash = RXTX_LOG_K5ViewerMix(hash, gClearActive); + hash = RXTX_LOG_K5ViewerMix(hash, gLogHasTraffic); + hash = RXTX_LOG_K5ViewerMix(hash, gNextTrafficSequence); + hash = RXTX_LOG_K5ViewerMix(hash, gSessionFlags); + hash = RXTX_LOG_K5ViewerMix(hash, gSessionFrequency); + hash = RXTX_LOG_K5ViewerMix(hash, gSessionChannel); + // Mix the exported seconds, not the raw ticks: two consecutive ticks + // map to the same durationSeconds, hashing them would resend an + // identical packet every 500 ms during an active session. + hash = RXTX_LOG_K5ViewerMix(hash, (uint32_t)((gSessionTicks500ms + 1u) / 2u)); + hash = RXTX_LOG_K5ViewerMix(hash, gSessionSMeter); + hash = RXTX_LOG_K5ViewerMix(hash, gSessionBattVolt); + + return hash; +} + +static void RXTX_LOG_SetK5ViewerChannelName(RXTX_LogK5ViewerRow_t *row, uint16_t channel) +{ + memset(row->channelName, 0, sizeof(row->channelName)); + + if (channel == RXTX_LOG_CHANNEL_NONE) + return; + + char name[RXTX_LOG_K5VIEWER_NAME_LENGTH + 1u]; + SETTINGS_FetchChannelName(name, channel); + for (uint8_t i = 0; i < RXTX_LOG_K5VIEWER_NAME_LENGTH && name[i] != 0; i++) + row->channelName[i] = name[i]; +} + +static void RXTX_LOG_CopyK5ViewerRow(RXTX_LogK5ViewerRow_t *dst, const RXTX_LogFlashEntry_t *src) +{ + dst->frequency = src->frequency; + dst->trafficSeq = src->trafficSeq; + dst->durationSeconds = src->durationSeconds; + dst->channel = src->channel; + dst->flags = src->flags; + dst->meter = src->sMeter; + dst->battVolt = src->battVolt; + RXTX_LOG_SetK5ViewerChannelName(dst, src->channel); +} + +// Stream the whole packet through `send` without ever holding it in RAM: +// peak stack stays at one row plus one flash entry. The row area is always +// full-length; rowCount is not known before scanning, so the header +// announces every slot and padding rows are all-zero (the viewer already +// skips rows with frequency == 0). +void RXTX_LOG_SendK5ViewerPacket(void (*send)(const uint8_t *data, uint16_t size)) +{ + RXTX_LogK5ViewerRow_t row; + uint8_t rowsSent = 0; + + uint8_t header[4] = {RXTX_LOG_K5VIEWER_VERSION, 0, RXTX_LOG_K5VIEWER_ROW_COUNT, 0}; + if (gSessionActive) + header[1] |= RXTX_LOG_K5VIEWER_STATUS_ACTIVE; + if (gLogHasTraffic) + header[1] |= RXTX_LOG_K5VIEWER_STATUS_HAS_TRAFFIC; + if (gClearActive) + header[1] |= RXTX_LOG_K5VIEWER_STATUS_CLEARING; + send(header, sizeof(header)); + + row.frequency = gSessionFrequency; + row.trafficSeq = gNextTrafficSequence; + row.durationSeconds = (gSessionTicks500ms + 1u) / 2u; + row.channel = gSessionChannel; + row.flags = gSessionFlags; + row.meter = gSessionSMeter; + row.battVolt = gSessionBattVolt; + RXTX_LOG_SetK5ViewerChannelName(&row, gSessionChannel); + send((const uint8_t *)&row, sizeof(row)); + + if (gLogHasTraffic) { + uint16_t slot = RXTX_LOG_AddressToSlot(gNextFlashAddress); + for (uint16_t scanned = 0; + scanned < RXTX_LOG_SLOT_COUNT && rowsSent < RXTX_LOG_K5VIEWER_ROW_COUNT; + scanned++) + { + RXTX_LogFlashEntry_t flashEntry; + + slot = RXTX_LOG_PreviousSlot(slot); + PY25Q16_ReadBuffer(RXTX_LOG_SlotToAddress(slot), &flashEntry, sizeof(flashEntry)); + + if (RXTX_LOG_IsBlankFlashEntry(&flashEntry)) + break; + + if (!RXTX_LOG_IsValidFlashEntry(&flashEntry) || + !RXTX_LOG_IsTrafficFlags(flashEntry.flags)) + continue; + + RXTX_LOG_CopyK5ViewerRow(&row, &flashEntry); + send((const uint8_t *)&row, sizeof(row)); + rowsSent++; + } + } + + memset(&row, 0, sizeof(row)); + for (; rowsSent < RXTX_LOG_K5VIEWER_ROW_COUNT; rowsSent++) + send((const uint8_t *)&row, sizeof(row)); +} +#endif + static void RXTX_LOG_CaptureSession(uint8_t flags, const VFO_Info_t *vfo) { if (gClearActive) diff --git a/App/app/rxtx_log.h b/App/app/rxtx_log.h index 94d47f1f..0a9ca573 100644 --- a/App/app/rxtx_log.h +++ b/App/app/rxtx_log.h @@ -21,8 +21,8 @@ // (rxtx_log.c) so both layouts match byte-for-byte up to and including // battVolt, copied in one pass. Scan-only fields (sequence) sit past the // copied prefix in the flash layout and are not cached in RAM. -// The channel name is not stored: it is resolved from `channel` at display -// time via SETTINGS_FetchChannelName. +// The channel name is not stored in flash: it is resolved from `channel` when +// exporting the K5Viewer packet. typedef struct { uint32_t frequency; uint32_t trafficSeq; @@ -33,6 +33,35 @@ typedef struct { uint8_t battVolt; } RXTX_LogEntry_t; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER +#define RXTX_LOG_K5VIEWER_VERSION 2u +#define RXTX_LOG_K5VIEWER_ROW_COUNT 64u +#define RXTX_LOG_K5VIEWER_NAME_LENGTH 10u +#define RXTX_LOG_K5VIEWER_STATUS_ACTIVE (1u << 0) +#define RXTX_LOG_K5VIEWER_STATUS_HAS_TRAFFIC (1u << 1) +#define RXTX_LOG_K5VIEWER_STATUS_CLEARING (1u << 2) + +typedef struct __attribute__((packed)) { + uint32_t frequency; + uint32_t trafficSeq; + uint16_t durationSeconds; + uint16_t channel; + uint8_t flags; + uint8_t meter; + uint8_t battVolt; + char channelName[RXTX_LOG_K5VIEWER_NAME_LENGTH]; +} RXTX_LogK5ViewerRow_t; + +// On-wire packet layout: 4-byte header (version, status, rowCount, +// reserved), one live row, then RXTX_LOG_K5VIEWER_ROW_COUNT row slots, +// zero-padded past the last valid row. The packet is streamed row by row +// and never built whole in RAM, so only its size is defined here. +#define RXTX_LOG_K5VIEWER_PACKET_SIZE (4u + ((RXTX_LOG_K5VIEWER_ROW_COUNT + 1u) * sizeof(RXTX_LogK5ViewerRow_t))) + +uint32_t RXTX_LOG_K5ViewerSignature(void); +void RXTX_LOG_SendK5ViewerPacket(void (*send)(const uint8_t *data, uint16_t size)); +#endif + void RXTX_LOG_Init(void); void RXTX_LOG_BeginRx(const VFO_Info_t *vfo, FUNCTION_Type_t function); void RXTX_LOG_BeginTx(const VFO_Info_t *vfo); diff --git a/App/driver/keyboard.c b/App/driver/keyboard.c index 0634ac1e..b8a8d983 100644 --- a/App/driver/keyboard.c +++ b/App/driver/keyboard.c @@ -39,8 +39,14 @@ bool gWasFKeyPressed = false; // Packet types for serial key injection (K5Viewer → radio) #define SERIAL_KEY_TYPE 0x03 #define SERIAL_KEY_TYPE_LONG 0x04 +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER +#define SERIAL_FEATURE_TYPE 0x05 +#endif volatile KEY_Code_t gKeyFromSerial = KEY_INVALID; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER +volatile uint8_t gSerialViewerFeatures = 0; +#endif static uint8_t gSerialKeyHoldCount = 0; static uint8_t gSerialKeyLong = 0; // 0 = short press, 1 = long press @@ -71,13 +77,35 @@ bool KEYBOARD_ProcessProtocolByte(ParseState_t *state, uint8_t b) break; case STATE_KA_2: +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER + if (b == 0x00) + *state = STATE_KA_3; + else if (b == SERIAL_FEATURE_TYPE) + *state = STATE_KA_FEATURE; + else + *state = STATE_IDLE; +#else *state = (b == 0x00) ? STATE_KA_3 : STATE_IDLE; +#endif break; - + case STATE_KA_3: - if (b == 0x00) connected = true; + if (b == 0x00) { +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER + gSerialViewerFeatures = 0; +#endif + connected = true; + } *state = STATE_IDLE; break; + +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER + case STATE_KA_FEATURE: + gSerialViewerFeatures = b; + connected = true; + *state = STATE_IDLE; + break; +#endif case STATE_KEY_1: *state = (b == 0x55) ? STATE_KEY_2 : STATE_IDLE; @@ -265,4 +293,4 @@ KEY_Code_t KEYBOARD_GetKey(void) void HideFKeyIcon(void) { gWasFKeyPressed = false; gUpdateStatus = true; -} \ No newline at end of file +} diff --git a/App/driver/keyboard.h b/App/driver/keyboard.h index 3fe092a9..7450308e 100644 --- a/App/driver/keyboard.h +++ b/App/driver/keyboard.h @@ -50,6 +50,9 @@ typedef enum { STATE_KA_1, STATE_KA_2, STATE_KA_3, +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER + STATE_KA_FEATURE, +#endif STATE_KEY_1, STATE_KEY_2, STATE_KEY_3, @@ -65,6 +68,13 @@ extern bool gWasFKeyPressed; // Serial-injected key (written by UART/VCP parser, consumed by KEYBOARD_Poll). extern volatile KEY_Code_t gKeyFromSerial; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER +#define SERIAL_VIEWER_FEATURE_RF_LOG 0x01u + +// Extension flags announced by the viewer's feature keepalive. +extern volatile uint8_t gSerialViewerFeatures; +#endif + bool KEYBOARD_ProcessProtocolByte(ParseState_t *state, uint8_t b); #endif @@ -73,4 +83,4 @@ KEY_Code_t KEYBOARD_GetKey(void); void HideFKeyIcon(void); -#endif \ No newline at end of file +#endif diff --git a/App/driver/vcp.c b/App/driver/vcp.c index 021b6359..e514ea73 100644 --- a/App/driver/vcp.c +++ b/App/driver/vcp.c @@ -46,15 +46,19 @@ void VCP_Init() bool VCP_ScreenshotPing(void) { // State machine for parsing incoming packets: - // Keepalive: 0x55 0xAA 0x00 0x00 → viewer alive - // Short key press: 0xAA 0x55 0x03 → inject short press - // Long key press: 0xAA 0x55 0x04 → inject long press + // Keepalive: 0x55 0xAA 0x00 0x00 → viewer alive, no extensions + // Feature keepalive (ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER builds only): + // 0x55 0xAA 0x05 → viewer alive, extensions enabled + // flags bit 0 = RF log stream + // Short key press: 0xAA 0x55 0x03 → inject short press + // Long key press: 0xAA 0x55 0x04 → inject long press // // State transitions: // IDLE → 0x55 → KA_1 // KA_1 → 0xAA → KA_2 (else IDLE) - // KA_2 → 0x00 → KA_3 (else IDLE) - // KA_3 → 0x00 → keepalive OK, IDLE + // KA_2 → 0x00 → KA_3 (else 0x05 → KA_FEATURE, else IDLE) + // KA_3 → 0x00 → keepalive OK, clear extensions, IDLE + // KA_FEATURE → → keepalive OK, set extensions, IDLE // // IDLE → 0xAA → KEY_1 // KEY_1 → 0x55 → KEY_2 (else IDLE) diff --git a/App/screenshot.c b/App/screenshot.c index cbd265e0..1fb09b50 100644 --- a/App/screenshot.c +++ b/App/screenshot.c @@ -21,6 +21,9 @@ #include "driver/vcp.h" #include "driver/keyboard.h" #include "driver/bk4819.h" +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER +#include "app/rxtx_log.h" +#endif // SRAM optimization: minimize static allocations // - previousHash: one fingerprint per 8-byte chunk instead of a full @@ -41,6 +44,10 @@ static uint8_t forcedBlock = 0; static uint8_t keepAlive = 3; static bool hasConnectionPing = false; static bool wasConnected = false; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER +static uint32_t previousRfLogSignature = 0; +static bool rfLogSent = false; +#endif // FNV-1a over one 8-byte chunk, folded to 16 bits static uint16_t SCREENSHOT_Hash(const uint8_t *data) @@ -83,6 +90,8 @@ enum { SCREENSHOT_CHUNKS_PER_LINE = 16, SCREENSHOT_HALF_LINE_COLUMNS = LCD_WIDTH / 2, SCREENSHOT_MARKER_BASE = 0xF0, + SCREENSHOT_TYPE_DIFF = 0x02, + SCREENSHOT_TYPE_RXTX_LOG = 0x05, SCREENSHOT_FLAG_DEEP_SLEEP = 1 << 0, SCREENSHOT_FLAG_LED_RED = 1 << 1, SCREENSHOT_FLAG_LED_GREEN = 1 << 2, @@ -106,11 +115,48 @@ static uint8_t SCREENSHOT_StateFlags(void) return flags; } +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER +static bool SCREENSHOT_HasPendingRfLogUpdate(void) +{ + if ((gSerialViewerFeatures & SERIAL_VIEWER_FEATURE_RF_LOG) == 0) + return false; + + return !rfLogSent || RXTX_LOG_K5ViewerSignature() != previousRfLogSignature; +} + +static void SCREENSHOT_SendRfLog(void) +{ + // Capture the signature before streaming: a state change landing + // during the blocking send still differs afterwards and triggers a + // resend on the next cycle. + previousRfLogSignature = RXTX_LOG_K5ViewerSignature(); + rfLogSent = true; + + const uint16_t len = RXTX_LOG_K5VIEWER_PACKET_SIZE; + uint8_t header[5] = { + 0xAA, 0x55, SCREENSHOT_TYPE_RXTX_LOG, + (uint8_t)(len >> 8), + (uint8_t)(len & 0xFF) + }; + + SCREENSHOT_Send(header, 5); + RXTX_LOG_SendK5ViewerPacket(SCREENSHOT_Send); + + uint8_t end = 0x0A; + SCREENSHOT_Send(&end, 1); +} +#endif + bool SCREENSHOT_HasPendingStateChange(void) { if (gUART_LockScreenshot > 0 || keepAlive == 0 || !hasConnectionPing) return false; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER + if (SCREENSHOT_HasPendingRfLogUpdate()) + return true; +#endif + return !wasConnected || SCREENSHOT_StateFlags() != previousStateFlags; } @@ -146,6 +192,10 @@ void SCREENSHOT_Update(bool force) wasConnected = false; hasConnectionPing = false; previousStateFlags = 0xFF; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER + gSerialViewerFeatures = 0; + rfLogSent = false; +#endif return; } } else { @@ -159,6 +209,11 @@ void SCREENSHOT_Update(bool force) const uint8_t stateFlags = SCREENSHOT_StateFlags(); const bool stateChanged = (stateFlags != previousStateFlags); +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER + const bool rfLogPending = SCREENSHOT_HasPendingRfLogUpdate(); +#else + const bool rfLogPending = false; +#endif // ==== FIRST PASS: Count changed chunks ==== uint16_t deltaLen = 0; @@ -179,7 +234,7 @@ void SCREENSHOT_Update(bool force) forcedBlock = (forcedBlock + 1) % 128; - if (deltaLen == 0 && !stateChanged) + if (deltaLen == 0 && !stateChanged && !rfLogPending) return; // Skip transmission if a key is currently pressed @@ -187,39 +242,52 @@ void SCREENSHOT_Update(bool force) if (gKeyReading0 != KEY_INVALID) return; - // ==== Send version marker and state flags ==== - // 0xF0 keeps a resync-safe marker before the standard AA 55 header. - uint8_t versionMarker = SCREENSHOT_MARKER_BASE | stateFlags; - SCREENSHOT_Send(&versionMarker, 1); +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER + // A pending RF log packet may be the only thing to send: skip the + // frame section when it carries nothing. Without the RF log stream + // the early return above already guarantees this condition. + if (deltaLen != 0 || stateChanged) +#endif + { + // ==== Send version marker and state flags ==== + // 0xF0 keeps a resync-safe marker before the standard AA 55 header. + uint8_t versionMarker = SCREENSHOT_MARKER_BASE | stateFlags; + SCREENSHOT_Send(&versionMarker, 1); - // ==== Send header ==== - uint8_t header[5] = { - 0xAA, 0x55, 0x02, - (uint8_t)(deltaLen >> 8), - (uint8_t)(deltaLen & 0xFF) - }; + // ==== Send header ==== + uint8_t header[5] = { + 0xAA, 0x55, SCREENSHOT_TYPE_DIFF, + (uint8_t)(deltaLen >> 8), + (uint8_t)(deltaLen & 0xFF) + }; - SCREENSHOT_Send(header, 5); + SCREENSHOT_Send(header, 5); - // ==== SECOND PASS: Send only changed chunks ==== - for (uint8_t chunkIdx = 0; chunkIdx < 128; chunkIdx++) { - if (!(changedBitmap[chunkIdx >> 3] & (1 << (chunkIdx & 7)))) - continue; + // ==== SECOND PASS: Send only changed chunks ==== + for (uint8_t chunkIdx = 0; chunkIdx < 128; chunkIdx++) { + if (!(changedBitmap[chunkIdx >> 3] & (1 << (chunkIdx & 7)))) + continue; - chunk[0] = chunkIdx; - SCREENSHOT_Chunk(chunkIdx, &chunk[1]); + chunk[0] = chunkIdx; + SCREENSHOT_Chunk(chunkIdx, &chunk[1]); - SCREENSHOT_Send(chunk, 9); + SCREENSHOT_Send(chunk, 9); - // Update the fingerprint only once the chunk is actually sent, - // so chunks skipped by an early return stay marked as changed. - // Hashing the recomputed payload also keeps the fingerprint in - // sync if the display buffer changed between the two passes. - previousHash[chunkIdx] = SCREENSHOT_Hash(&chunk[1]); + // Update the fingerprint only once the chunk is actually sent, + // so chunks skipped by an early return stay marked as changed. + // Hashing the recomputed payload also keeps the fingerprint in + // sync if the display buffer changed between the two passes. + previousHash[chunkIdx] = SCREENSHOT_Hash(&chunk[1]); + } + + uint8_t end = 0x0A; + SCREENSHOT_Send(&end, 1); } - uint8_t end = 0x0A; - SCREENSHOT_Send(&end, 1); +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER + if (rfLogPending) + SCREENSHOT_SendRfLog(); +#endif previousStateFlags = stateFlags; wasConnected = true; diff --git a/CMakePresets.json b/CMakePresets.json index cf7a5797..43895a67 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -74,6 +74,7 @@ "ENABLE_FEAT_F4HWN_BEAM": false, "ENABLE_FEAT_F4HWN_RXTX_LOG": false, "ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP": false, + "ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER": false, "ENABLE_FEAT_F4HWN_LOGO": false, "ENABLE_FEAT_F4HWN_LOGO_SAV": false, "ENABLE_AGC_SHOW_DATA": false, @@ -207,6 +208,7 @@ "ENABLE_FEAT_F4HWN_BEAM": true, "ENABLE_FEAT_F4HWN_RXTX_LOG": true, "ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP": false, + "ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER": false, "ENABLE_FEAT_F4HWN_QRCODE": true, "ENABLE_FEAT_F4HWN_MEM": true, "ENABLE_FEAT_F4HWN_LOGO": true, From 46034ec39f123a64dc499bfc052a277e2637bdeb Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Thu, 9 Jul 2026 05:22:39 +0200 Subject: [PATCH 29/37] Rename screenshot module to k5viewer --- App/CMakeLists.txt | 8 +-- App/app/aircopy.c | 16 ++--- App/app/app.c | 20 +++--- App/app/breakout.c | 12 ++-- App/app/spectrum.c | 14 ++-- App/app/uart.c | 4 +- App/driver/keyboard.c | 4 +- App/driver/keyboard.h | 2 +- App/driver/st7565.c | 6 +- App/driver/uart.c | 4 +- App/driver/uart.h | 2 +- App/driver/vcp.c | 8 +-- App/driver/vcp.h | 4 +- App/{screenshot.c => k5viewer.c} | 108 +++++++++++++++---------------- App/{screenshot.h => k5viewer.h} | 10 +-- App/misc.c | 14 ++-- App/misc.h | 8 +-- App/ui/welcome.c | 8 +-- CMakePresets.json | 14 ++-- 19 files changed, 133 insertions(+), 133 deletions(-) rename App/{screenshot.c => k5viewer.c} (72%) rename App/{screenshot.h => k5viewer.h} (80%) diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index e5060527..0fdd20ce 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -180,8 +180,8 @@ enable_feature(ENABLE_FEAT_F4HWN) enable_feature(ENABLE_FEAT_F4HWN_GAME app/breakout.c ) -enable_feature(ENABLE_FEAT_F4HWN_SCREENSHOT - screenshot.c +enable_feature(ENABLE_FEAT_F4HWN_K5VIEWER + k5viewer.c ) enable_feature(ENABLE_FEAT_F4HWN_SPECTRUM) enable_feature(ENABLE_FEAT_F4HWN_RX_TX_TIMER) @@ -213,8 +213,8 @@ enable_feature(ENABLE_FEAT_F4HWN_RXTX_LOG enable_feature(ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP) enable_feature(ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER) -if(ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER AND NOT (ENABLE_FEAT_F4HWN_RXTX_LOG AND ENABLE_FEAT_F4HWN_SCREENSHOT)) - message(FATAL_ERROR "ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER requires ENABLE_FEAT_F4HWN_RXTX_LOG (log data) and ENABLE_FEAT_F4HWN_SCREENSHOT (serial transport).") +if(ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER AND NOT (ENABLE_FEAT_F4HWN_RXTX_LOG AND ENABLE_FEAT_F4HWN_K5VIEWER)) + message(FATAL_ERROR "ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER requires ENABLE_FEAT_F4HWN_RXTX_LOG (log data) and ENABLE_FEAT_F4HWN_K5VIEWER (serial transport).") endif() if(ENABLE_FEAT_F4HWN_BEAM AND NOT ENABLE_AIRCOPY) diff --git a/App/app/aircopy.c b/App/app/aircopy.c index 884ff90c..8ff485e5 100644 --- a/App/app/aircopy.c +++ b/App/app/aircopy.c @@ -30,8 +30,8 @@ #include "settings.h" #include -#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT -#include "screenshot.h" +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER +#include "k5viewer.h" #endif static const uint16_t Obfuscation[8] = { 0x6C16, 0xE614, 0x912E, 0x400D, 0x3521, 0x40D5, 0x0313, 0x80E9 }; @@ -140,8 +140,8 @@ static void AIRCOPY_clear() { crc[i] = 0; } - #ifdef ENABLE_FEAT_F4HWN_SCREENSHOT - SCREENSHOT_Update(true); + #ifdef ENABLE_FEAT_F4HWN_K5VIEWER + K5VIEWER_Update(true); #endif } @@ -170,8 +170,8 @@ static inline void AIRCOPY_CheckComplete(uint16_t *num) if (done >= map->total_blocks) { gAircopyState = AIRCOPY_COMPLETE; -#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT - SCREENSHOT_Update(false); +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER + K5VIEWER_Update(false); #endif } } @@ -222,8 +222,8 @@ bool AIRCOPY_SendMessage(void) // Check if transfer is complete if (CurrentSegmentIndex >= map->num_segments) { gAircopyState = AIRCOPY_COMPLETE; - #ifdef ENABLE_FEAT_F4HWN_SCREENSHOT - SCREENSHOT_Update(false); + #ifdef ENABLE_FEAT_F4HWN_K5VIEWER + K5VIEWER_Update(false); #endif return 0; } diff --git a/App/app/app.c b/App/app/app.c index 84890f2a..b6b02ecf 100644 --- a/App/app/app.c +++ b/App/app/app.c @@ -83,8 +83,8 @@ #include "ui/ui.h" #include "ui/welcome.h" -#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT - #include "screenshot.h" +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER + #include "k5viewer.h" #endif static bool flagSaveVfo; @@ -230,8 +230,8 @@ static void ScreenSaverRenderLogoPlus(bool reset) static void ScreenSaverUpdateViewer(void) { -#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT - SCREENSHOT_Update(false); +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER + K5VIEWER_Update(false); #endif } @@ -1121,10 +1121,10 @@ static void HandleVox(void) void APP_Update(void) { -#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER // Parse incoming packets on every tick so serial keys are never missed, // regardless of whether the screen needs redrawing. - SCREENSHOT_ParseInput(); + K5VIEWER_ParseInput(); #endif #ifdef ENABLE_VOICE @@ -1666,15 +1666,15 @@ void APP_TimeSlice10ms(void) UI_DisplayStatus(); } - #ifdef ENABLE_FEAT_F4HWN_SCREENSHOT + #ifdef ENABLE_FEAT_F4HWN_K5VIEWER if (gUpdateDisplayCurrent || gUpdateStatusCurrent #ifdef ENABLE_FEAT_F4HWN_LOGO_SAV || screenSaverRendered #endif ) { - SCREENSHOT_Update(false); - } else if (SCREENSHOT_HasPendingStateChange()) { - SCREENSHOT_Update(false); + K5VIEWER_Update(false); + } else if (K5VIEWER_HasPendingStateChange()) { + K5VIEWER_Update(false); } #endif diff --git a/App/app/breakout.c b/App/app/breakout.c index e9867a25..6059eb63 100644 --- a/App/app/breakout.c +++ b/App/app/breakout.c @@ -16,8 +16,8 @@ #include "app/breakout.h" -#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT -#include "screenshot.h" +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER +#include "k5viewer.h" #endif static uint32_t randSeed = 1; @@ -304,10 +304,10 @@ static void OnKeyDown(uint8_t key) // HandleUserInput static bool HandleUserInput() { -#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER // Parse incoming packets on every tick so serial keys are never missed, // regardless of whether the screen needs redrawing. - SCREENSHOT_ParseInput(); + K5VIEWER_ParseInput(); #endif kbd.prev = kbd.current; @@ -388,10 +388,10 @@ void APP_RunBreakout(void) { ST7565_BlitStatusLine(); // Blank status line ST7565_BlitFullScreen(); - #ifdef ENABLE_FEAT_F4HWN_SCREENSHOT + #ifdef ENABLE_FEAT_F4HWN_K5VIEWER if(isPaused || swap == 0) { - SCREENSHOT_Update(false); + K5VIEWER_Update(false); } #endif } diff --git a/App/app/spectrum.c b/App/app/spectrum.c index a57a552e..29b51a52 100644 --- a/App/app/spectrum.c +++ b/App/app/spectrum.c @@ -27,8 +27,8 @@ #include "ui/helper.h" #include "ui/main.h" -#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT -#include "screenshot.h" +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER +#include "k5viewer.h" #endif #ifdef ENABLE_FEAT_F4HWN_SPECTRUM @@ -2461,10 +2461,10 @@ static void UpdateListening() static void Tick() { -#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER // Parse incoming packets on every tick so serial keys are never missed, // regardless of whether the screen needs redrawing. - SCREENSHOT_ParseInput(); + K5VIEWER_ParseInput(); #endif if (gNextTimeslice) @@ -2532,9 +2532,9 @@ static void Tick() if (redrawScreen || ++renderTimer >= RENDER_PERIOD_TICKS) { Render(); - // For screenshot - #ifdef ENABLE_FEAT_F4HWN_SCREENSHOT - SCREENSHOT_Update(false); + // For K5Viewer + #ifdef ENABLE_FEAT_F4HWN_K5VIEWER + K5VIEWER_Update(false); #endif redrawScreen = false; renderTimer = 0; diff --git a/App/app/uart.c b/App/app/uart.c index 8f7d592b..7d14e65e 100644 --- a/App/app/uart.c +++ b/App/app/uart.c @@ -863,7 +863,7 @@ void UART_HandleCommand(uint32_t Port) #endif } // switch - #ifdef ENABLE_FEAT_F4HWN_SCREENSHOT - gUART_LockScreenshot = 20; // lock screenshot + #ifdef ENABLE_FEAT_F4HWN_K5VIEWER + gUART_LockK5Viewer = 20; // lock the K5Viewer stream #endif } \ No newline at end of file diff --git a/App/driver/keyboard.c b/App/driver/keyboard.c index b8a8d983..42ca3c3b 100644 --- a/App/driver/keyboard.c +++ b/App/driver/keyboard.c @@ -27,7 +27,7 @@ KEY_Code_t gKeyReading1 = KEY_INVALID; uint16_t gDebounceCounter = 0; bool gWasFKeyPressed = false; -#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER // Short press: hold key for SERIAL_KEY_SHORT_POLLS calls. // Must exceed key_debounce_10ms (2) to trigger ProcessKey(key, true, false). #define SERIAL_KEY_SHORT_POLLS 5 @@ -184,7 +184,7 @@ static const KEY_Code_t keyboard[5][4] = { KEY_Code_t KEYBOARD_Poll(void) { -#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER // Serial-injected key: hold it for SHORT or LONG polls depending on press type, // so the debounce counter in app.c reaches the right threshold: // - Short: key_debounce_10ms (2) → ProcessKey(key, true, false) diff --git a/App/driver/keyboard.h b/App/driver/keyboard.h index 7450308e..579063b4 100644 --- a/App/driver/keyboard.h +++ b/App/driver/keyboard.h @@ -64,7 +64,7 @@ extern KEY_Code_t gKeyReading1; extern uint16_t gDebounceCounter; extern bool gWasFKeyPressed; -#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER // Serial-injected key (written by UART/VCP parser, consumed by KEYBOARD_Poll). extern volatile KEY_Code_t gKeyFromSerial; diff --git a/App/driver/st7565.c b/App/driver/st7565.c index b6b0a655..c488bb68 100644 --- a/App/driver/st7565.c +++ b/App/driver/st7565.c @@ -24,7 +24,7 @@ #include "driver/st7565.h" #include "driver/system.h" #include "misc.h" -#include "screenshot.h" +#include "k5viewer.h" #define SPIx SPI1 @@ -164,8 +164,8 @@ void ST7565_DrawLine(const unsigned int Column, const unsigned int Line, const u void ST7565_BlitLine(unsigned line) { ST7565_BlitScreen(line + 1); - #ifdef ENABLE_FEAT_F4HWN_SCREENSHOT - SCREENSHOT_Update(true); // Force immediate capture + #ifdef ENABLE_FEAT_F4HWN_K5VIEWER + K5VIEWER_Update(true); // Force immediate capture #endif } diff --git a/App/driver/uart.c b/App/driver/uart.c index b9b0e2a6..661bdb98 100644 --- a/App/driver/uart.c +++ b/App/driver/uart.c @@ -23,7 +23,7 @@ #include "py32f071_ll_gpio.h" #include "py32f071_ll_usart.h" -#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER #include "driver/keyboard.h" #endif @@ -150,7 +150,7 @@ void UART_LogSend(const void *pBuffer, uint32_t Size) } } -#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER bool UART_IsCableConnected(void) { static uint8_t read_ptr = 0; static ParseState_t state = STATE_IDLE; diff --git a/App/driver/uart.h b/App/driver/uart.h index 70bd401c..c1f27269 100644 --- a/App/driver/uart.h +++ b/App/driver/uart.h @@ -27,7 +27,7 @@ void UART_Init(void); void UART_Send(const void *pBuffer, uint32_t Size); void UART_LogSend(const void *pBuffer, uint32_t Size); -#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER bool UART_IsCableConnected(void); #endif diff --git a/App/driver/vcp.c b/App/driver/vcp.c index e514ea73..4f96d560 100644 --- a/App/driver/vcp.c +++ b/App/driver/vcp.c @@ -18,7 +18,7 @@ #include "usb_config.h" #include "py32f071_ll_bus.h" -#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER #include "driver/keyboard.h" #endif @@ -42,8 +42,8 @@ void VCP_Init() NVIC_EnableIRQ(USBD_IRQn); } -#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT -bool VCP_ScreenshotPing(void) +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER +bool VCP_K5ViewerPing(void) { // State machine for parsing incoming packets: // Keepalive: 0x55 0xAA 0x00 0x00 → viewer alive, no extensions @@ -93,4 +93,4 @@ bool VCP_ScreenshotPing(void) return connected; } -#endif // ENABLE_FEAT_F4HWN_SCREENSHOT +#endif // ENABLE_FEAT_F4HWN_K5VIEWER diff --git a/App/driver/vcp.h b/App/driver/vcp.h index 0346dca1..4d5fdd1d 100644 --- a/App/driver/vcp.h +++ b/App/driver/vcp.h @@ -29,8 +29,8 @@ extern volatile uint32_t VCP_RxBufPointer; void VCP_Init(); -#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT -bool VCP_ScreenshotPing(void); +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER +bool VCP_K5ViewerPing(void); #endif static inline void VCP_Send(const uint8_t *Buf, uint32_t Size) diff --git a/App/screenshot.c b/App/k5viewer.c similarity index 72% rename from App/screenshot.c rename to App/k5viewer.c index 1fb09b50..3f2f367c 100644 --- a/App/screenshot.c +++ b/App/k5viewer.c @@ -16,7 +16,7 @@ #include "debugging.h" #include "driver/st7565.h" -#include "screenshot.h" +#include "k5viewer.h" #include "misc.h" #include "driver/vcp.h" #include "driver/keyboard.h" @@ -50,7 +50,7 @@ static bool rfLogSent = false; #endif // FNV-1a over one 8-byte chunk, folded to 16 bits -static uint16_t SCREENSHOT_Hash(const uint8_t *data) +static uint16_t K5VIEWER_Hash(const uint8_t *data) { uint32_t h = 2166136261u; for (uint8_t i = 0; i < 8; i++) { @@ -59,26 +59,26 @@ static uint16_t SCREENSHOT_Hash(const uint8_t *data) return (uint16_t)(h ^ (h >> 16)); } -void SCREENSHOT_ParseInput(void) +void K5VIEWER_ParseInput(void) { - if (SCREENSHOT_IsLocked()) + if (K5VIEWER_IsLocked()) return; if (UART_IsCableConnected()) { keepAlive = 15; hasConnectionPing = true; - gUSB_ScreenshotEnabled = false; + gUSB_K5ViewerEnabled = false; } - else if (VCP_ScreenshotPing()) { + else if (VCP_K5ViewerPing()) { keepAlive = 15; hasConnectionPing = true; - gUSB_ScreenshotEnabled = true; + gUSB_K5ViewerEnabled = true; } } -static void SCREENSHOT_Send(const uint8_t *buf, uint16_t len) +static void K5VIEWER_Send(const uint8_t *buf, uint16_t len) { - if (gUSB_ScreenshotEnabled) { + if (gUSB_K5ViewerEnabled) { cdc_acm_data_send_with_dtr(buf, len); } else { UART_Send(buf, len); @@ -86,37 +86,37 @@ static void SCREENSHOT_Send(const uint8_t *buf, uint16_t len) } enum { - SCREENSHOT_CHUNK_SIZE = 8, - SCREENSHOT_CHUNKS_PER_LINE = 16, - SCREENSHOT_HALF_LINE_COLUMNS = LCD_WIDTH / 2, - SCREENSHOT_MARKER_BASE = 0xF0, - SCREENSHOT_TYPE_DIFF = 0x02, - SCREENSHOT_TYPE_RXTX_LOG = 0x05, - SCREENSHOT_FLAG_DEEP_SLEEP = 1 << 0, - SCREENSHOT_FLAG_LED_RED = 1 << 1, - SCREENSHOT_FLAG_LED_GREEN = 1 << 2, + K5VIEWER_CHUNK_SIZE = 8, + K5VIEWER_CHUNKS_PER_LINE = 16, + K5VIEWER_HALF_LINE_COLUMNS = LCD_WIDTH / 2, + K5VIEWER_MARKER_BASE = 0xF0, + K5VIEWER_TYPE_DIFF = 0x02, + K5VIEWER_TYPE_RXTX_LOG = 0x05, + K5VIEWER_FLAG_DEEP_SLEEP = 1 << 0, + K5VIEWER_FLAG_LED_RED = 1 << 1, + K5VIEWER_FLAG_LED_GREEN = 1 << 2, }; -static uint8_t SCREENSHOT_StateFlags(void) +static uint8_t K5VIEWER_StateFlags(void) { uint8_t flags = 0; #ifdef ENABLE_FEAT_F4HWN_SLEEP if (gWakeUp) - flags |= SCREENSHOT_FLAG_DEEP_SLEEP; + flags |= K5VIEWER_FLAG_DEEP_SLEEP; #endif if (BK4819_IsGpioOutSet(BK4819_GPIO5_PIN1_RED)) - flags |= SCREENSHOT_FLAG_LED_RED; + flags |= K5VIEWER_FLAG_LED_RED; if (BK4819_IsGpioOutSet(BK4819_GPIO6_PIN2_GREEN)) - flags |= SCREENSHOT_FLAG_LED_GREEN; + flags |= K5VIEWER_FLAG_LED_GREEN; return flags; } #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER -static bool SCREENSHOT_HasPendingRfLogUpdate(void) +static bool K5VIEWER_HasPendingRfLogUpdate(void) { if ((gSerialViewerFeatures & SERIAL_VIEWER_FEATURE_RF_LOG) == 0) return false; @@ -124,7 +124,7 @@ static bool SCREENSHOT_HasPendingRfLogUpdate(void) return !rfLogSent || RXTX_LOG_K5ViewerSignature() != previousRfLogSignature; } -static void SCREENSHOT_SendRfLog(void) +static void K5VIEWER_SendRfLog(void) { // Capture the signature before streaming: a state change landing // during the blocking send still differs afterwards and triggers a @@ -134,56 +134,56 @@ static void SCREENSHOT_SendRfLog(void) const uint16_t len = RXTX_LOG_K5VIEWER_PACKET_SIZE; uint8_t header[5] = { - 0xAA, 0x55, SCREENSHOT_TYPE_RXTX_LOG, + 0xAA, 0x55, K5VIEWER_TYPE_RXTX_LOG, (uint8_t)(len >> 8), (uint8_t)(len & 0xFF) }; - SCREENSHOT_Send(header, 5); - RXTX_LOG_SendK5ViewerPacket(SCREENSHOT_Send); + K5VIEWER_Send(header, 5); + RXTX_LOG_SendK5ViewerPacket(K5VIEWER_Send); uint8_t end = 0x0A; - SCREENSHOT_Send(&end, 1); + K5VIEWER_Send(&end, 1); } #endif -bool SCREENSHOT_HasPendingStateChange(void) +bool K5VIEWER_HasPendingStateChange(void) { - if (gUART_LockScreenshot > 0 || keepAlive == 0 || !hasConnectionPing) + if (gUART_LockK5Viewer > 0 || keepAlive == 0 || !hasConnectionPing) return false; #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER - if (SCREENSHOT_HasPendingRfLogUpdate()) + if (K5VIEWER_HasPendingRfLogUpdate()) return true; #endif - return !wasConnected || SCREENSHOT_StateFlags() != previousStateFlags; + return !wasConnected || K5VIEWER_StateFlags() != previousStateFlags; } // Compute one 8-byte output chunk directly from the display buffers. // Frame layout: per line (status + 7 frame lines), 8 bit layers of // 16 bytes each. Each bit layer is split into two 64-column chunks. -static void SCREENSHOT_Chunk(uint8_t chunkIdx, uint8_t *dest) +static void K5VIEWER_Chunk(uint8_t chunkIdx, uint8_t *dest) { - const uint8_t chunkInLine = chunkIdx % SCREENSHOT_CHUNKS_PER_LINE; - const uint8_t line = chunkIdx / SCREENSHOT_CHUNKS_PER_LINE; + const uint8_t chunkInLine = chunkIdx % K5VIEWER_CHUNKS_PER_LINE; + const uint8_t line = chunkIdx / K5VIEWER_CHUNKS_PER_LINE; const uint8_t bit = chunkInLine / 2; - const uint8_t columnBase = (chunkInLine % 2) * SCREENSHOT_HALF_LINE_COLUMNS; + const uint8_t columnBase = (chunkInLine % 2) * K5VIEWER_HALF_LINE_COLUMNS; const uint8_t *src = (line == 0 ? gStatusLine : gFrameBuffer[line - 1]) + columnBase; - for (uint8_t j = 0; j < SCREENSHOT_CHUNK_SIZE; j++) { + for (uint8_t j = 0; j < K5VIEWER_CHUNK_SIZE; j++) { uint8_t acc = 0; - for (uint8_t k = 0; k < SCREENSHOT_CHUNK_SIZE; k++) { - if (src[j * SCREENSHOT_CHUNK_SIZE + k] & (1 << bit)) acc |= (1 << k); + for (uint8_t k = 0; k < K5VIEWER_CHUNK_SIZE; k++) { + if (src[j * K5VIEWER_CHUNK_SIZE + k] & (1 << bit)) acc |= (1 << k); } dest[j] = gSetting_set_inv ? ~acc : acc; } } -void SCREENSHOT_Update(bool force) +void K5VIEWER_Update(bool force) { - if (SCREENSHOT_IsLocked()) + if (K5VIEWER_IsLocked()) return; if (keepAlive > 0) { @@ -207,10 +207,10 @@ void SCREENSHOT_Update(bool force) force = true; } - const uint8_t stateFlags = SCREENSHOT_StateFlags(); + const uint8_t stateFlags = K5VIEWER_StateFlags(); const bool stateChanged = (stateFlags != previousStateFlags); #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER - const bool rfLogPending = SCREENSHOT_HasPendingRfLogUpdate(); + const bool rfLogPending = K5VIEWER_HasPendingRfLogUpdate(); #else const bool rfLogPending = false; #endif @@ -221,9 +221,9 @@ void SCREENSHOT_Update(bool force) uint8_t chunk[9]; // [0] = index, [1..8] = payload for (uint8_t chunkIdx = 0; chunkIdx < 128; chunkIdx++) { - SCREENSHOT_Chunk(chunkIdx, &chunk[1]); + K5VIEWER_Chunk(chunkIdx, &chunk[1]); - bool changed = SCREENSHOT_Hash(&chunk[1]) != previousHash[chunkIdx]; + bool changed = K5VIEWER_Hash(&chunk[1]) != previousHash[chunkIdx]; bool isForced = (chunkIdx == forcedBlock); if (changed || isForced || force) { @@ -251,17 +251,17 @@ void SCREENSHOT_Update(bool force) { // ==== Send version marker and state flags ==== // 0xF0 keeps a resync-safe marker before the standard AA 55 header. - uint8_t versionMarker = SCREENSHOT_MARKER_BASE | stateFlags; - SCREENSHOT_Send(&versionMarker, 1); + uint8_t versionMarker = K5VIEWER_MARKER_BASE | stateFlags; + K5VIEWER_Send(&versionMarker, 1); // ==== Send header ==== uint8_t header[5] = { - 0xAA, 0x55, SCREENSHOT_TYPE_DIFF, + 0xAA, 0x55, K5VIEWER_TYPE_DIFF, (uint8_t)(deltaLen >> 8), (uint8_t)(deltaLen & 0xFF) }; - SCREENSHOT_Send(header, 5); + K5VIEWER_Send(header, 5); // ==== SECOND PASS: Send only changed chunks ==== for (uint8_t chunkIdx = 0; chunkIdx < 128; chunkIdx++) { @@ -269,24 +269,24 @@ void SCREENSHOT_Update(bool force) continue; chunk[0] = chunkIdx; - SCREENSHOT_Chunk(chunkIdx, &chunk[1]); + K5VIEWER_Chunk(chunkIdx, &chunk[1]); - SCREENSHOT_Send(chunk, 9); + K5VIEWER_Send(chunk, 9); // Update the fingerprint only once the chunk is actually sent, // so chunks skipped by an early return stay marked as changed. // Hashing the recomputed payload also keeps the fingerprint in // sync if the display buffer changed between the two passes. - previousHash[chunkIdx] = SCREENSHOT_Hash(&chunk[1]); + previousHash[chunkIdx] = K5VIEWER_Hash(&chunk[1]); } uint8_t end = 0x0A; - SCREENSHOT_Send(&end, 1); + K5VIEWER_Send(&end, 1); } #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER if (rfLogPending) - SCREENSHOT_SendRfLog(); + K5VIEWER_SendRfLog(); #endif previousStateFlags = stateFlags; diff --git a/App/screenshot.h b/App/k5viewer.h similarity index 80% rename from App/screenshot.h rename to App/k5viewer.h index a78eb892..55f2af23 100644 --- a/App/screenshot.h +++ b/App/k5viewer.h @@ -14,11 +14,11 @@ * limitations under the License. */ -#ifndef SCREENSHOT_H -#define SCREENSHOT_H +#ifndef K5VIEWER_H +#define K5VIEWER_H -void SCREENSHOT_Update(bool force); -void SCREENSHOT_ParseInput(void); -bool SCREENSHOT_HasPendingStateChange(void); +void K5VIEWER_Update(bool force); +void K5VIEWER_ParseInput(void); +bool K5VIEWER_HasPendingStateChange(void); #endif diff --git a/App/misc.c b/App/misc.c index a68e35db..5adf4648 100644 --- a/App/misc.c +++ b/App/misc.c @@ -210,9 +210,9 @@ volatile bool gTxTimeoutReached; #ifdef ENABLE_FEAT_F4HWN_RX_TX_TIMER volatile uint16_t gRxTimerCountdown_500ms; #endif - #ifdef ENABLE_FEAT_F4HWN_SCREENSHOT - volatile uint8_t gUART_LockScreenshot = 0; // lock screenshot if Chirp is used - bool gUSB_ScreenshotEnabled = false; + #ifdef ENABLE_FEAT_F4HWN_K5VIEWER + volatile uint8_t gUART_LockK5Viewer = 0; // lock the K5Viewer stream if Chirp is used + bool gUSB_K5ViewerEnabled = false; #endif #endif @@ -646,11 +646,11 @@ void MR_PrintCacheStats(void) #endif -#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT - bool SCREENSHOT_IsLocked(void) +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER + bool K5VIEWER_IsLocked(void) { - if (gUART_LockScreenshot > 0) { - gUART_LockScreenshot--; + if (gUART_LockK5Viewer > 0) { + gUART_LockK5Viewer--; return true; } diff --git a/App/misc.h b/App/misc.h index bf57cf27..9d904dd1 100644 --- a/App/misc.h +++ b/App/misc.h @@ -336,11 +336,11 @@ extern volatile bool gTxTimeoutReached; #ifdef ENABLE_FEAT_F4HWN_RX_TX_TIMER extern volatile uint16_t gRxTimerCountdown_500ms; #endif - #ifdef ENABLE_FEAT_F4HWN_SCREENSHOT - extern volatile uint8_t gUART_LockScreenshot; // lock screenshot if Chirp is used - extern bool gUSB_ScreenshotEnabled; + #ifdef ENABLE_FEAT_F4HWN_K5VIEWER + extern volatile uint8_t gUART_LockK5Viewer; // lock the K5Viewer stream if Chirp is used + extern bool gUSB_K5ViewerEnabled; - bool SCREENSHOT_IsLocked(void); + bool K5VIEWER_IsLocked(void); #endif #endif diff --git a/App/ui/welcome.c b/App/ui/welcome.c index d80d08a4..798526c5 100644 --- a/App/ui/welcome.c +++ b/App/ui/welcome.c @@ -29,8 +29,8 @@ #include "version.h" #include "bitmaps.h" -#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT - #include "screenshot.h" +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER + #include "k5viewer.h" #endif #ifdef ENABLE_FEAT_F4HWN_LOGO @@ -355,7 +355,7 @@ void UI_DisplayWelcome(void) ST7565_BlitStatusLine(); ST7565_BlitFullScreen(); - #ifdef ENABLE_FEAT_F4HWN_SCREENSHOT - SCREENSHOT_Update(true); + #ifdef ENABLE_FEAT_F4HWN_K5VIEWER + K5VIEWER_Update(true); #endif } diff --git a/CMakePresets.json b/CMakePresets.json index 43895a67..13320be2 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -47,7 +47,7 @@ "ENABLE_EXTRA_UART_CMD": false, "ENABLE_FEAT_F4HWN": true, "ENABLE_FEAT_F4HWN_GAME": false, - "ENABLE_FEAT_F4HWN_SCREENSHOT": false, + "ENABLE_FEAT_F4HWN_K5VIEWER": false, "ENABLE_FEAT_F4HWN_SPECTRUM": true, "ENABLE_FEAT_F4HWN_RX_TX_TIMER": true, "ENABLE_FEAT_F4HWN_CHARGING_C": false, @@ -100,7 +100,7 @@ "ENABLE_FMRADIO": false, "ENABLE_VOX": false, "ENABLE_AIRCOPY": true, - "ENABLE_FEAT_F4HWN_SCREENSHOT": true, + "ENABLE_FEAT_F4HWN_K5VIEWER": true, "ENABLE_FEAT_F4HWN_GAME": false, "ENABLE_FEAT_F4HWN_PMR": true, "ENABLE_FEAT_F4HWN_GMRS_FRS_MURS": true, @@ -117,7 +117,7 @@ "ENABLE_FMRADIO": true, "ENABLE_VOX": true, "ENABLE_AIRCOPY": true, - "ENABLE_FEAT_F4HWN_SCREENSHOT": true, + "ENABLE_FEAT_F4HWN_K5VIEWER": true, "ENABLE_FEAT_F4HWN_GAME": false, "ENABLE_FEAT_F4HWN_PMR": true, "ENABLE_FEAT_F4HWN_GMRS_FRS_MURS": true, @@ -134,7 +134,7 @@ "ENABLE_FMRADIO": true, "ENABLE_VOX": false, "ENABLE_AIRCOPY": false, - "ENABLE_FEAT_F4HWN_SCREENSHOT": false, + "ENABLE_FEAT_F4HWN_K5VIEWER": false, "ENABLE_FEAT_F4HWN_GAME": false, "ENABLE_FEAT_F4HWN_SPECTRUM": false, "ENABLE_FEAT_F4HWN_PMR": true, @@ -158,7 +158,7 @@ "ENABLE_FMRADIO": false, "ENABLE_VOX": true, "ENABLE_AIRCOPY": true, - "ENABLE_FEAT_F4HWN_SCREENSHOT": true, + "ENABLE_FEAT_F4HWN_K5VIEWER": true, "ENABLE_FEAT_F4HWN_GAME": false, "ENABLE_FEAT_F4HWN_PMR": true, "ENABLE_FEAT_F4HWN_GMRS_FRS_MURS": true, @@ -176,7 +176,7 @@ "ENABLE_FMRADIO": true, "ENABLE_VOX": false, "ENABLE_AIRCOPY": true, - "ENABLE_FEAT_F4HWN_SCREENSHOT": true, + "ENABLE_FEAT_F4HWN_K5VIEWER": true, "ENABLE_FEAT_F4HWN_GAME": true, "ENABLE_FEAT_F4HWN_PMR": true, "ENABLE_FEAT_F4HWN_GMRS_FRS_MURS": true, @@ -193,7 +193,7 @@ "ENABLE_FMRADIO": true, "ENABLE_VOX": true, "ENABLE_AIRCOPY": true, - "ENABLE_FEAT_F4HWN_SCREENSHOT": true, + "ENABLE_FEAT_F4HWN_K5VIEWER": true, "ENABLE_FEAT_F4HWN_GAME": true, "ENABLE_FEAT_F4HWN_PMR": true, "ENABLE_FEAT_F4HWN_GMRS_FRS_MURS": true, From 676f26be34bd769cfecc9ed9b0c1b83372210d13 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Thu, 9 Jul 2026 05:25:42 +0200 Subject: [PATCH 30/37] Remove legacy Python k5viewer tool --- tools/k5viewer/README.md | 88 -------------- tools/k5viewer/k5viewer.py | 228 ------------------------------------- 2 files changed, 316 deletions(-) delete mode 100644 tools/k5viewer/README.md delete mode 100755 tools/k5viewer/k5viewer.py diff --git a/tools/k5viewer/README.md b/tools/k5viewer/README.md deleted file mode 100644 index ec64ed96..00000000 --- a/tools/k5viewer/README.md +++ /dev/null @@ -1,88 +0,0 @@ -# Quansheng K5Viewer - -K5Viewer is a lightweight viewer that displays the live screen output of the Quansheng K5 transceiver (running F4HWN firmware), transmitted via UART to your computer through a Baofeng/Kenwood-style USB-to-Serial cable. - -https://github.com/user-attachments/assets/578c8605-9820-4a2c-90e4-5a93b2be7d2b - -> [!WARNING] -> K5Viewer IS NOT A REMOTE CONTROL TOOL. - -It simply displays the live screen of the Quansheng K5 on your computer and allows you to take screenshots. -It can be useful for illustrating documentation, creating tutorials, or demonstrating how K5Viewer works — whether by screen sharing or using a video projector. -It's also a handy tool for development and debugging, especially for display-related features. - -## 🚀 Features - -- Realtime display of 128×64 monochrome screen via serial connection (UART) -- Delta frame updates to minimize bandwidth usage -- Capture screen snapshots in PNG format -- Switch background color (gray, blue, or orange) -- Toggle inverted video mode -- Toggle LCD pixel rendering mode -- Resize the window (zoom in/out) -- Display current FPS in the window title - -## 🛠️ Requirements - -- Python **3.6+** -- pip (Python package installer) - -### 📦 Install dependencies: - -If necessary, use this command to install dependencies: - -```bash -pip install pyserial pygame -``` - -## ▶️ How to Run - -1. Connect your **Quansheng K5** (running **F4HWN firmware** v4.2) to your computer using a Baofeng/Kenwood-style USB-to-Serial cable. - -2. Run the `k5viewer` with the appropriate serial port: - - ```bash - ./k5viewer.py -port /dev/ttyUSB0 # Linux - ./k5viewer.py -port /dev/cu.usbserial-xxxx # macOS - ./k5viewer.py -port COM3 # Windows - ``` - > [!NOTE] - > If no -port is provided, the script defaults to /dev/ttyUSB0. - -Alternatively, you can manually edit the script and change the `DEFAULT_PORT` variable near the top of the file: - - ```python - DEFAULT_PORT = '/dev/ttyUSB0' # Linux - # or - DEFAULT_PORT = '/dev/cu.usbserial-xxxx' # macOS - # or - DEFAULT_PORT = 'COM3' # Windows - ``` - -You can also list available serial ports to help you choose: - - ```bash - ./k5viewer.py --list-ports - ``` - -## 🎮 Controls - -| Key | Action | -|-----------|---------------------------------| -| `Q` | Quit the viewer | -| `G` | Set background to **gray** | -| `B` | Set background to **blue** | -| `O` | Set background to **orange** | -| `W` | Set background to **white** | -| `I` | Toggle **video inversion** | -| `P` | Toggle **LCD pixel mode** | -| `SPACE` | Save a screenshot as PNG | -| `UP` | Increase window size | -| `DOWN` | Decrease window size | - - -Screenshots are saved as `screenshot_YYYYMMDD_HHMMSS.png` in the same directory. - -## 📬 Contact - -If you encounter issues or have suggestions, feel free to open an issue or submit a pull request. Enjoy building with your Quansheng K5! 📡 diff --git a/tools/k5viewer/k5viewer.py b/tools/k5viewer/k5viewer.py deleted file mode 100755 index 0d36d2ab..00000000 --- a/tools/k5viewer/k5viewer.py +++ /dev/null @@ -1,228 +0,0 @@ -#!/usr/bin/env python3 - -import os -import sys -import time -import datetime -import argparse - -os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide" - -import pygame -import serial -from serial.tools import list_ports - -# Version -VERSION = '1.0' - -# Serial configuration -DEFAULT_PORT = '/dev/ttyUSB0' # Change if needed (/dev/cu.usbserial-11130) -BAUDRATE = 38400 -TIMEOUT = 0.5 - -# Screen configuration -WIDTH, HEIGHT = 128, 64 -FRAME_SIZE = 1024 - -# Protocol -HEADER = b'\xAA\x55' -TYPE_SCREENSHOT = b'\x01' -TYPE_DIFF = b'\x02' - -# Framebuffer -framebuffer = bytearray([0] * FRAME_SIZE) - - -COLOR_SETS = { # {key: (name, foreground, background)} - "g": ("Grey", pygame.Color(0, 0, 0), pygame.Color(202, 202, 202)), - "o": ("Orange", pygame.Color(0, 0, 0), pygame.Color(255, 193, 37)), - "b": ("Blue", pygame.Color(0, 0, 0), pygame.Color(28, 134, 228)), - "w": ("White", pygame.Color(0, 0, 0), pygame.Color(255, 255, 255)), -} - -DEFAULT_COLOR = "g" # Must be a key of "COLOR_SETS" - -def send_keepalive(ser: serial.Serial): - # Send keepalive frame - try: - ser.write(b'\x55\xAA\x00\x00') # Keepalive frame - except serial.SerialException: - pass - -def read_frame(ser: serial.Serial) -> bytearray: - global framebuffer - while True: - try: - b = ser.read(1) - except serial.SerialException as e: - #print(f"[ERROR] Serial read failed: {e}") - print("[!] Your USB serial cable is probably being used by another application such as Chirp or Chrome.") - sys.exit(1) - if not b: - return None - if b == HEADER[0:1]: - b2 = ser.read(1) - if b2 == HEADER[1:2]: - t = ser.read(1) - size_bytes = ser.read(2) - size = int.from_bytes(size_bytes, 'big') - if t == TYPE_SCREENSHOT and size == FRAME_SIZE: - payload = ser.read(FRAME_SIZE) - framebuffer = bytearray(payload) - return framebuffer - elif t == TYPE_DIFF and size % 9 == 0: - payload = ser.read(size) - framebuffer = apply_diff(framebuffer, payload) - return framebuffer - - -def apply_diff(framebuffer: bytearray, diff_payload: bytes) -> bytearray: - i = 0 - while i + 9 <= len(diff_payload): - block_index = diff_payload[i] - i += 1 - if block_index >= 128: - break - framebuffer[block_index * 8 : block_index * 8 + 8] = diff_payload[i : i + 8] - i += 8 - return framebuffer - - -def draw_frame(screen: pygame.Surface, framebuffer: bytearray, bg_color: pygame.Color, fg_color: pygame.Color, pixel_size: int = 4, pixel_lcd: int = 0) -> pygame.Surface: - def get_bit(bit_idx): - byte_idx = bit_idx // 8 - bit_pos = bit_idx % 8 - if byte_idx < len(framebuffer): - return (framebuffer[byte_idx] >> bit_pos) & 0x01 - return 0 - - screen.fill(bg_color) - bit_index = 0 - for y in range(64): - for x in range(128): - if get_bit(bit_index): - px = x * (pixel_size - 1) - py = y * pixel_size - pygame.draw.rect(screen, fg_color, (px, py, pixel_size - 1 - pixel_lcd, pixel_size - pixel_lcd)) - bit_index += 1 - - pygame.display.flip() - return pygame.display.get_surface().copy() - - -def run_viewer(args: argparse.Namespace, ser: serial.Serial): - pixel_size = 5 - pixel_lcd = 0 - pygame.init() - screen = pygame.display.set_mode((WIDTH * (pixel_size - 1), HEIGHT * pixel_size)) - base_title = f"Quansheng K5Viewer v{VERSION} by F4HWN" - pygame.display.set_caption(f"{base_title} – No data") - - fg_color, bg_color = COLOR_SETS[DEFAULT_COLOR][1:] - last_surface = None - frame_count = 0 - frame_lost = 0 - last_time = time.monotonic() - - while True: - for event in pygame.event.get(): - if event.type == pygame.QUIT: - raise KeyboardInterrupt - elif event.type == pygame.KEYDOWN: - if event.key == pygame.K_q: - raise KeyboardInterrupt - if event.key == pygame.K_SPACE and last_surface: - filename = datetime.datetime.now().strftime("screenshot_%Y%m%d_%H%M%S.png") - pygame.image.save(last_surface, filename) - print(f"[✔] Screenshot saved: {filename}") - elif event.key == pygame.K_p: - pixel_lcd = 1 - pixel_lcd - draw_frame(screen, framebuffer, bg_color, fg_color, pixel_size, pixel_lcd) - elif event.key == pygame.K_i: - if bg_color == pygame.Color(0, 0, 0): - bg_color, fg_color = fg_color, pygame.Color(0, 0, 0) - else: - bg_color, fg_color = pygame.Color(0, 0, 0), bg_color - draw_frame(screen, framebuffer, bg_color, fg_color, pixel_size, pixel_lcd) - elif event.key == pygame.K_UP: - if pixel_size < 12: - pixel_size += 1 - #print(f"[✔] Resize: {pixel_size, (WIDTH * (pixel_size - 1)), HEIGHT * pixel_size}") - screen = pygame.display.set_mode((WIDTH * (pixel_size - 1), HEIGHT * pixel_size)) - draw_frame(screen, framebuffer, bg_color, fg_color, pixel_size, pixel_lcd) - elif event.key == pygame.K_DOWN: - if pixel_size > 3: - pixel_size -= 1 - #print(f"[✔] Resize: {pixel_size, (WIDTH * (pixel_size - 1)), HEIGHT * pixel_size}") - screen = pygame.display.set_mode((WIDTH * (pixel_size - 1), HEIGHT * pixel_size)) - draw_frame(screen, framebuffer, bg_color, fg_color, pixel_size, pixel_lcd) - pressed_key = event.unicode - if pressed_key in COLOR_SETS.keys(): - fg_color, bg_color = COLOR_SETS[pressed_key][1:] - frame = read_frame(ser) - if frame: - last_surface = draw_frame(screen, framebuffer, bg_color, fg_color, pixel_size, pixel_lcd) - frame_count += 1 - now = time.monotonic() - if now - last_time >= 1.0: - fps = frame_count / (now - last_time) - pygame.display.set_caption(f"{base_title} – FPS: {fps:>04.1f}") - frame_count = 0 - last_time = now - frame_lost = 0 - else: - frame_lost = min(frame_lost + 1, 5) - if frame_lost == 5: - pygame.display.set_caption(f"{base_title} – No data") - - send_keepalive(ser) - - -def cmd_list_ports(args: argparse.Namespace): - ports = list_ports.comports() - print("Available ports:") - for port in ports: - if port.vid is None: # Skipping virtual or non-USB ports - continue - description = " - ".join(filter(None, (port.product, port.manufacturer))) - if description: - print(f"- {description} : {port.device}") - else: - print(f"- {port.device}") - - -def main(): - parser = argparse.ArgumentParser( - prog="K5Viewer", - description="A live viewer for UV-K5 radios with F4HWN firmware", - epilog="F4HWN repo: https://github.com/armel/uv-k5-firmware-custom" - ) - parser.add_argument("--list-ports", action="store_true", help="list available ports and exit") - parser.add_argument("--port", type=str, help="serial port to use (in place of 'DEFAULT_PORT')") - parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION}", help="show program's version number and exit") - - args = parser.parse_args() - if args.list_ports: - cmd_list_ports(args) - exit(0) - # Running viewer - if not args.port and not DEFAULT_PORT: - print("Please specify the serial port to use or set 'DEFAULT_PORT', do 'k5viewer.py --help' for help") - exit(1) - serial_port = args.port or DEFAULT_PORT - try: - ser = serial.Serial(serial_port, BAUDRATE, timeout=TIMEOUT) - except serial.SerialException as e: - print(f"[!] Serial error: {e}") - sys.exit(1) - try: - run_viewer(args, ser) - except KeyboardInterrupt: - print("[✔] Exiting") - ser.close() - pygame.quit() - sys.exit() - - -if __name__ == "__main__": - main() \ No newline at end of file From b44df132d039da4a52be60ca8f749ba5a3d8e8a3 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sat, 11 Jul 2026 03:07:08 +0200 Subject: [PATCH 31/37] Add ACTIONS lock scope to SetLck --- App/app/action.c | 2 +- App/app/app.c | 11 ++++++++--- App/app/menu.c | 2 +- App/misc.c | 2 +- App/misc.h | 15 ++++++++++++++- App/settings.c | 10 ++-------- App/settings.h | 1 - App/ui/menu.c | 4 +++- App/ui/menu.h | 2 +- 9 files changed, 31 insertions(+), 18 deletions(-) diff --git a/App/app/action.c b/App/app/action.c index bc8893a5..97da7c59 100644 --- a/App/app/action.c +++ b/App/app/action.c @@ -472,7 +472,7 @@ static void ACTION_Scan_FM(bool bRestart) static void ACTION_AlarmOr1750(const bool b1750) { - if(gEeprom.KEY_LOCK && gEeprom.KEY_LOCK_PTT) + if(gEeprom.KEY_LOCK && (gSetting_set_lck & SET_LCK_PTT)) return; #if defined(ENABLE_ALARM) diff --git a/App/app/app.c b/App/app/app.c index b6b02ecf..e81e09ba 100644 --- a/App/app/app.c +++ b/App/app/app.c @@ -2308,7 +2308,7 @@ static void ProcessKey(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) #ifdef ENABLE_FEAT_F4HWN // Disable PTT if KEY_LOCK bool lck_condition = (gEeprom.KEY_LOCK || lowBatPopup) && gCurrentFunction != FUNCTION_TRANSMIT; - if(!gSetting_set_lck) + if((gSetting_set_lck & SET_LCK_PTT) == 0) lck_condition = lck_condition && Key != KEY_PTT; if (lck_condition) @@ -2325,6 +2325,12 @@ static void ProcessKey(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) return; } + // The ACTIONS restriction only applies when the keypad is actually + // locked: this block is also entered for the low battery popup, where + // action keys must keep working as before + const bool passActionKey = ((gSetting_set_lck & SET_LCK_ACTIONS) == 0 || !gEeprom.KEY_LOCK) && + (Key == KEY_SIDE1 || Key == KEY_SIDE2 || (Key == KEY_MENU && bKeyHeld)); + if (Key == KEY_F) { // function/key-lock key if (!bKeyPressed) return; @@ -2339,8 +2345,7 @@ static void ProcessKey(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) // KEY_MENU has a special treatment here, because we want to pass hold event to ACTION_Handle // but we don't want it to complain when initial press happens // we want to react on realese instead - else if (Key != KEY_SIDE1 && Key != KEY_SIDE2 && // pass side buttons - !(Key == KEY_MENU && bKeyHeld)) // pass KEY_MENU held + else if (!passActionKey) { if ((!bKeyPressed || bKeyHeld || (Key == KEY_MENU && bKeyPressed)) && // prevent released or held, prevent KEY_MENU pressed !(Key == KEY_MENU && !bKeyPressed)) // pass KEY_MENU released diff --git a/App/app/menu.c b/App/app/menu.c index 4ce70cef..1155455e 100644 --- a/App/app/menu.c +++ b/App/app/menu.c @@ -426,7 +426,7 @@ int MENU_GetLimits(uint8_t menu_id, int32_t *pMin, int32_t *pMax) #endif case MENU_SET_LCK: //*pMin = 0; - *pMax = ARRAY_SIZE(gSubMenu_SET_LCK) - 1; + *pMax = SET_LCK_LEN - 1; break; case MENU_SET_MET: case MENU_SET_GUI: diff --git a/App/misc.c b/App/misc.c index 5adf4648..63242354 100644 --- a/App/misc.c +++ b/App/misc.c @@ -127,7 +127,7 @@ enum BacklightOnRxTx_t gSetting_backlight_on_tx_rx; uint8_t gSetting_set_ctr = 10; bool gSetting_set_inv = false; uint8_t gSetting_set_eot = 0; - bool gSetting_set_lck = false; + uint8_t gSetting_set_lck = SET_LCK_KEYS; bool gSetting_set_met = 0; bool gSetting_set_gui = 0; #ifdef ENABLE_FEAT_F4HWN_AUDIO diff --git a/App/misc.h b/App/misc.h index 9d904dd1..465aa296 100644 --- a/App/misc.h +++ b/App/misc.h @@ -181,6 +181,19 @@ extern enum BacklightOnRxTx_t gSetting_backlight_on_tx_rx; #endif #ifdef ENABLE_FEAT_F4HWN + // Keypad lock scope. Values are BOTH a bitmask (ACTIONS/PTT bits tested + // individually) AND a contiguous 0..3 menu index: the order must stay + // aligned with gSubMenu_SET_LCK[] and any new entry must keep the range + // contiguous so that SET_LCK_LEN remains valid as menu bound and EEPROM + // range check. + enum SET_LCK_t { + SET_LCK_KEYS = 0u, + SET_LCK_ACTIONS = 1u, + SET_LCK_PTT = 2u, + SET_LCK_ACTIONS_PTT = SET_LCK_ACTIONS | SET_LCK_PTT, + SET_LCK_LEN + }; + #ifdef ENABLE_FEAT_F4HWN_LOGO_SAV enum SET_SAV_t { SET_SAV_OFF, @@ -197,7 +210,7 @@ extern enum BacklightOnRxTx_t gSetting_backlight_on_tx_rx; extern uint8_t gSetting_set_ctr; extern bool gSetting_set_inv; extern uint8_t gSetting_set_eot; - extern bool gSetting_set_lck; + extern uint8_t gSetting_set_lck; extern bool gSetting_set_met; extern bool gSetting_set_gui; #ifdef ENABLE_FEAT_F4HWN_AUDIO diff --git a/App/settings.c b/App/settings.c index 21dcd0a0..83fb8182 100644 --- a/App/settings.c +++ b/App/settings.c @@ -477,7 +477,6 @@ gEeprom.FreqChannel[1] = IS_FREQ_CHANNEL(Data16[5]) ? Data16[5] : (FREQ_CHANNE int tmp = ((Data[5] & 0xF0) >> 4); gSetting_set_inv = (((tmp >> 0) & 0x01) < 2) ? ((tmp >> 0) & 0x01): 0; - gSetting_set_lck = (((tmp >> 1) & 0x01) < 2) ? ((tmp >> 1) & 0x01): 0; gSetting_set_met = (((tmp >> 2) & 0x01) < 2) ? ((tmp >> 2) & 0x01): 0; gSetting_set_gui = (((tmp >> 3) & 0x01) < 2) ? ((tmp >> 3) & 0x01): 0; gSetting_set_ctr = (((Data[5] & 0x0F)) > 00 && ((Data[5] & 0x0F)) < 16) ? ((Data[5] & 0x0F)) : 10; @@ -492,7 +491,7 @@ gEeprom.FreqChannel[1] = IS_FREQ_CHANNEL(Data16[5]) ? Data16[5] : (FREQ_CHANNE #else gSetting_set_inv = 0; #endif - gSetting_set_lck = (tmp >> 1) & 0x01; + gSetting_set_lck = (Data[2] < SET_LCK_LEN) ? Data[2] : SET_LCK_KEYS; gSetting_set_met = (tmp >> 2) & 0x01; gSetting_set_gui = (tmp >> 3) & 0x01; @@ -510,7 +509,6 @@ gEeprom.FreqChannel[1] = IS_FREQ_CHANNEL(Data16[5]) ? Data16[5] : (FREQ_CHANNE // And set special session settings for actions gSetting_set_ptt_session = gSetting_set_ptt; - gEeprom.KEY_LOCK_PTT = gSetting_set_lck; #endif } @@ -1119,8 +1117,6 @@ void SETTINGS_SaveSettings(void) if(gSetting_set_inv == 1) tmp = tmp | (1 << 0); - if (gSetting_set_lck == 1) - tmp = tmp | (1 << 1); if (gSetting_set_met == 1) tmp = tmp | (1 << 2); if (gSetting_set_gui == 1) @@ -1134,10 +1130,10 @@ void SETTINGS_SaveSettings(void) #endif tmp = (gSetting_set_inv << 0) | - (gSetting_set_lck << 1) | (gSetting_set_met << 2) | (gSetting_set_gui << 3); + State[2] = gSetting_set_lck; State[5] = ((tmp << 4) | (gSetting_set_ctr & 0x0F)); State[6] = ((gSetting_set_tot << 4) | (gSetting_set_eot & 0x0F)); uint8_t set_ptt_scn_sav = gSetting_set_ptt & 0x01; @@ -1151,8 +1147,6 @@ void SETTINGS_SaveSettings(void) State[7] = ((gSetting_set_pwr << 4) | set_ptt_scn_sav); - gEeprom.KEY_LOCK_PTT = gSetting_set_lck; - PY25Q16_WriteBuffer(0x00A158, SecBuf, 8, false); #endif diff --git a/App/settings.h b/App/settings.h index da1b04fd..1f0153f9 100644 --- a/App/settings.h +++ b/App/settings.h @@ -199,7 +199,6 @@ typedef struct { uint8_t TX_TIMEOUT_TIMER; bool KEY_LOCK; #ifdef ENABLE_FEAT_F4HWN - bool KEY_LOCK_PTT; bool SET_NAV; #endif #ifdef ENABLE_FEAT_F4HWN_RESCUE_OPS diff --git a/App/ui/menu.c b/App/ui/menu.c index 445e4ebd..65975b1a 100644 --- a/App/ui/menu.c +++ b/App/ui/menu.c @@ -421,7 +421,9 @@ const char* const gSubMenu_SCRAMBLER[] = const char* const gSubMenu_SET_LCK[] = { "KEYS", - "KEYS+PTT" + "KEYS\nACTIONS", + "KEYS\nPTT", + "KEYS\nACTIONS\nPTT" }; const char* const gSubMenu_SET_MET[] = diff --git a/App/ui/menu.h b/App/ui/menu.h index 313692cb..06970139 100644 --- a/App/ui/menu.h +++ b/App/ui/menu.h @@ -195,7 +195,7 @@ extern const char* const gSubMenu_D_RSP[4]; extern const char* const gSubMenu_SET_PWR[7]; extern const char* const gSubMenu_SET_PTT[2]; extern const char* const gSubMenu_SET_TOT[4]; - extern const char* const gSubMenu_SET_LCK[2]; + extern const char* const gSubMenu_SET_LCK[]; extern const char* const gSubMenu_SET_MET[2]; #ifdef ENABLE_FEAT_F4HWN_SCAN_FASTER extern const char* const gSubMenu_SET_SCN[2]; From 29725ed704d459c9e649fc9070be36c71e7ecd3f Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sat, 11 Jul 2026 03:26:47 +0200 Subject: [PATCH 32/37] Allow unlock from RF LOG display --- App/app/rxtx_log.c | 8 +++++++- App/functions.h | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index d8d08005..dba25107 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -9,6 +9,7 @@ #include #include +#include "app/common.h" #include "app/generic.h" #include "app/rxtx_log.h" #include "audio.h" @@ -1059,7 +1060,12 @@ void RXTX_LOG_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) case KEY_F: // GENERIC_Key_F only toggles the flag on the MAIN screen, so // handle it here: F arms a go-to-first/last modifier for UP/DOWN. - if (bKeyPressed && !bKeyHeld) { + if (bKeyPressed && bKeyHeld) { + RXTX_LOG_CancelClearConfirm(); + HideFKeyIcon(); + COMMON_KeypadLockToggle(); + gUpdateStatus = true; + } else if (bKeyPressed) { RXTX_LOG_CancelClearConfirm(); gWasFKeyPressed = !gWasFKeyPressed; if (gWasFKeyPressed) diff --git a/App/functions.h b/App/functions.h index ec1d983c..7ec52795 100644 --- a/App/functions.h +++ b/App/functions.h @@ -17,6 +17,7 @@ #ifndef FUNCTIONS_H #define FUNCTIONS_H +#include #include enum FUNCTION_Type_t From 56a6083689b843d999fafc29e10611e3dd6c09ce Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 12 Jul 2026 05:15:57 +0200 Subject: [PATCH 33/37] Stream paginated RF log history to K5Viewer --- App/app/rxtx_log.c | 91 ++++++++++++++++++++++++++++++------------- App/app/rxtx_log.h | 7 ++++ App/driver/keyboard.h | 2 + App/driver/vcp.c | 2 + App/k5viewer.c | 59 ++++++++++++++++++++++------ 5 files changed, 123 insertions(+), 38 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index dba25107..8ec3f9dc 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -88,6 +88,7 @@ static_assert(sizeof(RXTX_LogEntry_t) >= RXTX_LOG_ENTRY_COPY_SIZE); // instead of the viewer. static_assert(sizeof(RXTX_LogK5ViewerRow_t) == 25); static_assert(RXTX_LOG_K5VIEWER_PACKET_SIZE == 1629); +static_assert(RXTX_LOG_K5VIEWER_HISTORY_PACKET_SIZE == 1600); #endif static RXTX_LogEntry_t gViewCache[RXTX_LOG_VIEW_CACHE_COUNT]; @@ -796,6 +797,57 @@ static void RXTX_LOG_CopyK5ViewerRow(RXTX_LogK5ViewerRow_t *dst, const RXTX_LogF RXTX_LOG_SetK5ViewerChannelName(dst, src->channel); } +// Send up to `count` rows whose trafficSeq is below `beforeSeq`, newest +// first, scanning the ring backwards from the write head and zero-padding +// past the last valid entry. Returns the trafficSeq of the oldest row +// sent when more visible history remains below it, 0 otherwise: feeding +// that value back as the next `beforeSeq` pages through the whole +// visible history without duplicating or skipping rows, even while new +// traffic keeps landing between pages (new entries sit above the bound). +static uint32_t RXTX_LOG_SendK5ViewerRows(uint32_t beforeSeq, uint8_t count, + void (*send)(const uint8_t *data, uint16_t size)) +{ + RXTX_LogK5ViewerRow_t row; + uint8_t rowsSent = 0; + uint32_t nextBefore = 0; + + if (gLogHasTraffic && beforeSeq > 0) { + uint16_t slot = RXTX_LOG_AddressToSlot(gNextFlashAddress); + + for (uint16_t scanned = 0; scanned < RXTX_LOG_SLOT_COUNT && rowsSent < count; scanned++) { + RXTX_LogFlashEntry_t flashEntry; + + slot = RXTX_LOG_PreviousSlot(slot); + PY25Q16_ReadBuffer(RXTX_LOG_SlotToAddress(slot), &flashEntry, sizeof(flashEntry)); + + if (RXTX_LOG_IsBlankFlashEntry(&flashEntry)) + break; + if (!RXTX_LOG_IsValidFlashEntry(&flashEntry) || !RXTX_LOG_IsTrafficFlags(flashEntry.flags)) + continue; + if ((gNextTrafficSequence - 1u - flashEntry.trafficSeq) >= RXTX_LOG_VISIBLE_COUNT) + break; + if (flashEntry.trafficSeq >= beforeSeq) + continue; + + RXTX_LOG_CopyK5ViewerRow(&row, &flashEntry); + send((const uint8_t *)&row, sizeof(row)); + rowsSent++; + nextBefore = flashEntry.trafficSeq; + } + } + + // A short page means the log ran out; a full page ending on the + // oldest visible entry is equally final. + if (rowsSent < count || (gNextTrafficSequence - nextBefore) >= RXTX_LOG_VISIBLE_COUNT) + nextBefore = 0; + + memset(&row, 0, sizeof(row)); + while (rowsSent++ < count) + send((const uint8_t *)&row, sizeof(row)); + + return nextBefore; +} + // Stream the whole packet through `send` without ever holding it in RAM: // peak stack stays at one row plus one flash entry. The row area is always // full-length; rowCount is not known before scanning, so the header @@ -804,7 +856,6 @@ static void RXTX_LOG_CopyK5ViewerRow(RXTX_LogK5ViewerRow_t *dst, const RXTX_LogF void RXTX_LOG_SendK5ViewerPacket(void (*send)(const uint8_t *data, uint16_t size)) { RXTX_LogK5ViewerRow_t row; - uint8_t rowsSent = 0; uint8_t header[4] = {RXTX_LOG_K5VIEWER_VERSION, 0, RXTX_LOG_K5VIEWER_ROW_COUNT, 0}; if (gSessionActive) @@ -824,34 +875,20 @@ void RXTX_LOG_SendK5ViewerPacket(void (*send)(const uint8_t *data, uint16_t size row.battVolt = gSessionBattVolt; RXTX_LOG_SetK5ViewerChannelName(&row, gSessionChannel); send((const uint8_t *)&row, sizeof(row)); + RXTX_LOG_SendK5ViewerRows(gNextTrafficSequence, RXTX_LOG_K5VIEWER_ROW_COUNT, send); +} - if (gLogHasTraffic) { - uint16_t slot = RXTX_LOG_AddressToSlot(gNextFlashAddress); - for (uint16_t scanned = 0; - scanned < RXTX_LOG_SLOT_COUNT && rowsSent < RXTX_LOG_K5VIEWER_ROW_COUNT; - scanned++) - { - RXTX_LogFlashEntry_t flashEntry; +uint32_t RXTX_LOG_SendK5ViewerHistoryPage(uint32_t beforeSeq, void (*send)(const uint8_t *data, uint16_t size)) +{ + // First page of a dump: start right below the rows the live packet + // already covers. A log shorter than the live packet leaves nothing + // to dump (the subtraction would wrap). + if (beforeSeq == RXTX_LOG_K5VIEWER_HISTORY_START) + beforeSeq = gNextTrafficSequence > RXTX_LOG_K5VIEWER_ROW_COUNT + ? gNextTrafficSequence - RXTX_LOG_K5VIEWER_ROW_COUNT + : 0; - slot = RXTX_LOG_PreviousSlot(slot); - PY25Q16_ReadBuffer(RXTX_LOG_SlotToAddress(slot), &flashEntry, sizeof(flashEntry)); - - if (RXTX_LOG_IsBlankFlashEntry(&flashEntry)) - break; - - if (!RXTX_LOG_IsValidFlashEntry(&flashEntry) || - !RXTX_LOG_IsTrafficFlags(flashEntry.flags)) - continue; - - RXTX_LOG_CopyK5ViewerRow(&row, &flashEntry); - send((const uint8_t *)&row, sizeof(row)); - rowsSent++; - } - } - - memset(&row, 0, sizeof(row)); - for (; rowsSent < RXTX_LOG_K5VIEWER_ROW_COUNT; rowsSent++) - send((const uint8_t *)&row, sizeof(row)); + return RXTX_LOG_SendK5ViewerRows(beforeSeq, RXTX_LOG_K5VIEWER_HISTORY_ROW_COUNT, send); } #endif diff --git a/App/app/rxtx_log.h b/App/app/rxtx_log.h index 0a9ca573..0c849bb3 100644 --- a/App/app/rxtx_log.h +++ b/App/app/rxtx_log.h @@ -36,6 +36,8 @@ typedef struct { #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER #define RXTX_LOG_K5VIEWER_VERSION 2u #define RXTX_LOG_K5VIEWER_ROW_COUNT 64u +#define RXTX_LOG_K5VIEWER_HISTORY_ROW_COUNT 64u +#define RXTX_LOG_K5VIEWER_HISTORY_START 0xFFFFFFFFu #define RXTX_LOG_K5VIEWER_NAME_LENGTH 10u #define RXTX_LOG_K5VIEWER_STATUS_ACTIVE (1u << 0) #define RXTX_LOG_K5VIEWER_STATUS_HAS_TRAFFIC (1u << 1) @@ -57,9 +59,14 @@ typedef struct __attribute__((packed)) { // zero-padded past the last valid row. The packet is streamed row by row // and never built whole in RAM, so only its size is defined here. #define RXTX_LOG_K5VIEWER_PACKET_SIZE (4u + ((RXTX_LOG_K5VIEWER_ROW_COUNT + 1u) * sizeof(RXTX_LogK5ViewerRow_t))) +#define RXTX_LOG_K5VIEWER_HISTORY_PACKET_SIZE (RXTX_LOG_K5VIEWER_HISTORY_ROW_COUNT * sizeof(RXTX_LogK5ViewerRow_t)) uint32_t RXTX_LOG_K5ViewerSignature(void); void RXTX_LOG_SendK5ViewerPacket(void (*send)(const uint8_t *data, uint16_t size)); +// Sends the page of history rows below `beforeSeq` (HISTORY_START begins +// a dump below the live packet's coverage) and returns the bound for the +// next page, 0 once the visible history is exhausted. +uint32_t RXTX_LOG_SendK5ViewerHistoryPage(uint32_t beforeSeq, void (*send)(const uint8_t *data, uint16_t size)); #endif void RXTX_LOG_Init(void); diff --git a/App/driver/keyboard.h b/App/driver/keyboard.h index 579063b4..4bc37b1f 100644 --- a/App/driver/keyboard.h +++ b/App/driver/keyboard.h @@ -70,6 +70,8 @@ extern volatile KEY_Code_t gKeyFromSerial; #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER #define SERIAL_VIEWER_FEATURE_RF_LOG 0x01u +#define SERIAL_VIEWER_FEATURE_RF_LOG_HISTORY 0x02u +#define SERIAL_VIEWER_FEATURE_RF_LOG_RESTART 0x80u // Extension flags announced by the viewer's feature keepalive. extern volatile uint8_t gSerialViewerFeatures; diff --git a/App/driver/vcp.c b/App/driver/vcp.c index 4f96d560..31727470 100644 --- a/App/driver/vcp.c +++ b/App/driver/vcp.c @@ -50,6 +50,8 @@ bool VCP_K5ViewerPing(void) // Feature keepalive (ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER builds only): // 0x55 0xAA 0x05 → viewer alive, extensions enabled // flags bit 0 = RF log stream + // flags bit 1 = paged RF log history + // flags bit 7 = restart RF log synchronization // Short key press: 0xAA 0x55 0x03 → inject short press // Long key press: 0xAA 0x55 0x04 → inject long press // diff --git a/App/k5viewer.c b/App/k5viewer.c index 3f2f367c..683b8859 100644 --- a/App/k5viewer.c +++ b/App/k5viewer.c @@ -47,6 +47,9 @@ static bool wasConnected = false; #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER static uint32_t previousRfLogSignature = 0; static bool rfLogSent = false; +// Pagination bound of the history dump: rows with trafficSeq below this +// value remain to be sent; HISTORY_START arms a new dump, 0 means done. +static uint32_t rfLogHistoryBefore = RXTX_LOG_K5VIEWER_HISTORY_START; #endif // FNV-1a over one 8-byte chunk, folded to 16 bits @@ -74,6 +77,7 @@ void K5VIEWER_ParseInput(void) hasConnectionPing = true; gUSB_K5ViewerEnabled = true; } + } static void K5VIEWER_Send(const uint8_t *buf, uint16_t len) @@ -92,6 +96,7 @@ enum { K5VIEWER_MARKER_BASE = 0xF0, K5VIEWER_TYPE_DIFF = 0x02, K5VIEWER_TYPE_RXTX_LOG = 0x05, + K5VIEWER_TYPE_RXTX_LOG_HISTORY = 0x06, K5VIEWER_FLAG_DEEP_SLEEP = 1 << 0, K5VIEWER_FLAG_LED_RED = 1 << 1, K5VIEWER_FLAG_LED_GREEN = 1 << 2, @@ -118,12 +123,32 @@ static uint8_t K5VIEWER_StateFlags(void) #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER static bool K5VIEWER_HasPendingRfLogUpdate(void) { + if ((gSerialViewerFeatures & SERIAL_VIEWER_FEATURE_RF_LOG_RESTART) != 0) { + gSerialViewerFeatures &= ~SERIAL_VIEWER_FEATURE_RF_LOG_RESTART; + rfLogSent = false; + rfLogHistoryBefore = RXTX_LOG_K5VIEWER_HISTORY_START; + } + if ((gSerialViewerFeatures & SERIAL_VIEWER_FEATURE_RF_LOG) == 0) return false; return !rfLogSent || RXTX_LOG_K5ViewerSignature() != previousRfLogSignature; } +static void K5VIEWER_SendRfLogFrameHeader(uint8_t type, uint16_t len) +{ + uint8_t header[5] = {0xAA, 0x55, type, (uint8_t)(len >> 8), (uint8_t)len}; + + K5VIEWER_Send(header, sizeof(header)); +} + +static void K5VIEWER_SendRfLogFrameEnd(void) +{ + const uint8_t end = 0x0A; + + K5VIEWER_Send(&end, 1); +} + static void K5VIEWER_SendRfLog(void) { // Capture the signature before streaming: a state change landing @@ -132,18 +157,23 @@ static void K5VIEWER_SendRfLog(void) previousRfLogSignature = RXTX_LOG_K5ViewerSignature(); rfLogSent = true; - const uint16_t len = RXTX_LOG_K5VIEWER_PACKET_SIZE; - uint8_t header[5] = { - 0xAA, 0x55, K5VIEWER_TYPE_RXTX_LOG, - (uint8_t)(len >> 8), - (uint8_t)(len & 0xFF) - }; - - K5VIEWER_Send(header, 5); + K5VIEWER_SendRfLogFrameHeader(K5VIEWER_TYPE_RXTX_LOG, RXTX_LOG_K5VIEWER_PACKET_SIZE); RXTX_LOG_SendK5ViewerPacket(K5VIEWER_Send); + K5VIEWER_SendRfLogFrameEnd(); +} - uint8_t end = 0x0A; - K5VIEWER_Send(&end, 1); +static bool K5VIEWER_HasPendingRfLogHistory(void) +{ + return (gSerialViewerFeatures & SERIAL_VIEWER_FEATURE_RF_LOG_HISTORY) != 0 && + rfLogHistoryBefore != 0; +} + +static void K5VIEWER_SendRfLogHistory(void) +{ + K5VIEWER_SendRfLogFrameHeader(K5VIEWER_TYPE_RXTX_LOG_HISTORY, + RXTX_LOG_K5VIEWER_HISTORY_PACKET_SIZE); + rfLogHistoryBefore = RXTX_LOG_SendK5ViewerHistoryPage(rfLogHistoryBefore, K5VIEWER_Send); + K5VIEWER_SendRfLogFrameEnd(); } #endif @@ -155,6 +185,8 @@ bool K5VIEWER_HasPendingStateChange(void) #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER if (K5VIEWER_HasPendingRfLogUpdate()) return true; + if (K5VIEWER_HasPendingRfLogHistory()) + return true; #endif return !wasConnected || K5VIEWER_StateFlags() != previousStateFlags; @@ -195,6 +227,7 @@ void K5VIEWER_Update(bool force) #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER gSerialViewerFeatures = 0; rfLogSent = false; + rfLogHistoryBefore = RXTX_LOG_K5VIEWER_HISTORY_START; #endif return; } @@ -211,8 +244,10 @@ void K5VIEWER_Update(bool force) const bool stateChanged = (stateFlags != previousStateFlags); #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER const bool rfLogPending = K5VIEWER_HasPendingRfLogUpdate(); + const bool rfLogHistoryPending = K5VIEWER_HasPendingRfLogHistory(); #else const bool rfLogPending = false; + const bool rfLogHistoryPending = false; #endif // ==== FIRST PASS: Count changed chunks ==== @@ -234,7 +269,7 @@ void K5VIEWER_Update(bool force) forcedBlock = (forcedBlock + 1) % 128; - if (deltaLen == 0 && !stateChanged && !rfLogPending) + if (deltaLen == 0 && !stateChanged && !rfLogPending && !rfLogHistoryPending) return; // Skip transmission if a key is currently pressed @@ -287,6 +322,8 @@ void K5VIEWER_Update(bool force) #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER if (rfLogPending) K5VIEWER_SendRfLog(); + if (rfLogHistoryPending) + K5VIEWER_SendRfLogHistory(); #endif previousStateFlags = stateFlags; From 2580acfb07275eafd37d6455399e2bf586a99b90 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 13 Jul 2026 04:27:33 +0200 Subject: [PATCH 34/37] Give session markers a trafficSeq and stream them to K5Viewer --- App/app/rxtx_log.c | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index 8ec3f9dc..634081c1 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -702,6 +702,9 @@ static void RXTX_LOG_WriteSessionMarker(void) RXTX_LogEntry_t entry; memset(&entry, 0, sizeof(entry)); + // Markers take a rank in the traffic sequence so the K5Viewer stream + // can order and page them along with the RX/TX rows they separate. + entry.trafficSeq = gNextTrafficSequence++; entry.channel = RXTX_LOG_CHANNEL_NONE; entry.flags = RXTX_LOG_FLAG_SESSION; entry.sMeter = RXTX_LOG_SMETER_UNKNOWN; @@ -822,10 +825,17 @@ static uint32_t RXTX_LOG_SendK5ViewerRows(uint32_t beforeSeq, uint8_t count, if (RXTX_LOG_IsBlankFlashEntry(&flashEntry)) break; - if (!RXTX_LOG_IsValidFlashEntry(&flashEntry) || !RXTX_LOG_IsTrafficFlags(flashEntry.flags)) + if (!RXTX_LOG_IsValidFlashEntry(&flashEntry)) continue; - if ((gNextTrafficSequence - 1u - flashEntry.trafficSeq) >= RXTX_LOG_VISIBLE_COUNT) - break; + if ((gNextTrafficSequence - 1u - flashEntry.trafficSeq) >= RXTX_LOG_VISIBLE_COUNT) { + // The backwards scan only guarantees decreasing ranks for + // traffic rows: a session marker written before markers + // carried a rank reads as 0, skip it without ending the + // scan or it would truncate the remaining history. + if (RXTX_LOG_IsTrafficFlags(flashEntry.flags)) + break; + continue; + } if (flashEntry.trafficSeq >= beforeSeq) continue; @@ -951,12 +961,16 @@ void RXTX_LOG_Init(void) if (!RXTX_LOG_IsValidFlashEntry(&flashEntry)) continue; - if (RXTX_LOG_IsTrafficFlags(flashEntry.flags)) { + if (RXTX_LOG_IsTrafficFlags(flashEntry.flags)) gLogHasTraffic = true; - if (!foundTraffic || flashEntry.trafficSeq > maxTrafficSeq) { - foundTraffic = true; - maxTrafficSeq = flashEntry.trafficSeq; - } + + // Session markers consume a trafficSeq too: restore the counter + // from every ranked entry, or a reboot would hand the rank the + // boot marker just took to the next traffic entry. Markers written + // before they carried a rank read as 0 and never win. + if (!foundTraffic || flashEntry.trafficSeq > maxTrafficSeq) { + foundTraffic = true; + maxTrafficSeq = flashEntry.trafficSeq; } if (!found || flashEntry.sequence > maxSequence) { From fc12a35418e70014a676394102ea4b16e925fa5a Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Wed, 15 Jul 2026 04:55:06 +0200 Subject: [PATCH 35/37] Update .gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index fb9a1e6c..d6d64b8f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ firmware compile_commands.json .vscode .claude +.agents /docs k5_eeprom.raw @@ -15,4 +16,4 @@ k5_eeprom.raw __pycache__/ .DS_Store -AGENTS.md \ No newline at end of file +AGENTS.md From 0567f017866191acf2f867f6f7c63b6137ad283b Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Wed, 15 Jul 2026 04:55:06 +0200 Subject: [PATCH 36/37] Keep RF log indexes traffic-only --- App/app/rxtx_log.c | 76 ++++++++++++++++++++-------------------------- 1 file changed, 33 insertions(+), 43 deletions(-) diff --git a/App/app/rxtx_log.c b/App/app/rxtx_log.c index 634081c1..3b3e9e87 100644 --- a/App/app/rxtx_log.c +++ b/App/app/rxtx_log.c @@ -477,9 +477,6 @@ static void RXTX_LOG_StepViewCacheScan(void) !RXTX_LOG_MatchesFlags(flashEntry.flags)) continue; - // Traffic entries carry their own absolute rank (trafficSeq); the - // distance from the newest one bounds how far back browsing goes, - // no running counter needed to reconstruct it during the scan. if (RXTX_LOG_IsTrafficFlags(flashEntry.flags) && (gNextTrafficSequence - 1u - flashEntry.trafficSeq) >= RXTX_LOG_VISIBLE_COUNT) { capReached = true; @@ -702,9 +699,9 @@ static void RXTX_LOG_WriteSessionMarker(void) RXTX_LogEntry_t entry; memset(&entry, 0, sizeof(entry)); - // Markers take a rank in the traffic sequence so the K5Viewer stream - // can order and page them along with the RX/TX rows they separate. - entry.trafficSeq = gNextTrafficSequence++; + // Markers share the ordinal of the next traffic row. Their flash + // sequence remains unique and is used to order them in K5Viewer. + entry.trafficSeq = gNextTrafficSequence; entry.channel = RXTX_LOG_CHANNEL_NONE; entry.flags = RXTX_LOG_FLAG_SESSION; entry.sMeter = RXTX_LOG_SMETER_UNKNOWN; @@ -791,7 +788,7 @@ static void RXTX_LOG_SetK5ViewerChannelName(RXTX_LogK5ViewerRow_t *row, uint16_t static void RXTX_LOG_CopyK5ViewerRow(RXTX_LogK5ViewerRow_t *dst, const RXTX_LogFlashEntry_t *src) { dst->frequency = src->frequency; - dst->trafficSeq = src->trafficSeq; + dst->trafficSeq = src->sequence; dst->durationSeconds = src->durationSeconds; dst->channel = src->channel; dst->flags = src->flags; @@ -800,9 +797,9 @@ static void RXTX_LOG_CopyK5ViewerRow(RXTX_LogK5ViewerRow_t *dst, const RXTX_LogF RXTX_LOG_SetK5ViewerChannelName(dst, src->channel); } -// Send up to `count` rows whose trafficSeq is below `beforeSeq`, newest +// Send up to `count` rows whose flash sequence is below `beforeSeq`, newest // first, scanning the ring backwards from the write head and zero-padding -// past the last valid entry. Returns the trafficSeq of the oldest row +// past the last valid entry. Returns the sequence of the oldest row // sent when more visible history remains below it, 0 otherwise: feeding // that value back as the next `beforeSeq` pages through the whole // visible history without duplicating or skipping rows, even while new @@ -813,6 +810,7 @@ static uint32_t RXTX_LOG_SendK5ViewerRows(uint32_t beforeSeq, uint8_t count, RXTX_LogK5ViewerRow_t row; uint8_t rowsSent = 0; uint32_t nextBefore = 0; + bool trafficLimitReached = false; if (gLogHasTraffic && beforeSeq > 0) { uint16_t slot = RXTX_LOG_AddressToSlot(gNextFlashAddress); @@ -827,28 +825,25 @@ static uint32_t RXTX_LOG_SendK5ViewerRows(uint32_t beforeSeq, uint8_t count, break; if (!RXTX_LOG_IsValidFlashEntry(&flashEntry)) continue; - if ((gNextTrafficSequence - 1u - flashEntry.trafficSeq) >= RXTX_LOG_VISIBLE_COUNT) { - // The backwards scan only guarantees decreasing ranks for - // traffic rows: a session marker written before markers - // carried a rank reads as 0, skip it without ending the - // scan or it would truncate the remaining history. - if (RXTX_LOG_IsTrafficFlags(flashEntry.flags)) - break; - continue; - } - if (flashEntry.trafficSeq >= beforeSeq) - continue; - RXTX_LOG_CopyK5ViewerRow(&row, &flashEntry); - send((const uint8_t *)&row, sizeof(row)); - rowsSent++; - nextBefore = flashEntry.trafficSeq; + if (RXTX_LOG_IsTrafficFlags(flashEntry.flags) && + (gNextTrafficSequence - 1u - flashEntry.trafficSeq) >= RXTX_LOG_VISIBLE_COUNT) { + trafficLimitReached = true; + break; + } + + if (flashEntry.sequence < beforeSeq) { + RXTX_LOG_CopyK5ViewerRow(&row, &flashEntry); + send((const uint8_t *)&row, sizeof(row)); + rowsSent++; + nextBefore = flashEntry.sequence; + } } } - // A short page means the log ran out; a full page ending on the - // oldest visible entry is equally final. - if (rowsSent < count || (gNextTrafficSequence - nextBefore) >= RXTX_LOG_VISIBLE_COUNT) + // A short page means the log ran out. Reaching 512 traffic rows is + // equally final; session markers never consume one of those rows. + if (rowsSent < count || trafficLimitReached) nextBefore = 0; memset(&row, 0, sizeof(row)); @@ -877,7 +872,7 @@ void RXTX_LOG_SendK5ViewerPacket(void (*send)(const uint8_t *data, uint16_t size send(header, sizeof(header)); row.frequency = gSessionFrequency; - row.trafficSeq = gNextTrafficSequence; + row.trafficSeq = gNextSequence; row.durationSeconds = (gSessionTicks500ms + 1u) / 2u; row.channel = gSessionChannel; row.flags = gSessionFlags; @@ -885,17 +880,16 @@ void RXTX_LOG_SendK5ViewerPacket(void (*send)(const uint8_t *data, uint16_t size row.battVolt = gSessionBattVolt; RXTX_LOG_SetK5ViewerChannelName(&row, gSessionChannel); send((const uint8_t *)&row, sizeof(row)); - RXTX_LOG_SendK5ViewerRows(gNextTrafficSequence, RXTX_LOG_K5VIEWER_ROW_COUNT, send); + RXTX_LOG_SendK5ViewerRows(gNextSequence, RXTX_LOG_K5VIEWER_ROW_COUNT, send); } uint32_t RXTX_LOG_SendK5ViewerHistoryPage(uint32_t beforeSeq, void (*send)(const uint8_t *data, uint16_t size)) { - // First page of a dump: start right below the rows the live packet - // already covers. A log shorter than the live packet leaves nothing - // to dump (the subtraction would wrap). + // Flash sequence counts every stored row, including session markers, + // so subtracting the live page size lands directly below that page. if (beforeSeq == RXTX_LOG_K5VIEWER_HISTORY_START) - beforeSeq = gNextTrafficSequence > RXTX_LOG_K5VIEWER_ROW_COUNT - ? gNextTrafficSequence - RXTX_LOG_K5VIEWER_ROW_COUNT + beforeSeq = gNextSequence > RXTX_LOG_K5VIEWER_ROW_COUNT + ? gNextSequence - RXTX_LOG_K5VIEWER_ROW_COUNT : 0; return RXTX_LOG_SendK5ViewerRows(beforeSeq, RXTX_LOG_K5VIEWER_HISTORY_ROW_COUNT, send); @@ -961,16 +955,12 @@ void RXTX_LOG_Init(void) if (!RXTX_LOG_IsValidFlashEntry(&flashEntry)) continue; - if (RXTX_LOG_IsTrafficFlags(flashEntry.flags)) + if (RXTX_LOG_IsTrafficFlags(flashEntry.flags)) { gLogHasTraffic = true; - - // Session markers consume a trafficSeq too: restore the counter - // from every ranked entry, or a reboot would hand the rank the - // boot marker just took to the next traffic entry. Markers written - // before they carried a rank read as 0 and never win. - if (!foundTraffic || flashEntry.trafficSeq > maxTrafficSeq) { - foundTraffic = true; - maxTrafficSeq = flashEntry.trafficSeq; + if (!foundTraffic || flashEntry.trafficSeq > maxTrafficSeq) { + foundTraffic = true; + maxTrafficSeq = flashEntry.trafficSeq; + } } if (!found || flashEntry.sequence > maxSequence) { From 39cbbd7e90ddd11e11dde3c7e21dbf4d05ad00ad Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 19 Jul 2026 01:40:08 +0200 Subject: [PATCH 37/37] Prepare new version v5.7.0 --- CMakePresets.json | 4 ++-- archive/f4hwn.fusion.v5.7.0.bin | Bin 0 -> 103548 bytes archive/f4hwn.fusion.v5.7.0.sa818.bin | Bin 0 -> 103548 bytes 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100755 archive/f4hwn.fusion.v5.7.0.bin create mode 100755 archive/f4hwn.fusion.v5.7.0.sa818.bin diff --git a/CMakePresets.json b/CMakePresets.json index 13320be2..960fed01 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -81,7 +81,7 @@ "ENABLE_UART_RW_BK_REGS": false, "ENABLE_SWD": false, "VERSION_STRING_1": "v0.22", - "VERSION_STRING_2": "v5.6.1" + "VERSION_STRING_2": "v5.7.0" } }, { @@ -208,7 +208,7 @@ "ENABLE_FEAT_F4HWN_BEAM": true, "ENABLE_FEAT_F4HWN_RXTX_LOG": true, "ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP": false, - "ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER": false, + "ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER": true, "ENABLE_FEAT_F4HWN_QRCODE": true, "ENABLE_FEAT_F4HWN_MEM": true, "ENABLE_FEAT_F4HWN_LOGO": true, diff --git a/archive/f4hwn.fusion.v5.7.0.bin b/archive/f4hwn.fusion.v5.7.0.bin new file mode 100755 index 0000000000000000000000000000000000000000..0fe4fd82d04360b72036b4d08aa664fa389e3e5c GIT binary patch literal 103548 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

}tkb?t) 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 TpTb{;;*fIK|Ns0yt%3gqLCY87 literal 0 HcmV?d00001