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 diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index df9ffccc..0fdd20ce 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) @@ -184,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) @@ -211,6 +207,15 @@ 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 +) +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_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) 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..97da7c59 100644 --- a/App/app/action.c +++ b/App/app/action.c @@ -42,12 +42,12 @@ #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 +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + #include "app/rxtx_log.h" +#endif #if defined(ENABLE_FMRADIO) static void ACTION_Scan_FM(bool bRestart); @@ -131,13 +131,12 @@ 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 +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + [ACTION_OPT_RXTX_LOG] = &ACTION_RxTxLog, +#endif }; static_assert(ARRAY_SIZE(action_opt_table) == ACTION_OPT_LEN); @@ -473,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/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/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 5b9d74d4..e81e09ba 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" @@ -80,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; @@ -116,14 +119,13 @@ 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 -// 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) @@ -228,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 } @@ -772,6 +774,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 @@ -1115,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 @@ -1576,6 +1582,10 @@ void APP_TimeSlice10ms(void) SETTINGS_SaveVfoIndicesFlush(); +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + RXTX_LOG_Task10ms(); +#endif + BACKLIGHT_Update(); gFlashLightBlinkCounter++; @@ -1656,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 @@ -1806,6 +1816,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) @@ -2294,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) @@ -2311,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; @@ -2325,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/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/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 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/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/app/rxtx_log.c b/App/app/rxtx_log.c new file mode 100644 index 00000000..3b3e9e87 --- /dev/null +++ b/App/app/rxtx_log.c @@ -0,0 +1,1358 @@ +/* Copyright 2026 + * + * Licensed under the Apache License, Version 2.0. + */ + +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + +#include +#include +#include + +#include "app/common.h" +#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" +#include "helper/battery.h" +#include "misc.h" +#include "settings.h" +#include "ui/helper.h" +#include "ui/menu.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 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) +// (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 +#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)) { + // 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; + uint16_t channel; + uint8_t flags; + uint8_t sMeter; + uint8_t battVolt; + // 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, pad) + +// 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, 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); +static_assert(RXTX_LOG_K5VIEWER_HISTORY_PACKET_SIZE == 1600); +#endif + +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; +#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 bool gClearConfirmActive; +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; +static uint8_t gSessionFlags; +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 uint8_t gLogDetailMode; + +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_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; +} + +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) +{ + static const char *const filterNames[] = {"ALL", "RX", "TX"}; + + return filterNames[gLogFilter]; +} + +static void RXTX_LOG_UpdateSessionMeters(void) +{ + 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 = + 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)); +} + +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_StartViewCacheScan(uint16_t start, bool circular, bool discoverTotal); + +static void RXTX_LOG_StartCursorView(uint16_t cursor) +{ + 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) +{ + 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; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP + gViewCacheCircular = false; + gViewTotalKnown = false; + gViewWrapPending = false; + gViewScanDiscoverTotal = false; + gViewScanWrapped = false; +#endif + 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_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; + 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) + 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; + RXTX_LOG_ResetLogCounters(); + } +} + +static uint16_t RXTX_LOG_PageStart(uint16_t indexFromNewest) +{ + 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, false, false); + return true; +} + +static void RXTX_LOG_StopViewScan(void) +{ + gViewScanActive = false; + gViewCacheComplete = true; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP + gViewScanDiscoverTotal = false; +#endif +} + +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP +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; + return true; +} +#endif + +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; + + gViewScanSlot = RXTX_LOG_PreviousSlot(gViewScanSlot); + 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 (RXTX_LOG_IsTrafficFlags(flashEntry.flags) && + (gNextTrafficSequence - 1u - flashEntry.trafficSeq) >= RXTX_LOG_VISIBLE_COUNT) { + capReached = true; + RXTX_LOG_StopViewScan(); + break; + } + + RXTX_LOG_RecordViewAnchor(gViewScanIndex, gViewScanSlot); + + if (gViewScanSkip > 0) { + gViewScanSkip--; + gViewScanIndex++; + continue; + } + + if (gViewCacheCount < RXTX_LOG_VIEW_CACHE_COUNT) { + memcpy(&gViewCache[gViewCacheCount], &flashEntry, RXTX_LOG_ENTRY_COPY_SIZE); + 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; + } + + if (gViewWrapPending) { + gViewWrapPending = false; + if (gViewTotalRows > 1u) { + RXTX_LOG_StartCursorView(gViewTotalRows - 1u); + return; + } + } + + if (RXTX_LOG_TryWrapViewCacheScan()) + return; +#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 + 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) +{ + 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; +#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; + } + + 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; +} + +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP +static void RXTX_LOG_RequestWrapToLast(void) +{ + gViewWrapPending = true; + + if (gViewScanActive && + gViewCacheFilter == gLogFilter && + gViewCacheStart == 0 && + !gViewCacheCircular) { + gViewScanDiscoverTotal = true; + return; + } + + RXTX_LOG_StartViewCacheScan(0, false, true); +} +#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 && + 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)); + + return RXTX_LOG_IsBlankFlashEntry(&entry); +} + +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 void RXTX_LOG_WriteEntry(const RXTX_LogEntry_t *src) +{ + RXTX_LogFlashEntry_t entry; + uint8_t commit = RXTX_LOG_ENTRY_COMMIT; + + 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(); + + PY25Q16_WriteBuffer(gNextFlashAddress, &entry, sizeof(entry), false); + PY25Q16_WriteBuffer(gNextFlashAddress + sizeof(entry) - 1u, &commit, 1, false); + RXTX_LOG_AdvanceFlashAddress(); +} + +static void RXTX_LOG_WriteSessionMarker(void) +{ + RXTX_LogEntry_t entry; + + memset(&entry, 0, sizeof(entry)); + // 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; + entry.battVolt = RXTX_LOG_BATT_UNKNOWN; + + RXTX_LOG_WriteEntry(&entry); + RXTX_LOG_InvalidateViewCache(); +} + +static void RXTX_LOG_EnsureViewCache(void) +{ + const uint16_t pageStart = RXTX_LOG_PageStart(gLogCursor); + + if (gViewCacheFilter == gLogFilter && + gViewCacheStart == pageStart && + (gViewScanActive || + gViewCacheComplete || + RXTX_LOG_ViewCacheCovers(gLogCursor))) + return; + + RXTX_LOG_StartCursorView(gLogCursor); +} + +static bool RXTX_LOG_GetFilteredEntry(uint16_t indexFromNewest, RXTX_LogEntry_t *entry) +{ + if (indexFromNewest >= RXTX_LOG_SLOT_COUNT) + return false; + + if (!RXTX_LOG_ViewCacheCovers(indexFromNewest)) + return false; + + *entry = gViewCache[indexFromNewest - gViewCacheStart]; + 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->sequence; + 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); +} + +// 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 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 +// 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; + bool trafficLimitReached = false; + + 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)) + continue; + + 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. 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)); + 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 +// 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 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 = gNextSequence; + 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)); + 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)) +{ + // 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 = 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); +} +#endif + +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; + + if (gSessionActive && + gSessionFlags == flags && + gSessionFrequency == frequency && + gSessionChannel == channel) + return; + + RXTX_LOG_EndActive(); + + gSessionActive = true; + gSessionFlags = flags; + gSessionFrequency = frequency; + gSessionChannel = channel; + gSessionTicks500ms = 0; + // 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(); +} + +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; + gSessionActive = false; + gSessionSMeter = RXTX_LOG_SMETER_UNKNOWN; + gSessionBattVolt = RXTX_LOG_BATT_UNKNOWN; + 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)) { + 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 (!foundTraffic || flashEntry.trafficSeq > maxTrafficSeq) { + foundTraffic = true; + maxTrafficSeq = flashEntry.trafficSeq; + } + } + + if (!found || flashEntry.sequence > maxSequence) { + found = true; + maxSequence = flashEntry.sequence; + maxAddress = address; + lastEntryFlags = flashEntry.flags; + } + } + + 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; + } + + 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) + 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; + gSessionSMeter = RXTX_LOG_SMETER_UNKNOWN; + gSessionBattVolt = RXTX_LOG_BATT_UNKNOWN; + return; + } + + RXTX_LOG_UpdateSessionMeters(); + + RXTX_LogEntry_t entry; + memset(&entry, 0, sizeof(entry)); + + entry.trafficSeq = gNextTrafficSequence++; + entry.frequency = gSessionFrequency; + entry.durationSeconds = (gSessionTicks500ms + 1u) / 2u; + entry.channel = gSessionChannel; + entry.flags = gSessionFlags; + entry.sMeter = gSessionSMeter; + entry.battVolt = gSessionBattVolt; + + if (entry.durationSeconds == 0) + entry.durationSeconds = 1; + + RXTX_LOG_WriteEntry(&entry); + gLogHasTraffic = true; + RXTX_LOG_InvalidateViewCache(); + + gSessionActive = false; + gSessionSMeter = RXTX_LOG_SMETER_UNKNOWN; + gSessionBattVolt = RXTX_LOG_BATT_UNKNOWN; +} + +void RXTX_LOG_Tick500ms(void) +{ + if (gSessionActive) { + RXTX_LOG_UpdateSessionMeters(); + if (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; + gLogDetailMode = RXTX_LOG_DETAIL_DURATION; + gClearConfirmActive = false; + gMenuClearHandled = false; + 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) { + RXTX_LOG_CancelClearConfirm(); + 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_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) { + RXTX_LOG_CancelClearConfirm(); + HideFKeyIcon(); + COMMON_KeypadLockToggle(); + gUpdateStatus = true; + } else if (bKeyPressed) { + RXTX_LOG_CancelClearConfirm(); + gWasFKeyPressed = !gWasFKeyPressed; + if (gWasFKeyPressed) + gKeyInputCountdown = key_input_timeout_500ms; + gUpdateStatus = true; + } + break; + + case KEY_UP: + RXTX_LOG_CancelClearConfirm(); + if (gWasFKeyPressed) { + HideFKeyIcon(); + RXTX_LOG_StartCursorView(0); + } else if (gLogCursor > 0) { + RXTX_LOG_StartCursorView(gLogCursor - 1u); + } +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP + else { + RXTX_LOG_GoToLastRow(); + } +#endif + gUpdateDisplay = true; + break; + + case KEY_DOWN: + RXTX_LOG_CancelClearConfirm(); + if (gWasFKeyPressed) { + HideFKeyIcon(); + RXTX_LOG_GoToLastRow(); + gUpdateDisplay = true; + break; + } +#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) + next = 0; + RXTX_LOG_StartCursorView(next); + gUpdateDisplay = true; + break; + } +#endif + + if (gViewCacheComplete && !gViewCacheHasOlder) + break; + + const uint16_t next = gLogCursor + 1u; + if (next < (uint16_t)(gViewCacheStart + gViewCacheCount)) { + gLogCursor = next; + } else if (gViewCacheHasOlder) { + gLogCursor = gViewCacheStart + gViewCacheCount; + RXTX_LOG_StartCursorView(gLogCursor); + } + } + gUpdateDisplay = true; + break; + + case KEY_MENU: + 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); + gUpdateDisplay = true; + } + break; + + case KEY_EXIT: + if (gClearConfirmActive) { + RXTX_LOG_CancelClearConfirm(); + break; + } + RXTX_LOG_CancelClearConfirm(); + 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) +{ + 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_FormatSMeter(uint8_t sMeter, char *buffer) +{ + if (sMeter > 9) + sprintf(buffer, "S9+%02u", sMeter - 9u); + else + sprintf(buffer, "S%u", sMeter); +} + +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); +} + +static void RXTX_LOG_ShowEmpty(bool showMessage) +{ + if (showMessage) + 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(); +} + +void UI_DisplayRxTxLog(void) +{ + char detail[8]; + char title[16]; + RXTX_LogEntry_t entry; + + UI_DisplayClear(); + + if (gClearActive) { + RXTX_LOG_ShowEmpty(true); + return; + } + + if (gClearConfirmActive) { + RXTX_LOG_ShowClearConfirm(); + return; + } + + RXTX_LOG_EnsureViewCache(); + + if (gLogFilter == RXTX_LOG_FILTER_ALL && !gLogHasTraffic) { + RXTX_LOG_ShowEmpty(true); + return; + } + + if (gViewCacheCount == 0) { + RXTX_LOG_ShowEmpty(!gViewScanActive); + return; + } + + 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 +#endif + { + 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((uint16_t)(gNextTrafficSequence - 1u - entry.trafficSeq), row); + + if (isTx) + UI_PrintStringSmallBold(title, 17, 0, row); + else + UI_PrintStringSmallNormal(title, 17, 0, row); + + GUI_DisplaySmallest(isTx ? "TX" : "RX", 95, (uint8_t)((row * 8u) + 1u), false, true); + + 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); + } 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. + 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(); +} + +#endif diff --git a/App/app/rxtx_log.h b/App/app/rxtx_log.h new file mode 100644 index 00000000..0c849bb3 --- /dev/null +++ b/App/app/rxtx_log.h @@ -0,0 +1,86 @@ +/* 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 512 + +// 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 in flash: it is resolved from `channel` when +// exporting the K5Viewer packet. +typedef struct { + uint32_t frequency; + uint32_t trafficSeq; + uint16_t durationSeconds; + uint16_t channel; + uint8_t flags; + uint8_t sMeter; + 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_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) +#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))) +#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); +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/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/eeprom_compat.c b/App/driver/eeprom_compat.c index b4cf7bf3..d880e97c 100644 --- a/App/driver/eeprom_compat.c +++ b/App/driver/eeprom_compat.c @@ -72,6 +72,13 @@ 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 + + // 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); diff --git a/App/driver/keyboard.c b/App/driver/keyboard.c index 0634ac1e..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 @@ -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; @@ -156,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) @@ -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..4bc37b1f 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, @@ -61,10 +64,19 @@ 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; +#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; +#endif + bool KEYBOARD_ProcessProtocolByte(ParseState_t *state, uint8_t b); #endif @@ -73,4 +85,4 @@ KEY_Code_t KEYBOARD_GetKey(void); void HideFKeyIcon(void); -#endif \ No newline at end of file +#endif 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 021b6359..31727470 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,19 +42,25 @@ 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 - // 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 + // 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 // // 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) @@ -89,4 +95,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/functions.c b/App/functions.c index f4794f91..32be4045 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,31 @@ 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_RECEIVE; + const bool previousWasTx = PreviousFunction == FUNCTION_TRANSMIT; + const bool nextIsActive = + Function == FUNCTION_TRANSMIT || + Function == FUNCTION_MONITOR || + 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/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 diff --git a/App/k5viewer.c b/App/k5viewer.c new file mode 100644 index 00000000..683b8859 --- /dev/null +++ b/App/k5viewer.c @@ -0,0 +1,331 @@ +/* Copyright 2024 Armel F4HWN + * https://github.com/armel + * + * 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 "debugging.h" +#include "driver/st7565.h" +#include "k5viewer.h" +#include "misc.h" +#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 +// 1024-byte copy of the previous frame (chunks are only compared, +// never retransmitted from history). A 16-bit fingerprint keeps the +// collision odds at ~1/65536 per chunk, so a stale chunk slipping +// through is rare; the forcedBlock rotation repairs that residual +// within at most 128 frames. +// Stack optimization: chunks are computed on demand straight from +// gStatusLine/gFrameBuffer instead of building the whole 1024-byte +// frame on the stack. Changed chunks are tracked in a 16-byte bitmap. +// Peak stack drops from ~1.3 KB to ~40 bytes, at the cost of +// transforming each transmitted chunk twice (negligible next to the +// blocking UART transfer). +static uint16_t previousHash[128]; +static uint8_t previousStateFlags = 0xFF; +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; +// 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 +static uint16_t K5VIEWER_Hash(const uint8_t *data) +{ + uint32_t h = 2166136261u; + for (uint8_t i = 0; i < 8; i++) { + h = (h ^ data[i]) * 16777619u; + } + return (uint16_t)(h ^ (h >> 16)); +} + +void K5VIEWER_ParseInput(void) +{ + if (K5VIEWER_IsLocked()) + return; + + if (UART_IsCableConnected()) { + keepAlive = 15; + hasConnectionPing = true; + gUSB_K5ViewerEnabled = false; + } + else if (VCP_K5ViewerPing()) { + keepAlive = 15; + hasConnectionPing = true; + gUSB_K5ViewerEnabled = true; + } + +} + +static void K5VIEWER_Send(const uint8_t *buf, uint16_t len) +{ + if (gUSB_K5ViewerEnabled) { + cdc_acm_data_send_with_dtr(buf, len); + } else { + UART_Send(buf, len); + } +} + +enum { + 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_TYPE_RXTX_LOG_HISTORY = 0x06, + K5VIEWER_FLAG_DEEP_SLEEP = 1 << 0, + K5VIEWER_FLAG_LED_RED = 1 << 1, + K5VIEWER_FLAG_LED_GREEN = 1 << 2, +}; + +static uint8_t K5VIEWER_StateFlags(void) +{ + uint8_t flags = 0; + +#ifdef ENABLE_FEAT_F4HWN_SLEEP + if (gWakeUp) + flags |= K5VIEWER_FLAG_DEEP_SLEEP; +#endif + + if (BK4819_IsGpioOutSet(BK4819_GPIO5_PIN1_RED)) + flags |= K5VIEWER_FLAG_LED_RED; + + if (BK4819_IsGpioOutSet(BK4819_GPIO6_PIN2_GREEN)) + flags |= K5VIEWER_FLAG_LED_GREEN; + + return flags; +} + +#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 + // during the blocking send still differs afterwards and triggers a + // resend on the next cycle. + previousRfLogSignature = RXTX_LOG_K5ViewerSignature(); + rfLogSent = true; + + K5VIEWER_SendRfLogFrameHeader(K5VIEWER_TYPE_RXTX_LOG, RXTX_LOG_K5VIEWER_PACKET_SIZE); + RXTX_LOG_SendK5ViewerPacket(K5VIEWER_Send); + K5VIEWER_SendRfLogFrameEnd(); +} + +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 + +bool K5VIEWER_HasPendingStateChange(void) +{ + if (gUART_LockK5Viewer > 0 || keepAlive == 0 || !hasConnectionPing) + return false; + +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER + if (K5VIEWER_HasPendingRfLogUpdate()) + return true; + if (K5VIEWER_HasPendingRfLogHistory()) + return true; +#endif + + 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 K5VIEWER_Chunk(uint8_t chunkIdx, uint8_t *dest) +{ + 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) * K5VIEWER_HALF_LINE_COLUMNS; + const uint8_t *src = (line == 0 ? gStatusLine : gFrameBuffer[line - 1]) + + columnBase; + + for (uint8_t j = 0; j < K5VIEWER_CHUNK_SIZE; j++) { + uint8_t acc = 0; + 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 K5VIEWER_Update(bool force) +{ + if (K5VIEWER_IsLocked()) + return; + + if (keepAlive > 0) { + if (--keepAlive == 0) { + // Connection just lost → reset state for next reconnection + wasConnected = false; + hasConnectionPing = false; + previousStateFlags = 0xFF; +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER + gSerialViewerFeatures = 0; + rfLogSent = false; + rfLogHistoryBefore = RXTX_LOG_K5VIEWER_HISTORY_START; +#endif + return; + } + } else { + return; + } + + // Connection is alive — detect reconnection and force full frame + if (!wasConnected) { + force = true; + } + + const uint8_t stateFlags = K5VIEWER_StateFlags(); + 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 ==== + uint16_t deltaLen = 0; + uint8_t changedBitmap[16] = {0}; // 1 bit per chunk + uint8_t chunk[9]; // [0] = index, [1..8] = payload + + for (uint8_t chunkIdx = 0; chunkIdx < 128; chunkIdx++) { + K5VIEWER_Chunk(chunkIdx, &chunk[1]); + + bool changed = K5VIEWER_Hash(&chunk[1]) != previousHash[chunkIdx]; + bool isForced = (chunkIdx == forcedBlock); + + if (changed || isForced || force) { + changedBitmap[chunkIdx >> 3] |= 1 << (chunkIdx & 7); + deltaLen += 9; + } + } + + forcedBlock = (forcedBlock + 1) % 128; + + if (deltaLen == 0 && !stateChanged && !rfLogPending && !rfLogHistoryPending) + return; + + // Skip transmission if a key is currently pressed + // UART_Send is blocking - would freeze the main loop and lose keypresses + if (gKeyReading0 != KEY_INVALID) + return; + +#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 = K5VIEWER_MARKER_BASE | stateFlags; + K5VIEWER_Send(&versionMarker, 1); + + // ==== Send header ==== + uint8_t header[5] = { + 0xAA, 0x55, K5VIEWER_TYPE_DIFF, + (uint8_t)(deltaLen >> 8), + (uint8_t)(deltaLen & 0xFF) + }; + + K5VIEWER_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; + + chunk[0] = chunkIdx; + K5VIEWER_Chunk(chunkIdx, &chunk[1]); + + 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] = K5VIEWER_Hash(&chunk[1]); + } + + uint8_t end = 0x0A; + K5VIEWER_Send(&end, 1); + } + +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER + if (rfLogPending) + K5VIEWER_SendRfLog(); + if (rfLogHistoryPending) + K5VIEWER_SendRfLogHistory(); +#endif + + previousStateFlags = stateFlags; + wasConnected = true; +} 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/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/misc.c b/App/misc.c index a68e35db..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 @@ -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..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 @@ -336,11 +349,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/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/screenshot.c b/App/screenshot.c deleted file mode 100644 index cbd265e0..00000000 --- a/App/screenshot.c +++ /dev/null @@ -1,226 +0,0 @@ -/* Copyright 2024 Armel F4HWN - * https://github.com/armel - * - * 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 "debugging.h" -#include "driver/st7565.h" -#include "screenshot.h" -#include "misc.h" -#include "driver/vcp.h" -#include "driver/keyboard.h" -#include "driver/bk4819.h" - -// SRAM optimization: minimize static allocations -// - previousHash: one fingerprint per 8-byte chunk instead of a full -// 1024-byte copy of the previous frame (chunks are only compared, -// never retransmitted from history). A 16-bit fingerprint keeps the -// collision odds at ~1/65536 per chunk, so a stale chunk slipping -// through is rare; the forcedBlock rotation repairs that residual -// within at most 128 frames. -// Stack optimization: chunks are computed on demand straight from -// gStatusLine/gFrameBuffer instead of building the whole 1024-byte -// frame on the stack. Changed chunks are tracked in a 16-byte bitmap. -// Peak stack drops from ~1.3 KB to ~40 bytes, at the cost of -// transforming each transmitted chunk twice (negligible next to the -// blocking UART transfer). -static uint16_t previousHash[128]; -static uint8_t previousStateFlags = 0xFF; -static uint8_t forcedBlock = 0; -static uint8_t keepAlive = 3; -static bool hasConnectionPing = false; -static bool wasConnected = false; - -// FNV-1a over one 8-byte chunk, folded to 16 bits -static uint16_t SCREENSHOT_Hash(const uint8_t *data) -{ - uint32_t h = 2166136261u; - for (uint8_t i = 0; i < 8; i++) { - h = (h ^ data[i]) * 16777619u; - } - return (uint16_t)(h ^ (h >> 16)); -} - -void SCREENSHOT_ParseInput(void) -{ - if (SCREENSHOT_IsLocked()) - return; - - if (UART_IsCableConnected()) { - keepAlive = 15; - hasConnectionPing = true; - gUSB_ScreenshotEnabled = false; - } - else if (VCP_ScreenshotPing()) { - keepAlive = 15; - hasConnectionPing = true; - gUSB_ScreenshotEnabled = true; - } -} - -static void SCREENSHOT_Send(const uint8_t *buf, uint16_t len) -{ - if (gUSB_ScreenshotEnabled) { - cdc_acm_data_send_with_dtr(buf, len); - } else { - UART_Send(buf, len); - } -} - -enum { - SCREENSHOT_CHUNK_SIZE = 8, - SCREENSHOT_CHUNKS_PER_LINE = 16, - SCREENSHOT_HALF_LINE_COLUMNS = LCD_WIDTH / 2, - SCREENSHOT_MARKER_BASE = 0xF0, - SCREENSHOT_FLAG_DEEP_SLEEP = 1 << 0, - SCREENSHOT_FLAG_LED_RED = 1 << 1, - SCREENSHOT_FLAG_LED_GREEN = 1 << 2, -}; - -static uint8_t SCREENSHOT_StateFlags(void) -{ - uint8_t flags = 0; - -#ifdef ENABLE_FEAT_F4HWN_SLEEP - if (gWakeUp) - flags |= SCREENSHOT_FLAG_DEEP_SLEEP; -#endif - - if (BK4819_IsGpioOutSet(BK4819_GPIO5_PIN1_RED)) - flags |= SCREENSHOT_FLAG_LED_RED; - - if (BK4819_IsGpioOutSet(BK4819_GPIO6_PIN2_GREEN)) - flags |= SCREENSHOT_FLAG_LED_GREEN; - - return flags; -} - -bool SCREENSHOT_HasPendingStateChange(void) -{ - if (gUART_LockScreenshot > 0 || keepAlive == 0 || !hasConnectionPing) - return false; - - return !wasConnected || SCREENSHOT_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) -{ - const uint8_t chunkInLine = chunkIdx % SCREENSHOT_CHUNKS_PER_LINE; - const uint8_t line = chunkIdx / SCREENSHOT_CHUNKS_PER_LINE; - const uint8_t bit = chunkInLine / 2; - const uint8_t columnBase = (chunkInLine % 2) * SCREENSHOT_HALF_LINE_COLUMNS; - const uint8_t *src = (line == 0 ? gStatusLine : gFrameBuffer[line - 1]) - + columnBase; - - for (uint8_t j = 0; j < SCREENSHOT_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); - } - dest[j] = gSetting_set_inv ? ~acc : acc; - } -} - -void SCREENSHOT_Update(bool force) -{ - if (SCREENSHOT_IsLocked()) - return; - - if (keepAlive > 0) { - if (--keepAlive == 0) { - // Connection just lost → reset state for next reconnection - wasConnected = false; - hasConnectionPing = false; - previousStateFlags = 0xFF; - return; - } - } else { - return; - } - - // Connection is alive — detect reconnection and force full frame - if (!wasConnected) { - force = true; - } - - const uint8_t stateFlags = SCREENSHOT_StateFlags(); - const bool stateChanged = (stateFlags != previousStateFlags); - - // ==== FIRST PASS: Count changed chunks ==== - uint16_t deltaLen = 0; - uint8_t changedBitmap[16] = {0}; // 1 bit per chunk - uint8_t chunk[9]; // [0] = index, [1..8] = payload - - for (uint8_t chunkIdx = 0; chunkIdx < 128; chunkIdx++) { - SCREENSHOT_Chunk(chunkIdx, &chunk[1]); - - bool changed = SCREENSHOT_Hash(&chunk[1]) != previousHash[chunkIdx]; - bool isForced = (chunkIdx == forcedBlock); - - if (changed || isForced || force) { - changedBitmap[chunkIdx >> 3] |= 1 << (chunkIdx & 7); - deltaLen += 9; - } - } - - forcedBlock = (forcedBlock + 1) % 128; - - if (deltaLen == 0 && !stateChanged) - return; - - // Skip transmission if a key is currently pressed - // UART_Send is blocking - would freeze the main loop and lose keypresses - 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); - - // ==== Send header ==== - uint8_t header[5] = { - 0xAA, 0x55, 0x02, - (uint8_t)(deltaLen >> 8), - (uint8_t)(deltaLen & 0xFF) - }; - - 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; - - chunk[0] = chunkIdx; - SCREENSHOT_Chunk(chunkIdx, &chunk[1]); - - 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]); - } - - uint8_t end = 0x0A; - SCREENSHOT_Send(&end, 1); - - previousStateFlags = stateFlags; - wasConnected = true; -} 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 07884bae..1f0153f9 100644 --- a/App/settings.h +++ b/App/settings.h @@ -127,12 +127,11 @@ 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 +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + ACTION_OPT_RXTX_LOG, #endif ACTION_OPT_LEN }; @@ -200,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 595f61bc..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[] = @@ -496,10 +498,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}, @@ -524,6 +522,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/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]; diff --git a/App/ui/status.c b/App/ui/status.c index ff44bd0f..94b2e0f4 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,31 +69,52 @@ 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); + } +#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 { #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; @@ -102,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; diff --git a/App/ui/ui.c b/App/ui/ui.c index 387a2000..116fa962 100644 --- a/App/ui/ui.c +++ b/App/ui/ui.c @@ -30,8 +30,8 @@ #ifdef ENABLE_FMRADIO #include "ui/fmradio.h" #endif -#ifdef ENABLE_REGA - #include "app/rega.h" +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + #include "app/rxtx_log.h" #endif #include "ui/inputbox.h" #include "ui/main.h" @@ -61,8 +61,8 @@ void (*UI_DisplayFunctions[])(void) = { [DISPLAY_AIRCOPY] = &UI_DisplayAircopy, #endif -#ifdef ENABLE_REGA - [DISPLAY_REGA] = &UI_DisplayREGA, +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + [DISPLAY_RXTX_LOG] = &UI_DisplayRxTxLog, #endif }; diff --git a/App/ui/ui.h b/App/ui/ui.h index 0f1fea42..94b1ede1 100644 --- a/App/ui/ui.h +++ b/App/ui/ui.h @@ -34,8 +34,8 @@ enum GUI_DisplayType_t DISPLAY_AIRCOPY, #endif -#ifdef ENABLE_REGA - DISPLAY_REGA, +#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG + DISPLAY_RXTX_LOG, #endif DISPLAY_N_ELEM, 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 1efcdbcf..960fed01 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -44,11 +44,10 @@ "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, - "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, @@ -73,13 +72,16 @@ "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_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, "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" } }, { @@ -98,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, @@ -115,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, @@ -132,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, @@ -156,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, @@ -174,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, @@ -191,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, @@ -204,6 +206,9 @@ "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_RXTX_LOG_WRAP": 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/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 diff --git a/archive/f4hwn.fusion.v5.7.0.bin b/archive/f4hwn.fusion.v5.7.0.bin new file mode 100755 index 00000000..0fe4fd82 Binary files /dev/null and b/archive/f4hwn.fusion.v5.7.0.bin differ diff --git a/archive/f4hwn.fusion.v5.7.0.sa818.bin b/archive/f4hwn.fusion.v5.7.0.sa818.bin new file mode 100755 index 00000000..15ff1e72 Binary files /dev/null and b/archive/f4hwn.fusion.v5.7.0.sa818.bin differ 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