mirror of
https://github.com/armel/uv-k1-k5v3-firmware-custom.git
synced 2026-10-06 05:00:23 +00:00
394 lines
14 KiB
C
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();
|
|
}
|