Files
uv-k1-k5v3-firmware-custom/App/apps/foxhunt/foxhunt_app.c
T

401 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.
*/
/*
* FoxHunt (RX) — overlay app. Ported from App/app/foxhunt.c, hunt sub-mode only
* (the beacon becomes a separate app). Signal-strength hunter: corrected dBm with
* a big number, an IARU S-meter staircase or a scrolling history, peak/min hold,
* a 1 s trend, a front-end attenuator ladder, and Geiger/station audio.
*
* No division is linked: numbers are printed by subtracting powers of ten and
* the few divisions by constants are exact multiply-and-shift equivalents,
* checked exhaustively over their input ranges.
*
* Keys: 1 gauge (bar/history) · 2 audio (off/beep/station) · 3 + UP/DOWN attenuator
* MENU reset holds · long F keypad lock · EXIT quit.
*/
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "../app_api.h"
#include "foxhunt_assets.h" /* generated by gen_assets.py */
#define LCD_WIDTH 128
#define BK_REG_13 0x13
/* --- tuning (from foxhunt.c) --- */
#define DBM_FLOOR (-141)
#define DBM_CEIL (-53)
#define TICK_MS 50
#define TREND_TICKS 20
#define LOCK_HOLD_MS 500
#define SILENCE_DBM (-120)
#define TONE_MIN 400
#define TONE_MAX 2400
#define RATE_SLOW 20
#define RATE_FAST 2
#define BLIP_MS 50
#define AUDIO_SETTLE 60
#define ATT_SETTLE 40
#define ATT_COUNT 6
#define ATT_BYP0 4
#define SEG_COUNT 13
#define BAR_X0 6
#define SEG_PITCH 9
#define SEG_W 8
#define SEG_BOTTOM 37
#define HIST_LEN 120
#define HIST_DECIM 3
#define GRAPH_X0 4
#define GRAPH_TOP 27
#define GRAPH_BOT 45
#define GRAPH_FLOOR 2
#define AUDIO_OFF 0
#define AUDIO_BEEP 1
#define AUDIO_STATION 2
#define GRAPH_BAR 0
#define GRAPH_HIST 1
#define CFG_MAGIC 0xF1 /* config validity marker */
/* The multiply-and-shift divisions below rely on these constants. */
_Static_assert(DBM_CEIL - DBM_FLOOR == 88, "lerp() divides by 88");
_Static_assert(SEG_COUNT == 13 && (GRAPH_BOT - GRAPH_FLOOR - GRAPH_TOP) * SEG_COUNT <= 208,
"drawHist() divides by 13 up to 208");
/* Texts, the attenuator ladder, the powers of ten and the icons are read-only
* assets (gen_assets.py): icons are copied straight into the LCD buffers. */
/* 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 first so their offsets fit ldrb's 0..31 immediate. The overlay
* is zeroed by the loader at each launch. */
struct globals {
bool foxLocked, fArm, fLongDone, running;
uint8_t foxAudioMode, foxGraphMode, attStep, audioTick;
uint8_t trendTick, histHead, histTick, prevKey;
uint16_t fHoldMs;
/* words: loaded without a sign extension */
int32_t curDbm, peakDbm, minDbm, trendRef, trendDelta, histEma;
const app_api_t *api;
uint8_t *histBuf; /* HIST_LEN levels, on app_main's stack */
};
static struct globals g;
#define A (g.api)
_Static_assert(offsetof(struct globals, prevKey) < 32u, "byte fields out of ldrb range");
/* ---- tiny formatting ---- */
static uint8_t slen(const char *s){ uint8_t n=0; while(s[n])n++; return n; }
static char *put(char *o,const char *s){ while(*s)*o++=*s++; return o; }
/* Decimal digits of u, no leading zeros, by subtracting the powers of ten. */
static char *putu(char *o,uint32_t u){
uint32_t place[PLACE_LEN/4u];
A->asset_read(PLACE,place,sizeof(place));
bool lead=true;
for(uint8_t i=0;i<PLACE_LEN/4u;i++){
uint8_t d=0;
while(u>=place[i]){ u-=place[i]; d++; }
if(d||i==PLACE_LEN/4u-1u) lead=false;
if(!lead) *o++=(char)('0'+d);
}
return o;
}
static char *puti(char *o,int v){
if(v<0){ *o++='-'; v=-v; }
return putu(o,(uint32_t)v);
}
static void i2str(char *out,int v){ *puti(out,v)='\0'; }
/* Text from the assets in the caller's buffer (TEXT_MAX bytes). */
static char *T(char *buf,uint16_t off){ A->asset_read(off,buf,TEXT_MAX); return buf; }
/* ---- radio helpers ---- */
static int32_t iabs32(int32_t v){ return v<0?-v:v; }
static void applyAtt(void){
uint16_t reg = A->bk_read(BK_REG_13), gain;
A->asset_read(ATT_REG13 + g.attStep * 2u, &gain, sizeof(gain));
reg = (uint16_t)((reg & ~0x03FFu) | gain);
A->bk_write(BK_REG_13, reg);
}
static void setAudio(void){
if(g.foxAudioMode==AUDIO_OFF){ A->audio_path(false); return; }
A->audio_path(true);
A->delay_ms(AUDIO_SETTLE);
A->set_af(APP_AF_MUTE);
}
static int32_t lerp(int32_t dbm,int32_t lo,int32_t hi){
if(dbm<=DBM_FLOOR) return lo;
if(dbm>=DBM_CEIL) return hi;
const int32_t delta = hi - lo;
const uint32_t x = (uint32_t)(dbm-DBM_FLOOR) *
(uint32_t)(delta < 0 ? -delta : delta);
/* x / 88 as ((x / 8) * 23832) >> 18: exact for x <= 174000, i.e. every
|delta| <= 2000 (the tone and rate ranges) */
const uint32_t distance = ((x >> 3) * 23832u) >> 18;
return lo + (delta < 0 ? -(int32_t)distance : (int32_t)distance);
}
static void blip(uint16_t freq){
A->prepare_tone();
A->play_tone_raw(freq, BLIP_MS);
A->tones_off_rx();
A->set_agc(false);
applyAtt();
}
static uint8_t fillCount(int32_t dbm){
int32_t n;
if(dbm<-141) return 0;
if(dbm<=-93) n=(int32_t)(1u+(((uint32_t)(dbm+141)*43u)>>8)); /* (dbm+141)/6: exact to 127 */
else n=(int32_t)(9u+(((uint32_t)(dbm+93)*205u)>>11)); /* (dbm+93)/10: exact to 1028 */
if(n>SEG_COUNT) n=SEG_COUNT;
return (uint8_t)n;
}
static void buildS(char *out,int32_t dbm){
char t[TEXT_MAX];
if(dbm>=-93){ int32_t o=dbm-(-93); if(o>40)o=40; char *p=put(out,T(t,T_S9P)); if(o<10)*p++='0'; i2str(p,(int)o); }
else if(dbm<-141) put(out,T(t,T_S0))[0]='\0';
else { char *p=put(out,T(t,T_S)); i2str(p,(int)(((uint32_t)(dbm+147)*43u)>>8)); } /* (dbm+147)/6 */
}
static int32_t div_trunc_pow2(int32_t v, uint8_t shift)
{
if (v < 0)
return -(int32_t)((uint32_t)(-v) >> shift);
return (int32_t)((uint32_t)v >> shift);
}
static uint8_t cycleIndex(uint8_t value, uint8_t count, int8_t dir)
{
if (dir > 0)
return ++value < count ? value : 0u;
return value > 0u ? (uint8_t)(value - 1u) : (uint8_t)(count - 1u);
}
static void histSample(void){
g.histEma += div_trunc_pow2(g.curDbm * 8 - g.histEma, 2u);
if(++g.histTick<HIST_DECIM) return;
g.histTick=0;
g.histBuf[g.histHead]=fillCount(div_trunc_pow2(g.histEma, 3u));
if(++g.histHead>=HIST_LEN) g.histHead=0;
}
static void rebase(void){
g.curDbm=A->rssi_dbm();
g.peakDbm=g.minDbm=g.trendRef=g.curDbm;
g.trendDelta=0; g.trendTick=0;
uint8_t lvl=fillCount(g.curDbm);
for(uint8_t i=0;i<HIST_LEN;i++) g.histBuf[i]=lvl;
g.histHead=0; g.histTick=0; g.histEma=g.curDbm*8;
}
static void attCycle(int8_t dir){
g.attStep=cycleIndex(g.attStep,ATT_COUNT,dir);
applyAtt();
A->delay_ms(ATT_SETTLE);
rebase();
}
/* ---- drawing ---- */
static void fillRect(int16_t x0,int16_t y0,int16_t x1,int16_t y1){
for(int16_t x=x0;x<=x1;x++) A->draw_line(A->fb,x,y0,x,y1,true);
}
static void drawBar(void){
uint8_t n=fillCount(g.curDbm);
for(uint8_t i=0;i<n;i++){
int16_t sx=BAR_X0+i*SEG_PITCH;
fillRect(sx,SEG_BOTTOM-i,sx+SEG_W-1,SEG_BOTTOM);
}
A->draw_line(A->fb,BAR_X0,SEG_BOTTOM+2,BAR_X0+(SEG_COUNT-1)*SEG_PITCH+SEG_W-1,SEG_BOTTOM+2,true);
}
static void drawHist(void){
const uint8_t floorY=GRAPH_BOT-GRAPH_FLOOR;
const uint8_t span=floorY-GRAPH_TOP;
A->draw_line(A->fb,GRAPH_X0,GRAPH_BOT,GRAPH_X0+HIST_LEN-1,GRAPH_BOT,true);
uint8_t prevY=0, idx=g.histHead;
for(uint8_t c=0;c<HIST_LEN;c++){
uint8_t lvl=g.histBuf[idx]; if(++idx>=HIST_LEN)idx=0;
if(lvl>SEG_COUNT)lvl=SEG_COUNT;
uint8_t x=GRAPH_X0+c;
uint8_t y=(uint8_t)(floorY-(((uint32_t)lvl*span*79u)>>10)); /* lvl*span/13: exact to 208 */
for(uint8_t yy=y+1;yy<=floorY;yy++) if(((x+yy)&1)==0) A->draw_line(A->fb,x,yy,x,yy,true);
if(c==0){ A->draw_line(A->fb,x,y,x,y,true); }
else { uint8_t lo=(y<prevY)?y:prevY, hi=(y<prevY)?prevY:y; A->draw_line(A->fb,x,lo,x,hi,true); }
prevY=y;
}
}
static void tag(const char *s,uint8_t x,uint8_t line){
A->print_inverse(s,x,line,false,true,(uint8_t)(x+slen(s)*4));
}
static void draw(void){
char big[8], sMeter[8], str[16], t[TEXT_MAX];
A->display_clear();
A->status_clear();
A->print_inverse(T(t,T_TITLE),2,0,true,true,34);
A->draw_battery();
/* status-bar icons: graph mode @38, audio mode @55, F/lock @69 */
if(g.foxGraphMode==GRAPH_HIST) A->asset_read(BMP_GRAPH,A->status_line+38,BMP_GRAPH_LEN);
else A->asset_read(BMP_BARS,A->status_line+38,BMP_BARS_LEN);
if(g.foxAudioMode!=AUDIO_OFF)
A->asset_read(g.foxAudioMode==AUDIO_BEEP?BMP_SIGNAL:BMP_SPEAKER,A->status_line+55,BMP_SIGNAL_LEN);
if(g.foxLocked||g.fArm) A->asset_read(g.foxLocked?BMP_LOCK:BMP_F,A->status_line+70,BMP_F_LEN);
i2str(big,(int)g.curDbm);
A->display_freq(big,2,0,false);
A->print_normal(T(t,T_DBM),(uint8_t)(slen(big)*13+4),0,1);
/* trend arrow (line 0, right) + signed delta (line 1) */
A->asset_read(g.trendDelta>0?BMP_UP:g.trendDelta<0?BMP_DOWN:BMP_FLAT, A->fb[0]+115, BMP_UP_LEN);
if(g.trendDelta!=0){
char *p=str; if(g.trendDelta>0)*p++='+'; else{*p++='-';}
int32_t a=iabs32(g.trendDelta); if(a<10){*p++='0';} i2str(p,(int)a);
A->print_normal(T(t,T_DBM),127-3*7,0,1);
A->print_normal(str,(uint8_t)(127-3*7-2-slen(str)*7),1,1);
}
i2str(put(str,T(t,T_PK)),(int)g.peakDbm);
tag(str,4,2);
i2str(put(str,T(t,T_MN)),(int)g.minDbm);
tag(str,(uint8_t)(((uint32_t)LCD_WIDTH-(uint32_t)slen(str)*4u)>>1),2);
buildS(sMeter,g.curDbm);
A->print_inverse(sMeter,(uint8_t)(126-slen(sMeter)*4),2,false,true,125);
if(g.foxGraphMode==GRAPH_HIST) drawHist();
else drawBar();
if(g.attStep<ATT_BYP0){
uint8_t db; A->asset_read(ATT_DB+g.attStep,&db,1);
char *o=put(str,T(t,T_ATT)); o=puti(o,db); put(o,T(t,T_DB))[0]='\0';
}
else put(str,T(t,g.attStep==ATT_BYP0?T_BYP:T_BYP_PLUS))[0]='\0';
tag(str,4,6);
/* the RX frequency (10 Hz units) as MHz with 5 decimals: every digit, then
the point before the last five (f >= 18 MHz: at least 7 digits) */
{ char *e=putu(str,A->rx_freq());
e[1]='\0';
for(uint8_t k=0;k<5u;k++,e--) e[0]=e[-1];
e[0]='.'; }
A->print_normal(str,(uint8_t)(126-slen(str)*7),0,6);
}
/* ---- config (deferred) ---- */
static void loadConfig(void){
uint8_t c[4];
A->cfg_load(c,4);
if(c[0]==CFG_MAGIC){
if(c[1]<ATT_COUNT) g.attStep=c[1];
if(c[2]<=GRAPH_HIST) g.foxGraphMode=c[2];
if(c[3]<=AUDIO_STATION) g.foxAudioMode=c[3];
}
}
static void saveConfig(void){
uint8_t c[4]={CFG_MAGIC,g.attStep,g.foxGraphMode,g.foxAudioMode};
A->cfg_save(c,4);
}
/* ---- input ---- */
static void handleKeys(void){
uint8_t key=A->get_key();
/* long-press F -> keypad lock */
if(key==APP_KEY_F){
if(!g.fLongDone){ g.fHoldMs+=TICK_MS; if(g.fHoldMs>=LOCK_HOLD_MS){ g.fLongDone=true; g.foxLocked=!g.foxLocked; g.fArm=false; A->backlight_on(); } }
} else { g.fHoldMs=0; g.fLongDone=false; }
if(key==APP_KEY_INVALID||key==g.prevKey){ g.prevKey=key; return; }
g.prevKey=key;
A->backlight_on();
if(g.foxLocked){
if(key==APP_KEY_UP||key==APP_KEY_DOWN) attCycle(A->nav_dir(key));
return;
}
if(key==APP_KEY_F){ g.fArm=!g.fArm; return; }
int8_t dir=g.fArm?-1:1;
switch(key){
case APP_KEY_EXIT: g.running=false; break;
case APP_KEY_1: g.foxGraphMode^=1; break;
case APP_KEY_2:
g.foxAudioMode=cycleIndex(g.foxAudioMode,3u,dir); setAudio();
if(g.foxAudioMode==AUDIO_BEEP){ g.audioTick=RATE_SLOW; }
break;
case APP_KEY_3: attCycle(dir); break;
case APP_KEY_UP:
case APP_KEY_DOWN: attCycle(A->nav_dir(key)); break;
case APP_KEY_MENU: g.peakDbm=g.minDbm=g.trendRef=g.curDbm; break;
default: break;
}
g.fArm=false;
}
static void tickDelay(void){
for(uint8_t i=0;i<TICK_MS/10;i++){ A->delay_ms(10); A->backlight_update(); }
}
__attribute__((section(".text.entry"),used))
void app_main(const app_api_t *api){
uint8_t hist[HIST_LEN]; /* the history, on the stack: not in the overlay */
A=api;
g.histBuf=hist;
/* locks, holds, attenuator, bar gauge and audio off start zeroed (.bss) */
g.prevKey=APP_KEY_INVALID;
loadConfig();
A->backlight_on();
A->set_agc(false);
applyAtt();
setAudio();
A->delay_ms(ATT_SETTLE);
rebase();
g.running=true;
while(g.running){
handleKeys();
if(!g.running) break;
g.curDbm=A->rssi_dbm();
if(g.curDbm>g.peakDbm) g.peakDbm=g.curDbm;
if(g.curDbm<g.minDbm) g.minDbm=g.curDbm;
if(++g.trendTick>=TREND_TICKS){ g.trendDelta=g.curDbm-g.trendRef; g.trendRef=g.curDbm; g.trendTick=0; }
histSample();
draw();
A->blit_status();
A->blit_full();
if(g.foxAudioMode==AUDIO_BEEP && g.curDbm>=SILENCE_DBM){
if(++g.audioTick>=(uint8_t)lerp(g.curDbm,RATE_SLOW,RATE_FAST)){ g.audioTick=0; blip((uint16_t)lerp(g.curDbm,TONE_MIN,TONE_MAX)); }
} else if(g.foxAudioMode==AUDIO_STATION){
A->set_af(g.curDbm>=SILENCE_DBM?APP_AF_FM:APP_AF_MUTE);
}
A->battery_sample();
tickDelay();
}
saveConfig();
A->audio_path(false);
}