Files
uv-k1-k5v3-firmware-custom/App/apps/fm/fm_app.c
T

394 lines
14 KiB
C

/* Copyright 2026 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.
*/
/*
* Broadcast FM — overlay app. Sovereign: drives the BK1080 directly and owns the
* radio while running (NO BK4819 dual-watch, unlike the resident FM). Reimplements
* the full resident FM (fm.c / UI_DisplayFM) - VFO + 48 memories - sharing the
* resident channel table (gFM_Channels) and config (gEeprom.FM_*), committed to
* EEPROM on exit. The BK1080 audio is a hardware path, so playback needs no CPU loop.
*
* Keys (iso legacy): 0-9 = frequency (VFO) / channel (MR) entry · UP/DOWN = tune /
* channel step · STAR = manual scan · F+STAR = auto-scan (stores 48) · MENU = save
* (VFO) / delete (MR) · F+1 = band · F+3 = VFO<->MR · F+0 / EXIT = quit.
*/
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "../app_api.h"
#include "fm_assets.h" /* generated by gen_assets.py */
#define STEP 1
#define SCAN_SETTLE 100
#define CHMAX APP_FM_CH_MAX
/* All the state in one struct: Thumb-1 code then reaches every field from a
* single base address (one literal per function instead of one per global),
* byte fields (st's included) first so their offsets fit ldrb's 0..31
* immediate. The overlay is zeroed by the loader at each launch. */
struct globals {
app_fm_state_t st; /* freq_playing, sel_freq, band, is_mr, sel_ch */
uint8_t chPos, savePos, inIdx;
int8_t scanState; /* 0 = off, +1 up, -1 down (non-blocking) */
bool autoScan, foundFreq, askSave, askDelete, fArm, running;
uint8_t inBox[4];
uint16_t lo, hi;
uint16_t scanTimer; /* ms until the next scan step */
const app_api_t *api;
uint16_t *ch; /* shared gFM_Channels[48] */
};
static struct globals g;
#define A (g.api)
_Static_assert(offsetof(struct globals, inBox) + 4u <= 32u, "byte fields out of ldrb range");
/* Decimal digits of v (< 10000), no leading zeros, by subtracting the places:
* no division is linked. */
static char *putu(char *o, uint16_t v)
{
uint16_t place[PLACE_LEN / 2u];
A->asset_read(PLACE, place, sizeof(place));
bool lead = true;
for (uint8_t i = 0; i < PLACE_LEN / 2u; i++) {
uint8_t d = 0;
while (v >= place[i]) { v -= place[i]; d++; }
if (d || i == PLACE_LEN / 2u - 1u) lead = false;
if (!lead) *o++ = (char)('0' + d);
}
return o;
}
/* Texts, band names and the F icon are read-only assets (gen_assets.py).
* put_t() copies a text to o (a buffer of at least TEXT_MAX bytes) and returns
* its end. */
static char *put_t(char *o, uint16_t off){ A->asset_read(off,o,TEXT_MAX); while(*o)o++; return o; }
static void bandLimits(void){ g.lo=A->fm_lo(g.st.band); g.hi=A->fm_hi(g.st.band); }
static uint16_t wrap(uint16_t f){ if(f<g.lo)return g.hi; if(f>g.hi)return g.lo; return f; }
static void tune(void){ A->fm_set_freq(g.st.freq_playing, g.st.band); }
static void dirty(void){ /* nothing yet; committed on exit */ }
static bool validCh(uint8_t c){ return c<CHMAX && g.ch[c]>=g.lo && g.ch[c]<g.hi; }
static uint8_t findNext(uint8_t c, int8_t dir)
{
for(uint8_t i=0;i<CHMAX;i++){
if(c==0xFF) c=CHMAX-1;
else if(c>=CHMAX) c=0;
if(validCh(c)) return c;
c=(uint8_t)(c+dir);
}
return 0xFF;
}
static int configChannel(void)
{
g.st.freq_playing=g.st.sel_freq;
if(g.st.is_mr){
uint8_t c=findNext(g.st.sel_ch,+1);
if(c==0xFF){ g.st.is_mr=false; return -1; }
g.st.sel_ch=c; g.st.freq_playing=g.ch[c];
}
return 0;
}
/* ---- drawing (mirror UI_DisplayFM) ---- */
static void freqBig(uint16_t f)
{
char s[8], *o=s;
if(f<1000u)*o++=' '; /* MHz below 100 */
o=putu(o,f); /* every digit of f (0.1 MHz), */
o[0]=o[-1]; o[-1]='.'; o[1]='\0'; /* then the point before the last */
A->display_freq(s,36,1,true);
}
/* append a zero-padded 2-digit number (1..99), return the end pointer */
static char *num2(char *o, uint8_t n){ if(n<10)*o++='0'; return putu(o,n); }
/* append n (zero-padded to 2 digits if pad), ')' and the NUL */
static void numParen(char *o, uint8_t n, bool pad){ o=pad?num2(o,n):putu(o,n); *o++=')'; *o='\0'; }
static void draw(void)
{
char b[12], *o;
A->display_clear();
A->status_clear();
A->draw_battery();
if(g.fArm) A->asset_read(BMP_F,A->status_line+70,BMP_F_LEN); /* F armed indicator */
put_t(b,T_FM); A->print_string(b,2,0,0,8);
put_t(b,BAND_NAME+g.st.band*BAND_NAME_STRIDE); A->print_normal(b,1,0,6);
/* ---- status (line 3) ---- */
if(g.askSave) put_t(b,T_SAVE);
else if(g.askDelete) put_t(b,T_DEL);
else if(g.scanState) {
if(g.autoScan) numParen(put_t(b,T_ASCAN),g.chPos,false); /* count like UI_DisplayFM */
else put_t(b,T_MSCAN);
}
else if(g.st.is_mr) numParen(put_t(b,T_MR_CH),(uint8_t)(g.st.sel_ch+1),true);
else {
put_t(b,T_VFO);
for(uint8_t i=0;i<CHMAX;i++) if(g.ch[i]==g.st.freq_playing){ numParen(put_t(b,T_VFO_CH),(uint8_t)(i+1),true); break; }
}
A->print_string(b,0,127,3,10);
/* ---- line 1: input in progress / channel prompt / frequency ---- */
if(g.inIdx>0){
if(g.st.is_mr||g.askSave){ /* channel number: "CH-XX" */
o=put_t(b,T_CH);
*o++=(char)('0'+g.inBox[0]);
*o++=(g.inIdx>1)?(char)('0'+g.inBox[1]):'-';
*o='\0';
A->print_string(b,0,127,1,10);
} else { /* VFO frequency: "ddd.d" */
for(uint8_t k=0;k<4u;k++)
b[k+(k==3u)]=(k<g.inIdx)?(char)('0'+g.inBox[k]):'-';
b[3]='.';
b[5]='\0';
A->display_freq(b,36,1,false);
}
} else if(g.askSave||g.askDelete){ /* the save slot / the channel to delete */
o=put_t(b,T_CH); o=num2(o,(uint8_t)((g.askSave?g.savePos:g.st.sel_ch)+1)); *o='\0';
A->print_string(b,0,127,1,10);
} else {
freqBig(g.st.freq_playing);
}
}
static void show(void){ draw(); A->blit_status(); A->blit_full(); }
/* ---- non-blocking scan state machine (mirrors ACTION_Scan_FM / FM_Play / FM_Tune) ---- */
static void scanTuneNext(void) /* FM_Tune(freq, scanState, false): step + mute + settle */
{
A->fm_mute(true);
g.foundFreq=false;
g.st.freq_playing=wrap((uint16_t)(g.st.freq_playing+g.scanState));
tune();
g.scanTimer=SCAN_SETTLE;
}
static void stopScan(void) /* FM_PlayAndUpdate */
{
if(g.autoScan){ g.st.is_mr=true; g.st.sel_ch=0; }
g.scanState=0; g.autoScan=false;
configChannel();
tune();
A->fm_mute(false);
}
static void scanStep(void) /* FM_Play: one step when the settle timer expires */
{
if(A->fm_valid(g.st.freq_playing,g.lo)==0){ /* station locked */
if(!g.autoScan){
g.scanState=0; g.foundFreq=true;
if(!g.st.is_mr) g.st.sel_freq=g.st.freq_playing;
A->fm_mute(false); /* audio on */
return;
}
if(g.chPos<CHMAX) g.ch[g.chPos++]=g.st.freq_playing;
if(g.chPos>=CHMAX){ stopScan(); return; }
}
if(g.autoScan && g.st.freq_playing>=A->fm_hi(1)){ stopScan(); return; }
scanTuneNext();
}
static void startManual(int8_t dir)
{
if(g.scanState){ stopScan(); return; } /* STAR toggles */
g.autoScan=false; g.chPos=0; g.scanState=dir;
scanTuneNext();
}
static void startAuto(void)
{
if(g.scanState){ stopScan(); return; }
g.autoScan=true; g.chPos=0; g.scanState=+1;
for(uint8_t i=0;i<CHMAX;i++) g.ch[i]=0xFFFF;
g.st.freq_playing=g.lo;
A->fm_mute(true);
tune(); /* FM_Tune(lo,1,true): no step */
g.scanTimer=SCAN_SETTLE;
}
/* ---- digit entry ---- */
static void digit(uint8_t d)
{
if(g.askDelete||g.scanState) return;
if(g.askSave || g.st.is_mr){ /* 2-digit channel */
if(g.inIdx<2) g.inBox[g.inIdx++]=d;
if(g.inIdx>=2){
uint8_t c=(uint8_t)(g.inBox[0]*10+g.inBox[1]-1); g.inIdx=0;
if(g.askSave){ if(c<CHMAX) g.savePos=c; }
else if(validCh(c)){ g.st.sel_ch=c; g.st.freq_playing=g.ch[c]; tune(); }
}
return;
}
/* VFO frequency: 4 digits, with the leading-digit>1 zero-pad trick */
g.inBox[g.inIdx++]=d;
if(g.inIdx==1 && g.inBox[0]>1){ g.inBox[1]=g.inBox[0]; g.inBox[0]=0; g.inIdx=2; }
else if(g.inIdx>3){
uint16_t f=(uint16_t)(g.inBox[0]*1000+g.inBox[1]*100+g.inBox[2]*10+g.inBox[3]); g.inIdx=0;
if(f>=g.lo && f<=g.hi){ g.st.sel_freq=f; g.st.freq_playing=f; tune(); }
}
}
/* ---- MENU: save (VFO) / delete (MR) ---- */
static void menu(void)
{
g.inIdx=0;
if(!g.st.is_mr){ /* VFO: save */
if(g.askSave){ g.ch[g.savePos]=g.st.freq_playing; dirty(); }
g.askSave=!g.askSave;
} else { /* MR: delete */
if(g.askDelete){ g.ch[g.st.sel_ch]=0xFFFF; configChannel(); tune(); dirty(); }
g.askDelete=!g.askDelete;
}
}
/* ---- UP/DOWN: tune (VFO) or channel step (MR) ---- */
static void upDown(int8_t step)
{
if(g.scanState){ g.scanState=step; scanTuneNext(); return; } /* continue scan, new direction */
if(g.askSave){
if(step>0){ if(++g.savePos>=CHMAX) g.savePos=0; }
else g.savePos=g.savePos?(uint8_t)(g.savePos-1u):(uint8_t)(CHMAX-1u);
return;
}
if(g.st.is_mr){
uint8_t c=findNext((uint8_t)(g.st.sel_ch+step),step);
if(c!=0xFF && c!=g.st.sel_ch){ g.st.sel_ch=c; g.st.freq_playing=g.ch[c]; tune(); }
} else {
g.st.sel_freq=wrap((uint16_t)(g.st.sel_freq+step));
g.st.freq_playing=g.st.sel_freq; tune();
}
}
static void toggleMr(void)
{
g.st.is_mr=!g.st.is_mr;
if(configChannel()!=0){ /* no valid channel: stayed VFO */ }
tune();
g.askSave=g.askDelete=false; g.inIdx=0;
}
static void cycleBand(void)
{
g.st.band=(uint8_t)((g.st.band+1)&3u); bandLimits();
if(g.st.freq_playing<g.lo||g.st.freq_playing>g.hi){ g.st.freq_playing=g.lo; g.st.sel_freq=g.lo; }
tune();
}
/* F+key and long-press functions (band / VFO<->MR / auto-scan / quit). */
static void func(uint8_t key)
{
switch(key){
case APP_KEY_0: g.running=false; break;
case APP_KEY_1: cycleBand(); break;
case APP_KEY_3: toggleMr(); break;
case APP_KEY_STAR: startAuto(); break;
default: break;
}
}
static void onShort(uint8_t key)
{
if(key==APP_KEY_F){ g.fArm=!g.fArm; return; }
if(g.fArm){ g.fArm=false; func(key); return; } /* F + key */
if(key<=APP_KEY_9){ digit(key); return; }
switch(key){
case APP_KEY_UP:
case APP_KEY_DOWN: upDown(A->nav_dir(key)); break;
case APP_KEY_STAR: startManual(+1); break;
case APP_KEY_MENU: menu(); break;
case APP_KEY_EXIT:
if(g.inIdx) g.inIdx--;
else if(g.askSave||g.askDelete) g.askSave=g.askDelete=false;
else g.running=false;
break;
default: break;
}
}
static void onLong(uint8_t key){ g.fArm=false; func(key); } /* long band / MR / scan / quit */
__attribute__((section(".text.entry"),used))
void app_main(const app_api_t *api)
{
A=api;
A->backlight_on();
g.ch=A->fm_channels;
A->fm_state(&g.st,false); /* inherit the resident FM state */
if(g.st.band>3) g.st.band=0;
bandLimits();
if(g.st.freq_playing<g.lo||g.st.freq_playing>g.hi) g.st.freq_playing=g.lo;
if(g.st.sel_freq<g.lo||g.st.sel_freq>g.hi) g.st.sel_freq=g.st.freq_playing;
if(g.st.is_mr) configChannel();
/* askSave, askDelete, fArm, inIdx and savePos start zeroed (.bss) */
A->fm_enter(g.st.freq_playing,g.st.band);
/* short = on release (held < ~0.5 s) · long = at the 0.5 s threshold ·
UP/DOWN = immediate step on press, then auto-repeat while held. */
g.running=true;
uint8_t held=APP_KEY_INVALID;
uint16_t heldMs=0;
bool firedLong=false;
bool dirty=true; /* redraw only on change */
while(g.running){
uint8_t key=A->get_key();
bool rep=(key==APP_KEY_UP||key==APP_KEY_DOWN);
if(key==APP_KEY_SAVER){
held=APP_KEY_INVALID; heldMs=0; firedLong=false;
A->delay_ms(10); A->backlight_update();
continue;
} else if(key==APP_KEY_WAKE||key==APP_KEY_PTT){
held=APP_KEY_INVALID; heldMs=0; firedLong=false; dirty=true;
} else if(key==APP_KEY_INVALID){
if(held!=APP_KEY_INVALID && !firedLong &&
held!=APP_KEY_UP && held!=APP_KEY_DOWN){
onShort(held); dirty=true;
}
held=APP_KEY_INVALID; heldMs=0; firedLong=false;
} else if(key!=held){
A->backlight_on();
held=key; heldMs=0; firedLong=false;
if(rep){ onShort(key); dirty=true; } /* immediate first step */
} else {
heldMs=(uint16_t)(heldMs+50u);
if(rep){
if(heldMs>=300){ onShort(held); dirty=true; } /* auto-repeat */
} else if(!firedLong && heldMs>=400){
firedLong=true; onLong(held); dirty=true;
}
}
/* non-blocking scan: one step each time the settle timer expires */
if(g.scanState){
/* Match resident FM: an active scan keeps the display awake. */
A->backlight_on();
if(g.scanTimer<=50) scanStep();
else g.scanTimer=(uint16_t)(g.scanTimer-50);
dirty=true;
}
if(dirty){ show(); dirty=false; }
/* Keep the resident 10 ms fade cadence while retaining this app's
* existing 50 ms key/scan state-machine tick. */
for(uint8_t i=0;i<5u;i++){
A->delay_ms(10);
A->backlight_update();
}
}
/* push our state back to the resident FM and persist (config + 48 channels) */
if(!g.st.is_mr) g.st.sel_freq=g.st.freq_playing;
A->fm_state(&g.st,true);
A->fm_commit();
A->fm_exit();
}