From 0187c05e654c4aa54a8d2e07e73c27e0ea16df86 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 5 Jul 2026 17:59:58 +0200 Subject: [PATCH] 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,