From 8c78d28f0d7be3e54b74c72aa42075f285c3beb6 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 30 Aug 2026 12:45:36 +0200 Subject: [PATCH] Add TripleVFO, the king of clickbait... --- App/apps/app_api.h | 58 ++++- App/apps/app_overlay.c | 408 +++++++++++++++++++++++++++++++++++ App/apps/trivfo/app.ld | 12 ++ App/apps/trivfo/build.sh | 20 ++ App/apps/trivfo/trivfo_app.c | 254 ++++++++++++++++++++++ 5 files changed, 748 insertions(+), 4 deletions(-) create mode 100644 App/apps/trivfo/app.ld create mode 100755 App/apps/trivfo/build.sh create mode 100644 App/apps/trivfo/trivfo_app.c diff --git a/App/apps/app_api.h b/App/apps/app_api.h index 58188e31..5396f48c 100644 --- a/App/apps/app_api.h +++ b/App/apps/app_api.h @@ -36,11 +36,11 @@ #include #include -/* ABI 3: the table below (v1 core + the fields once labelled "v2 additions") is a - * single versioned layout. Any change to app_api_t - a reorder, a removal, or an - * append - MUST bump this. Keep in sync with ABI_VERSION in pack_app.py, which +/* The table below is a single versioned layout. Any change to app_api_t - a + * reorder, a removal, or an append - MUST bump this. Keep in sync with the + * value read by pack_app.py, which * stamps the blob the loader checks against. */ -#define APP_ABI_VERSION 3u +#define APP_ABI_VERSION 4u /* KEY codes mirrored from driver/keyboard.h (enum KEY_Code_e). Kept in sync by * value so the app stays independent of the firmware headers. */ @@ -61,6 +61,7 @@ enum { APP_KEY_EXIT = 13, APP_KEY_STAR = 14, APP_KEY_F = 15, + APP_KEY_PTT = 16, APP_KEY_INVALID = 19, }; @@ -78,6 +79,43 @@ typedef struct { uint8_t sel_ch; /* selected memory channel 0..47 */ } app_fm_state_t; +/* Compact, pointer-free description of one receiver in the resident triple-VFO + * service. Overlay apps must never see VFO_Info_t directly: its layout varies + * with firmware features and contains resident pointers. */ +typedef struct { + uint32_t frequency; /* RX frequency, x10 Hz */ + uint16_t channel; /* memory channel, zero based */ + uint16_t step; /* step, 10 Hz units */ + uint16_t code_value; /* CTCSS x0.1 Hz or DCS octal source value */ + int16_t rssi_dbm; /* last/current corrected RSSI */ + uint8_t modulation; + uint8_t power; + uint8_t bandwidth; + uint8_t code_type; + uint8_t code; + uint8_t offset_direction; + uint8_t reverse; + uint8_t squelch; + uint8_t flags; /* APP_TRIVFO_* below */ + char name[11]; /* channel name, trimmed and NUL terminated */ +} app_trivfo_info_t; + +enum { + APP_TRIVFO_SELECTED = 1u << 0, + APP_TRIVFO_TUNED = 1u << 1, + APP_TRIVFO_RECEIVING = 1u << 2, + APP_TRIVFO_TX = 1u << 3, + APP_TRIVFO_USER_POWER = 1u << 4, + APP_TRIVFO_AUDIO_BAR = 1u << 5, +}; + +enum { + APP_TRIVFO_SCAN = 0, + APP_TRIVFO_RX = 1, + APP_TRIVFO_HOLD = 2, + APP_TRIVFO_TX_STATE = 3, +}; + typedef struct app_api { uint8_t abi_version; /* == APP_ABI_VERSION */ @@ -174,6 +212,18 @@ typedef struct app_api { * UV-K1 LEFT/RIGHT -> -1/+1 * Returns 0 for any other key. Keep get_key() raw for spatial controls. */ int8_t (*nav_dir)(uint8_t key); + + /* ---- triple VFO (ABI 4) ---- + * A and B are the live Main Display VFOs. C is a resident temporary VFO + * loaded from c_channel (or the first valid memory after B when invalid). + * tick is called every 20 ms by the app and returns APP_TRIVFO_* state. */ + uint16_t (*trivfo_enter)(uint16_t c_channel); + void (*trivfo_leave)(void); + void (*trivfo_get)(uint8_t vfo, app_trivfo_info_t *info); + void (*trivfo_select)(uint8_t vfo); + uint16_t (*trivfo_step)(uint8_t vfo, int8_t direction); + uint8_t (*trivfo_tick)(void); + uint8_t (*trivfo_ptt)(bool pressed); /* 0 OK; non-zero TX denial/timeout */ } app_api_t; /* BK4819 AF modes for set_af (mirror driver/bk4819.h values). */ diff --git a/App/apps/app_overlay.c b/App/apps/app_overlay.c index 82190092..15fff980 100644 --- a/App/apps/app_overlay.c +++ b/App/apps/app_overlay.c @@ -32,10 +32,14 @@ #include "driver/st7565.h" #include "driver/system.h" #include "driver/backlight.h" +#include "app/app.h" #include "ui/helper.h" #include "ui/status.h" #include "board.h" #include "audio.h" +#include "dcs.h" +#include "functions.h" +#include "frequencies.h" #include "radio.h" #include "helper/battery.h" #include "settings.h" @@ -91,6 +95,382 @@ static uint32_t app_make_seed(void) * gEeprom.VfoInfo[0].pRX->Frequency; } +/* ---- ABI 4: resident triple-VFO engine --------------------------------- + * The overlay owns the UI and key timing, while this resident engine owns all + * radio details. Keeping VFO_Info_t and BK4819 sequencing on this side makes + * the app independent of feature-dependent firmware layouts. */ +#define APP_TRIVFO_COUNT 3u +#define APP_TRIVFO_TUNE_TICKS 5u /* 100 ms at one tick / 20 ms */ +#define APP_TRIVFO_TX_HOLD_TICKS 125u /* keep the existing 2.5 s TX return */ + +static VFO_Info_t app_trivfo_c; +static VFO_Info_t *app_trivfo_saved_rx; +static VFO_Info_t *app_trivfo_saved_tx; +static VFO_Info_t *app_trivfo_saved_current; +static uint8_t app_trivfo_selected; +static uint8_t app_trivfo_pre_rx_selected; +static uint8_t app_trivfo_current; +static uint8_t app_trivfo_settle; +static uint16_t app_trivfo_hold; +static uint8_t app_trivfo_candidate_wait; +static uint32_t app_trivfo_tx_ticks; +static bool app_trivfo_running; +static bool app_trivfo_receiving; +static bool app_trivfo_transmitting; +static bool app_trivfo_sql_open; +static bool app_trivfo_ctcss_ok; +static bool app_trivfo_cdcss_ok; +static bool app_trivfo_ab_dirty; +static bool app_trivfo_restore_selection; +static uint8_t app_trivfo_freq_dirty; + +static VFO_Info_t *app_trivfo_vfo(uint8_t vfo) +{ + return vfo < 2u ? &gEeprom.VfoInfo[vfo] : &app_trivfo_c; +} + +static bool app_trivfo_load_memory(VFO_Info_t *vfo, uint16_t channel) +{ + ChannelScanDisplayInfo_t info; + if (!IS_MR_CHANNEL(channel) || + !SETTINGS_FetchChannelScanDisplayInfo(channel, &info)) + return false; + + RADIO_InitInfo(vfo, channel, info.rx.Frequency); + vfo->freq_config_RX = info.rx; + vfo->freq_config_TX = info.tx; + vfo->TX_OFFSET_FREQUENCY = info.offset; + vfo->StepFrequency = info.stepFrequency; + vfo->STEP_SETTING = info.stepSetting; + vfo->Modulation = info.modulation; + vfo->TX_OFFSET_FREQUENCY_DIRECTION = info.txOffsetFrequencyDirection; + vfo->OUTPUT_POWER = info.outputPower; + vfo->FrequencyReverse = info.frequencyReverse; + vfo->CHANNEL_BANDWIDTH = info.channelBandwidth; + vfo->BUSY_CHANNEL_LOCK = info.busyChannelLock; + vfo->TX_LOCK = info.txLock; +#ifdef ENABLE_DTMF_CALLING + vfo->DTMF_DECODING_ENABLE = info.dtmfDecodingEnable; +#endif + vfo->DTMF_PTT_ID_TX_MODE = info.dtmfPttIdTxMode; + vfo->Band = FREQUENCY_GetBand(vfo->freq_config_RX.Frequency); + vfo->Compander = MR_GetChannelAttributes(channel)->compander; + SETTINGS_FetchChannelName(vfo->Name, channel); + vfo->pRX = vfo->FrequencyReverse ? &vfo->freq_config_TX : &vfo->freq_config_RX; + vfo->pTX = vfo->FrequencyReverse ? &vfo->freq_config_RX : &vfo->freq_config_TX; + RADIO_ConfigureSquelchAndOutputPower(vfo); + return true; +} + +static uint16_t app_trivfo_next_channel(uint16_t channel, int8_t direction, uint8_t vfo) +{ + if (direction == 0) + direction = 1; + channel = RADIO_FindNextChannel((uint16_t)(channel + direction), direction, + false, vfo < 2u ? vfo : 0u); + return channel; +} + +static void app_trivfo_tune(uint8_t vfo) +{ + app_trivfo_current = vfo % APP_TRIVFO_COUNT; + gRxVfo = app_trivfo_vfo(app_trivfo_current); + gCurrentVfo = gRxVfo; + app_trivfo_receiving = false; + app_trivfo_sql_open = false; + app_trivfo_ctcss_ok = false; + app_trivfo_cdcss_ok = false; + app_trivfo_candidate_wait = 0; + app_trivfo_settle = APP_TRIVFO_TUNE_TICKS; + AUDIO_AudioPathOff(); + gEnableSpeaker = false; + RADIO_SetupRegisters(false); + FUNCTION_Init(); +} + +static void app_trivfo_poll_irq(void) +{ + while (BK4819_ReadRegister(BK4819_REG_0C) & 1u) { + BK4819_WriteRegister(BK4819_REG_02, 0); + const uint16_t irq = BK4819_ReadRegister(BK4819_REG_02); + if (irq & BK4819_REG_02_SQUELCH_LOST) app_trivfo_sql_open = true; + if (irq & BK4819_REG_02_SQUELCH_FOUND) { + app_trivfo_sql_open = false; + app_trivfo_ctcss_ok = false; + app_trivfo_cdcss_ok = false; + } + + /* A BK4819 CSS interrupt is a transition, not a persistent level. + * Depending on the silicon revision and the configured polarity, the + * first transition can be reported as FOUND or LOST. MAIN keeps the + * decoder state across both transitions; do the same here and use any + * CSS transition as proof that the configured decoder has acquired the + * signal. A wrong CTCSS/DCS does not generate either transition. */ + if (irq & (BK4819_REG_02_CTCSS_LOST | BK4819_REG_02_CTCSS_FOUND)) + app_trivfo_ctcss_ok = true; + if (irq & (BK4819_REG_02_CDCSS_LOST | BK4819_REG_02_CDCSS_FOUND)) + app_trivfo_cdcss_ok = true; + } +} + +static bool app_trivfo_qualified(void) +{ + const VFO_Info_t *vfo = app_trivfo_vfo(app_trivfo_current); + if (!app_trivfo_sql_open) + return false; + if (vfo->Modulation != MODULATION_FM || vfo->pRX->CodeType == CODE_TYPE_OFF) + return true; + if (vfo->pRX->CodeType == CODE_TYPE_CONTINUOUS_TONE) + return app_trivfo_ctcss_ok; + return app_trivfo_cdcss_ok; +} + +static uint16_t app_trivfo_enter(uint16_t c_channel) +{ + app_trivfo_saved_rx = gRxVfo; + app_trivfo_saved_tx = gTxVfo; + app_trivfo_saved_current = gCurrentVfo; + + if (!IS_MR_CHANNEL(c_channel) || + !SETTINGS_FetchChannelScanInfo(c_channel, NULL, NULL)) { + uint16_t start = IS_MR_CHANNEL(gEeprom.ScreenChannel[1]) + ? gEeprom.ScreenChannel[1] : gEeprom.MrChannel[1]; + c_channel = app_trivfo_next_channel(start, 1, 2); + } + if (c_channel == 0xFFFFu || !app_trivfo_load_memory(&app_trivfo_c, c_channel)) { + c_channel = RADIO_FindNextChannel(MR_CHANNEL_FIRST, RADIO_CHANNEL_UP, false, 0); + if (c_channel != 0xFFFFu) + app_trivfo_load_memory(&app_trivfo_c, c_channel); + } + + app_trivfo_selected = 0; + app_trivfo_pre_rx_selected = 0; + app_trivfo_restore_selection = false; + app_trivfo_hold = 0; + app_trivfo_running = true; + app_trivfo_transmitting = false; + app_trivfo_ab_dirty = false; + app_trivfo_freq_dirty = 0; + app_trivfo_tune(0); + return c_channel; +} + +static void app_trivfo_leave(void) +{ + if (!app_trivfo_running) + return; + if (app_trivfo_transmitting) { + BK4819_ToggleGpioOut(BK4819_GPIO1_PIN29_PA_ENABLE, false); + BK4819_ToggleGpioOut(BK4819_GPIO5_PIN1_RED, false); + } + AUDIO_AudioPathOff(); + gEnableSpeaker = false; + app_trivfo_running = false; + app_trivfo_transmitting = false; + gRxVfo = app_trivfo_saved_rx; + gTxVfo = app_trivfo_saved_tx; + gCurrentVfo = app_trivfo_saved_current; +} + +static void app_trivfo_get(uint8_t index, app_trivfo_info_t *info) +{ + if (info == NULL || index >= APP_TRIVFO_COUNT) + return; + const VFO_Info_t *vfo = app_trivfo_vfo(index); + memset(info, 0, sizeof(*info)); + info->frequency = vfo->pRX->Frequency; + info->channel = vfo->CHANNEL_SAVE; + info->step = vfo->StepFrequency; + if (vfo->pRX->CodeType == CODE_TYPE_CONTINUOUS_TONE) + info->code_value = CTCSS_Options[vfo->pRX->Code]; + else if (vfo->pRX->CodeType == CODE_TYPE_DIGITAL || + vfo->pRX->CodeType == CODE_TYPE_REVERSE_DIGITAL) + info->code_value = DCS_Options[vfo->pRX->Code]; + info->rssi_dbm = (index == app_trivfo_current) + ? BK4819_GetRSSI_dBm() + dBmCorrTable[vfo->Band] : -160; + info->modulation = vfo->Modulation; + info->power = vfo->OUTPUT_POWER == OUTPUT_POWER_USER + ? (uint8_t)(gSetting_set_pwr + 1u) : vfo->OUTPUT_POWER; + info->bandwidth = vfo->CHANNEL_BANDWIDTH; +#ifdef ENABLE_FEAT_F4HWN_NARROWER + if (info->bandwidth == BANDWIDTH_NARROW && gSetting_set_nfm == 1) + info->bandwidth++; +#endif + info->code_type = vfo->pRX->CodeType; + info->code = vfo->pRX->Code; + info->offset_direction = vfo->TX_OFFSET_FREQUENCY_DIRECTION; + info->reverse = vfo->FrequencyReverse; + info->squelch = gEeprom.SQUELCH_LEVEL; + if (index == app_trivfo_selected) info->flags |= APP_TRIVFO_SELECTED; + if (index == app_trivfo_current) info->flags |= APP_TRIVFO_TUNED; + if (index == app_trivfo_current && app_trivfo_receiving) info->flags |= APP_TRIVFO_RECEIVING; + if (index == app_trivfo_selected && app_trivfo_transmitting) info->flags |= APP_TRIVFO_TX; + if (vfo->OUTPUT_POWER == OUTPUT_POWER_USER) info->flags |= APP_TRIVFO_USER_POWER; +#ifdef ENABLE_AUDIO_BAR + if (gSetting_mic_bar) info->flags |= APP_TRIVFO_AUDIO_BAR; +#endif + if (IS_MR_CHANNEL(vfo->CHANNEL_SAVE)) + memcpy(info->name, vfo->Name, sizeof(info->name) - 1u); +} + +static void app_trivfo_select(uint8_t vfo) +{ + if (vfo < APP_TRIVFO_COUNT) + app_trivfo_selected = vfo; +} + +static uint16_t app_trivfo_step(uint8_t index, int8_t direction) +{ + if (index >= APP_TRIVFO_COUNT || app_trivfo_transmitting) + return 0xFFFFu; + VFO_Info_t *vfo = app_trivfo_vfo(index); + + if (IS_FREQ_CHANNEL(vfo->CHANNEL_SAVE)) { + if (direction == 0) + direction = 1; + const uint32_t frequency = APP_SetFrequencyByStep(vfo, direction); + if (RX_freq_check(frequency) < 0) + return 0xFFFFu; + + vfo->freq_config_RX.Frequency = frequency; + RADIO_ApplyOffset(vfo); + RADIO_ConfigureSquelchAndOutputPower(vfo); + if (index < 2u) + app_trivfo_freq_dirty |= (uint8_t)(1u << index); + app_trivfo_hold = 0; + app_trivfo_tune(index); + return vfo->CHANNEL_SAVE; + } + + uint16_t base = IS_MR_CHANNEL(vfo->CHANNEL_SAVE) ? vfo->CHANNEL_SAVE + : (index < 2u ? gEeprom.MrChannel[index] : gEeprom.MrChannel[1]); + const uint16_t channel = app_trivfo_next_channel(base, direction, index); + if (channel == 0xFFFFu) + return channel; + if (index < 2u) { + gEeprom.ScreenChannel[index] = channel; + gEeprom.MrChannel[index] = channel; + RADIO_ConfigureChannel(index, VFO_CONFIGURE_RELOAD); + app_trivfo_ab_dirty = true; + } else { + app_trivfo_load_memory(&app_trivfo_c, channel); + } + app_trivfo_hold = 0; + app_trivfo_tune(index); + return channel; +} + +static uint8_t app_trivfo_ptt(bool pressed); + +static uint8_t app_trivfo_tick(void) +{ + if (!app_trivfo_running) + return APP_TRIVFO_SCAN; + if (app_trivfo_transmitting) { + const uint32_t timeout = ((uint32_t)gEeprom.TX_TIMEOUT_TIMER + 1u) * 250u; + if (++app_trivfo_tx_ticks >= timeout) { + app_trivfo_ptt(false); + return APP_TRIVFO_HOLD; + } + return APP_TRIVFO_TX_STATE; + } + + app_trivfo_poll_irq(); + if (app_trivfo_settle > 0) { + app_trivfo_settle--; + return APP_TRIVFO_SCAN; + } + + const bool qualified = app_trivfo_qualified(); + if (qualified) { + app_trivfo_hold = 0; + if (!app_trivfo_receiving) { + if (app_trivfo_selected != app_trivfo_current) { + app_trivfo_pre_rx_selected = app_trivfo_selected; + app_trivfo_restore_selection = true; + app_trivfo_selected = app_trivfo_current; + } + app_trivfo_receiving = true; + AUDIO_AudioPathOn(); + gEnableSpeaker = true; + BK4819_SetRxAudioGain(); + /* BK4819_SetupSquelch() ends by selecting AF_MUTE. Mirror + * APP_StartListening(): restore the channel demodulator only once + * the carrier/CSS has qualified. */ + RADIO_SetModulation(gRxVfo->Modulation); + BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, true); + } + return APP_TRIVFO_RX; + } + + if (app_trivfo_receiving) { + app_trivfo_receiving = false; + AUDIO_AudioPathOff(); + gEnableSpeaker = false; + BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, false); + /* Use the same receive-response dwell as resident DWR. The resident + * value is expressed in 10 ms ticks; Triple VFO ticks every 20 ms. */ + app_trivfo_hold = (dual_watch_count_after_2_10ms + 1u) / 2u; + app_trivfo_candidate_wait = 50u; + } + if (app_trivfo_hold > 0) { + app_trivfo_hold--; + if (app_trivfo_hold == 0 && app_trivfo_restore_selection) { + app_trivfo_selected = app_trivfo_pre_rx_selected; + app_trivfo_restore_selection = false; + } + return APP_TRIVFO_HOLD; + } + + /* Like resident dual watch, give a coded carrier time to acquire its CSS. + * A wrong tone/code must not monopolise the receiver indefinitely. */ + if (app_trivfo_sql_open && app_trivfo_candidate_wait < 50u) { + app_trivfo_candidate_wait++; + return APP_TRIVFO_HOLD; + } + + app_trivfo_tune((uint8_t)((app_trivfo_current + 1u) % APP_TRIVFO_COUNT)); + return APP_TRIVFO_SCAN; +} + +static uint8_t app_trivfo_ptt(bool pressed) +{ + if (!app_trivfo_running) + return 1; + if (!pressed) { + if (!app_trivfo_transmitting) + return 0; + RADIO_SendEndOfTransmission(); + app_trivfo_transmitting = false; + BK4819_ToggleGpioOut(BK4819_GPIO5_PIN1_RED, false); + app_trivfo_hold = APP_TRIVFO_TX_HOLD_TICKS; + app_trivfo_tune(app_trivfo_selected); + return 0; + } + if (app_trivfo_transmitting) + return 0; + + VFO_Info_t *vfo = app_trivfo_vfo(app_trivfo_selected); + if ((TX_freq_check(vfo->pTX->Frequency) != 0 && vfo->TX_LOCK) || + vfo->Modulation != MODULATION_FM || + (vfo->BUSY_CHANNEL_LOCK && app_trivfo_receiving) || + gBatteryDisplayLevel == 0 || gBatteryDisplayLevel > 6) + return 1; + + AUDIO_AudioPathOff(); + gEnableSpeaker = false; + BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, false); + gRxVfo = gTxVfo = gCurrentVfo = vfo; + RADIO_SetTxParameters(); + BK4819_ToggleGpioOut(BK4819_GPIO5_PIN1_RED, true); + BK4819_DisableScramble(); + app_trivfo_current = app_trivfo_selected; + app_trivfo_receiving = false; + app_trivfo_transmitting = true; + app_trivfo_tx_ticks = 0; + return 0; +} + /* ---- v2 radio wrappers ---- */ static int16_t app_rssi_dbm(void) { return BK4819_GetRSSI_dBm() + dBmCorrTable[gRxVfo->Band]; } static uint16_t app_bk_read(uint8_t r) { return BK4819_ReadRegister((BK4819_REGISTER_t)r); } @@ -133,6 +513,12 @@ static void app_draw_battery(void) } static void app_battery_sample(void) { + /* The resident scheduler deliberately skips ADC battery updates while the + * PA is keyed. Do the same for Triple VFO: sampling the loaded voltage as + * capacity made an 80% pack appear to fall immediately to about 16%. */ + if (app_trivfo_transmitting) + return; + BOARD_ADC_GetBatteryInfo(&gBatteryVoltages[gBatteryVoltageIndex++], &gBatteryCurrent); if (gBatteryVoltageIndex > 3) gBatteryVoltageIndex = 0; @@ -334,6 +720,13 @@ uint8_t APP_LaunchOverlay(uint8_t slot) .fm_commit = app_fm_commit, #endif .nav_dir = app_nav_dir, + .trivfo_enter = app_trivfo_enter, + .trivfo_leave = app_trivfo_leave, + .trivfo_get = app_trivfo_get, + .trivfo_select = app_trivfo_select, + .trivfo_step = app_trivfo_step, + .trivfo_tick = app_trivfo_tick, + .trivfo_ptt = app_trivfo_ptt, }; app_run_slot = slot; /* for cfg_load / cfg_save */ @@ -359,6 +752,9 @@ uint8_t APP_LaunchOverlay(uint8_t slot) app_entry_t entry = (app_entry_t)(((uint32_t)ws + h.entry_off) | 1u); entry(&api); + /* A defensive leave also covers an app returning through an error path. */ + app_trivfo_leave(); + /* Restore the resident RX/dual-watch tuning the app ran on top of. */ gEeprom.RX_VFO = saved_rx_vfo; gRxVfo = saved_rx; @@ -367,6 +763,18 @@ uint8_t APP_LaunchOverlay(uint8_t slot) /* The overlay held app code, not a valid config sector. */ PY25Q16_InvalidateCache(); + if (app_trivfo_ab_dirty) { + SETTINGS_SaveVfoIndices(); + app_trivfo_ab_dirty = false; + } + + for (uint8_t i = 0; i < 2u; i++) { + if (app_trivfo_freq_dirty & (1u << i)) + SETTINGS_SaveChannel(gEeprom.VfoInfo[i].CHANNEL_SAVE, i, + &gEeprom.VfoInfo[i], 1); + } + app_trivfo_freq_dirty = 0; + /* Commit any deferred config the app staged (RMW keeps the slot header). */ if (app_cfg_len) { PY25Q16_WriteBuffer(APP_SLOT_BASE(slot) + APP_CFG_OFFSET, app_cfg_buf, app_cfg_len, false); diff --git a/App/apps/trivfo/app.ld b/App/apps/trivfo/app.ld new file mode 100644 index 00000000..32ba57b3 --- /dev/null +++ b/App/apps/trivfo/app.ld @@ -0,0 +1,12 @@ +APP_VMA = DEFINED(APP_VMA) ? APP_VMA : 0x20000280; +APP_LENGTH = 0x1000; +ENTRY(app_main) +MEMORY { APP (rwx) : ORIGIN = APP_VMA, LENGTH = APP_LENGTH } +SECTIONS { + .app APP_VMA : { + KEEP(*(.text.entry)) *(.text .text.*) *(.rodata .rodata.*) + . = ALIGN(4); *(.data .data.*) . = ALIGN(4); *(.bss .bss.* COMMON) . = ALIGN(4); + } > APP + ASSERT(. <= APP_VMA + APP_LENGTH, "Triple VFO overlay app overflows the 4 KiB overlay") + /DISCARD/ : { *(.ARM.exidx*) *(.ARM.extab*) *(.eh_frame*) *(.comment) *(.note.*) } +} diff --git a/App/apps/trivfo/build.sh b/App/apps/trivfo/build.sh new file mode 100755 index 00000000..dc6cc7a7 --- /dev/null +++ b/App/apps/trivfo/build.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail +APP="$(basename "$PWD")" +APP_NAME="Triple VFO" +APP_VER="1.0" +APP_VMA=${APP_VMA:-0x20000280} +OUT="${APP_NAME// /}" +CC=/opt/toolchain/bin/arm-none-eabi-gcc +OBJCOPY=/opt/toolchain/bin/arm-none-eabi-objcopy +command -v arm-none-eabi-gcc >/dev/null 2>&1 && { CC=arm-none-eabi-gcc; OBJCOPY=arm-none-eabi-objcopy; } +CFLAGS="-mcpu=cortex-m0plus -mthumb -Os -std=gnu11 -ffreestanding -fno-builtin -fno-common -fomit-frame-pointer -ffunction-sections -fdata-sections -Wall -Wextra" +LDFLAGS="-nostdlib -nostartfiles -T app.ld -Wl,--defsym,APP_VMA=${APP_VMA} -Wl,--gc-sections -Wl,-Map=${APP}.map -Wl,--build-id=none -Wl,--no-warn-rwx-segments" +rm -f ./*.app ./*.elf ./*.bin +"$CC" $CFLAGS $LDFLAGS "${APP}_app.c" -lgcc -o "${APP}.elf" +"$OBJCOPY" -O binary "${APP}.elf" "${APP}.bin" +python3 ../pack_app.py "${APP}.bin" "${OUT}.app" --name "$APP_NAME" --ver "$APP_VER" --vma "${APP_VMA}" >/dev/null +BYTES=$(wc -c < "${APP}.bin") +test "$BYTES" -le 4096 +printf ' ✅ %-13s %4d B (%d%% of 4 KiB) -> %s.app\n' \ + "$APP_NAME" "$BYTES" "$(( BYTES * 100 / 4096 ))" "$OUT" diff --git a/App/apps/trivfo/trivfo_app.c b/App/apps/trivfo/trivfo_app.c new file mode 100644 index 00000000..20b8dd78 --- /dev/null +++ b/App/apps/trivfo/trivfo_app.c @@ -0,0 +1,254 @@ +/* Copyright 2026 Armel F4HWN + * SPDX-License-Identifier: Apache-2.0 + * + * Triple VFO — modal overlay receiver/transmitter. A and B are the resident + * Main Display VFOs; C is an app-persistent memory channel. The resident ABI + * performs all RF sequencing while this blob owns the seven-line Tiny UI. + */ +#include +#include +#include "../app_api.h" + +#define TICK_MS 20u +#define LONG_MS 400u +#define CFG_MAGIC 0xC3u +#define VFO_COUNT 3u +#define MR_MAX 1024u +#define SCOPE_SAMPLES 43u +#define SCOPE_FLOOR 200u + +static const app_api_t *A; +static app_trivfo_info_t vi[VFO_COUNT]; +static uint8_t selected, state; +static uint16_t cChannel; +static bool running, fArm, txDenied, channelLabelOn, showFrequency; +static uint16_t scopeBuf[SCOPE_SAMPLES], scopeFloor; +static uint8_t scopeWrite, scopeReady, batteryTicks; +static bool scopeTx; +static char text[16]; + +/* Resident status.c uses this exact 8-column inverted glyph at x=69. Keep + * the overlay copy byte-for-byte identical so F has the same position and + * polarity as every other firmware screen. */ +static const uint8_t fontF[8] = { + 0x7f, 0x00, 0x76, 0x76, 0x76, 0x76, 0x7e, 0x7f +}; + +static uint8_t slen(const char *s){ uint8_t n=0; while(s[n])n++; return n; } +static bool copyName(char *d,const char *s){ + uint8_t n=0; + while(n<10u){ + uint8_t c=(uint8_t)s[n]; + if(c==0u||c==0xffu) break; + d[n++]=(char)c; + } + while(n>0u&&d[n-1u]==' ') n--; + d[n]='\0'; + return n>0u; +} +static char *put(char *o,const char *s){ while(*s)*o++=*s++; return o; } +static char *putu(char *o,uint32_t v){ char t[10]; int8_t n=0; do{t[n++]=(char)('0'+v%10u);v/=10u;}while(v); while(n--)*o++=t[n]; return o; } +static char *puti(char *o,int16_t v){ if(v<0){*o++='-';v=(int16_t)-v;} return putu(o,(uint16_t)v); } +static char *put3(char *o,uint16_t v){ *o++=(char)('0'+(v/100u)%10u); *o++=(char)('0'+(v/10u)%10u); *o++=(char)('0'+v%10u); return o; } +static char *put4(char *o,uint16_t v){ *o++=(char)('0'+(v/1000u)%10u); return put3(o,(uint16_t)(v%1000u)); } + +static void formatFreq(char *s,uint32_t f){ + char *o=putu(s,f/100000u); *o++='.'; f%=100000u; + uint32_t p=10000u; while(p){ *o++=(char)('0'+(f/p)%10u); p/=10u; } *o='\0'; +} +static void formatChannel(char *s,const app_trivfo_info_t *v){ + char *o=s; + if(v->channelchannel+1u)); + else { *o++='F'; o=putu(o,(uint16_t)(v->channel-MR_MAX+1u)); } + *o='\0'; +} +static void formatStep(char *s,uint16_t step){ + char *o=putu(s,step/100u); + if(step%100u){ *o++='.'; *o++=(char)('0'+(step/10u)%10u); *o++=(char)('0'+step%10u); } + *o++='K'; *o='\0'; +} +static void formatCode(char *s,const app_trivfo_info_t *v){ + char *o=s; + if(v->code_type==1u){ o=putu(o,v->code_value/10u); *o++='.'; *o++=(char)('0'+v->code_value%10u); } + else if(v->code_type==2u||v->code_type==3u){ + uint16_t n=v->code_value; *o++=(char)('0'+((n>>6)&7u)); *o++=(char)('0'+((n>>3)&7u)); *o++=(char)('0'+(n&7u)); *o++=(v->code_type==2u?'N':'I'); + } else return formatStep(s,v->step); + *o='\0'; +} + +static void drawMeter(const app_trivfo_info_t *v){ + int16_t dbm=v->rssi_dbm; if(dbm>-53)dbm=-53; + uint8_t s=0,over=0; + if(dbm>=-93){ s=9; over=(uint8_t)(dbm+93); if(over>40)over=40; } + else if(dbm>=-141) s=(uint8_t)((dbm+147)/6); + + char *o=text; if(dbm>-100)*o++=' '; o=puti(o,dbm); o=put(o," dBm"); *o='\0'; + A->print_tiny(text,2,1,false,true); + if(over){ o=text; *o++='+'; if(over<10)*o++='0'; o=putu(o,over); } + else { o=text; *o++='S'; o=putu(o,s); } + *o='\0'; A->print_normal(text,38,0,0); + + uint8_t level=(uint8_t)(s+over/10u); if(level>13u)level=13u; + for(uint8_t i=0;ifb[0][62u+i*5u]; + p[0]=0x3e; p[1]=(i<9u)?0x3e:0x22; p[2]=(i<9u)?0x3e:0x22; p[3]=0x3e; + } +} + +static void drawAudioScope(void){ + if(!scopeTx){ + for(uint8_t i=0;i=7u) scopeBuf[scopeWrite]=A->bk_read(0x64u); + else scopeReady++; + if(scopeBuf[scopeWrite]==0u) scopeBuf[scopeWrite]=SCOPE_FLOOR; + scopeWrite=(uint8_t)((scopeWrite+1u)%SCOPE_SAMPLES); + + uint16_t min=scopeBuf[0], max=scopeBuf[0]; + for(uint8_t i=1;imax) max=scopeBuf[i]; + } + if(scopeFloor>min) scopeFloor=(uint16_t)(scopeFloor-((scopeFloor-min)>>3)-1u); + else scopeFloor=(uint16_t)(scopeFloor+2u); + uint16_t range=max>scopeFloor?(uint16_t)(max-scopeFloor):0u; + + for(uint8_t i=0;i=50u){ + uint16_t value=scopeBuf[idx]>scopeFloor?(uint16_t)(scopeBuf[idx]-scopeFloor):0u; + height=(uint8_t)((uint32_t)value*7u/range); + } + uint8_t mask=height?(uint8_t)((0x7fu<<(7u-height))&0x7fu):0x40u; + A->fb[0][i*3u]=mask; A->fb[0][i*3u+1u]=mask; + } +} + +static void drawVfo(uint8_t n){ + const app_trivfo_info_t *v=&vi[n]; + uint8_t mainLine=(uint8_t)(n*2u+1u), techLine=(uint8_t)(mainLine+1u); + uint8_t techY=(uint8_t)(techLine*8u+1u); + + formatChannel(text,v); + if(!(v->flags&APP_TRIVFO_RECEIVING)||channelLabelOn) + A->print_inverse(text,2,mainLine,false,true,(uint8_t)(slen(text)*4u+3u)); + if(n==selected && txDenied) + A->print_tiny("TX DISABLE",22,(uint8_t)(mainLine*8u+1u),false,true); + else { + bool useName=false; + if(v->channelname); + if(!useName) formatFreq(text,v->frequency); + if(n==selected) + A->print_bold(text,22,0,mainLine); + else + A->print_normal(text,22,0,mainLine); + } + + const char *mod=v->modulation==0u?"FM":v->modulation==1u?"AM":v->modulation==2u?"USB":v->modulation==3u?"BYP":v->modulation==4u?"RAW":"?"; + A->print_tiny(mod,3,techY,false,true); + + static const char *const power[7]={"LOW1","LOW2","LOW3","LOW4","LOW5","MID","HIGH"}; + uint8_t p=(v->power>=1u&&v->power<=7u)?(uint8_t)(v->power-1u):0u; + A->print_tiny(power[p],24,techY,false,true); + if(v->flags&APP_TRIVFO_USER_POWER){ + A->fb[techLine][19]=0x3e; A->fb[techLine][20]=0x1c; A->fb[techLine][21]=0x08; + } + + if(v->offset_direction==1u) A->print_normal("+",41,0,techLine); + else if(v->offset_direction==2u) A->print_normal("-",41,0,techLine); + if(v->reverse) A->print_tiny("R",51,techY,false,true); + + if(v->code_type==1u){ A->print_tiny("CT",58,techY,false,true); formatCode(text,v); A->print_tiny(text,68,techY,false,true); } + else if(v->code_type==2u||v->code_type==3u){ A->print_tiny("DC",58,techY,false,true); formatCode(text,v); A->print_tiny(text,68,techY,false,true); } + else { formatStep(text,v->step); A->print_tiny(text,58,techY,false,true); } + + A->print_tiny(v->bandwidth==0u?"WIDE":v->bandwidth==1u?"NAR":"NAR+",91,techY,false,true); + text[0]='S'; text[1]='Q'; text[2]='L'; text[3]=(char)('0'+(v->squelch%10u)); text[4]='\0'; + A->print_tiny(text,110,techY,false,true); +} + +static void draw(void){ + for(uint8_t i=0;itrivfo_get(i,&vi[i]); + /* The resident scanner can temporarily select the VFO carrying the + * reception. Mirror that selection locally so the bold main field + * follows it immediately. */ + if(vi[i].flags&APP_TRIVFO_SELECTED) selected=i; + } + A->display_clear(); A->status_clear(); + /* Keep the application title in the resident upper-left position. */ + A->draw_battery(); + A->print_inverse("TRIPLE VFO",2,0,true,true,42); + if(fArm){ for(uint8_t i=0;i<8u;i++) A->status_line[69u+i]=fontF[i]; } + for(uint8_t i=0;iblit_status(); A->blit_full(); +} + +static void selectNext(void){ selected=(uint8_t)((selected+1u)%VFO_COUNT); A->trivfo_select(selected); } +static void toggleNameFrequency(void){ showFrequency=!showFrequency; } +static void stepSelected(uint8_t key){ int8_t d=A->nav_dir(key); uint16_t ch=A->trivfo_step(selected,d); if(selected==2u&&ch!=0xFFFFu)cChannel=ch; } + +static void loadCfg(void){ uint8_t c[4]; A->cfg_load(c,4); cChannel=(c[0]==CFG_MAGIC)?(uint16_t)(c[1]|((uint16_t)c[2]<<8)):0xFFFFu; } +static void saveCfg(void){ uint8_t c[3]={CFG_MAGIC,(uint8_t)cChannel,(uint8_t)(cChannel>>8)}; A->cfg_save(c,3); } + +__attribute__((section(".text.entry"),used)) +void app_main(const app_api_t *api){ + A=api; running=true; fArm=false; txDenied=false; channelLabelOn=true; showFrequency=false; selected=scopeWrite=scopeReady=batteryTicks=0; scopeTx=false; scopeFloor=SCOPE_FLOOR; loadCfg(); + cChannel=A->trivfo_enter(cChannel); A->trivfo_select(0); A->backlight_on(); + + uint8_t held=APP_KEY_INVALID, blinkTicks=0; uint16_t heldMs=0; bool longDone=false, ptt=false; + while(running){ + uint8_t key=A->get_key(); + if(key==APP_KEY_PTT){ + if(!ptt){ ptt=true; txDenied=A->trivfo_ptt(true)!=0; } + } else if(ptt){ ptt=false; A->trivfo_ptt(false); txDenied=false; } + + if(key==APP_KEY_INVALID||key==APP_KEY_PTT){ + if(held!=APP_KEY_INVALID&&!longDone){ + if(held==APP_KEY_F) fArm=!fArm; + else if(held==APP_KEY_1&&fArm){ fArm=false; toggleNameFrequency(); } + else if(held==APP_KEY_2&&fArm){ fArm=false; selectNext(); } + else if(held==APP_KEY_EXIT) running=false; + } + held=APP_KEY_INVALID; heldMs=0; longDone=false; + } else if(key!=held){ + held=key; heldMs=0; longDone=false; + if(key==APP_KEY_UP||key==APP_KEY_DOWN){ stepSelected(key); longDone=true; } + } else { + heldMs=(uint16_t)(heldMs+TICK_MS); + if((key==APP_KEY_UP||key==APP_KEY_DOWN)&&heldMs>=300u){ stepSelected(key); } + else if(!longDone&&heldMs>=LONG_MS){ + longDone=true; + /* Long-1 toggles name/frequency and Long-2 selects the next + * VFO; both are deliberately independent of F. */ + if(key==APP_KEY_1){ fArm=false; toggleNameFrequency(); } + else if(key==APP_KEY_2){ fArm=false; selectNext(); } + } + } + + state=A->trivfo_tick(); + if(state==APP_TRIVFO_RX){ + if(++blinkTicks>=25u){ blinkTicks=0; channelLabelOn=!channelLabelOn; } + } else { blinkTicks=0; channelLabelOn=true; } + draw(); + /* Match MAIN: no loaded-voltage samples during TX, then allow one + * second for the pack to recover before refreshing the four-sample + * battery average. */ + if(state==APP_TRIVFO_TX_STATE) batteryTicks=0; + else if(++batteryTicks>=50u){ batteryTicks=0; A->battery_sample(); } + A->delay_ms(TICK_MS); A->backlight_update(); + } + if(ptt) + A->trivfo_ptt(false); + saveCfg(); + A->trivfo_leave(); +}