mirror of
https://github.com/armel/uv-k1-k5v3-firmware-custom.git
synced 2026-10-03 19:47:24 +00:00
372 lines
14 KiB
C
372 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.
|
|
*/
|
|
|
|
/*
|
|
* Beacon (fox) — overlay app. Ported from App/app/foxhunt.c, beacon sub-mode only.
|
|
* Turns the radio into a hidden ARDF transmitter: keys up on the TX VFO and repeats
|
|
* a CW fox identifier (MOE..MO5 / MO / "<call> MOE") for the TX window, then stays
|
|
* silent for the idle gap. Two keying modes (key 4): TONE (default) keeps the carrier
|
|
* up and keys only the tone (MCW / F2A); CARR keys the PA together with the tone, so
|
|
* between elements the carrier itself is gone (carrier interruption, ARDF field pattern).
|
|
*
|
|
* No division is linked: numbers are printed by subtracting powers of ten.
|
|
*
|
|
* Keys: 1 TX window · 2 idle gap · 3 fox id · 4 keying mode TONE/CARR (F reverses)
|
|
* MENU restart idle · long F keypad lock · EXIT quit.
|
|
*/
|
|
|
|
#include <stdint.h>
|
|
#include <stdbool.h>
|
|
#include <stddef.h>
|
|
#include "../app_api.h"
|
|
#include "beacon_assets.h" /* generated by gen_assets.py */
|
|
|
|
#define LCD_WIDTH 128
|
|
#define TICK_MS 50
|
|
#define LOCK_HOLD_MS 500
|
|
#define MORSE_UNIT 100
|
|
#define TONE_HZ 1000
|
|
#define CALL_MAX 12
|
|
|
|
#define IDLE_DEF 30
|
|
#define IDLE_MIN 5
|
|
#define IDLE_MAX 240
|
|
#define IDLE_STEP 5
|
|
#define TX_DEF 30
|
|
#define TX_MIN 5
|
|
#define TX_MAX 60
|
|
#define TX_STEP 5
|
|
|
|
#define FOX_MO 5
|
|
#define FOX_CALL 6
|
|
#define FOX_COUNT 7
|
|
#define MSG_CALL 1
|
|
#define MSG_ID 3
|
|
#define MSG_ONE 2
|
|
|
|
#define CFG_MAGIC 0xF4
|
|
|
|
_Static_assert(IDLE_STEP == 5 && TX_STEP == 5, "loadConfig() checks multiples of 5");
|
|
|
|
/* Texts, fox identifiers, the Morse table, the powers of ten and icons are
|
|
* read-only assets (gen_assets.py); only the displayed item is fetched from
|
|
* external flash. */
|
|
|
|
/* 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, phaseTx, carrier;
|
|
uint8_t beaconIdle, idleLeft, idleTick, beaconTx, secShown, charsSent, foxFox;
|
|
uint8_t prevKey;
|
|
uint16_t fHoldMs, txMsLeft;
|
|
const app_api_t *api;
|
|
char foxCall[CALL_MAX+1];
|
|
char msg[17];
|
|
};
|
|
static struct globals g;
|
|
#define A (g.api)
|
|
|
|
_Static_assert(offsetof(struct globals, prevKey) < 32u, "byte fields out of ldrb range");
|
|
|
|
/* 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; }
|
|
|
|
/* ---- 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 v, no leading zeros, by subtracting the powers of ten. */
|
|
static char *putu(char *o,uint32_t v){
|
|
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(v>=place[i]){ v-=place[i]; d++; }
|
|
if(d||i==PLACE_LEN/4u-1u) lead=false;
|
|
if(!lead) *o++=(char)('0'+d);
|
|
}
|
|
return o;
|
|
}
|
|
static void put2(char **o,uint8_t v){ if(v<10)*(*o)++='0'; *o=putu(*o,v); }
|
|
static void cpy(uint8_t *d,const uint8_t *s,uint8_t n){ while(n--)*d++=*s++; }
|
|
static const char *findsp(const char *s){ while(*s){ if(*s==' ')return s; s++; } return NULL; }
|
|
static uint8_t mmin(uint8_t a,uint8_t b){ return a<b?a:b; }
|
|
/* Whole seconds left in the TX window, rounded up, by subtraction. */
|
|
static uint8_t secsLeft(void){ uint8_t s=0; for(uint32_t n=g.txMsLeft+999u;n>=1000u;n-=1000u) s++; return s; }
|
|
static bool multipleOf5(uint8_t v){ while(v>=5u) v-=5u; return v==0u; }
|
|
|
|
/* ---- settings cycles ---- */
|
|
static uint8_t rangeStep(uint8_t v,uint8_t lo,uint8_t hi,uint8_t step,int8_t dir){
|
|
if(dir>0) return (v>=hi)?lo:(uint8_t)(v+step);
|
|
return (v<=lo)?hi:(uint8_t)(v-step);
|
|
}
|
|
static bool settingKey(uint8_t key,int8_t dir){
|
|
switch(key){
|
|
case APP_KEY_1: g.beaconTx =rangeStep(g.beaconTx, TX_MIN, TX_MAX, TX_STEP, dir); return true;
|
|
case APP_KEY_2: g.beaconIdle=rangeStep(g.beaconIdle,IDLE_MIN,IDLE_MAX,IDLE_STEP,dir); return true;
|
|
case APP_KEY_3:
|
|
if(dir>0){ if(++g.foxFox>=FOX_COUNT) g.foxFox=0; }
|
|
else g.foxFox=g.foxFox?(uint8_t)(g.foxFox-1u):(uint8_t)(FOX_COUNT-1u);
|
|
return true;
|
|
case APP_KEY_4: g.carrier=!g.carrier; return true; /* TONE <-> CARR (dir moot) */
|
|
default: return false;
|
|
}
|
|
}
|
|
|
|
/* ---- message ---- */
|
|
static void buildMsg(void){
|
|
char t[TEXT_MAX];
|
|
char *o=g.msg;
|
|
uint8_t id=g.foxFox;
|
|
if(g.foxFox==FOX_CALL){ /* "<call> MOE", or "MOE" alone */
|
|
id=0;
|
|
if(g.foxCall[0]){ o=put(o,g.foxCall); *o++=' '; }
|
|
}
|
|
o=put(o,T(t,FOX_ID+id*FOX_ID_STRIDE));
|
|
*o='\0';
|
|
}
|
|
static void foxLabel(char *out){
|
|
char t[TEXT_MAX];
|
|
char *o=put(out,T(t,T_FOX));
|
|
o=put(o,T(t,g.foxFox==FOX_CALL?T_CALL:FOX_ID+g.foxFox*FOX_ID_STRIDE));
|
|
*o='\0';
|
|
}
|
|
static uint8_t morseByte(char c){
|
|
uint8_t i, code;
|
|
if(c>='a'&&c<='z') c-=32;
|
|
if(c>='A'&&c<='Z') i=(uint8_t)(c-'A');
|
|
else if(c>='0'&&c<='9') i=(uint8_t)(26+c-'0');
|
|
else if(c=='/') i=36;
|
|
else return 0;
|
|
A->asset_read(MORSE+i,&code,1);
|
|
return code;
|
|
}
|
|
|
|
/* ---- drawing ---- */
|
|
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 chrome(void){
|
|
char s[16], t[TEXT_MAX];
|
|
A->display_clear();
|
|
A->status_clear();
|
|
A->print_inverse(T(t,T_BEACON),2,0,true,true,26);
|
|
A->draw_battery();
|
|
if(g.foxLocked||g.fArm) A->asset_read(g.foxLocked?BMP_LOCK:BMP_F,A->status_line+70,BMP_F_LEN);
|
|
/* the TX 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(s,A->tx_freq());
|
|
e[1]='\0';
|
|
for(uint8_t k=0;k<5u;k++,e--) e[0]=e[-1];
|
|
e[0]='.'; }
|
|
A->print_normal(s,(uint8_t)(126-slen(s)*7),0,6);
|
|
}
|
|
static void drawTxSeconds(void){
|
|
char s[16], t[TEXT_MAX];
|
|
uint8_t sec=secsLeft();
|
|
for(uint8_t x=0;x<LCD_WIDTH;x++) A->fb[0][x]=0;
|
|
A->print_normal(T(t,T_TX),1,0,0);
|
|
char *o=s; put2(&o,sec); *o++='s'; *o='\0';
|
|
A->print_normal(s,(uint8_t)(127-slen(s)*7),0,0);
|
|
g.secShown=sec;
|
|
}
|
|
static void drawMessage(uint8_t vis){
|
|
const char *msg=g.msg;
|
|
const char *sp=findsp(msg);
|
|
if(!sp){
|
|
uint8_t idLen=slen(msg), v=mmin(vis,idLen);
|
|
char id[17]; cpy((uint8_t*)id,(const uint8_t*)msg,v); id[v]='\0';
|
|
A->print_string(id,(uint8_t)((LCD_WIDTH-idLen*8u)>>1),0,MSG_ONE,8);
|
|
return;
|
|
}
|
|
uint8_t callLen=(uint8_t)(sp-msg), vc=mmin(vis,callLen);
|
|
char call[CALL_MAX+1]; cpy((uint8_t*)call,(const uint8_t*)msg,vc); call[vc]='\0';
|
|
A->print_string(call,(uint8_t)((LCD_WIDTH-callLen*8u)>>1),0,MSG_CALL,8);
|
|
if(vis>callLen+1){
|
|
const char *id=sp+1; uint8_t idLen=slen(id), vi=mmin((uint8_t)(vis-callLen-1),idLen);
|
|
char idb[8]; cpy((uint8_t*)idb,(const uint8_t*)id,vi); idb[vi]='\0';
|
|
A->print_string(idb,(uint8_t)((LCD_WIDTH-idLen*8u)>>1),0,MSG_ID,8);
|
|
}
|
|
}
|
|
static void updateProgress(uint8_t vis){
|
|
const char *sp=findsp(g.msg); uint8_t top;
|
|
g.charsSent=vis;
|
|
if(!sp) top=MSG_ONE;
|
|
else { uint8_t callLen=(uint8_t)(sp-g.msg); if(vis==callLen+1) return; top=(vis<=callLen)?MSG_CALL:MSG_ID; }
|
|
for(uint8_t x=0;x<LCD_WIDTH;x++){ A->fb[top][x]=0; A->fb[top+1][x]=0; }
|
|
drawMessage(g.charsSent);
|
|
A->blit_line(top); A->blit_line(top+1);
|
|
}
|
|
static void beaconDraw(bool txNow,uint8_t il){
|
|
char s[16], t[TEXT_MAX];
|
|
chrome();
|
|
if(txNow){
|
|
A->asset_read(BMP_TX,A->status_line+48,BMP_TX_LEN);
|
|
drawTxSeconds();
|
|
drawMessage(g.charsSent);
|
|
} else {
|
|
A->print_string(T(t,T_IDLE),0,127,1,10);
|
|
char *o=s; put2(&o,il); *o++='s'; *o='\0';
|
|
A->print_string(s,0,127,3,10);
|
|
}
|
|
char *o=put(s,T(t,T_TX)); *o++=' '; o=putu(o,g.beaconTx); *o++='s'; *o='\0'; tag(s,4,5);
|
|
o=put(s,T(t,T_IDLE)); *o++=' '; o=putu(o,g.beaconIdle); *o++='s'; *o='\0'; tag(s,4,6);
|
|
foxLabel(s); tag(s,66,5);
|
|
{ const char *m=T(t,g.carrier?T_CARR:T_TONE); tag(m,(uint8_t)(125-slen(m)*4),5); }
|
|
}
|
|
static void blit(void){ A->blit_status(); A->blit_full(); }
|
|
|
|
/* ---- keypad-lock long-press ---- */
|
|
/* true exactly on the lock/unlock toggle, so a caller that does not redraw every tick
|
|
* (the TX loop) can refresh the padlock at once. */
|
|
static bool lockTrack(uint8_t key,uint16_t ms){
|
|
if(key!=APP_KEY_F){ g.fHoldMs=0; g.fLongDone=false; return false; }
|
|
if(g.fLongDone) return false;
|
|
g.fHoldMs+=ms;
|
|
if(g.fHoldMs>=LOCK_HOLD_MS){ g.fLongDone=true; g.foxLocked=!g.foxLocked; g.fArm=false; A->backlight_on(); return true; }
|
|
return false;
|
|
}
|
|
|
|
/* ---- interactive TX delay: drains the window, keys, live seconds. true = abort ---- */
|
|
static bool txDelay(uint16_t ms){
|
|
while(ms){
|
|
uint16_t slice=(ms>10)?10:ms;
|
|
A->delay_ms(slice); ms-=slice;
|
|
g.txMsLeft=(g.txMsLeft>slice)?(uint16_t)(g.txMsLeft-slice):0;
|
|
A->backlight_update();
|
|
if(secsLeft()!=g.secShown){ drawTxSeconds(); A->blit_line(0); }
|
|
|
|
uint8_t key=A->get_key();
|
|
if(lockTrack(key,slice)){ beaconDraw(true,0); blit(); } /* show the padlock at once, mid-TX */
|
|
if(key==APP_KEY_INVALID||key==g.prevKey){ g.prevKey=key; continue; }
|
|
g.prevKey=key;
|
|
A->backlight_on();
|
|
if(g.foxLocked) continue;
|
|
if(key==APP_KEY_EXIT){ g.running=false; return true; }
|
|
if(key==APP_KEY_MENU) return true; /* cut short, restart idle */
|
|
if(key==APP_KEY_F){ g.fArm=!g.fArm; beaconDraw(true,0); blit(); continue; }
|
|
if(settingKey(key,g.fArm?-1:1)){ g.fArm=false; beaconDraw(true,0); blit(); }
|
|
}
|
|
return false;
|
|
}
|
|
/* Keying: both modes key the tone; CARR gates the PA in lockstep so the carrier is truly
|
|
* gone between elements (tone muted too, else it bleeds through residual PA leakage). */
|
|
static void keyOn(void){ A->tx_mute(false); A->tx_carrier(true); } /* carrier up both modes; re-arms after a mid-window switch */
|
|
static void keyOff(void){ A->tx_mute(true); if(g.carrier) A->tx_carrier(false); }
|
|
static bool morseChar(char c,uint8_t vis){
|
|
uint8_t code=morseByte(c);
|
|
if(code==0){ if(txDelay(MORSE_UNIT*4)) return true; updateProgress(vis); return false; }
|
|
uint8_t bit=0x80; while(!(code&bit))bit>>=1;
|
|
for(bit>>=1;bit;bit>>=1){
|
|
uint16_t on=(code&bit)?(MORSE_UNIT*3):MORSE_UNIT;
|
|
keyOn();
|
|
if(txDelay(on)){ keyOff(); return true; }
|
|
keyOff();
|
|
if(txDelay(MORSE_UNIT)) return true;
|
|
}
|
|
updateProgress(vis);
|
|
return txDelay(MORSE_UNIT*2);
|
|
}
|
|
static void transmit(void){
|
|
A->tx_set_params();
|
|
A->tx_tone(TONE_HZ);
|
|
A->tx_mute(true); /* open muted (both modes) */
|
|
if(g.carrier) A->tx_carrier(false); /* CARR: carrier off until first element */
|
|
g.txMsLeft=(uint16_t)g.beaconTx*1000u;
|
|
bool stop=false;
|
|
while(!stop && g.txMsLeft>0){
|
|
buildMsg();
|
|
g.charsSent=0;
|
|
beaconDraw(true,0); blit();
|
|
for(uint8_t i=0;!stop && g.msg[i];i++) stop=morseChar(g.msg[i],(uint8_t)(i+1));
|
|
if(!stop && g.txMsLeft>0) stop=txDelay(MORSE_UNIT*7);
|
|
}
|
|
A->tx_mute(true);
|
|
A->tx_end(); /* PA off + restore RX */
|
|
}
|
|
|
|
/* ---- idle-phase keys ---- */
|
|
static void idleKeys(void){
|
|
uint8_t key=A->get_key();
|
|
lockTrack(key,TICK_MS);
|
|
if(key==APP_KEY_INVALID||key==g.prevKey){ g.prevKey=key; return; }
|
|
g.prevKey=key;
|
|
A->backlight_on();
|
|
if(g.foxLocked) return;
|
|
if(key==APP_KEY_F){ g.fArm=!g.fArm; return; }
|
|
switch(key){
|
|
case APP_KEY_EXIT: g.running=false; break;
|
|
case APP_KEY_MENU: g.idleLeft=g.beaconIdle; g.idleTick=0; break;
|
|
default: settingKey(key,g.fArm?-1:1); 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(); } }
|
|
|
|
/* ---- config (deferred) ---- */
|
|
static void loadConfig(void){
|
|
uint8_t c[5]; A->cfg_load(c,5);
|
|
if(c[0]==CFG_MAGIC){
|
|
if(c[1]>=IDLE_MIN&&c[1]<=IDLE_MAX&&multipleOf5(c[1])) g.beaconIdle=c[1];
|
|
if(c[2]<FOX_COUNT) g.foxFox=c[2];
|
|
if(c[3]>=TX_MIN&&c[3]<=TX_MAX&&multipleOf5(c[3])) g.beaconTx=c[3];
|
|
if(c[4]<=1) g.carrier=c[4]; /* erased (0xFF) legacy config -> keep default */
|
|
}
|
|
}
|
|
static void saveConfig(void){ uint8_t c[5]={CFG_MAGIC,g.beaconIdle,g.foxFox,g.beaconTx,(uint8_t)g.carrier}; A->cfg_save(c,5); }
|
|
|
|
static void txDenied(void){
|
|
char t[TEXT_MAX];
|
|
chrome();
|
|
A->print_string(T(t,T_TX_OFF),0,127,1,8);
|
|
blit();
|
|
for(uint8_t i=0;i<20;i++) tickDelay();
|
|
}
|
|
|
|
__attribute__((section(".text.entry"),used))
|
|
void app_main(const app_api_t *api){
|
|
A=api;
|
|
/* locks, holds and the TONE keying mode start zeroed (.bss) */
|
|
g.beaconIdle=IDLE_DEF; g.beaconTx=TX_DEF; g.foxFox=FOX_CALL;
|
|
g.prevKey=APP_KEY_INVALID;
|
|
A->boot_callsign(g.foxCall,sizeof(g.foxCall));
|
|
loadConfig();
|
|
A->backlight_on();
|
|
|
|
g.phaseTx=true; g.idleLeft=g.beaconIdle; g.idleTick=0;
|
|
g.running=true;
|
|
while(g.running){
|
|
if(g.phaseTx){
|
|
if(A->tx_state()!=0){ txDenied(); g.phaseTx=false; g.idleLeft=g.beaconIdle; g.idleTick=0; continue; }
|
|
transmit();
|
|
g.phaseTx=false; g.idleLeft=g.beaconIdle; g.idleTick=0;
|
|
continue;
|
|
}
|
|
idleKeys();
|
|
if(!g.running) break;
|
|
beaconDraw(false,g.idleLeft); blit();
|
|
if(++g.idleTick>=(1000/TICK_MS)){ g.idleTick=0; if(g.idleLeft>0)g.idleLeft--; if(g.idleLeft==0)g.phaseTx=true; }
|
|
A->battery_sample();
|
|
tickDelay();
|
|
}
|
|
|
|
saveConfig();
|
|
A->tx_end(); /* safety: PA off + RX restored */
|
|
A->audio_path(false);
|
|
}
|