APRS RX/TX: add validated 20x20 symbols and TX selector

This commit is contained in:
Armel FAUVEAU committed 2026-10-02 03:46:46 +02:00
1 parent f940a6b59e
commit c06246b798
12 files changed
+919 -82

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
"""Shared 20x20 monochrome APRS symbol assets.
Each icon is stored in native ST7565 page order: 20 bytes for each of three
8-pixel pages. Only the low four bits of the third page are used. This lets
the overlay apps copy an icon straight from external flash to the framebuffer.
The masks were validated pixel by pixel with
aprsrx/test/aprs_symbols_20_editor.html before being packed here.
"""
WIDTH = 20
PAGES = 3
BYTES_PER_ICON = WIDTH * PAGES
CODES = (
"/#", "/X", "/&", "/Y", "/'", "/[", "/-", "/\\",
"/.", "/^", "/0", "/_", "/:", "/a", "/;", "/b",
"/<", "/f", "/>", "/g", "/C", "/j", "/E", "/k",
"/I", "/m", "/K", "/O", "/P", "/s", "/R", "/v",
"/y", "/T", "/U", "/V", "\\#", "\\&", "\\-", "/W",
"\\.", "\\0", "E0", "I0", "W0", "YY", "\\^", "\\Y",
)
_HEX = """
00000000000000c0f0fcf0c000000000000000000001030787cfffffff7fffffffcf8707030100000000000607030101000000010103070600000000
800060606020202020a0f0a020202020606060001b1f0e04040c1c3e7fff7f737f7af27c380000000000000000000000010303030303030201000000
000000001c04047c04c4fcc4047c04041c00000000000000000080f0fccfcfcffcf08000000000000000000000040707050404040507070400000000
0000000000000080e000fefcf0c000000000000000008080a0b8bebfbf80ffdfdfdfdfdcd0c060200000050505050505050505050505050505000000
8080000000040c38f0e0c0800000000000000000203f1f0e0e0e0e8eeeff7f3f1f0e0e0e0e0e0e040000000000040603010000000000000000000000
000000000000000098bcbcbc980000000000000000000000001c1e03e3ff3fffe3031e1c000000000000000000000006070700070706000000000000
0000000000000080c0c28a2afe2a0a0200000000000000f8fcfe1f1fffff1f1f1ffcf800000000000000000707070707070704040407070000000000
00000030f090101010101010101090f0300000000000000000030e38e08080e0380e0300000000000000000000000000000303000000000000000000
00000078f8f0e0c080000080c0e0f0f878000000000000c0e0f1fb7f3f1f1f3f7ffbf1e0c00000000000000303010000000000000000010303000000
00000000008080c0f0dcdcf0c0808000000000000c0e0e0707070783ffffffff83070707070e0e0c0000000000070303010707010303070000000000
000000c0e030101808080808181030e0c000000000001f7fe080000000000000000080e07f1f00000000000000010103020202020301010000000000
00007c381010d0d010386c6c000000000000000000000000181cffff1c180000007cfc7c000000000000000000000707020202020202030000000000
00000000008000c0f0fcfee08080e0000000000000000038feffff1f0f01031f9ffff9fc3e0000000000000000010302000000020303010000000000
0080c0c0c0c0c04040c0c0c0f0f87840c0800000007f3f9fdf9939606079797f7f3f9cdc9c3d7e7c0000000103010000000000000000010301000000
0000000000000000c0f0c0000000000000000000000000c0f0f8fe3f0f070f3ffef8e0c0000000000001010101010101010101010101010101010100
0000000000000040c0400000101090e0800000000070882474248c722127190905738924772488700000000101010000000000000000000101010000
00000000000000000000008898f0c000000000003249b5b54933061c3c3c3f1f073349b5b54932000000000000000000000000000000000000000000
00008080c040406020203010101888888880c0807e3e9fdf9f3f9fdf9f3f617f7c7c3f98d898397f0000010301000103010000000000000103010000
000000008080c0c0c0c0c0c0c080000000000000787c1c4fefed4c1c3c3f3c3c1d4feeec4c1c7c780000000000000000000000000000000000000000
0000000000000040c0c0800000000000808000000c1830606070703838191f0f0e060703030101000000000000000000000000000000000000000000
00000000000000000000000080c07038381000000e1f1f1e9cdc6c34180c161b1d1c1c1c1e1f1f0e0002070703000000000000000000000000000000
000000000000000000e02020e0202020608000003e3e0e66f6660e1e1f1f1c1c1f0e66f6660f1f3e0000000000000000000000000000000000000000
000080c06030101888c8c88818103060c080000000070f18306040c78f9f9f8fc7406030180f07000000000000000000000000000000000000000000
e0e0e0e0e0e0e0e0e0e0e0e0e0e00080808000003f1fcfefcf1f3f1fcfefcf1f3f3f13cfe8c81f3c0000000100000000000100000000000001000000
000000000000007c44c4c4447c000000000000000000e0203c24e4040407070404e4243c20e000000000010101010100000000000001010101010000
0000f80601f88c0070f0f070008cf80106f8000000000003040001e0b8cfcfb8e0010004030000000000000000020303020202020303020000000000
0000000000000080c0e0fee6c680000000000000000000f8fc1e1fffff1c1c1cffff1f1efcf800000000000303030303030000000303030303030000
0000000000f0fc0cf6fafefaf60cfcf000000000000000000003071c61dfbfdf611c0703000000000000000000000000000606060000000000000000
000000000000240840606c604008240000000000703898d8dc9e3b79797f7f79793b9edcd89838780000000101000000000000000000000101000000
00000000000000000000c0d0180c0c0e0e00000000103070787c76767f777f777f777c78703000000000010101010101010101010101010101010000
0080c0e0e0e0e0e0e0e0e0e0e0606060e0c00000003f1fc8e8c8183f3f2020203f3f19c9ebce1c380000000001000000000000000000000001000000
00c0c0e0e0e0e0e0e0e0e0e0e06060c080000000003f1fcfefcf1f3f3f3f3f3f3f1ccceccd1f3e3c0000000001000000000000000000000100000000
00e0000000c0000000c0000000c0000000c00000000f01010107010101ff010101070101010701000000000000000000000300000000000000000000
00e03090d050505254585854525050d09030e00000ff803f6040424e4240424c424040603f80ff000000010103030301010101010103030301010000
00e03030f03030f03030f03030f0303070f0e000003f0e66f7660e3f3e3e3f3e0e67f6640c3f3f3c0000000000000000000000000000000000000000
00000080c0c08000000080c8f8e0c080000000003049b5b54933071f1f1f1f1f073349b5b54932000000000000000000000000000000000000000000
0000c07018080c0486e2e286040c081870c00000000f30e08003677f3f1f1f3f7f670380e0300f000000000001010302060404060203010100000000
000080e030180884c6e2f2e2c684081830e0800000001f70c0860f1f3f7fff7f3f1f0f86c0701f000000000000010102060404040602010100000000
0000000000000082c2ca8a2aff2a0a0a02020000000000f8fcfe1f1fffff1f1f1ffcf800000000000000000707070707070704040407070000000000
000080c0e0e0f0f8fcfcfefcf8f8f0f0e0c0000000000143630707436307074363070743630100000000000000020300000203000002030000000000
000000000070787c1c1c1cfcf8f00000000000000000000000000000dcdedf0701000000000000000000000000000000010101000000000000000000
000080e0301888cce4e4e4e4cc881830e080000000000f3860c78f9f3f3f3f3f9f8fc760380f00000000000000000001010101010100000000000000
000000000080c0c0ac1e1eacc0c0800000000000000000c0e0f9c5030100000103c5f9e0c00000000000000001010000000000000000010100000000
0000000000000080dc3e3edc8000000000000000000000c0e0f8eec380808080c3eef8e0c00000000000000103030301000000000103030301000000
000080603028fc2c26fe262cfc2830608000000000000f3262a7faa222ff22a2faa762320f0000000000000000000101030303010100000000000000
000000000000c06070607f60c080000000000000000000000000ff5455fd5554ff0f0000000000000000000000000103030303030100000000000000
008000000000001090f0f0901000000000008000030703030303034347ffff4743030303030307030000000000000000000000000000000000000000
00000000000080c0e0c0fefe8000000000000000000000000000ffa8faaafaa8ffff0000000000000000000000000102030203020301000000000000
"""
BITMAPS = bytes.fromhex(_HEX)
if len(CODES) != 48 or len(BITMAPS) != len(CODES) * BYTES_PER_ICON:
raise ValueError("invalid APRS 20x20 symbol table")
+4 -5
View File
@@ -11,7 +11,7 @@ Status:
| Audio path an app can sample | **Done** by EPIRB 406: RX audio on PA4, ADC channel 4 at 9.6 kHz |
| Integer demodulator, modelled on synthetic audio (`test/model_rx.py`) | **Done** (see below) |
| Flipper Zero test transmitter (`test/flipper_aprs.py`) | **Done**, files in `test/flipper/` |
| Radio app (`aprsrx_app.c`) | **v0.1 receives the Flipper frames on the radio**; v0.3 decodes Mic-E (on air: F5RAV via F1PRY-14); v0.4 decodes continuously with 3 slicers (on air: F1PRY-14 via F5KTR-3); v0.5 shows standard positions, the frequency bottom right as Beacon, drops `pp` and the UP/DOWN/5 tuning offset to fit 4 KiB (3976 B); main-screen frequency, WAIT capsule (4068 B); v0.6: speaker off, key 1 toggles it, per-slicer counters dropped (not built yet) |
| Radio app (`aprsrx_app.c`) | **v0.1 receives the Flipper frames on the radio**; v0.3 decodes Mic-E (on air: F5RAV via F1PRY-14); v0.4 decodes continuously with 3 slicers (on air: F1PRY-14 via F5KTR-3); v0.5 shows standard positions; v0.6 adds the speaker key; v0.7 adds the corrected 20x20 APRS symbol bitmaps (not built here) |
| Bench test with the Flipper on 433.650 MHz | **Done** (2026-09-30, v0.1: `_long` 10/10 in STD; `_badfcs` not shown) |
| Real station: FT3D beacon on 144.800 MHz | **Done** (2026-09-30, v0.1: Mic-E frame `>TXUPX9` received, FCS good, = 48°50.89' N 2°16.25' E) |
| Mic-E decoding (`test/mice.py`: spec encoder vs the app's decoder) | **Done** in the model: 6 cases + the FT3D frame |
@@ -41,7 +41,7 @@ The screen is redrawn after a new frame, a key, or every 5 s.
|---|---|
| Status bar | `APRS RX` title, then a `WAIT` capsule until the first frame, the speaker icon while the speaker is on, then in the scroll view ▲ while rows are hidden above and ▼ while rows are hidden below (x = 72-76 as in APRS TX, the speaker icon at x = 59-68; both drawn in one asset read) |
| Lines 0-3 | The frame in the small font (18 characters a row, non-ASCII shown as `.`), 4 rows at a time, scrolled pixel by pixel with UP/DOWN (v0.7): source call and frame number in bold (`F4HWN-7 #3`), `>DEST,DIGI*,...` (as many path entries as fit two rows), then the info field, whole. For Mic-E (most Yaesu/Kenwood beacons, v0.3): latitude `48 50.89N`, longitude `002 16.25E`, speed km/h, course and symbol, the message type (`Off Duty`, `En Route`...), then altitude and comment without the device markers; for an uncompressed position (`!` `=` `/` `@`, v0.5): latitude, longitude, then the timestamp if any, the symbol (table + code) and the comment. Compressed positions stay raw text. A new frame scrolls back to the top. Key * switches to the compact view (below) |
| Lines 4-5 | Under a dotted separator (y=31), the frequency drawn as the main screen draws it (big digits up to the kHz, the last two in the small font), centred; same code as APRS TX |
| Lines 4-6 | Under a dotted separator (y=31), the frequency drawn as the main screen draws it (big digits up to the kHz, the last two in the small font), aligned left. A matching 20x20 bitmap from the 48-symbol Yaesu set is shown at x=108 on the right for decoded Mic-E and uncompressed positions; the area stays blank for an unknown or unavailable symbol |
| Bottom row y=49 | `ok 12 -89dBm`: frames received, RSSI at the end of the last frame. (v0.4-v0.5 also showed frames per slicer, `sl a/b/c`: on air the outer slicers found frames too; dropped in v0.6 to fit the speaker key) |
Keys (UV-K5 and UV-K1):
@@ -60,8 +60,8 @@ EPIRB 406); every on-air decode was made in STD and the model shows no gain
for RAW, so v0.4 dropped it to fit the 4 KiB overlay.
The last good frame is kept on `app_main`'s stack and the demodulator state on
`capture`'s: the 4 KiB overlay also holds `.bss`. The screen texts and the two
cos tables are assets.
`capture`'s: the 4 KiB overlay also holds `.bss`. The screen texts, symbol
bitmaps and the two cos tables are assets.
## Demodulator (`test/model_rx.py`, class `Demod`; the C is a transcription)
@@ -154,4 +154,3 @@ the standard position display against real frames;
0.065 LSB/Hz), **measured 734-778** with the Flipper and **916** with a real
station (F1PRY-14): ~20 % of full scale, plenty of headroom, and the per-tone
AGC makes the absolute level irrelevant.
+23 -7
View File
@@ -146,6 +146,7 @@ static struct {
uint8_t seq, prevKey, redraw, spk; /* spk: speaker amplifier on (key 1) */
bool running;
uint8_t top, lim, vrow; /* scroll (px) and its limit, row being drawn */
uint8_t symTable, symCode; /* symbol extracted from the displayed frame */
uint8_t cw; /* view: 0 scroll, CW_COMPACT compact (saved) */
int16_t rssi; /* RSSI at the end of the last frame */
uint16_t flen, nOk; /* frame length, frames */
@@ -206,10 +207,10 @@ static void tiny(uint8_t y,char *end){ *end='\0'; g.A->print_tiny(str,0,y,false,
/* 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, 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. As APRS TX, without its 1 GHz case (the
* main screen's small font there): 1296.000 in big digits still fits (105 px). */
* digits in the small font on line 5 right after them. The group is aligned
* left to reserve the rightmost 20 columns for the APRS symbol. Big digits are
* 13 px (drawn from +2), the point 3 px. No division. As APRS TX, without its
* 1 GHz case (the main screen's small font there): 1296.000 still fits. */
static void drawFreq(uint32_t f){
const app_api_t *A=g.A;
uint16_t m=0;
@@ -219,10 +220,21 @@ static void drawFreq(uint32_t f){
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" */
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" */
A->print_normal(str+n-2u,(uint8_t)(big*13u+3u),0,5); /* "00" */
str[n-2u]='\0';
A->display_freq(str,x,4,false); /* "144.800" */
A->display_freq(str,0,4,false); /* "144.800" */
}
#define SYM_X 108u
static void drawSymbol(void){
const app_api_t *A=g.A;
uint8_t hit[3];
uint8_t slot=(uint8_t)((2u*g.symTable+13u*g.symCode)&127u);
A->asset_read((uint16_t)(SYM_MAP+slot*3u),hit,sizeof hit);
if(!hit[0] || hit[1]!=g.symTable || hit[2]!=g.symCode) return;
uint16_t off=(uint16_t)(SYM_BITMAPS+(uint16_t)(hit[0]-1u)*SYM_BYTES);
for(uint8_t page=0;page<3u;page++)
A->asset_read((uint16_t)(off+page*SYM_W),A->fb[4u+page]+SYM_X,SYM_W);
}
/* ---- SysTick cycle counter (call at least every 10 ms; wraps every 89 s,
@@ -492,6 +504,7 @@ static void drawMicE(const uint8_t *f,uint16_t i,uint16_t end,const char *s){
o=puti(str,(int32_t)((sp*1897u)>>10)); o=put(o,s+T_KMH);
o=puti(o,(int32_t)crs); *o++=' ';
*o++=safe(f[i+7]); *o++=safe(f[i+8]);
g.symTable=f[i+8]; g.symCode=f[i+7];
o=brk(o);
uint8_t idx=(uint8_t)(micBit(c[0])*4u+micBit(c[1])*2u+micBit(c[2]));
bool custom=false;
@@ -544,6 +557,7 @@ static bool drawPos(const uint8_t *f,uint16_t i,uint16_t end){
*o++=' ';
}
*o++=safe(q[8]); *o++=safe(q[18]); *o++=' ';
g.symTable=q[8]; g.symCode=q[18];
textRows(o,f,(uint16_t)(p+19u),end);
return true;
}
@@ -561,6 +575,7 @@ static void draw(void){
A->display_clear();
A->status_clear();
g.vrow=0;
g.symTable=g.symCode=0;
A->print_inverse(s+T_TITLE,2,0,true,true,(uint8_t)(2u+T_TITLE_CHARS*4u));
A->draw_battery();
@@ -613,6 +628,7 @@ static void draw(void){
* blank px between) */
for(uint8_t x=0;x<128u;x++) b[384u+x]=(uint8_t)((b[384u+x]&0x7Fu)|((~x&1u)<<7));
drawFreq(g.vfoFreq);
drawSymbol();
/* bottom line: "ok 12 -89dBm": frames, RSSI of the last one */
o=put(str,s+T_OK); o=puti(o,g.nOk); *o++=' '; *o++=' ';
+1 -1
View File
@@ -8,7 +8,7 @@ set -euo pipefail
APP="$(basename "$PWD")" # breakout, foxhunt, beacon, fm, ...
APP_NAME="APRS RX" # <-- the only per-app line
APP_VER="0.6"
APP_VER="0.7"
APP_API_MIN=2
APP_VMA=${APP_VMA:-0x20000280} # pinned overlay VMA (Core/py32f071xb.ld)
OUT="${APP_NAME// /}" # blob basename ("Broadcast FM" -> BroadcastFM)
+12
View File
@@ -15,6 +15,7 @@ sys.dont_write_bytecode = True
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, ".."))
from app_assets import Assets
from aprs_symbols_20 import BITMAPS, BYTES_PER_ICON, CODES, WIDTH
FS = 9600
TITLE = "APRS RX" # the version is in the .app header
@@ -59,6 +60,17 @@ for i in range(8):
tail += [(u if i & 2 else 0) | (d if i & 4 else 0) for u, d in zip(UP, DN)]
a.u8("BMP_TAIL", tail)
a.const("TAIL_W", len(tail) // 8)
symbol_map = bytearray(128 * 3)
for index, code in enumerate(CODES):
slot = (2 * ord(code[0]) + 13 * ord(code[1])) & 127
if symbol_map[slot * 3]:
raise ValueError("APRS symbol hash collision")
symbol_map[slot * 3:slot * 3 + 3] = bytes((index + 1, ord(code[0]), ord(code[1])))
a.raw("SYM_MAP", symbol_map)
a.raw("SYM_BITMAPS", BITMAPS)
a.const("SYM_COUNT", len(CODES))
a.const("SYM_W", WIDTH)
a.const("SYM_BYTES", BYTES_PER_ICON)
a.i8("COS1200", cos_table(1200)) # 8 entries
a.i8("COS2200", cos_table(2200)) # 48 entries
a.main()
@@ -0,0 +1,248 @@
{
"format": "aprs-symbols-20",
"version": 1,
"width": 20,
"height": 20,
"symbols": [
{
"code": "/#",
"name": "DIGI",
"pixels": "0000000000000000000000000000000000000000000000000100000000000000000001000000000000000000111000000000000000001110000000000000000111110000000000000001111100000000011111111111111111000011111111111111100000011111111111110000000001111111110000000000001111111000000000000011111110000000000001111111110000000000111110111110000000001111000111100000000111000000011100000001100000000011000000000000000000000000"
},
{
"code": "/X",
"name": "HELICOPTER",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000001111111111111111100011100000100000111010000000011100000000110000001111100000001110000111111110000001111111111010010000111001111110110110001100001111111111100000000001111111111000000000001111111100000000000001000010000000000000111111101000000000000111111100000000000000000000000000000000000000000000"
},
{
"code": "/&",
"name": "HF GATEWAY",
"pixels": "0000000000000000000000000000000000000000000011111111111110000000100100100100100000001001001001001000000000010010010000000000000101110100000000000000011100000000000000000111000000000000000001110000000000000000111110000000000000001111100000000000000110001100000000000001100011000000000000011111110000000000001111111110000000000011100011100000000000110000011000000000011111111111000000000000000000000000"
},
{
"code": "/Y",
"name": "SAILBOAT",
"pixels": "0000000000000000000000000000001000000000000000000011000000000000000000110000000000000000001110000000000000001011100000000000000010111100000000000001101111000000000000011011111000000000001110111110000000000011101111110000000001111011111100000000011110111111100000001111101000000011000000000011111111100011111111111111110000111111111111111000000000000000000000000011111111111111100000000000000000000000"
},
{
"code": "/'",
"name": "SMALL ACFT",
"pixels": "0000000000000000000000000000000000000000000001100000000000000000001100000000000000000001100000000000000000011100000000000000000011100000000011000000111100000000011000000111100000000111111111111111111001111111111111111111011111111111111111100110000001111000000011000000111100000000000000001110000000000000000111000000000000000001100000000000000000110000000000000000011000000000000000000000000000000000"
},
{
"code": "/[",
"name": "PERSON",
"pixels": "0000000000000000000000000000000000000000000000000111000000000000000011111000000000000000111110000000000000000111000000000000000000000000000000000000111110000000000000011111110000000000001111111110000000000110011100110000000001100111001100000000011001110011000000000000111110000000000000001101100000000000000011011000000000000000110110000000000000011101110000000000000111011100000000000000000000000000"
},
{
"code": "/-",
"name": "VHF HOUSE",
"pixels": "0000000000000000000000000000011111110000000000000000100000000000000000111110000000000000000010000000000000000001110000000000000011001000000000000001111010000000000000111111100000000000011111111000000000001111111111000000000111111111111000000001111111111110000000011100110001100000000111001100011000000001110011000110000000011111110001100000000111111100011000000001111111111110000000000000000000000000"
},
{
"code": "/\\",
"name": "DIRECTION FIND",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000011111111111111000000110000000000110000000100000000001000000001100000000110000000001000000001000000000011000000110000000000010000001000000000000110000110000000000000100001000000000000001100110000000000000001001000000000000000011110000000000000000011000000000000000000110000000000000000000000000000000000000000000000000"
},
{
"code": "/.",
"name": "X",
"pixels": "0000000000000000000000000000000000000000000000000000000000000001100000000001100000011100000000111000000111100000011110000001111100001111100000001111100111110000000001111111111000000000001111111100000000000001111110000000000000111111110000000000011111111110000000001111100111110000000111110000111110000001111000000111100000011100000000111000000110000000000110000000000000000000000000000000000000000000"
},
{
"code": "/^",
"name": "LARGE ACFT",
"pixels": "0000000000000000000000000000000000000000000000000110000000000000000001100000000000000000111100000000000000001001000000000000000111111000000000000111111111100000000111111111111110000111111111111111111011111110111101111111111000001111000001110000000011110000000000000000111100000000000000001111000000000000000111111000000000000111111111100000000001110110111000000000010001100010000000000000000000000000"
},
{
"code": "/0",
"name": "CIRCLE",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000111111000000000000111000011100000000011000000001100000001100000000001100000011000000000011000001100000000000011000011000000000000110000110000000000001100001100000000000011000011000000000000110000011000000000011000000110000000000110000000110000000011000000000111000011100000000000011111100000000000000000000000000000000000000000000000"
},
{
"code": "/_",
"name": "WEATHER STN",
"pixels": "0000000000000000000000000000000000000000001000000011000000000011000001110000000000111111110000000000001100000111000000000010001100110000000000000011000000000000000000110000000000000000001100000000000000000111100001110000000011111100011100000000111111000111000000000011000001110000000000110000011100000000001100000010000000000011000000100000000000111111111000000000001100000000000000000000000000000000"
},
{
"code": "/:",
"name": "FIRE",
"pixels": "0000000000000000000000000000001000000000000000000110000000000000000001100000000000000000111000000000000000001111001000000000000111110010000000000101111111100000000001111111111000000000111110111100100000001111100111011000000111111001111110000001111100011111100000011110000001111000000011100000011100000000111000001111000000000110000011100000000000110001110000000000000000000000000000000000000000000000"
},
{
"code": "/a",
"name": "AMBULANCE",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000000000110000000000000000011100000000000000000111000000011111111111111100001111110011111001100011111100111110001000111100000011100001001111000000111111111011111100111111111110111111001111111111101100011111111000111010010011111100100110001110000000011100000011100000000111000000010000000000100000000000000000000000000000000000000000000"
},
{
"code": "/;",
"name": "CAMPGROUND",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000100000000000000000011100000000000000000111000000000000000011111000000000000001111111000000000000011111110000000000001111011110000000000111100011100000000001111000111100000000111100000111100000001111000001111000001111111111111111110000000000000000000000000000000000000000000000000000000000000"
},
{
"code": "/b",
"name": "BICYCLE",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000011100000000000000000000100000000000111000001000000000000100000111000000000001111111010000000000101000100100000011110010010011100001000100011001000100100100100100100100101011101110001011101010010010000010010010010001000000010001000011100000000011100000000000000000000000000000000000000000000000000000000000000"
},
{
"code": "/<",
"name": "MOTORCYCLE",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000000011000000000000000000011000000000000000000010000000000000000000110000000000000000111100000011111000011111111001000011000111100001000110011111110011000010010011111001001001011010111110101101010110100111001011010010010000000001001000011000000000001100000000000000000000000000000000000000000000000000000000000000000000000000000000000"
},
{
"code": "/f",
"name": "FIRE TRUCK",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000000000111100000000000001111000000000000011110000000000000111100000000001000111000000000111111001111111111001000111111111111010010000111111111110111100001111111111101111111111111111111011111111111000100011111100011100100010011110010010011101110000001110000111011100000011100000100010000000010000000000000000000000000000000000000000000"
},
{
"code": "/>",
"name": "CAR",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001111111000000000001111111111000000000111000100110000000001100001000110000001111111111111111110111111111111111111111110000111111000011111001100111100110011110111100000011110110000110000000011000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
},
{
"code": "/g",
"name": "GLIDER",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000111000000000000000000111000001100000000000111001111100000000000111111100010000000001111100000110000011111100000000110011111100000000000111111100000000000000111100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
},
{
"code": "/C",
"name": "CANOE",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000000000001100000000000000000111100000000000000001110000000000000000110000000000000000011000000011000000001100001101111000000110000111111111111011011111111111111101101111111110111110110111111111000000011000000000000000001100000000000000000110000000000000000111000000000000000011110000000000000000011000000000000000000000000000000000000"
},
{
"code": "/j",
"name": "JEEP",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000111111110000000000001001000100000000000010010000100000000001100100001101111111111001111111111111111111111111111111000111111110001111100100111111001001111011100000000111001000111000000001110000000100000000001000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
},
{
"code": "/E",
"name": "EYE",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000111111000000000000111000011100000000011000000001100000001100001100001100000110000111100001100011000011111100001100110000111111000011001100001111110000110001100001111000011000001100001100001100000001100000000110000000001110000111000000000000111111000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
},
{
"code": "/k",
"name": "TRUCK",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000111111111111110000001111111111111100000011111111111111011100111111111111111100101111111111111111001011111111111111010011111111111111110111111100011100011110001110010010010011001001001110001110000111000011100011100001110000010000010000001000000000000000000000000000000000000000000000000000000000000000"
},
{
"code": "/I",
"name": "TCP/IP",
"pixels": "0000000000000000000000000000000000000000000000011111100000000000000100001000000000000001000010000000000000010000100000000000000111111000000000000000011000000000000000000110000000000000000001100000000000001111111111110000000010000000000100000000100000000001000000111110000001111100001000100000010001000010001000000100010000111110000001111100000000000000000000000000000000000000000000000000000000000000"
},
{
"code": "/m",
"name": "MIC-E REPEATER",
"pixels": "0000100000000001000000010000000000001000000100100000010010000010011000000110010000100100111100100100001001001111001001000010010011110010010000100110011001100100000100100110010010000001000001100000100000001000011000010000000000001111000000000000000010010000000000000001100110000000000000010110100000000000000111111000000000000011000011000000000001111111111000000000000000000000000000000000000000000000"
},
{
"code": "/K",
"name": "SCHOOL",
"pixels": "0000000000000000000000000000001110000000000000000011100000000000000000100000000000000000001000000000000000000111000000000000000011111000000000000001111111000000000000111000111000000000011110001111000000001111111111111000000111111111111111000001111111111111110000011001100011001100000110011000110011000001100110001100110000011111100011111100000111111000111111000000000000000000000000000000000000000000"
},
{
"code": "/O",
"name": "BALLOON",
"pixels": "0000000000000000000000000000111110000000000000111010111000000000001101110110000000000110111110110000000001101111101100000000011011111011000000000110111110110000000001101111101100000000011001110011000000000011011101100000000000010111010000000000000101110100000000000000101010000000000000001101100000000000000001110000000000000000000000000000000000000111000000000000000001110000000000000000000000000000"
},
{
"code": "/P",
"name": "POLICE",
"pixels": "0000000000000000000000000000000000000000000000100010001000000000000100100100000000000000000000000000000000100111001000000000000011111000000000000000000000000000000000111111110000000000011001100110000000001100011000110000011111111111111111111111111111111111111111000011111111000011100110011111100110010011110000000011110000011000000000011000000000000000000000000000000000000000000000000000000000000000"
},
{
"code": "/s",
"name": "POWERED SHIP",
"pixels": "0000000000000000000000000000000000011000000000000000011110000000000000001111100000000000000110000000000000000000000000000000000000110000000000000000001100000000000000001111110000000000001111111100000000000111111111100000000011001010101100000111111111111111110000111111111111111100000111111111111110000000000000000000000000111111111111111100000000000000000000000000000000000000000000000000000000000000"
},
{
"code": "/R",
"name": "REC VEHICLE",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000111111111111110000011111111111111110001111111111110001100011000011000111110000110000110001100110001100001100011000110011111111000111111110110001110001110001101001001111111001001000111000000000111000001110000000001110000001000000000001000000000000000000000000000000000000000000000000000000000000000"
},
{
"code": "/v",
"name": "VAN",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000111111111111000000111111111111111000001111111111110011000011111111111100011000111111111111000011001111111111111111111011111111111111111110110001111111100011101001001111110010011000111000000001110000001110000000011100000001000000000010000000000000000000000000000000000000000000000000000000000000000"
},
{
"code": "/y",
"name": "YAGI",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000100010001000100010001000100010001000100011111111111111111100100010001000100010001000100010001000100010000000100000000000000000001000000000000000000010000000000000000000100000000000000000001000000000000000000010000000000000000000100000000000000000000000000000000000000000000000000"
},
{
"code": "/T",
"name": "SSTV",
"pixels": "0000000000000000000000000001000010000000000000001001000000000000000001100000000000111111111111111100011000000000000001100100111111111111001001011000000000011010010100000000000010100101001110101000101001010001000100001010010100010001000010100101000000000000101001011000000000011010010011111111111100100110000000000000011000111111111111111100000011100000011100000000000000000000000000000000000000000000"
},
{
"code": "/U",
"name": "BUS",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000111111111111111100011111111111111111100100100100100100111001001001001001000110010010010010010001100111111111111110011001111111111111111111011000111111100011110100100111110010011101011101111101110111000111000000011100000000100000000010000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
},
{
"code": "/V",
"name": "ATV",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000000011000000000000000000010000000000000000000110000000000110000011110000000011110001111110000011111111111111111000000011111111100001000110011111110011000010010011111001001001011010111110101101010110100000001011010010010000000001001000011000000000001100000000000000000000000000000000000000000000000000000000000000000000000000000000000"
},
{
"code": "\\#",
"name": "OVERLAY DIGI",
"pixels": "0000000000000000000000000000111100000000000000111001110000000000111000000111000000011000000000011000000100000110000010000011000001100000110000100000111100000100010001111111111000100100011111111110001001000011111111000010010000011111100000100010000111111000010000110011100111001100000100110000110010000001100000000001100000001110000001110000000000111001110000000000000011110000000000000000000000000000"
},
{
"code": "\\&",
"name": "OVERLAY GATE",
"pixels": "0000000000000000000000000000111110000000000000011000110000000000011000000011000000001100001000011000000110000111000011000001000011111000010000110001111111000110001000111111111000100010011111111111001000100111111111110010001000111111111000100011000111111100011000010000111110000100000110000111000011000000110000100001100000000110000000110000000000011000110000000000000011111000000000000000000000000000"
},
{
"code": "\\-",
"name": "HF HOUSE",
"pixels": "0000000000001000000000000001111111111100000000000000100000000000000001111111000000000000000010000000000000000001110000000000000011001000000000000001111010000000000000111111100000000000011111111000000000001111111111000000000111111111111000000001111111111110000000011100110001100000000111001100011000000001110011000110000000011111110001100000000111111100011000000001111111111110000000000000000000000000"
},
{
"code": "/W",
"name": "NWS SITE",
"pixels": "0000000000000000000000000000001000000000000000001111000000000000000111111100000000000011111111110000000011111111111110000001111111111111110000111111111111111100001111111111111111000001111111111111100000000110011001100000000000000000000000000000000000000000000000001000100010001000000110011001100110000000000000000000000000000010001000100000000001100110011000000000000000000000000000000000000000000000"
},
{
"code": "\\.",
"name": "AMBIGUOUS",
"pixels": "0000000000000000000000000000000000000000000000011111000000000000001111111000000000000111111111000000000001110001110000000000011100011100000000000000000111000000000000000011100000000000000001110000000000000000111100000000000000001110000000000000000011100000000000000000000000000000000000001110000000000000000011100000000000000000111000000000000000000000000000000000000000000000000000000000000000000000"
},
{
"code": "\\0",
"name": "OVERLAY CIRCLE",
"pixels": "0000000000000000000000000000000000000000000000011111100000000000011100001110000000001100000000110000000110001111000110000001000111111000100000110011111111001100001001111111111001000010011111111110010000100111111111100100001100111111110011000001000111111000100000011000111100011000000011000000001100000000011100001110000000000001111110000000000000000000000000000000000000000000000000000000000000000000"
},
{
"code": "E0",
"name": "ECHOLINK",
"pixels": "0000000000000000000000000000011000000000000000001111000000000000000011110000000000000000011000000000000000001001000000000000001100001100000000000111100111100000000001111001111000000000000100001000000000000010000001000000000001000000001000000000010000000010000000001100000000110000000111100000011110000001111000000111100000001100000000110000000000000000000000000000000000000000000000000000000000000000"
},
{
"code": "I0",
"name": "IRLP",
"pixels": "0000000000000000000000000000011000000000000000001111000000000000000011110000000000000000111100000000000000000110000000000000000010010000000000000001100110000000000000010000100000000000001100001100000000000010000001000000000001100000011000000000010000000010000000001110000001110000000111110000111110000001111111111111100000011111000011111000000011100000011100000000000000000000000000000000000000000000"
},
{
"code": "W0",
"name": "WIRES",
"pixels": "0000000000000000000000000000111000000000000000111111100000000000011101011100000000001010010010100000000111111111111100000001001001001001000000100010010010001000001001000100010010000011111111111111100000100100010001001000001000100100100010000001001001001001000000011111111111110000000010100100101000000000011101011100000000000011111110000000000000001110000000000000000000000000000000000000000000000000"
},
{
"code": "YY",
"name": "YAESU RADIO",
"pixels": "0000000000100000000000000000001000000000000000000010000000000000000000100000000000000000101000000000000000011111000000000000001111111000000000000010000011000000000000101110110000000000001000001100000000000011111111000000000000100100110000000000001111111000000000000010010010000000000000111111100000000000001001001000000000000011111110000000000000011111000000000000000000000000000000000000000000000000"
},
{
"code": "\\^",
"name": "ALT AIRCRAFT",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000001111110000000000000000110000000000000000001100000000001000000111100000010111111111111111111111111111111111111111101000000111100000010000000000110000000000000000001100000000000000000011000000000000000011111100000000000000001100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
},
{
"code": "\\Y",
"name": "RADIOS/DEVICES",
"pixels": "0000000000000000000000000000001100000000000000000011000000000000000000110000000000000000001100000000000000001011000000000000000111110000000000000011111110000000000000100000110000000000001011101100000000000010000011000000000000111111110000000000001010101100000000000011111111000000000000101010110000000000001111111100000000000010101011000000000000011111100000000000000000000000000000000000000000000000"
}
]
}
@@ -0,0 +1,431 @@
<div id="aprs-pixel-workbench" class="aprs-workbench viz-dotted-background">
<style>
#aprs-pixel-workbench {
--p-bg: light-dark(#f7fbfd, #101820);
--p-panel: light-dark(#ffffff, #17232d);
--p-ink: light-dark(#102536, #eef8ff);
--p-muted: light-dark(#577184, #a8bdca);
--p-line: light-dark(#b8d8e8, #355163);
--p-grid: light-dark(#d8eef7, #29414f);
--p-pixel: light-dark(#06141f, #f6fbff);
--p-cyan: light-dark(#007b9d, #43dff8);
color-scheme: light dark;
color: var(--p-ink);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
padding: 16px;
}
#aprs-pixel-workbench * { box-sizing: border-box; }
#aprs-pixel-workbench .shell {
max-width: 980px;
margin: 0 auto;
background: var(--p-bg);
border: 1px solid var(--p-line);
border-radius: 14px;
overflow: hidden;
}
#aprs-pixel-workbench header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
background: light-dark(#07385f, #082b48);
color: #eefcff;
}
#aprs-pixel-workbench h1,
#aprs-pixel-workbench h2 { margin: 0; font-weight: 500; }
#aprs-pixel-workbench header span { color: #43dff8; }
#aprs-pixel-workbench .stage {
display: grid;
grid-template-columns: minmax(230px, 1fr) minmax(260px, 1fr) 96px;
gap: 16px;
align-items: start;
padding: 16px;
border-bottom: 1px solid var(--p-line);
}
#aprs-pixel-workbench .pane-title {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
color: var(--p-muted);
}
#aprs-pixel-workbench .source-window {
width: 100%;
aspect-ratio: 156 / 136;
background-image: url("./aprs_symbols_20_mockup.png");
background-repeat: no-repeat;
background-size: 897.4359% 823.5294%;
border: 1px solid var(--p-line);
background-color: #fff;
}
#aprs-pixel-workbench .matrix {
display: grid;
grid-template-columns: repeat(20, 1fr);
aspect-ratio: 1;
width: min(100%, 320px);
background: var(--p-panel);
border: 1px solid var(--p-line);
}
#aprs-pixel-workbench .matrix button {
min-width: 0;
min-height: 0;
margin: 0;
padding: 0;
border: 0;
border-right: 1px solid var(--p-grid);
border-bottom: 1px solid var(--p-grid);
border-radius: 0;
background: transparent;
}
#aprs-pixel-workbench .matrix button.on { background: var(--p-pixel); }
#aprs-pixel-workbench .native-wrap {
display: grid;
justify-items: center;
gap: 10px;
}
#aprs-pixel-workbench .native {
display: grid;
grid-template-columns: repeat(20, 1px);
grid-template-rows: repeat(20, 1px);
width: 20px;
height: 20px;
background: light-dark(#fff, #eaf3f8);
outline: 1px solid var(--p-line);
}
#aprs-pixel-workbench .native i,
#aprs-pixel-workbench .mini i { background: transparent; }
#aprs-pixel-workbench .native i.on,
#aprs-pixel-workbench .mini i.on { background: #07141d; }
#aprs-pixel-workbench .selected-code {
font-size: 22px;
color: var(--p-cyan);
text-align: center;
}
#aprs-pixel-workbench .tools {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 16px 16px;
border-bottom: 1px solid var(--p-line);
}
#aprs-pixel-workbench .tools button,
#aprs-pixel-workbench .tools label,
#aprs-pixel-workbench .symbol {
color: var(--p-ink);
background: var(--p-panel);
border: 1px solid var(--p-line);
font: inherit;
}
#aprs-pixel-workbench .tools button,
#aprs-pixel-workbench .tools label {
padding: 7px 10px;
border-radius: 7px;
}
#aprs-pixel-workbench .tools input { display: none; }
#aprs-pixel-workbench .save-status {
align-self: center;
color: var(--p-muted);
margin-left: auto;
}
#aprs-pixel-workbench .symbols {
display: grid;
grid-template-columns: repeat(8, minmax(72px, 1fr));
gap: 8px;
padding: 16px;
}
#aprs-pixel-workbench .symbol {
min-width: 0;
padding: 7px 4px 6px;
border-radius: 8px;
display: grid;
justify-items: center;
gap: 5px;
}
#aprs-pixel-workbench .symbol[aria-pressed="true"] {
border-color: var(--p-cyan);
box-shadow: inset 0 0 0 1px var(--p-cyan);
}
#aprs-pixel-workbench .mini {
display: grid;
grid-template-columns: repeat(20, 2px);
grid-template-rows: repeat(20, 2px);
width: 40px;
height: 40px;
background: #f7fbfd;
}
#aprs-pixel-workbench .symbol small {
overflow: hidden;
max-width: 100%;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--p-muted);
}
#aprs-pixel-workbench details { margin: 0 16px 16px; }
#aprs-pixel-workbench summary { color: var(--p-cyan); }
#aprs-pixel-workbench details img {
display: block;
width: 100%;
height: auto;
margin-top: 10px;
border: 1px solid var(--p-line);
}
@media (max-width: 760px) {
#aprs-pixel-workbench .stage { grid-template-columns: 1fr 1fr; }
#aprs-pixel-workbench .native-wrap { grid-column: 1 / -1; }
#aprs-pixel-workbench .symbols { grid-template-columns: repeat(4, minmax(64px, 1fr)); }
}
@media (max-width: 480px) {
#aprs-pixel-workbench .stage { grid-template-columns: 1fr; }
#aprs-pixel-workbench .symbols { grid-template-columns: repeat(3, minmax(64px, 1fr)); }
}
</style>
<section class="shell" aria-label="Atelier de validation APRS 20 par 20">
<header>
<h1>APRS 20×20</h1>
<span>48 pictogrammes · validation pixel par pixel</span>
</header>
<div class="stage">
<section>
<div class="pane-title"><span>Planche source</span><strong id="source-name"></strong></div>
<div id="source-window" class="source-window" role="img"></div>
</section>
<section>
<div class="pane-title"><span>Matrice CSS éditable</span><strong>20 × 20</strong></div>
<div id="matrix" class="matrix" aria-label="Matrice de pixels éditable"></div>
</section>
<section class="native-wrap">
<div class="pane-title">Taille écran</div>
<div id="native" class="native" role="img" aria-label="Aperçu à taille réelle"></div>
<div id="selected-code" class="selected-code"></div>
</section>
</div>
<div class="tools">
<button id="undo-one" type="button">Rétablir ce picto</button>
<button id="undo-all" type="button">Rétablir les 48</button>
<button id="invert" type="button">Inverser les pixels</button>
<button id="export-json" type="button">Exporter JSON</button>
<label for="import-json">Importer JSON</label>
<input id="import-json" type="file" accept="application/json,.json">
<span id="save-status" class="save-status" aria-live="polite">Sauvegarde automatique active</span>
</div>
<div id="symbols" class="symbols" aria-label="Sélection des 48 pictogrammes"></div>
<details>
<summary>Afficher la planche complète</summary>
<img src="./aprs_symbols_20_mockup.png" alt="Planche source des 48 pictogrammes APRS">
</details>
</section>
<script>
(() => {
const root = document.getElementById('aprs-pixel-workbench');
const codes = ['/\x23','/X','/&','/Y',"/'",'/[','/-','/\\','/.','/^','/0','/_','/:','/a','/;','/b','/<','/f','/>','/g','/C','/j','/E','/k','/I','/m','/K','/O','/P','/s','/R','/v','/y','/T','/U','/V','\\\x23','\\&','\\-','/W','\\.','\\0','E0','I0','W0','YY','\\^','\\Y'];
const names = ['DIGI','HELICOPTER','HF GATEWAY','SAILBOAT','SMALL ACFT','PERSON','VHF HOUSE','DIRECTION FIND','X','LARGE ACFT','CIRCLE','WEATHER STN','FIRE','AMBULANCE','CAMPGROUND','BICYCLE','MOTORCYCLE','FIRE TRUCK','CAR','GLIDER','CANOE','JEEP','EYE','TRUCK','TCP/IP','MIC-E REPEATER','SCHOOL','BALLOON','POLICE','POWERED SHIP','REC VEHICLE','VAN','YAGI','SSTV','BUS','ATV','OVERLAY DIGI','OVERLAY GATE','HF HOUSE','NWS SITE','AMBIGUOUS','OVERLAY CIRCLE','ECHOLINK','IRLP','WIRES','YAESU RADIO','ALT AIRCRAFT','RADIOS/DEVICES'];
const sourceX = [17,189,362,535,708,881,1054,1227];
const sourceY = [87,256,427,598,768,939];
const encoded = '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';
const raw = Uint8Array.from(atob(encoded), c => c.charCodeAt(0));
const original = new Uint8Array(48 * 400);
for (let n = 0; n < 48; n++) {
for (let y = 0; y < 20; y++) {
for (let x = 0; x < 20; x++) {
original[n * 400 + y * 20 + x] = (raw[n * 60 + Math.floor(y / 8) * 20 + x] >> (y & 7)) & 1;
}
}
}
function packPixels(values) {
const bytes = new Uint8Array(values.length / 8);
for (let i = 0; i < values.length; i++) if (values[i]) bytes[i >> 3] |= 1 << (i & 7);
return btoa(String.fromCharCode(...bytes));
}
function unpackPixels(encoded) {
const bytes = Uint8Array.from(atob(encoded), c => c.charCodeAt(0));
if (bytes.length !== 2400) throw new Error('Sauvegarde locale invalide');
const values = new Uint8Array(48 * 400);
for (let i = 0; i < values.length; i++) values[i] = (bytes[i >> 3] >> (i & 7)) & 1;
return values;
}
const storageKey = 'aprs-symbols-20-editor-v1';
let localSaved = null;
try { localSaved = JSON.parse(localStorage.getItem(storageKey)); } catch (_) {}
const saved = window.openai?.widgetState?.privateContent || localSaved;
let pixels = new Uint8Array(original);
let selected = 5;
if (saved) {
try {
if (typeof saved.packed === 'string') pixels = unpackPixels(saved.packed);
else if (saved.pixels && saved.pixels.length === 19200)
pixels = Uint8Array.from(saved.pixels, c => c === '1' ? 1 : 0);
selected = Number.isInteger(saved.selected) ? Math.max(0, Math.min(47, saved.selected)) : selected;
} catch (_) {}
}
const matrix = root.querySelector('#matrix');
const native = root.querySelector('#native');
const symbols = root.querySelector('#symbols');
const source = root.querySelector('#source-window');
const sourceName = root.querySelector('#source-name');
const selectedCode = root.querySelector('#selected-code');
const saveStatus = root.querySelector('#save-status');
function cells(container, index, editable) {
container.replaceChildren();
for (let p = 0; p < 400; p++) {
const cell = document.createElement(editable ? 'button' : 'i');
if (editable) {
cell.type = 'button';
cell.setAttribute('aria-label', `Pixel ${p % 20 + 1}, ${Math.floor(p / 20) + 1}`);
cell.addEventListener('click', () => {
pixels[index * 400 + p] ^= 1;
renderSelected();
renderMini(index);
remember();
});
}
cell.classList.toggle('on', !!pixels[index * 400 + p]);
container.appendChild(cell);
}
}
function renderMini(index) {
const mini = symbols.children[index]?.querySelector('.mini');
if (mini) cells(mini, index, false);
}
function renderSelected() {
cells(matrix, selected, true);
cells(native, selected, false);
selectedCode.textContent = codes[selected];
sourceName.textContent = names[selected];
const col = selected % 8;
const row = Math.floor(selected / 8);
const scale = source.clientWidth / 156;
source.style.backgroundSize = `${1400 * scale}px ${1120 * scale}px`;
source.style.backgroundPosition = `${-sourceX[col] * scale}px ${-sourceY[row] * scale}px`;
source.setAttribute('aria-label', `${codes[selected]} ${names[selected]}, planche source`);
[...symbols.children].forEach((button, i) => button.setAttribute('aria-pressed', String(i === selected)));
}
function remember() {
const packed = packPixels(pixels);
try {
localStorage.setItem(storageKey, JSON.stringify({ selected, packed }));
saveStatus.textContent = 'Sauvegardé automatiquement';
} catch (_) {
saveStatus.textContent = 'Sauvegarde locale indisponible — exportez le JSON';
}
if (window.openai?.setWidgetState) {
window.openai.setWidgetState({
modelContent: { selectedCode: codes[selected], edited: !pixels.every((v, i) => v === original[i]) },
privateContent: { selected, packed }
}).catch(() => {});
}
}
function exportDocument() {
return {
format: 'aprs-symbols-20',
version: 1,
width: 20,
height: 20,
symbols: codes.map((code, index) => ({
code,
name: names[index],
pixels: Array.from(pixels.subarray(index * 400, index * 400 + 400), p => p ? '1' : '0').join('')
}))
};
}
function pixelsFromDocument(document) {
if (!document || document.format !== 'aprs-symbols-20' || document.version !== 1 ||
document.width !== 20 || document.height !== 20 || !Array.isArray(document.symbols) ||
document.symbols.length !== 48) throw new Error('Format APRS 20x20 invalide');
const next = new Uint8Array(48 * 400);
document.symbols.forEach((symbol, index) => {
if (!symbol || symbol.code !== codes[index] || typeof symbol.pixels !== 'string' ||
!/^[01]{400}$/.test(symbol.pixels)) throw new Error(`Symbole ${codes[index]} invalide`);
for (let p = 0; p < 400; p++) next[index * 400 + p] = symbol.pixels[p] === '1' ? 1 : 0;
});
return next;
}
codes.forEach((code, index) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'symbol';
button.setAttribute('aria-label', `${code} ${names[index]}`);
button.innerHTML = `<span class="mini" aria-hidden="true"></span><strong>${code}</strong><small>${names[index]}</small>`;
button.addEventListener('click', () => { selected = index; renderSelected(); remember(); });
symbols.appendChild(button);
renderMini(index);
});
root.querySelector('#undo-one').addEventListener('click', () => {
pixels.set(original.subarray(selected * 400, selected * 400 + 400), selected * 400);
renderSelected(); renderMini(selected); remember();
});
root.querySelector('#undo-all').addEventListener('click', () => {
pixels = new Uint8Array(original);
for (let i = 0; i < 48; i++) renderMini(i);
renderSelected(); remember();
});
root.querySelector('#invert').addEventListener('click', () => {
for (let p = 0; p < 400; p++) pixels[selected * 400 + p] ^= 1;
renderSelected(); renderMini(selected); remember();
});
root.querySelector('#export-json').addEventListener('click', () => {
const blob = new Blob([JSON.stringify(exportDocument(), null, 2) + '\n'], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'aprs_symbols_20_edited.json';
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
saveStatus.textContent = 'JSON exporté';
});
root.querySelector('#import-json').addEventListener('change', async event => {
const file = event.target.files?.[0];
if (!file) return;
try {
pixels = pixelsFromDocument(JSON.parse(await file.text()));
for (let i = 0; i < 48; i++) renderMini(i);
renderSelected();
remember();
saveStatus.textContent = 'JSON importé et sauvegardé';
} catch (error) {
saveStatus.textContent = error instanceof Error ? error.message : 'Import impossible';
}
event.target.value = '';
});
window.addEventListener('openai:set_globals', event => {
const next = event.detail?.globals?.widgetState?.privateContent;
if (!next) return;
try {
if (typeof next.packed === 'string') pixels = unpackPixels(next.packed);
else if (next.pixels && next.pixels.length === 19200)
pixels = Uint8Array.from(next.pixels, c => c === '1' ? 1 : 0);
else return;
selected = Math.max(0, Math.min(47, next.selected || 0));
for (let i = 0; i < 48; i++) renderMini(i);
renderSelected();
} catch (_) {}
});
new ResizeObserver(renderSelected).observe(source);
renderSelected();
})();
</script>
</div>
+22 -14
View File
@@ -7,7 +7,7 @@ which is also the test receiver.
| Step | State |
|---|---|
| Frame and bit stream model (`test/tx_model.py`) | **Done**: identical to the RX app's AX.25 reference, decoded by the RX model |
| Radio app (`aprstx_app.c`) | **v0.1 works on the radio** (2026-09-30); v0.2 edits the position on the radio, frequency bottom right as Beacon (builds: 2812 B; radio test pending); v0.3 adds the SSID and path to the editor, the edited field in bold (not built yet) |
| Radio app (`aprstx_app.c`) | **v0.1 works on the radio** (2026-09-30); v0.2 edits the position; v0.3 adds the SSID and path; v0.4 adds the corrected 20x20 bitmap and the 48-symbol selector (not built here) |
| On-air test: APRS RX on a second radio, FT3D | **Done** (2026-09-30, v0.1: frames received by a UV-K1 running APRS RX and by the FT3D, first try, default level 66 and twist 0) |
## Station settings (`gen_assets.py`, then rebuild)
@@ -18,7 +18,7 @@ which is also the test receiver.
| `DEST` | `APZK5` (APZ = experimental software) |
| `PATH` | `1` = `WIDE1-1`, an index in `PATHS`: `DIRECT` (no digipeater), `WIDE1-1`, `WIDE1-1,WIDE2-1`; the **default**, until one is chosen on the radio |
| `LAT`, `LON` | `4850.90N`, `00216.25E`: the **default** position, until one is edited on the radio (key 5) |
| `SYMBOL` | `/[` (person) |
| `SYMBOL` | `/[` (person): the default, until one of the 48 symbols is chosen on the radio |
| `COMMENT` | `UV-K5 & UV-K1 APRS TX` (43 characters at most) |
Frame sent: `F4HWN-7>APZK5,WIDE1-1:!4850.90N/00216.25E[UV-K5 & UV-K1 APRS TX` (66 bytes,
@@ -27,6 +27,10 @@ If the boot message is not a plain callsign (empty, more than 6 characters once
spaces are dropped, or with a `/`), the app shows `No boot callsign` and does not
transmit.
In the normal view, the TX frequency is aligned left below the separator and
the selected APRS symbol is shown as a 20x20 bitmap at x=108. The 48-symbol
Yaesu bitmap set is stored in the app assets; the editor keeps the full width.
## Keys (UV-K5 and UV-K1)
| Key | Action |
@@ -37,8 +41,8 @@ transmit.
| 2 / F then 2 | Twist `tw` up / down, -4..+8: 2200 Hz gain = level × (8 + tw) / 8 (-6..+6 dB) |
| F | Arm the next key's down direction (1, 2), as FoxHunt's F; icon in the status bar while armed |
| * | Scroll view / compact view (saved): compact shows the source in bold, then the path, the position `48 50.90N 002 16.25E`, the symbol and the comment in the tiny 3x5 font, 32 characters a row, without scroll |
| 5 | Edit the position, SSID and path |
| EXIT | Quit (level, twist, position, SSID, path and view are saved) |
| 5 | Edit the position, SSID, path and symbol |
| EXIT | Quit (level, twist, position, SSID, path, symbol and view are saved) |
### Editor (v0.3)
@@ -47,24 +51,27 @@ LAT 48 50.90 N
LON 002 16.25 E
SSID 7
PATH WIDE1-1
SYMB /[
0-9 digit * change F+* back
0-9, * change, F+* back
UP/DN move MENU ok EXIT
```
The field under the cursor is drawn in bold: a digit or the N/S, E/W letter of
the position, or the whole SSID or path value. Type the 13 digits in a row, as a
the position, or the whole SSID, path or symbol value. Type the 13 digits in a row, as a
frequency: `485090` then `0021625` (degrees, minutes, hundredths of a minute: the
APRS format, read as is from a GPS or aprs.fi in degrees-minutes). The cursor
skips the separators and N/S, and goes on from the latitude to the longitude;
UP/DOWN also stop on N/S and E/W, then take it on to SSID and PATH.
UP/DOWN also stop on N/S and E/W, then take it on to SSID, PATH and SYMB.
When SYMB is selected, both shortened help rows remain visible and the edited
20x20 symbol is previewed at x=108, the same position as on the normal screen.
| Key | Action |
|---|---|
| 0-9 | Digit at the cursor, then the next one (on a position digit) |
| * | N/S (latitude line), E/W (longitude line), next SSID 0-15 (SSID), next path (PATH) |
| F then * | Previous SSID or path (the `F` icon in the status bar while F is armed, as in FoxHunt; F again disarms it) |
| UP / DOWN | Move the cursor: 6 digits, N/S, 7 digits, E/W, SSID, PATH |
| * | N/S (latitude line), E/W (longitude line), next SSID 0-15 (SSID), next path (PATH), or next symbol (SYMB) |
| F then * | Previous SSID, path or symbol (the `F` icon in the status bar while F is armed, as in FoxHunt; F again disarms it) |
| UP / DOWN | Move the cursor: 6 digits, N/S, 7 digits, E/W, SSID, PATH, SYMB |
| MENU | Check (degrees ≤ 90 / 180, minutes < 60) and keep: the frame is rebuilt; `Invalid position` otherwise |
| EXIT | Cancel |
@@ -72,9 +79,10 @@ Paths: `DIRECT` (heard only by the stations and iGates in range), `WIDE1-1` (one
repeat by a nearby digipeater), `WIDE1-1,2-1` (`WIDE1-1,WIDE2-1`: the usual
mobile / portable path, two repeats).
Everything is saved on exit (`cfg_save`, 12 bytes: magic 0xA8, level, twist, 13
Everything is saved on exit (`cfg_save`, 13 bytes: magic 0xA8, level, twist, 13
digits and the hemispheres in nibbles, `0x80 | path << 4 | SSID`, then the view:
14 compact, anything else scroll). A v0.2 config (10 bytes: byte 10 erased)
14 compact, anything else scroll, then the symbol index). An older config with
byte 12 erased uses the default person symbol. A v0.2 config (10 bytes: byte 10 erased)
keeps its position and takes the default SSID and path; an 11-byte config
(byte 11 erased) opens in the scroll view; a v0.1 config (magic 0xA7) is
ignored: defaults.
@@ -109,8 +117,8 @@ SPI writes, a few tens of µs out of 833. `tx_mute` + `tx_end` restore RX.
`test/tx_model.py` (needs `../aprsrx/test`): frame from the real assets vs
`ax25.build`, bit stream vs `ax25.hdlc_bits`, bad boot callsigns refused, an
edited position (south/west) vs `ax25.build`, every path with SSID 0, 15 and 9
vs `ax25.build`, the config round trip (and a v0.2 config falling back to the
default SSID and path), the editor rows and bold column, the editor keys (13
and a selected symbol vs `ax25.build`, the config round trip (and a v0.2 config
falling back to the default SSID, path and symbol), the editor rows and bold column, the editor keys (13
digits typed in a row, cursor stops, `*` and F then `*` on each field; the
UP/DOWN keys through `nav_dir()`, with SET_NAV on and off), the position limits,
then
+54 -28
View File
@@ -20,8 +20,8 @@
*
* 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
* gen_assets.py (assets). The position, the SSID, the path (DIRECT, WIDE1-1
* or WIDE1-1,WIDE2-1) and the symbol 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.
*
@@ -36,14 +36,14 @@
* 1 px per loop · 1 / F then 1 tone level up / down (deviation, REG_70 gain
* 10-127) · 2 / F then 2 twist up / down: 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 · * scroll view / compact view · EXIT quit. F as FoxHunt's: the next key goes down, an icon
* SSID, path and symbol · * scroll view / compact view · EXIT quit. F as FoxHunt's: the next key goes down, an icon
* in the status bar while armed.
* 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) ·
* position lines, next SSID, path or symbol on theirs · F then * the previous
* value · UP/DOWN move the cursor (6 digits, N/S, 7 digits, E/W, SSID, path, symbol) ·
* MENU check and keep · EXIT cancel.
* Level, twist, position, SSID, path and view are saved on exit (cfg_save, 12
* Level, twist, position, SSID, path, symbol and view are saved on exit (cfg_save, 13
* bytes).
*/
@@ -89,16 +89,17 @@
their place while armed; not while transmitting:
the capsule ends at x = 72 */
/* editor cursor stops: 0-5 the latitude digits, N/S, 7-13 the longitude
* digits, E/W, SSID, path */
* digits, E/W, SSID, path, symbol */
#define CUR_NS LAT_DIGITS
#define CUR_EW (POS_DIGITS + 1u)
#define CUR_SSID (CUR_EW + 1u)
#define CUR_PATH (CUR_EW + 2u)
#define CUR_SYM (CUR_EW + 3u)
#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 12u /* magic, level, twist, 13 digits + hemispheres in nibbles,
#define CFG_LEN 13u /* magic, level, twist, 13 digits + hemispheres in nibbles,
then 0x80 | path << 4 | SSID (v0.2 configs: defaults),
then the view (CW_COMPACT, else scroll: 0xFF before v0.4) */
the view, then the symbol index (default if erased) */
#define LOOP_MS 40u
#define REFRESH_LOOPS 125u /* a redraw every 5 s without a key (battery) */
@@ -116,6 +117,7 @@ static struct {
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 sym, esym; /* transmitted symbol and edited index */
uint8_t tick; /* loops since the last draw() */
uint8_t top, lim, vrow; /* scroll (px) and its limit, row being drawn */
uint8_t cw; /* view: 0 scroll, CW_COMPACT compact */
@@ -197,8 +199,9 @@ static char *brk(char *o){
/* 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. */
* string in the big font). The group is aligned left to reserve the rightmost
* 20 columns for the APRS symbol. Big digits are 13 px (drawn from +2), the
* point 3 px. No division. */
static void drawFreq(uint32_t f){
const app_api_t *A=g.A;
uint16_t m=0;
@@ -208,11 +211,19 @@ static void drawFreq(uint32_t f){
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" */
if(m>=1000u){ A->print_string(str,0,0,4,8); return; }
A->print_normal(str+n-2u,(uint8_t)(big*13u+3u),0,5); /* "00" */
str[n-2u]='\0';
A->display_freq(str,x,4,false); /* "144.800" */
A->display_freq(str,0,4,false); /* "144.800" */
}
#define SYM_X 108u
_Static_assert(SYM_X+SYM_W<=128u,"APRS symbol must fit on screen");
static void drawSymbol(uint8_t x,uint8_t sym){
const app_api_t *A=g.A;
uint16_t off=(uint16_t)(SYM_BITMAPS+(uint16_t)sym*SYM_BYTES);
for(uint8_t page=0;page<3u;page++)
A->asset_read((uint16_t)(off+page*SYM_W),A->fb[4u+page]+x,SYM_W);
}
/* AX.25 address field -> "F4HWN-7" */
static char *putCall(char *o,const uint8_t *a){
@@ -258,7 +269,7 @@ static void build(void){
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);
A->asset_read((uint16_t)(SYM_CODES+g.sym*2u),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 */
@@ -332,13 +343,12 @@ static void editRow(const char *label,uint8_t line,const uint8_t *d,uint8_t n,ch
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){
/* A labelled value on a normal-font line, at x; the value is bold when selected. */
static void fieldRow(const char *label,const char *val,uint8_t x,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);
g.A->print_normal(str,x,0,line);
if(sel) g.A->print_bold(val,(uint8_t)(x+FIELD_COL*7u),0,line);
}
/* ---- display ---- */
@@ -365,10 +375,19 @@ static void draw(void){
(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);
fieldRow(s+T_SSID,v,0,2,g.cur==CUR_SSID);
fieldRow(s+T_PATH,s+T_PATHS+g.epath*T_PATHS_STRIDE,0,3,g.cur==CUR_PATH);
A->asset_read((uint16_t)(SYM_CODES+g.esym*2u),v,2); v[2]='\0';
if(g.cur==CUR_SYM){
fieldRow(s+T_SYM,v,0,4,true);
drawSymbol(SYM_X,g.esym);
tiny(40,put(str,s+T_HELP1));
tiny(48,put(str,g.status==ST_BADPOS?s+T_BADPOS:s+T_HELP2));
} else {
fieldRow(s+T_SYM,v,0,4,false);
tiny(40,put(str,s+T_HELP1));
tiny(48,put(str,g.status==ST_BADPOS?s+T_BADPOS:s+T_HELP2));
}
} else {
g.vrow=0;
if(g.flen){
@@ -427,6 +446,7 @@ static void draw(void){
* blank px between) */
for(uint8_t x=0;x<128u;x++) b[384u+x]=(uint8_t)((b[384u+x]&0x7Fu)|((~x&1u)<<7));
drawFreq(A->tx_freq());
drawSymbol(SYM_X,g.sym);
/* bottom line: "lvl 66 tw +0 sent 3", or why nothing was sent */
if(g.status==ST_DENIED||g.status==ST_NOCALL)
@@ -488,7 +508,7 @@ static void handleKeys(void){
if(key==APP_KEY_F){ g.fArm=!g.fArm; return; }
bool back=g.fArm; /* F then a key: it goes backwards (down) */
g.fArm=false;
if(g.edit){ /* position, SSID and path editor */
if(g.edit){ /* position, SSID, path and symbol editor */
g.status=ST_IDLE;
uint8_t cur=g.cur;
bool lon=cur>CUR_NS && cur<=CUR_EW; /* on the longitude line */
@@ -503,13 +523,16 @@ static void handleKeys(void){
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 if(cur==CUR_PATH){
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(back) g.esym=(uint8_t)(g.esym?g.esym-1u:SYM_COUNT-1u);
else g.esym=(uint8_t)(g.esym+1u<SYM_COUNT?g.esym+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.hemi=g.ehemi; g.ssid=g.essid; g.path=g.epath; g.sym=g.esym;
g.edit=false;
build();
if(!g.flen) g.status=ST_NOCALL;
@@ -518,7 +541,7 @@ static void handleKeys(void){
else {
int8_t d=A->nav_dir(key);
if(d<0 && g.cur) g.cur--;
if(d>0 && g.cur<CUR_PATH) g.cur++;
if(d>0 && g.cur<CUR_SYM) g.cur++;
}
return;
}
@@ -535,7 +558,7 @@ static void handleKeys(void){
else if(key==APP_KEY_STAR){ g.cw^=CW_COMPACT; g.top=0; } /* the view */
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.ehemi=g.hemi; g.essid=g.ssid; g.epath=g.path; g.esym=g.sym;
g.cur=0;
g.top=0; /* the frame may change: back to its top */
g.edit=true;
@@ -553,6 +576,7 @@ void app_main(const app_api_t *api){
g.lvl=LVL_DEF;
g.ssid=CFG_SSID;
g.path=CFG_PATH;
g.sym=SYM_INDEX;
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);
@@ -565,6 +589,7 @@ void app_main(const app_api_t *api){
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); }
if(c[11]==CW_COMPACT) g.cw=CW_COMPACT;
if(c[12]<SYM_COUNT) g.sym=c[12];
}
build();
if(!g.flen) g.status=ST_NOCALL;
@@ -591,5 +616,6 @@ void app_main(const app_api_t *api){
c[9]|=(uint8_t)(g.hemi<<4); /* the 14th nibble */
c[10]=(uint8_t)(0x80u|(g.path<<4)|g.ssid);
c[11]=g.cw;
c[12]=g.sym;
api->cfg_save(c,CFG_LEN);
}
+1 -1
View File
@@ -8,7 +8,7 @@ set -euo pipefail
APP="$(basename "$PWD")" # breakout, foxhunt, beacon, fm, ...
APP_NAME="APRS TX" # <-- the only per-app line
APP_VER="0.3"
APP_VER="0.4"
APP_API_MIN=2
APP_VMA=${APP_VMA:-0x20000280} # pinned overlay VMA (Core/py32f071xb.ld)
OUT="${APP_NAME// /}" # blob basename ("Broadcast FM" -> BroadcastFM)
+9 -2
View File
@@ -17,6 +17,7 @@ sys.dont_write_bytecode = True
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, ".."))
from app_assets import Assets
from aprs_symbols_20 import BITMAPS, BYTES_PER_ICON, CODES, WIDTH
# ---- station settings ----
SSID = 7 # F4HWN-7 (0-15, 0 = no SSID)
@@ -44,7 +45,8 @@ UI = [
("T_LON", "LON "),
("T_SSID", "SSID "), # FIELD_COL (5) characters: the value
("T_PATH", "PATH "), # starts at the same column
("T_HELP1", "0-9 digit * change F+* back"), # * on the bold field
("T_SYM", "SYMB "), # selected APRS table/code pair
("T_HELP1", "0-9, * change, F+* back"), # * on the bold field
("T_HELP2", "UP/DN move MENU ok EXIT"),
("T_BADPOS", "Invalid position"),
]
@@ -89,8 +91,13 @@ a.const("CFG_PATH", PATH)
a.raw("F_DEST", addr(DEST))
a.raw("F_WIDE", b"".join(addr(h) for h in WIDE))
a.u8("POS_DEF", POS + [HEMI])
a.raw("F_SYM", SYMBOL.encode("ascii"))
a.raw("F_COMMENT", COMMENT.encode("ascii"))
a.raw("SYM_CODES", "".join(CODES).encode("ascii"))
a.raw("SYM_BITMAPS", BITMAPS)
a.const("SYM_INDEX", CODES.index(SYMBOL))
a.const("SYM_COUNT", len(CODES))
a.const("SYM_W", WIDTH)
a.const("SYM_BYTES", BYTES_PER_ICON)
a.u8("BMP_F", [0x3e,0x7f,0x41,0x75,0x75,0x75,0x7d,0x7f,0x3e]) # F armed, as FoxHunt / FM / Beacon
# scroll marks (status bar, x = 75, as APRS RX): up only, down only, both; up
# in bits 0-2 (rows hidden above), down in bits 4-6 (rows hidden below)
+36 -24
View File
@@ -51,25 +51,26 @@ def pos_ok(d):
CW_COMPACT = 14 # aprstx_app.c: the compact view
def cfg_pack(lvl, tw, pos, hemi, ssid, path, cw=0):
c = [0xA8, lvl, tw & 0xFF] + [0] * 9
def cfg_pack(lvl, tw, pos, hemi, ssid, path, cw=0, sym=5):
c = [0xA8, lvl, tw & 0xFF] + [0] * 10
for i, x in enumerate(pos): c[3 + i // 2] |= x << ((i & 1) * 4)
c[9] |= hemi << 4
c[10] = 0x80 | path << 4 | ssid
c[11] = cw
c[12] = sym
return bytes(c)
def cfg_unpack(c, D):
"""aprstx_app.c app_main(): position, hemispheres, SSID, path (the
defaults when byte 10 is erased or from a v0.2 config) and view (scroll
unless byte 11 is CW_COMPACT: erased before v0.4)"""
defaults when byte 10 is erased or from a v0.2 config), view (scroll unless
byte 11 is CW_COMPACT) and symbol (default when byte 12 is erased)"""
pos = [(c[3 + i // 2] >> ((i & 1) * 4)) & 15 for i in range(13)]
cw = CW_COMPACT if c[11] == CW_COMPACT else 0
h = c[10]
if (h & 0xC0) == 0x80 and (h >> 4) & 3 <= 2:
return pos, c[9] >> 4, h & 15, (h >> 4) & 3, cw
return pos, c[9] >> 4, D["CFG_SSID"], D["CFG_PATH"], cw
return pos, c[9] >> 4, h & 15, (h >> 4) & 3, cw, c[12] if c[12] < D["SYM_COUNT"] else D["SYM_INDEX"]
return pos, c[9] >> 4, D["CFG_SSID"], D["CFG_PATH"], cw, c[12] if c[12] < D["SYM_COUNT"] else D["SYM_INDEX"]
def edit_row(label, d, h, cur):
@@ -86,7 +87,7 @@ def edit_row(label, d, h, cur):
return o + h, col
CUR_NS, CUR_EW, CUR_SSID, CUR_PATH = 6, 14, 15, 16
CUR_NS, CUR_EW, CUR_SSID, CUR_PATH, CUR_SYM = 6, 14, 15, 16, 17
def nav_dir(key, set_nav):
@@ -96,7 +97,7 @@ def nav_dir(key, set_nav):
return d if set_nav else -d
def editor_keys(keys, ed, hemi=0, ssid=7, path=1, cur=0, set_nav=True):
def editor_keys(keys, ed, hemi=0, ssid=7, path=1, sym=5, cur=0, set_nav=True):
"""aprstx_app.c handleKeys() in the editor: digits 0-9, '*', raw UP 'U' /
DOWN 'D' through nav_dir(), 'F'"""
ed = list(ed)
@@ -116,16 +117,18 @@ def editor_keys(keys, ed, hemi=0, ssid=7, path=1, cur=0, set_nav=True):
elif k == "*":
if cur <= CUR_EW: hemi ^= 2 if lon else 1
elif cur == CUR_SSID: ssid = (ssid + (15 if back else 1)) & 15
elif back: path = path - 1 if path else 2
else: path = path + 1 if path < 2 else 0
elif cur == CUR_PATH:
path = path - 1 if back and path else 2 if back else path + 1 if path < 2 else 0
elif back: sym = sym - 1 if sym else 47
else: sym = sym + 1 if sym < 47 else 0
else:
d = nav_dir(k, set_nav)
if d < 0 and cur: cur -= 1
if d > 0 and cur < CUR_PATH: cur += 1
return ed, hemi, ssid, path, cur
if d > 0 and cur < CUR_SYM: cur += 1
return ed, hemi, ssid, path, sym, cur
def build_c(blob, D, call=CALL, pos=None, hemi=None, ssid=None, path=None):
def build_c(blob, D, call=CALL, pos=None, hemi=None, ssid=None, path=None, sym=None):
"""aprstx_app.c build()"""
if not call or len(call) > 6 or "/" in call:
return None
@@ -135,9 +138,10 @@ def build_c(blob, D, call=CALL, pos=None, hemi=None, ssid=None, path=None):
hemi = pd[13] if hemi is None else hemi
ssid = D["CFG_SSID"] if ssid is None else ssid
path = D["CFG_PATH"] if path is None else path
sym = rd("F_SYM").decode()
info = "!" + put_coord(pos[:6], "S" if hemi & 1 else "N") + sym[0] + \
put_coord(pos[6:], "W" if hemi & 2 else "E") + sym[1] + rd("F_COMMENT").decode()
sym = D["SYM_INDEX"] if sym is None else sym
pair = rd("SYM_CODES")[sym * 2:sym * 2 + 2].decode()
info = "!" + put_coord(pos[:6], "S" if hemi & 1 else "N") + pair[0] + \
put_coord(pos[6:], "W" if hemi & 2 else "E") + pair[1] + rd("F_COMMENT").decode()
f = bytearray(rd("F_DEST"))
f += bytes(ord(call[i]) << 1 if i < len(call) else 0x40 for i in range(6))
f.append(0x60 | (ssid << 1) | (0 if path else 1))
@@ -229,10 +233,16 @@ def main():
want = build("F4HWN-%d" % sid if sid else "F4HWN", dst="APZK5", path=WIDE[:path], info=info)
ok &= f3 == want
print("SSID %2d, path %d frame:" % (sid, path), f3 == want, decode(f3))
f3 = build_c(blob, D, sym=0)
want = build("F4HWN-7", dst="APZK5", path=["WIDE1-1"],
info="!4850.90N/00216.25E#UV-K5 & UV-K1 APRS TX")
ok &= f3 == want
print("selected /# symbol frame:", f3 == want, decode(f3))
c = cfg_pack(70, -2, pos, 3, 9, 2, CW_COMPACT)
good = len(c) == 12 and cfg_unpack(c, D) == (pos, 3, 9, 2, CW_COMPACT)
good = len(c) == 13 and cfg_unpack(c, D) == (pos, 3, 9, 2, CW_COMPACT, 5)
old = cfg_pack(70, -2, pos, 3, 0, 0)[:10] # a v0.2 config: byte 10 erased or 0
good &= all(cfg_unpack(old + bytes([b, 0xFF]), D) == (pos, 3, D["CFG_SSID"], D["CFG_PATH"], 0)
good &= all(cfg_unpack(old + bytes([b, 0xFF, 0xFF]), D) ==
(pos, 3, D["CFG_SSID"], D["CFG_PATH"], 0, D["SYM_INDEX"])
for b in (0xFF, 0x00)) # byte 11 erased: scroll view
ok &= good
print("config round trip, v0.2 config -> default SSID and path, scroll view:", good, c.hex())
@@ -250,21 +260,23 @@ def main():
# the cursor. Run for both SET_NAV settings: the raw key moving the cursor
# forward (nav_dir +1) is UP with SET_NAV, DOWN without.
typed = editor_keys("3352131511256", [0] * 13)
good &= typed == (pos, 0, 7, 1, 13)
good &= typed == (pos, 0, 7, 1, 5, 13)
good &= editor_keys("2*", pos)[:2] == ([2] + pos[1:], 1) # '*' on a LAT digit
for sn in (True, False):
N, P = ("U", "D") if sn else ("D", "U") # next / previous field
k = lambda keys, **kw: editor_keys(keys, pos, set_nav=sn, **kw)
good &= k(N)[4] == 1 and k(P)[4] == 0 and k(N + P)[4] == 0 # the raw keys' direction
good &= k(P * 13 + N * 6 + "*")[1:] == (0 ^ 1, 7, 1, CUR_NS)
good &= k(N)[5] == 1 and k(P)[5] == 0 and k(N + P)[5] == 0 # the raw keys' direction
good &= k(P * 13 + N * 6 + "*")[1:] == (0 ^ 1, 7, 1, 5, CUR_NS)
good &= k(N * 14 + "*" + "5")[:2] == (pos, 2) # E/W: '*', a digit ignored
good &= k(N * 15 + "**")[2:] == (9, 1, CUR_SSID)
good &= k(N * 16 + "**" + N)[3:] == (0, CUR_PATH) # stops on the last field
good &= k(N * 15 + "**")[2:] == (9, 1, 5, CUR_SSID)
good &= k(N * 16 + "**" + N)[3:] == (0, 5, CUR_SYM)
good &= k(N * 15 + "F*F*")[2] == 5 # F then '*': SSID back
good &= k(N * 15 + "F*" * 8, ssid=3)[2] == 11 # ... wrapping 0 -> 15
good &= k(N * 16 + "F*F*F*")[3] == 1 # path back, 1 -> 0 -> 2 -> 1
good &= k(N * 15 + "FF*")[2] == 8 # F twice: disarmed
good &= k(N * 15 + "F" + N + "*")[3:] == (2, CUR_PATH) # F used by a move, '*' forward
good &= k(N * 15 + "F" + N + "*")[3:] == (2, 5, CUR_PATH) # F used by a move, '*' forward
good &= k(N * 17 + "**")[4:] == (7, CUR_SYM) # symbol forward
good &= k(N * 17 + "F*")[4:] == (4, CUR_SYM) # symbol backward
ok &= good
print("editor keys (SET_NAV on and off):", good)
checks = [([4,8,5,0,9,0,0,0,2,1,6,2,5], True), ([9,0,0,0,0,0,1,8,0,0,0,0,0], True),