Files
uv-k1-k5v3-firmware-custom/App/apps/aprstx/aprstx_app.c
T

497 lines
22 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.
*/
/*
* APRS TX - sends one APRS position frame (AX.25 UI frame, Bell 202 AFSK
* 1200 bauds) on the TX VFO at each press of PTT or MENU.
*
* The source is the boot-message callsign (API boot_callsign) with an SSID;
* destination, the WIDE path entries, symbol and comment are AX.25-encoded in
* gen_assets.py (assets). The position, the SSID and the path (DIRECT, WIDE1-1
* or WIDE1-1,WIDE2-1) are edited on the radio (key 5) and saved; gen_assets.py
* holds the defaults. The frame is built at launch and after each edit, FCS
* included.
*
* AFSK comes from the BK4829 tone generator, as the Beacon app keys MCW: the tone
* frequency (REG_71) is rewritten at each NRZI transition, 1200 Hz mark / 2200 Hz
* space, bits timed to the cycle (40000 cycles at 48 MHz) from SysTick. The
* mic ADC is off while a tone is sent; any CTCSS/DCS of the VFO is cleared
* (REG_51) so nothing rides under the AFSK. test/tx_model.py mirrors the frame
* and bit stream and decodes them with the APRS RX model.
*
* Keys (UV-K5 and UV-K1): PTT or MENU send · UP/DOWN tone level (deviation,
* REG_70 gain 10-127) · 1/3 twist: 2200 Hz gain = level x (8 + tw) / 8,
* tw -4..+8 (-6..+6 dB), for a receiver's de-emphasis · 5 edit the position,
* SSID and path · EXIT quit.
* Editor (the field under the cursor in bold): 0-9 type a digit (the cursor
* skips the separators and N/S, and moves on) · * toggle N/S or E/W on the
* position lines, next SSID or path on theirs · F then * the previous SSID or
* path · UP/DOWN move the cursor (6 digits, N/S, 7 digits, E/W, SSID, path) ·
* MENU check and keep · EXIT cancel.
* Level, twist, position, SSID and path are saved on exit (cfg_save, 11 bytes).
*/
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "../app_api.h"
#include "aprstx_assets.h" /* generated by gen_assets.py */
/* ---- SysTick (core at 48 MHz, 10 ms period) ---- */
#define SYST_LOAD (*(volatile uint32_t *)0xE000E014u)
#define SYST_VAL (*(volatile uint32_t *)0xE000E018u)
#define CYC_PER_BIT (48000000u / 1200u) /* 40000: exact */
/* ---- BK4829 ---- */
#define REG_51 0x51 /* sub-audio: 0 = no CTCSS/DCS (BK4819_ExitSubAu) */
#define REG_70 0x70 /* tone 1: <15> enable, <14:8> gain */
#define REG_71 0x71 /* tone 1 frequency, Hz x 10.32444 */
#define F_MARK 12389u /* 1200 Hz: (f * 1353245 + 2^16) >> 17, scale_freq */
#define F_SPACE 22714u /* 2200 Hz */
#define LVL_DEF 66 /* BK4819_TransmitTone's tone gain */
#define LVL_MIN 10
#define LVL_MAX 127
#define LVL_STEP 4
#define TW_MIN (-4) /* 2200 Hz gain x 0.5 (-6 dB) */
#define TW_MAX 8 /* 2200 Hz gain x 2 (+6 dB) */
/* ---- AX.25 ---- */
#define TXDELAY_FLAGS 40u /* 267 ms of flags before the frame */
#define TAIL_FLAGS 3u
#define CALL_MAX 6u
#define INFO_LEN (1u + 8u + 1u + 9u + 1u + F_COMMENT_LEN) /* !lat/lon[comment */
#define PATH_MAX 2u /* WIDE entries: 0 = DIRECT */
#define FRAME_MAX (7u + 7u + PATH_MAX * 7u + 2u + INFO_LEN + 2u)
/* position: 13 digits DDMMhh DDDMMhh, hemispheres bit 0 south, bit 1 west */
#define POS_DIGITS 13u
#define LAT_DIGITS 6u
#define TX_CAPS_X 40u /* "TRANSMIT" capsule in the status bar, after the title */
/* editor cursor stops: 0-5 the latitude digits, N/S, 7-13 the longitude
* digits, E/W, SSID, path */
#define CUR_NS LAT_DIGITS
#define CUR_EW (POS_DIGITS + 1u)
#define CUR_SSID (CUR_EW + 1u)
#define CUR_PATH (CUR_EW + 2u)
#define FIELD_COL 5u /* "SSID " / "PATH ": the value's column */
#define CFG_MAGIC 0xA8u /* 0xA7 (v0.1: level and twist only) is ignored */
#define CFG_LEN 11u /* magic, level, twist, 13 digits + hemispheres in nibbles,
then 0x80 | path << 4 | SSID (v0.2 configs: defaults) */
#define LOOP_MS 40u
#define REFRESH_LOOPS 125u /* a redraw every 5 s without a key (battery) */
enum { ST_IDLE = 0, ST_TX, ST_DENIED, ST_NOCALL, ST_BADPOS };
/* The app state in one struct: a function loads one base address instead of one
* literal-pool word per global. Bytes, then the halfword, then words, so every
* field stays within the Thumb load/store immediate ranges (ldrb <= 31, ldrh <= 62,
* ldr <= 124); the arrays, addressed from their base, go last. The loader zeroes
* the overlay (.bss) at every launch. */
static struct {
uint8_t flen, prevKey, space, ones, lvl, status;
int8_t tw;
bool running, edit, redraw; /* redraw: draw() at the next loop */
bool fArm; /* editor: F pressed, the next key goes back */
uint8_t hemi, ehemi, cur; /* hemispheres, edited ones, editor cursor */
uint8_t ssid, essid, path, epath; /* source SSID, path, and the edited ones */
uint8_t tick; /* loops since the last draw() */
uint16_t sent; /* frames sent */
const app_api_t *A;
uint8_t *frm; /* the frame (app_main's stack) */
uint32_t tPrev, tCyc, next; /* SysTick cycle counter, next bit edge */
uint8_t pos[POS_DIGITS]; /* the position sent */
uint8_t ed[POS_DIGITS]; /* the position being edited */
} g;
static char str[34];
/* ---- tiny freestanding helpers: GCC may turn small copies into calls ---- */
void *memset(void *d, int c, size_t n) {
uint8_t *p = d; while (n--) *p++ = (uint8_t)c; return d;
}
void *memcpy(void *d, const void *s, size_t n) {
uint8_t *p = d; const uint8_t *q = s; while (n--) *p++ = *q++; return d;
}
/* ---- formatting ---- */
static char *put(char *o,const char *s){ while(*s)*o++=*s++; return o; }
/* Formatting by repeated subtraction: no division. Values printed stay below 100000. */
static uint8_t sub(uint32_t *v,uint32_t d){ uint8_t q=0; while(*v>=d){ *v-=d; q++; } return q; }
static const uint16_t P10[]={10000u,1000u,100u,10u,1u};
static char *puti(char *o,int32_t v){
uint32_t u;
if(v<0){*o++='-';u=(uint32_t)(-v);} else u=(uint32_t)v;
bool lead=false;
for(uint8_t i=0;i<5;i++){
uint8_t c=sub(&u,P10[i]);
if(c||lead||i==4u){ *o++=(char)('0'+c); lead=true; }
}
return o;
}
/* A tiny row holds 32 characters (4 px each): print_tiny does not clip, so
* every row built below stays within that. */
static void tiny(uint8_t y,char *end){ *end='\0'; g.A->print_tiny(str,0,y,false,true); }
/* The frequency (10 Hz units) drawn as the main screen draws it (ui/main.c,
* ENABLE_BIG_FREQ): the big digits up to the kHz on lines 4-5, the last two
* digits in the small font on line 5 right after them (from 1 GHz, the whole
* string in the big font), the group centred. Big digits are 13 px (drawn from
* +2), the point 3 px: the group shows (n big digits x 13 + 14) px. No division. */
static void drawFreq(uint32_t f){
const app_api_t *A=g.A;
uint16_t m=0;
while(f>=100000u){ f-=100000u; m++; }
char *o=puti(str,m);
*o++='.';
for(uint8_t i=0;i<5u;i++) *o++=(char)('0'+sub(&f,P10[i]));
*o='\0';
uint8_t n=(uint8_t)(o-str), big=(uint8_t)(n-3u); /* digits before "00" */
if(m>=1000u){ A->print_string(str,(uint8_t)((128u-n*8u)>>1),0,4,8); return; }
uint8_t x=(uint8_t)(((128u-(big*13u+14u))>>1)-2u);
A->print_normal(str+n-2u,(uint8_t)(x+big*13u+3u),0,5); /* "00" */
str[n-2u]='\0';
A->display_freq(str,x,4,false); /* "144.800" */
}
/* AX.25 address field -> "F4HWN-7" */
static char *putCall(char *o,const uint8_t *a){
for(uint8_t i=0;i<6u;i++){ char c=(char)(a[i]>>1); if(c!=' ') *o++=c; }
uint8_t ssid=(uint8_t)((a[6]>>1)&15u);
if(ssid){ *o++='-'; o=puti(o,ssid); }
return o;
}
/* ---- position ---- */
/* "4850.90N" (6 digits) or "00216.25E" (7 digits), as APRS sends it */
static uint8_t *putCoord(uint8_t *o,const uint8_t *d,uint8_t n,char h){
for(uint8_t i=0;i<n;i++){ if(i==n-2u) *o++='.'; *o++=(uint8_t)('0'+d[i]); }
*o++=(uint8_t)h;
return o;
}
/* degrees <= 90 / 180 (and nothing beyond), minutes < 60 */
static bool posOk(const uint8_t *d){
uint8_t la=(uint8_t)(d[0]*10u+d[1]), lam=(uint8_t)(d[2]*10u+d[3]);
uint8_t lom=(uint8_t)(d[9]*10u+d[10]);
uint16_t lo=(uint16_t)(d[6]*100u+d[7]*10u+d[8]);
for(uint8_t i=0;i<POS_DIGITS;i++) if(d[i]>9u) return false;
if(la>90u || lam>59u || lo>180u || lom>59u) return false;
if(la==90u && (lam|d[4]|d[5])) return false;
if(lo==180u && (lom|d[11]|d[12])) return false;
return true;
}
/* ---- the frame: dest, source (boot callsign + SSID), path, UI, info, FCS ---- */
static void build(void){
const app_api_t *A=g.A;
uint8_t *f=g.frm;
char call[12];
A->boot_callsign(call,sizeof call);
uint8_t n=0;
while(call[n]){ if(call[n]=='/') return; n++; } /* AX.25: letters and digits */
if(!n || n>CALL_MAX) return; /* flen stays 0: no TX */
A->asset_read(F_DEST,f,F_DEST_LEN);
for(uint8_t i=0;i<6u;i++) f[7+i]=(uint8_t)((i<n?call[i]:' ')<<1);
f[13]=(uint8_t)(0x60u|(g.ssid<<1)|(g.path?0u:1u)); /* end bit if DIRECT */
uint8_t k=(uint8_t)(14u+g.path*7u); /* the first path WIDEs */
if(g.path){ A->asset_read(F_WIDE,f+14,(uint16_t)(k-14u)); f[k-1]|=1u; }
f[k++]=0x03; f[k++]=0xF0; /* UI frame, no layer 3 */
uint8_t sym[2];
A->asset_read(F_SYM,sym,2);
f[k++]='!'; /* position, no messaging */
k=(uint8_t)(putCoord(f+k,g.pos,LAT_DIGITS,(g.hemi&1u)?'S':'N')-f);
f[k++]=sym[0]; /* symbol table */
k=(uint8_t)(putCoord(f+k,g.pos+LAT_DIGITS,POS_DIGITS-LAT_DIGITS,(g.hemi&2u)?'W':'E')-f);
f[k++]=sym[1]; /* symbol code */
A->asset_read(F_COMMENT,f+k,F_COMMENT_LEN); k+=F_COMMENT_LEN;
uint16_t c=0xFFFFu; /* CRC-16/X.25 */
for(uint8_t i=0;i<k;i++){
c^=f[i];
for(uint8_t b=0;b<8u;b++) c=(c&1u)?(uint16_t)((c>>1)^0x8408u):(uint16_t)(c>>1);
}
c^=0xFFFFu;
f[k++]=(uint8_t)c; f[k++]=(uint8_t)(c>>8);
g.flen=k;
}
/* ---- SysTick cycle counter (call at least every 10 ms; compare with a signed
* difference) ---- */
static void clkStart(void){ g.tPrev=SYST_VAL; g.tCyc=0; }
static uint32_t clkCyc(void){
uint32_t v=SYST_VAL;
g.tCyc += (v<=g.tPrev) ? g.tPrev-v : g.tPrev+(SYST_LOAD+1u-v);
g.tPrev=v;
return g.tCyc;
}
/* ---- AFSK ---- */
static void setTone(void){
const app_api_t *A=g.A;
uint32_t v=g.lvl;
if(g.space){ v=(v*(uint32_t)(8+g.tw))>>3; if(v>LVL_MAX) v=LVL_MAX; }
A->bk_write(REG_71,(uint16_t)(g.space?F_SPACE:F_MARK));
A->bk_write(REG_70,(uint16_t)(0x8000u|(v<<8)));
}
/* one bit on the air: NRZI (a 0 changes the tone), from the next bit edge */
static void sendBit(uint8_t b){
while((int32_t)(clkCyc()-g.next)<0){}
if(!b){ g.space^=1u; setTone(); }
g.next+=CYC_PER_BIT;
}
/* one byte LSB first; inside the frame, a 0 is stuffed after five 1s */
static void sendByte(uint8_t v,bool stuff){
for(uint8_t i=0;i<8u;i++){
uint8_t b=(uint8_t)(v&1u);
v>>=1;
sendBit(b);
if(stuff){
if(!b) g.ones=0;
else if(++g.ones==5u){ sendBit(0); g.ones=0; }
}
}
}
/* "LAT 48 50.90 N" on a normal-font line (7 px per character). The line's
* cursor stops are its n digits then the hemisphere (cur = n); the one under cur
* is redrawn in bold (glyphs overwrite their columns). A cursor off the line
* (negative, or past the hemisphere) shows none. */
static void editRow(const char *label,uint8_t line,const uint8_t *d,uint8_t n,char h,int8_t cur){
char *o=put(str,label);
uint8_t col=0;
for(uint8_t i=0;i<n;i++){
if(i==n-4u) *o++=' ';
if(i==n-2u) *o++='.';
if((int8_t)i==cur) col=(uint8_t)(o-str);
*o++=(char)('0'+d[i]);
}
*o++=' ';
if((int8_t)n==cur) col=(uint8_t)(o-str);
*o++=h; *o='\0';
g.A->print_normal(str,0,0,line);
if((uint8_t)cur<=n){ str[0]=str[col]; str[1]='\0'; g.A->print_bold(str,(uint8_t)(col*7u),0,line); }
}
/* "SSID 7" / "PATH WIDE1-1" on a normal-font line, the value in bold when the
* cursor is on it */
static void fieldRow(const char *label,const char *val,uint8_t line,bool sel){
char *o=put(put(str,label),val);
*o='\0';
g.A->print_normal(str,0,0,line);
if(sel) g.A->print_bold(val,FIELD_COL*7u,0,line);
}
/* ---- display ---- */
static void draw(void){
/* The UI texts live in the assets: one read per frame into this word-aligned
* stack block (gen_assets.py keeps every offset aligned). */
char s[UI_SIZE] __attribute__((aligned(4)));
const app_api_t *A=g.A;
char *o;
A->asset_read(0,s,UI_SIZE);
A->display_clear();
A->status_clear();
A->print_inverse(s+T_TITLE,2,0,true,true,(uint8_t)(2u+T_TITLE_CHARS*4u));
if(g.status==ST_TX) /* a second capsule while on the air */
A->print_inverse(s+T_TX,TX_CAPS_X,0,true,true,(uint8_t)(TX_CAPS_X+T_TX_CHARS*4u));
if(g.fArm) /* editor: F armed, as FoxHunt / FM / Beacon */
A->asset_read(BMP_F,A->status_line+70,BMP_F_LEN);
A->draw_battery();
if(g.edit){
int8_t c=(int8_t)g.cur; /* each line takes its own stops from it */
editRow(s+T_LAT,0,g.ed,LAT_DIGITS,(g.ehemi&1u)?'S':'N',c);
editRow(s+T_LON,1,g.ed+LAT_DIGITS,POS_DIGITS-LAT_DIGITS,(g.ehemi&2u)?'W':'E',
(int8_t)(c-(int8_t)(CUR_NS+1u)));
char v[3];
*puti(v,g.essid)='\0';
fieldRow(s+T_SSID,v,2,g.cur==CUR_SSID);
fieldRow(s+T_PATH,s+T_PATHS+g.epath*T_PATHS_STRIDE,3,g.cur==CUR_PATH);
tiny(40,put(str,s+T_HELP1));
tiny(48,put(str,g.status==ST_BADPOS?s+T_BADPOS:s+T_HELP2));
} else {
if(g.flen){
const uint8_t *f=g.frm;
o=putCall(str,f+7); *o='\0';
A->print_normal(str,0,0,0); /* source */
o=str; *o++='>'; o=putCall(o,f);
uint8_t e=13; /* the last address byte */
while(!(f[e]&1u)){ e+=7; *o++=','; o=putCall(o,f+e-6); }
tiny(9,o); /* ">APZK5,WIDE1-1,WIDE2-1" (<= 2 hops) */
uint8_t i=(uint8_t)(e+3u), end=(uint8_t)(g.flen-2u); /* info, after control + PID */
for(uint8_t row=0;row<2u;row++){ /* info (<= 64), 2 rows of 32 */
o=str;
for(; o<str+32 && i<end; i++) *o++=(char)f[i];
tiny((uint8_t)(17u+row*7u),o);
}
}
/* dotted separator above the frequency: y = 30, bit 6 of line 3 (the big
* digits start at y = 33) */
for(uint8_t x=0;x<128u;x+=2u) A->fb[3][x]|=0x40u;
drawFreq(A->tx_freq());
/* bottom line: "lvl 66 tw +0 sent 3", or why nothing was sent */
if(g.status==ST_DENIED||g.status==ST_NOCALL)
o=put(str,g.status==ST_DENIED?s+T_DENIED:s+T_NOCALL);
else {
o=put(str,s+T_LVL); o=puti(o,g.lvl);
o=put(o,s+T_TW); if(g.tw>0) *o++='+'; o=puti(o,g.tw);
o=put(o,s+T_SENT); o=puti(o,g.sent);
}
tiny(49,o);
}
A->blit_status();
A->blit_full();
}
/* ---- one frame on the air ---- */
static void send(void){
const app_api_t *A=g.A;
if(!g.flen){ g.status=ST_NOCALL; return; }
if(A->tx_state()){ g.status=ST_DENIED; return; }
g.status=ST_TX;
draw();
A->tx_set_params(); /* carrier + PA on the TX VFO */
A->bk_write(REG_51,0); /* no CTCSS/DCS under the AFSK */
A->tx_tone(1200); /* tone path on, mic off, 50 ms settle */
g.space=0;
setTone(); /* mark at our level */
clkStart();
g.next=CYC_PER_BIT;
for(uint8_t i=0;i<TXDELAY_FLAGS;i++) sendByte(0x7E,false);
g.ones=0;
for(uint8_t i=0;i<g.flen;i++) sendByte(g.frm[i],true);
for(uint8_t i=0;i<TAIL_FLAGS;i++) sendByte(0x7E,false);
while((int32_t)(clkCyc()-g.next)<0){} /* the last bit plays out */
A->tx_mute(true);
A->tx_end(); /* PA off + RX restored */
if(g.sent<9999u) g.sent++;
g.status=ST_IDLE;
}
/* ---- input ---- */
static void handleKeys(void){
const app_api_t *A=g.A;
uint8_t key=A->get_key();
if(key==APP_KEY_INVALID||key==g.prevKey){ g.prevKey=key; return; }
g.prevKey=key;
g.redraw=true; /* a key changes the screen, a send too */
A->backlight_on();
if(g.edit){ /* position, SSID and path editor */
g.status=ST_IDLE;
if(key==APP_KEY_F){ g.fArm=!g.fArm; return; }
bool back=g.fArm; /* F then a key: it goes backwards */
g.fArm=false;
uint8_t cur=g.cur;
bool lon=cur>CUR_NS && cur<=CUR_EW; /* on the longitude line */
if(key<=APP_KEY_9){
if(cur<CUR_NS || (lon && cur<CUR_EW)){ /* a digit: its stop is one past it on LON */
g.ed[cur-lon]=key;
if(++cur==CUR_NS) cur++; /* typing goes on past N/S ... */
if(cur==CUR_EW) cur--; /* ... and stays on the last digit */
g.cur=cur;
}
}
else if(key==APP_KEY_STAR){
if(cur<=CUR_EW) g.ehemi^=(uint8_t)(lon?2u:1u); /* on a digit or the letter */
else if(cur==CUR_SSID) g.essid=(uint8_t)((g.essid+(back?15u:1u))&15u);
else if(back) g.epath=(uint8_t)(g.epath?g.epath-1u:PATH_MAX);
else g.epath=(uint8_t)(g.epath<PATH_MAX?g.epath+1u:0u);
}
else if(key==APP_KEY_MENU){
if(!posOk(g.ed)){ g.status=ST_BADPOS; return; }
memcpy(g.pos,g.ed,POS_DIGITS);
g.hemi=g.ehemi; g.ssid=g.essid; g.path=g.epath;
g.edit=false;
build();
if(!g.flen) g.status=ST_NOCALL;
}
else if(key==APP_KEY_EXIT) g.edit=false;
else {
int8_t d=A->nav_dir(key);
if(d<0 && g.cur) g.cur--;
if(d>0 && g.cur<CUR_PATH) g.cur++;
}
return;
}
if(key==APP_KEY_EXIT) g.running=false;
else if(key==APP_KEY_MENU||key==APP_KEY_PTT) send();
else if(key==APP_KEY_1){ if(g.tw>TW_MIN) g.tw--; }
else if(key==APP_KEY_3){ if(g.tw<TW_MAX) g.tw++; }
else if(key==APP_KEY_5){
memcpy(g.ed,g.pos,POS_DIGITS);
g.ehemi=g.hemi; g.essid=g.ssid; g.epath=g.path;
g.cur=0;
g.edit=true;
if(g.status!=ST_NOCALL) g.status=ST_IDLE;
}
else {
int8_t d=A->nav_dir(key);
if(d>0 && g.lvl<=LVL_MAX-LVL_STEP) g.lvl=(uint8_t)(g.lvl+LVL_STEP);
if(d<0 && g.lvl>=LVL_MIN+LVL_STEP) g.lvl=(uint8_t)(g.lvl-LVL_STEP);
}
}
__attribute__((section(".text.entry"),used))
void app_main(const app_api_t *api){
uint8_t frm[FRAME_MAX]; /* on the stack: the 4 KiB overlay also holds .bss */
uint8_t c[CFG_LEN];
g.A=api;
g.frm=frm;
g.prevKey=APP_KEY_INVALID; /* the rest of g starts at 0 (overlay zeroed by the loader) */
g.lvl=LVL_DEF;
g.ssid=CFG_SSID;
g.path=CFG_PATH;
api->asset_read(POS_DEF,g.pos,POS_DIGITS);
api->asset_read(POS_DEF+POS_DIGITS,&g.hemi,1);
api->cfg_load(c,CFG_LEN);
if(c[0]==CFG_MAGIC){
if(c[1]>=LVL_MIN && c[1]<=LVL_MAX) g.lvl=c[1];
if((int8_t)c[2]>=TW_MIN && (int8_t)c[2]<=TW_MAX) g.tw=(int8_t)c[2];
for(uint8_t i=0;i<POS_DIGITS;i++) g.ed[i]=(uint8_t)((c[3+i/2u]>>((i&1u)*4u))&15u);
uint8_t h=(uint8_t)(c[9]>>4);
if(posOk(g.ed) && h<=3u){ memcpy(g.pos,g.ed,POS_DIGITS); g.hemi=h; }
h=c[10]; /* 0x80 | path << 4 | SSID; erased (0xFF) or 0: defaults */
if((h&0xC0u)==0x80u && ((h>>4)&3u)<=PATH_MAX){ g.ssid=(uint8_t)(h&15u); g.path=(uint8_t)((h>>4)&3u); }
}
build();
if(!g.flen) g.status=ST_NOCALL;
api->backlight_on();
g.running=true;
g.redraw=true;
while(g.running){
handleKeys();
if(!g.running) break;
/* The screen only changes on a key (or a send it starts): redraw then,
* and every 5 s for the battery, not at each loop. The state stays in g:
* locals here would live on the stack across the inlined calls. */
if(++g.tick>=REFRESH_LOOPS) g.redraw=true;
if(g.redraw){ g.redraw=false; g.tick=0; draw(); }
api->battery_sample();
api->delay_ms(LOOP_MS);
api->backlight_update(); /* normal BLTime timeout; keys re-arm it */
}
c[0]=CFG_MAGIC; c[1]=g.lvl; c[2]=(uint8_t)g.tw;
for(uint8_t i=3;i<CFG_LEN;i++) c[i]=0;
for(uint8_t i=0;i<POS_DIGITS;i++) c[3+i/2u]|=(uint8_t)(g.pos[i]<<((i&1u)*4u));
c[9]|=(uint8_t)(g.hemi<<4); /* the 14th nibble */
c[10]=(uint8_t)(0x80u|(g.path<<4)|g.ssid);
api->cfg_save(c,CFG_LEN);
}