Compare commits

..
Author SHA1 Message Date
Armel FAUVEAU 2846fe3ae6 Fix APRSTX scroll 2026-10-04 19:05:21 +02:00
Armel FAUVEAU 1d89f5f4da Pin current VFO to selected TX VFO 2026-10-04 18:42:20 +02:00
Armel FAUVEAU 4ec18cf758 SSTV: EXIT aborts a reception and leaves the picture view first 2026-10-04 01:50:51 +02:00
Armel FAUVEAU 26e05b38ee APRS RX/TX: lower the symbol by 1 px 2026-10-04 00:26:26 +02:00
Armel FAUVEAU 840d08aad7 APRS RX: five-frame history, one frame at a time 2026-10-03 23:49:31 +02:00
Armel FAUVEAU 7357f87042 APRS RX: scroll long compact frames 2026-10-03 05:30:40 +02:00
Armel FAUVEAU ac7b8fa94b Add SSTV overlay app 2026-10-03 05:03:56 +02:00
Armel FAUVEAU e12e8f1c36 Improve Cube3D rendering and controls 2026-10-03 04:06:38 +02:00
Armel FAUVEAU cbb5d54566 APRS update beacon comment 2026-10-03 02:52:32 +02:00
Armel FAUVEAU 7db5cd9358 APRS RX/TX: v2 symbols, key remap, editor and dBm display fixes 2026-10-03 00:06:55 +02:00
Armel FAUVEAU b7ec793ba6 Show combined apps and assets total 2026-10-02 05:33:57 +02:00
Armel FAUVEAU a781e63653 Add stack usage diagnostics 2026-10-02 05:26:11 +02:00
Armel FAUVEAU c06246b798 APRS RX/TX: add validated 20x20 symbols and TX selector 2026-10-02 03:46:46 +02:00
Armel FAUVEAU f940a6b59e APRS RX: size optimizations (-136 B, 3940 B) 2026-10-01 23:43:47 +02:00
Armel FAUVEAU af8142990d APRS RX/TX: scrollable large-font frame view, compact view toggle 2026-10-01 19:22:24 +02:00
Armel FAUVEAU 4934f83fea Merge pull request #602 from jdenoy/feature/epirb406-popcount
EPIRB 406 v1.7: loop bit count in the sync search (-36 B, 3,284 B)
2026-10-01 17:38:08 +02:00
Johan Denoyer a4f491fcb7 EPIRB 406 v1.7: loop bit count in the sync search (-36 B, 3,284 B)
popc32 counts one pass per set bit instead of the branch-free version: at most
4 x 32 passes per half-bit while searching, against 5,000 cycles per sample.
Host tests 29/29. v1.6 checked on a UV-K1 with the rpitx bench frame.
2026-10-01 09:53:27 +02:00
Armel FAUVEAU 1d04b550bb Add SSID and path to APRS TX editor, honour backlight timeout in APRS apps 2026-10-01 05:17:01 +02:00
Armel FAUVEAU 27248795cb Set APRS TX default comment to 'UV-K5 & UV-K1 APRS TX' 2026-09-30 05:52:26 +02:00
Armel FAUVEAU 9d751bdc11 Add 1 px gap between APRS RX text rows 2026-09-30 05:45:42 +02:00
Armel FAUVEAU 2b33f3ebdd Mute APRS RX speaker by default, toggle it with key 1 2026-09-30 05:25:34 +02:00
Armel FAUVEAU 92f793bb2b Add APRS RX and APRS TX overlay Apps 2026-09-30 05:13:27 +02:00
Armel FAUVEAU 5e8469966b Add totals row to overlay app build report 2026-09-30 01:59:14 +02:00
Armel FAUVEAU eb2921067b Add EPIRB 406 overlay App (author @jdenoy, feat by @armel) 2026-09-30 01:45:55 +02:00
Armel FAUVEAU 0069073d12 Improve RescueOps flashlight timeout 2026-09-30 00:05:09 +02:00
Armel FAUVEAU a52134fb63 Fix CRLF handling for app builds (Discussion #596) 2026-09-29 22:00:21 +02:00
Armel FAUVEAU 65d67fa261 Improve Plasma app 2026-09-29 19:45:36 +02:00
Armel FAUVEAU 5d6325a16f Decouple overlay BEAM from AirCopy FSK driver 2026-09-29 19:26:23 +02:00
Armel FAUVEAU a65944ed33 Add SysInfo overlay App 2026-09-29 17:42:08 +02:00
Armel FAUVEAU 096b768ff6 Full watch animate scan chevrons 2026-09-29 03:44:31 +02:00
Armel FAUVEAU c2e8f0fdc7 Fix menu exit after hot config switching 2026-09-29 00:27:24 +02:00
Armel FAUVEAU c147fbfcae Fix app compilation paths under Git Bash (Discussion #596) 2026-09-28 23:13:57 +02:00
Armel FAUVEAU 6ed6d20e76 Improve status bar config and RX mode indicators 2026-09-28 23:01:35 +02:00
Armel FAUVEAU fa01072596 Add hot Multiboot config switching and bank indicator 2026-09-28 20:06:43 +02:00
Armel FAUVEAU 8a8b6814f6 Optimize overlay apps and reduce Flash usage 2026-09-28 05:26:26 +02:00
Armel FAUVEAU e4c0f00126 Optimize Multiboot flash handling and RAM restore progress 2026-09-28 05:06:35 +02:00
Armel FAUVEAU 0333d2d4c6 Reduce DCS table size and simplify approved-code indexing 2026-09-28 04:32:29 +02:00
Armel FAUVEAU 7ccac733de Fix Full Watch channel navigation after priority swaps 2026-09-28 04:03:00 +02:00
Armel FAUVEAU 641aefb1a7 Reduce menu flash footprint with shared settings descriptors 2026-09-28 03:53:33 +02:00
Armel FAUVEAU f7004b871a Optimize Full Watch flash footprint 2026-09-28 03:08:25 +02:00
Armel FAUVEAU b204c68fa5 Add experimental and optional Full Watch with priority channel swapping 2026-09-28 02:55:56 +02:00
Armel FAUVEAU 5d7bd5094b Speed up and smooth Spectrum3D rendering with Bresenham 2026-09-27 23:21:46 +02:00
Armel FAUVEAU 712e4fbb65 Fix Spectrum3D audio flutter while listening, trim overlay code size 2026-09-27 03:12:36 +02:00
Armel FAUVEAU 2aff80cc0e Refactor Minesweeper: API v2 assets, title screen, bug fixes 2026-09-27 00:34:54 +02:00
Armel FAUVEAU b1dde27536 Merge pull request #592 from Sarijen/feature_update_v6
Add new overlay app - Minesweeper game
2026-09-26 17:29:20 +02:00
Sarijen e3aab8ebd1 Add new overlay app - Minesweeper game 2026-09-26 16:22:52 +02:00
Armel FAUVEAU 74820b4e95 Improve Spectrum3D, add API division, keep app settings on update 2026-09-26 15:01:41 +02:00
Armel FAUVEAU d2b983bfab Optimize signed division in assembly — divide the code size too 2026-09-26 03:33:23 +02:00
Armel FAUVEAU c8e426ad6b Increase app slots from 8 to 16 2026-09-25 05:37:39 +02:00
Armel FAUVEAU 3b59758425 Shrink overlay apps: state structs, no libgcc division, stack buffers 2026-09-25 05:13:14 +02:00
Armel FAUVEAU cc1439dcc2 Add Spectrum3D app 2026-09-25 04:04:44 +02:00
Armel FAUVEAU 9d5b3af7a3 Add read-only assets to overlay apps and improve apps 2026-09-24 22:32:00 +02:00
Armel FAUVEAU f64406277c Remove unused Triple VFO preset 2026-09-24 17:42:59 +02:00
Armel FAUVEAU 8534c6cd5d Show uppercase category capsule in status bar 2026-09-24 17:32:47 +02:00
Armel FAUVEAU 05327d0b87 Clean apps 2026-09-24 00:57:37 +02:00
Armel FAUVEAU dde1572070 Enable screen saver support in Space Impact 2026-09-24 00:55:06 +02:00
Armel FAUVEAU 235664e2e3 Add Rapid Roll overlay game 2026-09-24 00:40:41 +02:00
Armel FAUVEAU c358b7043f Add Space Impact overlay game 2026-09-23 19:50:28 +02:00
Armel FAUVEAU 7ba1d3a256 Add UART and flash cloning to AirCopy 2026-09-22 23:29:27 +02:00
Armel FAUVEAU 32b4c19616 Update donors 2026-09-22 17:29:00 +02:00
Armel FAUVEAU f2a27ef0fd Optimize Aircopy and validate TX/RX selections 2026-09-22 17:25:12 +02:00
Armel FAUVEAU 720a5e8861 Speed up AirCopy by skipping identical blocks 2026-09-22 06:01:40 +02:00
Armel FAUVEAU 398482e06b Speed up AirCopy ~2x with multi-block FSK framing 2026-09-21 23:27:42 +02:00
Armel FAUVEAU c986ff5766 Update rolling Fusion development firmware 2026-09-21 01:19:10 +02:00
Armel FAUVEAU aa165cd3f1 Show selected list count for ScList MIX 2026-09-19 18:18:37 +02:00
Armel FAUVEAU cc2281d3cb Add MIX scan list mode 2026-09-19 05:05:38 +02:00
Armel FAUVEAU e9d4e9e184 Fix Breakout screen saver and redraw artifacts 2026-09-19 00:15:55 +02:00
Armel FAUVEAU 374616c796 Fix Snake overlay state, RNG and screen saver 2026-09-19 00:00:32 +02:00
Armel FAUVEAU 9f4ffa23a0 Merge pull request #590 from mrkusypl/feature_update_v6
Add new overlay app - Snake game
2026-09-18 23:45:13 +02:00
mrkusypl 9be2697619 Add new overlay app - Snake game 2026-09-18 17:00:50 +02:00
Armel FAUVEAU fd437ec015 Refresh app slots during UV Studio updates 2026-09-18 04:59:17 +02:00
Armel FAUVEAU 7d0f2646f4 Fix compiler warnings and report them in All builds 2026-09-17 13:35:10 +02:00
Armel FAUVEAU 33839d5142 Keep OnePush responsive during slot refresh 2026-09-17 03:00:12 +02:00
Armel FAUVEAU a8dd32598b Optimize printf and validate supported formats at build time 2026-09-17 01:39:51 +02:00
Armel FAUVEAU c9fe10352b Pack small fonts using 7-bit column encoding 2026-09-16 05:33:57 +02:00
Armel FAUVEAU 7ecc855387 Optimize side-function menu table and validate entries at compile time 2026-09-16 05:07:15 +02:00
Armel FAUVEAU 5819a1fbb9 Optimize big font storage with lossless packed encoding 2026-09-16 04:38:08 +02:00
Armel FAUVEAU f14b8a9e20 Save 352 bytes by making USB noncacheable RAM NOLOAD 2026-09-16 03:25:26 +02:00
Armel FAUVEAU 1b08dba6a5 Add new logo 2026-09-16 01:55:23 +02:00
Armel FAUVEAU eabba8d50b Fix RFLog channel names across config banks (Issue #584) 2026-09-15 19:28:29 +02:00
Armel FAUVEAU 889813e802 Fix RF Log corrupting overlay apps on launch (issue #583) 2026-09-15 13:47:07 +02:00
Armel FAUVEAU 9d8e082ea2 Update donors 2026-09-15 04:25:53 +02:00
Armel FAUVEAU aad41605c9 Add CRC32 external flash verification 2026-09-15 04:23:46 +02:00
Armel FAUVEAU 8741319915 Add external flash dump and restore (Labs only) 2026-09-15 04:03:11 +02:00
Armel FAUVEAU 932737cbd7 Fix scrolling Ninja progress in compile-firmware.sh, add emoji output 2026-09-14 02:06:06 +02:00
Armel FAUVEAU f4cfb7de99 Prevent reboot loop on wiped calibration (clamp battery cal divisor) 2026-09-14 00:15:23 +02:00
Armel FAUVEAU c7a6d11d2b Fix missing DCS squelch tail (134.4Hz turn-off tone) in bk4829 driver (issue #580) 2026-09-13 00:52:07 +02:00
Armel FAUVEAU a57b10b985 Update donors 2026-09-13 00:29:45 +02:00
Armel FAUVEAU 3a50e2127c Merge pull request #567 from armel/feature_update_v6
Feature update v6
2026-09-10 22:17:34 +02:00
Armel FAUVEAU c665bb0fa7 Patch Fieldops and Labs 2026-09-10 22:16:49 +02:00
Armel FAUVEAU fee567d417 Patch Fusion and Transfer 2026-09-10 22:14:43 +02:00
Armel FAUVEAU 7a2687b0b2 Fix SetNav persistence in Fusion and Transfer 2026-09-10 22:12:50 +02:00
Armel FAUVEAU e5f6fb236b Merge pull request #542 from Sarijen/main
Fix RSSI level line overlapping BW text in spectrum
2026-09-10 13:48:47 +02:00
Armel FAUVEAU 345535de4c Merge pull request #560 from armel/feature_update_v6
Remove bad idea
2026-09-09 22:10:31 +02:00
Armel FAUVEAU bf6c28f74b Remove bad idea 2026-09-09 22:10:01 +02:00
Armel FAUVEAU 705d68f89f Merge pull request #559 from armel/feature_update_v6
Feature update v6
2026-09-09 21:53:26 +02:00
Armel FAUVEAU 150b96f736 Update donors 2026-09-09 21:52:25 +02:00
Armel FAUVEAU a73a606b4b Prepare new version 2026-09-09 21:49:25 +02:00
Armel FAUVEAU 0aebf26ada Merge private multiboot development into feature_update_v6
# Conflicts:
#	App/app/app.c
#	CMakePresets.json
2026-09-09 21:38:15 +02:00
Armel FAUVEAU 43e9e1ec67 Support macOS Bash 3.2 in compile scripts 2026-09-08 19:25:22 +02:00
Armel FAUVEAU dbb9d42d2d Count the scan-list name hold in 10 ms ticks, not 500 ms 2026-09-07 22:41:50 +02:00
Armel FAUVEAU 749e856959 Restyle the F status indicator 2026-09-07 03:19:43 +02:00
Armel FAUVEAU 960d13e120 Don't force backlight off on serial session init 2026-09-07 01:17:11 +02:00
Armel FAUVEAU d12dbe0bf9 Fix beacon carrier staying off after a mid-window CARR→TONE switch 2026-09-06 23:39:17 +02:00
Armel FAUVEAU 34ea3fd6af Add optional CARR carrier-keying mode to beacon 2026-09-06 23:27:41 +02:00
Armel FAUVEAU 07226822cd Re-arm BLTime on keypress in overlay apps 2026-09-03 22:27:17 +02:00
Armel FAUVEAU a41fb99862 Fix CHIRP upload stall on unaligned final EEPROM write block 2026-09-03 19:02:21 +02:00
Armel FAUVEAU cc6527afc1 Fix Beam overlay VFO restore and deferred channel save 2026-09-03 02:53:38 +02:00
Armel FAUVEAU d95e4efa57 Add ENABLE_FMRADIO_EMBEDDED to split resident FM from shared driver 2026-09-03 00:18:52 +02:00
Armel FAUVEAU 74b2397c96 Optimize Tetris 2026-09-02 21:27:14 +02:00
Armel FAUVEAU e7db0bc1dc Make Triple VFO and Beam overlay services optional 2026-09-02 20:22:00 +02:00
Armel FAUVEAU 9019d0df1a Add Tetris app, just for fun 2026-09-02 12:41:54 +02:00
Armel FAUVEAU cd99ddc46d Rename BEAM overlay app to Beam 2026-08-31 21:54:30 +02:00
Armel FAUVEAU b26265c5f0 Optimize BEAM channel field copies 2026-08-31 21:49:12 +02:00
Armel FAUVEAU c8da5f99cc Version overlay app ABI and capabilities 2026-08-31 20:51:09 +02:00
Armel FAUVEAU 902e19ba8d Port BEAM to overlay apps 2026-08-31 18:28:38 +02:00
Armel FAUVEAU 62906636ed Optimize FM and overlay shortcuts 2026-08-31 02:03:28 +02:00
Armel FAUVEAU 99057f9dd2 Add BLTime support to overlay apps and Max preset 2026-08-31 01:33:23 +02:00
Armel FAUVEAU 0850420fdf Decouple FoxHunt and Beacon actions 2026-08-30 23:12:27 +02:00
Armel FAUVEAU 4c3847ef34 Launch Broadcast FM overlay from FM action 2026-08-30 22:22:20 +02:00
Armel FAUVEAU bd049c0467 Add K5Viewer support to multiboot 2026-08-30 18:42:02 +02:00
Armel FAUVEAU 66ca97fa5e Add K5Viewer support to overlay menu and apps 2026-08-30 18:31:50 +02:00
Armel FAUVEAU 2a82a62ffd Preserve MAIN VFO selection in Triple VFO 2026-08-30 17:39:43 +02:00
Armel FAUVEAU 2f80e60c01 Add SetGUI and PTT support to Triple VFO 2026-08-30 17:28:33 +02:00
Armel FAUVEAU 048e7e483b Optimize overlay apps and shared audio scope 2026-08-30 17:02:35 +02:00
Armel FAUVEAU 88e76b0e9b Optimize overlay ABI and reduce flash footprint 2026-08-30 13:15:16 +02:00
Armel FAUVEAU 8c78d28f0d Add TripleVFO, the king of clickbait... 2026-08-30 12:45:36 +02:00
Armel FAUVEAU b86da168e9 Improve apps 2026-08-30 00:38:51 +02:00
Armel FAUVEAU 104b7fb0b7 Refine Apps and multiboot UI 2026-08-29 13:54:44 +02:00
Armel FAUVEAU 9358d5d58f Add Cube3D app, just for fun 2026-08-29 11:58:20 +02:00
Armel FAUVEAU 9d6b560a54 Apply SetNav in overlay apps and FoxHunt 2026-08-28 23:43:42 +02:00
Armel FAUVEAU f4811cbe1e Add Plasma app, just for fun 2026-08-28 20:49:23 +02:00
Armel FAUVEAU 678cb9b73a Add full memory and settings transfer 2026-08-28 17:13:13 +02:00
Armel FAUVEAU f35408317e Add external-flash overlay app platform 2026-08-28 00:40:24 +02:00
Sarijen 3f395f9954 Fix RSSI level line overlapping BW text in spectrum 2026-08-26 23:19:58 +02:00
Armel FAUVEAU 3f958f637d Optimize FoxHunt Flash usage 2026-08-25 22:06:24 +02:00
Armel FAUVEAU b0143f9f9c Optimize Breakout Flash and RAM usage 2026-08-25 18:45:16 +02:00
Armel FAUVEAU 24d3e5283f Optimize Broadcast FM display and remove dead channel formatter 2026-08-25 15:25:24 +02:00
Armel FAUVEAU 285f543d46 Make Aircopy transfers reliable with ACK/RESEND recovery and leaner FSK handling 2026-08-25 13:05:20 +02:00
Armel FAUVEAU ce6bf08b23 Strengthening input boundary validation and preventing memory disclosure 2026-08-24 19:41:20 +02:00
Armel FAUVEAU 265f83a447 Reject oversized EEPROM read commands (Thanks @BB-KING777) 2026-08-24 02:46:58 +02:00
Armel FAUVEAU 88d17655de Rename Field preset to FieldOps 2026-08-24 00:53:35 +02:00
Armel FAUVEAU 92c380dd7a Rename Field preset to FieldOps 2026-08-24 00:48:31 +02:00
Armel FAUVEAU e1dfbde590 Prevent FM start during active VFO RX 2026-08-24 00:14:40 +02:00
Armel FAUVEAU 04a80916a2 Rename Extended preset to Max 2026-08-23 23:57:27 +02:00
Armel FAUVEAU 4315b98aec Preserve RescueOps settings across presets 2026-08-22 22:49:14 +02:00
Armel FAUVEAU 751c6064d1 Replace build options with CHIRP capabilities 2026-08-22 15:55:32 +02:00
Armel FAUVEAU ec7b4083c0 Stabilize action IDs and handle unavailable actions 2026-08-22 14:25:56 +02:00
Armel FAUVEAU 941693a0d8 Remove ENABLE_TX_WHEN_AM 2026-08-22 13:58:06 +02:00
Armel FAUVEAU 66fb8b1ed4 Remove ENABLE_AM_FIX 2026-08-22 13:52:04 +02:00
Armel FAUVEAU 7d550585d0 Remove ENABLE_BLMIN_TMP_OFF 2026-08-22 13:18:06 +02:00
Armel FAUVEAU 33dde24226 Remove ENABLE_ALARM and isolate ENABLE_TX1750 2026-08-22 12:58:34 +02:00
Armel FAUVEAU 1bf75db62a Preserve Ninja progress in interactive Docker builds 2026-08-21 03:26:43 +02:00
Armel FAUVEAU e4a645a5c7 Add SetCfg to pick a config bank independently of the slot 2026-08-21 02:56:54 +02:00
Armel FAUVEAU 5b6174085b Optimize multiboot RAM with a safe Overlay 2026-08-19 17:52:47 +02:00
Armel FAUVEAU 4ffff5149f Many fix presets and Multiboot 2026-08-19 16:23:04 +02:00
Armel FAUVEAU 4c74e79a40 Gate multiboot restore progress bar behind MULTIBOOT_LOW_RAM 2026-08-19 16:21:06 +02:00
Armel FAUVEAU ec0d537b69 Add ENABLE_NO_HEAP to drop the unused reserved heap 2026-08-19 16:19:48 +02:00
Armel FAUVEAU 86e48f5f69 Add slot tag to SysInfo 2026-08-19 14:42:46 +02:00
Armel FAUVEAU 024bfbaaf3 Fix optional UART and USB builds 2026-08-19 05:06:46 +02:00
Armel FAUVEAU 7d8b95407f Document support policy for disabled preset options 2026-08-19 04:17:40 +02:00
Armel FAUVEAU 94f1f978ff Improve startup backlight fade visibility 2026-08-19 04:02:39 +02:00
Armel FAUVEAU 4cc5d11142 Fix VCP reply buffer alignment 2026-08-18 18:02:25 +02:00
Armel FAUVEAU c8b65bb9d8 Many multiboot fix 2026-08-18 17:03:44 +02:00
Armel FAUVEAU 78f5b76a71 Multiboot: per-slot config profiles + restorable Main backup 2026-08-18 03:57:29 +02:00
Armel FAUVEAU 295d4a2c05 Change version 2026-08-17 02:44:28 +02:00
Armel FAUVEAU 435192e3bf Merge pull request #511 from armel/feature_update_v5
Feature update v5
2026-08-14 02:27:10 +02:00
Armel FAUVEAU 9c86a7c349 Prepare v5.9.0 2026-08-14 02:21:59 +02:00
Armel FAUVEAU 0a61a6de12 Update rolling Fusion development firmware 2026-08-13 20:18:09 +02:00
EthanYan6 1021ee5e18 FM scan ignore channel RX 2026-08-13 20:04:41 +02:00
EthanYan6 bef97f787b FM scan ignore channel RX 2026-08-13 19:53:28 +02:00
Armel FAUVEAU ca06478e24 Improve UI 2026-08-12 12:40:12 +02:00
Armel FAUVEAU 919a687936 Refine multiboot presets, UI and build output 2026-08-12 03:25:53 +02:00
Armel FAUVEAU a2551d97dc Improve mb 2026-08-11 22:33:33 +02:00
Armel FAUVEAU a25d2fc75e Add mb firmware slots 2026-08-11 22:33:33 +02:00
Armel FAUVEAU 6692b5191b Update rolling Fusion development firmware 2026-08-11 22:24:36 +02:00
Armel FAUVEAU c65db46bad Add side-key action picker (F + hold side key) 2026-08-11 22:12:25 +02:00
Armel FAUVEAU a38f611bd5 Update rolling Fusion development firmware 2026-08-09 23:51:51 +02:00
Armel FAUVEAU 528678a31b Hold scan resume during scan list name (memory scan only) 2026-08-09 23:44:40 +02:00
Armel FAUVEAU abc146a1b6 Add 2 digits 2026-08-09 17:45:20 +02:00
Armel FAUVEAU c7ff3eeb66 Update rolling Fusion development firmware 2026-08-09 16:55:51 +02:00
Armel FAUVEAU a3c72bdd93 Add menu categories with All fallback 2026-08-09 16:54:45 +02:00
Armel FAUVEAU e4408fcb55 Fix unused searchStart warning in chFrScanner.c 2026-08-09 16:46:58 +02:00
Armel FAUVEAU 5715cd27ef Fix compile-with-docker.sh: pass extra CMake args safely 2026-08-09 16:37:18 +02:00
Armel FAUVEAU 253104bb87 Update rolling Fusion development firmware 2026-08-08 23:37:55 +02:00
Armel FAUVEAU debf67a027 Add Fox Hunt / Beacon keypad lock (long-press F, Discussion #498) 2026-08-08 23:36:38 +02:00
Armel FAUVEAU 535ee80d2b Update rolling Fusion development firmware 2026-08-08 01:34:34 +02:00
Armel FAUVEAU 559460739e Fix Fox Hunt: disable SetOff auto power-off in this mode 2026-08-08 01:33:34 +02:00
Armel FAUVEAU 0d69388e6d Update rolling Fusion development firmware 2026-08-06 04:45:44 +02:00
Armel FAUVEAU 9bf075ea22 Add experiment BYP/BYP+ mode 2026-08-06 04:45:11 +02:00
Armel FAUVEAU fe9c4e9432 Merge pull request #484 from armel/feature_update_v5
Feature update v5
2026-08-04 17:45:07 +02:00
Armel FAUVEAU 1e4698409b Prepare new version 5.8.0 2026-08-04 17:15:56 +02:00
Armel FAUVEAU 39f46a8f93 Update donors 2026-08-04 14:41:27 +02:00
Armel FAUVEAU 3518ab8294 Fix restart command during Beacon TX 2026-08-04 04:09:02 +02:00
Armel FAUVEAU c45b7d2deb Update README 2026-08-04 03:33:12 +02:00
Armel FAUVEAU f904b87172 Update README 2026-08-04 03:28:16 +02:00
Armel FAUVEAU 926787ddf3 Trim Fox Hunt buffers and drop hot-path divisions (-20 B flash) 2026-08-04 02:22:58 +02:00
Armel FAUVEAU fb9491caa5 Factor duplicated Fox Hunt helpers (-32 B flash) 2026-08-04 01:44:35 +02:00
Armel FAUVEAU 04e8d47130 Add F + number reverse-cycle and Beacon TX bitmap 2026-08-04 01:16:06 +02:00
Armel FAUVEAU 3bdddce67b Rework Fox Hunt/Beacon for ARDF: fox IDs, TX window, unified keys 2026-08-03 23:35:02 +02:00
Armel FAUVEAU d5cde97e5b Update rolling Fusion development firmware 2026-08-03 01:18:55 +02:00
Armel FAUVEAU 66ed14dead Show progressive CW beacon text 2026-08-03 01:18:25 +02:00
Armel FAUVEAU 602dfad6f3 Update rolling Fusion development firmware 2026-08-02 17:35:46 +02:00
Armel FAUVEAU 0203586cdc Fix RX mode restore after scan transitions (Issue #480) 2026-08-02 17:35:09 +02:00
Armel FAUVEAU 3e5795255d Update rolling Fusion development firmware 2026-08-02 05:51:35 +02:00
Armel FAUVEAU a8912d3208 Fix: handle restart in modal apps 2026-08-02 05:50:40 +02:00
Armel FAUVEAU fe602b075d Update rolling Fusion development firmware 2026-08-02 05:23:43 +02:00
Armel FAUVEAU e445cfca85 Fox Hunt: factor shared beacon draw code, -156 B flash 2026-08-02 05:22:37 +02:00
Armel FAUVEAU 18e9de38e0 Update rolling Fusion development firmware 2026-08-02 05:03:14 +02:00
Armel FAUVEAU f371795ef8 Update next version 2026-08-02 05:02:48 +02:00
Armel FAUVEAU e9181c2287 Add Fox Hunt signal-history gauge and persistent settings 2026-08-02 05:01:20 +02:00
Armel FAUVEAU 5437bf2d8e Update rolling Fusion development firmware 2026-08-01 23:41:34 +02:00
Armel FAUVEAU b972e026ca Enforce TX gates in Beacon mode: F LOCK, TX LOCK, battery, AM (Issue #478) 2026-08-01 23:40:20 +02:00
Armel FAUVEAU 0cad22f369 Update donors 2026-08-01 18:33:57 +02:00
Armel FAUVEAU 2a13c1819d Update rolling Fusion development firmware 2026-08-01 16:14:56 +02:00
Armel FAUVEAU 4799eb46ac Fix RX mode restore after Close Call (Issue #478) 2026-08-01 16:14:08 +02:00
Armel FAUVEAU 2569e4649d Update rolling Fusion development firmware 2026-08-01 15:36:21 +02:00
Armel FAUVEAU a59f3edcff Add Beacon mode 2026-08-01 15:35:46 +02:00
Armel FAUVEAU 027240de54 Update rolling Fusion development firmware 2026-08-01 03:38:43 +02:00
Armel FAUVEAU 73f44911ed Add Fox Hunt mode 2026-08-01 03:38:03 +02:00
Armel FAUVEAU c0e3909853 Update rolling Fusion development firmware 2026-07-31 02:37:14 +02:00
Armel FAUVEAU 31ff3b4df0 Remove dead empty block 2026-07-31 02:36:23 +02:00
Armel FAUVEAU bbee375623 Update rolling Fusion development firmware 2026-07-30 19:13:04 +02:00
Armel FAUVEAU 12dfa8a87f Optimize RAM usage by making lookup tables const 2026-07-30 19:05:16 +02:00
Armel FAUVEAU c9da65117f Avoid unnecessary flash sector erases 2026-07-30 02:56:06 +02:00
Armel FAUVEAU 222448e1ad Avoid full-buffer channel attribute writes 2026-07-30 02:32:13 +02:00
Armel FAUVEAU e1610bcc6f Avoid fast scan RSSI and chevron overlap 2026-07-29 23:23:30 +02:00
Armel FAUVEAU 7f906300e3 Add opt-in Fusion development firmware build 2026-07-28 18:33:19 +02:00
Armel FAUVEAU bf7eda8a48 Fix false Remove Offset indicator after disabling reverse (issue #469) 2026-07-27 17:09:39 +02:00
Armel FAUVEAU 2ee1d96e5c Smooth power-on backlight fade with smoothstep easing (~470 ms) 2026-07-26 16:53:15 +02:00
Armel FAUVEAU 65ec12bb0d Show active scan list name when it changes during scan (discussion #462) 2026-07-22 04:05:07 +02:00
Armel FAUVEAU dd3fc786b5 Gate RF logging on key assignment 2026-07-20 22:29:15 +02:00
Armel FAUVEAU 10868858c5 Update README 2026-07-19 15:50:34 +02:00
Armel FAUVEAU 3bd3ebba2c Merge pull request #457 from armel/feature_update_v5
Feature update v5
2026-07-19 01:41:58 +02:00
Armel FAUVEAU 39cbbd7e90 Prepare new version v5.7.0 2026-07-19 01:40:08 +02:00
Armel FAUVEAU 0567f01786 Keep RF log indexes traffic-only 2026-07-15 04:55:06 +02:00
Armel FAUVEAU fc12a35418 Update .gitignore 2026-07-15 04:55:06 +02:00
Armel FAUVEAU 2580acfb07 Give session markers a trafficSeq and stream them to K5Viewer 2026-07-13 04:27:33 +02:00
Armel FAUVEAU 56a6083689 Stream paginated RF log history to K5Viewer 2026-07-12 05:15:57 +02:00
Armel FAUVEAU 29725ed704 Allow unlock from RF LOG display 2026-07-11 03:26:47 +02:00
Armel FAUVEAU b44df132d0 Add ACTIONS lock scope to SetLck 2026-07-11 03:07:08 +02:00
Armel FAUVEAU 676f26be34 Remove legacy Python k5viewer tool 2026-07-09 05:25:42 +02:00
Armel FAUVEAU 46034ec39f Rename screenshot module to k5viewer 2026-07-09 05:22:39 +02:00
Armel FAUVEAU e2d21c61c2 Add optional RX/TX log streaming to K5Viewer 2026-07-09 05:12:57 +02:00
Armel FAUVEAU f8e1b7a436 Increase the scanrange skip list from 32 to 64 entries 2026-07-08 03:41:27 +02:00
Armel FAUVEAU 9447eaada9 Add secure clear log confirmation flow 2026-07-07 14:15:02 +02:00
Armel FAUVEAU 43533c838b Reorder RX/TX log flash layout to shrink the view cache 2026-07-07 02:56:22 +02:00
Armel FAUVEAU 7306db5f8f Show TX power level for TX rows in RX/TX log S-meter view 2026-07-07 02:36:16 +02:00
Armel FAUVEAU fbd7b37c21 Log minimum battery voltage per session in RX/TX log 2026-07-07 02:30:15 +02:00
Armel FAUVEAU 8d3754b42f Center S-meter text in the RX/TX log detail badge 2026-07-07 02:18:58 +02:00
Armel FAUVEAU 6a556e0601 Add S-meter to RX/TX log 2026-07-07 00:13:50 +02:00
Armel FAUVEAU 9c1b1cb063 Remove unused REGA emergency alarm feature (ENABLE_REGA) 2026-07-06 15:25:29 +02:00
Armel FAUVEAU ae7bf65383 Ignore squelch-only incoming state in RX/TX log 2026-07-06 12:22:50 +02:00
Armel FAUVEAU fb1360cce2 Replace unreachable RX/TX log EEPROM mapping with documentation comment 2026-07-06 03:52:57 +02:00
Armel FAUVEAU 374e52c4d1 Resolve RX/TX log channel names at display time instead of storing them 2026-07-06 03:35:54 +02:00
Armel FAUVEAU 42aeac3469 Move left by 1 px 2026-07-06 03:18:22 +02:00
Armel FAUVEAU 98ac57b032 Keep status bar layout stable when RX/TX log filter label is shown 2026-07-06 00:11:49 +02:00
Armel FAUVEAU 3e6e7c87b5 Add F + UP/DOWN shortcuts to jump to first/last RX/TX log entry 2026-07-05 23:28:31 +02:00
Armel FAUVEAU e908eda1da Make RX/TX log wrap-around scrolling optional via ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP 2026-07-05 23:19:00 +02:00
Armel FAUVEAU 4fccdb78b9 Dedupe RX/TX log entry copies by aligning RAM and flash struct layouts 2026-07-05 23:01:27 +02:00
Armel FAUVEAU a14127867e Remove redundant name sanitization already done by SETTINGS_FetchChannelName 2026-07-05 22:48:40 +02:00
Armel FAUVEAU c02aac265a Remove unused KEY_STAR 2026-07-05 22:38:36 +02:00
Armel FAUVEAU c24592cbe8 Factor RX/TX log empty screen rendering 2026-07-05 22:33:33 +02:00
Armel FAUVEAU ff096e12e5 Simplify RX/TX log badge numbering and dedupe repeated reset/stop code 2026-07-05 21:01:31 +02:00
Armel FAUVEAU 5fcc1598dd Fix RX/TX log wrap upward before total is known 2026-07-05 20:02:35 +02:00
Armel FAUVEAU 9b8493b450 Fix RX/TX log end-of-list scrolling 2026-07-05 19:16:35 +02:00
Armel FAUVEAU f1df96f1f6 Keep RX/TX log numbering continuous across sessions 2026-07-05 18:44:32 +02:00
Armel FAUVEAU f36c524fa7 Expose 512 entries in RX/TX log view instead of 250 2026-07-05 18:39:40 +02:00
Armel FAUVEAU 9ab5542326 Fix duplicate session marker on repeated reboots without traffic 2026-07-05 18:16:49 +02:00
Armel FAUVEAU 0187c05e65 Add RX/TX history log stored in external flash 2026-07-05 17:59:58 +02:00
Armel FAUVEAU 914c354da0 Update donors 2026-07-02 00:57:02 +02:00
Armel FAUVEAU a771387edd Merge pull request #437 from armel/feature_update_v5
Feature update v5
2026-06-30 16:06:49 +02:00
Armel FAUVEAU 09bc14e64b Add NOTICE 2026-06-30 16:05:51 +02:00
Armel FAUVEAU 58c0170fb1 Fix auto key lock timer on startup (issue #431) 2026-06-29 15:09:55 +02:00
258 changed files with 32997 additions and 4231 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+7 -4
View File
@@ -14,11 +14,14 @@ jobs:
- name: Compile firmware
run: |
chmod +x compile-with-docker.sh
./compile-with-docker.sh
chmod +x compile-firmware.sh compile-app.sh
./compile-firmware.sh All
./compile-app.sh All
- name: Upload firmware artifact
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: firmware-artifact
path: compiled-firmware/f4hwn.packed.bin
path: |
build/*/*.bin
build/Apps/*.app
+11 -1
View File
@@ -8,6 +8,7 @@ firmware
compile_commands.json
.vscode
.claude
.agents
/docs
k5_eeprom.raw
@@ -15,4 +16,13 @@ k5_eeprom.raw
__pycache__/
.DS_Store
AGENTS.md
AGENTS.md
# Overlay-app build artifacts (generated by build.sh / compile-app.sh)
App/apps/*/*.elf
App/apps/*/*.bin
App/apps/*/*.map
App/apps/*/*.app
App/apps/*/*_app_blob.h
App/apps/*/*_assets.h
build/apps/
+110 -16
View File
@@ -5,6 +5,8 @@ target_link_libraries(App INTERFACE PY32F071_Driver)
target_compile_definitions(App INTERFACE PRINTF_INCLUDE_CONFIG_H)
target_sources(App INTERFACE
# libgcc override (signed division)
compact_div.S
# Drivers
driver/backlight.c
driver/bk4829.c
@@ -70,6 +72,7 @@ if(ENABLE_FEAT_F4HWN)
"AUTHOR_STRING_2=\"${AUTHOR_STRING_2}\""
"VERSION_STRING_1=\"${VERSION_STRING_1}\""
"VERSION_STRING_2=\"${VERSION_STRING_2}\""
"DISPLAY_VERSION_STRING_2=\"${DISPLAY_VERSION_STRING_2}\""
"EDITION_STRING=\"${EDITION_STRING}\""
ALERT_TOT=10
)
@@ -94,8 +97,7 @@ if(ENABLE_AIRCOPY OR ENABLE_UART OR ENABLE_USB)
endif()
enable_feature(ENABLE_UART
driver/uart.c
app/uart.c
driver/uart.c
# driver/aes.c
)
@@ -117,21 +119,34 @@ endif()
# ---- STOCK QUANSHENG FEATURES ----
# ENABLE_FMRADIO keeps the BK1080 driver and the FM data/helpers shared with the
# overlay. ENABLE_FMRADIO_EMBEDDED additionally selects the legacy resident
# state machine and its integration with the main scheduler.
enable_feature(ENABLE_FMRADIO
driver/bk1080.c
app/fm.c
ui/fmradio.c
driver/bk1080.c
app/fm.c
)
if(ENABLE_FMRADIO AND NOT ENABLE_FEAT_F4HWN_OVERLAY_APPS)
target_compile_definitions(App INTERFACE ENABLE_FMRADIO_EMBEDDED)
target_sources(App INTERFACE ui/fmradio.c)
endif()
enable_feature(ENABLE_AIRCOPY
app/aircopy.c
ui/aircopy.c
)
enable_feature(ENABLE_AIRCOPY_UART)
if(ENABLE_AIRCOPY_UART AND NOT (ENABLE_AIRCOPY AND ENABLE_UART))
message(FATAL_ERROR "ENABLE_AIRCOPY_UART requires ENABLE_AIRCOPY and ENABLE_UART.")
endif()
enable_feature(ENABLE_AIRCOPY_FLASH)
if(ENABLE_AIRCOPY_FLASH AND NOT (ENABLE_AIRCOPY_UART AND ENABLE_FEAT_F4HWN_MULTIBOOT))
message(FATAL_ERROR "ENABLE_AIRCOPY_FLASH requires ENABLE_AIRCOPY_UART and ENABLE_FEAT_F4HWN_MULTIBOOT.")
endif()
enable_feature(ENABLE_NOAA)
enable_feature(ENABLE_VOICE
driver/voice.c
)
enable_feature(ENABLE_VOX)
enable_feature(ENABLE_ALARM)
enable_feature(ENABLE_TX1750)
enable_feature(ENABLE_PWRON_PASSWORD
ui/lock.c
@@ -151,7 +166,6 @@ enable_feature(ENABLE_SMALL_BOLD)
enable_feature(ENABLE_CUSTOM_MENU_LAYOUT)
enable_feature(ENABLE_KEEP_MEM_NAME)
enable_feature(ENABLE_WIDE_RX)
enable_feature(ENABLE_TX_WHEN_AM)
enable_feature(ENABLE_F_CAL_MENU)
enable_feature(ENABLE_CTCSS_TAIL_PHASE_SHIFT)
enable_feature(ENABLE_BOOT_BEEPS)
@@ -166,26 +180,55 @@ enable_feature(ENABLE_FEAT_F4HWN_AUDIO_SCOPE)
enable_feature(ENABLE_COPY_CHAN_TO_VFO)
enable_feature(ENABLE_REDUCE_LOW_MID_TX_POWER)
enable_feature(ENABLE_BYP_RAW_DEMODULATORS)
enable_feature(ENABLE_BLMIN_TMP_OFF)
enable_feature(ENABLE_SCAN_RANGES)
# ---- CONTRIB MODS ----
# Thank you @markusb
enable_feature(ENABLE_REGA
app/rega.c
)
# Thank you @reppad
enable_feature(ENABLE_EXTRA_UART_CMD)
# ---- F4HWN MODS ----
if(NOT ENABLE_UART AND NOT ENABLE_USB)
if(ENABLE_FEAT_F4HWN_K5VIEWER OR ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER)
message(STATUS "Disabling K5Viewer because both ENABLE_UART and ENABLE_USB are OFF.")
endif()
set(ENABLE_FEAT_F4HWN_K5VIEWER OFF)
set(ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER OFF)
endif()
enable_feature(ENABLE_FEAT_F4HWN)
enable_feature(ENABLE_FEAT_F4HWN_GAME
app/breakout.c
enable_feature(ENABLE_FEAT_F4HWN_FULL_WATCH)
if(ENABLE_FEAT_F4HWN_FULL_WATCH AND NOT ENABLE_FEAT_F4HWN)
message(FATAL_ERROR "ENABLE_FEAT_F4HWN_FULL_WATCH requires ENABLE_FEAT_F4HWN.")
endif()
# GAME keeps the F+7 hook; the resident breakout.c is compiled only when the
# overlay does NOT provide Breakout as an app (that is the flash we recover).
if(ENABLE_FEAT_F4HWN_GAME)
target_compile_definitions(App INTERFACE ENABLE_FEAT_F4HWN_GAME)
if(NOT ENABLE_FEAT_F4HWN_OVERLAY_APPS)
target_sources(App INTERFACE app/breakout.c)
endif()
endif()
enable_feature(ENABLE_FEAT_F4HWN_OVERLAY_APPS
apps/app_overlay.c
apps/app_menu.c
)
enable_feature(ENABLE_FEAT_F4HWN_SCREENSHOT
screenshot.c
enable_feature(ENABLE_FEAT_F4HWN_OVERLAY_BEAM)
enable_feature(ENABLE_FEAT_F4HWN_OVERLAY_INFO
stack_usage.c
)
if(ENABLE_FEAT_F4HWN_OVERLAY_APPS AND NOT ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY)
message(FATAL_ERROR "ENABLE_FEAT_F4HWN_OVERLAY_APPS requires ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY (the 4 KiB overlay workspace).")
endif()
if(ENABLE_FEAT_F4HWN_OVERLAY_BEAM AND NOT ENABLE_FEAT_F4HWN_OVERLAY_APPS)
message(FATAL_ERROR "Overlay Beam service requires ENABLE_FEAT_F4HWN_OVERLAY_APPS.")
endif()
if(ENABLE_FEAT_F4HWN_OVERLAY_INFO AND NOT ENABLE_FEAT_F4HWN_OVERLAY_APPS)
message(FATAL_ERROR "Overlay system information requires ENABLE_FEAT_F4HWN_OVERLAY_APPS.")
endif()
enable_feature(ENABLE_FEAT_F4HWN_K5VIEWER
k5viewer.c
)
enable_feature(ENABLE_FEAT_F4HWN_SPECTRUM)
enable_feature(ENABLE_FEAT_F4HWN_RX_TX_TIMER)
@@ -211,18 +254,69 @@ enable_feature(ENABLE_FEAT_F4HWN_MEM)
enable_feature(ENABLE_FEAT_F4HWN_BEAM
app/beam.c
)
enable_feature(ENABLE_FEAT_F4HWN_RXTX_LOG
app/rxtx_log.c
)
enable_feature(ENABLE_FEAT_F4HWN_FOXHUNT)
enable_feature(ENABLE_FEAT_F4HWN_BEACON)
if(ENABLE_FEAT_F4HWN_FOXHUNT OR ENABLE_FEAT_F4HWN_BEACON)
target_sources(App INTERFACE app/foxhunt.c)
endif()
enable_feature(ENABLE_FEAT_F4HWN_ACTION_PICKER)
if(ENABLE_FEAT_F4HWN_ACTION_PICKER AND NOT ENABLE_FEAT_F4HWN)
message(FATAL_ERROR "ENABLE_FEAT_F4HWN_ACTION_PICKER requires ENABLE_FEAT_F4HWN.")
endif()
enable_feature(ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP)
enable_feature(ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER)
if(ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER AND NOT (ENABLE_FEAT_F4HWN_RXTX_LOG AND ENABLE_FEAT_F4HWN_K5VIEWER))
message(FATAL_ERROR "ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER requires ENABLE_FEAT_F4HWN_RXTX_LOG (log data) and ENABLE_FEAT_F4HWN_K5VIEWER (serial transport).")
endif()
if(ENABLE_FEAT_F4HWN_BEAM AND NOT ENABLE_AIRCOPY)
message(FATAL_ERROR "ENABLE_FEAT_F4HWN_BEAM requires ENABLE_AIRCOPY (it reuses g_FSK_Buffer, AIRCOPY_Obfuscate and the FSK packet plumbing).")
endif()
enable_feature(ENABLE_FEAT_F4HWN_MULTIBOOT
driver/mb_flash.c
ui/multiboot.c
)
if(ENABLE_FEAT_F4HWN_MULTIBOOT AND NOT ENABLE_FEAT_F4HWN)
message(FATAL_ERROR "ENABLE_FEAT_F4HWN_MULTIBOOT requires ENABLE_FEAT_F4HWN (the SetCfg menu, SysInfo badge and multiboot UI all live under ENABLE_FEAT_F4HWN).")
endif()
enable_feature(ENABLE_FEAT_F4HWN_MULTIBOOT_HOT_CFG)
if(ENABLE_FEAT_F4HWN_MULTIBOOT_HOT_CFG AND NOT ENABLE_FEAT_F4HWN_MULTIBOOT)
message(FATAL_ERROR "ENABLE_FEAT_F4HWN_MULTIBOOT_HOT_CFG requires ENABLE_FEAT_F4HWN_MULTIBOOT.")
endif()
enable_feature(ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY)
if(ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY AND NOT ENABLE_FEAT_F4HWN_MULTIBOOT)
message(FATAL_ERROR "ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY requires ENABLE_FEAT_F4HWN_MULTIBOOT.")
endif()
# External-flash dump / restore over serial (host: UV Studio). Restore keeps the
# device-specific calibration sector protected. Adds the
# 0x0738 (read) / 0x073A (sector erase) / 0x073C (write) / 0x073E (CRC32) UART
# commands and the PY25Q16 physical-address helpers they use.
enable_feature(ENABLE_FEAT_F4HWN_EXT_FLASH_RW)
if(ENABLE_FEAT_F4HWN_EXT_FLASH_RW AND NOT ENABLE_FEAT_F4HWN_MULTIBOOT)
message(FATAL_ERROR "ENABLE_FEAT_F4HWN_EXT_FLASH_RW requires ENABLE_FEAT_F4HWN_MULTIBOOT (the 0x0738/0x073A/0x073C/0x073E UART commands live under multiboot).")
endif()
enable_feature(ENABLE_FEAT_F4HWN_QRCODE)
enable_feature(ENABLE_FEAT_F4HWN_LOGO)
enable_feature(ENABLE_FEAT_F4HWN_LOGO_SAV)
enable_feature(ENABLE_FEAT_F4HWN_MENU_CAT)
if(ENABLE_FEAT_F4HWN_LOGO_SAV AND NOT ENABLE_FEAT_F4HWN_LOGO)
message(FATAL_ERROR "ENABLE_FEAT_F4HWN_LOGO_SAV requires ENABLE_FEAT_F4HWN_LOGO.")
endif()
if(ENABLE_FEAT_F4HWN_MENU_CAT AND NOT ENABLE_FEAT_F4HWN)
message(FATAL_ERROR "ENABLE_FEAT_F4HWN_MENU_CAT requires ENABLE_FEAT_F4HWN.")
endif()
# ---- DEBUGGING ----
enable_feature(ENABLE_AGC_SHOW_DATA)
-397
View File
@@ -1,397 +0,0 @@
/* Copyright 2023 OneOfEleven
* https://github.com/DualTachyon
*
* 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.
*/
// code to 'try' and reduce the AM demodulator saturation problem
//
// that is until someone works out how to properly configure the BK chip !
#include <string.h>
#include "am_fix.h"
#include "app/main.h"
#include "board.h"
#include "driver/bk4819.h"
#include "external/printf/printf.h"
#include "frequencies.h"
#include "functions.h"
#include "misc.h"
#include "settings.h"
#ifdef ENABLE_AGC_SHOW_DATA
#include "ui/main.h"
#endif
#ifdef ENABLE_AM_FIX
typedef struct
{
uint16_t reg_val;
int8_t gain_dB;
} __attribute__((packed)) t_gain_table;
// REG_10 AGC gain table
//
// <15:10> ???
//
// <9:8> = LNA Gain Short
// 3 = 0dB < original value
// 2 = -19dB // was -11
// 1 = -24dB // was -16
// 0 = -28dB // was -19
//
// <7:5> = LNA Gain
// 7 = 0dB
// 6 = -2dB
// 5 = -4dB < original value
// 4 = -6dB
// 3 = -9dB
// 2 = -14dB
// 1 = -19dB
// 0 = -24dB
//
// <4:3> = MIXER Gain
// 3 = 0dB < original value
// 2 = -3dB
// 1 = -6dB
// 0 = -8dB
//
// <2:0> = PGA Gain
// 7 = 0dB
// 6 = -3dB < original value
// 5 = -6dB
// 4 = -9dB
// 3 = -15dB
// 2 = -21dB
// 1 = -27dB
// 0 = -33dB
// front end register dB values
//
// these values need to be accurate for the code to properly/reliably switch
// between table entries when adjusting the front end registers.
//
// these 4 tables need a measuring/calibration update
//
//// static const int16_t lna_short_dB[] = { -19, -16, -11, 0}; // was (but wrong)
// static const int16_t lna_short_dB[] = { (-28), (-24), (-19), 0}; // corrected'ish
// static const int16_t lna_dB[] = { (-24), (-19), (-14), ( -9), (-6), (-4), (-2), 0};
// static const int16_t mixer_dB[] = { ( -8), ( -6), ( -3), 0};
// static const int16_t pga_dB[] = { (-33), (-27), (-21), (-15), (-9), (-6), (-3), 0};
// lookup table is hugely easier than writing code to do the same
//
#define LOOKUP_TABLE 1
#if LOOKUP_TABLE
static const t_gain_table gain_table[] =
{
{0x03BE, -7}, // 0 .. 3 5 3 6 .. 0dB -4dB 0dB -3dB .. -7dB original
{0x0000,-93}, // 1 .. 0 0 0 0 .. -28dB -24dB -8dB -33dB .. -93dB
{0x0008,-91}, // 2 .. 0 0 1 0 .. -28dB -24dB -6dB -33dB .. -91dB
{0x0010,-88}, // 3 .. 0 0 2 0 .. -28dB -24dB -3dB -33dB .. -88dB
{0x0001,-87}, // 4 .. 0 0 0 1 .. -28dB -24dB -8dB -27dB .. -87dB
{0x0009,-85}, // 5 .. 0 0 1 1 .. -28dB -24dB -6dB -27dB .. -85dB
{0x0011,-82}, // 6 .. 0 0 2 1 .. -28dB -24dB -3dB -27dB .. -82dB
{0x0002,-81}, // 7 .. 0 0 0 2 .. -28dB -24dB -8dB -21dB .. -81dB
{0x000A,-79}, // 8 .. 0 0 1 2 .. -28dB -24dB -6dB -21dB .. -79dB
{0x0012,-76}, // 9 .. 0 0 2 2 .. -28dB -24dB -3dB -21dB .. -76dB
{0x0003,-75}, // 10 .. 0 0 0 3 .. -28dB -24dB -8dB -15dB .. -75dB
{0x000B,-73}, // 11 .. 0 0 1 3 .. -28dB -24dB -6dB -15dB .. -73dB
{0x0013,-70}, // 12 .. 0 0 2 3 .. -28dB -24dB -3dB -15dB .. -70dB
{0x0004,-69}, // 13 .. 0 0 0 4 .. -28dB -24dB -8dB -9dB .. -69dB
{0x000C,-67}, // 14 .. 0 0 1 4 .. -28dB -24dB -6dB -9dB .. -67dB
{0x000D,-64}, // 15 .. 0 0 1 5 .. -28dB -24dB -6dB -6dB .. -64dB
{0x001C,-61}, // 16 .. 0 0 3 4 .. -28dB -24dB 0dB - 9dB .. -61dB
{0x001D,-58}, // 17 .. 0 0 3 5 .. -28dB -24dB 0dB -6dB .. -58dB
{0x001E,-55}, // 18 .. 0 0 3 6 .. -28dB -24dB 0dB -3dB .. -55dB
{0x001F,-52}, // 19 .. 0 0 3 7 .. -28dB -24dB 0dB 0dB .. -52dB
{0x003E,-50}, // 20 .. 0 1 3 6 .. -28dB -19dB 0dB -3dB .. -50dB
{0x003F,-47}, // 21 .. 0 1 3 7 .. -28dB -19dB 0dB 0dB .. -47dB
{0x005E,-45}, // 22 .. 0 2 3 6 .. -28dB -14dB 0dB -3dB .. -45dB
{0x005F,-42}, // 23 .. 0 2 3 7 .. -28dB -14dB 0dB 0dB .. -42dB
{0x007E,-40}, // 24 .. 0 3 3 6 .. -28dB -9dB 0dB -3dB .. -40dB
{0x007F,-37}, // 25 .. 0 3 3 7 .. -28dB -9dB 0dB 0dB .. -37dB
{0x009F,-34}, // 26 .. 0 4 3 7 .. -28dB -6dB 0dB 0dB .. -34dB
{0x00BF,-32}, // 27 .. 0 5 3 7 .. -28dB -4dB 0dB 0dB .. -32dB
{0x00DF,-30}, // 28 .. 0 6 3 7 .. -28dB -2dB 0dB 0dB .. -30dB
{0x00FF,-28}, // 29 .. 0 7 3 7 .. -28dB 0dB 0dB 0dB .. -28dB
{0x01DF,-26}, // 30 .. 1 6 3 7 .. -24dB -2dB 0dB 0dB .. -26dB
{0x01FF,-24}, // 31 .. 1 7 3 7 .. -24dB 0dB 0dB 0dB .. -24dB
{0x02BF,-23}, // 32 .. 2 5 3 7 .. -19dB -4dB 0dB 0dB .. -23dB
{0x02DF,-21}, // 33 .. 2 6 3 7 .. -19dB -2dB 0dB -0dB .. -21dB
{0x02FF,-19}, // 34 .. 2 7 3 7 .. -19dB 0dB 0dB 0dB .. -19dB
{0x035E,-17}, // 35 .. 3 2 3 6 .. 0dB -14dB 0dB -3dB .. -17dB
{0x035F,-14}, // 36 .. 3 2 3 7 .. 0dB -14dB 0dB 0dB .. -14dB
{0x037E,-12}, // 37 .. 3 3 3 6 .. 0dB -9dB 0dB -3dB .. -12dB
{0x037F,-9}, // 38 .. 3 3 3 7 .. 0dB -9dB 0dB 0dB .. -9dB
{0x038F,-6}, // 39 .. 3 4 3 7 .. 0dB - 6dB 0dB 0dB .. -6dB
{0x03BF,-4}, // 40 .. 3 5 3 7 .. 0dB -4dB 0dB 0dB .. -4dB
{0x03DF,-2}, // 41 .. 3 6 3 7 .. 0dB - 2dB 0dB 0dB .. -2dB
{0x03FF,0} // 42 .. 3 7 3 7 .. 0dB 0dB 0dB 0dB .. 0dB
};
const uint8_t gain_table_size = ARRAY_SIZE(gain_table);
#else
t_gain_table gain_table[100] = {{0x03BE, -7}}; //original
uint8_t gain_table_size = 0;
void CreateTable()
{
typedef union {
struct {
uint8_t pgaIdx:3;
uint8_t mixerIdx:2;
uint8_t lnaIdx:3;
uint8_t lnaSIdx:2;
};
uint16_t __raw;
} GainData;
static const int8_t lna_short_dB[] = {-28, -24, -19, 0}; // corrected'ish
static const int8_t lna_dB[] = {-24, -19, -14, -9, -6, -4, -2, 0};
static const int8_t mixer_dB[] = { -8, -6, -3, 0};
static const int8_t pga_dB[] = {-33, -27, -21, -15, -9, -6, -3, 0};
unsigned i;
for (uint8_t lnaSIdx = 0; lnaSIdx < ARRAY_SIZE(lna_short_dB); lnaSIdx++) {
for (uint8_t lnaIdx = 0; lnaIdx < ARRAY_SIZE(lna_dB); lnaIdx++) {
for (uint8_t mixerIdx = 0; mixerIdx < ARRAY_SIZE(mixer_dB); mixerIdx++) {
for (uint8_t pgaIdx = 0; pgaIdx < ARRAY_SIZE(pga_dB); pgaIdx++) {
int16_t db = lna_short_dB[lnaSIdx] + lna_dB[lnaIdx] + mixer_dB[mixerIdx] + pga_dB[pgaIdx];
GainData gainData = {{
pgaIdx,
mixerIdx,
lnaIdx,
lnaSIdx,
}};
for (i = 1; i < ARRAY_SIZE(gain_table); i++) {
t_gain_table * gain = &gain_table[i];
if (db == gain->gain_dB)
break;
if (db > gain->gain_dB)
continue;
if (db < gain->gain_dB) {
if(gain->gain_dB)
memmove(gain + 1, gain, 100 - i);
gain->gain_dB = db;
gain->reg_val = gainData.__raw;
break;
}
gain->gain_dB = db;
gain->reg_val = gainData.__raw;
break;
}
}
}
}
}
gain_table_size = i+1;
}
#endif
#ifdef ENABLE_AM_FIX_SHOW_DATA
// display update rate
static const unsigned int display_update_rate = 250 / 10; // max 250ms display update rate
unsigned int counter = 0;
#endif
unsigned int gain_table_index[2] = {0, 0};
// used simply to detect a changed gain setting
unsigned int gain_table_index_prev[2] = {0, 0};
// holds the previous RSSI level .. we do an average of old + new RSSI reading
int16_t prev_rssi[2] = {0, 0};
// to help reduce gain hunting, peak hold count down tick
unsigned int hold_counter[2] = {0, 0};
// -89dBm, any higher and the AM demodulator starts to saturate/clip/distort
const int16_t desired_rssi = (-89 + 160) * 2;
int8_t currentGainDiff;
bool enabled = true;
void AM_fix_init(void)
{ // called at boot-up
for (int i = 0; i < 2; i++) {
gain_table_index[i] = 0; // re-start with original QS setting
}
#if !LOOKUP_TABLE
CreateTable();
#endif
}
void AM_fix_reset(const unsigned vfo)
{ // reset the AM fixer upper
if (vfo > 1)
return;
#ifdef ENABLE_AM_FIX_SHOW_DATA
counter = 0;
#endif
prev_rssi[vfo] = 0;
hold_counter[vfo] = 0;
gain_table_index_prev[vfo] = 0;
}
// adjust the RX gain to try and prevent the AM demodulator from
// saturating/overloading/clipping (distorted AM audio)
//
// we're actually doing the BK4819's job for it here, but as the chip
// won't/don't do it for itself, we're left to bodging it ourself by
// playing with the RF front end gain setting
//
void AM_fix_10ms(const unsigned vfo)
{
if(!gSetting_AM_fix || !enabled || vfo > 1 )
return;
if (gCurrentFunction != FUNCTION_FOREGROUND && !FUNCTION_IsRx()) {
#ifdef ENABLE_AM_FIX_SHOW_DATA
counter = display_update_rate; // queue up a display update as soon as we switch to RX mode
#endif
return;
}
#ifdef ENABLE_AM_FIX_SHOW_DATA
if (counter > 0) {
if (++counter >= display_update_rate) { // trigger a display update
counter = 0;
gUpdateDisplay = true;
}
}
#endif
static uint32_t lastFreq[2];
if(gEeprom.VfoInfo[vfo].pRX->Frequency != lastFreq[vfo]) {
lastFreq[vfo] = gEeprom.VfoInfo[vfo].pRX->Frequency;
AM_fix_reset(vfo);
}
int16_t rssi;
{ // sample the current RSSI level
// average it with the previous rssi (a bit of noise/spike immunity)
const int16_t new_rssi = BK4819_GetRSSI();
rssi = (prev_rssi[vfo] > 0) ? (prev_rssi[vfo] + new_rssi) / 2 : new_rssi;
prev_rssi[vfo] = new_rssi;
}
#ifdef ENABLE_AM_FIX_SHOW_DATA
{
static int16_t lastRssi;
if (lastRssi != rssi) { // rssi changed
lastRssi = rssi;
if (counter == 0) {
counter = 1;
gUpdateDisplay = true; // trigger a display update
}
}
}
#endif
// automatically adjust the RF RX gain
// update the gain hold counter
if (hold_counter[vfo] > 0)
hold_counter[vfo]--;
// dB difference between actual and desired RSSI level
int16_t diff_dB = (rssi - desired_rssi) / 2;
if (diff_dB > 0) { // decrease gain
unsigned int index = gain_table_index[vfo]; // current position we're at
if (diff_dB >= 10) { // jump immediately to a new gain setting
// this greatly speeds up initial gain reduction (but reduces noise/spike immunity)
const int16_t desired_gain_dB = (int16_t)gain_table[index].gain_dB - diff_dB + 8; // get no closer than 8dB (bit of noise/spike immunity)
// scan the table to see what index to jump straight too
while (index > 1)
if (gain_table[--index].gain_dB <= desired_gain_dB)
break;
}
else
{ // incrementally reduce the gain .. taking it slow improves noise/spike immunity
if (index > 1)
index--; // slow step-by-step gain reduction
}
index = MAX(1u, index);
if (gain_table_index[vfo] != index)
{
gain_table_index[vfo] = index;
hold_counter[vfo] = 30; // 300ms hold
}
}
if (diff_dB >= -6) // 6dB hysterisis (help reduce gain hunting)
hold_counter[vfo] = 30; // 300ms hold
if (hold_counter[vfo] == 0)
{ // hold has been released, we're free to increase gain
const unsigned int index = gain_table_index[vfo] + 1; // move up to next gain index
gain_table_index[vfo] = MIN(index, gain_table_size - 1u);
}
{ // apply the new settings to the front end registers
const unsigned int index = gain_table_index[vfo];
// remember the new table index
gain_table_index_prev[vfo] = index;
currentGainDiff = gain_table[0].gain_dB - gain_table[index].gain_dB;
BK4819_WriteRegister(BK4819_REG_13, gain_table[index].reg_val);
#ifdef ENABLE_AGC_SHOW_DATA
UI_MAIN_PrintAGC(true);
#endif
}
#ifdef ENABLE_AM_FIX_SHOW_DATA
if (counter == 0) {
counter = 1;
gUpdateDisplay = true;
}
#endif
}
#ifdef ENABLE_AM_FIX_SHOW_DATA
void AM_fix_print_data(const unsigned vfo, char *s) {
if (s != NULL && vfo < ARRAY_SIZE(gain_table_index)) {
const unsigned int index = gain_table_index[vfo];
sprintf(s, "%2u %4ddB %3u", index, gain_table[index].gain_dB, prev_rssi[vfo]);
counter = 0;
}
}
#endif
int8_t AM_fix_get_gain_diff()
{
return currentGainDiff;
}
void AM_fix_enable(bool on)
{
enabled = on;
}
#endif
+284 -120
View File
@@ -25,12 +25,15 @@
#ifdef ENABLE_FLASHLIGHT
#include "app/flashlight.h"
#endif
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
#include "app/fm.h"
#endif
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_APPS
#include "apps/app_overlay.h"
#endif
#include "app/scanner.h"
#include "audio.h"
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
#include "driver/bk1080.h"
#endif
#include "driver/bk4819.h"
@@ -42,26 +45,34 @@
#include "ui/inputbox.h"
#include "ui/main.h"
#include "ui/ui.h"
#ifdef ENABLE_REGA
#include "app/rega.h"
#endif
#ifdef ENABLE_FEAT_F4HWN_BEAM
#include "app/beam.h"
#endif
#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG
#include "app/rxtx_log.h"
#endif
#if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_BEACON) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS)
#include "app/foxhunt.h"
#endif
#ifdef ENABLE_FEAT_F4HWN_ACTION_PICKER
#include "ui/menu.h"
#endif
#if defined(ENABLE_FMRADIO)
#if defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) && !defined(ENABLE_FEAT_F4HWN_BEAM)
static void ACTION_Beam(void);
#endif
#if defined(ENABLE_FMRADIO_EMBEDDED)
static void ACTION_Scan_FM(bool bRestart);
#endif
#if defined(ENABLE_ALARM) || defined(ENABLE_TX1750)
static void ACTION_AlarmOr1750(bool b1750);
inline static void ACTION_Alarm() { ACTION_AlarmOr1750(false); }
inline static void ACTION_1750() { ACTION_AlarmOr1750(true); };
#ifdef ENABLE_TX1750
static void ACTION_1750(void);
#endif
inline static void ACTION_ScanRestart() { ACTION_Scan(true); };
void (*action_opt_table[])(void) = {
void (*const action_opt_table[ACTION_OPT_LEN])(void) = {
[ACTION_OPT_NONE] = &FUNCTION_NOP,
[ACTION_OPT_POWER] = &ACTION_Power,
[ACTION_OPT_MONITOR] = &ACTION_Monitor,
@@ -73,38 +84,18 @@ void (*action_opt_table[])(void) = {
#ifdef ENABLE_FLASHLIGHT
[ACTION_OPT_FLASHLIGHT] = &ACTION_FlashLight,
#else
[ACTION_OPT_FLASHLIGHT] = &FUNCTION_NOP,
#endif
#ifdef ENABLE_VOX
[ACTION_OPT_VOX] = &ACTION_Vox,
#else
[ACTION_OPT_VOX] = &FUNCTION_NOP,
#endif
#ifdef ENABLE_FMRADIO
[ACTION_OPT_FM] = &ACTION_FM,
#else
[ACTION_OPT_FM] = &FUNCTION_NOP,
#endif
#ifdef ENABLE_ALARM
[ACTION_OPT_ALARM] = &ACTION_Alarm,
#else
[ACTION_OPT_ALARM] = &FUNCTION_NOP,
#endif
#ifdef ENABLE_TX1750
[ACTION_OPT_1750] = &ACTION_1750,
#else
[ACTION_OPT_1750] = &FUNCTION_NOP,
#endif
#ifdef ENABLE_BLMIN_TMP_OFF
[ACTION_OPT_BLMIN_TMP_OFF] = &ACTION_BlminTmpOff,
#else
[ACTION_OPT_BLMIN_TMP_OFF] = &FUNCTION_NOP,
#endif
#ifdef ENABLE_FEAT_F4HWN
@@ -112,7 +103,6 @@ void (*action_opt_table[])(void) = {
[ACTION_OPT_MAINONLY] = &ACTION_MainOnly,
[ACTION_OPT_PTT] = &ACTION_Ptt,
[ACTION_OPT_WN] = &ACTION_Wn,
[ACTION_OPT_BACKLIGHT] = &ACTION_BackLight,
//#if !defined(ENABLE_SPECTRUM) || !defined(ENABLE_FMRADIO)
[ACTION_OPT_MUTE] = &ACTION_Mute,
//#else
@@ -120,27 +110,65 @@ void (*action_opt_table[])(void) = {
//#endif
#ifdef ENABLE_FEAT_F4HWN_AUDIO
[ACTION_OPT_RXA] = &ACTION_RxA,
#else
[ACTION_OPT_RXA] = &FUNCTION_NOP,
#endif
#ifdef ENABLE_FEAT_F4HWN_RESCUE_OPS
[ACTION_OPT_POWER_HIGH] = &ACTION_Power_High,
[ACTION_OPT_REMOVE_OFFSET] = &ACTION_Remove_Offset,
#endif
#else
[ACTION_OPT_RXMODE] = &FUNCTION_NOP,
#endif
#ifdef ENABLE_REGA
[ACTION_OPT_REGA_ALARM] = &ACTION_RegaAlarm,
[ACTION_OPT_REGA_TEST] = &ACTION_RegaTest,
#endif
#ifdef ENABLE_FEAT_F4HWN_BEAM
#if defined(ENABLE_FEAT_F4HWN_BEAM) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS)
[ACTION_OPT_BEAM] = &ACTION_Beam,
#endif
#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG
[ACTION_OPT_RXTX_LOG] = &ACTION_RxTxLog,
#endif
#if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS)
[ACTION_OPT_FOXHUNT] = &ACTION_FoxHunt,
#endif
#if defined(ENABLE_FEAT_F4HWN_BEACON) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS)
[ACTION_OPT_BEACON] = &ACTION_Beacon,
#endif
};
static_assert(ARRAY_SIZE(action_opt_table) == ACTION_OPT_LEN);
static_assert(ACTION_OPT_RXTX_LOG == 18);
static_assert(ACTION_OPT_BEAM == 19);
static_assert(ACTION_OPT_POWER_HIGH == 20);
static_assert(ACTION_OPT_REMOVE_OFFSET == 21);
static_assert(ACTION_OPT_FOXHUNT == 22);
static_assert(ACTION_OPT_BEACON == 23);
bool ACTION_IsAvailable(uint8_t action)
{
if (action >= ACTION_OPT_LEN || action_opt_table[action] == NULL)
return false;
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_APPS
switch (action) {
#ifdef ENABLE_FMRADIO
case ACTION_OPT_FM:
return (APP_OverlayShortcutMask() & APP_SHORTCUT_FM) != 0;
#endif
#ifndef ENABLE_FEAT_F4HWN_FOXHUNT
case ACTION_OPT_FOXHUNT:
return (APP_OverlayShortcutMask() & APP_SHORTCUT_FOXHUNT) != 0;
#endif
#ifndef ENABLE_FEAT_F4HWN_BEACON
case ACTION_OPT_BEACON:
return (APP_OverlayShortcutMask() & APP_SHORTCUT_BEACON) != 0;
#endif
#ifndef ENABLE_FEAT_F4HWN_BEAM
case ACTION_OPT_BEAM:
return (APP_OverlayShortcutMask() & APP_SHORTCUT_BEAM) != 0;
#endif
default:
break;
}
#endif
return true;
}
void ACTION_Power(void)
{
@@ -187,7 +215,7 @@ void ACTION_Monitor(void)
RADIO_SetupRegisters(true);
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
if (gFmRadioMode) {
FM_Start();
gRequestDisplayScreen = DISPLAY_FM;
@@ -201,7 +229,7 @@ void ACTION_Scan(bool bRestart)
{
(void)bRestart;
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
if (gFmRadioMode) {
ACTION_Scan_FM(bRestart);
return;
@@ -244,7 +272,7 @@ void ACTION_Scan(bool bRestart)
// channel mode. Keep scanning but toggle between scan lists
RADIO_NextValidList(1);
UI_MAIN_NotifyScanProgressDataChanged();
UI_MAIN_NotifyScanListChanged();
#ifdef ENABLE_FEAT_F4HWN_RESUME_STATE
SETTINGS_WriteCurrentState();
@@ -294,6 +322,123 @@ void ACTION_SwitchDemodul(void)
}
#ifdef ENABLE_FMRADIO_EMBEDDED
inline static bool ACTION_IsBlockedInFM(uint8_t action)
{
switch (action) {
case ACTION_OPT_POWER:
case ACTION_OPT_MONITOR:
case ACTION_OPT_A_B:
case ACTION_OPT_VFO_MR:
case ACTION_OPT_SWITCH_DEMODUL:
#ifdef ENABLE_VOX
case ACTION_OPT_VOX:
#endif
#ifdef ENABLE_FEAT_F4HWN
case ACTION_OPT_RXMODE:
case ACTION_OPT_MAINONLY:
case ACTION_OPT_WN:
#ifdef ENABLE_FEAT_F4HWN_AUDIO
case ACTION_OPT_RXA:
#endif
#ifdef ENABLE_FEAT_F4HWN_RESCUE_OPS
case ACTION_OPT_POWER_HIGH:
case ACTION_OPT_REMOVE_OFFSET:
#endif
#endif
#if defined(ENABLE_FEAT_F4HWN_BEAM) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS)
case ACTION_OPT_BEAM:
#endif
#if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS)
case ACTION_OPT_FOXHUNT:
#endif
#if defined(ENABLE_FEAT_F4HWN_BEACON) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS)
case ACTION_OPT_BEACON:
#endif
return true;
default:
return false;
}
}
#endif
static void ACTION_Execute(uint8_t action)
{
if (!ACTION_IsAvailable(action)) {
gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL;
return;
}
#ifdef ENABLE_FMRADIO_EMBEDDED
if (gFmRadioMode && ACTION_IsBlockedInFM(action)) {
gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL;
return;
}
#endif
gBeepToPlay = BEEP_1KHZ_60MS_OPTIONAL;
action_opt_table[action]();
}
#ifdef ENABLE_FEAT_F4HWN_ACTION_PICKER
uint8_t gActionPickerKey;
uint8_t gActionPickerSelection[2] = {1, 1};
uint8_t gActionPickerTimeout_500ms;
bool ACTION_PickerProcessKey(KEY_Code_t key, bool isPressed, bool isHeld)
{
if (gActionPickerKey == 0)
return false;
if (isPressed)
gActionPickerTimeout_500ms = ACTION_PICKER_TIMEOUT_500MS;
uint8_t *selection = &gActionPickerSelection[gActionPickerKey - 1];
switch (key) {
case KEY_UP:
case KEY_DOWN:
if (isPressed && !isHeld) {
if (key == KEY_UP) {
if (--*selection == 0)
*selection = SIDEFUNCTION_COUNT - 1;
}
else if (++*selection >= SIDEFUNCTION_COUNT) {
*selection = 1;
}
gBeepToPlay = BEEP_1KHZ_60MS_OPTIONAL;
gUpdateDisplay = true;
}
return true;
case KEY_MENU:
if (!isPressed && !isHeld) {
const uint8_t action = *selection;
gActionPickerKey = 0;
gUpdateDisplay = true;
ACTION_Execute(action);
}
return true;
case KEY_EXIT:
case KEY_F:
if (!isPressed) {
gActionPickerKey = 0;
gUpdateDisplay = true;
}
return true;
case KEY_PTT:
gActionPickerKey = 0;
gUpdateDisplay = true;
return false;
default:
return true;
}
}
#endif
void ACTION_Handle(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld)
{
HideFKeyIcon();
@@ -355,51 +500,52 @@ void ACTION_Handle(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld)
}
// held or released after short press
ACTION_Execute(func);
}
gBeepToPlay = BEEP_1KHZ_60MS_OPTIONAL;
#ifdef ENABLE_FMRADIO
if (gFmRadioMode) { // do not run these actions in FM radio mode
switch (func) {
case ACTION_OPT_POWER:
case ACTION_OPT_MONITOR:
case ACTION_OPT_A_B:
case ACTION_OPT_VFO_MR:
case ACTION_OPT_SWITCH_DEMODUL:
#ifdef ENABLE_VOX
case ACTION_OPT_VOX:
#endif
#ifdef ENABLE_FEAT_F4HWN
case ACTION_OPT_RXMODE:
case ACTION_OPT_MAINONLY:
case ACTION_OPT_WN:
#ifdef ENABLE_FEAT_F4HWN_AUDIO
case ACTION_OPT_RXA:
#endif
#ifdef ENABLE_FEAT_F4HWN_RESCUE_OPS
case ACTION_OPT_POWER_HIGH:
case ACTION_OPT_REMOVE_OFFSET:
#endif
#endif
#ifdef ENABLE_FEAT_F4HWN_BEAM
case ACTION_OPT_BEAM:
#endif
gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL;
return;
default:
break;
}
}
#if defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) && !defined(ENABLE_FEAT_F4HWN_BEAM)
static void ACTION_Beam(void)
{
if (APP_LaunchOverlayShortcut(APP_SHORTCUT_BEAM) != APP_OK)
gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL;
}
#endif
action_opt_table[func]();
#if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS)
void ACTION_FoxHunt(void)
{
#ifdef ENABLE_FEAT_F4HWN_FOXHUNT
APP_RunFoxHunt();
GUI_SelectNextDisplay(DISPLAY_MAIN);
#else
if (APP_LaunchOverlayShortcut(APP_SHORTCUT_FOXHUNT) != APP_OK)
gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL;
#endif
}
#endif
#if defined(ENABLE_FEAT_F4HWN_BEACON) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS)
void ACTION_Beacon(void)
{
#ifdef ENABLE_FEAT_F4HWN_BEACON
APP_RunBeacon();
GUI_SelectNextDisplay(DISPLAY_MAIN);
#else
if (APP_LaunchOverlayShortcut(APP_SHORTCUT_BEACON) != APP_OK)
gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL;
#endif
}
#endif
#ifdef ENABLE_FMRADIO
void ACTION_FM(void)
{
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_APPS
if (APP_LaunchOverlayShortcut(APP_SHORTCUT_FM) != APP_OK)
gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL;
return;
#else
if (gCurrentFunction != FUNCTION_TRANSMIT && gCurrentFunction != FUNCTION_MONITOR)
{
gInputBoxIndex = 0;
@@ -415,8 +561,22 @@ void ACTION_FM(void)
return;
}
// Do not start broadcast FM while a VFO reception is already active.
// Keeping this check after the block above ensures EXIT can still
// turn FM off if the UI ever reaches DISPLAY_MAIN with FM mode active.
if (FUNCTION_IsRx()) {
gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL;
return;
}
gMonitor = false;
if (gScanStateDir != SCAN_OFF) {
// Stop the channel/frequency scan before switching to the FM radio.
gScanKeepResult = false;
CHFRSCANNER_Stop();
}
RADIO_SelectVfos();
RADIO_SetupRegisters(true);
@@ -424,8 +584,10 @@ void ACTION_FM(void)
gRequestDisplayScreen = DISPLAY_FM;
}
#endif /* !ENABLE_FEAT_F4HWN_OVERLAY_APPS */
}
#ifdef ENABLE_FMRADIO_EMBEDDED
static void ACTION_Scan_FM(bool bRestart)
{
if (FUNCTION_IsRx())
@@ -465,40 +627,24 @@ static void ACTION_Scan_FM(bool bRestart)
#endif
}
#endif
#endif
#if defined(ENABLE_ALARM) || defined(ENABLE_TX1750)
static void ACTION_AlarmOr1750(const bool b1750)
#ifdef ENABLE_TX1750
static void ACTION_1750(void)
{
if(gEeprom.KEY_LOCK && gEeprom.KEY_LOCK_PTT)
if(gEeprom.KEY_LOCK && (gSetting_set_lck & SET_LCK_PTT))
return;
#if defined(ENABLE_ALARM)
const AlarmState_t alarm_mode = (gEeprom.ALARM_MODE == ALARM_MODE_TONE) ? ALARM_STATE_TXALARM : ALARM_STATE_SITE_ALARM;
gAlarmRunningCounter = 0;
#endif
#if defined(ENABLE_ALARM) && defined(ENABLE_TX1750)
gAlarmState = b1750 ? ALARM_STATE_TX1750 : alarm_mode;
#elif defined(ENABLE_ALARM)
gAlarmState = alarm_mode;
#else
gAlarmState = ALARM_STATE_TX1750;
#endif
(void)b1750;
gTx1750Active = true;
gInputBoxIndex = 0;
gFlagPrepareTX = gAlarmState != ALARM_STATE_OFF;
gFlagPrepareTX = true;
if (gScreenToDisplay != DISPLAY_MENU) // 1of11 .. don't close the menu
gRequestDisplayScreen = DISPLAY_MAIN;
}
#endif
#ifdef ENABLE_VOX
@@ -515,18 +661,6 @@ void ACTION_Vox(void)
}
#endif
#ifdef ENABLE_BLMIN_TMP_OFF
void ACTION_BlminTmpOff(void)
{
if(++gEeprom.BACKLIGHT_MIN_STAT == BLMIN_STAT_UNKNOWN) {
gEeprom.BACKLIGHT_MIN_STAT = BLMIN_STAT_ON;
BACKLIGHT_SetBrightness(gEeprom.BACKLIGHT_MIN);
} else {
BACKLIGHT_SetBrightness(0);
}
}
#endif
#ifdef ENABLE_FEAT_F4HWN
void ACTION_Update(void)
{
@@ -535,8 +669,40 @@ void ACTION_Update(void)
gUpdateStatus = true;
}
#ifdef ENABLE_FEAT_F4HWN_FULL_WATCH
uint8_t ACTION_GetRxMode(void)
{
if (gEeprom.DUAL_WATCH == DUAL_WATCH_FULL)
return gEeprom.CROSS_BAND_RX_TX == CROSS_BAND_OFF ? 4 : 5;
return (gEeprom.DUAL_WATCH != DUAL_WATCH_OFF) +
(gEeprom.CROSS_BAND_RX_TX != CROSS_BAND_OFF) * 2;
}
void ACTION_SetRxMode(uint8_t mode)
{
const uint8_t selected = gEeprom.TX_VFO + 1u;
bool crossBand;
if (mode >= 4)
{
gEeprom.DUAL_WATCH = DUAL_WATCH_FULL;
crossBand = mode == 5;
}
else
{
gEeprom.DUAL_WATCH = selected * (mode & 1);
crossBand = (mode & 2) != 0;
}
gEeprom.CROSS_BAND_RX_TX = crossBand ? selected : CROSS_BAND_OFF;
}
#endif
void ACTION_RxMode(void)
{
#ifdef ENABLE_FEAT_F4HWN_FULL_WATCH
uint8_t mode = ACTION_GetRxMode() + 1;
ACTION_SetRxMode(mode < 6 ? mode : 0);
#else
static bool cycle = 0;
if (cycle) {
@@ -546,6 +712,8 @@ void ACTION_RxMode(void)
}
cycle = !cycle;
#endif
ACTION_Update();
}
@@ -611,11 +779,7 @@ void ACTION_Wn(void)
}
#endif
#ifdef ENABLE_AM_FIX
BK4819_SetFilterBandwidth(bw, true);
#else
BK4819_SetFilterBandwidth(bw, false);
#endif
BK4819_SetFilterBandwidth(bw, false);
}
void ACTION_BackLight(void)
@@ -657,7 +821,7 @@ void ACTION_Mute(void)
gMute = !gMute;
// Update the registers
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
BK1080_WriteRegister(BK1080_REG_05_SYSTEM_CONFIGURATION2, gMute ? 0x0A10 : 0x0A1F);
#endif
gEeprom.VOLUME_GAIN = gMute ? 0 : gEeprom.VOLUME_GAIN_BACKUP;
+18 -4
View File
@@ -17,6 +17,9 @@
#ifndef APP_ACTION_H
#define APP_ACTION_H
#include <stdbool.h>
#include <stdint.h>
#include "driver/keyboard.h"
void ACTION_Power(void);
@@ -31,12 +34,12 @@ void ACTION_Scan(bool bRestart);
#endif
void ACTION_SwitchDemodul(void);
#ifdef ENABLE_BLMIN_TMP_OFF
void ACTION_BlminTmpOff(void);
#endif
#ifdef ENABLE_FEAT_F4HWN
void ACTION_RxMode(void);
#ifdef ENABLE_FEAT_F4HWN_FULL_WATCH
uint8_t ACTION_GetRxMode(void);
void ACTION_SetRxMode(uint8_t mode);
#endif
void ACTION_MainOnly(void);
void ACTION_Ptt(void);
void ACTION_Wn(void);
@@ -50,8 +53,19 @@ void ACTION_SwitchDemodul(void);
void ACTION_Power_High(void);
void ACTION_Remove_Offset(void);
#endif
#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG
void ACTION_RxTxLog(void);
#endif
#endif
#ifdef ENABLE_FEAT_F4HWN_ACTION_PICKER
#define ACTION_PICKER_TIMEOUT_500MS 10u
extern uint8_t gActionPickerKey;
extern uint8_t gActionPickerSelection[2];
extern uint8_t gActionPickerTimeout_500ms;
bool ACTION_PickerProcessKey(KEY_Code_t key, bool isPressed, bool isHeld);
#endif
bool ACTION_IsAvailable(uint8_t action);
void ACTION_Handle(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld);
#endif
+1011 -200
View File
File diff suppressed because it is too large. Load diff
+59 -49
View File
@@ -26,55 +26,48 @@
// ============================================================================
#define AIRCOPY_BLOCK_SIZE 0x0040u // 64 bytes per AirCopy block
#define AIRCOPY_BLOCK_WORDS (AIRCOPY_BLOCK_SIZE / 2u) // 32 FSK words per block
// Every forward frame stays 100 words long. HASH carries 24 per-block CRC32
// values and the selected map; the receiver replies with an ACK carrying a
// 24-bit difference mask or a rejection when the selections do not match.
// DATA retains the proven [type][header][up to 3 blocks][CRC16][END] layout,
// with unused blocks zero-padded. Only blocks selected by the mask are sent.
// Reverse ACK frames are eight words long. A missing ACK triggers a retry.
//
// This wire format is not compatible with earlier AirCopy versions. Both radios
// must run the same firmware.
#define AIRCOPY_BLOCKS_PER_FRAME 3u
#define AIRCOPY_DATA_HEADER_WORDS 2u
#define AIRCOPY_DATA_WORDS (AIRCOPY_DATA_HEADER_WORDS + AIRCOPY_BLOCKS_PER_FRAME * AIRCOPY_BLOCK_WORDS + 2u)
#define AIRCOPY_CTRL_WORDS 8u // multiple of the 4-word RX FIFO threshold
#define AIRCOPY_FRAME_WORDS_MAX AIRCOPY_DATA_WORDS
#ifdef ENABLE_AIRCOPY_UART
// Enable exactly one of the validated cable rates below:
// #define AIRCOPY_UART_BAUD_RATE 38400u
// #define AIRCOPY_UART_BAUD_RATE 115200u
// #define AIRCOPY_UART_BAUD_RATE 230400u
#define AIRCOPY_UART_BAUD_RATE 460800u
#define AIRCOPY_UART_DEFAULT_BAUD 38400u
#endif
#if AIRCOPY_DATA_WORDS > 128u
#error AirCopy DATA frame exceeds the radio TX FIFO
#endif
#define AIRCOPY_CHANNELS_PER_BANK 128
#define AIRCOPY_NUM_BANKS MR_CHANNELS_MAX / AIRCOPY_CHANNELS_PER_BANK
#define AIRCOPY_CHANNEL_SIZE 16 // bytes per channel (freq/name)
#define AIRCOPY_BANK_SIZE_BYTES 0x1080u // 0x800 (Freq) + 0x800 (Name) + 0x80 (Attr)
#define AIRCOPY_NUM_MAPS (AIRCOPY_NUM_BANKS + 1u) // banks + one settings map
#define AIRCOPY_ALL_INDEX AIRCOPY_NUM_MAPS // selection sentinel: send/receive everything
#ifdef ENABLE_AIRCOPY_FLASH
#define AIRCOPY_FLASH_INDEX (AIRCOPY_ALL_INDEX + 1u) // full external-flash clone
#define AIRCOPY_FLASH_SECTORS 512u // 2 MiB / 4 KiB
#endif
#define AIRCOPY_BANK_BLOCKS 68u
#define AIRCOPY_SETTINGS_BLOCKS 12u
#define AIRCOPY_ALL_BLOCKS (AIRCOPY_NUM_BANKS * AIRCOPY_BANK_BLOCKS + AIRCOPY_SETTINGS_BLOCKS)
#define AIRCOPY_BAR_WIDTH 120 // Visible width of the progress gauge
// ============================================================================
// Segment write mode
// ============================================================================
/*
* Defines how a segment must be written to EEPROM.
*
* - STRUCT: structured data (frequencies, names)
* - BYTES : raw byte stream (attributes, settings, etc.)
*/
typedef enum {
AIRCOPY_WRITE_STRUCT = 0,
AIRCOPY_WRITE_BYTES = 1,
} AIRCOPY_WriteMode_t;
// ============================================================================
// Transfer segment structure
// ============================================================================
/*
* Describes a contiguous EEPROM region involved in AirCopy.
* The write_mode defines how the RX side must write the data.
*/
typedef struct {
uint16_t start_offset;
uint16_t end_offset;
AIRCOPY_WriteMode_t write_mode;
} AIRCOPY_Segment_t;
// ============================================================================
// Transfer map structure
// ============================================================================
/*
* A transfer map is a collection of segments describing
* one complete AirCopy operation (bank, settings, etc.).
*/
typedef struct {
const AIRCOPY_Segment_t *segments;
uint16_t num_segments;
uint16_t total_blocks;
} AIRCOPY_TransferMap_t;
// ============================================================================
// AirCopy state
// ============================================================================
@@ -82,9 +75,17 @@ typedef struct {
typedef enum {
AIRCOPY_READY = 0,
AIRCOPY_TRANSFER,
AIRCOPY_COMPLETE
AIRCOPY_COMPLETE,
AIRCOPY_FAILED
} AIRCOPY_State_t;
typedef enum {
AIRCOPY_TRANSPORT_AIR = 0,
#ifdef ENABLE_AIRCOPY_UART
AIRCOPY_TRANSPORT_UART,
#endif
} AIRCOPY_Transport_t;
// ============================================================================
// Globals
// ============================================================================
@@ -93,8 +94,11 @@ extern AIRCOPY_State_t gAircopyState;
extern uint16_t gAirCopyBlockNumber;
extern uint16_t gErrorsDuringAirCopy;
extern bool gAirCopyIsSendMode;
extern bool gAircopyAll; // All mode: banks + settings in one pass
extern AIRCOPY_Transport_t gAircopyTransport;
extern uint16_t g_FSK_Buffer[36];
extern uint16_t g_FSK_Buffer[AIRCOPY_FRAME_WORDS_MAX];
extern uint8_t gFskRxExpectedWords; // frame length the current role expects on RX
// ============================================================================
// API
@@ -102,9 +106,15 @@ extern uint16_t g_FSK_Buffer[36];
bool AIRCOPY_SendMessage(void);
void AIRCOPY_StorePacket(void);
#ifdef ENABLE_AIRCOPY_UART
void AIRCOPY_StoreUartPacket(const void *data, uint8_t words);
#endif
void AIRCOPY_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld);
const AIRCOPY_TransferMap_t* AIRCOPY_GetCurrentMap(void);
uint16_t AIRCOPY_GetTotalBlocks(void);
bool AIRCOPY_PixelWasCopied(uint8_t col);
uint8_t AIRCOPY_CurrentSliceMap(void); // map index of the block in progress (All slice label)
bool AIRCOPY_UsesUart(void);
bool AIRCOPY_IsFlash(void);
// XOR-obfuscate `count` words of g_FSK_Buffer starting at index 1.
// Self-inverse: applying twice restores the original buffer.
+458 -168
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -32,5 +32,13 @@ void APP_Update(void);
void APP_TimeSlice10ms(void);
void APP_TimeSlice500ms(void);
bool APP_IsScreenSaverDisplayed(void);
void APP_ModalBacklightTick(bool allowScreenSaver);
void APP_ModalScreenSaverExit(void);
#ifdef ENABLE_FEAT_F4HWN_FULL_WATCH
VFO_Info_t *APP_GetFullWatchDisplayVfo(uint8_t vfo);
// Requires Full Watch mode; initializes the background VFOs on first access.
VFO_Info_t *const *APP_GetFullWatchBackgroundVfos(uint8_t *count);
uint8_t APP_GetFullWatchScrollPhase(void);
#endif
#endif
+4 -15
View File
@@ -69,7 +69,6 @@ static_assert(sizeof(BEAM_Payload_t) <= 64);
BEAM_Mode_t gBeamMode = BEAM_MODE_TX;
BEAM_Status_t gBeamStatus = BEAM_STATUS_READY;
uint16_t gBeamCopiedChannel = 0xFFFFu;
uint8_t gBeamRxWordCount;
bool gBeamActive;
static VFO_Info_t gBeamRadioVfo;
@@ -120,7 +119,6 @@ static void BEAM_SendPacket(void)
payload->dtmf_decoding_enable = vfo->DTMF_DECODING_ENABLE;
#endif
payload->step_setting = vfo->STEP_SETTING;
payload->scrambling_type = vfo->SCRAMBLING_TYPE;
payload->band = vfo->Band;
payload->scanlist = vfo->SCANLIST_PARTICIPATION;
payload->compander = vfo->Compander;
@@ -143,7 +141,7 @@ static void BEAM_SendPacket(void)
ST7565_BlitFullScreen();
RADIO_SetTxParameters();
BK4819_SendFSKData(g_FSK_Buffer);
BK4819_SendFSKData(g_FSK_Buffer, 36); // BEAM uses a fixed 36-word frame
BK4819_SetupPowerAmplifier(0, 0);
BK4819_ToggleGpioOut(BK4819_GPIO1_PIN29_PA_ENABLE, false);
@@ -174,7 +172,6 @@ static void BEAM_SavePayloadToFirstFreeChannel(const BEAM_Payload_t *payload)
VFO_Info_t vfo;
RADIO_InitInfo(&vfo, channel, payload->rx_frequency);
// CRC + magic + version already validate the payload — no need to clamp fields.
vfo.TX_OFFSET_FREQUENCY = payload->tx_offset_frequency;
vfo.freq_config_RX.Code = payload->rx_code;
vfo.freq_config_TX.Code = payload->tx_code;
@@ -191,21 +188,18 @@ static void BEAM_SavePayloadToFirstFreeChannel(const BEAM_Payload_t *payload)
#ifdef ENABLE_DTMF_CALLING
vfo.DTMF_DECODING_ENABLE = payload->dtmf_decoding_enable;
#endif
vfo.STEP_SETTING = payload->step_setting;
vfo.STEP_SETTING = payload->step_setting < STEP_N_ELEM ? payload->step_setting : STEP_12_5kHz;
vfo.StepFrequency = gStepFrequencyTable[vfo.STEP_SETTING];
vfo.SCRAMBLING_TYPE = payload->scrambling_type;
vfo.Band = payload->band;
vfo.SCANLIST_PARTICIPATION = payload->scanlist;
vfo.Compander = payload->compander;
memcpy(vfo.Name, payload->name, sizeof(vfo.Name));
vfo.Name[sizeof(vfo.Name) - 1] = '\0';
RADIO_ApplyOffset(&vfo);
RADIO_ConfigureSquelchAndOutputPower(&vfo);
SETTINGS_SaveChannel(channel, gEeprom.TX_VFO, &vfo, 3);
#ifndef ENABLE_KEEP_MEM_NAME
SETTINGS_SaveChannelName(channel, vfo.Name);
#endif
gBeamCopiedChannel = channel;
gBeamStatus = BEAM_STATUS_RX_SAVED;
@@ -223,7 +217,6 @@ static void BEAM_KeyMenu(void)
} else {
gBeamStatus = BEAM_STATUS_RX_WAIT;
gBeamCopiedChannel = 0xFFFFu;
gBeamRxWordCount = 0;
gFSKWriteIndex = 0;
BK4819_PrepareFSKReceive();
}
@@ -251,7 +244,6 @@ void ACTION_Beam(void)
gBeamMode = BEAM_MODE_TX;
gBeamStatus = BEAM_STATUS_READY;
gBeamCopiedChannel = 0xFFFFu;
gBeamRxWordCount = 0;
gBeamActive = true;
GUI_SelectNextDisplay(DISPLAY_MAIN);
}
@@ -269,7 +261,6 @@ void BEAM_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld)
case KEY_DOWN:
gBeamMode ^= 1; // (gBeamMode == BEAM_MODE_TX) ? BEAM_MODE_RX : BEAM_MODE_TX
gBeamStatus = BEAM_STATUS_READY;
gBeamRxWordCount = 0;
break;
case KEY_MENU:
BEAM_KeyMenu();
@@ -292,7 +283,6 @@ void BEAM_StorePacket(void)
if (gFSKWriteIndex < 36)
return;
gBeamRxWordCount = gFSKWriteIndex;
gFSKWriteIndex = 0;
const uint16_t Status = BK4819_ReadRegister(BK4819_REG_0B);
@@ -317,7 +307,6 @@ void BEAM_StorePacket(void)
error:
gBeamStatus = BEAM_STATUS_ERROR;
gBeamRxWordCount = 0;
gUpdateDisplay = true;
BACKLIGHT_TurnOn();
}
-1
View File
@@ -42,7 +42,6 @@ typedef enum {
extern BEAM_Mode_t gBeamMode;
extern BEAM_Status_t gBeamStatus;
extern uint16_t gBeamCopiedChannel;
extern uint8_t gBeamRxWordCount;
extern bool gBeamActive;
void ACTION_Beam(void);
+39 -38
View File
@@ -16,10 +16,22 @@
#include "app/breakout.h"
#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT
#include "screenshot.h"
#if defined(ENABLE_UART) || defined(ENABLE_USB)
#include "app/uart.h"
#endif
#ifdef ENABLE_FEAT_F4HWN_K5VIEWER
#include "k5viewer.h"
#endif
#define BRICK_WIDTH 14
#define BRICK_HEIGHT 5
#define BALL_WIDTH 3
#define BALL_HEIGHT 3
#define RACKET_WIDTH 24
#define RACKET_HEIGHT 2
#define RACKET_Y 50
static uint32_t randSeed = 1;
static uint8_t blockAnim = 0;
@@ -97,16 +109,14 @@ void drawScore()
// Render the ball
void renderBall(bool state) {
UI_DrawRectangleBuffer(gFrameBuffer, ball.x, ball.y, ball.x + ball.w - 1, ball.y + ball.h - 1, state);
UI_DrawLineBuffer(gFrameBuffer, ball.x - 1, ball.y + 1, ball.x + ball.w, ball.y + 1, state);
UI_DrawRectangleBuffer(gFrameBuffer, ball.x, ball.y, ball.x + BALL_WIDTH - 1, ball.y + BALL_HEIGHT - 1, state);
UI_DrawLineBuffer(gFrameBuffer, ball.x - 1, ball.y + 1, ball.x + BALL_WIDTH, ball.y + 1, state);
}
// Init ball
void initBall() {
ball.x = 62;
ball.y = 30;
ball.w = 3;
ball.h = 3;
ball.dx = 0;
ball.dy = 1;
@@ -115,7 +125,7 @@ void initBall() {
// Calculate the direction of the bounced ball
void directionBall(int16_t x, uint8_t w, int8_t num) {
ball.dx = map(x + w - ball.x, 0, w, num, -num);
ball.dx = (int16_t)(x + w - ball.x) * (-num - num) / w + num;
ball.dy *= -1;
}
@@ -141,8 +151,8 @@ void drawBall() {
}
// And now Down...
if (ball.y == 47) {
if (ball.x + 1 >= racket.x && ball.x - 1 <= racket.x + racket.w) {
directionBall(racket.x, racket.w, 3);
if (ball.x + 1 >= racket.x && ball.x - 1 <= racket.x + RACKET_WIDTH) {
directionBall(racket.x, RACKET_WIDTH, 3);
tone = 400;
}
}
@@ -168,24 +178,15 @@ void drawBall() {
// Init wall
void initWall() {
uint8_t offset = 6;
uint8_t i = 0;
uint8_t j = 0;
uint8_t k = 0;
Brick *current = brick;
for (i = 0; i < BRICK_NUMBER; i++) {
if (i % 6 == 0) {
j = 0;
k++;
for (uint8_t y = 0; y < 24; y += 8) {
for (uint8_t x = 6; x < 126; x += 20) {
current->x = x;
current->y = y;
current->destroy = false;
current++;
}
brick[i].x = offset + (20 * j);
brick[i].y = -8 + 8 * k;
brick[i].w = 14;
brick[i].h = 5;
brick[i].destroy = false;
j++;
}
}
@@ -201,13 +202,13 @@ void drawWall() {
fb_ptr[14] = 0b00011110;
if ((ball.x + 1 >= brick[i].x &&
ball.x - 1 <= brick[i].x + brick[i].w) &&
ball.x - 1 <= brick[i].x + BRICK_WIDTH) &&
((ball.y + 1 >= brick[i].y &&
ball.y - 1 <= brick[i].y + brick[i].h))) {
ball.y - 1 <= brick[i].y + BRICK_HEIGHT))) {
brick[i].destroy = true;
score++;
directionBall(brick[i].x, brick[i].w, 2);
directionBall(brick[i].x, BRICK_WIDTH, 2);
BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, true);
memset(fb_ptr + 1, 0b00111111, 13);
@@ -234,16 +235,13 @@ void drawWall() {
// Render the racket shape
void renderRacket(int x, bool state) {
UI_DrawRectangleBuffer(gFrameBuffer, x + 1, racket.y, x + racket.w - 2, racket.y + racket.h, state);
UI_DrawLineBuffer(gFrameBuffer, x, racket.y + 1, x + racket.w - 1, racket.y + 1, state);
UI_DrawRectangleBuffer(gFrameBuffer, x + 1, RACKET_Y, x + RACKET_WIDTH - 2, RACKET_Y + RACKET_HEIGHT, state);
UI_DrawLineBuffer(gFrameBuffer, x, RACKET_Y + 1, x + RACKET_WIDTH - 1, RACKET_Y + 1, state);
}
// Init racket
void initRacket() {
racket.w = 24;
racket.x = (64) - (racket.w / 2);
racket.y = 50;
racket.h = 2;
racket.x = 64 - (RACKET_WIDTH / 2);
racket.p = racket.x;
renderRacket(racket.x, true);
@@ -304,10 +302,10 @@ static void OnKeyDown(uint8_t key)
// HandleUserInput
static bool HandleUserInput()
{
#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT
#ifdef ENABLE_FEAT_F4HWN_K5VIEWER
// Parse incoming packets on every tick so serial keys are never missed,
// regardless of whether the screen needs redrawing.
SCREENSHOT_ParseInput();
K5VIEWER_ParseInput();
#endif
kbd.prev = kbd.current;
@@ -359,6 +357,9 @@ void APP_RunBreakout(void) {
while(isInitialized)
{
#if defined(ENABLE_UART) || defined(ENABLE_USB)
UART_ServiceCommands();
#endif
Tick();
if(!isPaused)
{
@@ -388,10 +389,10 @@ void APP_RunBreakout(void) {
ST7565_BlitStatusLine(); // Blank status line
ST7565_BlitFullScreen();
#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT
#ifdef ENABLE_FEAT_F4HWN_K5VIEWER
if(isPaused || swap == 0)
{
SCREENSHOT_Update(false);
K5VIEWER_Update(false);
}
#endif
}
-7
View File
@@ -47,24 +47,17 @@
typedef struct {
uint8_t x; // x
uint8_t y; // y
uint8_t w; // width
uint8_t h; // height
bool destroy; // active, if true, check this button, else bypass
} Brick;
typedef struct {
int8_t x; // x
uint8_t y; // y
uint8_t w; // width
uint8_t h; // height
uint8_t p; // previous x
} Racket;
typedef struct {
int16_t x; // x
int8_t y; // y
uint8_t w; // width
uint8_t h; // height
int8_t dx; // move x
int8_t dy; // move y
} Ball;
+13 -39
View File
@@ -29,7 +29,7 @@ static uint8_t scanRangeCssCandidate = 0xFF;
static uint8_t scanRangeCssHitCount = 0;
#endif
#define SCAN_RANGE_SKIP_MAX 32
#define SCAN_RANGE_SKIP_MAX 64
#if (SCAN_RANGE_SKIP_MAX & (SCAN_RANGE_SKIP_MAX - 1)) != 0
#error SCAN_RANGE_SKIP_MAX must be a power of two
#endif
@@ -416,35 +416,7 @@ static bool ScanFastUpdateDisplayVfo(uint16_t channel, uint32_t *frequency, Modu
}
scanFastDisplayVfo = gEeprom.VfoInfo[gEeprom.RX_VFO];
scanFastDisplayVfo.CHANNEL_SAVE = channel;
scanFastDisplayVfo.freq_config_RX = info.rx;
scanFastDisplayVfo.freq_config_TX = info.tx;
scanFastDisplayVfo.TX_OFFSET_FREQUENCY = info.offset;
scanFastDisplayVfo.StepFrequency = info.stepFrequency;
scanFastDisplayVfo.STEP_SETTING = info.stepSetting;
scanFastDisplayVfo.Modulation = info.modulation;
scanFastDisplayVfo.TX_OFFSET_FREQUENCY_DIRECTION = info.txOffsetFrequencyDirection;
scanFastDisplayVfo.OUTPUT_POWER = info.outputPower;
scanFastDisplayVfo.FrequencyReverse = info.frequencyReverse;
scanFastDisplayVfo.CHANNEL_BANDWIDTH = info.channelBandwidth;
scanFastDisplayVfo.BUSY_CHANNEL_LOCK = info.busyChannelLock;
scanFastDisplayVfo.TX_LOCK = info.txLock;
#ifdef ENABLE_DTMF_CALLING
scanFastDisplayVfo.DTMF_DECODING_ENABLE = info.dtmfDecodingEnable;
#endif
scanFastDisplayVfo.DTMF_PTT_ID_TX_MODE = info.dtmfPttIdTxMode;
if (!scanFastDisplayVfo.FrequencyReverse)
{
scanFastDisplayVfo.pRX = &scanFastDisplayVfo.freq_config_RX;
scanFastDisplayVfo.pTX = &scanFastDisplayVfo.freq_config_TX;
}
else
{
scanFastDisplayVfo.pRX = &scanFastDisplayVfo.freq_config_TX;
scanFastDisplayVfo.pTX = &scanFastDisplayVfo.freq_config_RX;
}
SETTINGS_ApplyChannelScanDisplayInfo(&scanFastDisplayVfo, channel, &info);
scanFastDisplayVfoValid = true;
@@ -553,11 +525,7 @@ static void ScanFastApplyChannelShape(ModulationMode_t modulation)
}
else
{
#ifdef ENABLE_AM_FIX
BK4819_SetFilterBandwidth(BK4819_FILTER_BW_WIDE, true);
#else
BK4819_SetFilterBandwidth(BK4819_FILTER_BW_WIDE, false);
#endif
}
if (modulationChanged)
@@ -771,13 +739,17 @@ void CHFRSCANNER_Start(const bool storeBackupSettings, const int8_t scan_directi
#endif
if (IS_MR_CHANNEL(gNextMrChannel))
{
{
bool scanListChanged = false;
if(!RADIO_CheckValidList(gEeprom.SCAN_LIST_DEFAULT)) {
RADIO_NextValidList(1);
UI_MAIN_NotifyScanProgressDataChanged();
scanListChanged = true;
}
if (storeBackupSettings || scanListChanged)
UI_MAIN_NotifyScanListChanged();
// channel mode
if (storeBackupSettings) {
initialFrqOrChan = gRxVfo->CHANNEL_SAVE;
@@ -1045,9 +1017,9 @@ static void NextFreqChannel(void)
static void NextMemChannel(void)
{
static uint16_t prev_mr_chan = 0;
const bool enabled = (gEeprom.SCAN_LIST_DEFAULT > 0 && gEeprom.SCAN_LIST_DEFAULT <= MR_CHANNELS_LIST + 1) ? gEeprom.SCAN_LIST_ENABLED : true;
const int16_t chan1 = (gEeprom.SCAN_LIST_DEFAULT > 0 && gEeprom.SCAN_LIST_DEFAULT <= MR_CHANNELS_LIST + 1 && gEeprom.SCANLIST_PRIORITY_CH[0] != MR_CHANNELS_MAX) ? gEeprom.SCANLIST_PRIORITY_CH[0] : -1;
const int16_t chan2 = (gEeprom.SCAN_LIST_DEFAULT > 0 && gEeprom.SCAN_LIST_DEFAULT <= MR_CHANNELS_LIST + 1 && gEeprom.SCANLIST_PRIORITY_CH[1] != MR_CHANNELS_MAX) ? gEeprom.SCANLIST_PRIORITY_CH[1] : -1;
const bool enabled = (gEeprom.SCAN_LIST_DEFAULT > 0 && gEeprom.SCAN_LIST_DEFAULT <= SCAN_LIST_MODE_MIX) ? gEeprom.SCAN_LIST_ENABLED : true;
const int16_t chan1 = (gEeprom.SCAN_LIST_DEFAULT > 0 && gEeprom.SCAN_LIST_DEFAULT <= SCAN_LIST_MODE_MIX && gEeprom.SCANLIST_PRIORITY_CH[0] != MR_CHANNELS_MAX) ? gEeprom.SCANLIST_PRIORITY_CH[0] : -1;
const int16_t chan2 = (gEeprom.SCAN_LIST_DEFAULT > 0 && gEeprom.SCAN_LIST_DEFAULT <= SCAN_LIST_MODE_MIX && gEeprom.SCANLIST_PRIORITY_CH[1] != MR_CHANNELS_MAX) ? gEeprom.SCANLIST_PRIORITY_CH[1] : -1;
const uint16_t prev_chan = gNextMrChannel;
uint16_t chan = 0;
@@ -1129,7 +1101,9 @@ static void NextMemChannel(void)
if (!enabled || chan == 0xFFFF)
{
#ifdef ENABLE_FEAT_F4HWN_SCAN_FASTER
const uint16_t searchStart = gNextMrChannel;
#endif
chan = RADIO_FindNextChannel(gNextMrChannel + gScanStateDir, gScanStateDir, true, gEeprom.SCAN_LIST_DEFAULT);
if (chan == 0xFFFF)
{ // no valid channel found -> wrapping back to the first channel
+6 -2
View File
@@ -37,7 +37,11 @@ void COMMON_SwitchVFOs()
if (gEeprom.CROSS_BAND_RX_TX != CROSS_BAND_OFF)
gEeprom.CROSS_BAND_RX_TX = gEeprom.TX_VFO + 1;
if (gEeprom.DUAL_WATCH != DUAL_WATCH_OFF)
if (gEeprom.DUAL_WATCH != DUAL_WATCH_OFF
#ifdef ENABLE_FEAT_F4HWN_FULL_WATCH
&& gEeprom.DUAL_WATCH != DUAL_WATCH_FULL
#endif
)
gEeprom.DUAL_WATCH = gEeprom.TX_VFO + 1;
gRequestSaveSettings = 1;
@@ -85,4 +89,4 @@ void COMMON_SwitchVFOMode()
return;
}
}
}
}
+2 -2
View File
@@ -18,7 +18,7 @@
#include <stdio.h> // NULL
#include "app/chFrScanner.h"
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
#include "app/fm.h"
#endif
#include "app/scanner.h"
@@ -279,7 +279,7 @@ void DTMF_HandleRequest(void)
gDTMF_ReplyState = DTMF_REPLY_AB;
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
if (gFmRadioMode)
{
FM_TurnOff();
+12
View File
@@ -51,6 +51,9 @@ static inline void Flashlight_Toggle(){ GPIO_TogglePin(GPIO_PIN_FLASHLIGHT); }
void ACTION_FlashLight(void)
{
static uint16_t lastButtonTick;
const uint16_t now = gFlashLightBlinkCounter;
// switch (gFlashLightState) {
// case FLASHLIGHT_OFF:
// Flashlight_TurnOn();
@@ -63,6 +66,15 @@ static inline void Flashlight_Toggle(){ GPIO_TogglePin(GPIO_PIN_FLASHLIGHT); }
// Flashlight_TurnOff();
// }
if (gFlashLightState != FLASHLIGHT_OFF &&
(uint16_t)(now - lastButtonTick) >= (2500u / 10u)) {
Flashlight_TurnOff();
gFlashLightState = FLASHLIGHT_OFF;
return;
}
lastButtonTick = now;
if(gFlashLightState == FLASHLIGHT_OFF) {
Flashlight_TurnOn();
}
+6 -1
View File
@@ -33,6 +33,7 @@
#include "ui/ui.h"
uint16_t gFM_Channels[FM_CHANNELS_MAX];
#ifdef ENABLE_FMRADIO_EMBEDDED
bool gFmRadioMode;
uint8_t gFmRadioCountdown_500ms;
volatile uint16_t gFmPlayCountdown_10ms;
@@ -52,8 +53,8 @@ const uint8_t BUTTON_EVENT_HELD = BUTTON_STATE_PRESSED | BUTTON_STATE_HELD;
const uint8_t BUTTON_EVENT_SHORT = 0;
const uint8_t BUTTON_EVENT_LONG = BUTTON_STATE_HELD;
static void Key_FUNC(KEY_Code_t Key, uint8_t state);
#endif
bool FM_CheckValidChannel(uint8_t Channel)
{
@@ -94,6 +95,7 @@ int FM_ConfigureChannelState(void)
return 0;
}
#ifdef ENABLE_FMRADIO_EMBEDDED
void FM_SetFrequency(void)
{
BK1080_SetFrequency(gEeprom.FM_FrequencyPlaying, gEeprom.FM_Band/*, gEeprom.FM_Space*/);
@@ -196,6 +198,7 @@ void FM_PlayAndUpdate(void)
BACKLIGHT_TurnOn();
FM_AudioPathOn();
}
#endif
int FM_CheckFrequencyLock(uint16_t Frequency, uint16_t LowerLimit)
{
@@ -240,6 +243,7 @@ int FM_CheckFrequencyLock(uint16_t Frequency, uint16_t LowerLimit)
return 0;
}
#ifdef ENABLE_FMRADIO_EMBEDDED
static void Key_DIGITS(KEY_Code_t Key, uint8_t state)
{
enum { STATE_FREQ_MODE, STATE_MR_MODE, STATE_SAVE };
@@ -662,5 +666,6 @@ void FM_Start(void)
SETTINGS_WriteCurrentState();
#endif
}
#endif
#endif
+13 -8
View File
@@ -19,9 +19,12 @@
#ifdef ENABLE_FMRADIO
#include "driver/keyboard.h"
#include "misc.h"
#ifdef ENABLE_FMRADIO_EMBEDDED
#include "driver/keyboard.h"
#endif
#define FM_CHANNEL_UP 0x01
#define FM_CHANNEL_DOWN 0xFF
@@ -30,32 +33,34 @@ enum {
};
extern uint16_t gFM_Channels[FM_CHANNELS_MAX];
bool FM_CheckValidChannel(uint8_t Channel);
// returns first valid channel starting at Channel
uint8_t FM_FindNextChannel(uint8_t Channel, uint8_t Direction);
int FM_ConfigureChannelState(void);
int FM_CheckFrequencyLock(uint16_t Frequency, uint16_t LowerLimit);
#ifdef ENABLE_FMRADIO_EMBEDDED
extern bool gFmRadioMode;
extern uint8_t gFmRadioCountdown_500ms;
extern volatile uint16_t gFmPlayCountdown_10ms;
extern volatile int8_t gFM_ScanState;
extern bool gFM_AutoScan;
extern uint8_t gFM_ChannelPosition;
// Doubts about whether this should be signed or not
extern uint16_t gFM_FrequencyDeviation;
extern bool gFM_FoundFrequency;
extern uint16_t gFM_RestoreCountdown_10ms;
bool FM_CheckValidChannel(uint8_t Channel);
// returns first valid channel starting at Channel
uint8_t FM_FindNextChannel(uint8_t Channel, uint8_t Direction);
int FM_ConfigureChannelState(void);
void FM_TurnOff(void);
void FM_EraseChannels(void);
void FM_Tune(uint16_t Frequency, int8_t Step, bool bFlag);
void FM_PlayAndUpdate(void);
int FM_CheckFrequencyLock(uint16_t Frequency, uint16_t LowerLimit);
void FM_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld);
void FM_Play(void);
void FM_Start(void);
#endif
#endif
+1504
View File
File diff suppressed because it is too large. Load diff
+66
View File
@@ -0,0 +1,66 @@
/* 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.
*/
#ifndef APP_FOXHUNT_H
#define APP_FOXHUNT_H
#if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_BEACON)
#include "keyboard_state.h"
#include "../bitmaps.h"
#include "../board.h"
#include "py32f0xx.h"
#include "../driver/backlight.h"
#include "../driver/bk4819-regs.h"
#include "../driver/bk4819.h"
#include "../driver/gpio.h"
#include "../driver/keyboard.h"
#include "../driver/py25q16.h"
#include "../driver/st7565.h"
#include "../driver/system.h"
#include "../driver/systick.h"
#include "../external/printf/printf.h"
#include "../font.h"
#include "../helper/battery.h"
#include "../misc.h"
#include "../radio.h"
#include "../settings.h"
#include "../ui/battery.h"
#include "../ui/helper.h"
#include "../ui/ui.h"
#include "../audio.h"
#include <stdbool.h>
#include <stdint.h>
#include <string.h>
#endif
#if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_BEACON) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS)
// Assignable resident/overlay launchers (see action.c).
void ACTION_FoxHunt(void);
void ACTION_Beacon(void);
#endif
#ifdef ENABLE_FEAT_F4HWN_FOXHUNT
// Self-contained modal loop that owns the screen and keypad until EXIT.
void APP_RunFoxHunt(void);
#endif
#ifdef ENABLE_FEAT_F4HWN_BEACON
void APP_RunBeacon(void);
#endif
#endif // APP_FOXHUNT_H
+7 -7
View File
@@ -20,7 +20,7 @@
#include "app/chFrScanner.h"
#include "app/common.h"
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
#include "app/fm.h"
#endif
@@ -56,7 +56,7 @@ void GENERIC_Key_F(bool bKeyPressed, bool bKeyHeld)
COMMON_KeypadLockToggle();
}
else { // released
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
if ((gFmRadioMode || gScreenToDisplay != DISPLAY_MAIN) && gScreenToDisplay != DISPLAY_FM)
return;
#else
@@ -77,7 +77,7 @@ void GENERIC_Key_F(bool bKeyPressed, bool bKeyHeld)
}
}
else { // short pressed
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
if (gScreenToDisplay != DISPLAY_FM)
#endif
{
@@ -85,7 +85,7 @@ void GENERIC_Key_F(bool bKeyPressed, bool bKeyHeld)
return;
}
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
if (gFM_ScanState == FM_SCAN_OFF) { // not scanning
gBeepToPlay = BEEP_1KHZ_60MS_OPTIONAL;
return;
@@ -132,7 +132,7 @@ void GENERIC_Key_PTT(bool bKeyPressed)
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
if (gFM_ScanState != FM_SCAN_OFF) { // FM radio is scanning .. stop
FM_PlayAndUpdate();
#ifdef ENABLE_VOICE
@@ -143,7 +143,7 @@ void GENERIC_Key_PTT(bool bKeyPressed)
}
#endif
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
if (gScreenToDisplay == DISPLAY_FM)
goto start_tx; // listening to the FM radio .. start TX'ing
#endif
@@ -200,7 +200,7 @@ cancel_tx:
done:
gPttDebounceCounter = 0;
if (gScreenToDisplay != DISPLAY_MENU
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
&& gRequestDisplayScreen != DISPLAY_FM
#endif
) {
+79 -36
View File
@@ -20,19 +20,23 @@
#include "app/app.h"
#include "app/chFrScanner.h"
#include "app/common.h"
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
#include "app/fm.h"
#endif
#include "app/generic.h"
#include "app/main.h"
#include "app/menu.h"
#include "app/scanner.h"
#ifdef ENABLE_SPECTRUM
#include "app/spectrum.h"
#endif
#ifdef ENABLE_FEAT_F4HWN_GAME
#include "app/breakout.h"
#if defined(ENABLE_FEAT_F4HWN_GAME) && !defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS)
#include "app/breakout.h" // resident game only; the overlay path uses app_menu.h
#endif
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_APPS
#include "apps/app_menu.h"
#endif
#include "audio.h"
@@ -45,6 +49,7 @@
#include "settings.h"
#include "ui/inputbox.h"
#include "ui/main.h"
#include "ui/menu.h"
#include "ui/ui.h"
#include <stdlib.h>
@@ -100,12 +105,12 @@ static void toggle_chan_scanlist(void)
scanlist++;
if (scanlist > MR_CHANNELS_LIST + 1)
if (scanlist > SCAN_LIST_MODE_ALL)
scanlist = 0;
gTxVfo->SCANLIST_PARTICIPATION = scanlist;
SETTINGS_UpdateChannel(gTxVfo->CHANNEL_SAVE, gTxVfo, true, true, true);
SETTINGS_UpdateChannel(gTxVfo->CHANNEL_SAVE, gTxVfo, true);
}
gVfoConfigureMode = VFO_CONFIGURE;
@@ -225,6 +230,12 @@ static void processFKeyFunction(const KEY_Code_t Key, const bool beep)
case KEY_4:
HideFKeyIcon();
if (gScanStateDir != SCAN_OFF) {
// Stop the channel/frequency scan before saving the RX mode for Close Call.
gScanKeepResult = false;
CHFRSCANNER_Stop();
}
gBackup_CROSS_BAND_RX_TX = gEeprom.CROSS_BAND_RX_TX;
gEeprom.CROSS_BAND_RX_TX = CROSS_BAND_OFF;
@@ -262,9 +273,15 @@ static void processFKeyFunction(const KEY_Code_t Key, const bool beep)
break;
case KEY_7:
#ifdef ENABLE_FEAT_F4HWN_GAME
// F + 7 opens the overlay-apps menu when that support is built;
// otherwise it launches the resident game (GAME); otherwise VOX.
#if defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) || defined(ENABLE_FEAT_F4HWN_GAME)
if (!beep) {
APP_RunBreakout();
#if defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS)
APP_MenuOpen(); // overlay-apps selector
#else
APP_RunBreakout(); // resident game (no overlay support)
#endif
} else {
#endif
#ifdef ENABLE_VOX
@@ -272,7 +289,7 @@ static void processFKeyFunction(const KEY_Code_t Key, const bool beep)
//#else
// toggle_chan_scanlist();
#endif
#ifdef ENABLE_FEAT_F4HWN_GAME
#if defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) || defined(ENABLE_FEAT_F4HWN_GAME)
}
#endif
@@ -317,7 +334,7 @@ static void processFKeyFunction(const KEY_Code_t Key, const bool beep)
if (gScanStateDir != SCAN_OFF) {
RADIO_NextValidList(isKeyUp ? 1 : -1);
UI_MAIN_NotifyScanProgressDataChanged();
UI_MAIN_NotifyScanListChanged();
} else {
// Adjust squelch: UP increments, DOWN decrements
if (gSquelchLevelOriginal == 10)
@@ -448,6 +465,16 @@ static void MAIN_Key_DIGITS(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld)
gRequestDisplayScreen = DISPLAY_MAIN;
}
#ifdef ENABLE_FEAT_F4HWN_ACTION_PICKER
if (gWasFKeyPressed && (Key == KEY_SIDE1 || Key == KEY_SIDE2)) {
gActionPickerKey = (Key == KEY_SIDE1) ? 1 : 2;
gActionPickerTimeout_500ms = ACTION_PICKER_TIMEOUT_500MS;
gUpdateDisplay = true;
HideFKeyIcon();
return;
}
#endif
HideFKeyIcon();
processFKeyFunction(Key, true);
@@ -465,26 +492,6 @@ static void MAIN_Key_DIGITS(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld)
if (!gWasFKeyPressed) { // F-key wasn't pressed
if (gScanStateDir != SCAN_OFF){
/*
switch(Key) {
case KEY_0:
gEeprom.SCAN_LIST_DEFAULT = MR_CHANNELS_LIST + 1;
#ifdef ENABLE_FEAT_F4HWN_RESUME_STATE
SETTINGS_WriteCurrentState();
#endif
break;
case KEY_1...KEY_9:
gEeprom.SCAN_LIST_DEFAULT = Key;
#ifdef ENABLE_FEAT_F4HWN_RESUME_STATE
SETTINGS_WriteCurrentState();
#endif
break;
default:
break;
}
return;
*/
INPUTBOX_Append(Key);
/* Wait until exactly two digits are entered */
@@ -499,8 +506,26 @@ static void MAIN_Key_DIGITS(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld)
/* 00 = ALL scan lists */
if (value == 0)
{
gEeprom.SCAN_LIST_DEFAULT = MR_CHANNELS_LIST + 1;
UI_MAIN_NotifyScanProgressDataChanged();
gEeprom.SCAN_LIST_DEFAULT = SCAN_LIST_MODE_ALL;
UI_MAIN_NotifyScanListChanged();
#ifdef ENABLE_FEAT_F4HWN_RESUME_STATE
SETTINGS_WriteCurrentState();
#endif
return;
}
/* 25 = saved MIX selection */
if (value == SCAN_LIST_MIX_SHORTCUT)
{
gEeprom.SCAN_LIST_DEFAULT = SCAN_LIST_MODE_MIX;
if (!RADIO_CheckValidList(SCAN_LIST_MODE_MIX))
{
gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL;
RADIO_NextValidList(1);
}
UI_MAIN_NotifyScanListChanged();
#ifdef ENABLE_FEAT_F4HWN_RESUME_STATE
SETTINGS_WriteCurrentState();
#endif
@@ -519,7 +544,7 @@ static void MAIN_Key_DIGITS(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld)
gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL;
RADIO_NextValidList(1);
}
UI_MAIN_NotifyScanProgressDataChanged();
UI_MAIN_NotifyScanListChanged();
#ifdef ENABLE_FEAT_F4HWN_RESUME_STATE
SETTINGS_WriteCurrentState();
@@ -702,7 +727,7 @@ static void MAIN_Key_EXIT(bool bKeyPressed, bool bKeyHeld)
}
#endif
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
if (!gFmRadioMode)
#endif
{
@@ -737,7 +762,7 @@ static void MAIN_Key_EXIT(bool bKeyPressed, bool bKeyHeld)
return;
}
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
ACTION_FM();
#endif
return;
@@ -828,7 +853,13 @@ static void MAIN_Key_MENU(bool bKeyPressed, bool bKeyHeld)
#endif
gFlagRefreshSetting = true;
gScanMixEditorActive = false;
gRequestDisplayScreen = DISPLAY_MENU;
#ifdef ENABLE_FEAT_F4HWN_MENU_CAT
gMenuLevel = MENU_LEVEL_CAT;
UI_MENU_BuildCategoryScreen();
gMenuCursor = (gMenuCatCursor < gMenuListCount) ? gMenuCatCursor : 0;
#endif
#ifdef ENABLE_VOICE
gAnotherVoiceID = VOICE_ID_MENU;
#endif
@@ -918,6 +949,12 @@ static void MAIN_Key_STAR(bool bKeyPressed, bool bKeyHeld)
return;
}
#endif
if (gScanStateDir != SCAN_OFF) {
// Stop the channel/frequency scan before saving the RX mode for the CTCSS/DCS scan.
gScanKeepResult = false;
CHFRSCANNER_Stop();
}
// scan the CTCSS/DCS code
gBackup_CROSS_BAND_RX_TX = gEeprom.CROSS_BAND_RX_TX;
gEeprom.CROSS_BAND_RX_TX = CROSS_BAND_OFF;
@@ -972,6 +1009,12 @@ static void MAIN_Key_UP_DOWN(bool bKeyPressed, bool bKeyHeld, int8_t Direction)
}
if (gScanStateDir == SCAN_OFF) {
#ifdef ENABLE_FEAT_F4HWN_FULL_WATCH
// Navigate from the displayed priority; the normal reload ends the swap.
const VFO_Info_t *displayVfo = APP_GetFullWatchDisplayVfo(gEeprom.TX_VFO);
if (displayVfo != NULL)
Channel = displayVfo->CHANNEL_SAVE;
#endif
#ifdef ENABLE_NOAA
if (!IS_NOAA_CHANNEL(Channel))
#endif
@@ -994,7 +1037,7 @@ static void MAIN_Key_UP_DOWN(bool bKeyPressed, bool bKeyHeld, int8_t Direction)
Next = RADIO_FindNextChannel(Channel + Direction, Direction, false, 0);
if (Next == 0xFFFF)
return;
if (Channel == Next)
if (Channel == Next && gEeprom.ScreenChannel[gEeprom.TX_VFO] == Next)
return;
gEeprom.MrChannel[gEeprom.TX_VFO] = Next;
gEeprom.ScreenChannel[gEeprom.TX_VFO] = Next;
@@ -1027,7 +1070,7 @@ static void MAIN_Key_UP_DOWN(bool bKeyPressed, bool bKeyHeld, int8_t Direction)
void MAIN_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld)
{
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
if (gFmRadioMode && Key != KEY_PTT && Key != KEY_EXIT) {
if (!bKeyHeld && bKeyPressed)
gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL;
+437 -386
View File
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -24,6 +24,9 @@
#endif
extern uint8_t gUnlockAllTxConfCnt;
extern bool gScanMixEditorActive;
extern uint8_t gScanMixEditorCursor;
extern uint32_t gScanMixEditorMask;
int MENU_GetLimits(uint8_t menu_id, int32_t *pMin, int32_t *pMax);
void MENU_AcceptSetting(void);
@@ -35,4 +38,3 @@ void MENU_StopCssScan(void);
void MENU_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld);
#endif
+62
View File
@@ -0,0 +1,62 @@
/* Simple persistent byte settings. No callbacks or channel/VFO writes belong here.
* MENU_SETTING(id, lvalue, minimum, maximum) is supplied by the includer.
* Intentionally no include guard: used for validation and table generation.
*/
MENU_SETTING(MENU_SAVE, gEeprom.BATTERY_SAVE, 0, 5)
MENU_SETTING(MENU_BEEP, gEeprom.BEEP_CONTROL, 0, ARRAY_SIZE(gSubMenu_OFF_ON) - 1)
MENU_SETTING(MENU_TOT, gEeprom.TX_TIMEOUT_TIMER, 5, 179)
MENU_SETTING(MENU_SC_REV, gEeprom.SCAN_RESUME_MODE, 0, 104)
MENU_SETTING(MENU_MDF, gEeprom.CHANNEL_DISPLAY_MODE, 0, ARRAY_SIZE(gSubMenu_MDF) - 1)
MENU_SETTING(MENU_STE, gEeprom.TAIL_TONE_ELIMINATION, 0, ARRAY_SIZE(gSubMenu_OFF_ON) - 1)
MENU_SETTING(MENU_RP_STE, gEeprom.REPEATER_TAIL_TONE_ELIMINATION, 0, 10)
MENU_SETTING(MENU_S_LIST, gEeprom.SCAN_LIST_DEFAULT, 1, SCAN_LIST_MODE_MIX)
MENU_SETTING(MENU_D_ST, gEeprom.DTMF_SIDE_TONE, 0, ARRAY_SIZE(gSubMenu_OFF_ON) - 1)
MENU_SETTING(MENU_BATTYP, gEeprom.BATTERY_TYPE, 0, 4)
MENU_SETTING(MENU_BAT_TXT, gSetting_battery_text, 0, ARRAY_SIZE(gSubMenu_BAT_TXT) - 1)
#if defined(ENABLE_AUDIO_BAR)
MENU_SETTING(MENU_MIC_BAR, gSetting_mic_bar, 0, ARRAY_SIZE(gSubMenu_OFF_ON) - 1)
#endif
#if defined(ENABLE_DTMF_CALLING)
MENU_SETTING(MENU_D_RSP, gEeprom.DTMF_DECODE_RESPONSE, 0, ARRAY_SIZE(gSubMenu_D_RSP) - 1)
#endif
#if defined(ENABLE_DTMF_CALLING)
MENU_SETTING(MENU_D_HOLD, gEeprom.DTMF_auto_reset_time, 5, 60)
#endif
#if defined(ENABLE_FEAT_F4HWN)
MENU_SETTING(MENU_SET_NAV, gEeprom.SET_NAV, 0, 1)
#endif
#if defined(ENABLE_FEAT_F4HWN_SLEEP)
MENU_SETTING(MENU_SET_OFF, gSetting_set_off, 0, 120)
#endif
#if defined(ENABLE_FEAT_F4HWN)
MENU_SETTING(MENU_SET_TOT, gSetting_set_tot, 0, ARRAY_SIZE(gSubMenu_SET_TOT) - 1)
#endif
#if defined(ENABLE_FEAT_F4HWN)
MENU_SETTING(MENU_SET_EOT, gSetting_set_eot, 0, ARRAY_SIZE(gSubMenu_SET_TOT) - 1)
#endif
#if defined(ENABLE_FEAT_F4HWN) && defined(ENABLE_FEAT_F4HWN_CTR)
MENU_SETTING(MENU_SET_CTR, gSetting_set_ctr, 1, 15)
#endif
#if defined(ENABLE_FEAT_F4HWN)
MENU_SETTING(MENU_SET_LCK, gSetting_set_lck, 0, SET_LCK_LEN - 1)
#endif
#if defined(ENABLE_FEAT_F4HWN)
MENU_SETTING(MENU_SET_MET, gSetting_set_met, 0, ARRAY_SIZE(gSubMenu_SET_MET) - 1)
#endif
#if defined(ENABLE_FEAT_F4HWN)
MENU_SETTING(MENU_SET_GUI, gSetting_set_gui, 0, ARRAY_SIZE(gSubMenu_SET_MET) - 1)
#endif
#if defined(ENABLE_FEAT_F4HWN) && defined(ENABLE_FEAT_F4HWN_SCAN_FASTER)
MENU_SETTING(MENU_SET_SCN, gSetting_set_scn, 0, ARRAY_SIZE(gSubMenu_SET_SCN) - 1)
#endif
#if defined(ENABLE_FEAT_F4HWN) && defined(ENABLE_FEAT_F4HWN_VOL)
MENU_SETTING(MENU_SET_VOL, gEeprom.VOLUME_GAIN, 0, 63)
#endif
#if defined(ENABLE_FEAT_F4HWN)
MENU_SETTING(MENU_SET_TMR, gSetting_set_tmr, 0, ARRAY_SIZE(gSubMenu_OFF_ON) - 1)
#endif
#if defined(ENABLE_FEAT_F4HWN) && defined(ENABLE_FEAT_F4HWN_LOGO_SAV)
MENU_SETTING(MENU_SET_SAV, gSetting_set_sav, 0, SET_SAV_LEN - 1)
#endif
-187
View File
@@ -1,187 +0,0 @@
/*
* REGA Alam and Test
* ==================
*
* This bit of code is to implement the REGA test and alarm functions.
* This function sends out a fixed ZVEI tone sequence for testing or alarm purposes.
* Further information: https://www.rega.ch/en/our-missions/sites-and-infrastructure/emergency-radio
*
* There are two REGA ACTIONS: Test and Alarm.
* The Test action sends out a fixed ZVEI tone sequence for testing purposes.
* The Alarm action sends out a fixed ZVEI tone sequence for alarm purposes.
*
* These actions can be assigned to a key in the settings menu.
*
* The Test/Alarm function will perform the following actions:
* - Set the radio to transmit mode
* - Wait 100ms to allow the radio to switch to transmit mode and stabilize the tramsmitter
* - Send out the ZVEI tone sequence
* - Wait 100ms to allow the radio to finish transmitting
* - Set the radio back to receive mode
*
* The ZVEI tone squence for alarm is: 21414
* The ZVEI tone squence for test is: 21301
*
* To save space the two tone sequences are stored as precalculated register values.
* This avoids the need to calculate the register values at runtime.
* They are hardcoded as array of 32bit integers in the rega.h file.
*
* Copyright 2025 Markus Bärtschi
* https://github.com/markusb
*
* 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.
*/
#include <string.h>
#include <stdio.h>
#include <stdbool.h>
#include "rega.h"
#include "radio.h"
#include "action.h"
#include "misc.h"
#include "settings.h"
#include "functions.h"
#include "driver/bk4819.h"
#include "driver/st7565.h"
#include "driver/system.h"
#include "driver/gpio.h"
#include "bsp/dp32g030/gpio.h"
#include "ui/helper.h"
#include "ui/ui.h"
const uint16_t rega_alarm_tones[5] = {
1160, // 2 1160Hz
1060, // 1 1060Hz
1400, // 4 1400Hz
1060, // 1 1060Hz
1400, // 4 1400Hz
};
const uint16_t rega_test_tones[5] = {
1160, // 2 1160Hz
1060, // 1 1060Hz
1260, // 3 1260Hz
2400, // 0 2400Hz
1060, // 1 1060Hz
};
// Global variable to pass the message to the display
char rega_message[16];
// Transmit the ZVEI tone sequence for alarm
void ACTION_RegaAlarm()
{
const char message[16] = "REGA Alarm";
REGA_TransmitZvei(rega_alarm_tones,message);
}
// Tranmit the ZVEI tone sequence for test
void ACTION_RegaTest()
{
const char message[16] = "REGA Test";
REGA_TransmitZvei(rega_test_tones,message);
}
// Display the REGA message on the screen
void UI_DisplayREGA()
{
UI_DisplayClear();
UI_DisplayPopup(rega_message);
ST7565_BlitFullScreen();
}
// Transmit a ZVEI tone sequence on the REGA frequency
// Configures the radio on VFO A with the required parameters
// tones: array of 5 ZVEI tones
// message: message to display on the screen
void REGA_TransmitZvei(const uint16_t tones[],const char message[])
{
// Copy the message text
strncpy(rega_message,message,16);
// Trigger the display
gScreenToDisplay = DISPLAY_REGA;
// Flash the green LED twice
BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, true);
SYSTEM_DelayMs(70);
BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, false);
SYSTEM_DelayMs(50);
BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, true);
SYSTEM_DelayMs(70);
BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, false);
// Select VFO A
gEeprom.TX_VFO = 0;
gEeprom.ScreenChannel[gEeprom.TX_VFO] = gEeprom.FreqChannel[gEeprom.TX_VFO];
gRxVfo = gTxVfo;
gRequestSaveVFO = true;
gVfoConfigureMode = VFO_CONFIGURE_RELOAD;
// Set the REGA frequency, no offset
gTxVfo->freq_config_RX.Frequency = REGA_FREQUENCY;
gTxVfo->freq_config_TX = gTxVfo->freq_config_RX;
gTxVfo->TX_OFFSET_FREQUENCY = 0;
// Set the modulation to FM narrow
gTxVfo->Modulation = MODULATION_FM;
gTxVfo->CHANNEL_BANDWIDTH = BK4819_FILTER_BW_NARROW;
// Set Tx Squelch Tone
gTxVfo->freq_config_TX.CodeType = CODE_TYPE_CONTINUOUS_TONE;
gTxVfo->freq_config_TX.Code = REGA_CTCSS_FREQ_INDEX;
BK4819_SetCTCSSFrequency(CTCSS_Options[gTxVfo->freq_config_TX.Code]);
// Turn Rx squelch tone off
gTxVfo->freq_config_RX.CodeType = CODE_TYPE_OFF;
// Set the transmit power to high
gTxVfo->OUTPUT_POWER = OUTPUT_POWER_HIGH;
// Lock the keyboard
gEeprom.KEY_LOCK = true;
gRequestSaveChannel = 1;
gRequestSaveSettings = true;
// Configure the receiver
BK4819_RX_TurnOn();
// Set the radio to transmit mode
RADIO_PrepareCssTX();
FUNCTION_Select(FUNCTION_TRANSMIT);
// Wait to allow the radio to switch to transmit mode and stabilize the transmitter
SYSTEM_DelayMs(ZVEI_PRE_LENGTH_MS);
// Send out the ZVEI2 tone sequence
for (int i = 0; i < ZVEI_NUM_TONES; i++)
{
BK4819_PlaySingleTone(tones[i], ZVEI_TONE_LENGTH_MS, 100, true);
SYSTEM_DelayMs(ZVEI_PAUSE_LENGTH_MS);
}
// Wait to allow the radio to finish transmitting
SYSTEM_DelayMs(ZVEI_POST_LENGTH_MS);
// Flash the green LED twice
BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, true);
SYSTEM_DelayMs(70);
BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, false);
SYSTEM_DelayMs(70);
BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, true);
SYSTEM_DelayMs(70);
BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, false);
// Display the normal screen
gRequestDisplayScreen = DISPLAY_MAIN;
}
-65
View File
@@ -1,65 +0,0 @@
/*
* REGA Alam and Test
* ==================
*
* This function sends out a fixed ZVEI tone sequence for testing or alarm purposes for the Alpine REGA alarm channel.
* Further information: https://www.rega.ch/en/our-missions/sites-and-infrastructure/emergency-radio
*
* There are two REGA ACTIONS: Test and Alarm.
* The Test action sends out a fixed ZVEI tone sequence for testing purposes.
* The Alarm action sends out a fixed ZVEI tone sequence for alarm purposes.
*
* These actions can be assigned to a key in the settings menu.
*
* The Test/Alarm function will perform the following actions:
* - Set the radio configuration to the REGA frequency, CTCSS, FM, full power etc.
* - Start transmitting
* - Wait 100ms to allow the radio to switch to transmit mode and stabilize the transmitter
* - Send out the ZVEI tone sequence
* - Wait 100ms to allow the radio to finish transmitting
* - Set the radio back to receive mode
*
* The REGA frequency is 161.300 Mhz
*
* The ZVEI tone squence for alarm is: 21414
* The ZVEI tone squence for test is: 21301
*
* The two tone sequences are hardcoded in two arrays
*
* Copyright 2025 Markus Bärtschi
*
* https://github.com/markusb
*
*
* 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.
*/
#ifndef REGA_H
#define REGA_H
#include <stdint.h>
#define ZVEI_NUM_TONES 5
#define ZVEI_TONE_LENGTH_MS 70 // 70
#define ZVEI_PAUSE_LENGTH_MS 10 // 10
#define ZVEI_PRE_LENGTH_MS 300
#define ZVEI_POST_LENGTH_MS 100
#define REGA_CTCSS_FREQ_INDEX 18 // dcs.c: CTCSS_Options[18] = 1230
#define REGA_FREQUENCY 16130000 // 43370000 16130000
void ACTION_RegaAlarm(void);
void ACTION_RegaTest(void);
void UI_DisplayREGA(void);
void REGA_TransmitZvei(const uint16_t[], const char[]);
#endif
+1430
View File
File diff suppressed because it is too large. Load diff
+91
View File
@@ -0,0 +1,91 @@
/* Copyright 2026
*
* Licensed under the Apache License, Version 2.0.
*/
#ifndef APP_RXTX_LOG_H
#define APP_RXTX_LOG_H
#include <stdbool.h>
#include <stdint.h>
#include "driver/keyboard.h"
#include "functions.h"
#include "radio.h"
#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG
#define RXTX_LOG_VISIBLE_COUNT 512
// Field order mirrors the leading fields of RXTX_LogFlashEntry_t
// (rxtx_log.c) so both layouts match byte-for-byte up to and including
// battVolt, copied in one pass. Scan-only fields (sequence) sit past the
// copied prefix in the flash layout and are not cached in RAM.
// The channel name is not stored in flash: it is resolved from `channel` when
// exporting the K5Viewer packet.
typedef struct {
uint32_t frequency;
uint32_t trafficSeq;
uint16_t durationSeconds;
uint16_t channel;
uint8_t flags;
uint8_t sMeter;
uint8_t battVolt;
} RXTX_LogEntry_t;
#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER
#define RXTX_LOG_K5VIEWER_VERSION 2u
#define RXTX_LOG_K5VIEWER_ROW_COUNT 64u
#define RXTX_LOG_K5VIEWER_HISTORY_ROW_COUNT 64u
#define RXTX_LOG_K5VIEWER_HISTORY_START 0xFFFFFFFFu
#define RXTX_LOG_K5VIEWER_NAME_LENGTH 10u
#define RXTX_LOG_K5VIEWER_STATUS_ACTIVE (1u << 0)
#define RXTX_LOG_K5VIEWER_STATUS_HAS_TRAFFIC (1u << 1)
#define RXTX_LOG_K5VIEWER_STATUS_CLEARING (1u << 2)
#define RXTX_LOG_K5VIEWER_STATUS_DISABLED (1u << 3)
#define RXTX_LOG_K5VIEWER_STATUS_PACKET_SIZE 4u
typedef struct __attribute__((packed)) {
uint32_t frequency;
uint32_t trafficSeq;
uint16_t durationSeconds;
uint16_t channel;
uint8_t flags;
uint8_t meter;
uint8_t battVolt;
char channelName[RXTX_LOG_K5VIEWER_NAME_LENGTH];
} RXTX_LogK5ViewerRow_t;
// On-wire packet layout: a disabled log sends only the 4-byte status header.
// An enabled log continues with one live row and
// RXTX_LOG_K5VIEWER_ROW_COUNT row slots, zero-padded past the last valid row.
// The packet is streamed row by row and never built whole in RAM.
#define RXTX_LOG_K5VIEWER_PACKET_SIZE (RXTX_LOG_K5VIEWER_STATUS_PACKET_SIZE + ((RXTX_LOG_K5VIEWER_ROW_COUNT + 1u) * sizeof(RXTX_LogK5ViewerRow_t)))
#define RXTX_LOG_K5VIEWER_HISTORY_PACKET_SIZE (RXTX_LOG_K5VIEWER_HISTORY_ROW_COUNT * sizeof(RXTX_LogK5ViewerRow_t))
uint32_t RXTX_LOG_K5ViewerSignature(void);
void RXTX_LOG_SendK5ViewerPacket(void (*send)(const uint8_t *data, uint16_t size));
// Sends the page of history rows below `beforeSeq` (HISTORY_START begins
// a dump below the live packet's coverage) and returns the bound for the
// next page, 0 once the visible history is exhausted.
uint32_t RXTX_LOG_SendK5ViewerHistoryPage(uint32_t beforeSeq, void (*send)(const uint8_t *data, uint16_t size));
#endif
void RXTX_LOG_Init(void);
bool RXTX_LOG_IsEnabled(void);
void RXTX_LOG_BeginRx(const VFO_Info_t *vfo, FUNCTION_Type_t function);
void RXTX_LOG_BeginTx(const VFO_Info_t *vfo);
void RXTX_LOG_EndActive(void);
void RXTX_LOG_Suspend(void);
void RXTX_LOG_Resume(void);
void RXTX_LOG_Task10ms(void);
void RXTX_LOG_Tick500ms(void);
const char *RXTX_LOG_GetFilterName(void);
void ACTION_RxTxLog(void);
void RXTX_LOG_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld);
void UI_DisplayRxTxLog(void);
#endif
#endif
+23 -21
View File
@@ -14,10 +14,13 @@
* limitations under the License.
*/
#include "app/spectrum.h"
#include "am_fix.h"
#include "audio.h"
#include "misc.h"
#if defined(ENABLE_UART) || defined(ENABLE_USB)
#include "app/uart.h"
#endif
#ifdef ENABLE_SCAN_RANGES
#include "chFrScanner.h"
#endif
@@ -27,8 +30,8 @@
#include "ui/helper.h"
#include "ui/main.h"
#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT
#include "screenshot.h"
#ifdef ENABLE_FEAT_F4HWN_K5VIEWER
#include "k5viewer.h"
#endif
#ifdef ENABLE_FEAT_F4HWN_SPECTRUM
@@ -73,7 +76,7 @@ static uint16_t blacklistFreqs[15];
static uint8_t blacklistFreqsIdx;
#endif
const char *bwOptions[] = {"25", "12.5", "6.25"};
const char *const bwOptions[] = {"25", "12.5", "6.25"};
const uint8_t modulationTypeTuneSteps[] = {100, 50, 10};
const uint8_t modTypeReg47Values[] = {1, 7, 5};
@@ -176,7 +179,7 @@ char freqInputString[11];
uint8_t menuState = 0;
uint16_t listenT = 0;
RegisterSpec registerSpecs[] = {
const RegisterSpec registerSpecs[] = {
{},
{"LNAs", BK4819_REG_13, 8, 0b11, 1},
{"LNA", BK4819_REG_13, 5, 0b111, 1},
@@ -604,10 +607,6 @@ uint16_t GetRssi()
// Discard first read (AGC may still be transitioning), keep second
BK4819_GetRSSI();
uint16_t rssi = BK4819_GetRSSI();
#ifdef ENABLE_AM_FIX
if (settings.modulationType == MODULATION_AM && gSetting_AM_fix)
rssi += AM_fix_get_gain_diff() * 2;
#endif
return rssi;
}
@@ -1729,17 +1728,23 @@ static uint8_t GetScanStepTextWidth()
return (sprintf(NULL, "%u", GetScanStep() / 100) + 4) * 4; // "%u.%02uk", 4 px advance per char
}
static uint8_t GetBwTextWidth()
{
return (strlen(bwOptions[settings.listenBw]) * 4) + 4; // 4 px advance per char
}
static void DrawRssiTriggerLevel(const uint8_t *topY)
{
if (settings.rssiTriggerLevel == RSSI_MAX_VALUE || monitorMode)
return;
uint8_t scanStepTextWidth = GetScanStepTextWidth();
uint8_t bwTextWidth = GetBwTextWidth();
uint8_t y = Rssi2Y(settings.rssiTriggerLevel);
for (uint8_t x = 0; x < 128; x += 2)
{
if (SpectrumColumnAtOrAboveY(topY, x, y))
continue;
if (y <= 12 && (x < scanStepTextWidth + 2 || x >= 114))
if (y <= 12 && (x < scanStepTextWidth + 2 || x >= 128 - bwTextWidth - 2))
continue;
if (gFrameBuffer[y / 8][x] & (1 << (y % 8)))
continue;
@@ -2461,21 +2466,15 @@ static void UpdateListening()
static void Tick()
{
#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT
#ifdef ENABLE_FEAT_F4HWN_K5VIEWER
// Parse incoming packets on every tick so serial keys are never missed,
// regardless of whether the screen needs redrawing.
SCREENSHOT_ParseInput();
K5VIEWER_ParseInput();
#endif
if (gNextTimeslice)
{
gNextTimeslice = false;
#ifdef ENABLE_AM_FIX
if (settings.modulationType == MODULATION_AM && !lockAGC)
{
AM_fix_10ms(vfo); // allow AM_Fix to apply its AGC action
}
#endif
BACKLIGHT_Update();
}
@@ -2532,9 +2531,9 @@ static void Tick()
if (redrawScreen || ++renderTimer >= RENDER_PERIOD_TICKS)
{
Render();
// For screenshot
#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT
SCREENSHOT_Update(false);
// For K5Viewer
#ifdef ENABLE_FEAT_F4HWN_K5VIEWER
K5VIEWER_Update(false);
#endif
redrawScreen = false;
renderTimer = 0;
@@ -2608,6 +2607,9 @@ void APP_RunSpectrum()
while (isInitialized)
{
#if defined(ENABLE_UART) || defined(ENABLE_USB)
UART_ServiceCommands();
#endif
Tick();
}
+581 -29
View File
@@ -20,10 +20,13 @@
#if !defined(ENABLE_OVERLAY)
#include "py32f0xx.h"
#endif
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
#include "app/fm.h"
#endif
#include "app/uart.h"
#ifdef ENABLE_AIRCOPY_UART
#include "app/aircopy.h"
#endif
#include "board.h"
#include "py32f071_ll_dma.h"
#include "driver/backlight.h"
@@ -45,6 +48,17 @@
#include "settings.h"
#include "version.h"
#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT
#include "driver/mb_flash.h"
#ifdef ENABLE_FEAT_F4HWN_EXT_FLASH_RW
#include "driver/py25q16.h"
#endif
#endif
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_APPS
#include "apps/app_overlay.h"
#endif
#if defined(ENABLE_OVERLAY)
#include "sram-overlay.h"
#endif
@@ -60,6 +74,10 @@
// !! Make sure this is correct!
#define MAX_REPLY_SIZE 144
#ifdef ENABLE_AIRCOPY_UART
#define UART_CMD_AIRCOPY 0x0740u
#endif
typedef struct {
uint16_t ID;
uint16_t Size;
@@ -193,7 +211,8 @@ typedef union
#ifdef ENABLE_USB
static void SendReply_VCP(void *pReply, uint16_t Size)
{
static uint8_t VCP_ReplyBuf[MAX_REPLY_SIZE + sizeof(Header_t) + sizeof(Footer_t)];
static uint8_t VCP_ReplyBuf[MAX_REPLY_SIZE + sizeof(Header_t) + sizeof(Footer_t)]
__attribute__((aligned(4)));
// !!
if (Size > MAX_REPLY_SIZE)
@@ -201,11 +220,11 @@ static void SendReply_VCP(void *pReply, uint16_t Size)
return;
}
memcpy(VCP_ReplyBuf + sizeof(Header_t), pReply, Size);
uint8_t *pBody = VCP_ReplyBuf + sizeof(Header_t);
uint8_t *pFooter = pBody + Size;
Header_t *pHeader = (Header_t *)VCP_ReplyBuf;
Footer_t *pFooter = (Footer_t *)(VCP_ReplyBuf + sizeof(Header_t) + Size);
pReply = VCP_ReplyBuf + sizeof(Header_t);
memcpy(pBody, pReply, Size);
pReply = pBody;
if (bIsEncrypted)
{
@@ -215,23 +234,29 @@ static void SendReply_VCP(void *pReply, uint16_t Size)
pBytes[i] ^= Obfuscation[i % 16];
}
pHeader->ID = 0xCDAB;
pHeader->Size = Size;
/* Build the transport header/footer byte by byte. The reply body may have
* an odd size, so pFooter is not necessarily half-word aligned; casting it
* to Footer_t and storing ID as uint16_t can HardFault on Cortex-M0+. */
VCP_ReplyBuf[0] = 0xAB;
VCP_ReplyBuf[1] = 0xCD;
VCP_ReplyBuf[2] = (uint8_t)(Size & 0xFFu);
VCP_ReplyBuf[3] = (uint8_t)(Size >> 8);
// VCP_Send((uint8_t *)&Header, sizeof(Header));
// VCP_Send(pReply, Size);
if (bIsEncrypted)
{
pFooter->Padding[0] = Obfuscation[(Size + 0) % 16] ^ 0xFF;
pFooter->Padding[1] = Obfuscation[(Size + 1) % 16] ^ 0xFF;
pFooter[0] = Obfuscation[(Size + 0) % 16] ^ 0xFF;
pFooter[1] = Obfuscation[(Size + 1) % 16] ^ 0xFF;
}
else
{
pFooter->Padding[0] = 0xFF;
pFooter->Padding[1] = 0xFF;
pFooter[0] = 0xFF;
pFooter[1] = 0xFF;
}
pFooter->ID = 0xBADC;
pFooter[2] = 0xDC;
pFooter[3] = 0xBA;
// VCP_Send((uint8_t *)&Footer, sizeof(Footer));
@@ -249,6 +274,7 @@ static void SendReply(uint32_t Port, void *pReply, uint16_t Size)
}
#endif
#if defined(ENABLE_UART)
Header_t Header;
Footer_t Footer;
@@ -279,15 +305,61 @@ static void SendReply(uint32_t Port, void *pReply, uint16_t Size)
Footer.ID = 0xBADC;
UART_Send(&Footer, sizeof(Footer));
#endif
}
#ifdef ENABLE_AIRCOPY_UART
void UART_SendAircopy(const uint16_t *data, uint8_t words)
{
static union {
uint8_t Bytes[sizeof(Header_t) + 2u +
AIRCOPY_FRAME_WORDS_MAX * sizeof(uint16_t) + sizeof(uint16_t)];
struct __attribute__((packed, aligned(4))) {
Header_t Header;
uint8_t Words;
uint8_t Reserved;
uint16_t Data[AIRCOPY_FRAME_WORDS_MAX];
} Packet;
} Frame;
Header_t transportHeader;
const uint16_t transportFooter = 0xBADCu;
if (words == 0u || words > AIRCOPY_FRAME_WORDS_MAX)
return;
Frame.Packet.Header.ID = UART_CMD_AIRCOPY;
Frame.Packet.Header.Size = (uint16_t)(2u + words * sizeof(Frame.Packet.Data[0]));
Frame.Packet.Words = words;
Frame.Packet.Reserved = 0;
memcpy(Frame.Packet.Data, data, words * sizeof(Frame.Packet.Data[0]));
const uint16_t bodySize = (uint16_t)(sizeof(Frame.Packet.Header) +
Frame.Packet.Header.Size);
const uint16_t crc = CRC_Calculate(Frame.Bytes, bodySize);
Frame.Bytes[bodySize] = (uint8_t)crc;
Frame.Bytes[bodySize + 1u] = (uint8_t)(crc >> 8);
// Peer radios feed this packet back through UART_IsCommandAvailable(), so
// unlike a PC reply it must carry a real CRC instead of the 0xFFFF marker.
for (uint16_t i = 0; i < bodySize + sizeof(crc); i++)
Frame.Bytes[i] ^= Obfuscation[i % 16u];
transportHeader.ID = 0xCDABu;
transportHeader.Size = bodySize;
UART_Send(&transportHeader, sizeof(transportHeader));
UART_Send(Frame.Bytes, bodySize + sizeof(crc));
UART_Send(&transportFooter, sizeof(transportFooter));
}
#endif
static void SendVersion(uint32_t Port)
{
REPLY_0514_t Reply;
Reply.Data.Padding[0] = Reply.Data.Padding[1] = 0;
Reply.Header.ID = 0x0515;
Reply.Header.Size = sizeof(Reply.Data);
strcpy(Reply.Data.Version, Version);
strncpy(Reply.Data.Version, Version, sizeof(Reply.Data.Version));
Reply.Data.bHasCustomAesKey = bHasCustomAesKey;
Reply.Data.bIsInLockScreen = bIsInLockScreen;
Reply.Data.Challenge[0] = gChallenge[0];
@@ -342,14 +414,14 @@ static void CMD_0514(uint32_t Port, const uint8_t *pBuffer)
}
#endif
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
gFmRadioCountdown_500ms = fm_radio_countdown_500ms;
#endif
gSerialConfigCountDown_500ms = 12; // 6 sec
if (gEeprom.BACKLIGHT_TIME < 61) // backlight is set to be always on
BACKLIGHT_TurnOff(); // turn the LCD backlight off
// Backlight left untouched: a serial session is neutral, so the normal BLTime
// inactivity countdown keeps running from the last keypress (no forced turn-off).
SendVersion(Port);
}
@@ -386,10 +458,14 @@ static void CMD_051B(uint32_t Port, const uint8_t *pBuffer)
gSerialConfigCountDown_500ms = 12; // 6 sec
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
gFmRadioCountdown_500ms = fm_radio_countdown_500ms;
#endif
// Reject reads that do not fit in the fixed-size reply buffer.
if (pCmd->Size > sizeof(Reply.Data.Data))
return;
memset(&Reply, 0, sizeof(Reply));
Reply.Header.ID = 0x051C;
Reply.Header.Size = pCmd->Size + 4;
@@ -403,7 +479,7 @@ static void CMD_051B(uint32_t Port, const uint8_t *pBuffer)
{
EEPROM_ReadBuffer(pCmd->Offset, Reply.Data.Data, pCmd->Size);
}
SendReply(Port, &Reply, pCmd->Size + 8);
}
@@ -417,6 +493,14 @@ static void CMD_051D(uint32_t Port, const uint8_t *pBuffer)
uint32_t Timestamp = 0;
/* Bound the write against the received frame: Data[] must hold pCmd->Size
* bytes, otherwise the loop below would read adjacent RAM and persist it to
* EEPROM (memory disclosure). A non-multiple-of-8 Size is NOT rejected: the
* Size/8 loop simply truncates the sub-page tail, matching the historical
* behavior CHIRP relies on for its final (unaligned) config block. */
if (pCmd->Header.Size < 8u + pCmd->Size)
return;
if(0) {}
#if defined(ENABLE_UART)
else if (Port == UART_PORT_UART)
@@ -442,7 +526,7 @@ static void CMD_051D(uint32_t Port, const uint8_t *pBuffer)
bReloadEeprom = false;
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
gFmRadioCountdown_500ms = fm_radio_countdown_500ms;
#endif
@@ -465,12 +549,16 @@ static void CMD_051D(uint32_t Port, const uint8_t *pBuffer)
if ((Offset < 0x0E98 || Offset >= 0x0EA0) || !bIsInLockScreen || pCmd->bAllowPassword)
{
EEPROM_WriteBuffer(Offset, &pCmd->Data[i * 8U]);
EEPROM_WriteBuffer(Offset, &pCmd->Data[i * 8U], 8);
}
}
if (bReloadEeprom)
#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT_HOT_CFG
SETTINGS_InitEEPROM(false);
#else
SETTINGS_InitEEPROM();
#endif
}
SendReply(Port, &Reply, sizeof(Reply));
@@ -512,7 +600,7 @@ static void CMD_052D(uint32_t Port, const uint8_t *pBuffer)
REPLY_052D_t Reply;
bool bIsLocked;
#ifdef ENABLE_FMRADIO
#ifdef ENABLE_FMRADIO_EMBEDDED
gFmRadioCountdown_500ms = fm_radio_countdown_500ms;
#endif
Reply.Header.ID = 0x052E;
@@ -543,6 +631,7 @@ static void CMD_052D(uint32_t Port, const uint8_t *pBuffer)
gIsLocked = bIsLocked;
Reply.Data.bIsLocked = bIsLocked;
Reply.Data.Padding[0] = Reply.Data.Padding[1] = Reply.Data.Padding[2] = 0;
SendReply(Port, &Reply, sizeof(Reply));
}
@@ -593,8 +682,8 @@ static void CMD_052F(uint32_t Port, const uint8_t *pBuffer)
}
#endif
if (gEeprom.BACKLIGHT_TIME < 61) // backlight is set to be always on
BACKLIGHT_TurnOff(); // turn the LCD backlight off
// Backlight left untouched: a serial session is neutral, so the normal BLTime
// inactivity countdown keeps running from the last keypress (no forced turn-off).
SendVersion(Port);
}
@@ -779,9 +868,29 @@ bool UART_IsCommandAvailable(uint32_t Port)
Crc = pUART_Command->Buffer[Size] | (pUART_Command->Buffer[Size + 1] << 8);
return CRC_Calculate(pUART_Command->Buffer, Size) == Crc;
return Size >= sizeof(Header_t) &&
pUART_Command->Header.Size <= Size - sizeof(Header_t) &&
CRC_Calculate(pUART_Command->Buffer, Size) == Crc;
}
#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT
/* Timestamp latched by the device-info handshake (0x0514) for this port. Slot
* writes/erases require it to match, like the EEPROM write command (CMD_051D). */
static uint32_t mb_port_timestamp(uint32_t Port)
{
#if defined(ENABLE_UART)
if (Port == UART_PORT_UART)
return UART_Timestamp;
#endif
#if defined(ENABLE_USB)
if (Port == UART_PORT_VCP)
return VCP_Timestamp;
#endif
(void)Port;
return 0;
}
#endif
void UART_HandleCommand(uint32_t Port)
{
UART_Command_t *pUART_Command;
@@ -806,6 +915,22 @@ void UART_HandleCommand(uint32_t Port)
switch (pUART_Command->Header.ID)
{
#ifdef ENABLE_AIRCOPY_UART
case UART_CMD_AIRCOPY:
{
const uint8_t words = pUART_Command->Data[0];
const uint16_t payloadSize = (uint16_t)(2u + words * sizeof(uint16_t));
if (Port == UART_PORT_UART &&
words > 0u && words <= AIRCOPY_FRAME_WORDS_MAX &&
pUART_Command->Header.Size == payloadSize)
{
AIRCOPY_StoreUartPacket(&pUART_Command->Data[2], words);
}
break;
}
#endif
case 0x0514:
CMD_0514(Port, pUART_Command->Buffer);
break;
@@ -852,6 +977,420 @@ void UART_HandleCommand(uint32_t Port)
#endif
break;
#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT
// ---- M4 slot management ("Firmware Slots") ------------------------
case 0x0720: // slot info: read the 64-byte header only (fast, no CRC)
{
if (pUART_Command->Header.Size < 1u) break; // needs Data[0] (slot)
uint8_t slot = pUART_Command->Data[0];
mb_slot_header_t hdr;
memset(&hdr, 0, sizeof(hdr));
uint8_t status = MB_SlotInfo(slot, &hdr);
struct __attribute__((packed)) {
Header_t Header;
uint8_t Slot;
uint8_t Status;
uint8_t Hdr[sizeof(mb_slot_header_t)];
} Reply;
Reply.Header.ID = 0x0721;
Reply.Header.Size = 2 + sizeof(mb_slot_header_t);
Reply.Slot = slot;
Reply.Status = status;
memcpy(Reply.Hdr, &hdr, sizeof(hdr));
SendReply(Port, &Reply, sizeof(Reply));
break;
}
case 0x0722: // slot erase: wipe the whole 128 KiB slot region
{
if (pUART_Command->Header.Size < 6u) break; // needs Data[0] slot + Data[2..5] timestamp
gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s)
uint8_t slot = pUART_Command->Data[0];
uint32_t ts = (uint32_t)pUART_Command->Data[2]
| ((uint32_t)pUART_Command->Data[3] << 8)
| ((uint32_t)pUART_Command->Data[4] << 16)
| ((uint32_t)pUART_Command->Data[5] << 24);
uint8_t status = (ts != mb_port_timestamp(Port))
? MB_ERR_AUTH : MB_SlotErase(slot);
struct __attribute__((packed)) {
Header_t Header;
uint8_t Slot;
uint8_t Status;
} Reply;
Reply.Header.ID = 0x0723;
Reply.Header.Size = 2;
Reply.Slot = slot;
Reply.Status = status;
SendReply(Port, &Reply, sizeof(Reply));
break;
}
case 0x0724: // slot write: program bytes at slot+offset (slot pre-erased)
{
if (pUART_Command->Header.Size < 12u)
break;
gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s)
uint8_t slot = pUART_Command->Data[0];
uint32_t offset = (uint32_t)pUART_Command->Data[2]
| ((uint32_t)pUART_Command->Data[3] << 8)
| ((uint32_t)pUART_Command->Data[4] << 16)
| ((uint32_t)pUART_Command->Data[5] << 24);
uint16_t len = (uint16_t)(pUART_Command->Data[6]
| ((uint16_t)pUART_Command->Data[7] << 8));
uint32_t ts = (uint32_t)pUART_Command->Data[8]
| ((uint32_t)pUART_Command->Data[9] << 8)
| ((uint32_t)pUART_Command->Data[10] << 16)
| ((uint32_t)pUART_Command->Data[11] << 24);
uint8_t status;
if (ts != mb_port_timestamp(Port))
status = MB_ERR_AUTH;
else if (len > pUART_Command->Header.Size - 12u)
status = MB_ERR_SIZE;
else
status = MB_SlotWrite(slot, offset, &pUART_Command->Data[12], len);
struct __attribute__((packed)) {
Header_t Header;
uint8_t Slot;
uint8_t Status;
} Reply;
Reply.Header.ID = 0x0725;
Reply.Header.Size = 2;
Reply.Slot = slot;
Reply.Status = status;
SendReply(Port, &Reply, sizeof(Reply));
break;
}
case 0x0726: // slot validate: full image CRC-32, no reflash
{
if (pUART_Command->Header.Size < 1u) break; // needs Data[0] (slot)
gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s)
uint8_t slot = pUART_Command->Data[0];
uint32_t crc = 0;
uint8_t status = MB_ValidateSlot(slot, NULL, &crc);
struct __attribute__((packed)) {
Header_t Header;
uint32_t Crc32; // offset 4: 4-byte aligned, no unaligned store
uint8_t Slot;
uint8_t Status;
} Reply;
Reply.Header.ID = 0x0727;
Reply.Header.Size = 6;
Reply.Crc32 = crc;
Reply.Slot = slot;
Reply.Status = status;
SendReply(Port, &Reply, sizeof(Reply));
break;
}
case 0x0728: // config reset: wipe the 64 KiB of a config bank (1..4)
{
if (pUART_Command->Header.Size < 6u) break; // needs Data[0] bank + Data[2..5] timestamp
gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s)
uint8_t bank = pUART_Command->Data[0];
uint32_t ts = (uint32_t)pUART_Command->Data[2]
| ((uint32_t)pUART_Command->Data[3] << 8)
| ((uint32_t)pUART_Command->Data[4] << 16)
| ((uint32_t)pUART_Command->Data[5] << 24);
uint8_t status = (ts != mb_port_timestamp(Port))
? MB_ERR_AUTH : MB_BankErase(bank);
struct __attribute__((packed)) {
Header_t Header;
uint8_t Bank; // echoes the erased bank (same wire layout as slot replies)
uint8_t Status;
} Reply;
Reply.Header.ID = 0x0729;
Reply.Header.Size = 2;
Reply.Bank = bank;
Reply.Status = status;
SendReply(Port, &Reply, sizeof(Reply));
break;
}
#ifdef ENABLE_FEAT_F4HWN_EXT_FLASH_RW
// ---- full external-flash dump / restore (host: UV Studio) ----------
// Raw access by physical address, bypassing the config-bank mapping;
// all four commands are timestamp-authenticated like the slot ops.
// Restore flow: erase each unprotected 4 KiB sector (0x073A), then
// program it in chunks (0x073C). The calibration sector is rejected.
case 0x0738: // read raw external flash by physical address (full-chip dump)
{
if (pUART_Command->Header.Size != 10u) break; // addr(4) + len(2) + timestamp(4)
gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s)
uint32_t addr = (uint32_t)pUART_Command->Data[0]
| ((uint32_t)pUART_Command->Data[1] << 8)
| ((uint32_t)pUART_Command->Data[2] << 16)
| ((uint32_t)pUART_Command->Data[3] << 24);
uint16_t len = (uint16_t)(pUART_Command->Data[4]
| ((uint16_t)pUART_Command->Data[5] << 8));
uint32_t ts = (uint32_t)pUART_Command->Data[6]
| ((uint32_t)pUART_Command->Data[7] << 8)
| ((uint32_t)pUART_Command->Data[8] << 16)
| ((uint32_t)pUART_Command->Data[9] << 24);
struct __attribute__((packed)) {
Header_t Header;
uint32_t Address; // echoes the requested physical address
uint16_t Size; // bytes returned (0 on error)
uint8_t Status; // MB_OK or an MB_ERR_* code
uint8_t Data[PY25Q16_RAW_CHUNK_SIZE]; // capped to fit MAX_REPLY_SIZE
} Reply;
memset(&Reply, 0, sizeof(Reply));
uint8_t status;
if (ts != mb_port_timestamp(Port))
status = MB_ERR_AUTH;
else if (len == 0u || len > sizeof(Reply.Data))
status = MB_ERR_SIZE;
else if (addr > PY25Q16_TOTAL_SIZE || len > PY25Q16_TOTAL_SIZE - addr)
status = MB_ERR_SIZE;
else
{
PY25Q16_ReadBufferPhysical(addr, Reply.Data, len);
status = MB_OK;
}
Reply.Header.ID = 0x0739;
Reply.Address = addr;
Reply.Size = (status == MB_OK) ? len : 0;
Reply.Status = status;
Reply.Header.Size = 7 + Reply.Size; // Address(4)+Size(2)+Status(1)+Data
SendReply(Port, &Reply, 11 + Reply.Size); // + Header(4)
break;
}
case 0x073A: // erase one 4 KiB external-flash sector by physical address
{
if (pUART_Command->Header.Size != 8u) break; // addr(4) + timestamp(4)
gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s)
uint32_t addr = (uint32_t)pUART_Command->Data[0]
| ((uint32_t)pUART_Command->Data[1] << 8)
| ((uint32_t)pUART_Command->Data[2] << 16)
| ((uint32_t)pUART_Command->Data[3] << 24);
uint32_t ts = (uint32_t)pUART_Command->Data[4]
| ((uint32_t)pUART_Command->Data[5] << 8)
| ((uint32_t)pUART_Command->Data[6] << 16)
| ((uint32_t)pUART_Command->Data[7] << 24);
uint8_t status;
if (ts != mb_port_timestamp(Port))
status = MB_ERR_AUTH;
else if (addr >= PY25Q16_TOTAL_SIZE ||
(addr % PY25Q16_SECTOR_SIZE) != 0u)
status = MB_ERR_SIZE;
else if (addr == PY25Q16_CALIBRATION_SECTOR_BASE)
status = MB_ERR_PROTECTED; // device-specific data is never restorable
else
{
PY25Q16_SectorErasePhysical(addr);
status = MB_OK;
}
struct __attribute__((packed)) {
Header_t Header;
uint32_t Address; // echoes the requested physical address
uint8_t Status; // MB_OK or an MB_ERR_* code
} Reply;
Reply.Header.ID = 0x073B;
Reply.Address = addr;
Reply.Status = status;
Reply.Header.Size = 5; // Address(4)+Status(1)
SendReply(Port, &Reply, 9); // + Header(4)
break;
}
case 0x073C: // program bytes into external flash by physical address (sector pre-erased)
{
if (pUART_Command->Header.Size < 10u) break; // fixed fields + data
gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s)
uint32_t addr = (uint32_t)pUART_Command->Data[0]
| ((uint32_t)pUART_Command->Data[1] << 8)
| ((uint32_t)pUART_Command->Data[2] << 16)
| ((uint32_t)pUART_Command->Data[3] << 24);
uint16_t len = (uint16_t)(pUART_Command->Data[4]
| ((uint16_t)pUART_Command->Data[5] << 8));
uint32_t ts = (uint32_t)pUART_Command->Data[6]
| ((uint32_t)pUART_Command->Data[7] << 8)
| ((uint32_t)pUART_Command->Data[8] << 16)
| ((uint32_t)pUART_Command->Data[9] << 24);
uint8_t status;
if (ts != mb_port_timestamp(Port))
status = MB_ERR_AUTH;
else if (len == 0u || len > PY25Q16_RAW_CHUNK_SIZE ||
len != pUART_Command->Header.Size - 10u)
status = MB_ERR_SIZE;
else if (addr > PY25Q16_TOTAL_SIZE || len > PY25Q16_TOTAL_SIZE - addr)
status = MB_ERR_SIZE;
else if (addr < PY25Q16_CALIBRATION_SECTOR_BASE + PY25Q16_SECTOR_SIZE &&
addr + len > PY25Q16_CALIBRATION_SECTOR_BASE)
status = MB_ERR_PROTECTED; // device-specific data is never restorable
else
{
PY25Q16_WriteBufferPhysical(addr, &pUART_Command->Data[10], len);
status = MB_OK;
}
struct __attribute__((packed)) {
Header_t Header;
uint32_t Address; // echoes the requested physical address
uint16_t Size; // bytes written (0 on error)
uint8_t Status; // MB_OK or an MB_ERR_* code
} Reply;
Reply.Header.ID = 0x073D;
Reply.Address = addr;
Reply.Size = (status == MB_OK) ? len : 0;
Reply.Status = status;
Reply.Header.Size = 7; // Address(4)+Size(2)+Status(1)
SendReply(Port, &Reply, 11); // + Header(4)
break;
}
case 0x073E: // CRC-32 of a physical external-flash range
{
if (pUART_Command->Header.Size != 12u) break; // addr(4) + len(4) + timestamp(4)
gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s)
uint32_t addr = (uint32_t)pUART_Command->Data[0]
| ((uint32_t)pUART_Command->Data[1] << 8)
| ((uint32_t)pUART_Command->Data[2] << 16)
| ((uint32_t)pUART_Command->Data[3] << 24);
uint32_t len = (uint32_t)pUART_Command->Data[4]
| ((uint32_t)pUART_Command->Data[5] << 8)
| ((uint32_t)pUART_Command->Data[6] << 16)
| ((uint32_t)pUART_Command->Data[7] << 24);
uint32_t ts = (uint32_t)pUART_Command->Data[8]
| ((uint32_t)pUART_Command->Data[9] << 8)
| ((uint32_t)pUART_Command->Data[10] << 16)
| ((uint32_t)pUART_Command->Data[11] << 24);
uint32_t crc = 0;
uint8_t status;
if (ts != mb_port_timestamp(Port))
status = MB_ERR_AUTH;
else if (len == 0u || len > PY25Q16_SECTOR_SIZE)
status = MB_ERR_SIZE;
else
status = MB_ExternalFlashCrc32(addr, len, &crc);
struct __attribute__((packed)) {
Header_t Header;
uint32_t Address; // echoes the requested physical address
uint32_t Size; // bytes covered by the CRC
uint32_t Crc32; // zlib-compatible CRC-32
uint8_t Status; // MB_OK or an MB_ERR_* code
} Reply;
Reply.Header.ID = 0x073F;
Reply.Address = addr;
Reply.Size = (status == MB_OK) ? len : 0;
Reply.Crc32 = (status == MB_OK) ? crc : 0;
Reply.Status = status;
Reply.Header.Size = 13; // Address(4)+Size(4)+CRC32(4)+Status(1)
SendReply(Port, &Reply, sizeof(Reply));
break;
}
#endif // ENABLE_FEAT_F4HWN_EXT_FLASH_RW
#endif
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_APPS
// ---- overlay-app slot management ("Apps") -------------------------
// Parallels the firmware-slot family (0x072x); targets the external-flash
// Apps region. External flash only, never brick-critical.
case 0x0730: // app slot info: read the 64-byte header only
{
if (pUART_Command->Header.Size < 1u) break; // needs Data[0] (slot)
gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s)
uint8_t slot = pUART_Command->Data[0];
app_header_t hdr;
memset(&hdr, 0, sizeof(hdr));
uint8_t status = APP_SlotInfo(slot, &hdr);
struct __attribute__((packed)) {
Header_t Header;
uint8_t Slot;
uint8_t Status;
uint8_t Hdr[sizeof(app_header_t)];
} Reply;
Reply.Header.ID = 0x0731;
Reply.Header.Size = 2 + sizeof(app_header_t);
Reply.Slot = slot;
Reply.Status = status;
memcpy(Reply.Hdr, &hdr, sizeof(hdr));
SendReply(Port, &Reply, sizeof(Reply));
break;
}
case 0x0732: // app slot erase: wipe the 8 KiB slot region, but the app's tagged settings (see APP_SlotErase)
{
if (pUART_Command->Header.Size < 6u) break; // needs Data[0] slot + Data[2..5] timestamp
gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s)
uint8_t slot = pUART_Command->Data[0];
uint32_t ts = (uint32_t)pUART_Command->Data[2]
| ((uint32_t)pUART_Command->Data[3] << 8)
| ((uint32_t)pUART_Command->Data[4] << 16)
| ((uint32_t)pUART_Command->Data[5] << 24);
uint8_t status = (ts != mb_port_timestamp(Port))
? APP_ERR_AUTH : APP_SlotErase(slot);
struct __attribute__((packed)) {
Header_t Header;
uint8_t Slot;
uint8_t Status;
} Reply;
Reply.Header.ID = 0x0733;
Reply.Header.Size = 2;
Reply.Slot = slot;
Reply.Status = status;
SendReply(Port, &Reply, sizeof(Reply));
break;
}
case 0x0734: // app slot write: program bytes at slot+offset (pre-erased)
{
if (pUART_Command->Header.Size < 12u)
break;
gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s)
uint8_t slot = pUART_Command->Data[0];
uint32_t offset = (uint32_t)pUART_Command->Data[2]
| ((uint32_t)pUART_Command->Data[3] << 8)
| ((uint32_t)pUART_Command->Data[4] << 16)
| ((uint32_t)pUART_Command->Data[5] << 24);
uint16_t len = (uint16_t)(pUART_Command->Data[6]
| ((uint16_t)pUART_Command->Data[7] << 8));
uint32_t ts = (uint32_t)pUART_Command->Data[8]
| ((uint32_t)pUART_Command->Data[9] << 8)
| ((uint32_t)pUART_Command->Data[10] << 16)
| ((uint32_t)pUART_Command->Data[11] << 24);
uint8_t status;
if (ts != mb_port_timestamp(Port))
status = APP_ERR_AUTH;
else if (len > pUART_Command->Header.Size - 12u)
status = APP_ERR_SIZE;
else
status = APP_SlotWrite(slot, offset, &pUART_Command->Data[12], len);
struct __attribute__((packed)) {
Header_t Header;
uint8_t Slot;
uint8_t Status;
} Reply;
Reply.Header.ID = 0x0735;
Reply.Header.Size = 2;
Reply.Slot = slot;
Reply.Status = status;
SendReply(Port, &Reply, sizeof(Reply));
break;
}
case 0x0736: // app slot validate: header only (code CRC is checked at launch)
{
if (pUART_Command->Header.Size < 1u) break; // needs Data[0] (slot)
gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s)
uint8_t slot = pUART_Command->Data[0];
uint8_t status = APP_ValidateSlot(slot, NULL);
if (status == APP_OK)
APP_NotifySlotChanged();
struct __attribute__((packed)) {
Header_t Header;
uint8_t Slot;
uint8_t Status;
} Reply;
Reply.Header.ID = 0x0737;
Reply.Header.Size = 2;
Reply.Slot = slot;
Reply.Status = status;
SendReply(Port, &Reply, sizeof(Reply));
break;
}
#endif
#ifdef ENABLE_UART_RW_BK_REGS
case 0x0601:
CMD_0601_ReadBK4819Reg(Port, pUART_Command->Buffer);
@@ -863,7 +1402,20 @@ void UART_HandleCommand(uint32_t Port)
#endif
} // switch
#ifdef ENABLE_FEAT_F4HWN_SCREENSHOT
gUART_LockScreenshot = 20; // lock screenshot
#ifdef ENABLE_FEAT_F4HWN_K5VIEWER
gUART_LockK5Viewer = 20; // lock the K5Viewer stream
#endif
}
}
void UART_ServiceCommands(void)
{
#ifdef ENABLE_USB
if (UART_IsCommandAvailable(UART_PORT_VCP))
UART_HandleCommand(UART_PORT_VCP);
#endif
#ifdef ENABLE_UART
if (UART_IsCommandAvailable(UART_PORT_UART))
UART_HandleCommand(UART_PORT_UART);
#endif
}
+5 -1
View File
@@ -19,6 +19,7 @@
#define APP_UART_H
#include <stdbool.h>
#include <stdint.h>
enum
{
@@ -32,6 +33,9 @@ enum
bool UART_IsCommandAvailable(uint32_t Port);
void UART_HandleCommand(uint32_t Port);
void UART_ServiceCommands(void);
#ifdef ENABLE_AIRCOPY_UART
void UART_SendAircopy(const uint16_t *data, uint8_t words);
#endif
#endif
+300
View File
@@ -0,0 +1,300 @@
/* 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.
*/
/*
* Overlay-app ABI (POC).
*
* A leaf, modal "overlay app" is a self-contained code blob stored in the
* external SPI flash and copied into the 4 KiB overlay RAM (the PY25Q16 sector
* cache) to run, then discarded. It is linked with NOCROSSREFS and must not
* reference resident firmware symbols: every service it needs is reached through
* this table, which the loader fills and passes to the entry point.
*
* void app_main(const app_api_t *api); // entry, at blob offset 0
*
* Both the firmware loader and the app include THIS header. Within one ABI
* major, app_api_t is append-only: existing fields may never move, disappear or
* change signature. Append a service and bump APP_API_LEVEL; only apps using
* that service need the new level. A breaking layout change bumps
* APP_ABI_MAJOR and resets APP_API_LEVEL to 1.
*/
#ifndef APPS_APP_API_H
#define APPS_APP_API_H
#include <stdint.h>
#include <stdbool.h>
/* API levels within ABI major 1, one per published release (what a release
* ships is frozen; services added before the next release join its level):
* 1 v6.0.0 baseline: every service up to and including beam_draw
* 2 ticks_ms, rand32, asset_read (+ app_header_t asset_size / asset_crc),
* idivmod, uidivmod (the resident division helpers), Labs system info,
* current and minimum-since-boot free stack/RAM margin */
#define APP_ABI_MAJOR 1u
#define APP_API_LEVEL 2u
/* Minimum API level of an app that ships read-only assets (pack_app.py). */
#define APP_API_ASSETS 2u
/* KEY codes mirrored from driver/keyboard.h (enum KEY_Code_e). Kept in sync by
* value so the app stays independent of the firmware headers. */
enum {
APP_KEY_0 = 0,
APP_KEY_1 = 1,
APP_KEY_2 = 2,
APP_KEY_3 = 3,
APP_KEY_4 = 4,
APP_KEY_5 = 5,
APP_KEY_6 = 6,
APP_KEY_7 = 7,
APP_KEY_8 = 8,
APP_KEY_9 = 9,
APP_KEY_MENU = 10,
APP_KEY_UP = 11,
APP_KEY_DOWN = 12,
APP_KEY_EXIT = 13,
APP_KEY_STAR = 14,
APP_KEY_F = 15,
APP_KEY_PTT = 16,
APP_KEY_INVALID = 19,
APP_KEY_SAVER = 0xFD, /* virtual: saver active, skip this app frame */
APP_KEY_WAKE = 0xFE, /* virtual: saver dismissed, redraw without action */
};
/* The framebuffer is the resident gFrameBuffer[FRAME_LINES][128]; the app draws
* into it and calls a blit_* to push it to the LCD. */
typedef uint8_t (*app_fb_t)[128];
/* Broadcast FM shared state (mirrors gEeprom.FM_*), read/written via fm_state. */
#define APP_FM_CH_MAX 48
typedef struct {
uint16_t freq_playing; /* current tuned frequency (0.1 MHz) */
uint16_t sel_freq; /* last VFO frequency */
uint8_t band; /* 0..3 */
uint8_t is_mr; /* memory mode */
uint8_t sel_ch; /* selected memory channel 0..47 */
} app_fm_state_t;
/* Pointer-free BEAM channel description. The resident bridge translates this
* stable ABI type to/from feature-dependent VFO_Info_t. */
typedef struct {
uint32_t rx_frequency;
uint32_t tx_offset_frequency;
uint8_t rx_code;
uint8_t tx_code;
uint8_t rx_codetype;
uint8_t tx_codetype;
uint8_t modulation;
uint8_t tx_offset_direction;
uint8_t tx_lock;
uint8_t busy_channel_lock;
uint8_t output_power;
uint8_t channel_bandwidth;
uint8_t frequency_reverse;
uint8_t dtmf_ptt_id_mode;
uint8_t dtmf_decoding_enable;
uint8_t step_setting;
uint8_t scrambling_type;
uint8_t band;
uint8_t scanlist;
uint8_t compander;
char name[16]; /* fixed-width wire field; NUL termination is not required */
} app_beam_channel_t;
_Static_assert(sizeof(app_beam_channel_t) == 44u,
"BEAM ABI/wire channel layout changed");
enum {
APP_BEAM_RX_WAIT = 0,
APP_BEAM_RX_READY = 1,
APP_BEAM_RX_ERROR = 2,
};
/* CRITICAL PERSISTENCE RULE
*
* The app executes from the RAM buffer also used as PY25Q16's sector cache.
* Therefore no API callback may erase or write external flash while app_main()
* is running: a read-modify-write would overwrite the executing app. Services
* such as cfg_save, fm_commit and beam_save must only stage resident RAM state;
* APP_LaunchOverlay commits it after app_main() returns. */
typedef struct app_api {
/* Fixed four-byte prefix; services remain naturally pointer-aligned. */
uint8_t abi_major; /* == APP_ABI_MAJOR */
uint8_t api_level; /* == APP_API_LEVEL */
uint16_t api_size; /* sizeof(app_api_t), for optional probing */
app_fb_t fb; /* -> gFrameBuffer */
/* ---- display ---- */
void (*display_clear)(void); /* UI_DisplayClear */
void (*status_clear)(void); /* UI_StatusClear */
void (*draw_line)(app_fb_t fb, int16_t x1, int16_t y1,
int16_t x2, int16_t y2, bool black); /* UI_DrawLineBuffer */
void (*draw_rect)(app_fb_t fb, int16_t x1, int16_t y1,
int16_t x2, int16_t y2, bool black); /* UI_DrawRectangleBuffer */
void (*print_bold)(const char *s, uint8_t start,
uint8_t end, uint8_t line); /* UI_PrintStringSmallBold */
void (*print_tiny)(const char *s, uint8_t x, uint8_t y,
bool statusbar, bool fill); /* GUI_DisplaySmallest */
void (*blit_full)(void); /* ST7565_BlitFullScreen */
void (*blit_line)(unsigned line); /* ST7565_BlitLine */
void (*blit_status)(void); /* ST7565_BlitStatusLine */
/* ---- input / system ---- */
uint8_t (*get_key)(void); /* KEYBOARD_GetKey, returns an APP_KEY_* code */
void (*delay_ms)(uint32_t ms); /* SYSTEM_DelayMs */
/* ---- audio / indicator ---- */
void (*play_tone)(uint16_t tone, uint16_t ms); /* full BK4819 tone burst + AF path */
void (*led)(bool on); /* green GPIO indicator */
/* ---- extra text drawing ---- */
void (*print_normal)(const char *s, uint8_t start, uint8_t end, uint8_t line); /* UI_PrintStringSmallNormal */
void (*print_inverse)(const char *s, uint8_t x, uint8_t line,
bool statusbar, bool fill, uint8_t endX); /* GUI_DisplaySmallestInverse */
void (*display_freq)(const char *s, uint8_t x, uint8_t y, bool statusbar); /* UI_DisplayFrequency (big font) */
/* ---- BK4819 radio access ---- */
int16_t (*rssi_dbm)(void); /* corrected RSSI of the RX VFO, dBm */
uint16_t (*bk_read)(uint8_t reg); /* BK4819_ReadRegister */
void (*bk_write)(uint8_t reg, uint16_t v);/* BK4819_WriteRegister */
void (*set_agc)(bool on); /* BK4819_SetAGC */
void (*set_af)(uint8_t mode); /* BK4819_SetAF (APP_AF_* below) */
void (*audio_path)(bool on); /* AUDIO_AudioPathOn/Off */
void (*prepare_tone)(void); /* BK4819_PrepareToPlayTone(true) */
void (*play_tone_raw)(uint16_t hz, uint16_t ms); /* BK4819_PlayToneRaw */
void (*tones_off_rx)(void); /* BK4819_TurnsOffTones_TurnsOnRX */
uint32_t (*rx_freq)(void); /* current RX VFO frequency (x10 Hz) */
/* ---- config persistence (deferred: staged now, committed on exit) ---- */
void (*cfg_load)(uint8_t *buf, uint8_t len); /* read the app's saved config bytes */
void (*cfg_save)(const uint8_t *buf, uint8_t len); /* stage bytes; resident commits after the app returns */
/* ---- battery + backlight ---- */
void (*draw_battery)(void); /* UI_DrawStatusBattery into the status line */
void (*battery_sample)(void); /* periodic ADC sample so the level stays live */
void (*backlight_on)(void); /* BACKLIGHT_TurnOn */
void (*backlight_update)(void); /* resident fade + BLTime service */
void (*audio_scope)(uint8_t line, bool active); /* shared MAIN microphone scope */
uint8_t *status_line; /* -> gStatusLine (for status-bar icons) */
/* ---- TX (beacon) ---- */
uint8_t (*tx_state)(void); /* 0 = OK to transmit, else a denial code */
void (*tx_set_params)(void); /* RADIO_SetTxParameters (key up: carrier+PA) */
void (*tx_tone)(uint16_t hz);/* BK4819_TransmitTone prime (MCW tone) */
void (*tx_mute)(bool on); /* key the tone on(false)/off(true) via TxMute */
void (*tx_end)(void); /* PA off + RADIO_SetupRegisters (back to RX) */
void (*tx_carrier)(bool on); /* gate the PA on/off (beacon CARR carrier keying) */
uint32_t (*tx_freq)(void); /* current TX VFO frequency (x10 Hz) */
void (*boot_callsign)(char *buf, uint8_t len); /* sanitised callsign from the boot message */
void (*print_string)(const char *s, uint8_t start, uint8_t end,
uint8_t line, uint8_t width); /* UI_PrintString (big font) */
/* ---- broadcast FM (BK1080), sovereign: no BK4819 dual-watch ---- */
void (*fm_enter)(uint16_t freq, uint8_t band); /* BK1080_Init + antenna filter + audio on */
void (*fm_exit)(void); /* audio off + BK1080_Init0 + restore filter */
void (*fm_set_freq)(uint16_t freq, uint8_t band);/* BK1080_SetFrequency (freq in 0.1 MHz) */
uint16_t (*fm_lo)(uint8_t band); /* band low limit (0.1 MHz) */
uint16_t (*fm_hi)(uint8_t band); /* band high limit (0.1 MHz) */
void (*fm_mute)(bool mute); /* BK1080_Mute */
int8_t (*fm_valid)(uint16_t freq, uint16_t lo); /* FM_CheckFrequencyLock: 0 = station */
uint16_t *fm_channels; /* -> gFM_Channels[APP_FM_CH_MAX], shared RAM r/w */
void (*fm_state)(app_fm_state_t *s, bool write);/* read/write the resident gEeprom.FM_* */
void (*fm_commit)(void); /* deferred SETTINGS_SaveFM (config + channels) */
/* ---- navigation (API level 1 baseline) ----
* Convert a raw APP_KEY_UP/DOWN into a semantic value direction:
* UV-K5 UP/DOWN -> +1/-1
* UV-K1 LEFT/RIGHT -> -1/+1
* Returns 0 for any other key. Keep get_key() raw for spatial controls. */
int8_t (*nav_dir)(uint8_t key);
/* ---- BEAM channel transfer ----
* APP_CAP_BEAM (v6.0.0, retired): every service below, FSK included.
* APP_CAP_BEAM2: only beam_prepare (tunes the channel, no FSK setup),
* beam_get, beam_save and beam_draw; the app drives the FSK modem through
* bk_read/bk_write, and beam_leave/send/rx/rx_poll are NULL. */
void (*beam_prepare)(void); /* tune the fixed narrow-band FSK channel */
void (*beam_leave)(void); /* defensively stop FSK before app return */
void (*beam_get)(app_beam_channel_t *channel); /* export selected VFO */
uint16_t (*beam_save)(const app_beam_channel_t *channel); /* first free MR, or 0xffff */
void (*beam_send)(uint16_t *packet); /* transmit one 36-word FSK packet */
void (*beam_rx)(bool start); /* arm or stop FSK reception */
uint8_t (*beam_rx_poll)(uint16_t *packet); /* APP_BEAM_RX_* */
void (*beam_draw)(const char *status); /* MAIN display with one BEAM center line */
/* ---- API level 2: time, randomness, read-only assets ---- */
/* Free-running millisecond clock with 10 ms resolution (SysTick). Compare
* with unsigned subtraction: (api->ticks_ms() - start) >= period. */
uint32_t (*ticks_ms)(void);
/* xorshift32 PRNG, never 0. The resident state persists across launches
* and is re-mixed with RSSI noise and SysTick jitter at each launch, so apps
* need no seed of their own. */
uint32_t (*rand32)(void);
/* Copy len bytes of this app's assets, starting at offset, into buf. The
* read is clamped to the packed asset size; returns the byte count copied
* (0 past the end or when the app has no assets). The loader verified the
* assets' CRC before launch. buf may live in the overlay (.bss) or on the
* stack. */
uint16_t (*asset_read)(uint16_t offset, void *buf, uint16_t len);
/* ---- API level 2: integer division ---- */
/* The resident run-time helpers (the Cortex-M0+ has no divide
* instruction), so an app needs no libgcc division of its own: its
* __aeabi_idivmod / __aeabi_uidivmod (and the __aeabi_idiv / __aeabi_uidiv
* aliases) just forward here. C division (truncated toward zero), the
* quotient in the low word (r0) and the remainder in the high word (r1), as
* the AEABI helpers return them; x / 0 gives 0, remainder x. */
uint64_t (*idivmod)(int32_t n, int32_t d);
uint64_t (*uidivmod)(uint32_t n, uint32_t d);
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_INFO
/* ---- API level 2: zero-code Labs system information ---- */
const char *sys_edition;
const char *sys_version;
const char *sys_build_date;
const char *sys_build_time;
const char *sys_build_commit;
const void *sys_flash_end;
const void *sys_ram_end;
const uint16_t *sys_battery_voltage;
const void *sys_battery_type;
unsigned int (*sys_battery_percent)(unsigned int voltage_10mV);
void (*sys_storage_read)(uint32_t address, void *buffer, uint32_t size);
/* ---- API level 2: stack watermark diagnostics ---- */
uint32_t (*sys_stack_free_now)(void);
uint32_t (*sys_stack_free_min)(void);
#endif
} app_api_t;
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_INFO
_Static_assert(sizeof(app_api_t) == 328u,
"Labs system information must add exactly 52 API bytes");
#endif
/* BK4819 AF modes for set_af (mirror driver/bk4819.h values). */
enum { APP_AF_MUTE = 0, APP_AF_FM = 1, APP_AF_AM = 7 };
/* Register ids the apps use (mirror driver/bk4819-regs.h). */
enum { APP_BK_REG_13 = 0x13 };
/* Entry point every app blob exports, placed at blob offset 0. */
typedef void (*app_entry_t)(const app_api_t *api);
#endif /* APPS_APP_API_H */
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
# Shared artwork helpers for overlay-app title screens (used by gen_assets.py).
#
# Canvas is a 128x64 1-bit picture. Canvas.pages() packs it the way the LCD
# buffers are laid out: 8 pages of 128 column bytes, LSB = top row; page 0 is
# the status line, pages 1..7 the frame buffer. An app streams the result into
# A->status_line and A->fb with two asset_read calls.
#
# The logo font draws letters as stroke skeletons: polylines on a 14-row grid
# (x right, y down), scaled, drawn with a square pen and sheared into italics.
# Corners are cut by diagonal segments (chamfered arcade look), and each word
# is outlined by a one-pixel ring drawn one blank pixel away from the letters.
W, H = 128, 64
PROMPT_TOP = 56 # page 7 stays blank: the app blinks its prompt there
LOGO = {
"A": (9, [[(0, 13), (0, 3), (3, 0), (6, 0), (9, 3), (9, 13)], [(0, 8), (9, 8)]]),
"B": (9, [[(0, 13), (0, 0), (7, 0), (9, 2), (9, 4), (7, 6), (0, 6)],
[(7, 6), (9, 8), (9, 11), (7, 13), (0, 13)]]),
"C": (9, [[(9, 0), (3, 0), (0, 3), (0, 10), (3, 13), (9, 13)]]),
"D": (9, [[(0, 0), (6, 0), (9, 3), (9, 10), (6, 13), (0, 13), (0, 0)]]),
"E": (9, [[(9, 0), (0, 0), (0, 13), (9, 13)], [(0, 6), (6, 6)]]),
"I": (6, [[(0, 0), (6, 0)], [(3, 0), (3, 13)], [(0, 13), (6, 13)]]),
"K": (9, [[(0, 0), (0, 13)], [(9, 0), (3, 6), (0, 6)], [(3, 6), (9, 12), (9, 13)]]),
"L": (8, [[(0, 0), (0, 13), (8, 13)]]),
"M": (12, [[(0, 13), (0, 0), (6, 6), (12, 0), (12, 13)]]),
"N": (9, [[(0, 13), (0, 0), (9, 13), (9, 0)]]),
"O": (9, [[(3, 0), (6, 0), (9, 3), (9, 10), (6, 13), (3, 13), (0, 10), (0, 3), (3, 0)]]),
"P": (9, [[(0, 13), (0, 0), (7, 0), (9, 2), (9, 5), (7, 7), (0, 7)]]),
"R": (9, [[(0, 13), (0, 0), (7, 0), (9, 2), (9, 5), (7, 7), (0, 7)], [(5, 7), (9, 11), (9, 13)]]),
"S": (9, [[(9, 0), (2, 0), (0, 2), (0, 4), (2, 6), (7, 6), (9, 8), (9, 11), (7, 13), (0, 13)]]),
"T": (10, [[(0, 0), (10, 0)], [(5, 0), (5, 13)]]),
"U": (9, [[(0, 0), (0, 10), (3, 13), (6, 13), (9, 10), (9, 0)]]),
"W": (12, [[(0, 0), (0, 13), (6, 7), (12, 13), (12, 0)]]),
}
LOGO_KX, LOGO_KY = 1.0, 1.1 # default skeleton scale
LOGO_BRUSH = 3 # stroke thickness in pixels
LOGO_GAP = 3 # pixels between letters
LOGO_SHEAR = 4 # one pixel of slant every LOGO_SHEAR rows
LOGO_RING = 2 # outline distance from the letters (0 = none)
def line(pts, x0, y0, x1, y1):
"""Bresenham segment into the pts set."""
dx, dy = abs(x1 - x0), -abs(y1 - y0)
sx, sy = (1 if x0 < x1 else -1), (1 if y0 < y1 else -1)
err = dx + dy
while True:
pts.add((x0, y0))
if x0 == x1 and y0 == y1:
return
e2 = 2 * err
if e2 >= dy:
err += dy
x0 += sx
if e2 <= dx:
err += dx
y0 += sy
def logo_glyph(ch, kx, ky):
"""(advance width, pixel set) of one logo letter, before shearing."""
width, polylines = LOGO[ch]
skeleton = set()
for poly in polylines:
pts = [(round(x * kx), round(y * ky)) for x, y in poly]
for a, b in zip(pts, pts[1:]):
line(skeleton, *a, *b)
pixels = {(x + dx, y + dy) for x, y in skeleton
for dx in range(LOGO_BRUSH) for dy in range(LOGO_BRUSH)}
return round(width * kx) + LOGO_BRUSH, pixels
class Canvas:
def __init__(self):
self.px = [[0] * W for _ in range(H)]
def set(self, x, y, on=1):
if 0 <= x < W and 0 <= y < H:
self.px[y][x] = on
def rect(self, x0, y0, x1, y1):
for y in range(y0, y1 + 1):
for x in range(x0, x1 + 1):
self.set(x, y)
def sprite(self, x, y, cols, scale=1):
"""Column-major sprite (LSB = top row), as the apps store them."""
for i, c in enumerate(cols):
for bit in range(8):
if c >> bit & 1:
for dx in range(scale):
for dy in range(scale):
self.set(x + i * scale + dx, y + bit * scale + dy)
def word(self, text, y, kx=LOGO_KX, ky=LOGO_KY):
"""Centered, outlined italic logo word; returns its letter height."""
height = round(13 * ky) + LOGO_BRUSH
glyphs = [logo_glyph(ch, kx, ky) for ch in text]
total = sum(w for w, _ in glyphs) + LOGO_GAP * (len(glyphs) - 1) + (height - 1) // LOGO_SHEAR
x = (W - total) // 2
solid = set()
for width, pixels in glyphs:
for gx, gy in pixels:
solid.add((x + gx + (height - 1 - gy) // LOGO_SHEAR, y + gy))
x += width + LOGO_GAP
grown = [solid] # solid dilated by 0, 1, ... pixels
for _ in range(LOGO_RING):
grown.append({(px + dx, py + dy) for px, py in grown[-1]
for dx in (-1, 0, 1) for dy in (-1, 0, 1)})
ring = grown[-1] - grown[-2] if LOGO_RING else set()
for px, py in solid | ring:
self.set(px, py)
return height
def two_words(self, first, second):
"""Logo on two lines from the top of the screen; returns the bottom row."""
top = LOGO_RING # keep the outline on screen
height = self.word(first, top)
second_top = top + height + 2 * LOGO_RING + 1
return second_top + self.word(second, second_top) + LOGO_RING
def pages(self):
"""Title-screen payload: status page then the 7 frame-buffer pages."""
if any(self.px[y][x] for y in range(PROMPT_TOP, H) for x in range(W)):
raise SystemExit("title: the bottom page is reserved for the prompt")
out = bytearray()
for page in range(H // 8):
for x in range(W):
b = 0
for bit in range(8):
if self.px[page * 8 + bit][x]:
b |= 1 << bit
out.append(b)
return bytes(out)
def preview(self, path, scale=4):
"""Binary PGM, LCD-like colours (dark pixels on a pale panel)."""
with open(path, "wb") as f:
f.write(b"P5 %d %d 255\n" % (W * scale, H * scale))
for y in range(H):
row = bytes((40 if self.px[y][x] else 200)
for x in range(W) for _ in range(scale))
f.write(row * scale)
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
# Shared builder for overlay-app read-only assets (served by api->asset_read).
#
# Each app has a gen_assets.py that declares its texts and data with this
# module, then calls Assets.main(): it writes the asset blob packed into the
# .app by pack_app.py, and a C header of offsets the app includes. The header
# is generated before compiling, so code and data can never drift apart.
#
# a = Assets("SNAKE")
# a.text("T_PAUSE", "PAUSE") # NUL-terminated string
# a.table("T_STATE", ["TX", "RX"]) # fixed-stride strings (+ _STRIDE)
# a.u16("SPR_HEAD", [0x6566, 0x0FD2]) # packed little-endian data
# a.main() # argv: <out.bin> <out.h>
#
# Texts come first, so their offsets stay small (cheap immediates in Thumb
# code). TEXT_MAX is the longest text including its NUL: the size of the
# app's shared text buffer, read in one asset_read by T().
import os, re, struct, sys
HERE = os.path.dirname(os.path.abspath(__file__))
def _cdefine(header, name):
pat = re.compile(r"^\s*#define\s+" + re.escape(name) + r"\s+(0[xX][0-9a-fA-F]+|\d+)[uUlL]*\b")
with open(os.path.join(HERE, header)) as f:
for line in f:
m = pat.match(line)
if m:
return int(m.group(1), 0)
sys.exit(f"{header}: #define {name} not found")
ASSET_MAX = _cdefine("app_overlay.h", "APP_ASSET_MAX")
class Assets:
def __init__(self, guard):
self.guard = guard
self.texts = [] # (name, bytes)
self.data = [] # (name, bytes)
self.extra = [] # (name, value) plain constants
self.names = set()
def _name(self, name):
if name in self.names or not re.fullmatch(r"[A-Z][A-Z0-9_]*", name):
sys.exit(f"asset name {name!r} is invalid or duplicated")
self.names.add(name)
def text(self, name, s):
self._name(name)
self.texts.append((name, s.encode("ascii") + b"\x00"))
def table(self, name, strings, stride=None):
"""Fixed-stride string table: entry i is at name + i * name_STRIDE."""
self._name(name)
width = stride or max(len(s) for s in strings) + 1
if any(len(s) + 1 > width for s in strings):
sys.exit(f"{name}: an entry does not fit the {width} B stride")
blob = b"".join(s.encode("ascii").ljust(width, b"\x00") for s in strings)
self.texts.append((name, blob))
self.const(name + "_STRIDE", width)
def const(self, name, value):
self._name(name)
self.extra.append((name, value))
def raw(self, name, payload):
self._name(name)
self.data.append((name, bytes(payload)))
def u8(self, name, values): self.raw(name, struct.pack(f"<{len(values)}B", *values))
def i8(self, name, values): self.raw(name, struct.pack(f"<{len(values)}b", *values))
def u16(self, name, values): self.raw(name, struct.pack(f"<{len(values)}H", *values))
def i16(self, name, values): self.raw(name, struct.pack(f"<{len(values)}h", *values))
def u32(self, name, values): self.raw(name, struct.pack(f"<{len(values)}I", *values))
def build(self):
blob = bytearray()
lines = []
text_max = 1
strides = dict(self.extra)
for name, payload in self.texts:
lines.append((name, len(blob), len(payload)))
text_max = max(text_max, strides.get(name + "_STRIDE", len(payload)))
blob += payload
for name, payload in self.data: # asset_read copies bytes: no alignment needed
lines.append((name, len(blob), len(payload)))
blob += payload
if len(blob) > ASSET_MAX:
sys.exit(f"assets {len(blob)} B exceed the {ASSET_MAX} B asset area")
return bytes(blob), lines, text_max
def main(self):
if len(sys.argv) != 3:
sys.exit("usage: gen_assets.py <out.bin> <out.h>")
blob, lines, text_max = self.build()
with open(sys.argv[1], "wb") as f:
f.write(blob)
guard = self.guard.upper() + "_ASSETS_H"
out = [f"/* Generated by gen_assets.py - do not edit ({len(blob)} B of assets). */",
f"#ifndef {guard}", f"#define {guard}", "",
f"#define TEXT_MAX {text_max}u /* longest text incl. NUL */"]
for name, off, size in lines:
out.append(f"#define {name} {off}u")
out.append(f"#define {name}_LEN {size}u")
for name, value in self.extra:
out.append(f"#define {name} {value}u")
out += ["", f"#endif /* {guard} */", ""]
with open(sys.argv[2], "w") as f:
f.write("\n".join(out))
+351
View File
@@ -0,0 +1,351 @@
/* 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.
*/
#include "apps/app_overlay.h"
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_APPS
#include <string.h>
#include "apps/app_menu.h"
#include "app/app.h"
#include "app/uart.h"
#include "driver/backlight.h"
#include "driver/st7565.h"
#include "driver/keyboard.h"
#include "driver/system.h"
#include "driver/gpio.h"
#include "ui/helper.h"
#ifdef ENABLE_FEAT_F4HWN_K5VIEWER
#include "k5viewer.h"
#endif
/* "F4HWN APPS" banner and the same thin separator used by the multiboot
* selector. Bit 3 leaves room for the selected-row capsule's top edge. */
static void app_status_bar(void)
{
UI_StatusClear();
GUI_DisplaySmallestInverse("F4HWN APPS", 44, 0, true, true, 84);
for (uint8_t x = 2u; x < LCD_WIDTH - 2u; x++)
gFrameBuffer[0][x] |= 0x08u;
}
/* Fixed selection capsule around the primary information (the app name).
* Slot number stays in the normal font; size is plain 3x5 metadata. */
#define APP_NAME_BOX_START 19u
#define APP_NAME_BOX_END 102u
#define APP_NAME_TEXT_X 21u
static void app_wait_release(void);
static void app_invert_name(uint8_t line)
{
gFrameBuffer[line][APP_NAME_BOX_START] ^= 0x7Fu;
for (uint8_t x = APP_NAME_BOX_START + 1u; x < APP_NAME_BOX_END; x++)
{
gFrameBuffer[line][x] ^= 0xFFu;
gFrameBuffer[line - 1u][x] ^= 0x80u;
}
gFrameBuffer[line][APP_NAME_BOX_END] ^= 0x7Fu;
}
/* Debounced blocking key read, then wait for release (mirrors mb_get_key). */
static KEY_Code_t app_get_key(void)
{
#if defined(ENABLE_UART) || defined(ENABLE_USB)
const uint8_t slot_revision = APP_SlotRevision();
#endif
for (;;)
{
#ifdef ENABLE_FEAT_F4HWN_K5VIEWER
/* APP_MenuOpen() is modal and does not return to APP_Update(). Keep
* serial key injection and the viewer connection alive while waiting. */
K5VIEWER_ParseInput();
#endif
#if defined(ENABLE_UART) || defined(ENABLE_USB)
UART_ServiceCommands();
if (APP_SlotRevision() != slot_revision)
return KEY_INVALID;
#endif
APP_ModalBacklightTick(true);
if (APP_IsScreenSaverDisplayed())
{
if (KEYBOARD_GetKey() != KEY_INVALID)
{
APP_ModalScreenSaverExit();
BACKLIGHT_TurnOn();
app_wait_release();
return KEY_INVALID;
}
SYSTEM_DelayMs(10);
continue;
}
#ifdef ENABLE_FEAT_F4HWN_K5VIEWER
K5VIEWER_Update(false);
#endif
KEY_Code_t key = KEYBOARD_Poll();
if (key != KEY_INVALID)
{
SYSTEM_DelayMs(30);
if (KEYBOARD_Poll() == key)
{
BACKLIGHT_TurnOn();
while (KEYBOARD_Poll() != KEY_INVALID)
{
SYSTEM_DelayMs(10);
APP_ModalBacklightTick(false);
}
return key;
}
}
SYSTEM_DelayMs(10);
}
}
static void app_wait_release(void)
{
uint8_t stable = 0;
while (stable < 10u)
{
if (!GPIO_IsPttPressed() && KEYBOARD_Poll() == KEY_INVALID)
stable++;
else
stable = 0;
SYSTEM_DelayMs(10);
APP_ModalBacklightTick(true);
}
}
static void app_copy(char *dst, uint8_t cap, const char *src, uint8_t src_cap)
{
uint8_t n = 0;
while (n + 1u < cap && n < src_cap && src[n])
{
dst[n] = src[n];
n++;
}
dst[n] = '\0';
}
/* Display the code payload rounded up to 0.1 KiB, so the compact value never
* understates the space occupied by the app. The space before "KB" is omitted
* because this secondary value is rendered in the tiny 3x5 font. */
static void app_format_size(char out[6], uint32_t bytes)
{
if (bytes > APP_OVERLAY_MAX)
{
memcpy(out, "--KB", 5u);
return;
}
const uint16_t tenths = (uint16_t)((bytes * 10u + 1023u) / 1024u);
out[0] = (char)('0' + tenths / 10u);
out[1] = '.';
out[2] = (char)('0' + tenths % 10u);
out[3] = 'K';
out[4] = 'B';
out[5] = '\0';
}
/* Human-readable action for an APP_LaunchOverlay / APP_ValidateSlot failure. */
static const char *app_err_text(const app_header_t *header, uint8_t rc)
{
switch (rc)
{
case APP_ERR_SLOT: return "BAD SLOT";
case APP_ERR_MAGIC: return "NO APP";
case APP_ERR_ABI:
/* api_min == 0 also identifies pre-reset development blobs whose
* former uint16_t ABI value occupies these two bytes. */
if (header->api_min == 0u || header->abi_major < APP_ABI_MAJOR)
return "UPDATE APP";
return "UPDATE FIRMWARE";
case APP_ERR_NOT_COMMITTED: return "REINSTALL APP";
case APP_ERR_SIZE: return "UPDATE APP";
case APP_ERR_CRC: return "REINSTALL APP";
case APP_ERR_VMA: return "UPDATE APP";
case APP_ERR_AUTH: return "AUTH";
case APP_ERR_CAP: return "NOT SUPPORTED";
default: return "ERROR";
}
}
/* A launch failed: name the app and the action to take, then wait for a key. Without this
* an incompatible app would silently "do nothing" when selected. */
static void app_show_error(const app_header_t *header, uint8_t rc)
{
char nm[19];
app_copy(nm, sizeof(nm), header->name, APP_NAME_LEN);
UI_DisplayClear();
UI_StatusClear();
GUI_DisplaySmallestInverse("APP ERROR", 46, 0, true, true, 82);
UI_PrintStringSmallNormal(nm, 2, 0, 2); /* which app */
UI_PrintStringSmallNormal(app_err_text(header, rc), 2, 0, 4); /* action */
UI_PrintStringSmallNormal("Press any key", 2, 0, 6);
ST7565_BlitStatusLine();
ST7565_BlitFullScreen();
app_get_key(); /* blocking: dismiss on any key */
}
/* Five visible slots; line 0 holds the separator and line 6 the key hints. */
#define APP_MENU_ROWS 5u
#define APP_MENU_SLOT_COUNT APP_SLOT_COUNT
_Static_assert(APP_MENU_SLOT_COUNT <= APP_SLOT_COUNT,
"APP_MENU_SLOT_COUNT exceeds the physical app slot count");
static uint16_t app_scan_slots(void)
{
uint16_t installed = 0u;
for (uint8_t slot = 0; slot < APP_MENU_SLOT_COUNT; slot++)
{
app_header_t hdr;
if (APP_SlotInfo(slot, &hdr) == APP_OK &&
(hdr.flags & APP_FLAG_COMMITTED))
installed |= (uint16_t)((uint16_t)1u << slot);
}
return installed;
}
void APP_MenuOpen(void)
{
APP_ModalScreenSaverExit();
BACKLIGHT_TurnOn();
#ifdef ENABLE_FEAT_F4HWN_K5VIEWER
/* Detach the modal selector from the key state that triggered F+7. The
* normal K5Viewer updater suppresses frames while a key is held; without
* clearing this stale state, the selector could never publish its first
* frame to the viewer. */
gKeyReading0 = KEY_INVALID;
gKeyReading1 = KEY_INVALID;
#endif
/* Apps are installed from UV Studio (0x073x) into physical slots 0..N-1,
* shown here as 1..N. Keep empty slots in the list so their location is
* visible and selectable while scrolling. */
uint8_t slot_revision = APP_SlotRevision();
uint16_t installed = app_scan_slots();
/* Remember the physical slot and scrolling window across menu openings. */
static uint8_t sel = 0;
static uint8_t top = 0; /* first visible row of the scrolling window */
if (sel >= APP_MENU_SLOT_COUNT || top > APP_MENU_SLOT_COUNT - APP_MENU_ROWS)
sel = top = 0u;
app_wait_release();
for (;;)
{
const uint8_t current_revision = APP_SlotRevision();
if (current_revision != slot_revision)
{
installed = app_scan_slots();
slot_revision = current_revision;
}
UI_DisplayClear();
app_status_bar(); /* also wipes the VFO status line (DW, battery, ...) */
/* Slide [top, top+APP_MENU_ROWS) so it always contains the selection. */
if (sel < top)
top = sel;
else if (sel >= (uint8_t)(top + APP_MENU_ROWS))
top = (uint8_t)(sel - APP_MENU_ROWS + 1u);
for (uint8_t slot = top;
slot < APP_MENU_SLOT_COUNT && (uint8_t)(slot - top) < APP_MENU_ROWS;
slot++)
{
char number[3];
char name[14];
char size[6];
const uint8_t visible_number = (uint8_t)(slot + 1u);
const uint8_t fbLine = (uint8_t)(slot - top + 1u);
number[0] = (char)('0' + visible_number / 10u);
number[1] = (char)('0' + visible_number % 10u);
number[2] = '\0';
UI_PrintStringSmallNormal(number, 2u, 0, fbLine);
if (installed & (uint16_t)((uint16_t)1u << slot))
{
app_header_t hdr;
APP_SlotInfo(slot, &hdr);
app_copy(name, sizeof(name), hdr.name, APP_NAME_LEN);
app_format_size(size, hdr.code_size);
UI_PrintStringSmallNormal(name, APP_NAME_TEXT_X, 0, fbLine);
GUI_DisplaySmallest(size,
(uint8_t)(LCD_WIDTH - 2u - strlen(size) * 4u),
(uint8_t)(fbLine * 8u + 1u), false, true);
}
else
{
UI_PrintStringSmallNormal("Empty", APP_NAME_TEXT_X, 0, fbLine);
}
if (slot == sel)
app_invert_name(fbLine);
}
UI_DrawMenuKeyHints("RUN", "QUIT");
ST7565_BlitStatusLine();
ST7565_BlitFullScreen();
#ifdef ENABLE_FEAT_F4HWN_K5VIEWER
K5VIEWER_Update(false);
#endif
const KEY_Code_t key = app_get_key();
if (key == KEY_EXIT)
return;
switch (key)
{
case KEY_UP:
sel = (sel == 0u) ? (uint8_t)(APP_MENU_SLOT_COUNT - 1u) : (uint8_t)(sel - 1u);
break;
case KEY_DOWN:
sel = (uint8_t)((sel + 1u) % APP_MENU_SLOT_COUNT);
break;
case KEY_MENU:
{
if (!(installed & (uint16_t)((uint16_t)1u << sel)))
break;
app_header_t hdr;
APP_SlotInfo(sel, &hdr);
const uint8_t rc = APP_LaunchOverlay(sel); /* runs until the app exits */
if (rc != APP_OK)
app_show_error(&hdr, rc); /* no longer silent */
app_wait_release();
/* Radio apps (BEAM, ...) hand back straight to the radio screen. */
if (rc == APP_OK && (hdr.flags & APP_FLAG_EXIT_TO_MAIN))
return;
break;
}
default:
break;
}
}
}
#endif /* ENABLE_FEAT_F4HWN_OVERLAY_APPS */
+9 -19
View File
@@ -1,6 +1,5 @@
/* Copyright 2023 OneOfEleven
* https://github.com/DualTachyon
/* 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.
@@ -15,21 +14,12 @@
* limitations under the License.
*/
#ifndef AM_FIXH
#ifndef APPS_APP_MENU_H
#define APPS_APP_MENU_H
#include <stdint.h>
#include <stdbool.h>
/* Blocking "Apps" selector: scans the overlay-app slots, lists the committed
* ones by name, and launches the chosen one. UP/DOWN move, MENU launches, EXIT
* closes. Returns when the user leaves. */
void APP_MenuOpen(void);
#ifdef ENABLE_AM_FIX
void AM_fix_init(void);
void AM_fix_reset(const unsigned vfo);
void AM_fix_10ms(const unsigned vfo);
#ifdef ENABLE_AM_FIX_SHOW_DATA
void AM_fix_print_data(const unsigned vfo, char *s);
#endif
int8_t AM_fix_get_gain_diff();
void AM_fix_enable(bool on);
#endif
#endif
#endif /* APPS_APP_MENU_H */
+933
View File
@@ -0,0 +1,933 @@
/* 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.
*/
#include "apps/app_overlay.h"
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_APPS
#include <string.h>
#include <stddef.h> /* offsetof */
#include "py32f0xx.h"
#include "driver/bk4819.h"
#include "driver/bk4819-regs.h"
#ifdef ENABLE_FMRADIO
#include "driver/bk1080.h"
#include "app/fm.h"
#endif
#include "driver/keyboard.h"
#include "driver/mb_flash.h"
#include "driver/py25q16.h"
#include "driver/st7565.h"
#include "driver/system.h"
#include "driver/backlight.h"
#ifdef ENABLE_FEAT_F4HWN_K5VIEWER
#include "k5viewer.h"
#endif
#include "app/app.h"
#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG
#include "app/rxtx_log.h"
#endif
#include "ui/helper.h"
#include "ui/main.h"
#include "ui/status.h"
#include "board.h"
#include "audio.h"
#include "dcs.h"
#include "functions.h"
#include "frequencies.h"
#include "radio.h"
#include "scheduler.h"
#include "helper/battery.h"
#include "settings.h"
#include "misc.h" /* dBmCorrTable */
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_INFO
#include "version.h"
#include "stack_usage.h"
#endif
_Static_assert(sizeof(app_header_t) == 64u && _Alignof(app_header_t) == 4u,
"app_header_t must stay 64 bytes with word alignment");
_Static_assert(offsetof(app_header_t, magic) == 0u &&
offsetof(app_header_t, hdr_version) == 4u &&
offsetof(app_header_t, abi_major) == 6u &&
offsetof(app_header_t, api_min) == 7u &&
offsetof(app_header_t, code_size) == 8u &&
offsetof(app_header_t, code_crc32) == 12u &&
offsetof(app_header_t, entry_off) == 16u &&
offsetof(app_header_t, flags) == 18u &&
offsetof(app_header_t, name) == 20u &&
offsetof(app_header_t, version) == 36u &&
offsetof(app_header_t, link_vma) == 52u &&
offsetof(app_header_t, required_caps) == 56u &&
offsetof(app_header_t, asset_size) == 60u &&
offsetof(app_header_t, asset_crc) == 62u,
"FAP1 field offsets must match existing apps and host tools");
_Static_assert(sizeof(app_api_t) <= UINT16_MAX, "app_api_t size field overflow");
_Static_assert(APP_ASSET_OFFSET + APP_ASSET_MAX == APP_CODE_OFFSET,
"assets must end where the code sector starts");
_Static_assert(APP_ASSET_MAX <= APP_OVERLAY_MAX,
"assets are CRC-checked through the overlay buffer");
enum {
APP_AVAILABLE_CAPS = 0u
#ifdef ENABLE_FMRADIO
| APP_CAP_FM
#endif
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_BEAM
| APP_CAP_BEAM2
#endif
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_INFO
| APP_CAP_SYSINFO
#endif
};
/* Keep the small zero-initialized state together so callbacks can address it
* from one base. The nonzero RNG seed stays separate to keep this in .bss. */
static struct {
uint16_t asset_size;
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_BEAM
uint16_t beam_pending_channel;
#endif
uint8_t run_slot;
uint8_t cfg_len; /* staged length; 0 = nothing to commit */
bool allow_screen_saver;
bool screen_saver_wake;
#ifdef ENABLE_FMRADIO
bool fm_dirty;
#endif
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_BEAM
bool beam_dirty;
#endif
bool shortcuts_cached;
uint8_t shortcut_mask;
uint8_t shortcut_slots[4];
uint8_t slot_revision;
uint8_t cfg_buf[16];
} app_state;
/* ---- ABI wrappers: the few resident calls that are not a direct signature match ---- */
static void app_backlight_on(void)
{
APP_ModalScreenSaverExit();
BACKLIGHT_TurnOn();
}
static void app_backlight_update(void)
{
APP_ModalBacklightTick(app_state.allow_screen_saver);
}
static uint8_t app_get_key(void)
{
#ifdef ENABLE_FEAT_F4HWN_K5VIEWER
/* Overlay apps run synchronously outside APP_Update(). Keep serial key
* injection alive while an app owns the foreground loop. */
K5VIEWER_ParseInput();
#endif
const KEY_Code_t key = KEYBOARD_GetKey();
if (app_state.screen_saver_wake) {
if (key == KEY_INVALID)
app_state.screen_saver_wake = false;
return APP_KEY_INVALID;
}
if (!APP_IsScreenSaverDisplayed()) {
if (key != KEY_INVALID)
BACKLIGHT_TurnOn(); /* re-arm BLTime on activity, mirrors ProcessKey():
overlay apps bypass the resident key handler, so
the saver would otherwise fire mid-use. */
return (uint8_t)key;
}
if (key == KEY_INVALID)
return APP_KEY_SAVER;
app_backlight_on();
if (key == KEY_PTT)
return APP_KEY_PTT;
app_state.screen_saver_wake = true;
return APP_KEY_WAKE;
}
#ifdef ENABLE_FEAT_F4HWN_K5VIEWER
static void app_blit_full(void)
{
ST7565_BlitFullScreen();
/* The normal loop mirrors completed frames after drawing. Overlay apps
* bypass that loop, so publish the frame from this ABI wrapper. */
K5VIEWER_Update(false);
}
#endif
static int8_t app_nav_dir(uint8_t key)
{
int8_t direction;
if (key == KEY_UP)
direction = 1;
else if (key == KEY_DOWN)
direction = -1;
else
return 0;
return gEeprom.SET_NAV ? direction : -direction;
}
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_INFO
extern uint8_t _eflash_used;
extern uint8_t _ebss;
#endif
static void app_led(bool on) { BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, on); }
static void app_play_tone(uint16_t tone, uint16_t ms)
{
BK4819_PrepareToPlayTone(true);
AUDIO_AudioPathOn();
BK4819_PlayToneRaw(tone, ms);
AUDIO_AudioPathOff();
}
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_BEAM
/* ---- optional BEAM radio/channel bridge -----------------------------------
* The modal app owns the packet format, CRC, UI, state machine and the FSK
* modem (plain BK4819 register sequences through bk_read/bk_write). Resident
* code only tunes the fixed channel and translates the stable ABI channel
* structure, the parts which depend on VFO_Info_t. */
static VFO_Info_t app_beam_vfo;
static app_beam_channel_t app_beam_pending;
static void app_beam_prepare(void)
{
const uint16_t channel = FREQ_CHANNEL_FIRST + BAND6_400MHz;
RADIO_InitInfo(&app_beam_vfo, channel, DEFAULT_FREQ);
app_beam_vfo.CHANNEL_BANDWIDTH = BANDWIDTH_NARROW;
app_beam_vfo.OUTPUT_POWER = OUTPUT_POWER_LOW1;
RADIO_ConfigureSquelchAndOutputPower(&app_beam_vfo);
gRxVfo = &app_beam_vfo;
gTxVfo = &app_beam_vfo;
gCurrentVfo = &app_beam_vfo;
RADIO_SetupRegisters(true);
}
/* Wire<->VFO fields that are a plain one-byte copy in BOTH directions. Fields
* that differ in width (frequency, offset, band) or are enum-typed on the VFO
* side (modulation, code types, PTT-id) are excluded: enums are int-sized here
* (no -fshort-enums), so a byte copy would truncate them. Those stay as the
* explicit width-converting assignments below. Driving the byte fields from one
* table collapses two near-identical copy blocks into a single shared loop. */
#ifdef ENABLE_DTMF_CALLING
#define APP_BEAM_BYTE_FIELDS_DTMF(F) F(dtmf_decoding_enable, DTMF_DECODING_ENABLE)
#else
#define APP_BEAM_BYTE_FIELDS_DTMF(F)
#endif
#define APP_BEAM_BYTE_FIELDS(F) \
F(rx_code, freq_config_RX.Code) \
F(tx_code, freq_config_TX.Code) \
F(tx_offset_direction, TX_OFFSET_FREQUENCY_DIRECTION) \
F(tx_lock, TX_LOCK) \
F(busy_channel_lock, BUSY_CHANNEL_LOCK) \
F(output_power, OUTPUT_POWER) \
F(channel_bandwidth, CHANNEL_BANDWIDTH) \
F(scanlist, SCANLIST_PARTICIPATION) \
F(compander, Compander) \
APP_BEAM_BYTE_FIELDS_DTMF(F)
typedef struct { uint8_t wire_off, vfo_off; } app_beam_byte_map_t;
#define APP_BEAM_MAP_ROW(w, v) { offsetof(app_beam_channel_t, w), offsetof(VFO_Info_t, v) },
static const app_beam_byte_map_t app_beam_byte_map[] = {
APP_BEAM_BYTE_FIELDS(APP_BEAM_MAP_ROW)
};
#undef APP_BEAM_MAP_ROW
/* Widening either side of a mapped field must fail to compile here rather than
* silently truncate through the byte copy. */
#define APP_BEAM_MAP_CHECK(w, v) \
_Static_assert(sizeof(((app_beam_channel_t *)0)->w) == 1u, #w); \
_Static_assert(sizeof(((VFO_Info_t *)0)->v) == 1u, #v);
APP_BEAM_BYTE_FIELDS(APP_BEAM_MAP_CHECK)
#undef APP_BEAM_MAP_CHECK
_Static_assert(sizeof(VFO_Info_t) <= 256u && sizeof(app_beam_channel_t) <= 256u,
"app_beam_byte_map offsets must fit in uint8_t");
/* Copy every mapped byte field in one direction (to_vfo = save, else export). */
static void app_beam_copy_bytes(app_beam_channel_t *wire, VFO_Info_t *vfo, bool to_vfo)
{
for (unsigned i = 0; i < sizeof(app_beam_byte_map) / sizeof(app_beam_byte_map[0]); i++) {
uint8_t *w = (uint8_t *)wire + app_beam_byte_map[i].wire_off;
uint8_t *v = (uint8_t *)vfo + app_beam_byte_map[i].vfo_off;
if (to_vfo) *v = *w;
else *w = *v;
}
}
static void app_beam_get(app_beam_channel_t *out)
{
if (out == NULL)
return;
memset(out, 0, sizeof(*out));
VFO_Info_t *vfo = &gEeprom.VfoInfo[gEeprom.TX_VFO];
app_beam_copy_bytes(out, vfo, false); /* plain one-byte fields */
out->rx_frequency = vfo->freq_config_RX.Frequency;
out->tx_offset_frequency = vfo->TX_OFFSET_FREQUENCY;
out->rx_codetype = vfo->freq_config_RX.CodeType;
out->tx_codetype = vfo->freq_config_TX.CodeType;
out->modulation = vfo->Modulation;
out->frequency_reverse = vfo->FrequencyReverse;
out->dtmf_ptt_id_mode = vfo->DTMF_PTT_ID_TX_MODE;
out->step_setting = vfo->STEP_SETTING;
out->band = vfo->Band;
if (IS_MR_CHANNEL(vfo->CHANNEL_SAVE))
SETTINGS_FetchChannelName(out->name, vfo->CHANNEL_SAVE);
else
memcpy(out->name, vfo->Name, sizeof(out->name));
}
static uint16_t app_beam_save(const app_beam_channel_t *in)
{
if (in == NULL)
return 0xFFFFu;
/* Only one external-flash write can be deferred per app run. Preserve the
first successfully received channel if an older app tries to queue more. */
if (app_state.beam_dirty)
return 0xFFFFu;
uint16_t channel = MR_CHANNEL_FIRST;
while (IS_MR_CHANNEL(channel) && RADIO_CheckValidChannel(channel, false, 0))
channel++;
if (!IS_MR_CHANNEL(channel))
return 0xFFFFu;
/* External flash cannot be written while the overlay executes from its
sector-cache RAM. Keep the pointer-free payload separate from the radio
VFO: app_beam_prepare() may reuse that VFO before the app returns. */
memcpy(&app_beam_pending, in, sizeof(app_beam_pending));
app_state.beam_pending_channel = channel;
app_state.beam_dirty = true;
return channel;
}
/* Called only after the overlay has returned and its code no longer executes
* from the PY25Q16 sector cache. */
static void app_beam_commit(void)
{
if (!app_state.beam_dirty)
return;
app_state.beam_dirty = false;
const uint16_t channel = app_state.beam_pending_channel;
/* The overlay has returned, so the temporary radio VFO is now free to
become the channel-save staging object. */
RADIO_InitInfo(&app_beam_vfo, channel, app_beam_pending.rx_frequency);
app_beam_copy_bytes(&app_beam_pending, &app_beam_vfo, true);
app_beam_vfo.TX_OFFSET_FREQUENCY = app_beam_pending.tx_offset_frequency;
app_beam_vfo.freq_config_RX.CodeType = app_beam_pending.rx_codetype;
app_beam_vfo.freq_config_TX.CodeType = app_beam_pending.tx_codetype;
app_beam_vfo.Modulation = app_beam_pending.modulation;
app_beam_vfo.FrequencyReverse = app_beam_pending.frequency_reverse;
app_beam_vfo.DTMF_PTT_ID_TX_MODE = app_beam_pending.dtmf_ptt_id_mode;
app_beam_vfo.STEP_SETTING = app_beam_pending.step_setting < STEP_N_ELEM
? app_beam_pending.step_setting : STEP_12_5kHz;
app_beam_vfo.StepFrequency = gStepFrequencyTable[app_beam_vfo.STEP_SETTING];
memcpy(app_beam_vfo.Name, app_beam_pending.name, sizeof(app_beam_vfo.Name));
app_beam_vfo.Name[sizeof(app_beam_vfo.Name) - 1u] = '\0';
SETTINGS_SaveChannel(channel, gEeprom.TX_VFO, &app_beam_vfo, 3);
#ifndef ENABLE_KEEP_MEM_NAME
SETTINGS_SaveChannelName(channel, app_beam_vfo.Name);
#endif
gEeprom.MrChannel[gEeprom.TX_VFO] = channel;
gEeprom.ScreenChannel[gEeprom.TX_VFO] = channel;
RADIO_ConfigureChannel(gEeprom.TX_VFO, VFO_CONFIGURE_RELOAD);
RADIO_SelectVfos();
RADIO_SetupRegisters(true);
PY25Q16_InvalidateCache();
}
static void app_beam_draw(const char *status)
{
UI_DisplayStatus();
UI_DisplayMain();
#ifdef ENABLE_FEAT_F4HWN
const uint8_t line = (gEeprom.DUAL_WATCH == DUAL_WATCH_OFF &&
gEeprom.CROSS_BAND_RX_TX == CROSS_BAND_OFF) ? 5u : 3u;
#else
const uint8_t line = 3u;
#endif
memset(gFrameBuffer[line], 0, LCD_WIDTH);
UI_PrintStringSmallBold(status, 2, LCD_WIDTH - 1u, line);
}
#endif
/* ---- radio wrappers ---- */
static int16_t app_rssi_dbm(void) { return BK4819_GetRSSI_dBm() + dBmCorrTable[gRxVfo->Band]; }
static uint16_t app_bk_read(uint8_t r) { return BK4819_ReadRegister((BK4819_REGISTER_t)r); }
static void app_bk_write(uint8_t r, uint16_t v) { BK4819_WriteRegister((BK4819_REGISTER_t)r, v); }
static void app_set_af(uint8_t m) { BK4819_SetAF((BK4819_AF_Type_t)m); }
static void app_audio_path(bool on){ if (on) AUDIO_AudioPathOn(); else AUDIO_AudioPathOff(); }
static void app_prepare_tone(void) { BK4819_PrepareToPlayTone(true); }
static void app_play_tone_raw(uint16_t hz, uint16_t ms) { BK4819_PlayToneRaw(hz, ms); }
static uint32_t app_rx_freq(void) { return gRxVfo->pRX->Frequency; }
/* ---- v2 config (deferred, flash-backed) ----
* Stored per app slot in the header sector, just after the 64-byte header. cfg_load
* reads flash at launch (ReadBuffer bypasses the overlay cache). cfg_save only stages
* into RAM - the app runs from the sector cache, so it cannot write flash itself; the
* loader commits the staged bytes to flash after the app returns (RMW preserves the
* slot header), tagged with the app's name. Updating an app erases its slot: the
* tagged config is kept across that erase and handed back to the app of that name
* only, so an update keeps the app's settings while another app installed in the
* slot starts from its own defaults. */
#define APP_CFG_OFFSET 0x40u /* config area within the header sector */
#define APP_CFG_OWNER 0x50u /* name of the app the config belongs to */
static void app_cfg_load(uint8_t *buf, uint8_t len)
{
if (len > sizeof(app_state.cfg_buf)) len = sizeof(app_state.cfg_buf);
const uint32_t base = APP_SLOT_BASE(app_state.run_slot);
char name[APP_NAME_LEN], owner[APP_NAME_LEN];
PY25Q16_ReadBuffer(base + offsetof(app_header_t, name), name, sizeof(name));
PY25Q16_ReadBuffer(base + APP_CFG_OWNER, owner, sizeof(owner));
/* This app's config, or an untagged one saved before the tag existed.
* Another app's reads as erased flash, i.e. the app's defaults. */
if ((uint8_t)owner[0] == 0xFFu || !memcmp(owner, name, sizeof(name)))
PY25Q16_ReadBuffer(base + APP_CFG_OFFSET, buf, len);
else
memset(buf, 0xFF, len);
}
static void app_cfg_save(const uint8_t *buf, uint8_t len)
{
if (len > sizeof(app_state.cfg_buf)) len = sizeof(app_state.cfg_buf);
memcpy(app_state.cfg_buf, buf, len);
app_state.cfg_len = len; /* mark dirty; the loader commits after the app returns */
}
_Static_assert(APP_CFG_OFFSET + sizeof(app_state.cfg_buf) <= APP_CFG_OWNER &&
APP_CFG_OWNER + APP_NAME_LEN <= APP_ASSET_OFFSET,
"config area overlaps its owner tag or the assets");
/* ---- API level 2: time, randomness, read-only assets ---- */
static uint32_t app_rng_state = 0x2545F491u;
static uint32_t app_ticks_ms(void)
{
return SCHEDULER_GetTick10ms() * 10u;
}
static uint32_t app_rand32(void)
{
uint32_t x = app_rng_state;
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
app_rng_state = x;
return x;
}
/* Fold fresh entropy into the persistent state: RSSI noise LSBs, the SysTick
* phase of the key press that launched the app, and the 10 ms counter. */
static void app_rng_mix(void)
{
app_rng_state ^= ((uint32_t)BK4819_ReadRegister(BK4819_REG_67) << 16) ^
SysTick->VAL ^ SCHEDULER_GetTick10ms();
if (app_rng_state == 0u)
app_rng_state = 0x2545F491u;
app_rand32();
}
static uint16_t app_asset_read(uint16_t offset, void *buf, uint16_t len)
{
if (offset >= app_state.asset_size)
return 0;
if (len > app_state.asset_size - offset)
len = app_state.asset_size - offset;
PY25Q16_ReadBuffer(APP_SLOT_BASE(app_state.run_slot) + APP_ASSET_OFFSET + offset, buf, len);
return len;
}
/* ---- API level 2: integer division ----
* The run-time helpers the firmware links anyway (App/compact_div.S over
* libgcc's unsigned division), served as they are: their {quotient, remainder}
* register pair is declared as one 64-bit return value (low word = r0). */
extern uint64_t __aeabi_idivmod(int32_t n, int32_t d);
extern uint64_t __aeabi_uidivmod(uint32_t n, uint32_t d);
/* ---- v2 battery / backlight ---- */
static void app_draw_battery(void)
{
char t[8];
UI_DrawStatusBattery(gStatusLine, t);
}
static void app_battery_sample(void)
{
BATTERY_Sample(false);
}
/* ---- v2 TX (beacon) ---- */
static uint8_t app_tx_state(void)
{
if (TX_freq_check(gTxVfo->pTX->Frequency) != 0 && gTxVfo->TX_LOCK) return 1; /* TX disable */
if (gBatteryDisplayLevel == 0) return 2; /* battery low */
if (gBatteryDisplayLevel > 6) return 3; /* voltage high */
if (gTxVfo->Modulation != MODULATION_FM) return 1;
return 0;
}
static void app_tx_tone(uint16_t hz) { BK4819_TransmitTone(false, hz); }
static void app_tx_mute(bool on) { if (on) BK4819_EnterTxMute(); else BK4819_ExitTxMute(); }
static void app_tx_end(void) { BK4819_ToggleGpioOut(BK4819_GPIO1_PIN29_PA_ENABLE, false); RADIO_SetupRegisters(true); }
static void app_tx_carrier(bool on) { BK4819_ToggleGpioOut(BK4819_GPIO1_PIN29_PA_ENABLE, on); }
static uint32_t app_tx_freq(void) { return gTxVfo->pTX->Frequency; }
static void app_boot_callsign(char *buf, uint8_t len)
{
char raw[12]; uint8_t n = 0;
PY25Q16_ReadBuffer(SETTINGS_BOOT_MESSAGE_LINE1_ADDR, raw, sizeof(raw));
for (uint8_t i = 0; i < sizeof(raw) && (uint8_t)(n + 1) < len; i++) {
char c = raw[i];
if (c == '\0' || (uint8_t)c == 0xFFu) break;
if (c >= 'a' && c <= 'z') c -= 32;
if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '/') buf[n++] = c;
}
buf[n] = '\0';
}
#ifdef ENABLE_FMRADIO
/* ---- v2 broadcast FM (BK1080), sovereign (no BK4819 dual-watch) ---- */
static void app_fm_enter(uint16_t f, uint8_t b)
{
BK1080_Init(f, b);
BK4819_PickRXFilterPathBasedOnFrequency(10320000); /* FM band antenna filter */
AUDIO_AudioPathOn();
gEnableSpeaker = true;
}
static void app_fm_exit(void)
{
AUDIO_AudioPathOff();
gEnableSpeaker = false;
BK1080_Init0();
BK4819_PickRXFilterPathBasedOnFrequency(gRxVfo->pRX->Frequency); /* restore RX filter */
}
static int8_t app_fm_valid(uint16_t f, uint16_t lo) { return (int8_t)FM_CheckFrequencyLock(f, lo); }
static void app_fm_state(app_fm_state_t *s, bool write)
{
if (write) {
gEeprom.FM_FrequencyPlaying = s->freq_playing;
gEeprom.FM_SelectedFrequency = s->sel_freq;
gEeprom.FM_Band = s->band & 3u;
gEeprom.FM_IsMrMode = s->is_mr ? true : false;
gEeprom.FM_SelectedChannel = s->sel_ch;
} else {
s->freq_playing = gEeprom.FM_FrequencyPlaying;
s->sel_freq = gEeprom.FM_SelectedFrequency;
s->band = gEeprom.FM_Band;
s->is_mr = gEeprom.FM_IsMrMode;
s->sel_ch = gEeprom.FM_SelectedChannel;
}
}
static void app_fm_commit(void) { app_state.fm_dirty = true; }
#endif
/* Internal callers discard the header on failure, so read directly into their
* aligned object without another 64-byte stack object and copy. */
static uint8_t app_read_validated_header(uint8_t slot, app_header_t *h)
{
if (slot >= APP_SLOT_COUNT)
return APP_ERR_SLOT;
PY25Q16_ReadBuffer(APP_SLOT_BASE(slot), h, sizeof(*h));
if (h->magic != APP_MAGIC) return APP_ERR_MAGIC;
if (h->hdr_version != APP_HDR_VERSION) return APP_ERR_MAGIC;
if (h->abi_major != APP_ABI_MAJOR || h->api_min == 0u ||
h->api_min > APP_API_LEVEL) return APP_ERR_ABI;
if (!(h->flags & APP_FLAG_COMMITTED)) return APP_ERR_NOT_COMMITTED;
if (h->required_caps & ~APP_AVAILABLE_CAPS) return APP_ERR_CAP;
if (h->code_size < 2u || h->code_size > APP_OVERLAY_MAX ||
(uint32_t)h->entry_off > h->code_size - 2u || /* leave room for a 2-byte Thumb insn */
(h->entry_off & 1u) != 0u || /* entry must be Thumb-aligned (even) */
h->asset_size > APP_ASSET_MAX)
return APP_ERR_SIZE;
return APP_OK;
}
uint8_t APP_ValidateSlot(uint8_t slot, app_header_t *out_header)
{
app_header_t h;
const uint8_t rc = app_read_validated_header(slot, &h);
/* Preserve the public API: a failed validation leaves out_header intact. */
if (rc == APP_OK && out_header)
*out_header = h;
return rc;
}
uint8_t APP_SlotRevision(void)
{
return app_state.slot_revision;
}
void APP_NotifySlotChanged(void)
{
app_state.shortcuts_cached = false;
app_state.slot_revision++;
}
static int8_t app_shortcut_index(uint8_t shortcut)
{
if (shortcut == APP_SHORTCUT_FM) return 0;
if (shortcut == APP_SHORTCUT_FOXHUNT) return 1;
if (shortcut == APP_SHORTCUT_BEACON) return 2;
if (shortcut == APP_SHORTCUT_BEAM) return 3;
return -1;
}
static void app_cache_shortcuts(void)
{
if (app_state.shortcuts_cached)
return;
app_state.shortcut_mask = 0;
const uint32_t overlay_vma = (uint32_t)PY25Q16_OverlayBuffer();
for (uint8_t slot = 0; slot < APP_SLOT_COUNT; slot++) {
app_header_t h;
if (app_read_validated_header(slot, &h) != APP_OK || h.link_vma != overlay_vma)
continue;
const uint8_t shortcut = (uint8_t)((h.flags & APP_FLAG_SHORTCUT_MASK) >>
APP_FLAG_SHORTCUT_SHIFT);
const int8_t index = app_shortcut_index(shortcut);
if (index >= 0) {
if (!(app_state.shortcut_mask & shortcut)) {
app_state.shortcut_mask |= shortcut;
app_state.shortcut_slots[index] = slot;
}
}
}
app_state.shortcuts_cached = true;
}
uint8_t APP_OverlayShortcutMask(void)
{
app_cache_shortcuts();
return app_state.shortcut_mask;
}
uint8_t APP_LaunchOverlayShortcut(uint8_t shortcut)
{
const int8_t index = app_shortcut_index(shortcut);
if (index < 0)
return APP_ERR_MAGIC;
app_cache_shortcuts();
return (app_state.shortcut_mask & shortcut)
? APP_LaunchOverlay(app_state.shortcut_slots[index])
: APP_ERR_MAGIC;
}
uint8_t APP_SlotInfo(uint8_t slot, app_header_t *out_header)
{
if (slot >= APP_SLOT_COUNT)
return APP_ERR_SLOT;
app_header_t local;
app_header_t *h = out_header ? out_header : &local;
/* Slot info returns the raw header even when its magic is invalid. */
PY25Q16_ReadBuffer(APP_SLOT_BASE(slot), h, sizeof(*h));
return (h->magic == APP_MAGIC) ? APP_OK : APP_ERR_MAGIC;
}
/* All services are immutable. Keeping the table in flash avoids rebuilding a
* roughly quarter-kilobyte automatic object on every launch and removes that
* object from the launcher's stack frame. Callbacks must also obey the ABI's
* no-external-flash-write rule while entry() is running. */
static const app_api_t app_api = {
.abi_major = APP_ABI_MAJOR,
.api_level = APP_API_LEVEL,
.api_size = sizeof(app_api_t),
.fb = gFrameBuffer,
.display_clear = UI_DisplayClear,
.status_clear = UI_StatusClear,
.draw_line = UI_DrawLineBuffer,
.draw_rect = UI_DrawRectangleBuffer,
.print_bold = UI_PrintStringSmallBold,
.print_tiny = GUI_DisplaySmallest,
#ifdef ENABLE_FEAT_F4HWN_K5VIEWER
.blit_full = app_blit_full,
#else
.blit_full = ST7565_BlitFullScreen,
#endif
.blit_line = ST7565_BlitLine,
.blit_status = ST7565_BlitStatusLine,
.get_key = app_get_key,
.delay_ms = SYSTEM_DelayMs,
.play_tone = app_play_tone,
.led = app_led,
.print_normal = UI_PrintStringSmallNormal,
.print_inverse = GUI_DisplaySmallestInverse,
.display_freq = UI_DisplayFrequency,
.rssi_dbm = app_rssi_dbm,
.bk_read = app_bk_read,
.bk_write = app_bk_write,
.set_agc = BK4819_SetAGC,
.set_af = app_set_af,
.audio_path = app_audio_path,
.prepare_tone = app_prepare_tone,
.play_tone_raw = app_play_tone_raw,
.tones_off_rx = BK4819_TurnsOffTones_TurnsOnRX,
.rx_freq = app_rx_freq,
.cfg_load = app_cfg_load,
.cfg_save = app_cfg_save,
.draw_battery = app_draw_battery,
.battery_sample = app_battery_sample,
.backlight_on = app_backlight_on,
.backlight_update = app_backlight_update,
.audio_scope = UI_DisplayAudioScopeOverlay,
.status_line = gStatusLine,
.tx_state = app_tx_state,
.tx_set_params = RADIO_SetTxParameters,
.tx_tone = app_tx_tone,
.tx_mute = app_tx_mute,
.tx_end = app_tx_end,
.tx_carrier = app_tx_carrier,
.tx_freq = app_tx_freq,
.boot_callsign = app_boot_callsign,
.print_string = UI_PrintString,
#ifdef ENABLE_FMRADIO
.fm_enter = app_fm_enter,
.fm_exit = app_fm_exit,
.fm_set_freq = BK1080_SetFrequency,
.fm_lo = BK1080_GetFreqLoLimit,
.fm_hi = BK1080_GetFreqHiLimit,
.fm_mute = BK1080_Mute,
.fm_valid = app_fm_valid,
.fm_channels = gFM_Channels,
.fm_state = app_fm_state,
.fm_commit = app_fm_commit,
#endif
.nav_dir = app_nav_dir,
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_BEAM
.beam_prepare = app_beam_prepare,
/* APP_CAP_BEAM2: beam_leave, beam_send, beam_rx and beam_rx_poll stay
* NULL, the app drives the FSK modem through bk_read/bk_write. */
.beam_get = app_beam_get,
.beam_save = app_beam_save,
.beam_draw = app_beam_draw,
#endif
.ticks_ms = app_ticks_ms,
.rand32 = app_rand32,
.asset_read = app_asset_read,
.idivmod = __aeabi_idivmod,
.uidivmod = __aeabi_uidivmod,
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_INFO
.sys_edition = Edition,
.sys_version = DisplayVersion,
.sys_build_date = BuildDate,
.sys_build_time = BuildTime,
.sys_build_commit = BuildCommit,
.sys_flash_end = &_eflash_used,
.sys_ram_end = &_ebss,
.sys_battery_voltage = &gBatteryVoltageAverage,
.sys_battery_type = &gEeprom.BATTERY_TYPE,
.sys_battery_percent = BATTERY_VoltsToPercent,
.sys_storage_read = PY25Q16_ReadBuffer,
.sys_stack_free_now = STACK_FreeNow,
.sys_stack_free_min = STACK_FreeMinimum,
#endif
};
uint8_t APP_LaunchOverlay(uint8_t slot)
{
app_header_t h;
uint8_t rc = app_read_validated_header(slot, &h);
if (rc != APP_OK)
return rc;
/* The app's absolute data references only resolve if it runs at the exact
* VMA it was linked for. The overlay VMA varies with the firmware's RAM
* layout (per preset/features), so the app records its link VMA and we
* refuse a mismatch cleanly instead of jumping into misaddressed code. */
uint8_t *ws = PY25Q16_OverlayBuffer();
if (h.link_vma != (uint32_t)ws)
return APP_ERR_VMA;
/* Flush and suspend RF logging before the sector cache becomes executable
* app code. Both a pending RX and an app-owned TX could otherwise write a
* log entry through the same 4 KiB buffer and overwrite the running app. */
#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG
RXTX_LOG_Suspend();
#endif
/* Repurpose the sector cache: drop any cached config sector, load the code
* straight in (ReadBuffer bypasses the cache), and verify it in RAM before
* trusting it. Zeroing first leaves the app's .bss clean. The assets are
* verified first through the same buffer: a slot written by a host that
* does not know the asset area (older UV Studio) is refused here instead of
* handing the app unprogrammed flash. */
PY25Q16_InvalidateCache();
bool assets_ok = true;
if (h.asset_size) {
PY25Q16_ReadBuffer(APP_SLOT_BASE(slot) + APP_ASSET_OFFSET, ws, h.asset_size);
assets_ok = (uint16_t)MB_Crc32Bytes(ws, h.asset_size) == h.asset_crc;
}
memset(ws, 0, APP_OVERLAY_MAX);
PY25Q16_ReadBuffer(APP_SLOT_BASE(slot) + APP_CODE_OFFSET, ws, h.code_size);
if (!assets_ok || MB_Crc32Bytes(ws, h.code_size) != h.code_crc32) {
PY25Q16_InvalidateCache();
#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG
RXTX_LOG_Resume();
#endif
return APP_ERR_CRC;
}
/* Ensure every store to the overlay is visible before we branch into it. */
__DSB();
__ISB();
app_state.run_slot = slot; /* for cfg_load / cfg_save / asset_read */
app_state.asset_size = h.asset_size;
app_state.cfg_len = 0;
app_rng_mix();
#ifdef ENABLE_FMRADIO
app_state.fm_dirty = false;
#endif
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_BEAM
app_state.beam_dirty = false;
#endif
app_state.allow_screen_saver = (h.flags & APP_FLAG_SCREEN_SAVER) != 0;
app_state.screen_saver_wake = false;
APP_ModalScreenSaverExit();
BACKLIGHT_TurnOn();
#ifdef ENABLE_FEAT_F4HWN_K5VIEWER
/* The caller enters from a debounced key event, so the resident key state
* still contains that trigger while the modal app is running. Clear it so
* K5Viewer is allowed to mirror overlay frames immediately. */
gKeyReading0 = KEY_INVALID;
gKeyReading1 = KEY_INVALID;
#endif
/* Pin RX to the user-selected VFO before the app runs. Under dual watch
* gRxVfo is whichever VFO the receiver was parked on when F+7 was pressed,
* so an RF app (FoxHunt, a future S-meter, ...) would measure and display a
* VFO the user did not pick - sometimes A, sometimes B. Point RX at the
* selected (TX) VFO and retune so rx_freq(), rssi_dbm() and the tuned
* hardware all agree on the selected channel. gCurrentVfo follows too:
* RADIO_SetTxParameters keys it, and dual watch may have left it on the
* other VFO, so a TX app (APRS TX, Beacon, SSTV) would transmit there while
* tx_freq() shows the selected one. Save all three pointers:
* radio apps such as BEAM temporarily replace them while they run. */
const uint8_t saved_rx_vfo = gEeprom.RX_VFO;
VFO_Info_t *const saved_rx = gRxVfo;
VFO_Info_t *const saved_tx = gTxVfo;
VFO_Info_t *const saved_current = gCurrentVfo;
gEeprom.RX_VFO = gEeprom.TX_VFO;
gRxVfo = gTxVfo;
gCurrentVfo = gTxVfo;
RADIO_SetupRegisters(true);
app_entry_t entry = (app_entry_t)(((uint32_t)ws + h.entry_off) | 1u);
entry(&app_api);
APP_ModalScreenSaverExit();
app_state.allow_screen_saver = false;
app_state.screen_saver_wake = false;
/* Restore the resident RX/dual-watch tuning the app ran on top of. */
gEeprom.RX_VFO = saved_rx_vfo;
gRxVfo = saved_rx;
gTxVfo = saved_tx;
gCurrentVfo = saved_current;
RADIO_SetupRegisters(true);
/* The overlay held app code, not a valid config sector. */
PY25Q16_InvalidateCache();
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_BEAM
app_beam_commit();
#endif
/* Commit any deferred config the app staged (RMW keeps the slot header),
* tagged with the app's name. */
if (app_state.cfg_len) {
const uint32_t base = APP_SLOT_BASE(slot);
PY25Q16_WriteBuffer(base + APP_CFG_OFFSET, app_state.cfg_buf, app_state.cfg_len, false);
PY25Q16_WriteBuffer(base + APP_CFG_OWNER, h.name, APP_NAME_LEN, false);
PY25Q16_InvalidateCache();
}
#ifdef ENABLE_FMRADIO
/* Commit the FM config + 48 channels the app edited (shared with resident FM). */
if (app_state.fm_dirty) {
app_state.fm_dirty = false;
SETTINGS_SaveFM();
PY25Q16_InvalidateCache();
}
#endif
#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG
RXTX_LOG_Resume();
#endif
return APP_OK;
}
uint8_t APP_SlotErase(uint8_t slot)
{
if (slot >= APP_SLOT_COUNT)
return APP_ERR_SLOT;
uint32_t base = APP_SLOT_BASE(slot);
/* An update erases the slot before the host rewrites it: keep the config
* and its owner tag across the erase (an untagged config is given the
* installed app's name), so that the same app finds its settings again. */
uint8_t keep[APP_CFG_OWNER + APP_NAME_LEN - APP_CFG_OFFSET];
uint8_t *const owner = keep + (APP_CFG_OWNER - APP_CFG_OFFSET);
app_header_t h;
PY25Q16_ReadBuffer(base + APP_CFG_OFFSET, keep, sizeof(keep));
if (*owner == 0xFFu && app_read_validated_header(slot, &h) == APP_OK)
memcpy(owner, h.name, APP_NAME_LEN);
for (uint32_t off = 0; off < APP_SLOT_STRIDE; off += APP_SECTOR_SIZE)
PY25Q16_SectorErase(base + off);
PY25Q16_InvalidateCache();
if (*owner != 0xFFu) {
PY25Q16_WriteBuffer(base + APP_CFG_OFFSET, keep, sizeof(keep), false);
PY25Q16_InvalidateCache();
}
APP_NotifySlotChanged();
return APP_OK;
}
uint8_t APP_SlotWrite(uint8_t slot, uint32_t offset, const uint8_t *data, uint32_t len)
{
if (slot >= APP_SLOT_COUNT)
return APP_ERR_SLOT;
if (offset > APP_SLOT_STRIDE || len > APP_SLOT_STRIDE - offset)
return APP_ERR_SIZE;
PY25Q16_WriteBuffer(APP_SLOT_BASE(slot) + offset, data, len, false);
PY25Q16_InvalidateCache();
app_state.shortcuts_cached = false;
return APP_OK;
}
#endif /* ENABLE_FEAT_F4HWN_OVERLAY_APPS */
+163
View File
@@ -0,0 +1,163 @@
/* 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.
*/
/*
* Overlay-app loader (POC).
*
* A leaf, modal app is a self-contained blob held in the external SPI flash. To
* run it the loader validates the header + code CRC, copies the code into the
* 4 KiB overlay (the PY25Q16 sector cache, shared VMA with the multiboot RAM
* stub), and calls its entry point. The blob runs from RAM and reaches every
* resident service through app_api_t. The loader never touches the internal
* application flash, so a corrupt or incompatible blob is only refused (header/CRC
* mismatch) or crashes into a watchdog reset.
*
* This is NOT a security sandbox. The CRC is an integrity check, not authentication,
* and a launched app is TRUSTED NATIVE CODE: it runs privileged with full access to
* memory and peripherals (no MPU, no crypto auth), so a malicious or buggy app CAN
* brick the radio - e.g. by driving the internal-flash controller itself. Only
* install apps you trust.
*
* External-flash "Apps" region (carved from the free space in the PY25Q16 map,
* after the multiboot markers, before the RX/TX log):
*
* 0x102000 slot 0 ] 16 slots x 8 KiB = 128 KiB
* 0x104000 slot 1 ] each slot: 4 KiB header sector + 4 KiB code
* ... ]
* 0x120000 slot 15 ]
*
* Header sector layout (slot-relative):
*
* 0x000 app_header_t (64 B)
* 0x040 per-app config staged by cfg_save (16 B)
* 0x050 name of the app that config belongs to (16 B, reserved up to 0x0FF);
* kept with the config when the slot is erased for an update
* 0x100 read-only assets (API level 2, up to 3840 B, CRC-checked at launch)
* 0x1000 code (<= 4 KiB, copied into the overlay RAM)
*
* Host tooling (APP_SlotErase/Write/Info) touches the external flash only and is
* never brick-critical; a bad slot is simply refused at launch by the CRC check.
*/
#ifndef APPS_APP_OVERLAY_H
#define APPS_APP_OVERLAY_H
#include <stdint.h>
#include <stdbool.h>
#include "app_api.h"
/* ---- external-flash Apps region ---- */
#define APP_REGION_BASE 0x00102000u /* first app slot */
#define APP_SLOT_STRIDE 0x00002000u /* 8 KiB per slot */
#define APP_CODE_OFFSET 0x00001000u /* code starts after the 4 KiB header sector */
#define APP_SECTOR_SIZE 0x00001000u /* external NOR erase granularity */
#define APP_SLOT_COUNT 16u
#define APP_SLOT_BASE(s) (APP_REGION_BASE + (uint32_t)(s) * APP_SLOT_STRIDE)
/* ---- overlay RAM budget: the PY25Q16 4 KiB sector cache reused as workspace.
* The link VMA itself is pinned in Core/py32f071xb.ld (ORIGIN+0x280) and passed
* via compile-app.sh; the loader checks each blob's link_vma against it. ---- */
#define APP_OVERLAY_MAX 0x00001000u /* 4 KiB */
/* ---- read-only assets, served by api->asset_read (API level 2) ----
* Literal values so pack_app.py can parse them; checked against the slot
* geometry by static asserts in app_overlay.c. */
#define APP_ASSET_OFFSET 0x00000100u /* assets start in the header sector */
#define APP_ASSET_MAX 0x00000F00u /* up to the end of the header sector */
/* ---- blob header (64 bytes, little-endian; see App/apps/pack_app.py) ---- */
#define APP_MAGIC 0x31504146u /* "FAP1" */
#define APP_HDR_VERSION 1u
#define APP_FLAG_COMMITTED 0x0001u
#define APP_FLAG_SCREEN_SAVER 0x0002u
#define APP_FLAG_EXIT_TO_MAIN 0x0004u /* leave the Apps menu when the app returns */
#define APP_FLAG_SHORTCUT_SHIFT 8u
#define APP_FLAG_SHORTCUT_MASK 0x0F00u
#define APP_NAME_LEN 16
#define APP_VERSION_LEN 16
#define APP_SHORTCUT_FM 0x01u
#define APP_SHORTCUT_FOXHUNT 0x02u
#define APP_SHORTCUT_BEACON 0x04u
#define APP_SHORTCUT_BEAM 0x08u
/* Optional resident facilities an app may require. Requirements live in the
* previously reserved header bytes, so app_header_t remains 64 bytes. */
#define APP_CAP_FM 0x00000001u
/* Retired: the v6.0.0 BEAM bridge (resident FSK send/receive). No longer
* advertised, so a v6.0.0 Beam.app is refused with APP_ERR_CAP. */
#define APP_CAP_BEAM 0x00000004u
#define APP_CAP_SYSINFO 0x00000008u
/* BEAM channel bridge only (beam_prepare/get/save/draw); the app drives the
* FSK modem itself through bk_read/bk_write. */
#define APP_CAP_BEAM2 0x00000010u
/* Aligned RAM objects; app_overlay.c pins every serialized field offset. */
typedef struct {
uint32_t magic; /* APP_MAGIC */
uint16_t hdr_version; /* APP_HDR_VERSION */
uint8_t abi_major; /* required ABI family */
uint8_t api_min; /* minimum append-only API level */
uint32_t code_size; /* bytes of code, <= APP_OVERLAY_MAX */
uint32_t code_crc32; /* CRC-32 (zlib) over code_size bytes */
uint16_t entry_off; /* entry offset within the code (0) */
uint16_t flags; /* APP_FLAG_COMMITTED, ... */
char name[APP_NAME_LEN]; /* human-readable, NUL-terminated */
char version[APP_VERSION_LEN];/* app version string */
uint32_t link_vma; /* RAM VMA the code was linked at */
uint32_t required_caps; /* APP_CAP_* required by this app */
uint16_t asset_size; /* bytes at APP_ASSET_OFFSET, 0 = none */
uint16_t asset_crc; /* low 16 bits of the assets' CRC-32 */
} app_header_t;
enum {
APP_OK = 0,
APP_ERR_SLOT, /* slot index out of range */
APP_ERR_MAGIC, /* no/invalid header */
APP_ERR_ABI, /* ABI family/API level mismatch */
APP_ERR_NOT_COMMITTED, /* image not marked complete */
APP_ERR_SIZE, /* code_size or asset_size out of range */
APP_ERR_CRC, /* code or asset CRC mismatch */
APP_ERR_VMA, /* overlay buffer not at the link VMA */
APP_ERR_AUTH, /* host write refused: timestamp mismatch */
APP_ERR_CAP, /* required firmware capability missing */
};
/* Read + validate a slot header (no CRC of the code). */
uint8_t APP_ValidateSlot(uint8_t slot, app_header_t *out_header);
/* Validate, load into the overlay, verify the code CRC, and run the app.
* Returns when the app exits; the internal flash is never touched. */
uint8_t APP_LaunchOverlay(uint8_t slot);
/* Launch the first installed app advertising this shortcut bit. */
uint8_t APP_LaunchOverlayShortcut(uint8_t shortcut);
/* Cached availability of apps exposed as resident quick actions. */
uint8_t APP_OverlayShortcutMask(void);
/* Host-tool slot management (external flash only, never brick-critical). */
uint8_t APP_SlotInfo(uint8_t slot, app_header_t *out_header);
uint8_t APP_SlotErase(uint8_t slot);
uint8_t APP_SlotWrite(uint8_t slot, uint32_t offset, const uint8_t *data, uint32_t len);
/* Change token used by the modal Apps selector. A completed host install is
* published after validation; erasing a slot publishes directly. */
uint8_t APP_SlotRevision(void);
void APP_NotifySlotChanged(void);
#endif /* APPS_APP_OVERLAY_H */
+94
View File
@@ -0,0 +1,94 @@
"""Shared 20x20 monochrome APRS symbol assets.
Each icon is stored in native ST7565 page order: 20 bytes for each of three
8-pixel pages. The masks below fill rows 0-19; they are packed one pixel
lower (rows 1-20, the low five bits of the third page), so the icon sits one
pixel below the top of its three display lines. 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 and are packed here from its export
aprsrx/test/aprs_symbols_20_edited_v2.json.
"""
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
0000000080c0c0cedfdfdfdfcec0c08000000000000000001f1f00ffff3f3fffff001f1f000000000000000000000007070000070700000000000000
0000000000000080c0c28a2afe2a0a0200000000000000f8fcfe1f1fffff1f1f1ffcf800000000000000000707070707070704040407070000000000
00000030f090101010101010101090f0300000000000000000030e38e08080e0380e0300000000000000000000000000000303000000000000000000
00000078f8f0e0c080000080c0e0f0f878000000000000c0e0f1fb7f3f1f1f3f7ffbf1e0c00000000000000303010000000000000000010303000000
000000000080c0e0fefffffee0c08000000000001c0c0e07071f1f07ffffffff071f1f07070e0c1c0000000000060707030707030707060000000000
0080e070180c0c0606060606060c0c1870e08000001f7fe0800000000000000000000080e07f1f000000000001030306060606060603030100000000
00007c381010d0d010386c6c000000000000000000000000181cffff1c180000007cfc7c000000000000000000000707020202020202030000000000
00000000008000c0f0fcfee08080e0000000000000000038feffff1f0f01031f9ffff9fc3e0000000000000000010302000000020303010000000000
0080c0c0c0c0c04040c0c0c0f0f87840c0800000007f3f9fdf9939606079797f7f3f9cdc9c3d7e7c0000000103010000000000000000010301000000
0000000000000000c0f0c0000000000000000000000000c0f0f8fe3f0f070f3ffef8e0c0000000000001010101010101010101010101010101010100
0000000000000040c0400000101090e0800000000070882474248c722127190905738924772488700000000101010000000000000000000101010000
00000000000000000000008898f0c000000000003249b5b54933061c3c3c3f1f073349b5b54932000000000000000000000000000000000000000000
00008080c040406020203010101888888880c0807e3e9fdf9f3f9fdf9f3f617f7c7c3f98d898397f0000010301000103010000000000000103010000
000000008080c0c0c0c0c0c0c080000000000000787c1c4fefed4c1c3c3f3c3c1d4feeec4c1c7c780000000000000000000000000000000000000000
0000000000000040c0c0800000000000808000000c1830606070703838191f0f0e060703030101000000000000000000000000000000000000000000
00000000000000000000000080c07038381000000e1f1f1e9cdc6c34180c161b1d1c1c1c1e1f1f0e0002070703000000000000000000000000000000
000000000000000000e02020e0202020608000003e3e0e66f6660e1e1f1f1c1c1f0e66f6660f1f3e0000000000000000000000000000000000000000
000080c06030101888c8c88818103060c080000000070f18306040c78f9f9f8fc7406030180f07000000000000000000000000000000000000000000
e0e0e0e0e0e0e0e0e0e0e0e0e0e00080808000003f1fcfefcf1f3f1fcfefcf1f3f3f13cfe8c81f3c0000000100000000000100000000000001000000
000000000000007c44c4c4447c000000000000000000e0203c24e4040407070404e4243c20e000000000010101010100000000000001010101010000
0000f80601f88c0070f0f070008cf80106f8000000000003040001e0b8cfcfb8e0010004030000000000000000020303020202020303020000000000
0000000000000080c0e0fee6c680000000000000000000f8fc1e1fffff1c1c1cffff1f1efcf800000000000303030303030000000303030303030000
0000e0f8fc0ce6fafefefefefae60cfcf8e0000000000003070e1927df3f3fdf27190e07030000000000000000000003070707070300000000000000
000000000000240840606c604008240000000000703898d8dc9e3b79797f7f79793b9edcd89838780000000101000000000000000000000101000000
00000000000000000000c0d0180c0c0e0e00000000103070787c76767f777f777f777c78703000000000010101010101010101010101010101010000
0080c0e0e0e0e0e0e0e0e0e0e0606060e0c00000003f1fc8e8c8183f3f2020203f3f19c9ebce1c380000000001000000000000000000000001000000
00c0c0e0e0e0e0e0e0e0e0e0e06060c080000000003f1fcfefcf1f3f3f3f3f3f3f1ccceccd1f3e3c0000000001000000000000000000000100000000
00e0000000c0000000c0000000c0000000c00000000f01010107010101ff010101070101010701000000000000000000000300000000000000000000
00e03090d050505254585854525050d09030e00000ff803f6040424e4240424c424040603f80ff000000010103030301010101010103030301010000
00e03030f03030f03030f03030f0303070f0e000003f0e66f7660e3f3e3e3f3e0e67f6640c3f3f3c0000000000000000000000000000000000000000
00000080c0c08000000080c8f8e0c080000000003049b5b54933071f1f1f1f1f073349b5b54932000000000000000000000000000000000000000000
0000c07018080c0486e2e286040c081870c00000000f30e08003677f3f1f1f3f7f670380e0300f000000000001010302060404060203010100000000
0000c07018088cc4e6f2f2e6c48c081870c00000000f30e0860f1f3f7fffff7f3f1f0f86e0300f000000000001010302060404060203010100000000
0000000000000082c2ca8a2aff2a0a0a02020000000000f8fcfe1f1fffff1f1f1ffcf800000000000000000707070707070704040407070000000000
000080c0e0e0f0f8fcfcfefcf8f8f0f0e0c0000000000143630707436307074363070743630100000000000000020300000203000002030000000000
000000000070787c1c1c1cfcf8f00000000000000000000000000000dcdedf0701000000000000000000000000000000010101000000000000000000
80e0781c0c86c6e3e3e3e3e3e3c6860c1c78e0801f7fe080001f3f7f7f7f7f7f7f3f1f0080e07f1f000001030306060c0c0c0c0c0c06060303010000
00000000000000c06c1e1e6cc00000000000000000000080e0360f0f060000060f0f36e0800000000000030707030000000000000000030707030000
0000000000000080dc3e3edc8000000000000000000000c0e0f8ce838080808083cef8e0c00000000000000103030100000000000001030301000000
000080603028fc2c26fe262cfc2830608000000000000f3262a7faa222ff22a2faa762320f0000000000000000000101030303010100000000000000
000000000000c06070607f60c080000000000000000000000000ff5455fd5554ff0f0000000000000000000000000103030303030100000000000000
008000000000001090f0f0901000000000008000030703030303034347ffff4743030303030307030000000000000000000000000000000000000000
00000000000080c0e0c0fefe8000000000000000000000000000ffa8faaafaa8ffff0000000000000000000000000102030203020301000000000000
"""
def _lowered(masks):
"""Shift every 24-row column down by one pixel (bit 0 of page 0 is the top)."""
out = bytearray(len(masks))
for icon in range(0, len(masks), BYTES_PER_ICON):
for x in range(WIDTH):
column = sum(masks[icon + page * WIDTH + x] << (8 * page) for page in range(PAGES))
if column >> (8 * PAGES - 1):
raise ValueError("APRS symbol mask has no free row to move down")
column <<= 1
for page in range(PAGES):
out[icon + page * WIDTH + x] = (column >> (8 * page)) & 0xFF
return bytes(out)
BITMAPS = _lowered(bytes.fromhex(_HEX))
if len(CODES) != 48 or len(BITMAPS) != len(CODES) * BYTES_PER_ICON:
raise ValueError("invalid APRS 20x20 symbol table")
+184
View File
@@ -0,0 +1,184 @@
# APRS RX: on-radio APRS receiver (work in progress)
Goal: an overlay app that receives APRS (AX.25 UI frames, Bell 202 AFSK 1200
bauds) on the UV-K1 / UV-K5 v3 and keeps the last five frames, shown one at a time.
Status:
| Step | State |
|---|---|
| Hardware demodulation by the BK4829 FSK block | **Dead end** (2026-09-25, FT3D on 144.800: the chip has no Bell 202 demodulator) |
| 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; v0.6 adds the speaker key; v0.7 adds the corrected 20x20 APRS symbol bitmaps; v0.8 saves the speaker setting; v0.9 scrolls long compact frames by 1 px |
| 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 |
## Using the app
1. Set the VFO to the APRS frequency, **FM**: 144.800 MHz, or 433.650 MHz for the
Flipper test files.
2. Launch **APRS RX**.
3. The speaker is off by default (v0.6): the decoder does not need it, only the
BK4829 AF output (tested on the radio: PA4 joins the audio before the
amplifier). Key 1 turns it on to listen to the channel, as FoxHunt's audio
key; since v0.8 the app keeps it as you left it.
Since v0.4 the app decodes **continuously**, as a TNC does: no RSSI trigger,
the FCS and a UI-frame check (control 0x03, PID 0xF0) sort frames from noise.
Up to v0.3, captures started 10 dB above a tracked noise floor; on a busy
144.800 that floor crept up (−132 → −104 dBm), so weak stations were never
sampled. Several frames in one transmission are now received too.
Keys and the screen cost sample time (the key scan alone is ~400 µs), so they
are served every 50 ms only while no slicer is inside a preamble (two
back-to-back flags) or a frame whose first bytes look like a callsign (busy
~4 % of the time on noise, in the model), and after 3 s of busy at the latest.
The screen is redrawn after a new frame, a key, or every 5 s.
The RAM history holds five frames, newest first; a new frame replaces the
oldest when the history is full. The screen shows one frame at a time: its
callsign in the large bold font, fixed on line 0, and its body scrolled under it
(y = 8 to 30). At the right of the callsign, once several frames are kept, a capsule
tells which frame is shown: `2/5`, the second newest of five kept (`1` is the
newest). Left of it (or alone at the edge with a single frame), a second capsule
shows the count of frames received. The symbol and RSSI are those of the frame shown. A new frame is
shown at once, from its first row. UP/DOWN held scrolls the body and stops at
its first / last row; pressing the key again there shows the newer (UP) or older
(DOWN) frame from its first row, so holding a key never leaves the frame. Body
rows occupy eight pixels in normal mode (3 rows at rest) or six in compact mode
(4 rows). History is lost on exit; no external-flash journal is used.
| Screen | Content |
|---|---|
| Status bar | `APRS RX` title, then a `WAIT` capsule until the first frame, the speaker icon while the speaker is on, then ▲ while rows or a newer frame are hidden above and ▼ while rows or an older frame 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 | One frame: source call in bold, fixed on line 0, with two capsules at the right (the frames received, then with several frames kept `2/5`, frame shown / frames kept, 1 = newest; x >= 89); under it the body in the small font (18 characters a row, non-ASCII shown as `.`), 3 rows at a time, scrolled pixel by pixel with UP/DOWN (v0.7): `>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 is shown at once. Key * switches to the compact view (below) |
| 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, y=33-52, on the right for decoded Mic-E and uncompressed positions; the area stays blank for an unknown or unavailable symbol |
| Bottom row y=49 | `-89dBm`: RSSI at the end of the frame shown (the count of frames received moved to the capsule on line 0). (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):
| Key | Action |
|---|---|
| UP/DOWN (held) | Scroll the body of the frame shown 1 px per 50 ms slot (UV-K1: LEFT/RIGHT, as `nav_dir`), down to its last row |
| UP/DOWN (pressed again at the first / last row) | Show the newer / older frame, from its first row |
| * | Normal / compact view, saved on exit, returns to the first row of the frame shown. The callsign stays large and bold on line 0. Body rows use the normal font (18 characters) or 3x5 font (32 characters), continuous 1 px scrolling |
| 1 | Speaker on/off, saved on exit (v0.8; off by default); FoxHunt's speaker icon in the status bar while on |
| 2 | Clear all five frames and the counters |
| EXIT | Quit |
The receive path is the firmware's own (STD: 300 Hz high-pass, de-emphasis,
3 kHz low-pass). Up to v0.3, key 1 switched to RAW (filters and AFC off, as
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 five frame buffers and their rotating pointers live on `app_main`'s stack;
the demodulator state lives in `listen`. Frames are copied only once on receipt;
rotation moves pointers, not payloads. Buffers and pointers take 1690 bytes on
the MCU, 1360 bytes more than the former 330-byte buffer (676 more than three
frames), excluding compiler
stack alignment and spill slots. The 4 KiB overlay also holds `.bss`. The screen
texts, symbol bitmaps and the two cos tables are assets. The display loads a
252-byte block containing 36 bytes of UI labels, 88 bytes of Mic-E messages and
the 128-byte Mic-E lookup. Compared with the original label-only read, this adds
212 temporary stack bytes and replaces character-decoding branches and separate
message reads.
The front-end initial state is stored in a 116-byte asset immediately before
the cosine tables, allowing one read to initialize `dem_t`. This replaces the
zeroing and initial-value assignments without adding a RAM buffer. Total assets
occupy 3832 of the 3840 available bytes.
## Demodulator (`test/model_rx.py`, class `Demod`; the C is a transcription)
Per ADC sample (9.6 kHz, 8 samples per bit):
1. DC removal (1-pole tracker, 64 samples).
2. Band-pass biquad centred on √(1200·2200) = 1625 Hz, Q 0.9 (Q14): equal gain
on both tones, removes the out-of-band noise the box correlators let through.
3. Four sliding correlators over one bit: I/Q at 1200 Hz (8-entry cos table) and
2200 Hz (48 entries = 11 cycles). The sine is the cos table read 3/4 period
ahead (+6, +12). Products `>> 8`, so the decision below never overflows int32.
4. Magnitudes `max + 3/8 min`, per-tone peak trackers (attack 1/32, decay
1/1024): each tone normalised by its own peak, so the twist (pre-emphasis or
not, de-emphasis or not) does not bias the decision much.
5. **3 slicers** (since v0.4), decision `sign(wa·Mm·Ps − wb·Ms·Pm)` with
wa:wb = 2:3, 1:1, 3:2, each with its own DPLL, NRZI and HDLC below (Direwolf's
multi-slicer idea): the AGC does not cancel a strong twist in noise. A frame
from any slicer is accepted; a copy from another slicer within 1 s is dropped.
6. DPLL: 65536 per bit, a bit at each wrap; each transition pulls the phase 1/4 of
the way toward mid-bit + half a sample (the transition is only seen at the
next sample: without that half-sample, a +1 % clock failed long frames).
7. NRZI, HDLC (flag, destuffing, abort after 7 ones), CRC-16/X.25 per byte,
frame accepted on the residue 0xF0B8 **and** as a UI frame: noise brings ~0.7
candidates per second to the FCS check, so without the UI check a false frame
would pass about once a day.
### Slicers (`test/variants.py`, 4 seeds, 24 hard cases: noise 3000-4000 Hz, ±1 % clock, twist −9 to +9 dB at noise 2500 Hz, RAW and STD)
| Variant | Frames /96 |
|---|---|
| 1 slicer (v0.1-v0.3) | 57 |
| 3 slicers, weights ×2 | 65 |
| **3 slicers, weights ×1.5 (v0.4)** | **71** |
| 5 slicers (×1.5 and ×2) | 71 |
The gain is on strong twist: sine AFSK at +6 / +9 dB in RAW goes from 0/4 to 4/4.
Noise only (30 s, 2 seeds, 50 and 400 LSB rms): no frame accepted.
### Model results (`test/model_rx.py`, 3 seeds per case, 1 slicer; 3 slicers: all ≥ these)
Channel: discriminator output + white noise (rms over 48 kHz) → radio audio path
(RAW: 5 kHz low-pass; STD: 300 Hz high-pass, 750 µs de-emphasis, 3 kHz low-pass)
→ 12-bit ADC at 9.6 kHz, 0.065 LSB/Hz (EPIRB 406 measurement), optional clock
error. 150 ms of no-carrier noise before and after each burst.
| Case | RAW | STD |
|---|---|---|
| Flipper, clean: `pos`, `long` (233 bytes) | 3/3, 3/3 | 3/3, 3/3 |
| Flipper `badfcs` (must be rejected) | 0 frames | 0 frames |
| Flipper, noise 1500 / 3000 / 4500 Hz | 3/3, 2/3, 0/3 | 3/3, 2/3, 0/3 |
| Flipper, ±4 kHz carrier offset | 3/3 | 3/3 |
| Flipper `long`, ±1 % sample clock | 3/3 | 3/3 (−1 %), 3/3 (+1 %) |
| Sine AFSK (a real station), twist −6 / 0 / +6 dB, noise 1500 | 3/3, 3/3, 2/3 | 2/3, 3/3, 3/3 |
Noise 4500 Hz is Eb/N0 ≈ 9.6 dB: the theoretical frame success of non-coherent
FSK on a 520-bit frame is then ~6 %, so the failures there are expected.
(At ±0.5 % clock, 6/6 in every variant tried.)
## Flipper Zero test transmitter (`test/flipper_aprs.py`)
The Flipper's CC1101 cannot put an audio tone on FM, only switch between two
frequencies (preset `2FSKDev238Async`, ±2.38 kHz). Each AFSK tone is therefore
sent as a square wave: the carrier toggles at twice the tone frequency,
phase-continuous across bits, with edge times rounded from exact values (no
drift). The receiver's discriminator outputs that square wave and its audio
low-pass leaves mostly the fundamental. The Flipper cannot reach 2 m anyway; the
script refuses 144-146 MHz.
```
test/flipper_aprs.py test/flipper [--call F4HWN] [--freq 433650000]
```
writes `aprs_pos.sub`, `_digi`, `_msg`, `_status`, `_long` (233 bytes, 2 s) and
`_badfcs` (one info bit flipped: must not be shown). 100 ms of carrier, 40 flags
(267 ms), the frame, 3 flags, 10 ms of carrier. Copy them to `subghz/` on the
Flipper, set the radio to 433.650 MHz FM, launch the app, then Send one file at
a time, about 1 s apart. (Up to v0.4, UP/DOWN tuned ±5 kHz for a Flipper whose
crystal puts the carrier off the channel; never needed on the bench, dropped in
v0.5 to fit 4 KiB.)
`test/ax25.py` builds the frames (shared by the generator and the model);
`test/mice.py` checks the Mic-E display decoder against a spec encoder, and
the standard position display against real frames;
`test/variants.py` compares demodulator variants on the hard cases (dev tool).
## Bench checklist
- Level reaching the ADC (`pp`, shown up to v0.4, dropped in v0.5 to make
room): estimated ~300 LSB peak-to-peak for the Flipper (±2.38 kHz at
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.
+43
View File
@@ -0,0 +1,43 @@
/* Overlay-app link script (POC).
*
* The blob is linked to run at the PY25Q16 sector-cache overlay VMA (shared with
* the multiboot RAM stub — never both active at once). Code + rodata + data +
* bss must all fit in the 4 KiB overlay. objcopy -O binary emits text+rodata+
* data only; the loader zeroes the overlay before copying, so bss starts clean.
*
* VMA must match ADDR(.mb_workspace) in Core/py32f071xb.ld. If the firmware RAM
* layout shifts, update APP_VMA and the loader's compile-time assert catches a
* mismatch. */
/* APP_VMA is the target firmware's __mb_workspace_start (read from its .map). It
* varies with the RAM layout, so pass it from the build: -Wl,--defsym,APP_VMA=0x...
* The default is only a fallback for a standalone size check. */
APP_VMA = DEFINED(APP_VMA) ? APP_VMA : 0x20000280;
APP_LENGTH = 0x1000; /* 4 KiB sector-cache overlay */
ENTRY(app_main)
MEMORY {
APP (rwx) : ORIGIN = APP_VMA, LENGTH = APP_LENGTH
}
SECTIONS {
.app APP_VMA : {
KEEP(*(.text.entry)) /* app_main pinned to offset 0 */
*(.text .text.*)
*(.rodata .rodata.*)
. = ALIGN(4);
*(.data .data.*)
. = ALIGN(4);
__app_bss_start = .;
*(.bss .bss.* COMMON)
. = ALIGN(4);
__app_bss_end = .;
} > APP
__app_end = .;
ASSERT(__app_end <= APP_VMA + APP_LENGTH,
"APRS RX overlay app overflows the 4 KiB overlay")
/DISCARD/ : { *(.ARM.exidx*) *(.ARM.extab*) *(.eh_frame*) *(.comment) *(.note.*) }
}
+800
View File
@@ -0,0 +1,800 @@
/* 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 RX - receives APRS (AX.25 UI frames, Bell 202 AFSK 1200 bauds) in
* software and keeps the last five frames, shown one at a time: source,
* destination and path, the info field. Tune the VFO (FM; 144.800 MHz, or 433.650 MHz for the Flipper Zero
* bench files of test/flipper_aprs.py), then launch.
*
* The BK4829 has no Bell 202 demodulator (see README.md), so the RX audio is
* sampled as EPIRB 406 does: it reaches PA4 (the voice DAC pin), held at
* mid-scale by the MCU DAC (unbuffered), and is read on ADC channel 4 at
* 9.6 kHz, timed from SysTick, continuously, as a TNC does: no RSSI gate, the
* FCS and the UI check sort frames from noise. Each sample goes through the
* demodulator modelled and tested in test/model_rx.py: band-pass, 1200/2200 Hz
* sliding correlators over one bit, per-tone AGC, then 3 slicers (mark/space
* weights 2:3, 1:1, 3:2), each with its DPLL, NRZI, HDLC and FCS. Keep the two
* in step.
* Keys and the screen cost sample time (the key scan alone is ~400 us), so they
* are served every 50 ms only while no slicer is inside a preamble or a
* plausible frame; the redraw follows a new frame, a key, or every 5 s.
*
* Keys (UV-K5 and UV-K1): UP/DOWN (held) scroll the frame 1 px per slot when
* needed; pressed again at either end: the newer / older frame · * normal
* view / compact view (saved) · 1 speaker on/off (saved, off by default: the
* decoder does not need it) · 2 clear · EXIT quit.
* The loader re-runs RADIO_SetupRegisters on exit; the app restores the ADC,
* PA4, the DAC and its clock itself.
*/
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "../app_api.h"
#include "aprsrx_assets.h" /* generated by gen_assets.py */
/* ---- MCU registers (PY32F071, core and SysTick at 48 MHz, 10 ms period) ----
* The ADC and DAC registers are reached from one base per block: the base goes
* through an empty asm, so GCC addresses them as [base, #offset] from one
* literal-pool word instead of one word per register address. */
static inline volatile uint32_t *hw(uint32_t a){
volatile uint32_t *p=(volatile uint32_t *)a;
__asm__("" : "+l"(p));
return p;
}
#define SYST_LOAD (*(volatile uint32_t *)0xE000E014u)
#define SYST_VAL (*(volatile uint32_t *)0xE000E018u)
#define ADC_SR (hw(0x40012400u)[0x00/4])
#define ADC_CR2 (hw(0x40012400u)[0x08/4])
#define ADC_SMPR3 (hw(0x40012400u)[0x14/4])
#define ADC_SQR3 (hw(0x40012400u)[0x38/4])
#define ADC_DR (hw(0x40012400u)[0x50/4])
#define GPIOA_MODER (*(volatile uint32_t *)0x50000000u)
#define DAC_CR (hw(0x40007400u)[0x00/4])
#define DAC_SWTRIGR (hw(0x40007400u)[0x04/4])
#define DAC_DHR12R1 (hw(0x40007400u)[0x08/4])
#define RCC_APBENR1 (*(volatile uint32_t *)0x4002103Cu)
#define RCC_DACEN (1u << 29)
/* DAC channel 1 on, output buffer off, software trigger (TSEL1 = 111) */
#define DAC_CR_BIAS ((1u << 0) | (1u << 1) | (1u << 2) | (7u << 3))
#define BIAS_CODE 2048u
#define ADC_SR_EOC (1u << 1)
#define ADC_CR2_START ((1u << 22) | (1u << 20)) /* SWSTART | EXTTRIG */
#define SMP8_POS 24u
#define SMP4_POS 12u
#define ADC_CH_PA4 4u
#define FS 9600u
#define CYC_PER_SAMPLE (48000000u / FS) /* 5000 */
#define HOUSE_EVERY 480u /* samples between key/screen slots (50 ms) */
#define BUSY_MAX 60u /* serve them anyway after 60 busy slots (3 s) */
#define REFRESH_SLOTS 100u /* 100 housekeeping slots = 5 s (battery redraw) */
#define REDRAW_ON 1u /* g.redraw: a key or the periodic refresh */
#define REDRAW_FRAME 2u /* g.redraw: a new frame, also wakes the backlight */
#define DUP_MS 1000u /* the same frame from another slicer */
/* ---- BK4829 ---- */
/* ---- demodulator (test/model_rx.py, class Demod) ---- */
#define BP_B0 5356 /* band-pass 1625 Hz, Q 0.9, Q14 (b2 = -b0, b1 = 0) */
#define BP_A1 (-10714)
#define BP_A2 5673
#define AGC_ATT 5 /* peak tracker: attack 1/32 ... */
#define AGC_DEC 10 /* ... decay 1/1024 per sample */
#define PK_MIN 16
#define PLL_STEP 8192 /* 65536 per bit / 8 samples per bit */
#define PLL_CTR 36864 /* transitions pulled to mid-bit + half a sample */
#define PLL_SHIFT 2 /* 1/4 of the error per transition */
#define NSL 3 /* slicers */
#define PRE_BITS 24u /* busy for 3 bytes after two back-to-back flags */
/* ---- AX.25 ---- */
#define FRAME_MAX 330u /* 10 addresses + control + PID + 256 info + FCS */
#define FRAME_MIN 18u /* 2 addresses + control + PID + FCS */
#define FCS_GOOD 0xF0B8u
#define HISTORY 5u
typedef struct {
uint16_t len;
int16_t rssi;
uint8_t buf[FRAME_MAX];
} frame_t;
/* the front end shared by the slicers */
typedef struct {
int32_t dc; /* DC tracker, Q4 */
int32_t x1, x2, y1, y2; /* band-pass state */
int32_t im, qm, is, qs; /* sliding correlator sums */
int32_t pm, ps; /* per-tone magnitude peaks (AGC) */
uint32_t r, ks; /* ring slot = 1200 Hz phase, 2200 Hz phase;
words: dem_t is inlined on the stack, where
only words have an sp-relative load/store */
int16_t ring[8][4]; /* the products of the last bit */
int8_t cm[8], cs[48]; /* cos tables (assets) */
} dem_t;
/* listen() loads both tables in one asset_read: they must stay back to back,
* in the assets as in dem_t. */
_Static_assert(COS2200==COS1200+COS1200_LEN &&
sizeof(((dem_t *)0)->cm)==COS1200_LEN &&
sizeof(((dem_t *)0)->cs)==COS2200_LEN &&
offsetof(dem_t,cs)==offsetof(dem_t,cm)+COS1200_LEN,
"cos tables must stay contiguous");
_Static_assert(BIAS_CODE==2048u && offsetof(dem_t,pm)==36u &&
offsetof(dem_t,ps)==40u &&
offsetof(dem_t,cm)==DEMOD_INIT_LEN &&
COS1200==DEMOD_INIT+DEMOD_INIT_LEN &&
sizeof(dem_t)==DEMOD_INIT_LEN+COS1200_LEN+COS2200_LEN,
"demodulator initialization asset must match dem_t");
/* one slicer: decision weights, DPLL, NRZI, HDLC */
typedef struct {
int32_t dprev, phase; /* last decision, DPLL phase (65536/bit) */
uint16_t n, crc; /* bytes, running FCS */
uint8_t wa, wb, last; /* mark/space weights, last level */
uint8_t sr, ones, byte, nb; /* HDLC: shift reg, 1 run, byte, bit count */
uint8_t inframe, hdr; /* between flags, first bytes look like a call */
uint8_t bits; /* bits since the last flag */
bool pre; /* this flag came right after another one */
uint8_t buf[FRAME_MAX];
} sl_t;
/* The app state in one struct: a function loads one base address instead of one
* literal-pool word per global. Bytes, then halfwords, then words, so every field
* stays within the Thumb load/store immediate ranges (ldrb <= 31, ldrh <= 62,
* ldr <= 124); the ADC registers shared with housekeeping go last.
* The loader zeroes the overlay (.bss) at every launch. */
static struct {
uint8_t prevKey, redraw;
bool running;
uint8_t count, cur; /* history count, frame shown (0 = newest) */
uint8_t symTable, symCode; /* symbol of the frame shown; table zero never
matches an asset */
uint8_t cw, spk; /* the saved settings, in cfg order: view (0 scroll,
CW_COMPACT compact), speaker amplifier on (key 1) */
uint8_t refresh; /* housekeeping slots since the last redraw */
uint16_t vrow; /* y of the next body row before scrolling */
uint16_t top, lim, nOk; /* pixel scroll of the body, its limit, frames */
const app_api_t *A;
char *text; /* formatting buffer (app_main's stack) */
frame_t **history; /* newest first; buffers live on app_main's stack */
uint32_t tPrev, tCyc; /* SysTick cycle counter */
uint32_t tFrame; /* ticks_ms of the last frame */
uint32_t savedSqr3, savedSmpr3;
} g;
#define str g.text
/* ---- tiny freestanding helpers: GCC may turn the zeroing / copy loops into calls ---- */
void *memset(void *d, int c, size_t n) {
uint8_t *p = d; while (n) p[--n] = (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) { --n; p[n] = q[n]; } return d;
}
/* ---- formatting ---- */
/* Share the string-copy loop across the display formatters. */
__attribute__((noinline))
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 unsigned sub(uint32_t *v,uint32_t d){ unsigned 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(unsigned i=0;i<5u;i++){
unsigned 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); }
#define WAIT_CAPS_X 40u /* "WAIT" capsule in the status bar, after the title */
#define SPK_X 59u /* speaker icon (FoxHunt's, x = 59-68) after the capsule
(x <= 56), then the scroll marks at x = 72-76 as in APRS
TX, before the battery text (x >= 83): BMP_TAIL in the
assets, both in one read */
/* One frame at a time: its source in bold on line 0, its body scrolled under
* it, y = 8 to 30, in one of two views (key *):
* - normal (g.cw = 0): 18 characters per row, 8 px apart, 3 rows at rest;
* - compact (g.cw = CW_COMPACT): 32 tiny characters per row, 6 px apart, 4 rows. */
#define ROW_CHARS 18u
#define CAPS_END 127u /* last column of the capsules at the right of the
source (x >= 89 with "65535" and "2/5") */
#define CW_COMPACT 14u /* compact rows: ROW_CHARS + 14 = 32 characters */
/* 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 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. */
__attribute__((noinline))
static void drawFreq(uint32_t f){
const app_api_t *A=g.A;
unsigned m=0;
while(f>=100000u){ f-=100000u; m++; }
char *o=puti(str,m);
*o++='.';
for(unsigned i=0;i<5u;i++) *o++=(char)('0'+sub(&f,P10[i]));
*o='\0';
o-=2; /* last two digits */
A->print_normal(o,(uint8_t)((o-str)*13u-10u),0,5);
*o='\0';
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,
* so 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;
}
/* ---- PA4 bias: the pin has no DC reference of its own (AC-coupled audio); the
* MCU DAC on PA4, output buffer off, at mid-scale, holds it at ~VDD/2.
* Set for the whole run, restored on exit (see EPIRB 406). ---- */
static void biasOn(uint32_t moder,uint32_t rcc){
GPIOA_MODER=moder|(3u<<8); /* PA4 analog */
RCC_APBENR1=rcc|RCC_DACEN;
DAC_CR=DAC_CR_BIAS;
DAC_DHR12R1=BIAS_CODE;
DAC_SWTRIGR=1u; /* DHR -> DOR */
}
static void biasOff(uint32_t dac,uint32_t dhr,uint32_t rcc,uint32_t moder){
DAC_CR=dac; DAC_DHR12R1=dhr; RCC_APBENR1=rcc; GPIOA_MODER=moder;
}
/* ---- ADC on PA4 (channel 4); channel 8 (battery) restored afterwards ---- */
static void adcSelPA4(void){
ADC_SMPR3=(g.savedSmpr3&~(7u<<SMP4_POS))|(((g.savedSmpr3>>SMP8_POS)&7u)<<SMP4_POS);
ADC_SQR3=(g.savedSqr3&~0x1Fu)|ADC_CH_PA4;
}
static void adcRestore(void){ ADC_SQR3=g.savedSqr3; ADC_SMPR3=g.savedSmpr3; }
static uint16_t adcRead(void){
ADC_CR2|=ADC_CR2_START;
for(uint16_t n=0; !(ADC_SR&ADC_SR_EOC) && n<2000u; n++){}
return (uint16_t)(ADC_DR&0x0FFFu);
}
static bool callChar(uint8_t c){ return (c>='A'&&c<='Z')||(c>='0'&&c<='9')||c==' '; }
/* An APRS frame is a UI frame: the address field ends (bit 0 of an SSID byte)
* and is followed by control 0x03 and PID 0xF0. Rejects the noise burst that
* passes the 16-bit FCS about once a day in continuous decoding. */
static bool isUI(const uint8_t *f,uint16_t n){
uint16_t end=(uint16_t)(n-2u), e=13;
while(!(f[e]&1u) && e+7u<end) e+=7;
return (f[e]&1u) && e+2u<end && f[e+1]==0x03u && f[e+2]==0xF0u;
}
/* ---- a good frame from a slicer: shown unless another slicer just gave it ---- */
static void accept(const sl_t *m){
const app_api_t *A=g.A;
uint16_t n=m->n;
uint32_t t=A->ticks_ms();
frame_t *f=g.history[0];
if(!(g.count && n==f->len && f->buf[n-1]==m->buf[n-1] && f->buf[n-2]==m->buf[n-2]
&& t-g.tFrame<DUP_MS)){
f=g.history[HISTORY-1u];
for(unsigned k=HISTORY-1u;k;k--) g.history[k]=g.history[k-1u];
g.history[0]=f;
memcpy(f->buf,m->buf,n);
f->len=n;
f->rssi=A->rssi_dbm(); /* capture while the carrier is still up */
if(g.count<HISTORY) g.count++;
g.nOk++; /* wraps after 65535: five digits in its capsule */
g.cur=0; /* show it, from its first row */
g.top=0;
g.redraw|=REDRAW_FRAME;
}
g.tFrame=t;
}
/* ---- HDLC: one bit after NRZI. A UI frame with a good FCS goes to accept()
* (m->buf, m->n bytes) before the closing flag opens the next frame. Not
* inlined: a function of its own, its m stays in a register (inlined in the
* slicer loop, it cost 56 B more); called 3600 times a second only. ---- */
__attribute__((noinline))
static void hdlcBit(sl_t *m,uint8_t b){
if(m->bits<255u) m->bits++;
m->sr=(uint8_t)((m->sr>>1)|(b<<7));
if(m->sr==0x7Eu){
bool ok = m->inframe && m->n>=FRAME_MIN && m->crc==FCS_GOOD && isUI(m->buf,m->n);
m->pre = m->bits==8u;
m->bits=0;
if(ok) accept(m);
m->inframe=1; m->hdr=1; m->n=0; m->crc=0xFFFFu;
m->ones=m->byte=m->nb=0;
return;
}
if(b){
if(++m->ones>6u){ m->ones=7; m->inframe=0; return; } /* abort / noise */
} else {
if(m->ones==5u){ m->ones=0; return; } /* stuffed zero */
m->ones=0;
}
if(!m->inframe) return;
m->byte|=(uint8_t)(b<<m->nb);
if(++m->nb==8u){
if(m->n<FRAME_MAX){
uint16_t c=m->crc^m->byte; /* CRC-16/X.25, reflected 0x1021 */
for(uint8_t i=0;i<8u;i++) c=(c&1u)?(uint16_t)((c>>1)^0x8408u):(uint16_t)(c>>1);
m->crc=c;
if(m->n<6u && ((m->byte&1u) || !callChar((uint8_t)(m->byte>>1)))) m->hdr=0;
m->buf[m->n++]=m->byte;
} else m->inframe=0;
m->byte=m->nb=0;
}
}
/* A slicer is busy inside a preamble (two back-to-back flags, then 3 bytes) or
* a frame whose first bytes look like a callsign: noise is busy ~4 % of the time. */
static bool busy(const sl_t *m){
return (m->pre && m->bits<PRE_BITS) || (m->inframe && m->n && m->hdr);
}
/* |(i, q)| ~ max + 3/8 min */
static int32_t mag(int32_t i,int32_t q){
if(i<0) i=-i;
if(q<0) q=-q;
if(i<q){ int32_t t=i; i=q; q=t; }
return i+(q>>2)+(q>>3);
}
/* Magnitude of one tone and its peak tracker (AGC). Not inlined: one copy for
* both tones, two calls per sample. */
__attribute__((noinline))
static int32_t tone(int32_t i,int32_t q,int32_t *pk){
int32_t mt=mag(i,q), p=*pk;
p+= mt>p ? (mt-p)>>AGC_ATT : -(p>>AGC_DEC);
if(p<PK_MIN) p=PK_MIN;
*pk=p;
return mt;
}
/* ---- front end, one ADC sample: *a = Mm*Ps, *b = Ms*Pm (each tone over its
* own peak, without division; both stay below ~2^21) ---- */
static void front(dem_t *m,int32_t adc,int32_t *a,int32_t *b){
m->dc+=((adc<<4)-m->dc)>>6;
int32_t x=adc-(m->dc>>4);
int32_t y=(BP_B0*(x-m->x2)-BP_A1*m->y1-BP_A2*m->y2)>>14;
m->x2=m->x1; m->x1=x; m->y2=m->y1; m->y1=y;
/* sliding correlators over one bit; the sine is the cos table 3/4 period ahead.
* At 9.6 kHz one bit and one 1200 Hz period are both 8 samples: the ring slot
* is also the 1200 Hz table phase. */
int16_t *o=m->ring[m->r];
uint32_t k=m->ks+12u; if(k>=48u) k-=48u;
int32_t p;
p=(y*m->cm[m->r])>>8; m->im+=p-o[0]; o[0]=(int16_t)p;
p=(y*m->cm[(m->r+6u)&7u])>>8; m->qm+=p-o[1]; o[1]=(int16_t)p;
p=(y*m->cs[m->ks])>>8; m->is+=p-o[2]; o[2]=(int16_t)p;
p=(y*m->cs[k])>>8; m->qs+=p-o[3]; o[3]=(int16_t)p;
m->r=(m->r+1u)&7u;
if(++m->ks>=48u) m->ks=0;
int32_t mm=tone(m->im,m->qm,&m->pm), ms=tone(m->is,m->qs,&m->ps);
*a=mm*m->ps;
*b=ms*m->pm;
}
/* ---- slicer: decision sign(wa*a - wb*b), DPLL, NRZI, HDLC ---- */
static void slice(sl_t *m,int32_t a,int32_t b){
int32_t d=a*m->wa-b*m->wb;
/* DPLL: a bit at each phase wrap (mid-bit), transitions pulled to mid-phase */
m->phase+=PLL_STEP;
if((d>0)!=(m->dprev>0)) m->phase+=(PLL_CTR-m->phase)>>PLL_SHIFT;
m->dprev=d;
if(m->phase>=65536){
m->phase-=65536;
uint8_t lv=d>0;
hdlcBit(m,lv==m->last); /* NRZI: no change = 1 */
m->last=lv;
}
}
/* ---- 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;
}
/* The tiny font covers 0x20-0x7F: anything else shows as '.'. */
__attribute__((noinline))
static char safe(uint8_t ch){ return (ch<0x20u||ch>0x7Eu)?'.':(char)ch; }
/* Body rows str..end, wrapped every ROW_CHARS + g.cw. A visible row is rendered
* on scratch page 4, then its columns are ORed into lines 0-3 at its exact pixel
* offset: 8-pixel and 6-pixel rows scroll alike, without another framebuffer.
* Rows may spill over line 0 and the separator: draw() redraws both afterwards.
* The scratch page is left blank: display_clear() blanks it before the first
* row and each row clears it after use. */
static char *emit(char *end){
const app_api_t *A=g.A;
char *p=str;
while(p<end){
unsigned w=ROW_CHARS+g.cw; /* 18 or 32; compare lengths before forming p + w */
char *q=((unsigned)(end-p)>w)?p+w:end;
char c=*q; *q='\0';
unsigned y=(unsigned)g.vrow-g.top; /* wraps to a huge value above the screen */
if(y-1u<30u){ /* y = 1..30: higher rows lie under the source */
uint8_t *scratch=A->fb[4];
if(g.cw) A->print_tiny(p,0,32,false,true);
else A->print_normal(p,0,0,4);
for(unsigned x=0;x<128u;x++){
uint32_t bits=(uint32_t)scratch[x]<<y;
for(uint8_t *out=A->fb[0]+x;bits;out+=128){
*out|=(uint8_t)bits;
bits>>=8;
}
}
memset(scratch,0,128);
}
*q=c;
g.vrow+=g.cw?6u:8u;
p=q;
}
return str; /* the next row starts there */
}
/* Between two parts of a row ("48 50.89N" / "002 16.25E"): a new row in the
* scroll view, a space in the compact one. */
static char *brk(char *o){
if(g.cw){ *o++=' '; return o; }
return emit(o);
}
/* Info bytes f[i..end) after the text already at str..o, on as many rows as
* they take. */
static void textRows(char *o,const uint8_t *f,unsigned i,unsigned end){
const uint8_t *p=f+i, *last=f+end;
do{
for(; o<str+ROW_CHARS+g.cw && p<last; p++) *o++=safe(*p);
o=emit(o);
}while(p<last);
}
/* ---- Mic-E (APRS 1.0.1 chapter 10; test/mice.py is the reference) ----
* The latitude, N/S, the longitude +100 offset, E/W and the message bits are in
* the destination address; the longitude, speed, course and symbol in the info
* field. Byte arithmetic wraps as uint8, so a bad field prints junk, never loops. */
static char *put2(char *o,uint32_t v){ *o++=(char)('0'+sub(&v,10u)); *o++=(char)('0'+v); return o; }
static char *put3(char *o,uint32_t v){ *o++=(char)('0'+sub(&v,100u)); return put2(o,v); }
static bool micType(uint8_t t){ return t=='`'||t=='\''||t==0x1Cu||t==0x1Du; }
/* Keep decoder temporaries out of the history renderer's register allocation. */
__attribute__((noinline))
static void drawMicE(const uint8_t *f,unsigned i,unsigned end,const char *s){
uint8_t c[6];
/* The asset lookup packs digit, position/message bit and custom flag. */
for(unsigned k=0;k<6u;k++) c[k]=(uint8_t)s[T_MIC+(f[k]>>1)];
/* "48 50.89N" / "002 16.25E" (brk) */
char *o=str;
for(uint8_t k=0;k<6u;k++){
*o++=(char)('0'+(c[k]&15u));
if(k==1u) *o++=' ';
if(k==3u) *o++='.';
}
*o++=(c[3]&16u)?'N':'S';
o=brk(o);
uint32_t deg=(uint8_t)(f[i+1]-28u);
if(c[4]&16u) deg+=100u;
if(deg>=180u && deg<=189u) deg-=80u;
else if(deg>=190u && deg<=199u) deg-=190u;
uint32_t mn=(uint8_t)(f[i+2]-28u);
if(mn>=60u) mn-=60u;
o=put3(o,deg); *o++=' '; o=put2(o,mn); *o++='.'; o=put2(o,(uint8_t)(f[i+3]-28u));
*o++=(c[5]&16u)?'W':'E';
emit(o);
/* "12km/h 245 [/" / "En Route" (brk): speed in knots x 1.8525 (1897/1024) */
uint32_t dc=(uint8_t)(f[i+5]-28u);
uint32_t sp=(uint32_t)(uint8_t)(f[i+4]-28u)*10u+sub(&dc,10u);
uint32_t crs=dc*100u+(uint8_t)(f[i+6]-28u);
if(sp>=800u) sp-=800u;
if(crs>=400u) crs-=400u;
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);
unsigned idx=0;
unsigned custom=0;
for(unsigned k=0;k<3u;k++){
idx=(idx<<1)|((c[k]>>4)&1u);
custom|=c[k]&32u;
}
if(custom && idx){ o=put(o,s+T_CUSTOM); *o++=(char)('0'+7u-idx); }
else o=put(o,s+T_MSG+idx*T_MSG_STRIDE);
emit(o);
/* comment: trailing CR/LF, the device markers (Yaesu/other: '`' or '\'' +
* text + 2 chars; Kenwood: '>' or ']' + text [+ '=' or '^']) and a leading
* "xxx}" altitude (base 91, metres + 10000) */
unsigned j=i+9u; /* the caller guarantees j <= end */
while(end>j && (f[end-1]=='\r'||f[end-1]=='\n')) end--;
if(j<end){
uint8_t m=f[j];
if(m=='`'||m=='\''){ j++; if(end-j>=2) end-=2u; }
else if(m=='>'||m==']'){ j++; if(end>j && (f[end-1]=='='||f[end-1]=='^')) end--; }
}
o=str;
if(end-j>=4 && f[j+3]=='}'){
int32_t alt=(((int32_t)f[j]-33)*91+((int32_t)f[j+1]-33))*91+((int32_t)f[j+2]-33)-10000;
o=puti(o,alt); *o++='m'; *o++=' ';
j+=4u;
}
textRows(o,f,j,end);
}
/* ---- uncompressed position ('!' '=' or, after a 7-character timestamp, '/'
* '@'; test/mice.py draw_pos is the reference): "49 21.07N 001 50.50E", then
* the timestamp, the symbol and the comment. False for anything else (compressed
* positions included), which is then shown as raw text. The position takes
* two rows in the scroll view, "49 21.07N" / "001 50.50E", one when compact. ---- */
__attribute__((noinline))
static bool drawPos(const uint8_t *f,unsigned i,unsigned end){
uint8_t t=f[i];
unsigned p=i+1u; /* native-width offset; bounds checked below */
if(t=='/'||t=='@') p+=7u;
else if(t!='!'&&t!='=') return false;
if(end<p+19u || f[p+4]!='.' || f[p+14]!='.') return false;
const uint8_t *q=f+p; /* DDMM.hhN T DDDMM.hhE C */
char *o=str;
for(uint8_t k=0;k<18u;k++){
if(k==8u){ o=brk(o); continue; } /* symbol table: later */
if(k==2u||k==12u) *o++=' ';
*o++=safe(q[k]);
}
o=emit(o);
if(p>i+1u){
/* A timestamp always occupies the seven bytes after the data type. */
for(unsigned k=0;k<7u;k++) *o++=safe(f[i+1u+k]);
*o++=' ';
}
*o++=safe(q[8]); *o++=safe(q[18]); *o++=' ';
g.symTable=q[8]; g.symCode=q[18];
textRows(o,f,p+19u,end);
return true;
}
/* ---- display ---- (not inlined: 16 B less) */
__attribute__((noinline))
static void draw(void){
const app_api_t *A=g.A;
/* The UI texts and Mic-E lookup live in the assets: one read into this word-aligned
* stack block, so each text costs a 2-byte sp-relative add instead of a
* literal and its pool word (gen_assets.py keeps every offset aligned). */
char s[UI_SIZE] __attribute__((aligned(4)));
char *o;
frame_t *frame=g.history[g.cur]; /* read only when there is a frame */
uint8_t *b=A->fb[0];
A->asset_read(0,s,UI_SIZE);
A->display_clear();
A->status_clear();
g.vrow=8; /* the body starts under the source */
g.symTable=0;
A->print_inverse(s+T_TITLE,2,0,true,true,(uint8_t)(2u+T_TITLE_CHARS*4u));
A->draw_battery();
if(!g.count) /* no frame yet: a capsule, as APRS TX's TRANSMIT */
A->print_inverse(s+T_WAIT,WAIT_CAPS_X,0,true,true,(uint8_t)(WAIT_CAPS_X+T_WAIT_CHARS*4u));
else {
const uint8_t *f=frame->buf;
unsigned end=frame->len-2u; /* the FCS is not shown */
/* ">DEST,DIGI*,..." while it fits 2 rows, then the rest of the
* address field is skipped up to its end bit */
o=str; *o++='>'; o=putCall(o,f);
unsigned e=13; /* isUI() already checked the end marker and bounds */
while(!(f[e]&1u)){
e+=7;
if(o<=str+20){ *o++=','; o=putCall(o,f+e-6); if(f[e]&0x80u) *o++='*'; }
}
emit(o);
/* info field (after control and PID): Mic-E and uncompressed positions
* decoded, anything else as text */
unsigned i=e+3u;
if(end>=i+9u && micType(f[i])) drawMicE(f,i,end,s);
else if(i<end && !drawPos(f,i,end)) textRows(str,f,i,end);
/* the source, fixed on line 0 over the body rows scrolled there */
memset(b,0,128);
*putCall(str,f+7)='\0';
A->print_bold(str,0,0,0);
/* at the right of the source, two capsules: once several frames are
* kept, "2/5" at the edge (the frame shown / the frames kept, 1 =
* newest), and left of it the frames received. A capsule runs from 2 px before its text to its end column. */
unsigned capsEnd=CAPS_END;
if(g.count>1u){
o=str; *o++=(char)('1'+g.cur); *o++='/'; *o++=(char)('0'+g.count); *o='\0';
A->print_inverse(str,CAPS_END-12u,0,false,true,CAPS_END);
capsEnd-=16u; /* that capsule and a blank column */
}
o=puti(str,g.nOk); *o='\0';
A->print_inverse(str,(uint8_t)(capsEnd-4u*(unsigned)(o-str)),0,false,true,(uint8_t)capsEnd);
}
/* The last glyph ends one pixel before its row height: the scroll stops
* with it right above the separator. */
unsigned lim=g.vrow>32u?g.vrow-32u:0u;
g.lim=lim;
/* Status bar from SPK_X: the speaker icon while on, an up mark while rows
* or a newer frame hide above, a down mark while rows or an older frame hide
* below: UP / DOWN has something to show. */
unsigned t=g.spk;
if(g.top || g.cur) t+=2u;
if(g.top<lim || g.cur+1u<g.count) t+=4u;
A->asset_read((uint16_t)(BMP_TAIL+t*TAIL_W),A->status_line+SPK_X,TAIL_W);
/* dotted separator above the frequency: y = 31, bit 7 of line 3, over
* whatever the shift brought there (the big digits start at y = 33: one
* blank px between) */
for(unsigned x=0;x<128u;x+=2u){
b[384u+x]|=0x80u;
b[385u+x]&=0x7Fu;
}
drawFreq(A->rx_freq());
drawSymbol();
/* bottom line: "-89dBm", RSSI of the frame shown (none captured yet: blank) */
o=str;
if(g.count){ o=puti(o,frame->rssi); o=put(o,s+T_DBM); }
tiny(49,o);
}
/* ---- input ---- */
static void handleKeys(void){
const app_api_t *A=g.A;
uint8_t key=A->get_key();
/* UP/DOWN, held: scroll the body 1 px per slot (20 px/s) while rows overflow.
* Pressed again once at an end: the newer (UP) or older (DOWN) frame, from its
* first row; holding the key never leaves the frame. */
int d=A->nav_dir(key);
unsigned top=(unsigned)(g.top+d); /* wraps past the first row */
if(top<=g.lim){
if(top!=g.top){ g.top=(uint16_t)top; g.redraw|=REDRAW_ON; }
} else if(key!=g.prevKey && (unsigned)(g.cur+d)<g.count){
g.cur=(uint8_t)(g.cur+d); /* the new key redraws below */
g.top=0;
}
if(key==APP_KEY_INVALID||key==g.prevKey){ g.prevKey=key; return; }
g.prevKey=key;
g.redraw|=REDRAW_ON;
if(key==APP_KEY_EXIT) g.running=false;
else if(key==APP_KEY_2){ g.count=g.cur=0; g.nOk=0; g.top=0; } /* g.lim: 0 at the redraw that follows */
else if(key==APP_KEY_1){ g.spk^=1u; A->audio_path(g.spk); } /* speaker, as FoxHunt's audio */
else if(key==APP_KEY_STAR){ g.cw^=CW_COMPACT; g.top=0; } /* the view (saved on exit) */
}
/* ---- keys and screen, between frames ---- */
static void house(void){
const app_api_t *A=g.A;
handleKeys();
if(!g.running) return;
if(g.redraw&REDRAW_FRAME) A->backlight_on(); /* wake on a new frame */
A->backlight_update(); /* normal BLTime timeout; keys re-arm it in get_key() */
if(++g.refresh>=REFRESH_SLOTS) g.redraw|=REDRAW_ON;
if(!g.redraw) return;
g.redraw=0;
g.refresh=0;
adcRestore(); /* the battery is on ADC channel 8 */
A->battery_sample();
adcSelPA4();
draw();
A->blit_status();
A->blit_full();
}
/* ---- the receiver: PA4 at 9.6 kHz, continuously, until EXIT ---- */
/* Keep decoder stack offsets independent of the five history buffers. */
__attribute__((noinline))
static void listen(void){
const app_api_t *A=g.A;
dem_t d;
sl_t sl[NSL];
memset(sl,0,sizeof sl);
A->asset_read(DEMOD_INIT,&d,sizeof d); /* initial state, then both cosine tables */
sl[0].wa=2; sl[0].wb=3; /* favours space */
sl[1].wa=1; sl[1].wb=1;
sl[2].wa=3; sl[2].wb=2; /* favours mark */
/* hdlcBit initializes the CRC at the opening flag, before collecting bytes. */
adcSelPA4();
clkStart();
uint32_t next=0; /* the first sample at once */
uint16_t cnt=0;
uint8_t busyFor=0;
g.redraw=REDRAW_ON;
while(g.running){
while((int32_t)(clkCyc()-next)<0){}
next+=CYC_PER_SAMPLE;
int32_t a,b;
front(&d,adcRead(),&a,&b);
for(uint8_t k=0;k<NSL;k++) slice(&sl[k],a,b);
if(++cnt<HOUSE_EVERY) continue;
cnt=0;
bool bz=false; /* only the slot's own sample decides */
for(uint8_t k=0;k<NSL;k++) bz|=busy(&sl[k]);
if(bz && ++busyFor<BUSY_MAX) continue;
busyFor=0;
house();
next=clkCyc(); /* skip the samples the slot took */
}
adcRestore();
}
__attribute__((section(".text.entry"),used))
void app_main(const app_api_t *api){
frame_t frames[HISTORY]; /* on the stack: the 4 KiB overlay also holds .bss */
frame_t *history[HISTORY];
for(unsigned k=0;k<HISTORY;k++) history[k]=&frames[k];
char text[34];
g.A=api;
g.text=text;
g.history=history;
/* The overlay loader zeroes g; the first idle key scan records APP_KEY_INVALID. */
g.savedSqr3=ADC_SQR3; g.savedSmpr3=ADC_SMPR3;
/* Only app_main needs these snapshots; keep them outside the overlay .bss. */
const uint32_t savedModer=GPIOA_MODER, savedDac=DAC_CR;
const uint32_t savedRcc=RCC_APBENR1, savedDhr=DAC_DHR12R1;
biasOn(savedModer,savedRcc);
api->backlight_on();
api->cfg_load(&g.cw,2); /* view and speaker; erased flash (0xFF) or junk:
scroll, speaker off */
if(g.cw!=CW_COMPACT) g.cw=0;
g.spk=(uint8_t)(g.spk==1u);
/* Speaker amplifier (PA8) as saved, off by default: the decoder does not
* need it (tested on the radio, 2026-09-30): PA4, the voice-prompt DAC pin,
* joins the audio before the amplifier; only the BK4829 AF output must be
* on. Key 1 toggles the speaker to listen to the channel. */
api->audio_path(g.spk);
api->set_af(APP_AF_FM);
api->delay_ms(50);
g.running=true;
listen();
biasOff(savedDac,savedDhr,savedRcc,savedModer);
api->set_af(APP_AF_MUTE);
api->audio_path(false);
api->cfg_save(&g.cw,2); /* staged: flash is written only after a change */
}
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Build one overlay-app blob (.app). Invoked by ../../../compile-app.sh inside
# the uvk1-uvk5v3 Docker image, run from this app's directory. Everything is
# derived from the directory name; only APP_NAME - the human label baked into
# the 64-byte blob header - is per-app. The .app file is named after it (spaces
# stripped), so "Broadcast FM" -> BroadcastFM.app.
set -euo pipefail
APP="$(basename "$PWD")" # breakout, foxhunt, beacon, fm, ...
APP_NAME="APRS RX" # <-- the only per-app line
APP_VER="0.9"
APP_API_MIN=2
APP_VMA=${APP_VMA:-0x20000280} # pinned overlay VMA (Core/py32f071xb.ld)
OUT="${APP_NAME// /}" # blob basename ("Broadcast FM" -> BroadcastFM)
CC=/opt/toolchain/bin/arm-none-eabi-gcc
OBJCOPY=/opt/toolchain/bin/arm-none-eabi-objcopy
command -v arm-none-eabi-gcc >/dev/null 2>&1 && { CC=arm-none-eabi-gcc; OBJCOPY=arm-none-eabi-objcopy; }
# GCC 13 supports -Oz: prioritize overlay size over instruction count.
CFLAGS="-mcpu=cortex-m0plus -mthumb -Oz -fno-jump-tables -std=gnu11 -ffreestanding -fno-builtin -fno-common \
-fomit-frame-pointer -ffunction-sections -fdata-sections -Wall -Wextra"
LDFLAGS="-nostdlib -nostartfiles -T app.ld -Wl,--defsym,APP_VMA=${APP_VMA} \
-Wl,--gc-sections -Wl,-Map=${APP}.map -Wl,--build-id=none -Wl,--no-warn-rwx-segments"
rm -f ./*.app ./*.elf ./*.bin # drop stale artifacts so discovery is unambiguous
step() { printf '\r 🔨 %-13s [%d/4] %-8s' "$APP_NAME" "$1" "$2"; }
trap 'printf "\r ❌ %-13s build failed \n" "$APP_NAME"' ERR
step 1 assets ; APP_VER="$APP_VER" python3 ./gen_assets.py "${APP}_assets.bin" "${APP}_assets.h"
# A failed link (4 KiB overflow) leaves no ELF: keep an object to disassemble.
step 2 compile ; "$CC" $CFLAGS $LDFLAGS "${APP}_app.c" -lgcc -o "${APP}.elf" \
|| { "$CC" $CFLAGS -c "${APP}_app.c" -o "${APP}.o"; false; }
step 3 objcopy ; "$OBJCOPY" -O binary "${APP}.elf" "${APP}.bin"
step 4 pack ; python3 ../pack_app.py "${APP}.bin" "${OUT}.app" \
--name "$APP_NAME" --ver "$APP_VER" --api-min "$APP_API_MIN" --vma "${APP_VMA}" \
--shortcut none --assets "${APP}_assets.bin" >/dev/null
trap - ERR
BYTES=$(wc -c < "${APP}.bin")
if [ "$BYTES" -gt 4096 ]; then
printf '\r 🚨 %-13s OVERFLOWS 4 KiB (%d B) \n' "$APP_NAME" "$BYTES"; exit 1
fi
printf '\r ✅ %-13s %4d B (%d%% of 4 KiB) -> %s.app \n' \
"$APP_NAME" "$BYTES" "$(( BYTES * 100 / 4096 ))" "$OUT"
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env python3
# APRS RX read-only assets: the screen texts and the correlator tables.
#
# The UI texts come first, each padded to a multiple of 4 bytes: draw() reads
# them in one asset_read into a word-aligned stack block, so every text costs a
# 2-byte sp-relative add instead of a literal-pool load and a pool word.
# The Mic-E message names are a fixed-stride table read straight into the row.
# The tables are 127 * cos(2 pi f n / 9600): one period of 1200 Hz (8 samples)
# and of 2200 Hz (48 samples = 11 cycles). The sine is the same table read 3/4
# of a period ahead (+6 and +12), as in test/model_rx.py.
#
# ./gen_assets.py aprsrx_assets.bin aprsrx_assets.h
import math, os, sys
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
WAIT_CAPS = "WAIT" # status-bar capsule until a frame
UI = [
("T_TITLE", TITLE),
("T_WAIT", WAIT_CAPS),
("T_DBM", "dBm"),
("T_KMH", "km/h "),
("T_CUSTOM", "Custom-"),
]
# Mic-E message types, indexed by the destination's message bits A B C
MIC_MSG = ["Emergency", "Priority", "Special", "Committed",
"Returning", "In Service", "En Route", "Off Duty"]
def cos_table(f):
per = FS // math.gcd(FS, f)
return [int(round(127 * math.cos(2 * math.pi * f * n / FS))) for n in range(per)]
def mic_code(c):
"""Digit in bits 0-3, message/position bit in bit 4, custom flag in bit 5."""
digit = 0
for base in (ord('0'), ord('A'), ord('P')):
if base <= c < base + 10:
digit = c - base
custom = ord('A') <= c <= ord('K')
bit = c >= ord('P') or custom
return digit | (int(bit) << 4) | (int(custom) << 5)
a = Assets("APRSRX")
ui_size = 0
for name, s in UI:
pad = -(len(s) + 1) % 4 # keep every offset word-aligned
a.text(name, s + "\0" * pad)
ui_size += len(s) + 1 + pad
a.u8("T_MIC", [mic_code(c) for c in range(128)])
a.const("T_TITLE_CHARS", len(TITLE))
a.const("T_WAIT_CHARS", len(WAIT_CAPS))
a.table("T_MSG", MIC_MSG) # Mic-E standard messages
# Assets.build() places every text/table before binary data. Include the
# messages between the UI labels and T_MIC in the single display read.
ui_size += len(MIC_MSG) * (max(map(len, MIC_MSG)) + 1) + 128
a.const("UI_SIZE", ui_size)
# The status bar from x = 59 (SPK_X), in one asset_read: the speaker icon
# (bit 0 of the index), 3 blank columns, then the scroll marks at x = 72, up
# (bit 1, rows hidden above) in bits 0-2, down (bit 2, rows hidden below) in
# bits 4-6. 8 variants of TAIL_W bytes.
SPK = [0x1c,0x1c,0x3e,0x7f,0x00,0x22,0x1c,0x41,0x22,0x1c] # FoxHunt's
UP, DN = [0x04, 0x06, 0x07, 0x06, 0x04], [0x10, 0x30, 0x70, 0x30, 0x10]
tail = []
for i in range(8):
tail += (SPK if i & 1 else [0] * len(SPK)) + [0] * 3
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)
# dem_t prefix: dc, eight filter/correlator words, pm/ps, r/ks, ring.
# Append the existing cosine tables so one read initializes the entire front end.
a.u32("DEMOD_INIT", [2048 << 4] + [0] * 8 + [64, 64] + [0] * 18)
a.i8("COS1200", cos_table(1200)) # 8 entries
a.i8("COS2200", cos_table(2200)) # 48 entries
if __name__ == "__main__":
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,249 @@
{
"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": "0000000011110000000000000001111110000000000000011111100000000000000111111000000000000000111100000000000000000000000000000000011111111110000000001111111111110000000011011111101100000000110111111011000000001101111110110000000011011111101100000000110111111011000000000001111110000000000000011001100000000000000110011000000000000001100110000000000000011001100000000000000110011000000000000000000000000000"
},
{
"code": "/-",
"name": "VHF HOUSE",
"pixels": "0000000000000000000000000000011111110000000000000000100000000000000000111110000000000000000010000000000000000001110000000000000011001000000000000001111010000000000000111111100000000000011111111000000000001111111111000000000111111111111000000001111111111110000000011100110001100000000111001100011000000001110011000110000000011111110001100000000111111100011000000001111111111110000000000000000000000000"
},
{
"code": "/\\",
"name": "DIRECTION FIND",
"pixels": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000011111111111111000000110000000000110000000100000000001000000001100000000110000000001000000001000000000011000000110000000000010000001000000000000110000110000000000000100001000000000000001100110000000000000001001000000000000000011110000000000000000011000000000000000000110000000000000000000000000000000000000000000000000"
},
{
"code": "/.",
"name": "X",
"pixels": "0000000000000000000000000000000000000000000000000000000000000001100000000001100000011100000000111000000111100000011110000001111100001111100000001111100111110000000001111111111000000000001111111100000000000001111110000000000000111111110000000000011111111110000000001111100111110000000111110000111110000001111000000111100000011100000000111000000110000000000110000000000000000000000000000000000000000000"
},
{
"code": "/^",
"name": "LARGE ACFT",
"pixels": "0000000001100000000000000000111100000000000000001111000000000000000011110000000000000000111100000000000000011111100000000000001111111100000000000111111111100000000111111111111110000011111111111111110011111111111111111111111001101111011001111000011011110110000100000000111100000000000000001111000000000000000011110000000000000011111111000000000001111111111000000000011101101110000000000000000000000000"
},
{
"code": "/0",
"name": "CIRCLE",
"pixels": "0000000000000000000000000001111110000000000001111111111000000000111000000111000000011000000000011000001100000000000011000011000000000000110001100000000000000110011000000000000001100110000000000000011001100000000000000110011000000000000001100110000000000000011000110000000000001100001100000000000011000001100000000001100000001110000001110000000001111111111000000000000111111000000000000000000000000000"
},
{
"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": "0000000000000000000000000011111111000000000011101111011100000001110111111011100000011001111110011000001110111111110111000011101111111101110000111011111111011100000110111111110110000001110111111011100000001101111110110000000001101111011000000000001011110100000000000001011010000000000000001001000000000000000010010000000000000001111110000000000000011111100000000000000011110000000000000000000000000000"
},
{
"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": "0000000000000000000000000000111100000000000000111001110000000000111000000111000000011000011000011000000100001111000010000011000111111000110000100011111111000100010001111111111000100100111111111111001001001111111111110010010001111111111000100010001111111100010000110001111110001100000100001111000010000001100001100001100000001110000001110000000000111001110000000000000011110000000000000000000000000000"
},
{
"code": "\\-",
"name": "HF HOUSE",
"pixels": "0000000000001000000000000001111111111100000000000000100000000000000001111111000000000000000010000000000000000001110000000000000011001000000000000001111010000000000000111111100000000000011111111000000000001111111111000000000111111111111000000001111111111110000000011100110001100000000111001100011000000001110011000110000000011111110001100000000111111100011000000001111111111110000000000000000000000000"
},
{
"code": "/W",
"name": "NWS SITE",
"pixels": "0000000000000000000000000000001000000000000000001111000000000000000111111100000000000011111111110000000011111111111110000001111111111111110000111111111111111100001111111111111111000001111111111111100000000110011001100000000000000000000000000000000000000000000000001000100010001000000110011001100110000000000000000000000000000010001000100000000001100110011000000000000000000000000000000000000000000000"
},
{
"code": "\\.",
"name": "AMBIGUOUS",
"pixels": "0000000000000000000000000000000000000000000000011111000000000000001111111000000000000111111111000000000001110001110000000000011100011100000000000000000111000000000000000011100000000000000001110000000000000000111100000000000000001110000000000000000011100000000000000000000000000000000000001110000000000000000011100000000000000000111000000000000000000000000000000000000000000000000000000000000000000000"
},
{
"code": "\\0",
"name": "OVERLAY CIRCLE",
"pixels": "0000000111111000000000000111111111100000000111100000011110000011100000000001110000110000000000001100011000011111100001100110001111111100011011000111111111100011110001111111111000111100011111111110001111000111111111100011110001111111111000111100011111111110001101100011111111000110011000011111100001100011000000000000110000111000000000011100000111100000011110000000011111111110000000000001111110000000"
},
{
"code": "E0",
"name": "ECHOLINK",
"pixels": "0000000000000000000000000000011000000000000000001111000000000000000011110000000000000000011000000000000000001001000000000000000110011000000000000001000010000000000000110000110000000000011110011110000000000111100111100000000000110000110000000000010000000010000000001100000000110000000010000000000100000001100000000001100000111100000000111100001111000000001111000001100000000001100000000000000000000000"
},
{
"code": "I0",
"name": "IRLP",
"pixels": "0000000000000000000000000000011000000000000000001111000000000000000011110000000000000000111100000000000000000110000000000000000010010000000000000001100110000000000000010000100000000000001100001100000000000010000001000000000001100000011000000000010000000010000000001100000000110000000111100000011110000001111111111111100000011110000001111000000011000000001100000000000000000000000000000000000000000000"
},
{
"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 = 'AAAAAAAAAMDw/PDAAAAAAAAAAAAAAQMHh8////9/////z4cHAwEAAAAAAAYHAwEBAAAAAQEDBwYAAAAAgABgYGAgICAgoPCgICAgIGBgYAAbHw4EBAwcPn//f3N/evJ8OAAAAAAAAAAAAAAAAQMDAwMDAwIBAAAAAAAAABwEBHwExPzEBHwEBBwAAAAAAAAAAACA8PzPz8/88IAAAAAAAAAAAAAABAcHBQQEBAUHBwQAAAAAAAAAAAAAAIDgAP788MAAAAAAAAAAAICAoLi+v7+A/9/f39/c0MBgIAAABQUFBQUFBQUFBQUFBQUFAAAAgIAAAAAEDDjw4MCAAAAAAAAAAAAgPx8ODg4Oju7/fz8fDg4ODg4OBAAAAAAABAYDAQAAAAAAAAAAAAAAAAAAAAAAAACYvLy8mAAAAAAAAAAAAAAAABweA+P/P//jAx4cAAAAAAAAAAAAAAAGBwcABwcGAAAAAAAAAAAAAAAAAIDAwooq/ioKAgAAAAAAAAD4/P4fH///Hx8f/PgAAAAAAAAAAAcHBwcHBwcEBAQHBwAAAAAAAAAAMPCQEBAQEBAQEBCQ8DAAAAAAAAAAAAMOOOCAgOA4DgMAAAAAAAAAAAAAAAAAAAMDAAAAAAAAAAAAAAAAePjw4MCAAACAwODw+HgAAAAAAADA4PH7fz8fHz9/+/HgwAAAAAAAAAMDAQAAAAAAAAAAAQMDAAAAAAAAAACAgMDw3NzwwICAAAAAAAAMDg4HBwcHg/////+DBwcHBw4ODAAAAAAABwMDAQcHAQMDBwAAAAAAAAAAwOAwEBgICAgIGBAw4MAAAAAAAB9/4IAAAAAAAAAAAIDgfx8AAAAAAAAAAQEDAgICAgMBAQAAAAAAAAB8OBAQ0NAQOGxsAAAAAAAAAAAAAAAAGBz//xwYAAAAfPx8AAAAAAAAAAAAAAcHAgICAgICAwAAAAAAAAAAAACAAMDw/P7ggIDgAAAAAAAAAAA4/v//Hw8BAx+f//n8PgAAAAAAAAAAAQMCAAAAAgMDAQAAAAAAAIDAwMDAwEBAwMDA8Ph4QMCAAAAAfz+f35k5YGB5eX9/P5zcnD1+fAAAAAEDAQAAAAAAAAAAAQMBAAAAAAAAAAAAAADA8MAAAAAAAAAAAAAAAADA8Pj+Pw8HDz/++ODAAAAAAAABAQEBAQEBAQEBAQEBAQEBAQEAAAAAAAAAAEDAQAAAEBCQ4IAAAAAAcIgkdCSMciEnGQkFc4kkdySIcAAAAAEBAQAAAAAAAAAAAAEBAQAAAAAAAAAAAAAAAACImPDAAAAAAAAySbW1STMGHDw8Px8HM0m1tUkyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAgMBAQGAgIDAQEBiIiIiAwIB+Pp/fnz+f358/YX98fD+Y2Jg5fwAAAQMBAAEDAQAAAAAAAAEDAQAAAAAAAICAwMDAwMDAwIAAAAAAAAB4fBxP7+1MHDw/PDwdT+7sTBx8eAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEDAwIAAAAAAAICAAAAMGDBgYHBwODgZHw8OBgcDAwEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgMBwODgQAAAOHx8enNxsNBgMFhsdHBwcHh8fDgACBwcDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4CAg4CAgIGCAAAA+Pg5m9mYOHh8fHBwfDmb2Zg8fPgAAAAAAAAAAAAAAAAAAAAAAAAAAAACAwGAwEBiIyMiIGBAwYMCAAAAABw8YMGBAx4+fn4/HQGAwGA8HAAAAAAAAAAAAAAAAAAAAAAAAAAAA4ODg4ODg4ODg4ODg4OAAgICAAAA/H8/vzx8/H8/vzx8/PxPP6MgfPAAAAAEAAAAAAAEAAAAAAAABAAAAAAAAAAAAAHxExMREfAAAAAAAAAAAAOAgPCTkBAQHBwQE5CQ8IOAAAAAAAQEBAQEAAAAAAAABAQEBAQAAAAD4BgH4jABw8PBwAIz4AQb4AAAAAAADBAAB4LjPz7jgAQAEAwAAAAAAAAAAAgMDAgICAgMDAgAAAAAAAAAAAAAAAIDA4P7mxoAAAAAAAAAAAAD4/B4f//8cHBz//x8e/PgAAAAAAAMDAwMDAwAAAAMDAwMDAwAAAAAAAADw/Az2+v769gz88AAAAAAAAAAAAAMHHGHfv99hHAcDAAAAAAAAAAAAAAAAAAYGBgAAAAAAAAAAAAAAAAAAJAhAYGxgQAgkAAAAAABwOJjY3J47eXl/f3l5O57c2Jg4eAAAAAEBAAAAAAAAAAAAAAEBAAAAAAAAAAAAAAAAAMDQGAwMDg4AAAAAEDBweHx2dn93f3d/d3x4cDAAAAAAAQEBAQEBAQEBAQEBAQEBAQAAAIDA4ODg4ODg4ODg4GBgYODAAAAAPx/I6MgYPz8gICA/PxnJ684cOAAAAAABAAAAAAAAAAAAAAABAAAAAMDA4ODg4ODg4ODg4GBgwIAAAAAAPx/P788fPz8/Pz8/HMzszR8+PAAAAAABAAAAAAAAAAAAAAEAAAAAAOAAAADAAAAAwAAAAMAAAADAAAAADwEBAQcBAQH/AQEBBwEBAQcBAAAAAAAAAAAAAAMAAAAAAAAAAAAAAOAwkNBQUFJUWFhUUlBQ0JAw4AAA/4A/YEBCTkJAQkxCQEBgP4D/AAAAAQEDAwMBAQEBAQEDAwMBAQAAAOAwMPAwMPAwMPAwMPAwMHDw4AAAPw5m92YOPz4+Pz4OZ/ZkDD8/PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgMDAgAAAAIDI+ODAgAAAAAAwSbW1STMHHx8fHx8HM0m1tUkyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAcBgIDASG4uKGBAwIGHDAAAAADzDggANnfz8fHz9/ZwOA4DAPAAAAAAABAQMCBgQEBgIDAQEAAAAAAACA4DAYCITG4vLixoQIGDDggAAAAB9wwIYPHz9//38/Hw+GwHAfAAAAAAAAAQECBgQEBAYCAQEAAAAAAAAAAAAAAILCyooq/yoKCgICAAAAAAD4/P4fH///Hx8f/PgAAAAAAAAAAAcHBwcHBwcEBAQHBwAAAAAAAACAwODg8Pj8/P78+Pjw8ODAAAAAAAFDYwcHQ2MHB0NjBwdDYwEAAAAAAAAAAgMAAAIDAAACAwAAAAAAAAAAAABweHwcHBz8+PAAAAAAAAAAAAAAAAAAANze3wcBAAAAAAAAAAAAAAAAAAAAAQEBAAAAAAAAAAAAAACA4DAYiMzk5OTkzIgYMOCAAAAAAA84YMePnz8/Pz+fj8dgOA8AAAAAAAAAAAABAQEBAQEAAAAAAAAAAAAAAACAwMCsHh6swMCAAAAAAAAAAADA4PnFAwEAAAEDxfngwAAAAAAAAAABAQAAAAAAAAAAAQEAAAAAAAAAAAAAAIDcPj7cgAAAAAAAAAAAAADA4Pjuw4CAgIDD7vjgwAAAAAAAAAEDAwMBAAAAAAEDAwMBAAAAAACAYDAo/Cwm/iYs/CgwYIAAAAAAAA8yYqf6oiL/IqL6p2IyDwAAAAAAAAAAAAEBAwMDAQEAAAAAAAAAAAAAAAAAwGBwYH9gwIAAAAAAAAAAAAAAAAD/VFX9VVT/DwAAAAAAAAAAAAAAAAEDAwMDAwEAAAAAAAAAAIAAAAAAABCQ8PCQEAAAAAAAgAADBwMDAwMDQ0f//0dDAwMDAwMHAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgMDgwP7+gAAAAAAAAAAAAAAAAAD/qPqq+qj//wAAAAAAAAAAAAAAAAECAwIDAgMBAAAAAAAA';
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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""AX.25 UI frames for APRS, and their Bell 202 bit stream.
build(src, dst, path, info) -> frame bytes (addresses + 0x03 0xF0 + info + FCS)
hdlc_bits(frame) -> bits on the air before NRZI: flags, the frame
LSB first with bit stuffing, flags
decode(frame) -> "SRC>DST,PATH:info" (None if the FCS is wrong)
ax25.py [--call F4HWN] prints the test frames and their length
"""
import argparse
TXDELAY_FLAGS = 40 # ~267 ms of 0x7E before the frame (1 flag = 6.67 ms)
TAIL_FLAGS = 3
def crc16(data):
"""CRC-16/X.25 (the AX.25 FCS): reflected 0x1021, init/xorout 0xFFFF."""
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(8):
crc = (crc >> 1) ^ 0x8408 if crc & 1 else crc >> 1
return crc ^ 0xFFFF
def addr(call, last=False, repeated=False):
"""One 7-byte address field: 6 chars shifted left, then the SSID byte."""
call, _, ssid = call.upper().rstrip("*").partition("-")
out = bytes((ord(c) << 1) for c in call.ljust(6)[:6])
sb = 0x60 | ((int(ssid) if ssid else 0) << 1)
if repeated:
sb |= 0x80 # H bit: this digipeater has relayed it
if last:
sb |= 0x01 # end of the address field
return out + bytes([sb])
def build(src, dst="APZK5", path=(), info=""):
hops = list(path)
f = addr(dst) + addr(src, last=not hops)
for i, h in enumerate(hops):
f += addr(h, last=i == len(hops) - 1, repeated=h.endswith("*"))
f += b"\x03\xF0" + info.encode("latin-1")
fcs = crc16(f)
return f + bytes([fcs & 0xFF, fcs >> 8])
def hdlc_bits(frame, lead=TXDELAY_FLAGS, tail=TAIL_FLAGS):
flag = [(0x7E >> i) & 1 for i in range(8)]
bits = flag * lead
ones = 0
for b in frame:
for i in range(8):
bit = (b >> i) & 1
bits.append(bit)
ones = ones + 1 if bit else 0
if ones == 5:
bits.append(0) # stuffed zero
ones = 0
return bits + flag * tail
def decode(frame):
"""Readable form of a received frame, or None if too short / bad FCS."""
if len(frame) < 18 or crc16(frame[:-2]) != frame[-2] | (frame[-1] << 8):
return None
calls, i = [], 0
while i + 7 <= len(frame) - 2:
c = "".join(chr(x >> 1) for x in frame[i:i + 6]).rstrip()
ssid = (frame[i + 6] >> 1) & 15
if ssid:
c += "-%d" % ssid
if len(calls) >= 2 and frame[i + 6] & 0x80:
c += "*"
calls.append(c)
i += 7
if frame[i - 1] & 1:
break
info = frame[i + 2:-2].decode("latin-1")
return "%s>%s%s:%s" % (calls[1], calls[0],
"".join("," + c for c in calls[2:]), info)
def test_frames(call="F4HWN"):
"""name -> frame bytes. 'badfcs' has one flipped info bit: must be rejected."""
pos = "!4850.90N/00216.25E-UV-K5 APRS RX test"
fr = {
"pos": build(call, path=["WIDE1-1"], info=pos),
"digi": build(call, path=["F1ZZZ-10*", "WIDE2-1"], info=pos),
"msg": build(call, path=["WIDE1-1"], info=":%-9s:Hello from the Flipper{01" % call),
"status": build(call, info=">Flipper Zero AFSK square-wave test"),
"long": build(call, path=["WIDE1-1", "WIDE2-2"],
info=">" + "".join(chr(0x41 + i % 26) for i in range(200))),
}
bad = bytearray(fr["pos"])
bad[20] ^= 0x04
fr["badfcs"] = bytes(bad)
return fr
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--call", default="F4HWN")
a = ap.parse_args()
for name, f in test_frames(a.call).items():
print("%-7s %3d bytes %s" % (name, len(f), decode(f)))
@@ -0,0 +1,11 @@
Filetype: Flipper SubGhz RAW File
Version: 1
Frequency: 433650000
Preset: FuriHalSubGhzPreset2FSKDev238Async
Protocol: RAW
RAW_Data: 100227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227
RAW_Data: 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353
RAW_Data: 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -291 417 -353 227 -228 227 -353 417 -417 416 -291 227 -227 228 -416 417 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 227 -228 227 -290 417 -417 416 -354 227 -227 228 -227 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -291 416 -417 417 -353 227 -228 227 -353 417 -417 416 -291 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -291 416 -354 227 -227 228 -353 417 -290 227 -228 227 -417 416 -228 227 -227 291 -416 417 -417 353 -227 228 -227 353 -417 291 -227 227 -227 417 -417 227 -227 227 -291 417 -416 417 -417 416 -354 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 227 -227 228 -227 353 -417 291 -227 227 -227 417 -417 416 -417 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -417 416 -228 227 -227 291 -416 354 -227 227 -228 353 -417 290 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -417 416 -417 417 -227 227 -227 228 -227 227 -228 353 -417 416 -417 291 -227 227 -227 417 -417 416 -417 417 -416 417 -417 227 -227 227 -291 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -416 417 -227 228 -227 290 -417 417 -416 354 -227 227 -228 353 -417 290 -227 228 -227 417 -416 228 -227 227 -291 416 -417 417 -416 417 -353 228 -227 227 -354 416 -417 417 -416 417 -417 416 -291 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 354 -416 417 -417 290 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -354 416 -417 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 354 -416 417 -417 290 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -354 416 -417 417 -416 417 -291 227 -227 227 -417 417 -227 227 -227 291 -417
RAW_Data: 353 -227 228 -227 353 -417 291 -227 227 -227 228 -227 227 -291 416 -417 417 -416 417 -417 416 -354 227 -227 228 -353 417 -416 417 -417 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 353 -417 290 -227 228 -227 417 -416 228 -227 227 -291 416 -354 227 -227 228 -353 417 -290 227 -228 227 -417 416 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -417 417 -227 227 -227 291 -417 353 -227 228 -227 353 -417 417 -416 291 -227 227 -228 416 -417 227 -228 227 -290 417 -417 416 -354 227 -227 228 -227 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 353 -417 291 -227 227 -227 417 -417 416 -417 417 -416 417 -417 227 -227 227 -291 417 -416 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -417 416 -228 227 -227 291 -416 354 -227 227 -228 353 -417 290 -227 228 -227 417 -416 417 -417 416 -417 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 417 -417 290 -227 228 -227 417 -416 417 -417 227 -227 227 -291 417 -416 417 -417 416 -417 417 -353 227 -228 227 -353 417 -291 227 -227 227 -417 417 -227 227 -227 291 -417 416 -417 417 -416 354 -227 227 -228 353 -417 290 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -417 416 -228 227 -227 227 -228 227 -227 354 -416 417 -417 416 -417 417 -416 417 -417 290 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -354 416 -291 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 417 -416 228 -227 227 -291 416 -354 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 227 -227 228 -227 353 -417 291 -227 227 -227 417 -417 416 -417 227 -228 227 -290 417 -417 416 -417 417 -353 227 -228 227 -353 417 -417 416 -291 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 417 -416 228 -227 227 -291 416 -417 417 -416 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 353 -417 416 -417 417 -416 417 -417 290 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -354 416 -417 417 -290 227 -228 227 -417 416 -417 417 -416 417 -227 228 -227 290 -417 417 -416 354 -227 227 -228 227 -227 227 -228 416 -417 417 -416 417 -417 227 -227 227 -291 417 -416 417 -353 228 -227 227 -228 227 -227 227 -417 417 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 417 -417
RAW_Data: 416 -417 417 -416 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 227 -228 227 -290 417 -417 416 -417 417 -353 227 -228 227 -227 227 -228 227 -417 416 -417 417 -227 227 -227 228 -227 227 -228 353 -417 416 -417 417 -416 291 -227 227 -228 227 -227 227 -291 417 -353 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 417 -416 354 -227 227 -228 353 -417 416 -417 291 -227 227 -227 228 -227 227 -291 416 -417 417 -353 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 354 -416 291 -227 227 -228 416 -417 227 -228 227 -290 417 -417 416 -354 227 -227 228 -353 417 -416 417 -291 227 -227 227 -417 417 -227 227 -227 291 -417 353 -227 228 -227 227 -227 228 -227 417 -416 228 -227 227 -291 416 -354 227 -227 228 -353 417 -416 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -353 417 -417 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 353 -417 290 -227 228 -227 227 -228 227 -290 417 -417 416 -354 227 -227 228 -353 417 -290 227 -228 227 -417 416 -228 227 -227 291 -416 417 -417 353 -227 228 -227 353 -417 291 -227 227 -227 228 -227 227 -291 416 -354 227 -227 228 -227 227 -227 228 -416 417 -417 416 -228 227 -227 291 -416 354 -227 227 -228 353 -417 416 -417 417 -416 291 -227 227 -228 227 -227 227 -291 417 -353 227 -228 227 -353 417 -291 227 -227 227 -417 417 -227 227 -227 228 -227 227 -228 353 -417 290 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -354 416 -417 417 -416 417 -417 416 -291 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 354 -416 417 -417 416 -417 291 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -290 417 -417 416 -354 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 353 -417 290 -227 228 -227 417 -416 417 -417 227 -227 227 -228 227 -227 228 -353 417 -416 417 -417 416 -291 227 -227 228 -416 417 -417 416 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -291 416 -354 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 227 -227 228 -227
RAW_Data: 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -417 416 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 291 -416 10278
@@ -0,0 +1,11 @@
Filetype: Flipper SubGhz RAW File
Version: 1
Frequency: 433650000
Preset: FuriHalSubGhzPreset2FSKDev238Async
Protocol: RAW
RAW_Data: 100227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227
RAW_Data: 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353
RAW_Data: 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -291 417 -353 227 -228 227 -353 417 -417 416 -291 227 -227 228 -416 417 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 227 -228 227 -290 417 -417 416 -354 227 -227 228 -227 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -291 416 -417 417 -353 227 -228 227 -353 417 -417 416 -291 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -291 416 -354 227 -227 228 -353 417 -290 227 -228 227 -417 416 -228 227 -227 291 -416 417 -417 353 -227 228 -227 353 -417 291 -227 227 -227 417 -417 227 -227 227 -291 417 -416 417 -417 416 -354 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 227 -227 228 -227 353 -417 291 -227 227 -227 417 -417 416 -417 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -417 416 -228 227 -227 291 -416 354 -227 227 -228 353 -417 290 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -417 416 -417 417 -227 227 -227 228 -227 227 -228 353 -417 416 -417 291 -227 227 -227 417 -417 416 -417 417 -416 417 -417 227 -227 227 -291 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -416 417 -227 228 -227 290 -417 417 -416 354 -227 227 -228 353 -417 290 -227 228 -227 417 -416 228 -227 227 -291 416 -417 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 353 -417 290 -227 228 -227 417 -416 417 -417 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -417 416 -417 417 -416 417 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 417 -416 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 416 -417 417 -416 354 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -353 417 -417 416 -417 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227
RAW_Data: 354 -416 291 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -417 417 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -353 417 -417 416 -417 417 -416 417 -291 227 -227 227 -228 227 -227 291 -416 417 -417 353 -227 228 -227 227 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 353 -417 417 -416 291 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -353 417 -417 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 353 -417 417 -416 291 -227 227 -228 416 -417 417 -416 228 -227 227 -291 416 -417 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -416 417 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 417 -416 228 -227 227 -291 416 -354 227 -227 228 -227 227 -227 228 -227 227 -227 291 -417 416 -417 417 -416 354 -227 227 -228 353 -417 290 -227 228 -227 417 -416 228 -227 227 -291 416 -354 227 -227 228 -353 417 -290 227 -228 227 -417 416 -417 417 -416 417 -417 416 -417 417 -416 417 -227 228 -227 290 -417 353 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -291 417 -353 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 417 -417 416 -417 353 -228 227 -227 354 -416 291 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -417 416 -228 227 -227 291 -416 354 -227 227 -228 353 -417 290 -227 228 -227 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -417 417 -416 417 -417 416 -417 417 -227 227 -227 228 -227 227 -228 353 -417 290 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 416 -417 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 353 -417 416 -417 417 -416 417 -417 290 -227 228 -227 417 -416 417 -417 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 353 -417 416 -417 291 -227 227 -227 417 -417 227 -227 227 -291 417 -353 227 -228 227 -353 417 -417 416 -417 417 -290 227 -228 227 -417 416 -228 227 -227 291 -416 354 -227 227 -228 353 -417 416 -417 417 -416
RAW_Data: 291 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -291 417 -353 227 -228 227 -353 417 -417 416 -417 417 -290 227 -228 227 -417 416 -228 227 -227 227 -228 227 -227 228 -227 227 -227 417 -417 416 -417 417 -416 228 -227 227 -291 416 -354 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 353 -417 416 -417 291 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 227 -227 228 -227 353 -417 291 -227 227 -227 228 -227 227 -291 416 -417 417 -353 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 417 -416 417 -417 227 -227 227 -291 417 -416 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -227 228 -227 227 -227 228 -227 353 -417 291 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -417 417 -290 227 -228 227 -227 228 -227 290 -417 417 -416 354 -227 227 -228 227 -227 227 -228 227 -227 227 -291 417 -416 417 -353 228 -227 227 -354 416 -417 417 -416 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 416 -417 417 -416 228 -227 227 -227 228 -227 227 -354 416 -417 417 -416 417 -291 227 -227 227 -417 417 -227 227 -227 291 -417 353 -227 228 -227 353 -417 291 -227 227 -227 228 -227 227 -291 416 -354 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 290 -417 353 -228 227 -227 354 -416 417 -417 290 -227 228 -227 417 -416 228 -227 227 -291 416 -354 227 -227 228 -227 227 -227 228 -416 417 -417 416 -228 227 -227 291 -416 417 -417 353 -227 228 -227 353 -417 417 -416 291 -227 227 -228 227 -227 227 -291 417 -416 417 -417 416 -354 227 -227 228 -353 417 -416 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 353 -417 290 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -416 417 -417 416 -228 227 -227 291 -416 417 -417 353 -227 228 -227 227 -227 228 -227 417 -416 228 -227 227 -291 416 -354 227 -227 228 -227 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 354 -416 291 -227 227 -228 416 -417 227 -228 227 -290 417 -417 416 -354 227 -227 228 -353 417 -290
RAW_Data: 227 -228 227 -417 416 -417 417 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -291 417 -416 417 -353 228 -227 227 -228 227 -227 227 -417 417 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 417 -416 417 -417 227 -227 227 -291 417 -416 417 -417 416 -417 417 -353 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 417 -417 416 -417 417 -416 354 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 291 -417 416 -417 417 -416 354 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -353 417 -416 417 -417 416 -291 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 10000
@@ -0,0 +1,18 @@
Filetype: Flipper SubGhz RAW File
Version: 1
Frequency: 433650000
Preset: FuriHalSubGhzPreset2FSKDev238Async
Protocol: RAW
RAW_Data: 100227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227
RAW_Data: 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353
RAW_Data: 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -291 417 -353 227 -228 227 -353 417 -417 416 -291 227 -227 228 -416 417 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 227 -228 227 -290 417 -417 416 -354 227 -227 228 -227 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -291 416 -417 417 -353 227 -228 227 -353 417 -417 416 -291 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -291 416 -354 227 -227 228 -353 417 -290 227 -228 227 -417 416 -228 227 -227 291 -416 417 -417 353 -227 228 -227 353 -417 291 -227 227 -227 417 -417 227 -227 227 -291 417 -416 417 -417 416 -354 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 227 -227 228 -227 353 -417 291 -227 227 -227 417 -417 416 -417 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -417 416 -228 227 -227 291 -416 354 -227 227 -228 353 -417 290 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -417 416 -417 417 -227 227 -227 228 -227 227 -228 353 -417 416 -417 291 -227 227 -227 417 -417 416 -417 417 -416 417 -417 227 -227 227 -291 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -416 417 -227 228 -227 290 -417 417 -416 354 -227 227 -228 353 -417 290 -227 228 -227 417 -416 228 -227 227 -291 416 -417 417 -416 417 -353 228 -227 227 -354 416 -417 417 -416 417 -417 416 -291 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 354 -416 417 -417 290 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -354 416 -417 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 354 -416 417 -417 290 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -354 416 -417 417 -416 417 -291 227 -227 227 -417 417 -227 227 -227 291 -417
RAW_Data: 353 -227 228 -227 353 -417 291 -227 227 -227 228 -227 227 -291 416 -354 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 290 -417 417 -416 417 -417 353 -227 228 -227 353 -417 417 -416 417 -417 416 -417 291 -227 227 -227 228 -227 227 -291 416 -417 417 -353 227 -228 227 -227 227 -228 227 -417 416 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -291 417 -353 227 -228 227 -353 417 -417 416 -291 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 353 -417 417 -416 291 -227 227 -228 227 -227 227 -291 417 -353 227 -228 227 -353 417 -417 416 -291 227 -227 228 -416 417 -417 416 -228 227 -227 291 -416 417 -417 416 -417 353 -228 227 -227 354 -416 291 -227 227 -228 416 -417 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -227 228 -227 227 -227 228 -227 353 -417 291 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -417 417 -416 417 -291 227 -227 227 -417 417 -227 227 -227 291 -417 353 -227 228 -227 353 -417 291 -227 227 -227 417 -417 227 -227 227 -291 417 -416 417 -417 416 -417 417 -416 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -291 416 -354 227 -227 228 -353 417 -416 417 -291 227 -227 227 -417 417 -227 227 -227 291 -417 353 -227 228 -227 227 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 353 -417 417 -416 417 -417 290 -227 228 -227 417 -416 228 -227 227 -291 416 -417 417 -353 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 353 -417 416 -417 291 -227 227 -227 228 -227 227 -291 416 -417 417 -353 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 227 -227 227 -291 417 -353 227 -228 227 -353 417 -417 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -290 417 -417 416 -354 227 -227 228 -353 417 -290 227 -228 227 -417 416 -417 417 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 227 -228 227 -290 417 -417 416 -354 227 -227 228 -227 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 354 -416
RAW_Data: 417 -417 290 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -228 227 -227 227 -417 417 -227 227 -227 228 -227 227 -228 353 -417 416 -417 291 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 417 -417 416 -417 417 -416 417 -417 353 -227 228 -227 353 -417 417 -416 291 -227 227 -228 416 -417 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -227 228 -227 227 -227 228 -227 353 -417 291 -227 227 -227 417 -417 416 -417 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 416 -417 417 -416 228 -227 227 -227 228 -227 227 -228 227 -227 227 -417 417 -227 227 -227 228 -227 227 -228 353 -417 416 -417 291 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -353 417 -416 417 -291 227 -227 227 -228 227 -227 291 -416 417 -417 353 -227 228 -227 227 -227 228 -227 417 -416 417 -417 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 227 -227 228 -227 353 -417 417 -416 291 -227 227 -228 227 -227 227 -291 417 -416 417 -417 416 -417 417 -353 227 -228 227 -227 227 -228 227 -417 416 -417 417 -227 227 -227 291 -417 353 -227 228 -227 353 -417 417 -416 417 -417 290 -227 228 -227 227 -228 227 -290 417 -417 416 -354 227 -227 228 -353 417 -416 417 -417 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 227 -227 228 -227 417 -416 417 -417 416 -417 227 -228 227 -227 227 -228 227 -353 417 -417 416 -291 227 -227 228 -416 417 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -417 416 -228 227 -227 227 -228 227 -227 354 -416 417 -417 416 -417 291 -227 227 -227 417 -417 227 -227 227 -291 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 353 -417 417 -416 291 -227 227 -228 227 -227 227 -291 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -354 416 -417 417 -290 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 417 -416 228 -227 227 -291 416 -417 417 -353 227 -228 227 -353
RAW_Data: 417 -291 227 -227 227 -417 417 -416 417 -227 228 -227 290 -417 417 -416 354 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 416 -417 417 -416 228 -227 227 -291 416 -417 417 -353 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 417 -416 354 -227 227 -228 353 -417 416 -417 291 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 353 -417 417 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 416 -417 417 -416 417 -417 416 -417 353 -228 227 -227 354 -416 417 -417 290 -227 228 -227 417 -416 228 -227 227 -291 416 -354 227 -227 228 -227 227 -227 228 -416 417 -417 416 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 416 -417 417 -416 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 227 -227 228 -227 417 -416 417 -417 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 353 -417 417 -416 291 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -353 417 -417 416 -291 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 417 -417 290 -227 228 -227 227 -228 227 -290 417 -417 416 -417 417 -416 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -227 228 -227 290 -417 353 -228 227 -227 354 -416 417 -417 416 -417 291 -227 227 -227 228 -227 227 -291 416 -417 417 -353 227 -228 227 -353 417 -417 416 -417 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 417 -416 228 -227 227 -227 228 -227 227 -354 416 -417 417 -290 227 -228 227 -417 416 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -417 417 -227 227 -227 228 -227 227 -228 353 -417 416 -417 417 -416 291 -227 227 -228 416 -417 227 -228 227 -290 417 -417 416 -354 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 290 -417
RAW_Data: 353 -228 227 -227 354 -416 417 -417 290 -227 228 -227 227 -228 227 -290 417 -417 416 -354 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -291 416 -354 227 -227 228 -353 417 -416 417 -291 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -291 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -416 417 -417 416 -228 227 -227 291 -416 417 -417 353 -227 228 -227 227 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 227 -227 228 -227 417 -416 417 -417 227 -227 227 -291 417 -416 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 291 -416 417 -417 353 -227 228 -227 353 -417 417 -416 291 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -417 416 -228 227 -227 227 -228 227 -227 354 -416 417 -417 290 -227 228 -227 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 290 -417 417 -416 417 -417 416 -417 417 -416 354 -227 227 -228 353 -417 416 -417 291 -227 227 -227 417 -417 227 -227 227 -291 417 -353 227 -228 227 -227 227 -228 227 -417 416 -417 417 -227 227 -227 228 -227 227 -228 353 -417 290 -227 228 -227 417 -416 417 -417 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -417 416 -228 227 -227 227 -228 227 -227 354 -416 417 -417 290 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -354 416 -417 417 -290 227 -228 227 -227 228 -227 290 -417 417 -416 354 -227 227 -228 227 -227 227 -228 416 -417 417 -416 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 353 -417 416 -417 291 -227 227 -227 228 -227 227 -291 416 -417 417 -416 417 -417 416 -354 227 -227 228 -227 227 -227 228 -416 417 -417 416 -228 227 -227 291 -416 354 -227 227 -228 353 -417 416 -417 417 -416 291 -227 227 -228 227 -227 227 -291 417 -416 417 -353 228 -227 227 -354 416 -417 417 -416 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 416 -417 417 -416 417 -417 227 -227 227 -228
RAW_Data: 227 -227 228 -353 417 -416 417 -291 227 -227 227 -417 417 -227 227 -227 291 -417 353 -227 228 -227 227 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 353 -417 417 -416 417 -417 290 -227 228 -227 417 -416 228 -227 227 -291 416 -417 417 -353 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 353 -417 416 -417 291 -227 227 -227 228 -227 227 -291 416 -417 417 -353 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 227 -227 227 -291 417 -353 227 -228 227 -353 417 -417 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -290 417 -417 416 -354 227 -227 228 -353 417 -290 227 -228 227 -417 416 -417 417 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 227 -228 227 -290 417 -417 416 -354 227 -227 228 -227 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 354 -416 417 -417 290 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -228 227 -227 227 -417 417 -227 227 -227 228 -227 227 -228 353 -417 416 -417 291 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 417 -417 416 -417 417 -416 417 -417 353 -227 228 -227 353 -417 417 -416 291 -227 227 -228 416 -417 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -227 228 -227 227 -227 228 -227 353 -417 291 -227 227 -227 417 -417 416 -417 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 416 -417 417 -416 228 -227 227 -227 228 -227 227 -228 227 -227 227 -417 417 -227 227 -227 228 -227 227 -228 353 -417 416 -417 291 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -353 417 -416 417 -291 227 -227 227 -228 227 -227 291 -416 417 -417 353 -227 228 -227 227 -227 228 -227 417 -416 417 -417 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 227 -227 228 -227 353 -417 417 -416 291 -227 227 -228 227 -227
RAW_Data: 227 -291 417 -416 417 -417 416 -417 417 -353 227 -228 227 -227 227 -228 227 -417 416 -417 417 -227 227 -227 291 -417 353 -227 228 -227 353 -417 417 -416 417 -417 290 -227 228 -227 227 -228 227 -290 417 -417 416 -354 227 -227 228 -353 417 -416 417 -417 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 227 -227 228 -227 417 -416 417 -417 416 -417 227 -228 227 -227 227 -228 227 -353 417 -417 416 -291 227 -227 228 -416 417 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -417 416 -228 227 -227 227 -228 227 -227 354 -416 417 -417 416 -417 291 -227 227 -227 417 -417 227 -227 227 -291 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 353 -417 417 -416 291 -227 227 -228 227 -227 227 -291 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -354 416 -417 417 -290 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 417 -416 228 -227 227 -291 416 -417 417 -353 227 -228 227 -353 417 -291 227 -227 227 -417 417 -416 417 -227 228 -227 290 -417 417 -416 354 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 416 -417 417 -416 228 -227 227 -291 416 -417 417 -353 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 417 -416 354 -227 227 -228 353 -417 416 -417 291 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 353 -417 417 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 416 -417 417 -416 417 -417 416 -417 353 -228 227 -227 354 -416 417 -417 290 -227 228 -227 417 -416 228 -227 227 -291 416 -354 227 -227 228 -227 227 -227 228 -416 417 -417 416 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 416 -417 417 -416 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 227 -227 228 -227 417 -416 417 -417 227 -227 227 -228 227 -227 228 -227 227 -227
RAW_Data: 228 -416 417 -227 228 -227 227 -227 228 -227 353 -417 417 -416 291 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -353 417 -417 416 -291 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 417 -417 290 -227 228 -227 227 -228 227 -290 417 -417 416 -417 417 -416 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -227 228 -227 290 -417 353 -228 227 -227 354 -416 417 -417 416 -417 291 -227 227 -227 228 -227 227 -291 416 -417 417 -353 227 -228 227 -353 417 -417 416 -417 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 417 -416 228 -227 227 -227 228 -227 227 -354 416 -417 417 -290 227 -228 227 -417 416 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -417 417 -227 227 -227 228 -227 227 -228 353 -417 416 -417 417 -416 291 -227 227 -228 416 -417 227 -228 227 -290 417 -417 416 -354 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 354 -416 417 -417 290 -227 228 -227 227 -228 227 -290 417 -417 416 -354 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -291 416 -354 227 -227 228 -353 417 -416 417 -291 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -291 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -416 417 -417 416 -228 227 -227 291 -416 417 -417 353 -227 228 -227 227 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 227 -227 228 -227 417 -416 417 -417 227 -227 227 -291 417 -416 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 291 -416 417 -417 353 -227 228 -227 353 -417 417 -416 291 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -417 416 -228 227 -227 227 -228 227 -227 354 -416 417 -417 290 -227 228 -227 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 290 -417 417 -416 417 -417 416 -417 417 -416 354 -227 227 -228
RAW_Data: 353 -417 416 -417 291 -227 227 -227 417 -417 227 -227 227 -291 417 -353 227 -228 227 -227 227 -228 227 -417 416 -417 417 -227 227 -227 228 -227 227 -228 353 -417 290 -227 228 -227 417 -416 417 -417 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -417 416 -228 227 -227 227 -228 227 -227 354 -416 417 -417 290 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -354 416 -417 417 -290 227 -228 227 -227 228 -227 290 -417 417 -416 354 -227 227 -228 227 -227 227 -228 416 -417 417 -416 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 353 -417 416 -417 291 -227 227 -227 228 -227 227 -291 416 -417 417 -416 417 -417 416 -354 227 -227 228 -227 227 -227 228 -416 417 -417 416 -228 227 -227 291 -416 354 -227 227 -228 353 -417 416 -417 417 -416 291 -227 227 -228 227 -227 227 -291 417 -416 417 -353 228 -227 227 -354 416 -417 417 -416 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 416 -417 417 -416 417 -417 227 -227 227 -228 227 -227 228 -353 417 -416 417 -291 227 -227 227 -417 417 -227 227 -227 291 -417 353 -227 228 -227 227 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 353 -417 417 -416 417 -417 290 -227 228 -227 417 -416 228 -227 227 -291 416 -417 417 -353 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 353 -417 416 -417 291 -227 227 -227 228 -227 227 -291 416 -417 417 -353 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 227 -227 227 -291 417 -353 227 -228 227 -353 417 -417 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -290 417 -417 416 -354 227 -227 228 -353 417 -290 227 -228 227 -417 416 -417 417 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 227 -228 227 -290 417 -417 416 -354 227 -227 228 -227 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 354 -416 417 -417 290 -227 228 -227 417 -416
RAW_Data: 228 -227 227 -227 228 -227 227 -228 227 -227 227 -417 417 -227 227 -227 228 -227 227 -228 353 -417 416 -417 291 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 417 -417 416 -417 417 -416 417 -417 353 -227 228 -227 353 -417 417 -416 291 -227 227 -228 416 -417 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -227 228 -227 227 -227 228 -227 353 -417 291 -227 227 -227 417 -417 416 -417 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 416 -417 417 -416 228 -227 227 -227 228 -227 227 -228 227 -227 227 -417 417 -227 227 -227 228 -227 227 -228 353 -417 416 -417 291 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -353 417 -416 417 -291 227 -227 227 -228 227 -227 291 -416 417 -417 353 -227 228 -227 227 -227 228 -227 417 -416 417 -417 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 227 -227 228 -227 353 -417 417 -416 291 -227 227 -228 227 -227 227 -291 417 -416 417 -417 416 -417 417 -353 227 -228 227 -227 227 -228 227 -417 416 -417 417 -227 227 -227 291 -417 353 -227 228 -227 353 -417 417 -416 417 -417 290 -227 228 -227 227 -228 227 -290 417 -417 416 -354 227 -227 228 -353 417 -416 417 -417 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 227 -227 228 -227 417 -416 417 -417 416 -417 227 -228 227 -227 227 -228 227 -353 417 -417 416 -291 227 -227 228 -416 417 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -417 416 -228 227 -227 227 -228 227 -227 354 -416 417 -417 416 -417 291 -227 227 -227 417 -417 227 -227 227 -291 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 353 -417 417 -416 291 -227 227 -228 227 -227 227 -291 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -354 416 -417 417 -290 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 417 -416 228 -227 227 -291 416 -417 417 -353 227 -228 227 -353 417 -291 227 -227 227 -417 417 -416
RAW_Data: 417 -227 228 -227 290 -417 417 -416 354 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 416 -417 417 -416 228 -227 227 -291 416 -417 417 -353 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 417 -416 354 -227 227 -228 353 -417 416 -417 291 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 353 -417 417 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 416 -417 417 -416 417 -417 416 -417 353 -228 227 -227 354 -416 417 -417 290 -227 228 -227 417 -416 228 -227 227 -291 416 -354 227 -227 228 -227 227 -227 228 -416 417 -417 416 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 416 -417 417 -416 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 227 -227 228 -227 417 -416 417 -417 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 417 -416 228 -227 227 -227 228 -227 227 -228 227 -227 227 -417 417 -416 417 -417 416 -228 227 -227 227 -228 227 -227 354 -416 417 -417 290 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -291 416 -354 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -353 417 -10139
+10
View File
@@ -0,0 +1,10 @@
Filetype: Flipper SubGhz RAW File
Version: 1
Frequency: 433650000
Preset: FuriHalSubGhzPreset2FSKDev238Async
Protocol: RAW
RAW_Data: 100227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227
RAW_Data: 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353
RAW_Data: 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -291 417 -353 227 -228 227 -353 417 -417 416 -291 227 -227 228 -416 417 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 227 -228 227 -290 417 -417 416 -354 227 -227 228 -227 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -291 416 -417 417 -353 227 -228 227 -353 417 -417 416 -291 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -291 416 -354 227 -227 228 -353 417 -290 227 -228 227 -417 416 -228 227 -227 291 -416 417 -417 353 -227 228 -227 353 -417 291 -227 227 -227 417 -417 227 -227 227 -291 417 -416 417 -417 416 -354 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 227 -227 228 -227 353 -417 291 -227 227 -227 417 -417 416 -417 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -417 416 -228 227 -227 291 -416 354 -227 227 -228 353 -417 290 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -417 416 -417 417 -227 227 -227 228 -227 227 -228 353 -417 416 -417 291 -227 227 -227 417 -417 416 -417 417 -416 417 -417 227 -227 227 -291 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -416 417 -227 228 -227 290 -417 417 -416 354 -227 227 -228 353 -417 290 -227 228 -227 417 -416 228 -227 227 -291 416 -417 417 -416 417 -353 228 -227 227 -354 416 -417 417 -416 417 -417 416 -291 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 354 -416 417 -417 290 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -354 416 -417 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 354 -416 417 -417 290 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -354 416 -417 417 -416 417 -291 227 -227 227 -417 417 -227 227 -227 291 -417
RAW_Data: 353 -227 228 -227 353 -417 291 -227 227 -227 228 -227 227 -291 416 -417 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 353 -417 416 -417 417 -416 291 -227 227 -228 416 -417 227 -228 227 -290 417 -353 228 -227 227 -354 416 -291 227 -227 228 -416 417 -227 228 -227 290 -417 417 -416 417 -417 416 -417 417 -416 354 -227 227 -228 227 -227 227 -228 416 -417 417 -416 417 -417 416 -417 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 353 -417 416 -417 291 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -353 417 -416 417 -417 416 -291 227 -227 228 -416 417 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -353 417 -416 417 -417 416 -417 417 -290 227 -228 227 -227 228 -227 290 -417 417 -416 354 -227 227 -228 353 -417 416 -417 417 -416 417 -417 290 -227 228 -227 417 -416 417 -417 227 -227 227 -291 417 -353 227 -228 227 -353 417 -291 227 -227 227 -417 417 -416 417 -227 228 -227 290 -417 353 -228 227 -227 354 -416 291 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -417 417 -227 227 -227 291 -417 353 -227 228 -227 353 -417 417 -416 291 -227 227 -228 416 -417 227 -228 227 -290 417 -353 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 353 -417 417 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 290 -417 353 -228 227 -227 354 -416 417 -417 290 -227 228 -227 417 -416 417 -417 227 -227 227 -228 227 -227 228 -353 417 -416 417 -291 227 -227 227 -417 417 -416 417 -417 416 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 227 -227 227 -291 417 -416 417 -417 416 -354 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 227 -227 228 -227 353 -417 417 -416 417 -417 290 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 417 -416 417 -417 353 -227 228 -227 353 -417 291 -227 227 -227 417 -417 227 -227 227 -291 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 353 -417 290 -227 228 -227 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -353 417 -416 417 -417
RAW_Data: 416 -417 417 -416 417 -291 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 417 -417 290 -227 228 -227 227 -228 227 -227 227 -228 227 -353 417 -417 416 -417 417 -290 227 -228 227 -417 416 -228 227 -227 291 -416 354 -227 227 -228 353 -417 416 -417 291 -227 227 -227 417 -417 227 -227 227 -291 417 -416 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -353 417 -417 416 -417 417 -290 227 -228 227 -227 228 -227 290 -417 417 -416 354 -227 227 -228 353 -417 416 -417 417 -416 291 -227 227 -228 416 -417 227 -228 227 -290 417 -353 228 -227 227 -354 416 -417 417 -290 227 -228 227 -417 416 -228 227 -227 227 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -291 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 353 -417 416 -417 417 -416 291 -227 227 -228 227 -227 227 -291 417 -353 227 -228 227 -227 227 -228 227 -417 416 -417 417 -416 417 -227 228 -227 290 -417 353 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 290 -417 353 -228 227 -227 354 -416 417 -417 416 -417 417 -416 291 -227 227 -228 227 -227 227 -291 417 -416 417 -353 228 -227 227 -354 416 -417 417 -416 417 -291 227 -227 227 -417 417 -416 417 -227 228 -227 290 -417 417 -416 417 -417 416 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -291 416 -417 417 -416 417 -417 416 -417 417 -353 227 -228 227 -353 417 -291 227 -227 227 -417 417 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -417 417 -416 417 -417 416 -228 227 -227 291 -416 417 -417 416 -417 417 -416 417 -417 353 -227 228 -227 353 -417 291 -227 227 -227 228 -227 227 -291 416 -354 227 -227 228 -353 417 -416 417 -417 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 353 -417 291 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -291 417 -10277
+11
View File
@@ -0,0 +1,11 @@
Filetype: Flipper SubGhz RAW File
Version: 1
Frequency: 433650000
Preset: FuriHalSubGhzPreset2FSKDev238Async
Protocol: RAW
RAW_Data: 100227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227
RAW_Data: 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353
RAW_Data: 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -291 417 -353 227 -228 227 -353 417 -417 416 -291 227 -227 228 -416 417 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 227 -228 227 -290 417 -417 416 -354 227 -227 228 -227 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -291 416 -417 417 -353 227 -228 227 -353 417 -417 416 -291 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -291 416 -354 227 -227 228 -353 417 -290 227 -228 227 -417 416 -228 227 -227 291 -416 417 -417 353 -227 228 -227 353 -417 291 -227 227 -227 417 -417 227 -227 227 -291 417 -416 417 -417 416 -354 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 227 -227 228 -227 353 -417 291 -227 227 -227 417 -417 416 -417 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -417 416 -228 227 -227 291 -416 354 -227 227 -228 353 -417 290 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -417 416 -417 417 -227 227 -227 228 -227 227 -228 353 -417 416 -417 291 -227 227 -227 417 -417 416 -417 417 -416 417 -417 227 -227 227 -291 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -416 417 -227 228 -227 290 -417 417 -416 354 -227 227 -228 353 -417 290 -227 228 -227 417 -416 228 -227 227 -291 416 -417 417 -416 417 -353 228 -227 227 -354 416 -417 417 -416 417 -417 416 -291 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 354 -416 417 -417 290 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -354 416 -417 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 354 -416 417 -417 290 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -354 416 -417 417 -416 417 -291 227 -227 227 -417 417 -227 227 -227 291 -417
RAW_Data: 353 -227 228 -227 353 -417 291 -227 227 -227 228 -227 227 -291 416 -417 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 353 -417 416 -417 417 -416 291 -227 227 -228 416 -417 227 -228 227 -290 417 -353 228 -227 227 -354 416 -291 227 -227 228 -416 417 -227 228 -227 290 -417 417 -416 417 -417 416 -417 417 -416 417 -417 353 -227 228 -227 353 -417 291 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -417 416 -228 227 -227 227 -228 227 -227 354 -416 417 -417 416 -417 291 -227 227 -227 417 -417 227 -227 227 -291 417 -353 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 353 -417 291 -227 227 -227 228 -227 227 -291 416 -417 417 -353 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 354 -416 291 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -417 416 -228 227 -227 291 -416 417 -417 416 -417 417 -416 354 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -291 417 -353 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 416 -417 417 -416 417 -417 416 -417 227 -228 227 -290 417 -417 416 -354 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 417 -416 228 -227 227 -291 416 -354 227 -227 228 -353 417 -290 227 -228 227 -417 416 -417 417 -416 417 -227 228 -227 290 -417 353 -228 227 -227 354 -416 291 -227 227 -228 416 -417 417 -416 417 -417 227 -227 227 -291 417 -353 227 -228 227 -227 227 -228 227 -417 416 -228 227 -227 227 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -417 416 -417 417 -416 417 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -291 416 -417 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 417 -416 228 -227 227 -291 416 -354 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -354 416 -417 417 -290 227 -228 227 -227 228 -227 227 -227 228 -227 353 -417 291 -227 227 -227 228 -227 227 -291 416 -417 417 -353 227 -228
RAW_Data: 227 -353 417 -291 227 -227 227 -228 227 -227 291 -416 417 -417 353 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -417 416 -354 227 -227 228 -353 417 -416 417 -291 227 -227 227 -228 227 -227 291 -416 417 -417 353 -227 228 -227 227 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -228 227 -227 227 -417 417 -416 417 -227 228 -227 227 -227 228 -227 353 -417 417 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -353 417 -416 417 -291 227 -227 227 -417 417 -416 417 -417 416 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -291 417 -416 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -417 416 -228 227 -227 291 -416 354 -227 227 -228 353 -417 290 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 353 -417 291 -227 227 -227 417 -417 416 -417 227 -228 227 -290 417 -353 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 354 -416 417 -417 290 -227 228 -227 417 -416 417 -417 227 -227 227 -228 227 -227 228 -353 417 -416 417 -417 416 -291 227 -227 228 -416 417 -417 416 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 416 -417 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -417 417 -227 227 -227 291 -417 416 -417 353 -228 227 -227 354 -416 417 -417 290 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 353 -417 416 -417 291 -227 227 -227 417 -417 227 -227 227 -291 417 -353 227 -228 227 -353 417 -417 416 -291 227 -227 228 -416 417 -227 228 -227 290 -417 417 -416 354 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -353 417 -416 417 -291 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 227 -227 227 -291 417 -416 417 -417 416 -354 227 -227 228 -353 417 -416 417 -417 416 -417 417 -290 227 -228 227 -417 416 -228 227 -227 227 -228 227 -227 354 -416 417 -417 416 -417 417 -416 291 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -353 417 -417 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 353 -417 290 -227 228 -227 227 -228 227 -290 417 -417 416 -417 417 -416 417 -353 228 -227 227 -354 416 -417 417 -416 417 -417 416 -417 417 -416 417 -417 416 -291 227 -227 228 -416 417 -417 416 -417 417 -416 417 -417
RAW_Data: 416 -417 417 -416 417 -227 228 -227 290 -417 417 -416 417 -417 416 -417 417 -416 417 -417 416 -417 353 -228 227 -227 10076
@@ -0,0 +1,10 @@
Filetype: Flipper SubGhz RAW File
Version: 1
Frequency: 433650000
Preset: FuriHalSubGhzPreset2FSKDev238Async
Protocol: RAW
RAW_Data: 100227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227
RAW_Data: 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353
RAW_Data: 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -291 417 -353 227 -228 227 -353 417 -417 416 -291 227 -227 228 -416 417 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 227 -228 227 -290 417 -417 416 -354 227 -227 228 -227 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -291 416 -417 417 -353 227 -228 227 -353 417 -417 416 -291 227 -227 228 -227 227 -227 291 -417 416 -417 353 -228 227 -227 228 -227 227 -227 228 -227 227 -291 416 -354 227 -227 228 -353 417 -290 227 -228 227 -417 416 -228 227 -227 291 -416 417 -417 353 -227 228 -227 353 -417 291 -227 227 -227 417 -417 227 -227 227 -291 417 -416 417 -417 416 -354 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 227 -227 228 -227 353 -417 291 -227 227 -227 417 -417 416 -417 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -417 416 -228 227 -227 291 -416 354 -227 227 -228 353 -417 290 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -417 416 -417 417 -227 227 -227 228 -227 227 -228 353 -417 416 -417 291 -227 227 -227 417 -417 416 -417 417 -416 417 -417 227 -227 227 -291 417 -416 417 -353 228 -227 227 -354 416 -291 227 -227 228 -416 417 -227 228 -227 290 -417 417 -416 354 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 291 -416 417 -417 416 -417 353 -228 227 -227 354 -416 291 -227 227 -228 416 -417 227 -228 227 -290 417 -353 228 -227 227 -354 416 -291 227 -227 228 -416 417 -417 416 -417 417 -416 417 -417 416 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -417 417 -227 227 -227 291 -417 353 -227 228 -227 227 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -354 416 -417 417 -290 227 -228 227 -417 416 -228 227 -227 227 -228 227 -227 228 -227 227 -227 417 -417 416 -417 417 -416 228 -227
RAW_Data: 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 291 -417 416 -417 417 -416 354 -227 227 -228 353 -417 290 -227 228 -227 417 -416 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 353 -417 290 -227 228 -227 417 -416 417 -417 416 -417 417 -416 228 -227 227 -227 228 -227 227 -354 416 -417 417 -290 227 -228 227 -417 416 -417 417 -416 417 -227 228 -227 290 -417 417 -416 354 -227 227 -228 353 -417 416 -417 417 -416 417 -417 290 -227 228 -227 417 -416 228 -227 227 -291 416 -354 227 -227 228 -353 417 -416 417 -291 227 -227 227 -417 417 -227 227 -227 228 -227 227 -228 353 -417 416 -417 417 -416 291 -227 227 -228 227 -227 227 -291 417 -416 417 -353 228 -227 227 -228 227 -227 227 -417 417 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 417 -417 416 -417 417 -416 417 -417 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -417 416 -228 227 -227 291 -416 354 -227 227 -228 353 -417 290 -227 228 -227 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -417 417 -227 227 -227 291 -417 353 -227 228 -227 353 -417 417 -416 291 -227 227 -228 416 -417 417 -416 417 -417 227 -227 227 -291 417 -353 227 -228 227 -227 227 -228 227 -417 416 -417 417 -416 417 -227 228 -227 290 -417 417 -416 354 -227 227 -228 227 -227 227 -228 416 -417 417 -416 417 -417 227 -227 227 -228 227 -227 228 -353 417 -290 227 -228 227 -227 228 -227 290 -417 353 -228 227 -227 354 -416 291 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -353 417 -291 227 -227 227 -228 227 -227 227 -228 227 -227 354 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -417 416 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -353 417 -416 417 -291 227 -227 227 -228 227 -227 291 -416 417 -417 416 -417 417 -416 354 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -290 417 -353 228 -227 227 -228 227 -227 227 -228 227 -227 291 -416 354 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -227 228 -227 290 -417 417 -416 354 -227 227 -228 227 -227 227 -228 416 -417 227 -228 227 -227 227 -228 227 -227 227 -228 227 -417 416 -417 417 -227 227 -227
RAW_Data: 228 -227 227 -228 227 -227 227 -228 416 -417 417 -416 228 -227 227 -291 416 -417 417 -416 417 -417 416 -354 227 -227 228 -227 227 -227 228 -227 227 -227 228 -227 227 -228 353 -417 416 -417 291 -227 227 -227 417 -417 227 -227 227 -291 417 -416 417 -417 416 -354 227 -227 228 -353 417 -416 417 -417 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 227 -227 227 -228 416 -417 417 -416 228 -227 227 -227 228 -227 227 -354 416 -291 227 -227 228 -227 227 -227 228 -227 227 -228 353 -417 290 -227 228 -227 417 -416 228 -227 227 -291 416 -354 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 290 -417 353 -228 227 -227 228 -227 227 -227 417 -417 416 -417 417 -416 417 -417 227 -227 227 -228 227 -227 228 -353 417 -416 417 -291 227 -227 227 -417 417 -416 417 -417 416 -228 227 -227 227 -228 227 -227 228 -227 227 -227 417 -417 227 -227 227 -228 227 -227 228 -227 227 -227 228 -227 227 -227 291 -417 353 -227 228 -227 353 -417 417 -416 291 -227 227 -228 227 -227 227 -228 227 -227 228 -227 227 -227 228 -416 417 -227 228 -227 290 -417 417 -416 417 -417 353 -227 228 -227 353 -417 417 -416 417 -417 290 -227 228 -227 417 -416 417 -417 227 -227 227 -291 417 -416 417 -353 228 -227 227 -228 227 -227 227 -417 417 -416 417 -417 416 -417 417 -416 417 -417 416 -417 417 -227 227 -227 291 -417 416 -417 417 -416 417 -417 416 -417 417 -416 417 -417 353 -227 228 -227 353 -417 417 -416 417 -417 416 -417 417 -416 417 -417 416 -417 291 -227 227 -227 10000
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Flipper Zero test transmitter for the APRS RX app: write Sub-GHz RAW files
(.sub) that play an APRS frame (AX.25, Bell 202 AFSK 1200 bauds) on 433.650 MHz
(433 MHz SRD band, the same bench channel as EPIRB 406).
The Flipper's CC1101 cannot frequency-modulate with an audio tone, only switch
between two frequencies (preset 2FSKDev238Async, +/-2.38 kHz). So each AFSK
tone is sent as a square wave: the carrier toggles at twice the tone frequency
(1200 Hz mark, 2200 Hz space), phase-continuous across bit boundaries, and the
receiver's discriminator outputs that square wave; its audio low-pass leaves
mostly the fundamental. The RAW durations are the square wave's half-periods,
rounded from exact edge times so the timing error never accumulates.
flipper_aprs.py <out_dir> [--call F4HWN] [--freq 433650000]
-> aprs_pos.sub, _digi, _msg, _status, _long, _badfcs (see ax25.test_frames)
One frame per file: press Send once, wait ~1 s before the next one.
"""
import argparse, os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ax25 import test_frames, hdlc_bits, decode
PRESET = "FuriHalSubGhzPreset2FSKDev238Async"
BAUD = 1200
MARK, SPACE = 1200.0, 2200.0
LEAD_US = 100000 # unmodulated carrier before the flags (squelch / RSSI trigger)
TAIL_US = 10000 # carrier kept after the last flag
PER_LINE = 512 # values per RAW_Data line, as the Flipper writes them
def nrzi_tones(bits):
"""Bell 202 NRZI: a 0 changes the tone, a 1 keeps it. Starts on mark."""
f, out = MARK, []
for b in bits:
if not b:
f = SPACE if f == MARK else MARK
out.append(f)
return out
def edges(tones, lead_us=LEAD_US, tail_us=TAIL_US):
"""Exact edge times (us) of the square wave; level is high at t=0.
Returns (edge_times, end_time)."""
t0 = float(lead_us) # lead carrier holds the high level
ph = 0.0 # tone phase in cycles (high while frac < 0.5)
tb = 1e6 / BAUD
ed = []
for i, f in enumerate(tones):
start, end = t0 + i * tb, t0 + (i + 1) * tb
t = start
while True:
nxt = (int(ph * 2) + 1) / 2.0 # next half-cycle boundary
dt = (nxt - ph) / f * 1e6
if t + dt > end:
ph += (end - t) * f / 1e6
break
t += dt
ph = nxt
ed.append(t)
return ed, t0 + len(tones) * tb + tail_us
def raw_durations(ed, end):
"""Signed RAW durations: positive = high tone, negative = low tone."""
pts = [0] + [round(t) for t in ed] + [round(end)]
out, lv = [], 1
for a, b in zip(pts, pts[1:]):
out.append((b - a) if lv else -(b - a))
lv ^= 1
return out
def sub_durations(frame):
ed, end = edges(nrzi_tones(hdlc_bits(frame)))
return raw_durations(ed, end)
def write_sub(path, frame, freq):
d = sub_durations(frame)
lines = ["Filetype: Flipper SubGhz RAW File", "Version: 1",
"Frequency: %d" % freq, "Preset: %s" % PRESET, "Protocol: RAW"]
for i in range(0, len(d), PER_LINE):
lines.append("RAW_Data: " + " ".join(str(x) for x in d[i:i + PER_LINE]))
with open(path, "w") as f:
f.write("\n".join(lines) + "\n")
return d
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("out", nargs="?", default=".")
ap.add_argument("--call", default="F4HWN")
ap.add_argument("--freq", type=int, default=433650000)
a = ap.parse_args()
if 144000000 <= a.freq <= 146000000:
sys.exit("refusing 2 m: the Flipper cannot reach it anyway, and 144.800 is live APRS")
os.makedirs(a.out, exist_ok=True)
for name, fr in test_frames(a.call).items():
p = os.path.join(a.out, "aprs_%s.sub" % name)
d = write_sub(p, fr, a.freq)
print("%-28s %5.0f ms %5d edges %s" % (p, sum(abs(x) for x in d) / 1000,
len(d), decode(fr) or "(bad FCS)"))
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Host model checks for APRSRX history; does not compile or execute the C app."""
from collections import deque
import random
HISTORY = 5
def check_rotation():
rng = random.Random(330)
slots = [None] * HISTORY
order = list(range(HISTORY))
count = 0
expected = deque(maxlen=HISTORY)
last_time = 0
now = 0xFFFFFF00
accepted = 0
for _ in range(10000):
if rng.randrange(20) == 0:
count = 0
expected.clear()
now = (now + rng.choice((0, 50, 999, 1000, 2000))) & 0xFFFFFFFF
if expected and rng.randrange(3) == 0:
payload = expected[0][0]
else:
payload = rng.randbytes(rng.choice((18, 19, 233, 330)))
record = (payload, rng.randrange(-130, -20))
duplicate = bool(expected and len(payload) == len(expected[0][0])
and payload[-2:] == expected[0][0][-2:]
and (now - last_time) & 0xFFFFFFFF < 1000)
newest = slots[order[0]]
reject = bool(count and len(payload) == len(newest[0])
and payload[-2:] == newest[0][-2:]
and (now - last_time) & 0xFFFFFFFF < 1000)
assert reject == duplicate
if not reject:
recycled = order[HISTORY - 1]
for k in range(HISTORY - 1, 0, -1):
order[k] = order[k - 1]
order[0] = recycled
slots[recycled] = record
count = min(count + 1, HISTORY)
expected.appendleft(record)
accepted += 1
last_time = now
assert sorted(order) == list(range(HISTORY))
assert [slots[k] for k in order[:count]] == list(expected)
print(f'History: 10000 events, {accepted} accepted; rotation, clear, duplicates, tick wrap OK')
def check_scroll():
# One frame at a time: the source on line 0, body rows from y = 8, scrolled
# by top. Transcription of emit() and of draw()'s limit.
for step in (6, 8):
for rows in range(1, 60):
end = 8 + rows * step # g.vrow after the last row
lim = end - 32 if end > 32 else 0
assert end < 65536
for top in range(lim + 1):
drawn = []
for r in range(rows):
y = (8 + r * step - top) & 0xFFFFFFFF
if (y - 1) & 0xFFFFFFFF < 30:
drawn.append(y)
else:
# Skipped rows lie wholly under the source (y <= 0, at
# most 8 px high) or wholly below the separator.
signed = 8 + r * step - top
assert signed <= 0 or signed >= 31
# No row is cut by the separator at rest positions the scroll can
# reach from the top: the first body row starts right under the source.
if top == 0:
assert drawn[0] == 8
# At the limit the last glyph row (ending one pixel before its row
# height) is the last one above the y = 31 separator.
assert end - 2 - lim <= 30
if lim:
assert end - 2 - lim == 30
print('Scroll: both fonts, 1 to 59 body rows, every pixel offset; clipping and limit OK')
def check_keys():
# Transcription of handleKeys(): held UP/DOWN scrolls inside the frame; a
# new press at an end changes frame.
rng = random.Random(59)
for _ in range(300):
count = rng.randrange(1, HISTORY + 1)
lims = [rng.choice((0, 0, 3, 40)) for _ in range(count)]
cur = top = 0
prev = None
for _ in range(400):
key = rng.choice((None, 'UP', 'DOWN', 'UP', 'DOWN', 'OTHER'))
if rng.randrange(3):
key = prev if prev else key # mostly held keys
d = {'UP': -1, 'DOWN': 1}.get(key, 0)
before = (cur, top)
lim = lims[cur]
moved = (top + d) & 0xFFFFFFFF
if moved <= lim:
top = moved
elif key != prev and (cur + d) & 0xFFFFFFFF < count:
cur = (cur + d) & 0xFF
top = 0
# Oracle.
if d == 0:
assert (cur, top) == before
elif 0 <= before[1] + d <= lim:
assert (cur, top) == (before[0], before[1] + d)
elif key == prev or not 0 <= before[0] + d < count:
assert (cur, top) == before # held at an end, or no such frame
else:
assert (cur, top) == (before[0] + d, 0)
assert 0 <= cur < count and 0 <= top <= lims[cur]
prev = key
print('Keys: held scroll stays in the frame, a new press at an end changes frame OK')
def check_columns():
# Compare the C scratch-column shift against a per-pixel clipping oracle.
for y in range(1, 31):
for glyph in range(256):
bits = (glyph << y) & 0xFFFFFFFF
pages = [0] * 4
page = 0
while bits:
assert page < 4
pages[page] |= bits & 255
bits >>= 8
page += 1
packed = sum(value << (8 * page) for page, value in enumerate(pages))
expected = sum(1 << (y + bit) for bit in range(8)
if glyph & (1 << bit) and y + bit < 32)
assert packed == expected
print('Scratch renderer: all 256 column patterns, 30 offsets, clipping OK')
if __name__ == '__main__':
check_rotation()
check_scroll()
check_keys()
check_columns()
+288
View File
@@ -0,0 +1,288 @@
#!/usr/bin/env python3
"""Mic-E: an encoder written from the APRS 1.0.1 spec (chapter 10), and the
display decoder of aprsrx_app.c transliterated byte for byte (uint8 wrap, no
division), checked against each other and against a real FT3D frame.
Also the standard (uncompressed) position display, against real frames.
mice.py runs the tests and prints the screen rows
"""
import os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
MSG = ["Emergency", "Priority", "Special", "Committed",
"Returning", "In Service", "En Route", "Off Duty"] # index = bits A B C
# ------------------------------------------------------------ encoder (spec) --
def encode(call, lat, lon, speed_kn=0, course=0, sym="[/", msg=7, custom=False,
comment="", path=(), marker="`", suffix="_0", alt_m=None):
"""lat/lon in signed decimal degrees -> AX.25 frame bytes."""
north, east = lat >= 0, lon >= 0
lat, lon = abs(lat), abs(lon)
ld = int(lat); lm = (lat - ld) * 60
lmi = int(lm); lh = int(round((lm - lmi) * 100))
if lh == 100: lmi, lh = lmi + 1, 0
digits = "%02d%02d%02d" % (ld, lmi, lh)
od = int(lon); om = (lon - od) * 60
omi = int(om); oh = int(round((om - omi) * 100))
if oh == 100: omi, oh = omi + 1, 0
offset = od < 10 or od >= 100
bits = [(msg >> 2) & 1, (msg >> 1) & 1, msg & 1]
dest = ""
for k, ch in enumerate(digits):
d = int(ch)
if k < 3:
one = bits[k]
dest += chr((ord("A") if custom else ord("P")) + d) if one else chr(ord("0") + d)
elif k == 3:
dest += chr(ord("P") + d) if north else chr(ord("0") + d)
elif k == 4:
dest += chr(ord("P") + d) if offset else chr(ord("0") + d)
else:
dest += chr(ord("P") + d) if not east else chr(ord("0") + d)
if od <= 9: b1 = od + 118
elif od <= 99: b1 = od + 28
elif od <= 109: b1 = od + 8
else: b1 = od - 72
b2 = omi + 88 if omi <= 9 else omi + 28
b3 = oh + 28
b4 = speed_kn // 10 + 28
b5 = (speed_kn % 10) * 10 + course // 100 + 28
b6 = course % 100 + 28
info = bytes([0x60, b1, b2, b3, b4, b5, b6]) + sym.encode()
body = comment
if alt_m is not None:
v = alt_m + 10000
body = chr(v // 8281 + 33) + chr(v // 91 % 91 + 33) + chr(v % 91 + 33) + "}" + body
if marker:
body = marker + body + suffix
return _frame(call, dest, path, info + body.encode("latin-1"))
def _frame(call, dest, path, info):
from ax25 import addr, crc16
hops = list(path)
f = addr(dest) + addr(call, last=not hops)
for i, h in enumerate(hops):
f += addr(h, last=i == len(hops) - 1)
f += b"\x03\xF0" + info
c = crc16(f)
return f + bytes([c & 0xFF, c >> 8])
# ------------------------------------------- decoder (mirror of aprsrx_app.c) --
def sub(v, d):
q = 0
while v >= d:
v -= d; q += 1
return q, v
def put2(v):
q, v = sub(v, 10)
return chr(48 + q) + chr(48 + v)
def put3(v):
q, v = sub(v, 100)
return chr(48 + q) + put2(v)
def mic_digit(c):
if 0x30 <= c <= 0x39: return c - 0x30
if 0x41 <= c <= 0x4A: return c - 0x41
if 0x50 <= c <= 0x59: return c - 0x50
return 0 # K, L, Z: position ambiguity
def mic_bit(c):
return c >= 0x50 or (0x41 <= c <= 0x4B)
def text_row(o, f, i, end):
while len(o) < 32 and i < end:
ch = f[i]
o += "." if ch < 0x20 or ch > 0x7E else chr(ch)
i += 1
return o, i
def is_mice(f, i, end):
return end - i >= 9 and f[i] in (0x60, 0x27, 0x1C, 0x1D)
def draw_mice(f, i, end):
c = [f[k] >> 1 for k in range(6)]
# row 1: position
o = ""
for k in range(6):
o += chr(48 + mic_digit(c[k]))
if k == 1: o += " "
if k == 3: o += "."
o += "N" if mic_bit(c[3]) else "S"
deg = (f[i + 1] - 28) & 0xFF
if mic_bit(c[4]): deg += 100
if 180 <= deg <= 189: deg -= 80
elif 190 <= deg <= 199: deg -= 190
mn = (f[i + 2] - 28) & 0xFF
if mn >= 60: mn -= 60
o += " " + put3(deg) + " " + put2(mn) + "." + put2((f[i + 3] - 28) & 0xFF)
o += "W" if mic_bit(c[5]) else "E"
r1 = o
# row 2: speed, course, symbol, message
dc = (f[i + 5] - 28) & 0xFF
q, dc = sub(dc, 10)
sp = ((f[i + 4] - 28) & 0xFF) * 10 + q
crs = dc * 100 + ((f[i + 6] - 28) & 0xFF)
if sp >= 800: sp -= 800
if crs >= 400: crs -= 400
kmh = (sp * 1897) >> 10
o = "%dkm/h %d " % (kmh, crs) + chr(f[i + 7]) + chr(f[i + 8]) + " "
idx = mic_bit(c[0]) * 4 + mic_bit(c[1]) * 2 + mic_bit(c[2])
custom = any(0x41 <= x <= 0x4B for x in c[:3])
o += ("Custom-" + chr(48 + 7 - idx)) if custom and idx else MSG[idx]
r2 = o
# row 3: comment without the device markers, altitude first
j = i + 9
while end > j and f[end - 1] in (0x0D, 0x0A): end -= 1
if j < end:
m = f[j]
if m in (0x60, 0x27):
j += 1
if end - j >= 2: end -= 2
elif m in (0x3E, 0x5D):
j += 1
if end > j and f[end - 1] in (0x3D, 0x5E): end -= 1
o = ""
if end - j >= 4 and f[j + 3] == 0x7D:
alt = (((f[j] - 33) * 91 + (f[j + 1] - 33)) * 91 + (f[j + 2] - 33)) - 10000
o = "%dm " % alt
j += 4
r3, _ = text_row(o, f, j, end)
return r1, r2, r3
def safe(ch):
return "." if ch < 0x20 or ch > 0x7E else chr(ch)
def draw_pos(f, i, end):
"""Uncompressed position ('!' '=' or, after a 7-char timestamp, '/' '@'):
None when the info field is anything else (shown as raw text)."""
t = f[i]
p = i + 1
if t in (0x2F, 0x40): p += 7
elif t not in (0x21, 0x3D): return None
if end < p + 19 or f[p + 4] != 0x2E or f[p + 14] != 0x2E: return None
q = f[p:]
o = ""
for k in range(18):
if k == 8: continue
if k in (2, 9, 12): o += " "
o += safe(q[k])
r1 = o
o = ""
if p > i + 1:
o += "".join(safe(f[k]) for k in range(i + 1, p)) + " "
o += safe(q[8]) + safe(q[18]) + " "
r2, j = text_row(o, f, p + 19, end)
r3, _ = text_row("", f, j, end)
return r1, r2, r3
def info_start(f):
end = len(f) - 2
e = 13
while not (f[e] & 1) and e + 7 < end:
e += 7
return e + 3, end
# ------------------------------------------------------------------- tests --
def main():
ok = True
# Import the generator without writing assets; check every destination code.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from gen_assets import a, mic_code, MIC_MSG
blob, fields, _ = a.build()
offsets = {name: (offset, size) for name, offset, size in fields}
symbol_offset, symbol_size = offsets["SYM_MAP"]
for slot in range(symbol_offset, symbol_offset + symbol_size, 3):
assert not blob[slot] or blob[slot + 1] != 0
# Verify the serialized front-end state independently of its generator.
import struct
init_offset, init_size = offsets["DEMOD_INIT"]
assert init_size == 116
words = struct.unpack_from('<29I', blob, init_offset)
assert words[0] == 32768 and words[9:11] == (64, 64)
assert all(value == 0 for i, value in enumerate(words) if i not in (0, 9, 10))
assert offsets["COS1200"][0] == init_offset + init_size
assert offsets["COS2200"][0] == init_offset + init_size + 8
assert len(blob) <= 3840
print(f"Demodulator initial state and table layout OK; assets {len(blob)}/3840 bytes")
ui_size = dict(a.extra)["UI_SIZE"]
for name in ("T_MIC", "T_MSG"):
offset, size = offsets[name]
assert offset + size <= ui_size
lut_offset, lut_size = offsets["T_MIC"]
assert lut_size == 128
for c in range(128):
packed = blob[lut_offset + c]
assert packed == mic_code(c)
assert packed & 15 == mic_digit(c)
assert bool(packed & 16) == mic_bit(c)
assert bool(packed & 32) == (0x41 <= c <= 0x4B)
print("Mic-E asset lookup: all 128 codes equivalent")
msg_offset, _ = offsets["T_MSG"]
stride = dict(a.extra)["T_MSG_STRIDE"]
for index, message in enumerate(MIC_MSG):
start = msg_offset + index * stride
assert blob[start:start + stride].split(b'\0', 1)[0].decode('ascii') == message
print(f"UI asset read: all messages and lookup covered by {ui_size} bytes")
# the FT3D frame received on 2026-09-30 (screen: >TXUPX9, `x,5l .[/`_0.)
ft3d = _frame("F4HWN", "TXUPX9", (), b"`x,5l \x1c[/`_0\r")
i, end = info_start(ft3d)
rows = draw_mice(ft3d, i, end)
print("FT3D ", rows)
ok &= rows == ("48 50.89N 002 16.25E", "0km/h 0 [/ Off Duty", "")
cases = [
# lat, lon, speed kn, course, msg, custom, alt, comment -> expected rows
(48.848306, 2.270844, 0, 0, 7, False, None, "", "48 50.90N 002 16.25E"),
(-33.8688, 151.2093, 25, 271, 6, False, 58, "Hello", "33 52.13S 151 12.56E"),
(40.7128, -74.0060, 5, 90, 0, False, None, "", "40 42.77N 074 00.36W"),
(51.5, -0.1276, 799, 359, 3, True, -12, "x", "51 30.00N 000 07.66W"),
(10.0, 105.5, 12, 0, 5, False, 1234, "", "10 00.00N 105 30.00E"),
(1.0, -179.99, 100, 180, 1, False, None, "", "01 00.00N 179 59.40W"),
]
for lat, lon, sp, crs, msg, cu, alt, com, want in cases:
f = encode("F4HWN-7", lat, lon, sp, crs, "[/", msg, cu, com, alt_m=alt)
i, end = info_start(f)
r1, r2, r3 = draw_mice(f, i, end)
kmh = (sp * 1897) >> 10
m = ("Custom-%d" % (7 - msg)) if cu and msg else MSG[msg]
w2 = "%dkm/h %d [/ %s" % (kmh, crs, m)
w3 = ("%dm " % alt if alt is not None else "") + com
good = (r1, r2, r3) == (want, w2, w3) and all(len(r) <= 32 for r in (r1, r2, r3))
ok &= good
print("OK " if good else "FAIL", (r1, r2, r3), "" if good else (want, w2, w3))
# standard positions (aprsrx_app.c drawPos)
pos_cases = [
("F1PRY-14", "@300158z4921.07N/00150.50EN APRS OISE V=12.5V",
("49 21.07N 001 50.50E", "300158z /N APRS OISE V=12.5V", "")),
("F4HWN", "!4850.90N/00216.25E-UV-K5 APRS RX test",
("48 50.90N 002 16.25E", "/- UV-K5 APRS RX test", "")),
("F4HWN", "=4850.90S\\12345.67W>" + "x" * 60,
("48 50.90S 123 45.67W", "\\> " + "x" * 29, "x" * 31)),
("F4HWN", "!/5L!!<*e7>7P[", None), # compressed: raw text
("F4HWN", "!4850.90N/00216.25", None), # too short
("F4HWN", ">status text", None),
]
for call, info, want in pos_cases:
f = _frame(call, "APZK5", (), info.encode("latin-1"))
i, end = info_start(f)
got = draw_pos(f, i, end)
good = got == want and (got is None or all(len(r) <= 32 for r in got))
ok &= good
print("OK " if good else "FAIL", got, "" if good else want)
print("ALL OK" if ok else "FAILURES")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""Pure-Python model of the APRS receive chain, and the integer demodulator the
radio app will run (written to be transliterated to C: ints, shifts, no division).
Chain: source (Flipper square-wave FSK from the .sub durations, or a real
station's sine AFSK with a given twist) -> discriminator output in Hz + offset +
white noise -> radio audio path (RAW: 5 kHz low-pass as in EPIRB 406; STD: 300 Hz
high-pass, 750 us de-emphasis, 3 kHz low-pass) -> 12-bit ADC on PA4 around 2048 at
9.6 kHz (optional clock error) -> Demod -> frames.
Demod (per ADC sample):
DC removal -> band-pass -> 4 sliding correlators over one bit (8 samples):
I/Q at 1200 and 2200 Hz -> magnitudes (max + 3/8 min) -> per-tone peak AGC
-> 3 slicers, decision sign(wa*Mm*Ps - wb*Ms*Pm) with wa:wb = 2:3, 1:1, 3:2,
each with its own DPLL (16-bit phase per bit, nudged 1/4 toward mid-bit on
each transition), NRZI, HDLC (flag, destuff, abort), FCS and UI check.
model_rx.py runs the test sweep, prints a table
"""
import math, os, random, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from ax25 import test_frames, hdlc_bits, crc16, decode
from flipper_aprs import nrzi_tones, edges
FS_SIM = 96000
FS_ADC = 9600
BIAS = 2048
LSB_PER_HZ = 0.065 # measured on the K1 (EPIRB 406 lab): ~2.3 kHz -> ~150 LSB
FLIPPER_DEV = 2380.0
# ---------------------------------------------------------------- channel ----
def flipper_wave(frame):
"""Discriminator output (Hz) of the Flipper square-wave FSK, box-averaged
per simulation sample from the exact (rounded to us, as in the .sub) edges."""
ed, end = edges(nrzi_tones(hdlc_bits(frame)))
ed = [float(round(t)) for t in ed]
n = int(end * 1e-6 * FS_SIM)
T = 1e6 / FS_SIM
out, lv, k = [], 1, 0
for i in range(n):
t0, t1 = i * T, (i + 1) * T
acc, t = 0.0, t0
while k < len(ed) and ed[k] < t1:
acc += (ed[k] - t) * (1 if lv else -1)
t = ed[k]
lv ^= 1
k += 1
acc += (t1 - t) * (1 if lv else -1)
out.append(FLIPPER_DEV * acc / T)
return out
def sine_wave(frame, dev=3000.0, twist_db=0.0):
"""A real station: phase-continuous sine AFSK, space tone twist_db louder."""
tones = nrzi_tones(hdlc_bits(frame))
spb = FS_SIM / 1200.0
out, ph = [], 0.0
lead = [0.0] * int(0.1 * FS_SIM)
a_space = dev * 10 ** (twist_db / 20)
for i in range(int(len(tones) * spb)):
f = tones[int(i / spb)]
ph += f / FS_SIM
out.append((dev if f == 1200.0 else a_space) * math.sin(2 * math.pi * ph))
return lead + out
def biquad_lp(x, fc):
w = math.tan(math.pi * fc / FS_SIM)
q = 1 / math.sqrt(2)
n = 1 / (1 + w / q + w * w)
b0 = w * w * n; b1 = 2 * b0; a1 = 2 * (w * w - 1) * n; a2 = (1 - w / q + w * w) * n
y, x1, x2, y1, y2 = [], 0.0, 0.0, 0.0, 0.0
for v in x:
o = b0 * v + b1 * x1 + b0 * x2 - a1 * y1 - a2 * y2
x2, x1, y2, y1 = x1, v, y1, o
y.append(o)
return y
def rc_hp(x, fc):
a = math.exp(-2 * math.pi * fc / FS_SIM)
y, acc, prev = [], 0.0, 0.0
for v in x:
acc = a * (acc + v - prev)
prev = v
y.append(acc)
return y
def deemph(x, tau_us=750.0, ref_hz=1200.0):
"""1-pole de-emphasis, unity gain at ref_hz."""
k = 1 - math.exp(-1 / (FS_SIM * tau_us * 1e-6))
g = math.sqrt(1 + (2 * math.pi * ref_hz * tau_us * 1e-6) ** 2)
y, acc = [], 0.0
for v in x:
acc += k * (v - acc)
y.append(acc * g)
return y
def channel(sig, mode="raw", noise_hz=0.0, off_hz=0.0, ppm=0.0, seed=1,
gap_ms=150):
rnd = random.Random(seed)
gap = int(gap_ms * 1e-3 * FS_SIM)
# no carrier around the burst: the discriminator outputs full-scale noise
x = [rnd.gauss(0, 6000) for _ in range(gap)]
x += [v + off_hz + rnd.gauss(0, noise_hz) for v in sig]
x += [rnd.gauss(0, 6000) for _ in range(gap)]
if mode == "raw":
x = rc_hp(biquad_lp(x, 5000), 30)
else:
x = biquad_lp(deemph(rc_hp(x, 300)), 3000)
step = FS_SIM / FS_ADC * (1 + ppm * 1e-6)
out, p = [], 0.0
while p < len(x) - 1:
i = int(p); f = p - i
v = x[i] * (1 - f) + x[i + 1] * f
out.append(min(4095, max(0, int(round(BIAS + LSB_PER_HZ * v + rnd.gauss(0, 1))))))
p += step
return out
# ------------------------------------------------------ integer demodulator --
def _tab(f, fs):
per = fs // math.gcd(fs, int(f))
c = [int(round(127 * math.cos(2 * math.pi * f * n / fs))) for n in range(per)]
s = [int(round(127 * math.sin(2 * math.pi * f * n / fs))) for n in range(per)]
return c, s
def is_ui(f):
"""An APRS frame is a UI frame: the address field ends (bit 0 of an SSID
byte) and is followed by control 0x03 and PID 0xF0. Rejects the rare noise
burst that passes the 16-bit FCS in continuous decoding."""
end = len(f) - 2
e = 13
while not (f[e] & 1) and e + 7 < end:
e += 7
return bool(f[e] & 1) and e + 2 < end and f[e + 1] == 0x03 and f[e + 2] == 0xF0
class Slicer:
"""One decision threshold + its own DPLL and HDLC. The decision is
sign(wa*Mm*Ps - wb*Ms*Pm): wa/wb != 1 favours one tone, which recovers
frames whose twist the per-tone AGC does not fully cancel (Direwolf's
multi-slicer idea)."""
def __init__(self, dm, wa, wb):
self.dm, self.wa, self.wb = dm, wa, wb
self.dprev = 0; self.phase = 0; self.last = 0
self.sr = 0; self.ones = 0; self.byte = 0; self.nb = 0
self.buf = bytearray(); self.inframe = False
self.good = 0
def step(self, a, b):
dm = self.dm
d = a * self.wa - b * self.wb
# DPLL: sample at phase wrap (mid-bit), transitions pulled to mid-phase
self.phase += dm.step
if (d > 0) != (self.dprev > 0):
self.phase += (dm.ctr - self.phase) >> dm.pll
self.dprev = d
if self.phase >= 65536:
self.phase -= 65536
lv = 1 if d > 0 else 0
self.bit(1 if lv == self.last else 0)
self.last = lv
def bit(self, b):
self.sr = (self.sr >> 1) | (b << 7)
if self.sr == 0x7E:
if self.inframe and len(self.buf) >= 18 and \
crc16(self.buf[:-2]) == self.buf[-2] | (self.buf[-1] << 8) and \
is_ui(self.buf):
self.good += 1
self.dm.frame(bytes(self.buf))
self.buf = bytearray(); self.inframe = True
self.ones = self.byte = self.nb = 0
return
if b:
self.ones += 1
if self.ones > 6: # abort / noise: wait for a flag
self.inframe = False
return
else:
if self.ones == 5: # stuffed zero
self.ones = 0
return
self.ones = 0
if not self.inframe:
return
self.byte |= b << self.nb
self.nb += 1
if self.nb == 8:
if len(self.buf) < 330:
self.buf.append(self.byte)
else:
self.inframe = False
self.byte = self.nb = 0
class Demod:
def __init__(self, fs=FS_ADC, agc=True, bp=True, att=5, dec=10, pll=2, ctr=36864,
slicers=((2, 3), (1, 1), (3, 2))):
self.ctr, self.bp, self.att, self.dec, self.pll = ctr, bp, att, dec, pll
# 2nd-order band-pass (RBJ, constant 0 dB peak) centred on
# sqrt(1200*2200) = 1625 Hz, Q = 0.9: coefficients in Q14
w0 = 2 * math.pi * 1625 / fs
al = math.sin(w0) / (2 * 0.9)
a0 = 1 + al
self.b0 = int(round(al / a0 * 16384))
self.a1 = int(round(-2 * math.cos(w0) / a0 * 16384))
self.a2 = int(round((1 - al) / a0 * 16384))
self.x1 = self.x2 = self.y1 = self.y2 = 0
self.N = fs // 1200 # correlation window = one bit
self.cm, _ = _tab(1200, fs) # 8 entries at 9.6 kHz
self.cs, _ = _tab(2200, fs) # 48 entries at 9.6 kHz
self.im = self.qm = self.is_ = self.qs = 0
self.ring = [[0, 0, 0, 0] for _ in range(self.N)]
self.r = 0; self.km = 0; self.ks = 0
self.dc = BIAS << 4
self.pm = self.ps = 64
self.agc = agc
self.step = (65536 * 1200 + fs // 2) // fs
self.sl = [Slicer(self, wa, wb) for wa, wb in slicers]
self.frames = []
self.maxab = 0
def frame(self, f):
if f not in self.frames: # the same frame from several slicers
self.frames.append(f)
@staticmethod
def mag(i, q):
a, b = abs(i), abs(q)
if a < b: a, b = b, a
return a + (b >> 2) + (b >> 3)
def sample(self, adc):
self.dc += ((adc << 4) - self.dc) >> 6
x = adc - (self.dc >> 4)
if self.bp:
y = (self.b0 * (x - self.x2) - self.a1 * self.y1 - self.a2 * self.y2) >> 14
self.x2, self.x1, self.y2, self.y1 = self.x1, x, self.y1, y
x = y
# sin = cos read 3/4 period ahead: +6 of 8 (1200 Hz), +12 of 48 (2200 Hz:
# 11 cycles per 48 samples, 12 samples = 2.75 cycles)
cm, cs = self.cm, self.cs
p = ((x * cm[self.km]) >> 8, (x * cm[(self.km + 6) & 7]) >> 8,
(x * cs[self.ks]) >> 8, (x * cs[(self.ks + 12) % 48]) >> 8)
o = self.ring[self.r]
self.im += p[0] - o[0]; self.qm += p[1] - o[1]
self.is_ += p[2] - o[2]; self.qs += p[3] - o[3]
self.ring[self.r] = p
self.r = (self.r + 1) % self.N
self.km = (self.km + 1) % len(self.cm)
self.ks = (self.ks + 1) % len(self.cs)
mm, ms = self.mag(self.im, self.qm), self.mag(self.is_, self.qs)
if self.agc:
self.pm += (mm - self.pm) >> self.att if mm > self.pm else -(self.pm >> self.dec)
self.ps += (ms - self.ps) >> self.att if ms > self.ps else -(self.ps >> self.dec)
if self.pm < 16: self.pm = 16
if self.ps < 16: self.ps = 16
a, b = mm * self.ps, ms * self.pm
else:
a, b = mm, ms
if a > self.maxab: self.maxab = a
if b > self.maxab: self.maxab = b
for s in self.sl:
s.step(a, b)
def run(adc, **kw):
d = Demod(**kw)
for s in adc:
d.sample(s)
return d.frames
# ------------------------------------------------------------------ sweep ----
def main():
fr = test_frames()
waves = {}
def wave(name, src, twist=0.0):
key = (name, src, twist)
if key not in waves:
waves[key] = flipper_wave(fr[name]) if src == "flipper" \
else sine_wave(fr[name], twist_db=twist)
return waves[key]
cases = []
for mode in ("raw", "std"):
for name in ("pos", "long", "badfcs"):
cases.append(("flipper", 0.0, mode, name, 0, 0, 0))
for noise in (1500, 3000, 4500, 6000):
cases.append(("flipper", 0.0, mode, "pos", noise, 0, 0))
for off in (-4000, 4000):
cases.append(("flipper", 0.0, mode, "pos", 1500, off, 0))
for ppm in (-10000, 10000):
cases.append(("flipper", 0.0, mode, "long", 1500, 0, ppm))
for tw in (-6.0, 0.0, 6.0):
cases.append(("sine", tw, mode, "pos", 1500, 0, 0))
print("%-7s %5s %-4s %-6s %6s %6s %7s | %-7s %-7s" %
("source", "twist", "mode", "frame", "noise", "off", "ppm", "agc", "no-agc"))
for src, tw, mode, name, noise, off, ppm in cases:
oks = []
for seed in (1, 2, 3):
adc = channel(wave(name, src, tw), mode, noise, off, ppm, seed)
for agc in (True, False):
got = run(adc, agc=agc)
want = [] if name == "badfcs" else [fr[name]]
oks.append((agc, got == want))
res = ["%d/3" % sum(ok for a, ok in oks if a == agc) for agc in (True, False)]
print("%-7s %5.0f %-4s %-6s %6d %6d %7d | %-7s %-7s" %
(src, tw, mode, name, noise, off, ppm, res[0], res[1]))
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "--one":
f = test_frames()["pos"]
got = run(channel(flipper_wave(f)))
print([decode(g) for g in got])
else:
main()
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Compare demodulator variants on the hard cases of the sweep (dev tool).
Run from a file (multiprocessing re-imports __main__)."""
import os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from multiprocessing import Pool
from model_rx import *
FR = test_frames()
CASES = []
for mode in ("raw", "std"):
for noise in (3000, 4000):
CASES.append(("flipper", 0.0, mode, "pos", noise, 0, 0))
CASES.append(("flipper", 0.0, mode, "long", 2000, 0, 10000))
CASES.append(("flipper", 0.0, mode, "long", 2000, 0, -10000))
for tw in (-9.0, -6.0, -3.0, 0.0, 3.0, 6.0, 9.0):
CASES.append(("sine", tw, mode, "pos", 2500, 0, 0))
CASES.append(("flipper", 0.0, mode, "badfcs", 1000, 0, 0))
VARIANTS = {
"s1": dict(slicers=((1, 1),)),
"s3x2": dict(slicers=((1, 2), (1, 1), (2, 1))),
"s3x1.5": dict(slicers=((2, 3), (1, 1), (3, 2))),
"s5": dict(slicers=((1, 2), (2, 3), (1, 1), (3, 2), (2, 1))),
"s3x3": dict(slicers=((1, 3), (1, 1), (3, 1))),
}
SEEDS = (1, 2, 3, 4)
def job(c):
src, tw, mode, name, noise, off, ppm = c
w = flipper_wave(FR[name]) if src == "flipper" else sine_wave(FR[name], twist_db=tw)
row, mx = [], 0
for seed in SEEDS:
adc = channel(w, mode, noise, off, ppm, seed)
want = [] if name == "badfcs" else [FR[name]]
r = []
for kw in VARIANTS.values():
d = Demod(**kw)
for s in adc:
d.sample(s)
r.append(d.frames == want)
mx = max(mx, d.maxab)
row.append(r)
return c, [sum(r[i] for r in row) for i in range(len(VARIANTS))], mx
if __name__ == "__main__":
with Pool() as p:
res = p.map(job, CASES)
names = list(VARIANTS)
print("%-40s " % "case" + " ".join("%6s" % n for n in names))
tot = [0] * len(names)
mx = 0
for c, r, m in res:
print("%-40s " % (" ".join(str(x) for x in c)) + " ".join("%6d" % x for x in r))
tot = [a + b for a, b in zip(tot, r)]
mx = max(mx, m)
print("%-40s " % ("TOTAL /%d" % (len(CASES) * len(SEEDS))) + " ".join("%6d" % x for x in tot))
print("max(Mm*Ps, Ms*Pm) = %d (x3 = %d, int32 max 2147483647)" % (mx, 3 * mx))
+126
View File
@@ -0,0 +1,126 @@
# APRS TX: on-radio APRS position beacon (work in progress)
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. Companion of [APRS RX](../aprsrx/README.md),
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; v0.3 adds the SSID and path; v0.4 adds the corrected 20x20 bitmap and the 48-symbol selector; v0.5 keeps the source fixed while the frame body scrolls (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)
| Setting | Default |
|---|---|
| Source | the boot-message callsign (API `boot_callsign`, 1-6 letters/digits) + `SSID` = 7 (0-15, 0 = none): the **default**, until one is chosen on the radio |
| `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 3) |
| `SYMBOL` | `/[` (person): the default, until one of the 48 symbols is chosen on the radio |
| `COMMENT` | `UV-K5/K1 F4HWN Firmware` (43 characters at most) |
Frame sent: `F4HWN-7>APZK5,WIDE1-1:!4850.90N/00216.25E[UV-K5/K1 F4HWN Firmware` (68 bytes,
~0.79 s on the air: 50 ms of tone settle, 40 flags = 267 ms, the frame, 3 flags).
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, y=33-52. 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 |
|---|---|
| PTT or MENU | Send one frame |
| UP / DOWN (held) | Scroll the frame body above the separator 1 px per 40 ms loop (UV-K1: LEFT/RIGHT, as `nav_dir`), while the source callsign stays fixed in bold on line 0 as in APRS RX. In the status bar (x = 72-76), ▲ while rows are hidden above and ▼ while rows are hidden below; while F is armed its icon (x = 70-78, centred on them) takes their place, and they are not drawn while transmitting (the `TRANSMIT` capsule ends at x = 72). The body uses the small font, 18 characters a row, 3 visible rows at a time: `>DEST,WIDE…` (a row breaks after a comma), latitude, longitude, then symbol and comment |
| 1 / F then 1 | Tone level (deviation) up / down: REG_70 gain 10-127, step 4, default 66 (the firmware's tone gain) |
| 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 |
| 3 | Edit the position, SSID, path and symbol |
| EXIT | Quit (level, twist, position, SSID, path, symbol and view are saved) |
### Editor (v0.3)
```
LAT 48 50.90 N
LON 002 16.25 E
SSID 7
PATH WIDE1-1
SYMB /[
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, 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, 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), 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 |
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`, 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, 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.
## How it transmits
As the Beacon app keys MCW: `tx_set_params` (carrier + PA on the TX VFO),
REG_51 = 0 (no CTCSS/DCS under the AFSK), `tx_tone(1200)` (tone path on, mic ADC
off, 50 ms settle). Then each bit is timed from SysTick, 40000 cycles at 48 MHz
(exact), and at each NRZI transition the tone frequency REG_71 (1200 Hz = 12389,
2200 Hz = 22714, the driver's `scale_freq`) and gain REG_70 are rewritten: two
SPI writes, a few tens of µs out of 833. `tx_mute` + `tx_end` restore RX.
## Unknowns for the first on-air test (all fine with the defaults on 2026-09-30)
1. **Phase continuity** when REG_71 is rewritten (an NCO keeps it, probably).
The RX model decodes even with a phase reset at every transition (3/3 clean,
2/3 with noise in STD); a hardware TNC such as the FT3D may be stricter.
2. **Twist.** The tone probably enters after the pre-emphasis, so the signal is
flat and a receiver with de-emphasis sees 2200 Hz ~5 dB low. If the FT3D
misses frames that APRS RX decodes, raise `tw` (key 2): +4 is +3.5 dB.
3. **Deviation** at level 66: aim for ~3 kHz; 1 up, F then 1 down.
## Test plan
1. Simplex test frequency (not 144.800 for the first frames), low power.
2. Receivers: APRS RX on a second radio (its per-slicer counters `s a/b/c`
show the twist: frames only on the space-favouring slicer = 2200 Hz low) and
the FT3D (APRS on band B, same frequency).
3. PTT, check both; adjust level and twist; then 144.800.
`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
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
AFSK (continuous phase or reset, tw 0/+4, TX path twist 0/+5 dB) decoded by the
RX model through its RAW and STD audio paths, with and without noise.
+43
View File
@@ -0,0 +1,43 @@
/* Overlay-app link script (POC).
*
* The blob is linked to run at the PY25Q16 sector-cache overlay VMA (shared with
* the multiboot RAM stub — never both active at once). Code + rodata + data +
* bss must all fit in the 4 KiB overlay. objcopy -O binary emits text+rodata+
* data only; the loader zeroes the overlay before copying, so bss starts clean.
*
* VMA must match ADDR(.mb_workspace) in Core/py32f071xb.ld. If the firmware RAM
* layout shifts, update APP_VMA and the loader's compile-time assert catches a
* mismatch. */
/* APP_VMA is the target firmware's __mb_workspace_start (read from its .map). It
* varies with the RAM layout, so pass it from the build: -Wl,--defsym,APP_VMA=0x...
* The default is only a fallback for a standalone size check. */
APP_VMA = DEFINED(APP_VMA) ? APP_VMA : 0x20000280;
APP_LENGTH = 0x1000; /* 4 KiB sector-cache overlay */
ENTRY(app_main)
MEMORY {
APP (rwx) : ORIGIN = APP_VMA, LENGTH = APP_LENGTH
}
SECTIONS {
.app APP_VMA : {
KEEP(*(.text.entry)) /* app_main pinned to offset 0 */
*(.text .text.*)
*(.rodata .rodata.*)
. = ALIGN(4);
*(.data .data.*)
. = ALIGN(4);
__app_bss_start = .;
*(.bss .bss.* COMMON)
. = ALIGN(4);
__app_bss_end = .;
} > APP
__app_end = .;
ASSERT(__app_end <= APP_VMA + APP_LENGTH,
"APRS TX overlay app overflows the 4 KiB overlay")
/DISCARD/ : { *(.ARM.exidx*) *(.ARM.extab*) *(.eh_frame*) *(.comment) *(.note.*) }
}
+626
View File
@@ -0,0 +1,626 @@
/* 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, the path (DIRECT, WIDE1-1
* or WIDE1-1,WIDE2-1) and the symbol are edited on the radio (key 3) 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 (held) scroll the frame
* 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 · 3 edit the position,
* 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, 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, symbol and view are saved on exit (cfg_save, 13
* 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 */
#define F_X 70u /* F icon (9 px), as FoxHunt / FM / Beacon */
#define SCROLL_X 72u /* scroll marks (5 px), centred on the F icon that takes
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, 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 13u /* magic, level, twist, 13 digits + hemispheres in nibbles,
then 0x80 | path << 4 | SSID (v0.2 configs: defaults),
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) */
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 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 */
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 frame is shown above the separator (y = 31), in one of two views (key *),
* as in APRS RX, with the source fixed on line 0 and the body scrolling below:
* - scroll (g.cw = 0): rows of 18 characters in the small font (7 px each),
* 8 px apart (glyphs 7 px, bit 7 blank), scrolled by g.top pixels: the source
* and 3 body rows show at g.top = 0;
* - compact (g.cw = CW_COMPACT): the source in the small font, then rows of 32
* tiny characters (4 px each), 6 px apart from y = 8, the 4th ending at
* y = 30; no scroll, the rest of the frame is not shown. */
#define ROW_CHARS 18u
#define CW_COMPACT 14u /* compact rows: ROW_CHARS + 14 = 32 characters */
/* One row str..end of the frame, wrapped every ROW_CHARS + g.cw (neither font
* clips). Scroll view: row r goes to line r - top/8 when that is 0..4: line 4
* (under the separator, redrawn after) only feeds the pixel shift of draw().
* Row 0, the source, is drawn in bold (same 7 px glyphs) on line 0 in both
* views. In scroll view draw() redraws it after the pixel shift; compact rows
* 1-4 use the tiny font and the next ones are dropped. */
static void emit(char *end){
const app_api_t *A=g.A;
char *p=str;
while(p<end){
uint8_t w=(uint8_t)(ROW_CHARS+g.cw); /* compare lengths: p + w may lie past str */
char *q=(end-p>w)?p+w:end;
char c=*q; *q='\0';
uint8_t r=g.vrow, ln=(uint8_t)(r-(g.top>>3)); /* top = 0 when compact */
if(g.cw && r){ if(r<5u) A->print_tiny(p,0,(uint8_t)(r*6u+2u),false,true); }
else if(ln<5u) (r?A->print_normal:A->print_bold)(p,0,0,ln);
*q=c;
g.vrow=(uint8_t)(r+1u);
p=q;
}
}
/* Between two parts of a row ("48 50.90N" / "002 16.25E"): a new row in the
* scroll view, a space in the compact one. */
static char *brk(char *o){
if(g.cw){ *o++=' '; return o; }
emit(o);
return str;
}
/* 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 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;
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,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,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){
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((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 */
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); }
}
/* 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,x,0,line);
if(sel) g.A->print_bold(val,(uint8_t)(x+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+F_X,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,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';
fieldRow(s+T_SYM,v,0,4,g.cur==CUR_SYM);
/* the icon stays on whatever the cursor: the help lines end at x = 100,
* before SYM_X */
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 {
g.vrow=0;
if(g.flen){
const uint8_t *f=g.frm;
o=putCall(str,f+7);
emit(o); /* source */
/* ">APZK5,WIDE1-1," / "WIDE2-1": a row breaks after a comma when
* the next call (up to 9 characters) might not fit */
o=str; *o++='>'; o=putCall(o,f);
uint8_t e=13; /* the last address byte */
while(!(f[e]&1u)){
e+=7; *o++=',';
if(o>str+ROW_CHARS+g.cw-9u){ emit(o); o=str; }
o=putCall(o,f+e-6);
}
emit(o);
/* info "!DDMM.hhN/DDDMM.hhE[comment" (built here, always this
* layout): "48 50.90N" / "002 16.25E" (brk) / "/[ " + the comment */
const uint8_t *q=f+e+4u; /* after control, PID and '!' */
uint8_t end=(uint8_t)(g.flen-2u);
o=str;
for(uint8_t k=0;k<18u;k++){
if(k==8u){ o=brk(o); continue; } /* symbol table: later */
if(k==2u||k==12u) *o++=' ';
*o++=(char)q[k];
}
emit(o);
o=str; *o++=(char)q[8]; *o++=(char)q[18]; *o++=' ';
for(uint8_t i=(uint8_t)(q+19u-f);i<end;i++){
*o++=(char)f[i];
if(o==str+ROW_CHARS+g.cw){ emit(o); o=str; }
}
emit(o);
}
/* the scroll limit: the last row fully shown, its blank bit 7 on the
* separator; 0 when compact or when the frame fits */
uint8_t lim=0;
if(!g.cw && g.vrow>4u) lim=(uint8_t)(g.vrow*8u-32u);
g.lim=lim;
/* an up mark while rows hide above, a down mark while rows hide below */
uint8_t mk=0;
if(g.top) mk=1u;
if(g.top<lim) mk+=2u;
if(mk && !g.fArm && g.status!=ST_TX)
A->asset_read((uint16_t)(BMP_SCROLL+(mk-1u)*BMP_SCROLL_W),A->status_line+SCROLL_X,BMP_SCROLL_W);
/* Pixel scroll: lines 0-3 go up top%8 px, the bits below coming from
* the next line (an 8-bit shift of the next line gives 0 at top%8 = 0).
* Line 4 was only the source of line 3: cleared for the frequency. */
uint8_t sh=g.top&7u, *b=A->fb[0];
for(uint16_t k=0;k<512u;k++) b[k]=(uint8_t)((b[k]>>sh)|(b[k+128u]<<(8u-sh)));
memset(b+512,0,128);
/* Keep the source fixed while the body scrolls underneath it, as APRS
* RX does. Any body pixels shifted into line 0 are deliberately hidden. */
if(g.flen){
memset(b,0,128);
*putCall(str,g.frm+7)='\0';
A->print_bold(str,0,0,0);
}
/* dotted separator above the frequency: y = 31, bit 7 of line 3, over
* whatever the shift brought there (the big digits start at y = 33: one
* 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)
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();
/* UP/DOWN, held, out of the editor: scroll 1 px per loop (25 px/s), from
* the source row to the last row fully shown (its blank bit 7 on the
* separator) */
if(!g.edit){
int8_t d=A->nav_dir(key); /* g.lim: draw(), 0 when compact */
if((d<0 && g.top) || (d>0 && g.top<g.lim)){ g.top=(uint8_t)(g.top+d); g.redraw=true; }
}
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(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, 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 */
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(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.sym=g.esym;
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_SYM) 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(back){ if(g.lvl>=LVL_MIN+LVL_STEP) g.lvl=(uint8_t)(g.lvl-LVL_STEP); }
else if(g.lvl<=LVL_MAX-LVL_STEP) g.lvl=(uint8_t)(g.lvl+LVL_STEP);
}
else if(key==APP_KEY_2){
if(back){ if(g.tw>TW_MIN) g.tw--; }
else if(g.tw<TW_MAX) g.tw++;
}
else if(key==APP_KEY_STAR){ g.cw^=CW_COMPACT; g.top=0; } /* the view */
else if(key==APP_KEY_3){
memcpy(g.ed,g.pos,POS_DIGITS);
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;
if(g.status!=ST_NOCALL) g.status=ST_IDLE;
}
}
__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;
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);
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); }
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;
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);
c[11]=g.cw;
c[12]=g.sym;
api->cfg_save(c,CFG_LEN);
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Build one overlay-app blob (.app). Invoked by ../../../compile-app.sh inside
# the uvk1-uvk5v3 Docker image, run from this app's directory. Everything is
# derived from the directory name; only APP_NAME - the human label baked into
# the 64-byte blob header - is per-app. The .app file is named after it (spaces
# stripped), so "Broadcast FM" -> BroadcastFM.app.
set -euo pipefail
APP="$(basename "$PWD")" # breakout, foxhunt, beacon, fm, ...
APP_NAME="APRS TX" # <-- the only per-app line
APP_VER="0.5"
APP_API_MIN=2
APP_VMA=${APP_VMA:-0x20000280} # pinned overlay VMA (Core/py32f071xb.ld)
OUT="${APP_NAME// /}" # blob basename ("Broadcast FM" -> BroadcastFM)
CC=/opt/toolchain/bin/arm-none-eabi-gcc
OBJCOPY=/opt/toolchain/bin/arm-none-eabi-objcopy
command -v arm-none-eabi-gcc >/dev/null 2>&1 && { CC=arm-none-eabi-gcc; OBJCOPY=arm-none-eabi-objcopy; }
CFLAGS="-mcpu=cortex-m0plus -mthumb -Os -fno-jump-tables -std=gnu11 -ffreestanding -fno-builtin -fno-common \
-fomit-frame-pointer -ffunction-sections -fdata-sections -Wall -Wextra"
LDFLAGS="-nostdlib -nostartfiles -T app.ld -Wl,--defsym,APP_VMA=${APP_VMA} \
-Wl,--gc-sections -Wl,-Map=${APP}.map -Wl,--build-id=none -Wl,--no-warn-rwx-segments"
rm -f ./*.app ./*.elf ./*.bin # drop stale artifacts so discovery is unambiguous
step() { printf '\r 🔨 %-13s [%d/4] %-8s' "$APP_NAME" "$1" "$2"; }
trap 'printf "\r ❌ %-13s build failed \n" "$APP_NAME"' ERR
step 1 assets ; APP_VER="$APP_VER" python3 ./gen_assets.py "${APP}_assets.bin" "${APP}_assets.h"
step 2 compile ; "$CC" $CFLAGS $LDFLAGS "${APP}_app.c" -lgcc -o "${APP}.elf"
step 3 objcopy ; "$OBJCOPY" -O binary "${APP}.elf" "${APP}.bin"
step 4 pack ; python3 ../pack_app.py "${APP}.bin" "${OUT}.app" \
--name "$APP_NAME" --ver "$APP_VER" --api-min "$APP_API_MIN" --vma "${APP_VMA}" \
--shortcut none --assets "${APP}_assets.bin" >/dev/null
trap - ERR
BYTES=$(wc -c < "${APP}.bin")
if [ "$BYTES" -gt 4096 ]; then
printf '\r 🚨 %-13s OVERFLOWS 4 KiB (%d B) \n' "$APP_NAME" "$BYTES"; exit 1
fi
printf '\r ✅ %-13s %4d B (%d%% of 4 KiB) -> %s.app \n' \
"$APP_NAME" "$BYTES" "$(( BYTES * 100 / 4096 ))" "$OUT"
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
# APRS TX read-only assets: the screen texts and the fixed part of the frame.
#
# The UI texts come first, each padded to a multiple of 4 bytes: draw() reads
# them in one asset_read into a word-aligned stack block, so every text costs a
# 2-byte sp-relative add instead of a literal-pool load and a pool word.
# The frame parts are AX.25-encoded here (addresses shifted left, SSID bytes):
# the app adds the source (boot-message callsign + SSID), the path (the first 0,
# 1 or 2 entries of WIDE, end-of-address bit set by the app), the position and
# the FCS. SSID, PATH and the position are the defaults, until they are edited
# on the radio. Edit the station settings below, then rebuild;
# test/tx_model.py checks the resulting frame.
#
# ./gen_assets.py aprstx_assets.bin aprstx_assets.h
import os, re, sys
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)
DEST = "APZK5" # APZ = experimental software
PATH = 1 # index in PATHS below
WIDE = ["WIDE1-1", "WIDE2-1"] # path n = the first n entries
PATHS = ["DIRECT", "WIDE1-1", "WIDE1-1,2-1"] # shown on the radio, per path
LAT, LON = "4850.90N", "00216.25E" # default position: DDMM.hhN, DDDMM.hhE
SYMBOL = "/[" # table, code: '[' = person
COMMENT = "UV-K5/K1 F4HWN Firmware"
# info field: "!" + LAT + SYMBOL[0] + LON + SYMBOL[1] + COMMENT
TITLE = "APRS TX"
TX_CAPS = "TRANSMIT" # status-bar capsule while on the air
UI = [
("T_TITLE", TITLE),
("T_TX", TX_CAPS),
("T_DENIED", "TX denied"),
("T_NOCALL", "No boot callsign"),
("T_LVL", "lvl "),
("T_TW", " tw "),
("T_SENT", " sent "),
("T_LAT", "LAT "), # 2 spaces: digits aligned with LON
("T_LON", "LON "),
("T_SSID", "SSID "), # FIELD_COL (5) characters: the value
("T_PATH", "PATH "), # starts at the same column
("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"),
]
def addr(call, last=False):
call, _, ssid = call.upper().partition("-")
if not (1 <= len(call) <= 6 and call.isalnum()):
sys.exit(f"bad AX.25 address {call!r}")
b = bytes(ord(c) << 1 for c in call.ljust(6))
return b + bytes([0x60 | (int(ssid or 0) << 1) | (1 if last else 0)])
if not 0 <= SSID <= 15:
sys.exit("SSID must be 0-15")
if len(WIDE) != 2 or len(PATHS) != len(WIDE) + 1 or not 0 <= PATH < len(PATHS):
sys.exit("PATHS lists DIRECT then 1 and 2 WIDE entries (frame buffer); PATH indexes it")
if any(len(s) > 18 - 5 for s in PATHS):
sys.exit("a PATHS name does not fit the line after 'PATH '")
if not (re.fullmatch(r"\d{4}\.\d{2}[NS]", LAT) and re.fullmatch(r"\d{5}\.\d{2}[EW]", LON)):
sys.exit("LAT must be DDMM.hhN/S, LON DDDMM.hhE/W")
if len(SYMBOL) != 2 or len(COMMENT) > 43:
sys.exit("SYMBOL is 2 characters, COMMENT at most 43 (frame buffer)")
# 13 digits (DDMMhh DDDMMhh), then the hemispheres: bit 0 south, bit 1 west
POS = [int(c) for c in LAT + LON if c.isdigit()]
HEMI = (LAT[-1] == "S") | (LON[-1] == "W") << 1
a = Assets("APRSTX")
ui_size = 0
for name, s in UI:
pad = -(len(s) + 1) % 4 # keep every offset word-aligned
a.text(name, s + "\0" * pad)
ui_size += len(s) + 1 + pad
PATHS_STRIDE = -(-(max(len(s) for s in PATHS) + 1) // 4) * 4 # word-aligned entries
a.table("T_PATHS", PATHS, stride=PATHS_STRIDE)
ui_size += len(PATHS) * PATHS_STRIDE
a.const("UI_SIZE", ui_size) # the UI block read by draw()
a.const("T_TITLE_CHARS", len(TITLE))
a.const("T_TX_CHARS", len(TX_CAPS))
a.const("CFG_SSID", SSID)
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_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)
UP, DN = [0x04, 0x06, 0x07, 0x06, 0x04], [0x10, 0x30, 0x70, 0x30, 0x10]
a.u8("BMP_SCROLL", UP + DN + [u | d for u, d in zip(UP, DN)])
a.const("BMP_SCROLL_W", 5)
a.main()
+312
View File
@@ -0,0 +1,312 @@
#!/usr/bin/env python3
"""Model of the APRS TX app: the frame and bit stream of aprstx_app.c rebuilt
from the real assets (gen_assets.py), checked against the AX.25 reference of
the RX app (ax25.py), then sent as AFSK and decoded by the APRS RX model
(model_rx.py) through a receiver audio path.
The tone generator is modelled with a phase-continuous NCO (expected: REG_71 is
rewritten, not the phase) and, as the worst case, with a phase reset at each
tone change. The TX tone path is assumed flat (no pre-emphasis): a receiver with
de-emphasis (STD path, FT3D) then sees 2200 Hz ~5 dB low, which the app's twist
setting (2200 Hz gain x (8 + tw) / 8) compensates.
tx_model.py runs the checks and the decode sweep
"""
import math, os, re, subprocess, sys, tempfile
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "..", "aprsrx", "test"))
from ax25 import build, hdlc_bits, crc16, decode
import model_rx as M
CALL = "F4HWN" # boot message
TXDELAY_FLAGS, TAIL_FLAGS = 40, 3
LEAD_S = 0.050 + 1 / 1200 # tx_tone's 50 ms settle, then the first bit edge
DEV_MARK = 3000.0 # Hz of deviation for the mark tone at the chosen level
def assets():
d = tempfile.mkdtemp()
b, h = os.path.join(d, "a.bin"), os.path.join(d, "a.h")
subprocess.run([sys.executable, os.path.join(HERE, "..", "gen_assets.py"), b, h], check=True)
defs = dict(re.findall(r"#define (\w+) (\d+)u", open(h).read()))
return open(b, "rb").read(), {k: int(v) for k, v in defs.items()}
def put_coord(d, h):
"""aprstx_app.c putCoord(): 4850.90N / 00216.25E"""
n = len(d)
return "".join(("." if i == n - 2 else "") + str(x) for i, x in enumerate(d)) + h
def pos_ok(d):
"""aprstx_app.c posOk()"""
if any(x > 9 for x in d): return False
la, lam, lo, lom = d[0] * 10 + d[1], d[2] * 10 + d[3], d[6] * 100 + d[7] * 10 + d[8], d[9] * 10 + d[10]
if la > 90 or lam > 59 or lo > 180 or lom > 59: return False
if la == 90 and (lam | d[4] | d[5]): return False
if lo == 180 and (lom | d[11] | d[12]): return False
return True
CW_COMPACT = 14 # aprstx_app.c: the compact view
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), 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, 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):
"""aprstx_app.c editRow(): the text and the bold column (stops: the n
digits, then the hemisphere at cur = n)"""
o, col, n = label, None, len(d)
for i, x in enumerate(d):
if i == n - 4: o += " "
if i == n - 2: o += "."
if i == cur: col = len(o)
o += str(x)
o += " "
if cur == n: col = len(o)
return o + h, col
CUR_NS, CUR_EW, CUR_SSID, CUR_PATH, CUR_SYM = 6, 14, 15, 16, 17
def nav_dir(key, set_nav):
"""app_overlay.c app_nav_dir(): the raw UP ('U') / DOWN ('D') key as a value
direction, +1 / -1 with SET_NAV, -1 / +1 without; 0 for any other key"""
d = 1 if key == "U" else -1 if key == "D" else 0
return d if set_nav else -d
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)
farm = False
for k in keys:
if k == "F":
farm = not farm
continue
back, farm = farm, False
lon = CUR_NS < cur <= CUR_EW
if k.isdigit():
if cur < CUR_NS or (lon and cur < CUR_EW):
ed[cur - lon] = int(k)
cur += 1
if cur == CUR_NS: cur += 1
if cur == CUR_EW: cur -= 1
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 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_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, sym=None):
"""aprstx_app.c build()"""
if not call or len(call) > 6 or "/" in call:
return None
rd = lambda name: blob[D[name]:D[name] + D[name + "_LEN"]]
pd = rd("POS_DEF")
pos = list(pd[:13]) if pos is None else pos
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 = 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))
p = bytearray(rd("F_WIDE")[:7 * path]) # the first path WIDE entries
if path: p[-1] |= 1 # end of the address field
f += p + b"\x03\xF0" + info.encode()
c = 0xFFFF
for x in f:
c ^= x
for _ in range(8):
c = (c >> 1) ^ 0x8408 if c & 1 else c >> 1
c ^= 0xFFFF
return bytes(f + bytes([c & 0xFF, c >> 8]))
def bits_c(frame):
"""aprstx_app.c sendByte()/sendBit(): the bits on the air, before NRZI"""
out, ones = [], 0
def byte(v, stuff):
nonlocal ones
for _ in range(8):
b = v & 1; v >>= 1
out.append(b)
if stuff:
if not b: ones = 0
else:
ones += 1
if ones == 5: out.append(0); ones = 0
for _ in range(TXDELAY_FLAGS): byte(0x7E, False)
ones = 0
for x in frame: byte(x, True)
for _ in range(TAIL_FLAGS): byte(0x7E, False)
return out
def afsk(bits, tw=0, reset=False, twist_path_db=0.0):
"""discriminator output (Hz) of the transmitted AFSK; tw as in the app,
twist_path_db an extra 2200 Hz gain of the TX audio path (unknown)"""
fs = M.FS_SIM
a_sp = DEV_MARK * min(127, 66 * (8 + tw) // 8) / 66 * 10 ** (twist_path_db / 20)
out, ph, space = [], 0.0, False
for i in range(int(LEAD_S * fs)): # plain mark before the flags
ph += 1200 / fs
out.append(DEV_MARK * math.sin(2 * math.pi * ph))
spb = fs / 1200
t = 0.0
for b in bits:
if not b:
space = not space
if reset: ph = 0.0
f, a = (2200, a_sp) if space else (1200, DEV_MARK)
t += spb
while len(out) < int(LEAD_S * fs + t):
ph += f / fs
out.append(a * math.sin(2 * math.pi * ph))
return out
def main():
blob, D = assets()
ok = True
f = build_c(blob, D)
ssid = D["CFG_SSID"]
WIDE = ["WIDE1-1", "WIDE2-1"] # gen_assets.py WIDE
info = "!4850.90N/00216.25E[UV-K5/K1 F4HWN Firmware" # gen_assets.py defaults
ref = build("%s-%d" % (CALL, ssid) if ssid else CALL, dst="APZK5",
path=WIDE[:D["CFG_PATH"]], info=info)
print("frame ", decode(f), "(%d bytes)" % len(f))
ok &= f == ref
print("frame == ax25.build:", f == ref)
bc = bits_c(f)
ok &= bc == hdlc_bits(f, TXDELAY_FLAGS, TAIL_FLAGS)
print("bits == ax25.hdlc_bits:", bc == hdlc_bits(f, TXDELAY_FLAGS, TAIL_FLAGS),
"(%d bits, %.0f ms + %.0f ms lead)" % (len(bc), len(bc) / 1.2, LEAD_S * 1000))
for bad in ("", "F4HWN/P", "F4HWN73"):
ok &= build_c(blob, D, bad) is None
print("bad boot callsigns refused:", True)
# position editor: another position, south/west, the frame and the config
pos = [3, 3, 5, 2, 1, 3, 1, 5, 1, 1, 2, 5, 6] # 33 52.13S 151 12.56W
f2 = build_c(blob, D, pos=pos, hemi=3)
want = build("F4HWN-7", dst="APZK5", path=["WIDE1-1"],
info="!3352.13S/15112.56W[UV-K5/K1 F4HWN Firmware")
ok &= f2 == want
print("edited position frame:", f2 == want, decode(f2))
# SSID and path edited: every path, SSID 0 (no suffix) and 15
for sid, path in ((0, 0), (15, 1), (9, 2)):
f3 = build_c(blob, D, ssid=sid, path=path)
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/K1 F4HWN Firmware")
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) == 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, 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())
rows = (edit_row("LAT ", pos[:6], "S", 2), edit_row("LON ", pos[6:], "W", -1),
edit_row("LAT ", pos[:6], "S", CUR_NS), # N/S in bold
edit_row("LON ", pos[6:], "W", CUR_EW - 7), # E/W in bold
edit_row("LON ", pos[6:], "W", CUR_SSID - 7)) # cursor on SSID: none
ok &= rows == (("LAT 33 52.13 S", 8), ("LON 151 12.56 W", None), ("LAT 33 52.13 S", 14),
("LON 151 12.56 W", 14), ("LON 151 12.56 W", None))
print("editor rows:", rows)
# nav_dir() as app_overlay.c: UP +1 / DOWN -1 with SET_NAV, swapped without
good = [nav_dir(k, sn) for sn in (True, False) for k in "UD*"] == [1, -1, 0, -1, 1, 0]
# 13 digits typed in a row skip N/S and stay on the last digit; the
# navigation keys reach N/S, E/W, SSID and path; '*' acts on the field under
# 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, 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)[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, 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, 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),
([9,0,0,1,0,0,0,0,0,0,0,0,0], False), ([4,8,6,0,0,0,0,0,0,0,0,0,0], False),
([4,8,0,0,0,0,1,8,0,0,0,0,1], False), ([4,8,0,0,0,0,1,8,1,0,0,0,0], False),
([4,8,0,0,0,0,0,0,0,6,0,0,0], False)]
good = all(pos_ok(d) == w for d, w in checks)
ok &= good
print("posOk limits:", good)
print("\n%-6s %-5s %4s %6s %5s | frames (3 seeds)" % ("phase", "rx", "tw", "path", "noise"))
for reset in (False, True):
for mode in ("raw", "std"):
for tw, path_db in ((0, 0.0), (4, 0.0), (0, 5.0)):
for noise in (0, 1500):
w = afsk(bc, tw, reset, path_db)
n = 0
for seed in (1, 2, 3):
adc = M.channel(w, mode, noise, 0, 0, seed)
d = M.Demod()
for s in adc:
d.sample(s)
n += d.frames == [f]
print("%-6s %-5s %4d %+5.0fdB %5d | %d/3" % (
"reset" if reset else "cont", mode, tw, path_db, noise, n))
if not reset and noise == 0:
ok &= n == 3
print("\nALL OK" if ok else "\nFAILURES")
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
+43
View File
@@ -0,0 +1,43 @@
/* Overlay-app link script (POC).
*
* The blob is linked to run at the PY25Q16 sector-cache overlay VMA (shared with
* the multiboot RAM stub — never both active at once). Code + rodata + data +
* bss must all fit in the 4 KiB overlay. objcopy -O binary emits text+rodata+
* data only; the loader zeroes the overlay before copying, so bss starts clean.
*
* VMA must match ADDR(.mb_workspace) in Core/py32f071xb.ld. If the firmware RAM
* layout shifts, update APP_VMA and the loader's compile-time assert catches a
* mismatch. */
/* APP_VMA is the target firmware's __mb_workspace_start (read from its .map). It
* varies with the RAM layout, so pass it from the build: -Wl,--defsym,APP_VMA=0x...
* The default is only a fallback for a standalone size check. */
APP_VMA = DEFINED(APP_VMA) ? APP_VMA : 0x20000280;
APP_LENGTH = 0x1000; /* 4 KiB sector-cache overlay */
ENTRY(app_main)
MEMORY {
APP (rwx) : ORIGIN = APP_VMA, LENGTH = APP_LENGTH
}
SECTIONS {
.app APP_VMA : {
KEEP(*(.text.entry)) /* app_main pinned to offset 0 */
*(.text .text.*)
*(.rodata .rodata.*)
. = ALIGN(4);
*(.data .data.*)
. = ALIGN(4);
__app_bss_start = .;
*(.bss .bss.* COMMON)
. = ALIGN(4);
__app_bss_end = .;
} > APP
__app_end = .;
ASSERT(__app_end <= APP_VMA + APP_LENGTH,
"Beacon overlay app overflows the 4 KiB overlay")
/DISCARD/ : { *(.ARM.exidx*) *(.ARM.extab*) *(.eh_frame*) *(.comment) *(.note.*) }
}
+371
View File
@@ -0,0 +1,371 @@
/* Copyright 2026 Armel F4HWN
* https://github.com/armel
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/*
* Beacon (fox) — overlay app. Ported from App/app/foxhunt.c, beacon sub-mode only.
* Turns the radio into a hidden ARDF transmitter: keys up on the TX VFO and repeats
* a CW fox identifier (MOE..MO5 / MO / "<call> MOE") for the TX window, then stays
* silent for the idle gap. Two keying modes (key 4): TONE (default) keeps the carrier
* up and keys only the tone (MCW / F2A); CARR keys the PA together with the tone, so
* between elements the carrier itself is gone (carrier interruption, ARDF field pattern).
*
* No division is linked: numbers are printed by subtracting powers of ten.
*
* Keys: 1 TX window · 2 idle gap · 3 fox id · 4 keying mode TONE/CARR (F reverses)
* MENU restart idle · long F keypad lock · EXIT quit.
*/
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "../app_api.h"
#include "beacon_assets.h" /* generated by gen_assets.py */
#define LCD_WIDTH 128
#define TICK_MS 50
#define LOCK_HOLD_MS 500
#define MORSE_UNIT 100
#define TONE_HZ 1000
#define CALL_MAX 12
#define IDLE_DEF 30
#define IDLE_MIN 5
#define IDLE_MAX 240
#define IDLE_STEP 5
#define TX_DEF 30
#define TX_MIN 5
#define TX_MAX 60
#define TX_STEP 5
#define FOX_MO 5
#define FOX_CALL 6
#define FOX_COUNT 7
#define MSG_CALL 1
#define MSG_ID 3
#define MSG_ONE 2
#define CFG_MAGIC 0xF4
_Static_assert(IDLE_STEP == 5 && TX_STEP == 5, "loadConfig() checks multiples of 5");
/* Texts, fox identifiers, the Morse table, the powers of ten and icons are
* read-only assets (gen_assets.py); only the displayed item is fetched from
* external flash. */
/* All the state in one struct: Thumb-1 code then reaches every field from a
* single base address (one literal per function instead of one per global),
* byte fields first so their offsets fit ldrb's 0..31 immediate. The overlay
* is zeroed by the loader at each launch. */
struct globals {
bool foxLocked, fArm, fLongDone, running, phaseTx, carrier;
uint8_t beaconIdle, idleLeft, idleTick, beaconTx, secShown, charsSent, foxFox;
uint8_t prevKey;
uint16_t fHoldMs, txMsLeft;
const app_api_t *api;
char foxCall[CALL_MAX+1];
char msg[17];
};
static struct globals g;
#define A (g.api)
_Static_assert(offsetof(struct globals, prevKey) < 32u, "byte fields out of ldrb range");
/* Text from the assets in the caller's buffer (TEXT_MAX bytes). */
static char *T(char *buf,uint16_t off){ A->asset_read(off,buf,TEXT_MAX); return buf; }
/* ---- tiny formatting ---- */
static uint8_t slen(const char *s){ uint8_t n=0; while(s[n])n++; return n; }
static char *put(char *o,const char *s){ while(*s)*o++=*s++; return o; }
/* Decimal digits of v, no leading zeros, by subtracting the powers of ten. */
static char *putu(char *o,uint32_t v){
uint32_t place[PLACE_LEN/4u];
A->asset_read(PLACE,place,sizeof(place));
bool lead=true;
for(uint8_t i=0;i<PLACE_LEN/4u;i++){
uint8_t d=0;
while(v>=place[i]){ v-=place[i]; d++; }
if(d||i==PLACE_LEN/4u-1u) lead=false;
if(!lead) *o++=(char)('0'+d);
}
return o;
}
static void put2(char **o,uint8_t v){ if(v<10)*(*o)++='0'; *o=putu(*o,v); }
static void cpy(uint8_t *d,const uint8_t *s,uint8_t n){ while(n--)*d++=*s++; }
static const char *findsp(const char *s){ while(*s){ if(*s==' ')return s; s++; } return NULL; }
static uint8_t mmin(uint8_t a,uint8_t b){ return a<b?a:b; }
/* Whole seconds left in the TX window, rounded up, by subtraction. */
static uint8_t secsLeft(void){ uint8_t s=0; for(uint32_t n=g.txMsLeft+999u;n>=1000u;n-=1000u) s++; return s; }
static bool multipleOf5(uint8_t v){ while(v>=5u) v-=5u; return v==0u; }
/* ---- settings cycles ---- */
static uint8_t rangeStep(uint8_t v,uint8_t lo,uint8_t hi,uint8_t step,int8_t dir){
if(dir>0) return (v>=hi)?lo:(uint8_t)(v+step);
return (v<=lo)?hi:(uint8_t)(v-step);
}
static bool settingKey(uint8_t key,int8_t dir){
switch(key){
case APP_KEY_1: g.beaconTx =rangeStep(g.beaconTx, TX_MIN, TX_MAX, TX_STEP, dir); return true;
case APP_KEY_2: g.beaconIdle=rangeStep(g.beaconIdle,IDLE_MIN,IDLE_MAX,IDLE_STEP,dir); return true;
case APP_KEY_3:
if(dir>0){ if(++g.foxFox>=FOX_COUNT) g.foxFox=0; }
else g.foxFox=g.foxFox?(uint8_t)(g.foxFox-1u):(uint8_t)(FOX_COUNT-1u);
return true;
case APP_KEY_4: g.carrier=!g.carrier; return true; /* TONE <-> CARR (dir moot) */
default: return false;
}
}
/* ---- message ---- */
static void buildMsg(void){
char t[TEXT_MAX];
char *o=g.msg;
uint8_t id=g.foxFox;
if(g.foxFox==FOX_CALL){ /* "<call> MOE", or "MOE" alone */
id=0;
if(g.foxCall[0]){ o=put(o,g.foxCall); *o++=' '; }
}
o=put(o,T(t,FOX_ID+id*FOX_ID_STRIDE));
*o='\0';
}
static void foxLabel(char *out){
char t[TEXT_MAX];
char *o=put(out,T(t,T_FOX));
o=put(o,T(t,g.foxFox==FOX_CALL?T_CALL:FOX_ID+g.foxFox*FOX_ID_STRIDE));
*o='\0';
}
static uint8_t morseByte(char c){
uint8_t i, code;
if(c>='a'&&c<='z') c-=32;
if(c>='A'&&c<='Z') i=(uint8_t)(c-'A');
else if(c>='0'&&c<='9') i=(uint8_t)(26+c-'0');
else if(c=='/') i=36;
else return 0;
A->asset_read(MORSE+i,&code,1);
return code;
}
/* ---- drawing ---- */
static void tag(const char *s,uint8_t x,uint8_t line){ A->print_inverse(s,x,line,false,true,(uint8_t)(x+slen(s)*4)); }
static void chrome(void){
char s[16], t[TEXT_MAX];
A->display_clear();
A->status_clear();
A->print_inverse(T(t,T_BEACON),2,0,true,true,26);
A->draw_battery();
if(g.foxLocked||g.fArm) A->asset_read(g.foxLocked?BMP_LOCK:BMP_F,A->status_line+70,BMP_F_LEN);
/* the TX frequency (10 Hz units) as MHz with 5 decimals: every digit, then
the point before the last five (f >= 18 MHz: at least 7 digits) */
{ char *e=putu(s,A->tx_freq());
e[1]='\0';
for(uint8_t k=0;k<5u;k++,e--) e[0]=e[-1];
e[0]='.'; }
A->print_normal(s,(uint8_t)(126-slen(s)*7),0,6);
}
static void drawTxSeconds(void){
char s[16], t[TEXT_MAX];
uint8_t sec=secsLeft();
for(uint8_t x=0;x<LCD_WIDTH;x++) A->fb[0][x]=0;
A->print_normal(T(t,T_TX),1,0,0);
char *o=s; put2(&o,sec); *o++='s'; *o='\0';
A->print_normal(s,(uint8_t)(127-slen(s)*7),0,0);
g.secShown=sec;
}
static void drawMessage(uint8_t vis){
const char *msg=g.msg;
const char *sp=findsp(msg);
if(!sp){
uint8_t idLen=slen(msg), v=mmin(vis,idLen);
char id[17]; cpy((uint8_t*)id,(const uint8_t*)msg,v); id[v]='\0';
A->print_string(id,(uint8_t)((LCD_WIDTH-idLen*8u)>>1),0,MSG_ONE,8);
return;
}
uint8_t callLen=(uint8_t)(sp-msg), vc=mmin(vis,callLen);
char call[CALL_MAX+1]; cpy((uint8_t*)call,(const uint8_t*)msg,vc); call[vc]='\0';
A->print_string(call,(uint8_t)((LCD_WIDTH-callLen*8u)>>1),0,MSG_CALL,8);
if(vis>callLen+1){
const char *id=sp+1; uint8_t idLen=slen(id), vi=mmin((uint8_t)(vis-callLen-1),idLen);
char idb[8]; cpy((uint8_t*)idb,(const uint8_t*)id,vi); idb[vi]='\0';
A->print_string(idb,(uint8_t)((LCD_WIDTH-idLen*8u)>>1),0,MSG_ID,8);
}
}
static void updateProgress(uint8_t vis){
const char *sp=findsp(g.msg); uint8_t top;
g.charsSent=vis;
if(!sp) top=MSG_ONE;
else { uint8_t callLen=(uint8_t)(sp-g.msg); if(vis==callLen+1) return; top=(vis<=callLen)?MSG_CALL:MSG_ID; }
for(uint8_t x=0;x<LCD_WIDTH;x++){ A->fb[top][x]=0; A->fb[top+1][x]=0; }
drawMessage(g.charsSent);
A->blit_line(top); A->blit_line(top+1);
}
static void beaconDraw(bool txNow,uint8_t il){
char s[16], t[TEXT_MAX];
chrome();
if(txNow){
A->asset_read(BMP_TX,A->status_line+48,BMP_TX_LEN);
drawTxSeconds();
drawMessage(g.charsSent);
} else {
A->print_string(T(t,T_IDLE),0,127,1,10);
char *o=s; put2(&o,il); *o++='s'; *o='\0';
A->print_string(s,0,127,3,10);
}
char *o=put(s,T(t,T_TX)); *o++=' '; o=putu(o,g.beaconTx); *o++='s'; *o='\0'; tag(s,4,5);
o=put(s,T(t,T_IDLE)); *o++=' '; o=putu(o,g.beaconIdle); *o++='s'; *o='\0'; tag(s,4,6);
foxLabel(s); tag(s,66,5);
{ const char *m=T(t,g.carrier?T_CARR:T_TONE); tag(m,(uint8_t)(125-slen(m)*4),5); }
}
static void blit(void){ A->blit_status(); A->blit_full(); }
/* ---- keypad-lock long-press ---- */
/* true exactly on the lock/unlock toggle, so a caller that does not redraw every tick
* (the TX loop) can refresh the padlock at once. */
static bool lockTrack(uint8_t key,uint16_t ms){
if(key!=APP_KEY_F){ g.fHoldMs=0; g.fLongDone=false; return false; }
if(g.fLongDone) return false;
g.fHoldMs+=ms;
if(g.fHoldMs>=LOCK_HOLD_MS){ g.fLongDone=true; g.foxLocked=!g.foxLocked; g.fArm=false; A->backlight_on(); return true; }
return false;
}
/* ---- interactive TX delay: drains the window, keys, live seconds. true = abort ---- */
static bool txDelay(uint16_t ms){
while(ms){
uint16_t slice=(ms>10)?10:ms;
A->delay_ms(slice); ms-=slice;
g.txMsLeft=(g.txMsLeft>slice)?(uint16_t)(g.txMsLeft-slice):0;
A->backlight_update();
if(secsLeft()!=g.secShown){ drawTxSeconds(); A->blit_line(0); }
uint8_t key=A->get_key();
if(lockTrack(key,slice)){ beaconDraw(true,0); blit(); } /* show the padlock at once, mid-TX */
if(key==APP_KEY_INVALID||key==g.prevKey){ g.prevKey=key; continue; }
g.prevKey=key;
A->backlight_on();
if(g.foxLocked) continue;
if(key==APP_KEY_EXIT){ g.running=false; return true; }
if(key==APP_KEY_MENU) return true; /* cut short, restart idle */
if(key==APP_KEY_F){ g.fArm=!g.fArm; beaconDraw(true,0); blit(); continue; }
if(settingKey(key,g.fArm?-1:1)){ g.fArm=false; beaconDraw(true,0); blit(); }
}
return false;
}
/* Keying: both modes key the tone; CARR gates the PA in lockstep so the carrier is truly
* gone between elements (tone muted too, else it bleeds through residual PA leakage). */
static void keyOn(void){ A->tx_mute(false); A->tx_carrier(true); } /* carrier up both modes; re-arms after a mid-window switch */
static void keyOff(void){ A->tx_mute(true); if(g.carrier) A->tx_carrier(false); }
static bool morseChar(char c,uint8_t vis){
uint8_t code=morseByte(c);
if(code==0){ if(txDelay(MORSE_UNIT*4)) return true; updateProgress(vis); return false; }
uint8_t bit=0x80; while(!(code&bit))bit>>=1;
for(bit>>=1;bit;bit>>=1){
uint16_t on=(code&bit)?(MORSE_UNIT*3):MORSE_UNIT;
keyOn();
if(txDelay(on)){ keyOff(); return true; }
keyOff();
if(txDelay(MORSE_UNIT)) return true;
}
updateProgress(vis);
return txDelay(MORSE_UNIT*2);
}
static void transmit(void){
A->tx_set_params();
A->tx_tone(TONE_HZ);
A->tx_mute(true); /* open muted (both modes) */
if(g.carrier) A->tx_carrier(false); /* CARR: carrier off until first element */
g.txMsLeft=(uint16_t)g.beaconTx*1000u;
bool stop=false;
while(!stop && g.txMsLeft>0){
buildMsg();
g.charsSent=0;
beaconDraw(true,0); blit();
for(uint8_t i=0;!stop && g.msg[i];i++) stop=morseChar(g.msg[i],(uint8_t)(i+1));
if(!stop && g.txMsLeft>0) stop=txDelay(MORSE_UNIT*7);
}
A->tx_mute(true);
A->tx_end(); /* PA off + restore RX */
}
/* ---- idle-phase keys ---- */
static void idleKeys(void){
uint8_t key=A->get_key();
lockTrack(key,TICK_MS);
if(key==APP_KEY_INVALID||key==g.prevKey){ g.prevKey=key; return; }
g.prevKey=key;
A->backlight_on();
if(g.foxLocked) return;
if(key==APP_KEY_F){ g.fArm=!g.fArm; return; }
switch(key){
case APP_KEY_EXIT: g.running=false; break;
case APP_KEY_MENU: g.idleLeft=g.beaconIdle; g.idleTick=0; break;
default: settingKey(key,g.fArm?-1:1); break;
}
g.fArm=false;
}
static void tickDelay(void){ for(uint8_t i=0;i<TICK_MS/10;i++){ A->delay_ms(10); A->backlight_update(); } }
/* ---- config (deferred) ---- */
static void loadConfig(void){
uint8_t c[5]; A->cfg_load(c,5);
if(c[0]==CFG_MAGIC){
if(c[1]>=IDLE_MIN&&c[1]<=IDLE_MAX&&multipleOf5(c[1])) g.beaconIdle=c[1];
if(c[2]<FOX_COUNT) g.foxFox=c[2];
if(c[3]>=TX_MIN&&c[3]<=TX_MAX&&multipleOf5(c[3])) g.beaconTx=c[3];
if(c[4]<=1) g.carrier=c[4]; /* erased (0xFF) legacy config -> keep default */
}
}
static void saveConfig(void){ uint8_t c[5]={CFG_MAGIC,g.beaconIdle,g.foxFox,g.beaconTx,(uint8_t)g.carrier}; A->cfg_save(c,5); }
static void txDenied(void){
char t[TEXT_MAX];
chrome();
A->print_string(T(t,T_TX_OFF),0,127,1,8);
blit();
for(uint8_t i=0;i<20;i++) tickDelay();
}
__attribute__((section(".text.entry"),used))
void app_main(const app_api_t *api){
A=api;
/* locks, holds and the TONE keying mode start zeroed (.bss) */
g.beaconIdle=IDLE_DEF; g.beaconTx=TX_DEF; g.foxFox=FOX_CALL;
g.prevKey=APP_KEY_INVALID;
A->boot_callsign(g.foxCall,sizeof(g.foxCall));
loadConfig();
A->backlight_on();
g.phaseTx=true; g.idleLeft=g.beaconIdle; g.idleTick=0;
g.running=true;
while(g.running){
if(g.phaseTx){
if(A->tx_state()!=0){ txDenied(); g.phaseTx=false; g.idleLeft=g.beaconIdle; g.idleTick=0; continue; }
transmit();
g.phaseTx=false; g.idleLeft=g.beaconIdle; g.idleTick=0;
continue;
}
idleKeys();
if(!g.running) break;
beaconDraw(false,g.idleLeft); blit();
if(++g.idleTick>=(1000/TICK_MS)){ g.idleTick=0; if(g.idleLeft>0)g.idleLeft--; if(g.idleLeft==0)g.phaseTx=true; }
A->battery_sample();
tickDelay();
}
saveConfig();
A->tx_end(); /* safety: PA off + RX restored */
A->audio_path(false);
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Build one overlay-app blob (.app). Invoked by ../../../compile-app.sh inside
# the uvk1-uvk5v3 Docker image, run from this app's directory. Everything is
# derived from the directory name; only APP_NAME - the human label baked into
# the 64-byte blob header - is per-app. The .app file is named after it (spaces
# stripped), so "Broadcast FM" -> BroadcastFM.app.
set -euo pipefail
APP="$(basename "$PWD")" # breakout, foxhunt, beacon, fm, ...
APP_NAME="Beacon" # <-- the only per-app line
APP_VER="1.1"
APP_API_MIN=2
APP_VMA=${APP_VMA:-0x20000280} # pinned overlay VMA (Core/py32f071xb.ld)
OUT="${APP_NAME// /}" # blob basename ("Broadcast FM" -> BroadcastFM)
CC=/opt/toolchain/bin/arm-none-eabi-gcc
OBJCOPY=/opt/toolchain/bin/arm-none-eabi-objcopy
command -v arm-none-eabi-gcc >/dev/null 2>&1 && { CC=arm-none-eabi-gcc; OBJCOPY=arm-none-eabi-objcopy; }
CFLAGS="-mcpu=cortex-m0plus -mthumb -Os -std=gnu11 -ffreestanding -fno-builtin -fno-common \
-fomit-frame-pointer -ffunction-sections -fdata-sections -Wall -Wextra"
LDFLAGS="-nostdlib -nostartfiles -T app.ld -Wl,--defsym,APP_VMA=${APP_VMA} \
-Wl,--gc-sections -Wl,-Map=${APP}.map -Wl,--build-id=none -Wl,--no-warn-rwx-segments"
rm -f ./*.app ./*.elf ./*.bin # drop stale artifacts so discovery is unambiguous
step() { printf '\r 🔨 %-13s [%d/4] %-8s' "$APP_NAME" "$1" "$2"; }
trap 'printf "\r ❌ %-13s build failed \n" "$APP_NAME"' ERR
step 1 assets ; python3 ./gen_assets.py "${APP}_assets.bin" "${APP}_assets.h"
step 2 compile ; "$CC" $CFLAGS $LDFLAGS "${APP}_app.c" -lgcc -o "${APP}.elf"
step 3 objcopy ; "$OBJCOPY" -O binary "${APP}.elf" "${APP}.bin"
step 4 pack ; python3 ../pack_app.py "${APP}.bin" "${OUT}.app" \
--name "$APP_NAME" --ver "$APP_VER" --api-min "$APP_API_MIN" --vma "${APP_VMA}" \
--shortcut beacon --assets "${APP}_assets.bin" >/dev/null
trap - ERR
BYTES=$(wc -c < "${APP}.bin")
if [ "$BYTES" -gt 4096 ]; then
printf '\r 🚨 %-13s OVERFLOWS 4 KiB (%d B) \n' "$APP_NAME" "$BYTES"; exit 1
fi
printf '\r ✅ %-13s %4d B (%d%% of 4 KiB) -> %s.app \n' \
"$APP_NAME" "$BYTES" "$(( BYTES * 100 / 4096 ))" "$OUT"
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env python3
# Beacon read-only assets: texts, fox identifiers, Morse table and icons.
#
# ./gen_assets.py beacon_assets.bin beacon_assets.h
import os, sys
sys.dont_write_bytecode = True
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from app_assets import Assets
a = Assets("BEACON")
a.text("T_BEACON", "BEACON")
a.text("T_TX", "TX")
a.text("T_IDLE", "IDLE")
a.text("T_FOX", "FOX ")
a.text("T_CALL", "CALL")
a.text("T_CARR", "CARR")
a.text("T_TONE", "TONE")
a.text("T_TX_OFF", "TX OFF")
# Fox identifiers by foxFox (0..FOX_MO); FOX_CALL sends "<call> " + entry 0.
a.table("FOX_ID", ["MOE", "MOI", "MOS", "MOH", "MO5", "MO"])
# Sentinel-prefixed Morse (leading 1, then elements MSB-first: 0 dit, 1 dah):
# A..Z, then 0..9, then '/'.
a.u8("MORSE", [0x05,0x18,0x1A,0x0C,0x02,0x12,0x0E,0x10,0x04,0x17,0x0D,0x14,0x07,
0x06,0x0F,0x16,0x1D,0x0A,0x08,0x03,0x09,0x11,0x0B,0x19,0x1B,0x1C,
0x3F,0x2F,0x27,0x23,0x21,0x20,0x30,0x38,0x3C,0x3E,
0x32])
a.u8("BMP_TX", [0x1c,0x22,0x41,0x1c,0x22,0x00,0x08,0x1c,0x1c,0x08,0x00,0x22,0x1c,0x41,0x22,0x1c])
a.u8("BMP_LOCK", [0x7c,0x46,0x45,0x45,0x45,0x45,0x45,0x46,0x7c])
a.u8("BMP_F", [0x3e,0x7f,0x41,0x75,0x75,0x75,0x7d,0x7f,0x3e])
# Powers of ten, 10^8 down to 1: numbers (the frequency too) are printed by
# subtraction, so the app links no division.
a.u32("PLACE", [10 ** k for k in range(8, -1, -1)])
a.main()
+30
View File
@@ -0,0 +1,30 @@
/* Overlay-app link script. */
APP_VMA = DEFINED(APP_VMA) ? APP_VMA : 0x20000280;
APP_LENGTH = 0x1000;
ENTRY(app_main)
MEMORY {
APP (rwx) : ORIGIN = APP_VMA, LENGTH = APP_LENGTH
}
SECTIONS {
.app APP_VMA : {
KEEP(*(.text.entry))
*(.text .text.*)
*(.rodata .rodata.*)
. = ALIGN(4);
*(.data .data.*)
. = ALIGN(4);
__app_bss_start = .;
*(.bss .bss.* COMMON)
. = ALIGN(4);
__app_bss_end = .;
} > APP
__app_end = .;
ASSERT(__app_end <= APP_VMA + APP_LENGTH,
"BEAM overlay app overflows the 4 KiB overlay")
/DISCARD/ : { *(.ARM.exidx*) *(.ARM.extab*) *(.eh_frame*) *(.comment) *(.note.*) }
}
+327
View File
@@ -0,0 +1,327 @@
/* Copyright 2026 Armel F4HWN
* Licensed under the Apache License, Version 2.0.
*
* BEAM overlay app. Packet version 2 remains wire-compatible with the
* resident implementation: one selected VFO is sent and a received VFO is
* stored in the first free memory channel.
*
* Requires APP_CAP_BEAM2: the resident bridge only tunes the channel and
* converts it to/from app_beam_channel_t; this app drives the BK4819 FSK modem
* itself, from register sequences stored in its assets.
*/
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "../app_api.h"
#include "beam_assets.h" /* generated by gen_assets.py */
#define PACKET_MAGIC 0xBEA5u
#define PACKET_VERSION 2u
#define PACKET_START 0xABCDu
#define PACKET_END 0xDCBAu
#define PACKET_WORDS 36u
#define STORE_WORDS 18u /* 72 bytes, 4-byte aligned for the payload */
/* BK4819 registers and REG_02 interrupt flags (mirror driver/bk4819-regs.h). */
enum {
REG_02 = 0x02, /* interrupt status */
REG_0B = 0x0B, /* FSK status: bit 4 = CRC error */
REG_0C = 0x0C, /* bit 0 = interrupt pending */
REG_5F = 0x5F, /* FSK data FIFO */
};
#define IRQ_FSK_FIFO_ALMOST_FULL (1u << 12)
#define IRQ_FSK_RX_FINISHED (1u << 13)
/* Largest register sequence (asset_read into a stack buffer). */
#define SEQ_MAX_LEN SEQ_RX_LEN
_Static_assert(SEQ_RESET_LEN <= SEQ_MAX_LEN && SEQ_SETUP_LEN <= SEQ_MAX_LEN &&
SEQ_TX_LOAD_LEN <= SEQ_MAX_LEN && SEQ_TX_GO_LEN <= SEQ_MAX_LEN &&
SEQ_TX_END_LEN <= SEQ_MAX_LEN, "SEQ_MAX_LEN too small");
enum {
STATUS_READY = 0,
STATUS_TX_WAIT,
STATUS_TX_DONE,
STATUS_RX_WAIT,
STATUS_RX_SAVED,
STATUS_RX_FULL,
STATUS_ERROR,
};
typedef struct {
uint16_t magic;
uint8_t version;
uint8_t pad;
app_beam_channel_t channel;
} beam_payload_t;
_Static_assert(sizeof(beam_payload_t) == 48u,
"BEAM v2 wire payload layout changed");
/* All the state in one struct: Thumb-1 code then reaches every field from a
* single base address (one literal per function instead of one per global).
* The packet buffer lives on app_main's stack, outside the 4 KiB overlay; the
* overlay itself is zeroed by the loader at each launch. */
struct globals {
uint8_t mode, status;
uint8_t fsk_index; /* next RX FIFO word of the packet */
bool receiving, running, dirty;
uint16_t copiedChannel;
const app_api_t *api;
uint32_t *packet_store; /* STORE_WORDS words, on app_main's stack */
};
static struct globals g;
#define A (g.api)
static uint16_t *packet(void) { return (uint16_t *)g.packet_store; }
static beam_payload_t *payload(void) { return (beam_payload_t *)&packet()[2]; }
static void clear_bytes(void *ptr, uint8_t count)
{
uint8_t *p = (uint8_t *)ptr;
while (count--) *p++ = 0;
}
static uint16_t crc16(const void *buffer, uint16_t size)
{
const uint8_t *data = (const uint8_t *)buffer;
uint16_t crc = 0;
while (size--) {
crc ^= (uint16_t)*data++ << 8;
for (uint8_t bit = 0; bit < 8u; bit++)
crc = (crc & 0x8000u) ? (uint16_t)((crc << 1) ^ 0x1021u)
: (uint16_t)(crc << 1);
}
return crc;
}
static void obfuscate(void)
{
uint16_t key[8]; /* obfuscation key, from the assets */
uint16_t *p = packet();
A->asset_read(OBFUSCATION, key, sizeof(key));
for (uint8_t i = 0; i < 32u; i++)
p[i + 1u] ^= key[i & 7u];
}
/* Run one register sequence: (register, value) word pairs, a SEQ_DELAY
* register meaning "wait value ms". */
static void bk_seq(uint16_t offset, uint16_t len)
{
uint16_t seq[SEQ_MAX_LEN / 2u];
A->asset_read(offset, seq, len);
for (const uint16_t *w = seq; w < seq + len / 2u; w += 2) {
if (w[0] == SEQ_DELAY)
A->delay_ms(w[1]);
else
A->bk_write((uint8_t)w[0], w[1]);
}
}
#define SEQ(name) bk_seq(name, name##_LEN)
/* Key up, send one packet (BK4819_SendFSKData), then back to RX. */
static void fsk_send(const uint16_t *p)
{
A->tx_set_params();
SEQ(SEQ_TX_LOAD);
for (uint8_t i = 0; i < PACKET_WORDS; i++)
A->bk_write(REG_5F, p[i]);
SEQ(SEQ_TX_GO);
/* TX-finished poll ceiling in 5 ms ticks: ~3 per word plus margin. */
for (uint16_t t = PACKET_WORDS * 3u + 100u; t && !(A->bk_read(REG_0C) & 1u); t--)
A->delay_ms(5);
SEQ(SEQ_TX_END);
A->tx_end();
}
static void fsk_rx_start(void)
{
g.fsk_index = 0;
SEQ(SEQ_RX);
}
/* Drain the RX FIFO into the packet; APP_BEAM_RX_* once complete. */
static uint8_t fsk_rx_poll(uint16_t *p)
{
while (A->bk_read(REG_0C) & 1u) {
A->bk_write(REG_02, 0);
const uint16_t irq = A->bk_read(REG_02);
if (irq & (IRQ_FSK_FIFO_ALMOST_FULL | IRQ_FSK_RX_FINISHED)) {
uint8_t words = (irq & IRQ_FSK_RX_FINISHED)
? (uint8_t)(PACKET_WORDS - g.fsk_index) : 4u;
while (words--) {
const uint16_t word = A->bk_read(REG_5F);
if (g.fsk_index < PACKET_WORDS)
p[g.fsk_index++] = word;
}
}
}
if (g.fsk_index < PACKET_WORDS)
return APP_BEAM_RX_WAIT;
const uint16_t status = A->bk_read(REG_0B);
fsk_rx_start();
return (status & 0x0010u) ? APP_BEAM_RX_ERROR : APP_BEAM_RX_READY;
}
static void draw(void)
{
/* T_STATE: READY in TX / RX mode, then one entry per later status. */
char state[T_STATE_STRIDE];
const uint8_t index = g.status ? (uint8_t)(g.status + 1u) : g.mode;
A->asset_read(T_STATE + index * T_STATE_STRIDE, state, sizeof(state));
A->beam_draw(state);
A->blit_status();
A->blit_full();
}
static void stop_rx(void)
{
if (g.receiving) {
SEQ(SEQ_RESET);
g.receiving = false;
}
}
static void send_packet(void)
{
uint16_t *p = packet();
clear_bytes(g.packet_store, STORE_WORDS * 4u);
p[0] = PACKET_START;
payload()->magic = PACKET_MAGIC;
payload()->version = PACKET_VERSION;
A->beam_get(&payload()->channel);
p[34] = crc16(&p[1], 2u + 64u);
p[35] = PACKET_END;
obfuscate();
g.status = STATUS_TX_WAIT;
draw();
fsk_send(p);
g.status = STATUS_TX_DONE;
for (uint8_t i = 0; i < 3u; i++) {
A->play_tone(880, 60);
if (i < 2u) A->delay_ms(20);
}
g.dirty = true;
}
static void start(void)
{
stop_rx();
/* A channel save is committed only after app_main() returns. Do not let a
second RX overwrite that pending save; exit and relaunch to receive more. */
if (g.mode && g.copiedChannel != 0xFFFFu)
return;
A->beam_prepare();
SEQ(SEQ_SETUP);
if (!g.mode) {
send_packet();
return;
}
fsk_rx_start();
g.receiving = true;
g.status = STATUS_RX_WAIT;
g.dirty = true;
}
static bool valid_packet(void)
{
uint16_t *p = packet();
if (p[0] != PACKET_START || p[35] != PACKET_END)
return false;
obfuscate();
if (p[34] != crc16(&p[1], 2u + 64u))
return false;
return payload()->magic == PACKET_MAGIC && payload()->version == PACKET_VERSION;
}
static void poll_rx(void)
{
if (!g.receiving)
return;
const uint8_t result = fsk_rx_poll(packet());
if (result == APP_BEAM_RX_WAIT)
return;
if (result == APP_BEAM_RX_ERROR || !valid_packet()) {
g.status = STATUS_ERROR;
A->backlight_on();
g.dirty = true;
return;
}
g.copiedChannel = A->beam_save(&payload()->channel);
stop_rx();
g.status = g.copiedChannel == 0xFFFFu ? STATUS_RX_FULL : STATUS_RX_SAVED;
A->backlight_on();
g.dirty = true;
}
static void key_press(uint8_t key)
{
switch (key) {
case APP_KEY_UP:
case APP_KEY_DOWN:
stop_rx();
g.mode ^= 1u;
g.status = STATUS_READY;
g.dirty = true;
break;
case APP_KEY_MENU:
start();
break;
case APP_KEY_EXIT:
g.running = false;
break;
case APP_KEY_PTT:
break;
default:
A->play_tone(500, 60);
break;
}
}
__attribute__((section(".text.entry"), used))
void app_main(const app_api_t *api)
{
uint32_t store[STORE_WORDS]; /* the packet: 4-byte aligned, not in the overlay */
A = api;
g.packet_store = store;
/* TX mode, READY and not receiving: zeroed (.bss) */
g.copiedChannel = 0xFFFFu;
g.running = true;
g.dirty = true;
A->backlight_on();
uint8_t previous = APP_KEY_INVALID;
uint8_t batteryTicks = 0;
while (g.running) {
const uint8_t key = A->get_key();
if (key == APP_KEY_SAVER) {
previous = APP_KEY_INVALID;
} else if (key == APP_KEY_WAKE) {
previous = APP_KEY_INVALID;
g.dirty = true;
} else {
if (key != previous && key != APP_KEY_INVALID) {
A->backlight_on();
key_press(key);
}
previous = key;
}
poll_rx();
if (g.dirty) { draw(); g.dirty = false; }
A->delay_ms(10);
A->backlight_update();
if (++batteryTicks >= 50u) {
batteryTicks = 0;
A->battery_sample();
g.dirty = true;
}
}
SEQ(SEQ_RESET); /* stop the FSK modem (RX or idle) before returning */
}
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
set -euo pipefail
APP="$(basename "$PWD")"
APP_NAME="Beam"
APP_VER="1.3"
APP_API_MIN=2
APP_VMA=${APP_VMA:-0x20000280}
OUT="${APP_NAME// /}"
CC=/opt/toolchain/bin/arm-none-eabi-gcc
OBJCOPY=/opt/toolchain/bin/arm-none-eabi-objcopy
command -v arm-none-eabi-gcc >/dev/null 2>&1 && { CC=arm-none-eabi-gcc; OBJCOPY=arm-none-eabi-objcopy; }
CFLAGS="-mcpu=cortex-m0plus -mthumb -Os -std=gnu11 -ffreestanding -fno-builtin -fno-common \
-fomit-frame-pointer -ffunction-sections -fdata-sections -Wall -Wextra"
LDFLAGS="-nostdlib -nostartfiles -T app.ld -Wl,--defsym,APP_VMA=${APP_VMA} \
-Wl,--gc-sections -Wl,-Map=${APP}.map -Wl,--build-id=none -Wl,--no-warn-rwx-segments"
rm -f ./*.app ./*.elf ./*.bin
step() { printf '\r 🔨 %-13s [%d/4] %-8s' "$APP_NAME" "$1" "$2"; }
trap 'printf "\r ❌ %-13s build failed \n" "$APP_NAME"' ERR
step 1 assets ; python3 ./gen_assets.py "${APP}_assets.bin" "${APP}_assets.h"
step 2 compile ; "$CC" $CFLAGS $LDFLAGS "${APP}_app.c" -lgcc -o "${APP}.elf"
step 3 objcopy ; "$OBJCOPY" -O binary "${APP}.elf" "${APP}.bin"
step 4 pack ; python3 ../pack_app.py "${APP}.bin" "${OUT}.app" \
--name "$APP_NAME" --ver "$APP_VER" --api-min "$APP_API_MIN" --vma "${APP_VMA}" \
--shortcut beam --exit-main --require beam2 --assets "${APP}_assets.bin" >/dev/null
trap - ERR
BYTES=$(wc -c < "${APP}.bin")
if [ "$BYTES" -gt 4096 ]; then
printf '\r 🚨 %-13s OVERFLOWS 4 KiB (%d B) \n' "$APP_NAME" "$BYTES"; exit 1
fi
printf '\r ✅ %-13s %4d B (%d%% of 4 KiB) -> %s.app \n' \
"$APP_NAME" "$BYTES" "$(( BYTES * 100 / 4096 ))" "$OUT"
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
# Beam read-only assets: status texts, the packet obfuscation key and the
# BK4819 FSK register sequences run by the app (APP_CAP_BEAM2).
#
# ./gen_assets.py beam_assets.bin beam_assets.h
import os, sys
sys.dont_write_bytecode = True
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from app_assets import Assets
a = Assets("BEAM")
# Indexed by beam_app.c: 0/1 = READY in TX/RX mode, then status + 1.
a.table("T_STATE", ["BEAM TX", "BEAM RX", "SENDING", "SENT", "WAITING",
"RECEIVED", "MEM FULL", "ERROR"])
a.u16("OBFUSCATION", [0x6C16, 0xE614, 0x912E, 0x400D, 0x3521, 0x40D5, 0x0313, 0x80E9])
# Register sequences: (register, value) word pairs; register SEQ_DELAY means
# "wait value ms". Each one mirrors the resident BK4819 driver routine noted.
SEQ_DELAY = 0xFF
a.const("SEQ_DELAY", SEQ_DELAY)
def seq(name, pairs):
a.u16(name, [w for pair in pairs for w in pair])
RESET = [ # BK4819_ResetFSK
(0x3F, 0x0000), # disable interrupts
(0x59, 0x0068), # sync length 4 bytes, 7 byte preamble
(SEQ_DELAY, 30),
(0x30, 0x0000), # BK4819_Idle
]
seq("SEQ_RESET", RESET)
seq("SEQ_SETUP", [ # BK4819_SetupAircopy + BK4819_ResetFSK
(0x70, 0x00C3), # enable Tone2, tuning gain 48
(0x72, 0x3065), # Tone2 baudrate 1200
(0x58, 0x00C1), # FSK enable, 1.2K RX bandwidth, TX/RX FSK 1.2K
(0x5C, 0x5665), # enable CRC
(0x5D, 0x4700), # FSK data length 72 bytes
(0x5E, 0x3204),
] + RESET)
seq("SEQ_RX", RESET + [ # BK4819_PrepareFSKReceive
(0x02, 0x0000),
(0x3F, 0x0000),
(0x37, 0x9F1F), # BK4819_RX_TurnOn
(0x30, 0x0000),
(0x30, 0xBFF1),
(0x3F, 0x3000), # FSK RX finished | FIFO almost full interrupts
(0x59, 0x4068), # clear RX FIFO
(0x59, 0x3068), # scramble + FSK RX enable
])
seq("SEQ_TX_LOAD", [ # BK4819_SendFSKData, before the FIFO fill
(SEQ_DELAY, 30),
(0x3F, 0x8000), # FSK TX finished interrupt
(0x59, 0x8068), # clear TX FIFO
(0x59, 0x0068),
])
seq("SEQ_TX_GO", [ # BK4819_SendFSKData, after the FIFO fill
(SEQ_DELAY, 20),
(0x59, 0x2868), # FSK TX enable
])
seq("SEQ_TX_END", [ # BK4819_SendFSKData tail + PA bias off
(0x02, 0x0000),
(SEQ_DELAY, 30),
] + RESET + [
(0x36, 0x0088), # BK4819_SetupPowerAmplifier(0, 0)
])
a.main()
+43
View File
@@ -0,0 +1,43 @@
/* Overlay-app link script (POC).
*
* The blob is linked to run at the PY25Q16 sector-cache overlay VMA (shared with
* the multiboot RAM stub — never both active at once). Code + rodata + data +
* bss must all fit in the 4 KiB overlay. objcopy -O binary emits text+rodata+
* data only; the loader zeroes the overlay before copying, so bss starts clean.
*
* VMA must match ADDR(.mb_workspace) in Core/py32f071xb.ld. If the firmware RAM
* layout shifts, update APP_VMA and the loader's compile-time assert catches a
* mismatch. */
/* APP_VMA is the target firmware's __mb_workspace_start (read from its .map). It
* varies with the RAM layout, so pass it from the build: -Wl,--defsym,APP_VMA=0x...
* The default is only a fallback for a standalone size check. */
APP_VMA = DEFINED(APP_VMA) ? APP_VMA : 0x20000280;
APP_LENGTH = 0x1000; /* 4 KiB sector-cache overlay */
ENTRY(app_main)
MEMORY {
APP (rwx) : ORIGIN = APP_VMA, LENGTH = APP_LENGTH
}
SECTIONS {
.app APP_VMA : {
KEEP(*(.text.entry)) /* app_main pinned to offset 0 */
*(.text .text.*)
*(.rodata .rodata.*)
. = ALIGN(4);
*(.data .data.*)
. = ALIGN(4);
__app_bss_start = .;
*(.bss .bss.* COMMON)
. = ALIGN(4);
__app_bss_end = .;
} > APP
__app_end = .;
ASSERT(__app_end <= APP_VMA + APP_LENGTH,
"Breakout overlay app overflows the 4 KiB overlay")
/DISCARD/ : { *(.ARM.exidx*) *(.ARM.extab*) *(.eh_frame*) *(.comment) *(.note.*) }
}
+380
View File
@@ -0,0 +1,380 @@
/* Copyright 2025 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.
*/
/*
* Breakout — overlay-app port (POC).
*
* Self-contained: no firmware headers, no libc. Every resident service is
* reached through the app_api_t table; the entry point app_main() is forced to
* blob offset 0 via section ".text.entry". The loader zeroes the 4 KiB overlay,
* copies this blob in, and calls app_main(&api).
*
* No division is linked (libgcc's two would cost ~740 B): numbers are printed
* by subtracting decimal places, bounce directions come from tables computed
* by gen_assets.py, and the wall is cleared by counting the bricks left.
*/
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "../app_api.h"
#include "breakout_assets.h" /* generated by gen_assets.py */
#define LCD_WIDTH 128
#define BRICK_NUMBER 18
#define BALL_NUMBER 5
#define BRICK_WIDTH 14
#define BRICK_HEIGHT 5
#define BALL_WIDTH 3
#define BALL_HEIGHT 3
#define RACKET_WIDTH 24
#define RACKET_HEIGHT 2
#define RACKET_Y 50
#define TICK_MS 20u
#define STR_LEN 12u /* "Score " + 5 digits + NUL */
typedef struct { uint8_t x; uint8_t y; bool destroy; } Brick;
typedef struct { uint8_t x; uint8_t p; } Racket; /* x: 0..102, even */
typedef struct { int16_t x; int8_t y; int8_t dx; int8_t dy; } Ball;
/* ---- tiny freestanding helpers (replace libc / libgcc-free where cheap) ---- */
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;
}
static int iabs(int v) { return v < 0 ? -v : v; }
static int imin(int a, int b) { return a < b ? a : b; }
/* ---- all the state in one struct: Thumb-1 code then reaches every field
* from a single base address (one literal per function instead of one per
* global), byte fields first so their offsets fit ldrb's 0..31 immediate.
* It lives in the overlay, which the loader zeroes at each launch. ---- */
struct globals {
uint8_t blockAnim, titleTick, levelCountBreakout, gameOverLevel;
uint8_t kbdPrev, kbdCur, wallLeft;
bool isInitialized, isPaused, saverPaused, saverActive;
bool gameOver, redrawRequired, title;
Racket racket;
uint16_t tone, score, gameOverScore;
int16_t ballCount;
Ball ball;
const app_api_t *api;
Brick brick[BRICK_NUMBER];
};
static struct globals g;
#define A (g.api)
_Static_assert(offsetof(struct globals, title) < 32u, "byte fields out of ldrb range");
/* A random ball dx in -3..3, by multiplication rather than a modulo. */
static int rand_dx(void) { return (int)(((A->rand32() >> 24) * 7u) >> 8) - 3; }
static void reset(void) { g.ballCount = BALL_NUMBER; g.levelCountBreakout = 1; g.score = 0; }
static void playBeep(uint16_t t) { A->play_tone(t, 100); }
/* "<label><value>" in tiny type on the status line: the value zero-padded to
* w digits, or longer if it needs more (up to 5, no division). */
static void stat(uint16_t label, uint16_t v, uint8_t w, uint8_t x) {
char s[STR_LEN];
uint16_t place[5];
A->asset_read(label, s, TEXT_MAX);
char *o = s;
while (*o) o++;
A->asset_read(PLACE, place, sizeof(place));
bool lead = true; /* still in leading zeros beyond the width */
for (uint8_t i = 0; i < 5u; i++) {
uint8_t d = 0;
while (v >= place[i]) { v -= place[i]; d++; }
if (d || i >= 5u - w) lead = false;
if (!lead) *o++ = (char)('0' + d);
}
*o = '\0';
A->print_tiny(s, x, 1, true, true);
}
/* A centred bold text from the assets on LCD line `line`. */
static void say(uint16_t off, uint8_t line) {
char s[TEXT_MAX];
A->asset_read(off, s, TEXT_MAX);
A->print_bold(s, 0, LCD_WIDTH - 1, line);
}
static void drawScoreValues(uint8_t level, int16_t balls, uint16_t value) {
A->status_clear();
stat(T_LEVEL, level, 2, 0);
stat(T_BALL, (balls < 0) ? 0 : (uint16_t)balls, 2, 45);
stat(T_SCORE, value, 4, 88);
}
static void drawScore(void) { drawScoreValues(g.levelCountBreakout, g.ballCount, g.score); }
static void renderBall(bool state) {
A->draw_rect(A->fb, g.ball.x, g.ball.y, g.ball.x + BALL_WIDTH - 1, g.ball.y + BALL_HEIGHT - 1, state);
A->draw_line(A->fb, g.ball.x - 1, g.ball.y + 1, g.ball.x + BALL_WIDTH, g.ball.y + 1, state);
}
static void initBall(void) { g.ball.x = 62; g.ball.y = 30; g.ball.dx = 0; g.ball.dy = 1; renderBall(true); }
/* Bounce off a racket or brick at x, w wide: the new dx for the hit offset
* x + w - ball.x (-1 at most below 0, as the hit tests allow) comes from a
* table of the original formula (x + w - ball.x) * -2num / w + num. */
static void bounce(uint16_t table, int x, int w) {
int8_t dx;
A->asset_read((uint16_t)(table + (x + w - g.ball.x) + 1), &dx, 1);
g.ball.dx = dx;
g.ball.dy = (int8_t)-g.ball.dy;
}
static void initWall(void) {
Brick *current = g.brick;
for (uint8_t y = 0; y < 24; y += 8)
for (uint8_t x = 6; x < 126; x += 20) {
current->x = x; current->y = y; current->destroy = false; current++;
}
g.wallLeft = BRICK_NUMBER;
}
static void initRacket(void);
static void drawBall(void) {
renderBall(false);
g.ball.x += g.ball.dx; g.ball.y += g.ball.dy;
if (g.ball.y <= 0) { g.ball.dx = (int8_t)rand_dx(); g.ball.dy = 1; }
else if (g.ball.x <= 2) { g.ball.dx = (int8_t)iabs(g.ball.dx); }
else if (g.ball.x >= 124) { g.ball.dx = (int8_t)-iabs(g.ball.dx); }
if (g.ball.y == 47) {
if (g.ball.x + 1 >= g.racket.x && g.ball.x - 1 <= g.racket.x + RACKET_WIDTH) {
bounce(RACKET_DX, g.racket.x, RACKET_WIDTH); g.tone = 400;
}
} else if (g.ball.y > 49) {
g.ballCount--;
A->display_clear();
drawScore();
g.tone = 800;
if (g.ballCount < 0) {
g.gameOverLevel = g.levelCountBreakout;
g.gameOverScore = g.score;
g.gameOver = true;
reset(); initWall(); /* drawWall drawn by main loop */
g.isPaused = true;
say(T_GAME_OVER, 4);
}
initRacket(); initBall();
}
renderBall(true);
}
static void drawWall(bool checkCollision) {
uint8_t anim[BRICK_ANIM_LEN]; /* brick patterns, read once per frame */
A->asset_read(BRICK_ANIM, anim, sizeof(anim));
for (uint8_t i = 0; i < BRICK_NUMBER; i++) {
Brick *b = &g.brick[i];
if (b->destroy) continue;
uint8_t *fb_ptr = A->fb[b->y >> 3] + b->x;
fb_ptr[0] = 0b00011110;
fb_ptr[14] = 0b00011110;
if (checkCollision &&
(g.ball.x + 1 >= b->x && g.ball.x - 1 <= b->x + BRICK_WIDTH) &&
(g.ball.y + 1 >= b->y && g.ball.y - 1 <= b->y + BRICK_HEIGHT)) {
b->destroy = true; g.score++;
bounce(BRICK_DX, b->x, BRICK_WIDTH);
A->led(true);
memset(fb_ptr + 1, 0b00111111, 13);
A->blit_line(b->y >> 3);
playBeep(600);
memset(fb_ptr + 0, 0b00000000, 15);
A->blit_line(b->y >> 3);
A->led(false);
if (--g.wallLeft == 0u) { /* the wall is cleared */
g.levelCountBreakout++; g.ballCount++; initWall(); return;
}
} else {
for (uint8_t k = 0; k < 13; k++)
fb_ptr[k + 1] = anim[(g.blockAnim + k) & 3u];
}
}
}
static void renderRacket(int x, bool state) {
A->draw_rect(A->fb, x + 1, RACKET_Y, x + RACKET_WIDTH - 2, RACKET_Y + RACKET_HEIGHT, state);
A->draw_line(A->fb, x, RACKET_Y + 1, x + RACKET_WIDTH - 1, RACKET_Y + 1, state);
}
static void initRacket(void) { g.racket.x = 64 - (RACKET_WIDTH / 2); g.racket.p = g.racket.x; renderRacket(g.racket.x, true); }
static void drawRacket(void) {
if (g.racket.p != g.racket.x) {
renderRacket(g.racket.p, false);
g.racket.p = g.racket.x;
renderRacket(g.racket.x, true);
}
}
static void redrawScene(void) {
A->display_clear();
if (g.gameOver) {
drawScoreValues(g.gameOverLevel, -1, g.gameOverScore);
} else {
drawScore();
drawWall(false);
}
renderRacket(g.racket.x, true);
g.racket.p = g.racket.x;
renderBall(true);
if (g.isPaused)
say(g.gameOver ? T_GAME_OVER : T_PAUSE, 4);
}
/* Title: the 1 KiB picture is streamed from the assets into the LCD buffers,
* with a blinking prompt on the blank bottom page. */
static void drawTitle(void) {
g.titleTick++;
A->asset_read(ART_TITLE, A->status_line, LCD_WIDTH);
A->asset_read(ART_TITLE + LCD_WIDTH, A->fb[0], 7u * LCD_WIDTH);
if (g.titleTick & 32u)
say(T_PRESS, 6);
}
static void OnKeyDown(uint8_t key) {
if (g.title) { /* MENU starts the game (and pauses it later on) */
if (key == APP_KEY_MENU) { g.title = false; g.redrawRequired = true; }
else if (key == APP_KEY_EXIT) g.isInitialized = false;
return;
}
bool wasPaused = g.isPaused;
switch (key) {
case APP_KEY_4:
case APP_KEY_UP: if (!g.isPaused && g.racket.x > 0) g.racket.x -= 2; g.isPaused = false; break;
case APP_KEY_0:
case APP_KEY_DOWN: if (!g.isPaused && g.racket.x < 102) g.racket.x += 2; g.isPaused = false; break;
case APP_KEY_MENU:
g.isPaused = !g.isPaused;
if (g.isPaused) say(T_PAUSE, 4);
break;
case APP_KEY_EXIT: g.isPaused = false; g.isInitialized = false; break;
}
if (wasPaused && !g.isPaused) {
g.gameOver = false;
for (uint8_t i = 0; i < 8; i++)
A->draw_line(A->fb, 32, 32 + i, 96, 32 + i, false);
}
}
static void handleInput(void) {
g.kbdPrev = g.kbdCur;
g.kbdCur = A->get_key();
if (g.kbdCur == APP_KEY_SAVER) {
if (!g.isPaused) {
g.isPaused = true;
g.saverPaused = true;
}
g.saverActive = true;
g.kbdPrev = APP_KEY_INVALID;
g.kbdCur = APP_KEY_INVALID;
return;
}
if (g.kbdCur == APP_KEY_WAKE || g.kbdCur == APP_KEY_PTT) {
if (g.saverPaused)
g.isPaused = false;
g.saverPaused = false;
g.saverActive = false;
g.redrawRequired = true;
g.kbdPrev = APP_KEY_INVALID;
g.kbdCur = APP_KEY_INVALID;
return;
}
if (g.kbdCur == APP_KEY_INVALID) return;
if (g.kbdCur == APP_KEY_UP || g.kbdCur == APP_KEY_DOWN ||
g.kbdCur == APP_KEY_4 || g.kbdCur == APP_KEY_0 || g.kbdCur != g.kbdPrev)
OnKeyDown(g.kbdCur);
}
/* ---- entry point, pinned to blob offset 0 ---- */
__attribute__((section(".text.entry"), used))
void app_main(const app_api_t *api) {
A = api;
#ifdef APP_POC_HELLO
/* Minimal bisection test: prove jump + ABI + return work, no game body. */
A->display_clear();
say(T_HELLO, 3);
A->blit_full();
A->delay_ms(2000);
return;
#endif
/* the loader zeroed the overlay: every flag starts false, every count 0 */
g.kbdPrev = APP_KEY_INVALID; g.kbdCur = APP_KEY_INVALID;
uint8_t swap = 0;
A->led(false);
A->backlight_on();
A->display_clear();
reset(); initWall(); initRacket(); initBall();
A->status_clear();
g.isInitialized = true;
g.title = true;
while (g.isInitialized) {
handleInput();
if (g.saverActive) {
A->backlight_update();
A->delay_ms(TICK_MS);
continue;
}
if (g.title) { /* the game scene is redrawn when MENU leaves the title */
drawTitle();
A->blit_status();
A->blit_full();
A->backlight_update();
A->delay_ms(TICK_MS);
continue;
}
if (g.redrawRequired) {
redrawScene();
g.redrawRequired = false;
}
if (!g.isPaused) {
if (swap == 0) g.blockAnim = (g.blockAnim + 1) & 3u;
swap = (swap + 1) & 3u;
drawScore(); drawWall(true); drawRacket(); drawBall();
if (g.tone != 0) { playBeep(g.tone); g.tone = 0; }
else A->delay_ms(40 - imin(g.levelCountBreakout - 1, 20));
}
A->blit_status();
A->blit_full();
A->backlight_update();
if (g.isPaused)
A->delay_ms(TICK_MS);
}
}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Build one overlay-app blob (.app). Invoked by ../../../compile-app.sh inside
# the uvk1-uvk5v3 Docker image, run from this app's directory. Everything is
# derived from the directory name; only APP_NAME - the human label baked into
# the 64-byte blob header - is per-app. The .app file is named after it (spaces
# stripped), so "Broadcast FM" -> BroadcastFM.app.
set -euo pipefail
APP="$(basename "$PWD")" # breakout, foxhunt, beacon, fm, ...
APP_NAME="Breakout" # <-- the only per-app line
APP_VER="1.1"
APP_API_MIN=2
APP_VMA=${APP_VMA:-0x20000280} # pinned overlay VMA (Core/py32f071xb.ld)
OUT="${APP_NAME// /}" # blob basename ("Broadcast FM" -> BroadcastFM)
CC=/opt/toolchain/bin/arm-none-eabi-gcc
OBJCOPY=/opt/toolchain/bin/arm-none-eabi-objcopy
command -v arm-none-eabi-gcc >/dev/null 2>&1 && { CC=arm-none-eabi-gcc; OBJCOPY=arm-none-eabi-objcopy; }
CFLAGS="-mcpu=cortex-m0plus -mthumb -Os -std=gnu11 -ffreestanding -fno-builtin -fno-common \
-fomit-frame-pointer -ffunction-sections -fdata-sections -Wall -Wextra"
LDFLAGS="-nostdlib -nostartfiles -T app.ld -Wl,--defsym,APP_VMA=${APP_VMA} \
-Wl,--gc-sections -Wl,-Map=${APP}.map -Wl,--build-id=none -Wl,--no-warn-rwx-segments"
rm -f ./*.app ./*.elf ./*.bin # drop stale artifacts so discovery is unambiguous
step() { printf '\r 🔨 %-13s [%d/4] %-8s' "$APP_NAME" "$1" "$2"; }
trap 'printf "\r ❌ %-13s build failed \n" "$APP_NAME"' ERR
step 1 assets ; python3 ./gen_assets.py "${APP}_assets.bin" "${APP}_assets.h"
step 2 compile ; "$CC" $CFLAGS $LDFLAGS "${APP}_app.c" -lgcc -o "${APP}.elf"
step 3 objcopy ; "$OBJCOPY" -O binary "${APP}.elf" "${APP}.bin"
step 4 pack ; python3 ../pack_app.py "${APP}.bin" "${OUT}.app" \
--name "$APP_NAME" --ver "$APP_VER" --api-min "$APP_API_MIN" --vma "${APP_VMA}" --screensaver --assets "${APP}_assets.bin" >/dev/null
trap - ERR
BYTES=$(wc -c < "${APP}.bin")
if [ "$BYTES" -gt 4096 ]; then
printf '\r 🚨 %-13s OVERFLOWS 4 KiB (%d B) \n' "$APP_NAME" "$BYTES"; exit 1
fi
printf '\r ✅ %-13s %4d B (%d%% of 4 KiB) -> %s.app \n' \
"$APP_NAME" "$BYTES" "$(( BYTES * 100 / 4096 ))" "$OUT"
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
# Breakout read-only assets: texts, the brick animation patterns and the
# 128x64 title screen.
#
# ./gen_assets.py breakout_assets.bin breakout_assets.h [preview.pgm]
import os, sys
sys.dont_write_bytecode = True
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from app_assets import Assets
from app_art import Canvas
BRICK_ANIM = [0b00110001, 0b00101001, 0b00100101, 0b00100011]
def bounce(w, num, t_max):
"""Ball dx after a hit at offset t = x + w - ball.x (t = -1 .. t_max), as
the original C expression t * (-2 * num) / w + num (division truncating
toward zero): the app reads it from here and links no division."""
out = []
for t in range(-1, t_max + 1):
a = t * (-2 * num)
q = abs(a) // w
out.append((q if a >= 0 else -q) + num)
return out
def brick(cv, x, y):
"""A 15 px brick as drawn by the game: end caps and the animated fill."""
for k in range(15):
col = 0b00011110 if k in (0, 14) else BRICK_ANIM[(k - 1) % 4]
for bit in range(8):
if col >> bit & 1:
cv.set(x + k, y + bit)
def title_screen():
"""BREAK / OUT, a few bricks, the racket and the ball with its trail."""
cv = Canvas()
cv.two_words("BREAK", "OUT")
for x in (2, 22, 86, 106):
brick(cv, x, 42)
for k in range(24): # racket, as renderRacket()
cv.set(52 + k, 54)
if 0 < k < 23:
cv.set(52 + k, 53); cv.set(52 + k, 55)
cv.rect(62, 47, 64, 49) # ball, as renderBall()
cv.set(61, 48); cv.set(65, 48)
for i in range(0, 8, 2): # dotted trail
cv.set(58 - i * 2, 47 - i // 2)
return cv
preview_path = sys.argv.pop(3) if len(sys.argv) == 4 else None
a = Assets("BREAKOUT")
a.text("T_LEVEL", "Level ")
a.text("T_BALL", "Ball ")
a.text("T_SCORE", "Score ")
a.text("T_GAME_OVER", "GAME OVER")
a.text("T_PAUSE", "PAUSE")
a.text("T_HELLO", "OVL HELLO") # APP_POC_HELLO bisection build only
a.text("T_PRESS", "PRESS MENU")
a.u8("BRICK_ANIM", BRICK_ANIM)
a.u16("PLACE", [10000, 1000, 100, 10, 1]) # decimal places, no division
# Hit offsets: ball.x within one pixel of the racket (24 px) or brick (14 px).
a.i8("RACKET_DX", bounce(24, 3, 25))
a.i8("BRICK_DX", bounce(14, 2, 15))
title = title_screen()
a.raw("ART_TITLE", title.pages())
a.main()
if preview_path:
title.preview(preview_path)
+25
View File
@@ -0,0 +1,25 @@
# Cube3D
Cube3D renders rotating 3D shapes on the 128x64 monochrome display. Geometry
and lookup tables live in the app assets; the overlay uses fixed
point arithmetic and performs no hardware floating-point or division.
The `GLOBE` scene has a dedicated renderer with latitude/longitude lines and
a stable circular limb. In wire mode its far side is
dotted; in solid mode it is hidden. Shape 10, `DODECA`, is reached with `*`
after the globe because the numeric keypad directly selects shapes 1 through 9.
## Keys
| Key | Action |
|---|---|
| 1-9 | Select a shape directly (`9` selects the globe) |
| * | Next shape |
| UP / DOWN | Increase / decrease rotation speed |
| F then UP / DOWN | Zoom in / out (eight levels) |
| F then * | Toggle wire / solid hidden-line rendering (`W` / `S`) |
| 0 | Reset the orientation and zoom |
| MENU | Pause / resume |
| EXIT | Quit |
The status bar shows the shape name, the armed `F`, and `PAUSE` when stopped.
+43
View File
@@ -0,0 +1,43 @@
/* Overlay-app link script (POC).
*
* The blob is linked to run at the PY25Q16 sector-cache overlay VMA (shared with
* the multiboot RAM stub — never both active at once). Code + rodata + data +
* bss must all fit in the 4 KiB overlay. objcopy -O binary emits text+rodata+
* data only; the loader zeroes the overlay before copying, so bss starts clean.
*
* VMA must match ADDR(.mb_workspace) in Core/py32f071xb.ld. If the firmware RAM
* layout shifts, update APP_VMA and the loader's compile-time assert catches a
* mismatch. */
/* APP_VMA is the target firmware's __mb_workspace_start (read from its .map). It
* varies with the RAM layout, so pass it from the build: -Wl,--defsym,APP_VMA=0x...
* The default is only a fallback for a standalone size check. */
APP_VMA = DEFINED(APP_VMA) ? APP_VMA : 0x20000280;
APP_LENGTH = 0x1000; /* 4 KiB sector-cache overlay */
ENTRY(app_main)
MEMORY {
APP (rwx) : ORIGIN = APP_VMA, LENGTH = APP_LENGTH
}
SECTIONS {
.app APP_VMA : {
KEEP(*(.text.entry)) /* app_main pinned to offset 0 */
*(.text .text.*)
*(.rodata .rodata.*)
. = ALIGN(4);
*(.data .data.*)
. = ALIGN(4);
__app_bss_start = .;
*(.bss .bss.* COMMON)
. = ALIGN(4);
__app_bss_end = .;
} > APP
__app_end = .;
ASSERT(__app_end <= APP_VMA + APP_LENGTH,
"Cube3D overlay app overflows the 4 KiB overlay")
/DISCARD/ : { *(.ARM.exidx*) *(.ARM.extab*) *(.eh_frame*) *(.comment) *(.note.*) }
}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Build one overlay-app blob (.app). Invoked by ../../../compile-app.sh inside
# the uvk1-uvk5v3 Docker image, run from this app's directory. Everything is
# derived from the directory name; only APP_NAME - the human label baked into
# the 64-byte blob header - is per-app. The .app file is named after it (spaces
# stripped), so "Broadcast FM" -> BroadcastFM.app.
set -euo pipefail
APP="$(basename "$PWD")" # breakout, foxhunt, beacon, fm, ...
APP_NAME="Cube3D" # <-- the only per-app line
APP_VER="1.2"
APP_API_MIN=2
APP_VMA=${APP_VMA:-0x20000280} # pinned overlay VMA (Core/py32f071xb.ld)
OUT="${APP_NAME// /}" # blob basename ("Broadcast FM" -> BroadcastFM)
CC=/opt/toolchain/bin/arm-none-eabi-gcc
OBJCOPY=/opt/toolchain/bin/arm-none-eabi-objcopy
command -v arm-none-eabi-gcc >/dev/null 2>&1 && { CC=arm-none-eabi-gcc; OBJCOPY=arm-none-eabi-objcopy; }
CFLAGS="-mcpu=cortex-m0plus -mthumb -Os -std=gnu11 -ffreestanding -fno-builtin -fno-common \
-fomit-frame-pointer -ffunction-sections -fdata-sections -Wall -Wextra"
LDFLAGS="-nostdlib -nostartfiles -T app.ld -Wl,--defsym,APP_VMA=${APP_VMA} \
-Wl,--gc-sections -Wl,-Map=${APP}.map -Wl,--build-id=none -Wl,--no-warn-rwx-segments"
rm -f ./*.app ./*.elf ./*.bin # drop stale artifacts so discovery is unambiguous
step() { printf '\r 🔨 %-13s [%d/4] %-8s' "$APP_NAME" "$1" "$2"; }
trap 'printf "\r ❌ %-13s build failed \n" "$APP_NAME"' ERR
step 1 assets ; python3 ./gen_assets.py "${APP}_assets.bin" "${APP}_assets.h"
step 2 compile ; "$CC" $CFLAGS $LDFLAGS "${APP}_app.c" -lgcc -o "${APP}.elf"
step 3 objcopy ; "$OBJCOPY" -O binary "${APP}.elf" "${APP}.bin"
step 4 pack ; python3 ../pack_app.py "${APP}.bin" "${OUT}.app" \
--name "$APP_NAME" --ver "$APP_VER" --api-min "$APP_API_MIN" --vma "${APP_VMA}" --screensaver --assets "${APP}_assets.bin" >/dev/null
trap - ERR
BYTES=$(wc -c < "${APP}.bin")
if [ "$BYTES" -gt 4096 ]; then
printf '\r 🚨 %-13s OVERFLOWS 4 KiB (%d B) \n' "$APP_NAME" "$BYTES"; exit 1
fi
printf '\r ✅ %-13s %4d B (%d%% of 4 KiB) -> %s.app \n' \
"$APP_NAME" "$BYTES" "$(( BYTES * 100 / 4096 ))" "$OUT"
+374
View File
@@ -0,0 +1,374 @@
/* 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.
*/
/*
* Cube3D — overlay app. A real-time rotating solid on the 1-bit 128x64 LCD.
* Vertices are spun by three axis rotations in Q14 fixed point (Cortex-M0+ has
* no FPU and no hardware divide), then perspective-projected with a multiply by
* a reciprocal from the assets, exactly equal to the divide it replaces, so no
* libgcc division is linked. Two looks, toggled with F then STAR:
* - SOLID: hidden-line removal by back-face culling (a face is drawn only when
* the signed area of its projected polygon shows it facing us).
* - WIRE : every edge, with the far hemisphere dotted for a depth cue.
* Edges use a self-clipped Bresenham writing the full 64 rows directly, since the
* resident pixel helper does not bound-check. The solids, the sine table and the
* speeds are read-only assets (gen_assets.py; faces with a uniform outward
* winding from an offline convex-hull extractor). They are copied onto the stack
* (the sine once, the displayed solid every frame), so none of them occupies the
* 4 KiB overlay. The globe is rendered procedurally as a latitude/longitude grid.
* Pure compute, no radio.
*
* Keys: UP/DOWN speed · F+UP/DOWN zoom · 1-9 shape · STAR next shape ·
* F+STAR solid/wire · 0 reset view · MENU pause · EXIT quit.
*/
#include <stdint.h>
#include <stdbool.h>
#include "../app_api.h"
#include "cube3d_assets.h" /* generated by gen_assets.py */
#define W 128
#define H 64
#define CX 64 /* projection centre x */
#define CY 32 /* projection centre y */
/* DIST (camera distance along +z, keeps zc > 0) and FOCAL (field-of-view
* scale) come from the assets: the reciprocal table is built for them. */
#define MAXV SHAPE_MAXV /* largest vertex count across the solids */
static const app_api_t *A;
static const int16_t *sinq; /* Q14 sine quadrant, on app_main's stack */
static const uint16_t *recipq;
static int rcx, rsx, rcy, rsy, rcz, rsz;
static uint8_t zoomq; /* Q4 screen scale: 16 = 100% */
static int sin8(uint8_t angle)
{
const uint8_t quadrant = angle >> 6;
uint8_t i = angle & 63u;
if (quadrant & 1u)
i = (uint8_t)(64u - i);
const int value = sinq[i];
return quadrant >= 2u ? -value : value;
}
/* Set one pixel across the full 64 rows: 0..7 -> status line, 8..63 -> fb. */
static void set_pixel(int x, int y)
{
if ((unsigned)x >= W || (unsigned)y >= H)
return;
const uint8_t bit = (uint8_t)(1u << (y & 7));
if (y < 8)
A->status_line[x] |= bit;
else
A->fb[(y >> 3) - 1][x] |= bit;
}
/* Integer Bresenham; dotted skips every other step for the depth cue. */
static void draw_edge(int x0, int y0, int x1, int y1, bool dotted)
{
const int dx = (x1 > x0 ? x1 - x0 : x0 - x1);
const int dy = -(y1 > y0 ? y1 - y0 : y0 - y1);
const int sx = (x0 < x1 ? 1 : -1);
const int sy = (y0 < y1 ? 1 : -1);
int err = dx + dy;
unsigned step = 0;
for (;;) {
if (!dotted || (step & 1u) == 0u)
set_pixel(x0, y0);
if (x0 == x1 && y0 == y1)
break;
const int e2 = 2 * err;
if (e2 >= dy) { err += dy; x0 += sx; }
if (e2 <= dx) { err += dx; y0 += sy; }
step++;
}
}
static void clear_screen(void)
{
for (uint8_t x = 0; x < W; x++) {
A->status_line[x] = 0;
for (uint8_t p = 0; p < 7u; p++)
A->fb[p][x] = 0;
}
}
/* n / zc, truncated toward zero like C, from zc's reciprocal m: exact for
* the projection's range (see gen_assets.py). */
static int proj(int n, uint32_t m)
{
const int q = (int)(((uint32_t)(n < 0 ? -n : n) * m) >> RECIP_SHIFT);
return n < 0 ? -q : q;
}
/* Rotate, project and retain rotated depth for hidden-line decisions. */
static void project_point(int x, int y, int z, int16_t *px, int16_t *py, int16_t *pz)
{
int ny = (y * rcx - z * rsx) >> 14;
int nz = (y * rsx + z * rcx) >> 14;
y = ny; z = nz;
int nx = (x * rcy + z * rsy) >> 14;
nz = (z * rcy - x * rsy) >> 14;
x = nx; z = nz;
nx = (x * rcz - y * rsz) >> 14;
ny = (x * rsz + y * rcz) >> 14;
x = nx; y = ny;
int zc = z + (int)DIST;
if (zc < (int)RECIP_ZMIN) zc = RECIP_ZMIN;
if (zc > (int)RECIP_ZMAX) zc = RECIP_ZMAX;
const uint32_t m = recipq[zc - (int)RECIP_ZMIN];
int qx = proj(x * (int)FOCAL, m), qy = proj(y * (int)FOCAL, m);
qx = qx < 0 ? -(((-qx) * zoomq) >> 4) : (qx * zoomq) >> 4;
qy = qy < 0 ? -(((-qy) * zoomq) >> 4) : (qy * zoomq) >> 4;
*px = (int16_t)(CX + qx);
*py = (int16_t)(CY + qy);
*pz = (int16_t)z;
}
#define GLOBE_R 36
#define GLOBE_SCREEN_R 19
/* Latitude/longitude use the same 256-step turn as sin8(). Longitude zero is
* the front of the globe, and positive latitude is drawn upward. */
static void globe_point(int8_t lat, int8_t lon, int16_t *x, int16_t *y, int16_t *z)
{
const uint8_t la = (uint8_t)lat, lo = (uint8_t)lon;
const int r = (GLOBE_R * sin8((uint8_t)(la + 64u))) >> 14;
const int gx = (r * sin8(lo)) >> 14;
const int gy = -((GLOBE_R * sin8(la)) >> 14);
const int gz = -((r * sin8((uint8_t)(lo + 64u))) >> 14);
project_point(gx, gy, gz, x, y, z);
}
static void globe_edge(int16_t x0, int16_t y0, int16_t z0,
int16_t x1, int16_t y1, int16_t z1, bool wire)
{
if (z0 <= 0 && z1 <= 0)
draw_edge(x0, y0, x1, y1, false);
else if (wire && z0 >= 0 && z1 >= 0)
draw_edge(x0, y0, x1, y1, true);
}
static void draw_globe(bool wire)
{
int16_t x0, y0, z0, x1, y1, z1;
/* Three parallels, each split into 16 short segments. */
for (int8_t lat = -32; lat <= 32; lat += 32) {
globe_point(lat, 0, &x0, &y0, &z0);
for (uint16_t lon = 16; lon <= 256u; lon += 16u) {
globe_point(lat, (int8_t)lon, &x1, &y1, &z1);
globe_edge(x0, y0, z0, x1, y1, z1, wire);
x0 = x1; y0 = y1; z0 = z1;
}
}
/* Eight pole-to-pole half-meridians make four complete great circles. */
for (uint16_t lon = 0; lon < 256u; lon += 32u) {
globe_point(-64, (int8_t)lon, &x0, &y0, &z0);
for (int8_t lat = -48; lat <= 64; lat += 16) {
globe_point(lat, (int8_t)lon, &x1, &y1, &z1);
globe_edge(x0, y0, z0, x1, y1, z1, wire);
x0 = x1; y0 = y1; z0 = z1;
}
}
/* A stable limb keeps the globe legible when most grid lines are edge-on. */
const int limb = (GLOBE_SCREEN_R * zoomq) >> 4;
x0 = (int16_t)(CX + limb); y0 = CY;
for (uint16_t a = 8; a <= 256u; a += 8u) {
x1 = (int16_t)(CX + ((limb * sin8((uint8_t)(a + 64u))) >> 14));
y1 = (int16_t)(CY + ((limb * sin8((uint8_t)a)) >> 14));
draw_edge(x0, y0, x1, y1, false);
x0 = x1; y0 = y1;
}
}
/* Signed area of a packed face's projected polygon (<0 == facing us). */
static int face_area(const uint8_t *f, const int16_t *px, const int16_t *py)
{
const uint8_t n = f[0];
const uint8_t *v = f + 1;
int sa = 0;
for (uint8_t k = 0; k < n; k++) {
const uint8_t a = v[k];
const uint8_t b = v[(k + 1u == n) ? 0u : k + 1u];
sa += (int)px[a] * py[b] - (int)px[b] * py[a];
}
return sa;
}
__attribute__((section(".text.entry"), used))
void app_main(const app_api_t *api)
{
A = api;
A->backlight_on();
A->status_clear();
/* Assets copied to the stack: shared tables once, the displayed solid every
* frame (nv, nf, name + NUL, vertices, packed faces; see gen_assets.py). */
int16_t sin_q[SIN_Q_LEN / 2u];
uint16_t recip[RECIP_LEN / 2u]; /* by zc - RECIP_ZMIN */
char pause[T_PAUSE_LEN];
uint8_t rec[SHAPE_REC_MAX];
uint8_t nshape;
int16_t px[MAXV], py[MAXV], pz[MAXV]; /* projected x, y and rotated depth */
A->asset_read(SIN_Q, sin_q, sizeof(sin_q));
A->asset_read(RECIP, recip, sizeof(recip));
A->asset_read(T_PAUSE, pause, sizeof(pause));
sinq = sin_q;
recipq = recip;
if (A->asset_read(SHAPES, &nshape, 1) != 1u || nshape == 0u)
return;
uint16_t ax = 0, ay = 0, az = 0; /* Q2 half-units: 2048 = full turn */
uint8_t shape = 0;
uint8_t speed = 4; /* 1..16, shared by all three rotation axes */
uint8_t zoom = 4; /* 1..8: 62.5%..150%, level 4 = 100% */
bool paused = false;
bool wire = true; /* false = solid (hidden-line) */
bool fArm = false; /* F: the next UP/DOWN or STAR is modified */
bool running = true;
uint8_t prevKey = APP_KEY_INVALID;
while (running) {
uint8_t key = A->get_key();
if (key == APP_KEY_SAVER) {
prevKey = APP_KEY_INVALID;
A->delay_ms(10);
A->backlight_update();
continue;
}
if (key == APP_KEY_WAKE)
key = APP_KEY_INVALID;
if (key != prevKey && key != APP_KEY_INVALID) {
A->backlight_on();
if (key == APP_KEY_F) {
fArm = !fArm;
prevKey = key;
continue;
}
const bool fn = fArm;
fArm = false;
switch (key) {
case APP_KEY_EXIT:
running = false;
break;
case APP_KEY_UP:
case APP_KEY_DOWN: {
const int8_t dir = A->nav_dir(key);
if (fn) {
if (dir > 0 && zoom < 8u) zoom++;
if (dir < 0 && zoom > 1u) zoom--;
} else {
if (dir > 0 && speed < 16u) speed++;
if (dir < 0 && speed > 1u) speed--;
}
break;
}
case APP_KEY_MENU:
paused = !paused;
break;
case APP_KEY_STAR: /* next shape, wrapping without a modulo */
if (fn)
wire = !wire;
else if (++shape >= nshape)
shape = 0;
break;
case APP_KEY_0:
ax = ay = az = 0;
zoom = 4;
break;
case APP_KEY_1: case APP_KEY_2: case APP_KEY_3: case APP_KEY_4:
case APP_KEY_5: case APP_KEY_6: case APP_KEY_7: case APP_KEY_8:
case APP_KEY_9:
if ((uint8_t)(key - APP_KEY_1) < nshape)
shape = (uint8_t)(key - APP_KEY_1);
break;
default:
break;
}
}
prevKey = key;
if (!running)
break;
uint8_t entry[3]; /* directory: u16 record offset, u8 record length */
A->asset_read(SHAPES + 1u + 3u * shape, entry, sizeof(entry));
const uint8_t reclen = entry[2] < sizeof(rec) ? entry[2] : (uint8_t)sizeof(rec);
A->asset_read(SHAPES + (uint16_t)(entry[0] | (entry[1] << 8)), rec, reclen);
const uint8_t nv = rec[0] < MAXV ? rec[0] : MAXV;
const uint8_t nf = rec[1];
uint8_t namelen = 0;
while (2u + namelen < reclen && rec[2u + namelen])
namelen++;
const int8_t (*verts)[3] = (const int8_t (*)[3])&rec[3u + namelen];
const uint8_t ia = (uint8_t)(ax >> 3), ib = (uint8_t)(ay >> 3), ic = (uint8_t)(az >> 3);
zoomq = (uint8_t)(8u + 2u * zoom);
rcx = sin8((uint8_t)(ia + 64u)); rsx = sin8(ia);
rcy = sin8((uint8_t)(ib + 64u)); rsy = sin8(ib);
rcz = sin8((uint8_t)(ic + 64u)); rsz = sin8(ic);
for (uint8_t i = 0; i < nv; i++) {
project_point(verts[i][0], verts[i][1], verts[i][2],
&px[i], &py[i], &pz[i]);
}
clear_screen();
if (nv == 0u && nf == 0u) {
draw_globe(wire);
} else {
const uint8_t *f = &rec[3u + namelen + 3u * rec[0]]; /* packed faces */
for (uint8_t i = 0; i < nf; i++) {
const uint8_t n = f[0];
if (wire || face_area(f, px, py) < 0) { /* else hidden face culled */
for (uint8_t k = 0; k < n; k++) {
const uint8_t a = f[1u + k];
const uint8_t b = f[(k + 1u == n) ? 1u : 2u + k];
const bool dotted = wire && (pz[a] + pz[b] > 0); /* far half */
draw_edge(px[a], py[a], px[b], py[b], dotted);
}
}
f += 1u + n;
}
}
/* Shape name: inverse label, top-left of the status bar (scan-list look). */
const uint8_t end = (uint8_t)(2u + 4u * namelen);
for (uint8_t i = 0; i <= end; i++)
A->status_line[i] = 0;
A->print_inverse((const char *)&rec[2], 2, 0, true, true, end);
if (fArm)
A->asset_read(BMP_F, A->status_line + 70, BMP_F_LEN);
if (paused)
A->print_inverse(pause, 82, 0, true, true, 104);
A->blit_status();
A->blit_full();
if (!paused) {
uint8_t rate;
A->asset_read(ROT_RATE + speed - 1u, &rate, 1);
ax += rate;
ay += (uint16_t)(rate + (rate >> 1));
az += (uint16_t)((rate + 1u) >> 1);
}
A->backlight_update();
A->delay_ms((uint32_t)(32u - speed * 2u)); /* slow low end, no added delay at level 16 */
}
}
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
# Cube3D read-only assets: the solids, Q14 sine quadrant and speeds.
#
# SHAPES layout (offsets relative to SHAPES):
# 0 count N
# 1 + 3*k solid k directory entry: u16 record offset, u8 record length
# record nv, nf, name + NUL, nv x (x, y, z) int8, then nf packed faces
# (n, v0 .. v{n-1}), each wound CCW as seen from outside
# The faces were generated offline by a convex-hull extractor, so every solid
# shares the same outward winding and the signed-area cull sign. SHAPE_MAXV and
# SHAPE_REC_MAX size the app's buffers, so adding a solid needs no C change.
#
# ./gen_assets.py cube3d_assets.bin cube3d_assets.h
import math, os, struct, sys
sys.dont_write_bytecode = True
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
from app_assets import Assets
MAXN = 6 # largest face polygon
SHAPES = [
("CUBE",
[(-26,-26,-26),(26,-26,-26),(26,26,-26),(-26,26,-26),(-26,-26,26),(26,-26,26),(26,26,26),(-26,26,26)],
[(2,1,0,3),(4,0,1,5),(7,3,0,4),(5,1,2,6),(6,2,3,7),(7,4,5,6)]),
("OCTAHEDRON",
[(38,0,0),(-38,0,0),(0,38,0),(0,-38,0),(0,0,38),(0,0,-38)],
[(4,0,2),(2,0,5),(3,0,4),(5,0,3),(2,1,4),(5,1,2),(4,1,3),(3,1,5)]),
("TETRAHEDRON",
[(28,28,28),(28,-28,-28),(-28,28,-28),(-28,-28,28)],
[(2,0,1),(1,0,3),(3,0,2),(2,1,3)]),
("DIAMOND",
[(30,0,0),(14,26,0),(-14,26,0),(-30,0,0),(-14,-26,0),(14,-26,0),(0,0,40),(0,0,-40)],
[(6,0,1),(1,0,7),(5,0,6),(7,0,5),(6,1,2),(2,1,7),(6,2,3),(3,2,7),(6,3,4),(4,3,7),(6,4,5),(5,4,7)]),
("ICOSAHEDRON",
[(0,18,29),(0,18,-29),(0,-18,29),(0,-18,-29),(18,29,0),(18,-29,0),(-18,29,0),(-18,-29,0),(29,0,18),(29,0,-18),(-29,0,18),(-29,0,-18)],
[(8,0,2),(2,0,10),(6,0,4),(4,0,8),(10,0,6),(3,1,9),(11,1,3),(4,1,6),(9,1,4),(6,1,11),
(5,2,7),(8,2,5),(7,2,10),(7,3,5),(5,3,9),(11,3,7),(9,4,8),(8,5,9),(10,6,11),(11,7,10)]),
("CUBOCTA",
[(-24,-24,0),(-24,24,0),(24,-24,0),(24,24,0),(-24,0,-24),(-24,0,24),(24,0,-24),(24,0,24),(0,-24,-24),(0,-24,24),(0,24,-24),(0,24,24)],
[(4,0,5,1),(2,9,0,8),(8,0,4),(5,0,9),(10,1,11,3),(4,1,10),(11,1,5),(3,7,2,6),(6,2,8),(9,2,7),(10,3,6),(7,3,11),(8,4,10,6),(7,11,5,9)]),
("HEXPRISM",
[(26,0,24),(12,22,24),(-12,22,24),(-26,0,24),(-12,-22,24),(12,-22,24),(26,0,-24),(12,22,-24),(-12,22,-24),(-26,0,-24),(-12,-22,-24),(12,-22,-24)],
[(4,5,0,1,2,3),(7,1,0,6),(6,0,5,11),(8,2,1,7),(9,3,2,8),(10,4,3,9),(11,5,4,10),(9,8,7,6,11,10)]),
("PENTAGEM",
[(28,0,0),(8,26,0),(-22,16,0),(-22,-16,0),(8,-26,0),(0,0,42),(0,0,-42)],
[(5,0,1),(1,0,6),(4,0,5),(6,0,4),(5,1,2),(2,1,6),(5,2,3),(3,2,6),(5,3,4),(4,3,6)]),
# Empty geometry selects the procedural globe renderer in cube3d_app.c.
("GLOBE", [], []),
("DODECA",
[(-20,-20,-20),(-20,-20,20),(-20,20,-20),(-20,20,20),
(20,-20,-20),(20,-20,20),(20,20,-20),(20,20,20),
(0,-12,-32),(0,-12,32),(0,12,-32),(0,12,32),
(-12,-32,0),(-12,32,0),(12,-32,0),(12,32,0),
(-32,0,-12),(-32,0,12),(32,0,-12),(32,0,12)],
[(17,16,0,12,1),(10,8,0,16,2),(14,12,0,8,4),(3,17,1,9,11),
(5,9,1,12,14),(3,13,2,16,17),(6,10,2,13,15),(15,13,3,11,7),
(5,14,4,18,19),(6,18,4,8,10),(11,9,5,19,7),(19,18,6,15,7)]),
]
# One Q14 sine quadrant: symmetry recovers the full 256-step wave.
SIN_Q = [
0, 402, 804, 1205, 1606, 2006, 2404, 2801, 3196, 3590, 3981,
4370, 4756, 5139, 5520, 5897, 6270, 6639, 7005, 7366, 7723, 8076,
8423, 8765, 9102, 9434, 9760,10080,10394,10702,11003,11297,11585,
11866,12140,12406,12665,12916,13160,13395,13623,13842,14053,14256,
14449,14635,14811,14978,15137,15286,15426,15557,15679,15791,15893,
15986,16069,16143,16207,16261,16305,16340,16364,16379,16384,
]
# Quarter-half-units per frame for speed levels 1..16. The low end has
# fractional angular steps; level 16 reaches 16 half-units/frame once divided
# by four.
ROT_RATE = [1, 2, 3, 4, 6, 8, 10, 12, 16, 20, 24, 30, 36, 44, 52, 64]
# Perspective projection without a division: for n = x * FOCAL and
# zc = z + DIST, the C expression n / zc (truncating toward zero) equals
# sign(n) * ((|n| * RECIP[zc - RECIP_ZMIN]) >> RECIP_SHIFT) exactly, with
# RECIP[d] = 2^RECIP_SHIFT // d + 1, as long as |n| < 2^RECIP_SHIFT / zc (the
# error term then stays below 1 / zc). The zc range covers every solid's
# largest radius, plus a margin for the Q14 rotation rounding.
DIST, FOCAL = 150, 80
RECIP_SHIFT = 20
R = int(max(math.sqrt(x * x + y * y + z * z) for _, v, _ in SHAPES for x, y, z in v)) + 4
RECIP_ZMIN, RECIP_ZMAX = DIST - R, DIST + R
RECIP = [(1 << RECIP_SHIFT) // d + 1 for d in range(RECIP_ZMIN, RECIP_ZMAX + 1)]
if FOCAL * R >= (1 << RECIP_SHIFT) // RECIP_ZMAX or FOCAL * R * RECIP[0] >= 1 << 31 or RECIP[0] > 0xFFFF:
sys.exit("RECIP: a solid is too large for an exact division-free projection")
def record(name, verts, faces):
out = bytes([len(verts), len(faces)]) + name.encode("ascii") + b"\x00"
for v in verts:
out += struct.pack("<3b", *v)
for f in faces:
if not 3 <= len(f) <= MAXN or any(i >= len(verts) for i in f):
sys.exit(f"{name}: bad face {f}")
out += bytes([len(f), *f])
if len(out) > 255:
sys.exit(f"{name}: record {len(out)} B exceeds 255 B")
return out
records = [record(*s) for s in SHAPES]
base = 1 + 3 * len(records)
shapes = bytes([len(records)])
body = b""
for r in records:
shapes += struct.pack("<HB", base + len(body), len(r))
body += r
a = Assets("CUBE3D")
a.text("T_PAUSE", "PAUSE")
a.raw("SHAPES", shapes + body)
a.i16("SIN_Q", SIN_Q)
a.u8("ROT_RATE", ROT_RATE)
a.u16("RECIP", RECIP)
a.u8("BMP_F", [0x3e,0x7f,0x41,0x75,0x75,0x75,0x7d,0x7f,0x3e])
a.const("RECIP_ZMIN", RECIP_ZMIN)
a.const("RECIP_ZMAX", RECIP_ZMAX)
a.const("RECIP_SHIFT", RECIP_SHIFT)
a.const("DIST", DIST)
a.const("FOCAL", FOCAL)
a.const("SHAPE_MAXV", max(len(v) for _, v, _ in SHAPES))
a.const("SHAPE_REC_MAX", max(len(r) for r in records))
a.main()
+318
View File
@@ -0,0 +1,318 @@
# EPIRB 406: on-radio 406 MHz beacon decoder (work in progress)
Goal: an overlay app that decodes first-generation Cospas-Sarsat 406 MHz beacon
messages directly on the UV-K1 / UV-K5 v3 and shows the 15-hex ID, country,
protocol, position and BCH status.
Status:
| Step | State |
|---|---|
| Feasibility: can an app read the demodulated signal? | **Done**: yes, on PA4 (see `../lab406/README.md`) |
| Decoder core (`dec406.c`), host-tested on synthetic audio | **Done**: 17/17 tests pass |
| Radio app (`epirb406_app.c`: trigger, sampling, display) | **v1.4 decodes the full reference frame on the K1** (position, `END F58521EDA3`); v1.6 (assets, 3,320 bytes) decodes the Flipper Zero test frames on the radio, and the rpitx bench frame on a UV-K1 (F4WAT, 2026-10-01); v1.7: 3,284 bytes |
| Bench test with the beacon generator on 433.650 MHz | **Done** (2026-09-27) |
| Bench test with a Flipper Zero on 433.650 MHz (`test/flipper406.py`) | **Done** (2026-09-30, v1.6) |
## Using the app
1. Set the VFO to the beacon frequency, **FM, wide bandwidth**: 406.031 MHz for
real beacons, or the generator frequency (433.650 MHz on the bench).
2. Launch **EPIRB 406** with no burst in progress: the noise floor is measured at
launch.
3. Lower the volume: the RX audio must stay on (it is what reaches PA4), so the
speaker plays each burst.
Each burst is detected on RSSI (10 dB above the floor), sampled for up to 900 ms
and decoded. The last decoded message stays on screen (the 2-message history was
dropped in v1.5 to make room for tuning).
| Screen | Content |
|---|---|
| Line 0 | 15-hex ID |
| Line 1 | Country code and protocol |
| Line 2 | Position (5 decimals, truncated) or "no position" |
| Line 3 | Tuned frequency and offset from the VFO, e.g. `433.645 -5 kHz` (v1.5; up to v1.4: `END` + raw bits 105-144) |
| Small rows | SELF-TEST, LONG/SHORT, BCH-1/BCH-2; `#` decode number, RSSI of the burst, internal/external position source, 121.5 homing, `coarse` (no PDF-2 offsets), `rawID` (not a standard location protocol, ID = raw bits 26-85) |
| Bottom row | RSSI / floor, decodes ok, errors, last error: `nosync` (no frame sync found) or `cut` (sync found, message incomplete) |
Keys (UV-K5 and UV-K1):
| Key | Action |
|---|---|
| UP / DOWN | Tune ±5 kHz from the VFO, up to ±50 kHz (direction follows the firmware's navigation setting, so K1 LEFT/RIGHT work as on the main screen) |
| 5 | Back to the VFO frequency |
| MENU | Clear the last message and the counters |
| EXIT | Quit (the firmware retunes to the VFO frequency) |
Tuning writes the BK4829 frequency registers (0x38/0x39, 10 Hz units) and
restarts the synthesizer (REG_30 to 0, then back) so it relocks; it only lasts
while the app runs. Useful when the carrier sits near the edge of the channel: on
the bench, the full frame was only received with the K1 5 kHz below the
generator. The backlight stays on while the app runs (since v1.4). The
decoder integrates the discriminator pulses (INT); the INT/DIR toggle of v1.1-v1.2
(key 1) was removed in v1.3 once INT was proven on the radio.
Space: v1.5 used 4,052 of the 4,096 bytes. To make it fit, the sync search
compares 32-bit words (the inverted-polarity distance is 44 minus the normal
one), number formatting uses subtraction instead of division (no `__udivsi3`),
only the last message is kept, and the app is built with `-fno-jump-tables`
(48 bytes saved on the key `switch`).
v1.6 moves the screen texts and the 24 protocol names to read-only assets
(`gen_assets.py`, API level 2, 432 bytes outside the overlay). Measured: the app
went from 4,052 to **3,320 bytes, leaving 776 bytes free** (44 before). `draw()` reads the UI texts in one
`asset_read` into a word-aligned stack block, so each text costs a 2-byte
sp-relative add instead of a literal-pool load and its pool word; each text is
padded to 4 bytes for that. A protocol name is read straight into the line
buffer. The names are parsed from `NAMES` in `dec406.c`, so the host test and the
app share one table (`dec406_proto_name` is dropped from the app by
`--gc-sections`). The hex digits of the ID are computed instead of taken from a
table. The app state is grouped in one struct (one base address per function
instead of a literal-pool word per global), the dead `seq`/`inv` copies of the
last message are gone, and the zeroing already done by the loader, the ADC
restore already done after each capture and the backlight call already made
every loop are no longer repeated.
v1.7 counts the sync-pattern bits with a loop (one pass per set bit) instead of
the branch-free version: 36 bytes less, **3,284 bytes, 812 free**. The cost is
at most 4 x 32 passes per half-bit (every 12 samples), and only while searching
for the sync, against 5,000 cycles per sample; host tests 29/29.
## Signal path on the radio
```
beacon RF --> BK4829 (RAW: no HPF300 / LPF3K / de-emphasis, AFC off)
--> AF output (FM) --> audio circuit --> PA4 --> ADC channel 4 @ 9.6 kHz
```
- **RAW receive** is set by the app itself (REG_2B bits 10/9/8, REG_73 bit 4), as
`BK4819_EnterRaw` does. The firmware restores all radio registers on app exit.
- **PA4** is the voice-prompt DAC pin. Voice is disabled in every preset, so the
app switches the DAC off and reads PA4 as an analog input. Measured on the K1:
idle 518, message range 165-894 (about 0.6 V p-p), no clipping.
- The receive audio must be on (AF output enabled, audio path on): the speaker
plays the burst.
- **PA4 bias**: the pin has no DC reference; the app holds it at mid-scale with
the MCU DAC (output buffer off, code 2048) for the whole run. The volume knob is analog; whether it changes the PA4 level
still has to be checked.
- Tuning: first-generation channels span 406.025-406.040 MHz. One VFO at
406.031 MHz with the wide filter covers them all; a carrier offset only shifts
the audio DC level, which the decoder removes.
## First-generation message (C/S T.001)
400 bps biphase-L, ±1.1 rad phase modulation. About 160 ms of unmodulated carrier,
then 112 (short) or 144 (long) bits, one burst about every 50 s.
| Bits | Content |
|---|---|
| 1-15 | Bit sync, all ones |
| 16-24 | Frame sync: `000101111` normal, `011010000` self-test |
| 25 | Format flag: 1 = long |
| 26 | Protocol flag: 0 = location protocols, 1 = user protocols |
| 27-36 | Country code (France 227/228) |
| 37-40 | Protocol code (37-39 for user protocols) |
| 41-85 | Identification and coarse position (PDF-1) |
| 86-106 | BCH-1, BCH(82,61) over bits 25-106 |
| 107-132 | Fine position offsets and flags (PDF-2, long messages) |
| 133-144 | BCH-2, BCH(26,14) over bits 107-144 |
Standard location protocols (codes 0010-0111, 1100, 1110): bits 41-64
identification, 65-85 coarse position (N/S, degrees, 15' steps), 107-110 `1101`,
111 position source, 112 121.5 MHz homing, 113-132 offsets (±minutes, 4 s steps).
The 15-hex ID is bits 26-85 with the position replaced by the default pattern.
## Decoder (`dec406.c`)
Streaming, one call per ADC sample, integer only, no division per sample,
freestanding (no libc). About 150 bytes of state.
1. **DC removal**: exponential tracker, fast while searching (~13 ms, so the
noise before the burst does not bias it), slow once locked (~107 ms).
2. **Phase rebuild**: the discriminator outputs dphi/dt, a pulse per phase flip.
A leaky integrator (~1.7 ms) rebuilds the ±1.1 rad biphase waveform.
3. **Clock recovery**: a DPLL (gain 1/8) aligns 12-sample half-bit slots on the
zero crossings of the rebuilt waveform.
4. **Sync search**: the signs of the last 44 half-bits are compared with 13
preamble ones + frame sync, normal and self-test, in both polarities (the
receive chain may invert). Up to 2 mismatches allowed.
5. **Bits**: soft decision per bit, first half minus second half.
6. **Parsing**: BCH-1 and BCH-2 syndromes, country, protocol, 15-hex ID, position
for standard location protocols (coarse + fine offsets).
Size for Cortex-M0+ (firmware toolchain, `-Os`): 1,468 bytes of code, after the
size work described above.
## Tests (`test/`)
```
test/run_tests.sh
```
- `frame406.py` builds frames: the bench generator's reference frame (long,
standard location test, country 227, ID `1C7C2468ACFFBFF`), a self-test
variant, a short message, or a frame with one bit flipped.
- `gen406.py` synthesizes what PA4 sees: biphase phase modulation with
raised-cosine transitions, carrier offset, noise in a 25 kHz channel, FM
discriminator, audio low-pass, AC coupling, 9.6 kHz ADC with clock error,
12-bit around the measured 518 bias, full-scale noise when no carrier.
- `host_dec406.c` runs the decoder over the samples and prints each message.
Results (2026-09-27): 29/29 pass. The first 17 cases, covering CNR 12-15 dB, ±5 kHz offset,
inverted chain, ±3000 ppm clock error, 50-250 µs rise time, 3 kHz audio
low-pass, 150 Hz AC coupling, a combined worst case, self-test, short message,
a corrupted bit (BCH-1 reported as failed) and 3 bursts in a row.
Sensitivity (5 seeds per point, CNR in 25 kHz):
| CNR | 12 dB | 10 dB | 9 dB | 8 dB | 7 dB | 6 dB |
|---|---|---|---|---|---|---|
| Decoded, BCH ok | 5/5 | 4/5 | 4/5 | 3/5 | 1/5 | 0/5 |
Possible gains later: BCH error correction (BCH-1 corrects up to 3 bit errors,
BCH-2 up to 2), and a 12.5 kHz filter when the beacon channel is known (+3 dB).
## Flipper Zero test transmitter (`test/flipper406.py`)
Without the rpitx generator, a Flipper Zero is enough to test the app on the bench.
It cannot phase-modulate, so each biphase-L half-bit is sent as one 2-FSK tone
(preset `2FSKDev238Async`, +/-2.38 kHz, Sub-GHz RAW durations of 1,250 us, 160 ms
of lead tone, 20 ms of tail). The receiver's discriminator then outputs the
biphase waveform itself instead of the pulses of a real beacon, and the app's
leaky integrator still tracks its sign: checked first with a pure-Python model of
the receive chain and dec406 (exact frame down to ~2.5 kHz rms of discriminator
noise, +/-4 kHz offset, 2000 ppm clock error), then on the radio.
```
test/flipper406.py test/flipper
```
writes `epirb406_long.sub` (reference frame), `_selftest`, `_short` and
`_bch_err` (bit 50 flipped, BCH-1 fails), on 433.650 MHz (433 MHz SRD band). Copy
them to `subghz/` on the Flipper, set the K1 to 433.650 MHz FM wide, launch the
app, then Send one file per burst, at least 2 s apart. If the Flipper's crystal
puts the carrier off the channel, tune with UP/DOWN. Never transmit these files
on 406.0-406.1 MHz.
## Bench results
**2026-09-27, UV-K1, generator on 433.650 MHz, DIR mode, first on-air decode.**
Sync found, but the message is wrong in a systematic way:
| | Sent | Received |
|---|---|---|
| 15-hex ID | `1C7C2468ACFFBFF` | `0C3C002004FFBFF` |
| Country | 227 | 97 |
| Position | 49.27111 N 0.78222 E | 16.00000 N 0.25000 E (coarse) |
| BCH | ok / ok | ERR / ERR |
Over bits 26-64: every `1` preceded by a `0` is read as `0` (9/9), every `1`
preceded by a `1` is correct (8/8), no `0` is ever wrong. Timing and sync are
therefore right; the decision fails where biphase-L has no transition at the bit
boundary (`0` then `1`), which depends on the real signal shape at PA4.
None of the simulated chains reproduce this exact pattern (pulse or de-emphasized
signal, low-pass, AC coupling, inverted: INT always decodes). Simulated ADC
clipping at 0 (PA4 bias is only 518/4095) does break INT decoding entirely, which
may explain why DIR was in use.
**Diagnosis with 406 Lab scope mode (v1.1-v1.5).** Sampled at 9.6 kHz, PA4 slid
towards 0 V as soon as fast sampling started, and about half of the message
samples were clipped at 0 whatever the AF gain or RAW setting (details in
`../lab406/README.md`). PA4 has no DC reference of its own: the audio reaches it
through a coupling capacitor, and fast ADC sampling drags the node down.
**Fix.** The MCU DAC on PA4, output buffer off, set to mid-scale (2048), holds
the pin at about VDD/2 as a weak bias. Measured with it (AF gain 14, RAW):
carrier 2048-2049, message 1896-2194, no clipping. EPIRB 406 v1.2 turns this bias
on at launch and restores the DAC, its clock and the pin on exit. The host test
generator now uses the measured bias (2048) and level (~150 LSB peak); 17/17
still pass.
**v1.2 on the bench (INT mode):** ID `1C7C2468ACFFBFF`, country 227, "Std test",
BCH-1 OK: the first block (bits 25-106) is received intact and BCH-1 is confirmed
on a real frame. BCH-2 fails, so the fine position offsets and the homing flag
are not applied (`49.25000N 0.75000E coarse`, no `121.5`). Either BCH-2 is
computed differently from the generator, or the last bits of the burst are
received with errors. v1.3 shows the raw end of the frame (`END` + bits 105-144
as hex) to tell which: an error-free reception of the reference frame reads
`END F58521EDA3`.
**v1.3 on the bench.** The generator sends exactly the reference frame
(`FFFE2F8E3E12345631401FB07DF58521EDA3`), so BCH-2 is computed correctly. The
received end changed from burst to burst (`F68521E704`, `D5786733DD`): errors
growing towards the end of the burst, not a fixed pattern.
**Root cause: DC tracker rounding.** The mean was updated with `e >> 10`; an
arithmetic right shift floors, so every small negative deviation moved the mean
down by one step and small positive ones never moved it up. The mean crept
downwards during the message, the leaky integrator accumulated the offset, and
the last bits went wrong. Negligible with the large swing of the first model,
fatal at the ~150 LSB swing measured on PA4. Reproduced on the host at the
measured level (BCH-1 ok / BCH-2 fail, 3 of 4 seeds at 30 dB CNR), fixed with
round-to-nearest: 4/4 from 30 down to 12 dB, and from -3% to +2% bit-rate error.
A second-order (bit-rate tracking) loop was tried and dropped: no gain once the
rounding was fixed.
The test suite now has 12 more cases at the measured level (CNR 12 dB, ±2%
clock, 4 noise seeds each): the old decoder fails 6 of them with the radio's
symptom, the fixed one passes all 29.
**Recordings from the radio (406 Rec, 2026-09-27).** Three bursts recorded
on the K1 and decoded on the host give exactly the radio's result (sync found,
polarity inverted, ID correct, BCH FAIL/FAIL). Activity per 10 ms shows carrier
until 150 ms after the trigger, modulation until **410 ms**, then noise: the
carrier is gone. 260 ms of modulation instead of 360 ms, so only ~104 of the 144
bits are on air, at the same point on every burst; the bit rate itself is exact
(the preamble pattern repeats every 24 samples at 9.6 kHz). A synthetic burst cut
after 106 bits decodes as BCH ok/FAIL (the v1.2 result) and after 104 bits as
FAIL/FAIL with the correct ID (the v1.4 result). **The bench generator (rpitx
playing the IQ file) drops the last ~100 ms of the burst**; the decoder is not at
fault for the end of the frame. Fix on the generator side: pad the IQ file with
at least 150 ms of carrier after the last bit. The recordings are kept outside
the repo in `~/dev/uv-k1-406-captures`.
**Correction.** The generator is not at fault: an RTL-SDR on the Mac decodes the
full burst on air. 406 Rec v1.1 (RSSI every 10 ms) showed RSSI falling to the
noise floor (about -93 dBm) 410-420 ms after the trigger on every burst: the
signal left the K1's channel, most likely a frequency move of the rpitx carrier
near the end of the burst that a wideband RTL-SDR decoder follows and a 25 kHz
channel does not. (The RSSI read during fast sampling showed a constant -21 dBm,
not the -64 to -75 dBm of the trigger; only its change is meaningful. v1.1 is a
diagnostic build: the RSSI reads disturb the sample timing.)
**Full decode on the radio (2026-09-27).** With the K1 VFO **5 kHz below** the
generator frequency (433.645 MHz for 433.650), EPIRB 406 v1.4 decoded the whole
reference frame: ID `1C7C2468ACFFBFF`, country 227, "Std test", position
49.27111 N 0.78222 E, `END F58521EDA3` (all 144 bits intact, BCH-2 ok). This confirms the explanation above: at
433.650 the rpitx carrier moves down near the end of the burst and leaves the
channel; 5 kHz lower, it stays inside. A steady carrier offset only shifts the
discriminator's DC level, which the decoder removes. Real first-generation
beacons have far more stable oscillators, but the offset matters there too: one
VFO at 406.031 MHz with the wide filter must cover 406.025-406.040.
The earlier fixes stand on their own: the PA4 bias (v1.2) removed the clipping
that caused the "every 1 after a 0" errors, and the DC tracker rounding (v1.4) is
a real bias reproduced on the host at the measured level.
## Open points
- **Default position pattern** in the 15-hex ID (`0 1111111 11 0 11111111 11`)
follows the reference decoder; to confirm against T.001.
- **BCH generators** are the T.001 ones. They are only checked against frames
built with the same code; the bench generator's real frame
(`FFFE2F8E3E12345631401FB07DF58521EDA3` expected) will confirm them.
- **Protocol name table** to check against T.001; national location, RLS and
ELT-DT positions are not decoded, and their ID is shown as raw bits 26-85.
- The synthetic chain is a model: the first real captures from PA4 may need the
integrator or DC constants retuned (`dec406_init(..., integrate)` also allows a
phase-like input if the hardware turns out to integrate already).
- Second-generation beacons (spread-spectrum OQPSK) are out of scope.
## Version
`APP_VER` in `build.sh` is bumped for every build that goes on a radio. It is
written into the title text of the assets by `gen_assets.py` and shown in the
status-bar title (e.g. `v1.1`), since the apps menu does not display versions.
Current: **v1.7**.
+43
View File
@@ -0,0 +1,43 @@
/* Overlay-app link script (POC).
*
* The blob is linked to run at the PY25Q16 sector-cache overlay VMA (shared with
* the multiboot RAM stub — never both active at once). Code + rodata + data +
* bss must all fit in the 4 KiB overlay. objcopy -O binary emits text+rodata+
* data only; the loader zeroes the overlay before copying, so bss starts clean.
*
* VMA must match ADDR(.mb_workspace) in Core/py32f071xb.ld. If the firmware RAM
* layout shifts, update APP_VMA and the loader's compile-time assert catches a
* mismatch. */
/* APP_VMA is the target firmware's __mb_workspace_start (read from its .map). It
* varies with the RAM layout, so pass it from the build: -Wl,--defsym,APP_VMA=0x...
* The default is only a fallback for a standalone size check. */
APP_VMA = DEFINED(APP_VMA) ? APP_VMA : 0x20000280;
APP_LENGTH = 0x1000; /* 4 KiB sector-cache overlay */
ENTRY(app_main)
MEMORY {
APP (rwx) : ORIGIN = APP_VMA, LENGTH = APP_LENGTH
}
SECTIONS {
.app APP_VMA : {
KEEP(*(.text.entry)) /* app_main pinned to offset 0 */
*(.text .text.*)
*(.rodata .rodata.*)
. = ALIGN(4);
*(.data .data.*)
. = ALIGN(4);
__app_bss_start = .;
*(.bss .bss.* COMMON)
. = ALIGN(4);
__app_bss_end = .;
} > APP
__app_end = .;
ASSERT(__app_end <= APP_VMA + APP_LENGTH,
"EPIRB 406 overlay app overflows the 4 KiB overlay")
/DISCARD/ : { *(.ARM.exidx*) *(.ARM.extab*) *(.eh_frame*) *(.comment) *(.note.*) }
}
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Build one overlay-app blob (.app). Invoked by ../../../compile-app.sh inside
# the uvk1-uvk5v3 Docker image, run from this app's directory. Everything is
# derived from the directory name; only APP_NAME - the human label baked into
# the 64-byte blob header - is per-app. The .app file is named after it (spaces
# stripped), so "Broadcast FM" -> BroadcastFM.app.
set -euo pipefail
APP="$(basename "$PWD")" # breakout, foxhunt, beacon, fm, ...
APP_NAME="EPIRB 406" # <-- the only per-app line
APP_VER="1.7"
APP_API_MIN=2
APP_VMA=${APP_VMA:-0x20000280} # pinned overlay VMA (Core/py32f071xb.ld)
OUT="${APP_NAME// /}" # blob basename ("Broadcast FM" -> BroadcastFM)
CC=/opt/toolchain/bin/arm-none-eabi-gcc
OBJCOPY=/opt/toolchain/bin/arm-none-eabi-objcopy
command -v arm-none-eabi-gcc >/dev/null 2>&1 && { CC=arm-none-eabi-gcc; OBJCOPY=arm-none-eabi-objcopy; }
CFLAGS="-mcpu=cortex-m0plus -mthumb -Os -fno-jump-tables -std=gnu11 -ffreestanding -fno-builtin -fno-common \
-fomit-frame-pointer -ffunction-sections -fdata-sections -Wall -Wextra"
LDFLAGS="-nostdlib -nostartfiles -T app.ld -Wl,--defsym,APP_VMA=${APP_VMA} \
-Wl,--gc-sections -Wl,-Map=${APP}.map -Wl,--build-id=none -Wl,--no-warn-rwx-segments"
rm -f ./*.app ./*.elf ./*.bin # drop stale artifacts so discovery is unambiguous
step() { printf '\r 🔨 %-13s [%d/4] %-8s' "$APP_NAME" "$1" "$2"; }
trap 'printf "\r ❌ %-13s build failed \n" "$APP_NAME"' ERR
step 1 assets ; APP_VER="$APP_VER" python3 ./gen_assets.py "${APP}_assets.bin" "${APP}_assets.h"
step 2 compile ; "$CC" $CFLAGS $LDFLAGS "${APP}_app.c" -lgcc -o "${APP}.elf"
step 3 objcopy ; "$OBJCOPY" -O binary "${APP}.elf" "${APP}.bin"
step 4 pack ; python3 ../pack_app.py "${APP}.bin" "${OUT}.app" \
--name "$APP_NAME" --ver "$APP_VER" --api-min "$APP_API_MIN" --vma "${APP_VMA}" \
--shortcut none --assets "${APP}_assets.bin" >/dev/null
trap - ERR
BYTES=$(wc -c < "${APP}.bin")
if [ "$BYTES" -gt 4096 ]; then
printf '\r 🚨 %-13s OVERFLOWS 4 KiB (%d B) \n' "$APP_NAME" "$BYTES"; exit 1
fi
printf '\r ✅ %-13s %4d B (%d%% of 4 KiB) -> %s.app \n' \
"$APP_NAME" "$BYTES" "$(( BYTES * 100 / 4096 ))" "$OUT"
+242
View File
@@ -0,0 +1,242 @@
/* Copyright 2026 Johan Denoyer F4WAT
* https://github.com/jdenoy
*
* 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.
*/
#include "dec406.h"
#define SLOT ((int32_t)DEC406_HALF * 256) /* half-bit slot, Q8 samples */
#define MEAN_SRCH 7 /* DC tracker while searching: ~13 ms at 9.6 kHz */
#define MEAN_DATA 10 /* DC tracker once locked: ~107 ms */
#define LEAK_SHR 4 /* phase integrator leak: ~1.7 ms, bounds DC error gain */
#define PLL_SHR 3 /* zero-crossing correction gain 1/8 */
#define SYNC_TOL 2 /* half-bit mismatches allowed in preamble + sync */
/* 13 preamble ones + frame sync, as 44 half-bit signs (1 = "10", 0 = "01"),
* split in a 12-bit high part and a 32-bit low part */
#define PAT_BITS 44u
#define PAT_HI_MASK 0xFFFu
#define PAT_HI 0xAAAu /* same for both syncs */
#define PAT_NORMAL 0xAAA959AAu /* sync 000101111 */
#define PAT_SELFTEST 0xAAA9A655u /* sync 011010000 */
/* BCH generators (C/S T.001): PDF-1 BCH(82,61), PDF-2 BCH(26,14) */
#define BCH1_GEN 0x26D9E3u /* x^21+x^18+x^17+x^15+x^14+x^12+x^11+x^8+x^7+x^6+x^5+x+1 */
#define BCH2_GEN 0x1539u /* x^12+x^10+x^8+x^5+x^4+x^3+1 */
/* Standard location default position, bits 65-85, as used for the 15-hex ID.
* Matches the reference decoder output for ID 1C7C2468ACFFBFF; to be confirmed
* against T.001. */
#define STD_DEFAULT_POS 0x0FFBFFu /* 0 1111111 11 0 11111111 11 */
/* Bit count, one pass per set bit: at most 4 x 32 passes per half-bit (every
* 12 samples, and only while searching), far inside the 5000 cycles of a
* sample period; 36 bytes smaller than the branch-free version. */
static uint8_t popc32(uint32_t x)
{
uint8_t n = 0;
for (; x; x &= x - 1u) n++;
return n;
}
/* Match the last 44 half-bits against one sync pattern in both polarities:
* returns 1 (normal), 2 (inverted) or 0. Inverted distance = 44 - distance. */
static uint8_t match(const dec406_t *d, uint32_t lo)
{
uint8_t n = (uint8_t)(popc32((d->hsrHi ^ PAT_HI) & PAT_HI_MASK) + popc32(d->hsrLo ^ lo));
if (n <= SYNC_TOL) return 1;
if (n >= PAT_BITS - SYNC_TOL) return 2;
return 0;
}
void dec406_rearm(dec406_t *d)
{
d->lvl = 0; d->acc = 0; d->h1 = 0; d->ph = 0; d->hsrHi = d->hsrLo = 0;
d->state = DEC406_SEARCH; d->half = 0; d->inv = 0; d->selftest = 0;
d->sign = 0; d->nbits = 0; d->total = 0;
for (uint8_t i = 0; i < sizeof d->bits; i++) d->bits[i] = 0;
}
void dec406_init(dec406_t *d, bool integrate)
{
d->meanQ8 = -1; /* seeded by the first sample */
d->integrate = integrate ? 1u : 0u;
dec406_rearm(d);
}
static void putBit(dec406_t *d, uint8_t b)
{
if (b) d->bits[d->nbits >> 3] |= (uint8_t)(0x80u >> (d->nbits & 7u));
d->nbits++;
}
static void emitHalf(dec406_t *d, int32_t h)
{
d->hsrHi = (d->hsrHi << 1) | (d->hsrLo >> 31);
d->hsrLo = (d->hsrLo << 1) | (h > 0 ? 1u : 0u);
if (d->state == DEC406_SEARCH) {
uint8_t m = match(d, PAT_NORMAL), st = 0;
if (!m) { m = match(d, PAT_SELFTEST); st = 1; }
if (m) {
d->inv = (uint8_t)(m - 1u); d->selftest = st;
d->state = DEC406_DATA; d->half = 0; d->nbits = 0;
}
return;
}
if (d->state != DEC406_DATA) return;
if (!d->half) { d->h1 = h; d->half = 1; return; }
d->half = 0;
/* biphase-L: "1" = high then low, "0" = low then high (soft decision) */
uint8_t b = (uint8_t)(((d->h1 - h) > 0) ^ d->inv);
putBit(d, b);
if (d->nbits == 1) d->total = b ? 120u : 88u; /* bit 25: long / short */
if (d->nbits >= d->total) d->state = DEC406_DONE;
}
bool dec406_push(dec406_t *d, uint16_t sample)
{
if (d->state == DEC406_DONE) return true;
int32_t xq = (int32_t)sample << 8;
if (d->meanQ8 < 0) d->meanQ8 = xq;
int32_t e = xq - d->meanQ8;
/* round to nearest: a plain >> floors, so small negative errors always moved
* the mean down and small positive ones never moved it up; at the ~150 LSB
* swing measured on PA4 that downward creep corrupted the end of the frame */
uint8_t msh = d->state == DEC406_SEARCH ? MEAN_SRCH : MEAN_DATA;
d->meanQ8 += (e + (1 << (msh - 1))) >> msh;
int32_t v = e >> 4;
if (d->integrate) d->lvl += v - (d->lvl >> LEAK_SHR);
else d->lvl = v;
/* DPLL: phase-waveform zero crossings sit on half-bit slot boundaries */
uint8_t s = d->lvl > 0 ? 1u : 0u;
if (s != d->sign) {
int32_t err = d->ph;
if (err > SLOT / 2) err -= SLOT;
d->ph -= err >> PLL_SHR;
if (d->ph < 0) d->ph += SLOT;
d->sign = s;
}
d->acc += d->lvl;
d->ph += 256;
if (d->ph >= SLOT) {
d->ph -= SLOT;
emitHalf(d, d->acc);
d->acc = 0;
}
return d->state == DEC406_DONE;
}
/* ---- parsing ---- */
static uint8_t bit(const dec406_t *d, uint8_t n) /* n = 25..144 */
{
uint8_t i = (uint8_t)(n - 25u);
return (uint8_t)((d->bits[i >> 3] >> (7u - (i & 7u))) & 1u);
}
static uint32_t field(const dec406_t *d, uint8_t first, uint8_t len)
{
uint32_t v = 0;
for (uint8_t i = 0; i < len; i++) v = (v << 1) | bit(d, (uint8_t)(first + i));
return v;
}
/* Remainder of the codeword spanning bits first..last: 0 when valid. */
static uint32_t syndrome(const dec406_t *d, uint8_t first, uint8_t last, uint32_t gen, uint8_t r)
{
uint32_t reg = 0;
for (uint8_t n = first; n <= last; n++) {
reg = (reg << 1) | bit(d, n);
if (reg >> r) reg ^= gen;
}
return reg;
}
static bool isStdLoc(uint8_t code)
{
/* 0010 EPIRB MMSI, 0011 ELT 24-bit, 0100 ELT serial, 0101 ELT op. designator,
* 0110 EPIRB serial, 0111 PLB serial, 1100 ship security, 1110 test */
return (code >= 2u && code <= 7u) || code == 12u || code == 14u;
}
void dec406_parse(const dec406_t *d, dec406_info_t *o)
{
o->longMsg = bit(d, 25);
o->selftest = d->selftest;
o->bch1 = syndrome(d, 25, 106, BCH1_GEN, 21) == 0;
o->bch2 = o->longMsg ? syndrome(d, 107, 144, BCH2_GEN, 12) == 0 : 1u;
o->userProto = bit(d, 26);
o->country = (uint16_t)field(d, 27, 10);
o->proto = (uint8_t)(o->userProto ? field(d, 37, 3) : field(d, 37, 4));
o->stdLoc = !o->userProto && isStdLoc(o->proto);
o->idRaw = !o->stdLoc;
o->idData = field(d, 41, 24);
o->hasPos = o->hasFine = o->internalPos = o->homing = 0;
o->latS = o->lonS = 0;
for (uint8_t k = 0; k < 15; k++) {
uint8_t nib = 0;
for (uint8_t j = 0; j < 4; j++) {
uint8_t n = (uint8_t)(26u + 4u * k + j);
uint8_t b = (o->stdLoc && n >= 65u) ? (uint8_t)((STD_DEFAULT_POS >> (85u - n)) & 1u) : bit(d, n);
nib = (uint8_t)((nib << 1) | b);
}
o->id[k] = (char)(nib < 10u ? '0' + nib : 'A' - 10 + nib); /* no hex table */
}
o->id[15] = '\0';
if (!o->stdLoc) return;
if (field(d, 65, 21) != STD_DEFAULT_POS) {
int32_t lat = (int32_t)field(d, 66, 7) * 3600 + (int32_t)field(d, 73, 2) * 900;
int32_t lon = (int32_t)field(d, 76, 8) * 3600 + (int32_t)field(d, 84, 2) * 900;
if (o->longMsg && o->bch2 && field(d, 107, 4) == 0xDu) {
uint32_t am = field(d, 114, 5), as = field(d, 119, 4);
uint32_t om = field(d, 124, 5), os = field(d, 129, 4);
if (am <= 30u && om <= 30u) {
int32_t dlat = (int32_t)(am * 60u + as * 4u), dlon = (int32_t)(om * 60u + os * 4u);
lat += bit(d, 113) ? dlat : -dlat;
lon += bit(d, 123) ? dlon : -dlon;
o->hasFine = 1;
}
}
o->latS = bit(d, 65) ? -lat : lat;
o->lonS = bit(d, 75) ? -lon : lon;
o->hasPos = 1;
}
if (o->longMsg && o->bch2) { o->internalPos = bit(d, 111); o->homing = bit(d, 112); }
}
const char *dec406_proto_name(const dec406_info_t *in)
{
/* 16 location protocol names (bits 37-40), then 8 user protocol names
* (bits 37-39), packed to avoid a pointer table */
static const char NAMES[] =
"Loc 0000\0Loc 0001\0EPIRB MMSI\0ELT 24bit\0ELT serial\0ELT opdes\0"
"EPIRB ser\0PLB serial\0Nat ELT\0ELT-DT\0Nat EPIRB\0Nat PLB\0"
"Ship sec\0RLS\0Std test\0Nat test\0"
"Orbito\0ELT avia\0Maritime\0Serial\0National\0Spare\0Callsign\0User test";
uint8_t n = in->userProto ? (uint8_t)(16u + (in->proto & 7u)) : (uint8_t)(in->proto & 15u);
const char *p = NAMES;
while (n--) { while (*p) p++; p++; }
return p;
}
+88
View File
@@ -0,0 +1,88 @@
/* Copyright 2026 Johan Denoyer F4WAT
* https://github.com/jdenoy
*
* 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.
*/
/*
* dec406: streaming decoder for first-generation Cospas-Sarsat 406 MHz beacon
* messages, fed with FM-discriminator audio samples (the BK4829 RAW RX audio as
* read on PA4). Freestanding C (no libc), no division in the per-sample path, so
* the same code runs in the overlay app and in the host test harness.
*
* Signal: 400 bps biphase-L, +/-1.1 rad phase modulation. The discriminator gives
* dphi/dt (a pulse per phase flip); a leaky integrator rebuilds the phase
* waveform, a DPLL on its zero crossings tracks the half-bit clock, and the
* half-bit signs are matched against 13 preamble ones + frame sync, in both
* polarities (the receive chain may invert). Bit n of the message (1-based, as in
* C/S T.001) for n = 25..144 is then stored MSB first in bits[].
*/
#ifndef DEC406_H
#define DEC406_H
#include <stdint.h>
#include <stdbool.h>
#define DEC406_FS 9600u /* sample rate expected by the decoder (Hz) */
#define DEC406_HALF 12u /* samples per half-bit at 400 bps */
enum { DEC406_SEARCH = 0, DEC406_DATA = 1, DEC406_DONE = 2 };
typedef struct {
int32_t meanQ8; /* input DC estimate, x256 */
int32_t lvl; /* rebuilt phase waveform */
int32_t acc; /* sum of lvl over the current half-bit slot */
int32_t h1; /* first half of the bit being received */
int32_t ph; /* position in the half-bit slot, Q8 */
uint32_t hsrHi, hsrLo; /* last 64 half-bit signs, newest in hsrLo bit 0 */
uint8_t state, half, inv, selftest, integrate, sign, nbits, total;
uint8_t bits[15]; /* message bits 25..144, MSB first */
} dec406_t;
typedef struct {
uint8_t longMsg; /* bit 25 */
uint8_t selftest; /* self-test frame sync */
uint8_t bch1, bch2; /* 1 = BCH ok (bch2 is 1 on short messages) */
uint8_t userProto; /* bit 26: 1 = user / user-location protocols */
uint8_t proto; /* protocol code: bits 37-40, or 37-39 if user */
uint8_t stdLoc; /* standard location protocol (position decoded) */
uint8_t hasPos; /* position present (not the default pattern) */
uint8_t hasFine; /* PDF-2 offsets applied */
uint8_t internalPos; /* bit 111: 1 = internal navigation device */
uint8_t homing; /* bit 112: 121.5 MHz homing */
uint8_t idRaw; /* 1 = ID is bits 26-85 as sent (not std location) */
uint16_t country; /* bits 27-36 */
uint32_t idData; /* std location: bits 41-64 */
int32_t latS, lonS; /* position in arc seconds, N and E positive */
char id[16]; /* 15-hex beacon ID, NUL terminated */
} dec406_info_t;
/* integrate: 1 = input is discriminator output (dphi/dt), rebuild the phase;
* 0 = input is already phase-like (use it directly). */
void dec406_init(dec406_t *d, bool integrate);
/* Re-arm for the next burst (keeps the DC estimate). */
void dec406_rearm(dec406_t *d);
/* Feed one sample (any unsigned ADC scale). Returns true when a full message
* has been received (state DEC406_DONE); call dec406_parse, then dec406_rearm. */
bool dec406_push(dec406_t *d, uint16_t sample);
/* BCH checks and field extraction. */
void dec406_parse(const dec406_t *d, dec406_info_t *out);
/* Short protocol name for display. */
const char *dec406_proto_name(const dec406_info_t *in);
#endif /* DEC406_H */
+326
View File
@@ -0,0 +1,326 @@
/* Copyright 2026 Johan Denoyer F4WAT
* https://github.com/jdenoy
*
* 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.
*/
/*
* EPIRB 406 (RX only) - decodes first-generation Cospas-Sarsat 406 MHz beacon
* messages on the radio. Tune the VFO (FM, wide; 406.031 MHz covers the
* 406.025-406.040 channels, or a test generator's frequency), then launch.
*
* The receiver is switched to RAW (RX HPF300 / LPF3K / de-emphasis and AFC off)
* and the RX audio is left on: it reaches PA4 (the voice DAC pin, unused while
* voice is disabled), held at mid-scale by the MCU DAC (unbuffered) and sampled
* on ADC channel 4 at 9.6 kHz, timed from
* SysTick, from the moment RSSI shows a burst. Samples go straight into dec406
* (see README.md); the last decoded message stays on screen.
*
* Keys (UV-K5 and UV-K1): UP/DOWN tune +/-5 kHz from the VFO (up to +/-50 kHz,
* while the app runs; direction as set by nav_dir) · 5 back to the VFO frequency
* MENU clear · EXIT quit. The speaker plays the bursts: lower the volume.
* The loader re-runs RADIO_SetupRegisters on exit; the app restores the ADC,
* PA4, the DAC and its clock itself.
* Screen texts and protocol names are read-only assets (gen_assets.py, API
* level 2), kept out of the 4 KiB overlay.
*/
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "../app_api.h"
#include "epirb406_assets.h" /* generated by gen_assets.py (title carries APP_VER) */
#include "dec406.c"
/* ---- MCU registers (PY32F071, core and SysTick at 48 MHz, 10 ms period) ---- */
#define SYST_LOAD (*(volatile uint32_t *)0xE000E014u)
#define SYST_VAL (*(volatile uint32_t *)0xE000E018u)
#define ADC_SR (*(volatile uint32_t *)0x40012400u)
#define ADC_CR2 (*(volatile uint32_t *)0x40012408u)
#define ADC_SMPR3 (*(volatile uint32_t *)0x40012414u)
#define ADC_SQR3 (*(volatile uint32_t *)0x40012438u)
#define ADC_DR (*(volatile uint32_t *)0x40012450u)
#define GPIOA_MODER (*(volatile uint32_t *)0x50000000u)
#define DAC_CR (*(volatile uint32_t *)0x40007400u)
#define DAC_SWTRIGR (*(volatile uint32_t *)0x40007404u)
#define DAC_DHR12R1 (*(volatile uint32_t *)0x40007408u)
#define RCC_APBENR1 (*(volatile uint32_t *)0x4002103Cu)
#define RCC_DACEN (1u << 29)
/* DAC channel 1 on, output buffer off, software trigger (TSEL1 = 111) */
#define DAC_CR_BIAS ((1u << 0) | (1u << 1) | (1u << 2) | (7u << 3))
#define BIAS_CODE 2048u
#define ADC_SR_EOC (1u << 1)
#define ADC_CR2_START ((1u << 22) | (1u << 20)) /* SWSTART | EXTTRIG */
#define SMP8_POS 24u
#define SMP4_POS 12u
#define ADC_CH_PA4 4u
#define CYC_PER_SAMPLE (48000000u / DEC406_FS) /* 5000 */
#define CAP_MAX_CYC (48000000u / 1000u * 900u) /* 900 ms after trigger */
/* ---- BK4829 RAW receive ---- */
#define REG_2B 0x2B
#define REG_73 0x73
#define REG_30 0x30 /* 0 then back: VCO recalibration, relock on the new frequency */
#define REG_38 0x38 /* RX frequency, 10 Hz units, low 16 bits */
#define REG_39 0x39 /* RX frequency, high 16 bits */
#define STEP_10HZ 500 /* 5 kHz */
#define OFF_MAX 10 /* +/- 10 steps = +/- 50 kHz */
#define TRIG_DB 10
#define REARM_DB 5
#define COOL_MS 1500
#define TICK_MS 50
/* The app state in one struct: a function loads one base address instead of one
* literal-pool word per global. Words, then bytes, then halfwords, so every field
* stays within the Thumb load/store immediate ranges (ldrb <= 31, ldrh <= 62,
* ldr <= 124). The loader zeroes the overlay (.bss) at every launch. */
static struct {
const app_api_t *A;
uint32_t vfoFreq; /* VFO RX frequency at launch, 10 Hz units */
int32_t floorQ; /* noise floor, dBm x64 */
uint32_t tPrev, tCyc; /* SysTick cycle counter */
uint8_t have, seq, prevKey, lastErr;
int8_t offSteps; /* tuning offset from the VFO, 5 kHz steps */
bool running;
int16_t rssi, burstRssi; /* current RSSI, RSSI of the last burst */
uint16_t nSync, nFail;
uint32_t savedSqr3, savedSmpr3, savedModer, savedDac, savedRcc, savedDhr;
dec406_info_t last; /* last decoded message */
} g;
static dec406_t d;
static char str[34];
/* ---- formatting ---- */
static char *put(char *o,const char *s){ while(*s)*o++=*s++; return o; }
/* Formatting by repeated subtraction: no division, so no __udivsi3 in the
* 4 KiB overlay. 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 char *puti(char *o,int32_t v){
static const uint16_t P10[]={10000u,1000u,100u,10u,1u};
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;
}
/* arc seconds -> "49.27111" (5 decimals, truncated) followed by the hemisphere */
static char *putPos(char *o,int32_t s,char pos,char neg){
uint32_t a=(uint32_t)(s<0?-s:s);
o=puti(o,sub(&a,3600u)); *o++='.';
for(uint8_t k=0;k<5;k++){ a*=10u; *o++=(char)('0'+sub(&a,3600u)); }
*o++= s<0?neg:pos;
return o;
}
static void tiny(uint8_t x,uint8_t y,char *end){ *end='\0'; g.A->print_tiny(str,x,y,false,true); }
static void line(uint8_t l,char *end){ *end='\0'; g.A->print_normal(str,0,0,l); }
/* ---- tuning: offset the RX frequency from the VFO (restored by the loader on exit) ---- */
static void tune(void){
uint32_t f=(uint32_t)((int32_t)g.vfoFreq+(int32_t)g.offSteps*STEP_10HZ);
g.A->bk_write(REG_38,(uint16_t)(f&0xFFFFu));
g.A->bk_write(REG_39,(uint16_t)(f>>16));
uint16_t r30=g.A->bk_read(REG_30);
g.A->bk_write(REG_30,0);
g.A->bk_write(REG_30,r30);
}
/* "433.645" from 10 Hz units, rounded to the kHz, without division */
static char *putFreq(char *o,uint32_t f){
uint16_t mhz=0;
f+=50u;
while(f>=100000u){ f-=100000u; mhz++; }
o=puti(o,mhz); *o++='.';
*o++=(char)('0'+sub(&f,10000u)); *o++=(char)('0'+sub(&f,1000u)); *o++=(char)('0'+sub(&f,100u));
return o;
}
/* ---- SysTick cycle counter (call at least every 10 ms) ---- */
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;
}
/* ---- PA4 bias: the pin has no DC reference of its own (AC-coupled audio), so
* 9.6 kHz sampling dragged it to 0 V and clipped half the message (406 Lab scope).
* The MCU DAC on PA4, output buffer off, at mid-scale, holds it at ~VDD/2.
* Set for the whole run, restored on exit. ---- */
static void biasOn(void){
GPIOA_MODER=g.savedModer|(3u<<8); /* PA4 analog */
RCC_APBENR1=g.savedRcc|RCC_DACEN;
DAC_CR=DAC_CR_BIAS;
DAC_DHR12R1=BIAS_CODE;
DAC_SWTRIGR=1u; /* DHR -> DOR */
}
static void biasOff(void){ DAC_CR=g.savedDac; DAC_DHR12R1=g.savedDhr; RCC_APBENR1=g.savedRcc; GPIOA_MODER=g.savedModer; }
/* ---- ADC on PA4 (channel 4); channel 8 (battery) restored afterwards ---- */
static void adcSelPA4(void){
ADC_SMPR3=(g.savedSmpr3&~(7u<<SMP4_POS))|(((g.savedSmpr3>>SMP8_POS)&7u)<<SMP4_POS);
ADC_SQR3=(g.savedSqr3&~0x1Fu)|ADC_CH_PA4;
}
static void adcRestore(void){ ADC_SQR3=g.savedSqr3; ADC_SMPR3=g.savedSmpr3; }
static uint16_t adcRead(void){
ADC_CR2|=ADC_CR2_START;
for(uint16_t n=0; !(ADC_SR&ADC_SR_EOC) && n<2000u; n++){}
return (uint16_t)(ADC_DR&0x0FFFu);
}
/* ---- one burst: sample PA4 at 9.6 kHz into the decoder ---- */
static void capture(void){
dec406_init(&d,true); /* INT: integrate the discriminator pulses */
adcSelPA4();
clkStart();
uint32_t next=0;
bool done=false;
while(next<CAP_MAX_CYC){
while(clkCyc()<next){}
if(dec406_push(&d,adcRead())){ done=true; break; }
next+=CYC_PER_SAMPLE;
}
adcRestore();
if(!done){ g.nFail++; g.lastErr = d.state==DEC406_DATA ? 2u : 1u; return; } /* 1 no sync, 2 cut */
g.nSync++; g.lastErr=0;
dec406_parse(&d,&g.last);
g.burstRssi=g.rssi; /* RSSI at the trigger (not updated while sampling) */
g.seq++;
g.have=1;
}
/* ---- display ---- */
static void draw(void){
/* The UI texts live in the assets: one read per frame into this word-aligned
* stack block, so each text costs a 2-byte sp-relative add instead of a
* literal and its pool word (gen_assets.py keeps every offset aligned). */
char s[UI_SIZE] __attribute__((aligned(4)));
char *o;
g.A->asset_read(0,s,UI_SIZE);
g.A->display_clear();
g.A->status_clear();
g.A->print_inverse(s+T_TITLE,2,0,true,true,(uint8_t)(2u+T_TITLE_CHARS*4u));
g.A->draw_battery();
if(!g.have){
o=put(str,s+T_WAIT); line(1,o);
} else {
const dec406_info_t *in=&g.last;
o=put(str,in->id); line(0,o); /* 15-hex ID */
o=puti(str,in->country); *o++=' ';
/* protocol name straight from the assets: 16 location names (bits 37-40),
* then 8 user names (bits 37-39); the entry's NUL padding ends the line */
uint8_t n=in->userProto ? (uint8_t)(16u+(in->proto&7u)) : (uint8_t)(in->proto&15u);
o+=g.A->asset_read((uint16_t)(T_PROTO+n*T_PROTO_STRIDE),o,T_PROTO_STRIDE);
line(1,o);
if(in->hasPos){ o=putPos(str,in->latS,'N','S'); *o++=' '; o=putPos(o,in->lonS,'E','W'); }
else o=put(str,s+T_NOPOS);
line(2,o);
o=str;
if(in->selftest) o=put(o,s+T_SELFTEST);
o=put(o,in->longMsg?s+T_LONG:s+T_SHORT);
o=put(o,s+T_BCH); o=put(o,in->bch1?s+T_OK:s+T_ERR); *o++='/'; o=put(o,in->bch2?s+T_OK:s+T_ERR);
tiny(0,33,o);
o=str; *o++='#'; o=puti(o,g.seq); *o++=' '; o=puti(o,g.burstRssi); o=put(o,s+T_DBM);
if(in->longMsg){ o=put(o,in->internalPos?s+T_INT:s+T_EXT); if(in->homing) o=put(o,s+T_HOMING); }
if(in->hasPos && !in->hasFine) o=put(o,s+T_COARSE);
if(in->idRaw) o=put(o,s+T_RAWID);
tiny(0,40,o);
}
o=putFreq(str,g.vfoFreq+(uint32_t)((int32_t)g.offSteps*STEP_10HZ));
if(g.offSteps){ *o++=' '; if(g.offSteps>0) *o++='+'; o=puti(o,(int32_t)g.offSteps*5); o=put(o,s+T_KHZ); }
tiny(0,26,o);
o=puti(str,g.rssi); *o++='/'; o=puti(o,g.floorQ/64); o=put(o,s+T_DBMOK); o=puti(o,g.nSync);
o=put(o,s+T_ERRS); o=puti(o,g.nFail);
if(g.lastErr) o=put(o,g.lastErr==1u?s+T_NOSYNC:s+T_CUT);
tiny(0,48,o);
}
/* ---- input ---- */
static void handleKeys(void){
uint8_t key=g.A->get_key();
if(key==APP_KEY_INVALID||key==g.prevKey){ g.prevKey=key; return; }
g.prevKey=key;
switch(key){
case APP_KEY_EXIT: g.running=false; break;
case APP_KEY_5: g.offSteps=0; tune(); break;
case APP_KEY_MENU: g.have=0; g.nSync=g.nFail=0; g.lastErr=0; break;
case APP_KEY_UP:
case APP_KEY_DOWN: {
int8_t n=(int8_t)(g.offSteps+g.A->nav_dir(key));
if(n>=-OFF_MAX && n<=OFF_MAX){ g.offSteps=n; tune(); }
break; }
default: break;
}
}
static void cooldown(void){
for(uint16_t ms=0; ms<COOL_MS; ms+=10){
if(g.A->rssi_dbm() < g.floorQ/64+REARM_DB) break;
g.A->delay_ms(10);
}
}
__attribute__((section(".text.entry"),used))
void app_main(const app_api_t *api){
g.A=api;
g.prevKey=APP_KEY_INVALID; /* the rest of g starts at 0 (overlay zeroed by the loader) */
g.savedSqr3=ADC_SQR3; g.savedSmpr3=ADC_SMPR3; g.savedModer=GPIOA_MODER; g.savedDac=DAC_CR;
g.savedRcc=RCC_APBENR1; g.savedDhr=DAC_DHR12R1;
biasOn();
g.A->backlight_on();
g.vfoFreq=g.A->rx_freq();
g.A->bk_write(REG_2B,(uint16_t)((g.A->bk_read(REG_2B)|0x0700u)&~0x0007u));
g.A->bk_write(REG_73,(uint16_t)(g.A->bk_read(REG_73)|0x0010u));
g.A->audio_path(true);
g.A->set_af(APP_AF_FM);
g.A->delay_ms(50);
int32_t f=0;
for(uint8_t i=0;i<16;i++){ f+=g.A->rssi_dbm(); g.A->delay_ms(5); }
g.floorQ=f*4;
g.running=true;
while(g.running){
for(uint8_t i=0;i<TICK_MS;i++){
g.rssi=g.A->rssi_dbm();
if(g.rssi>=g.floorQ/64+TRIG_DB){
capture();
draw(); g.A->blit_status(); g.A->blit_full();
cooldown();
break;
}
g.floorQ+=((int32_t)g.rssi*64-g.floorQ)/64;
g.A->delay_ms(1);
}
handleKeys();
if(!g.running) break;
draw();
g.A->blit_status();
g.A->blit_full();
g.A->battery_sample();
g.A->backlight_on(); /* keep the screen lit while waiting for bursts (and after a key) */
}
biasOff(); /* the ADC is already restored after each capture */
g.A->set_af(APP_AF_MUTE);
g.A->audio_path(false);
}
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env python3
# EPIRB 406 read-only assets: the screen texts and the protocol names.
#
# The UI texts come first, each padded to a multiple of 4 bytes: draw() reads
# them in one asset_read into a word-aligned stack block, so every text costs a
# 2-byte sp-relative add instead of a literal-pool load and a pool word.
# The protocol names are parsed from dec406.c (NAMES in dec406_proto_name), so
# the host test and the app share one table.
#
# APP_VER=1.6 ./gen_assets.py epirb406_assets.bin epirb406_assets.h
import os, re, sys
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
def proto_names():
src = open(os.path.join(HERE, "dec406.c")).read()
m = re.search(r"static const char NAMES\[\]\s*=(.*?);", src, re.S)
if not m:
sys.exit("dec406.c: NAMES table not found")
packed = "".join(re.findall(r'"((?:[^"\\]|\\.)*)"', m.group(1)))
names = packed.split("\\0")
if len(names) != 24:
sys.exit(f"dec406.c: {len(names)} protocol names, 24 expected")
return names
TITLE = "EPIRB 406 v" + os.environ.get("APP_VER", "dev")
UI = [
("T_TITLE", TITLE),
("T_WAIT", "Waiting..."),
("T_NOPOS", "no position"),
("T_SELFTEST", "SELF-TEST "),
("T_LONG", "LONG"),
("T_SHORT", "SHORT"),
("T_BCH", " BCH "),
("T_OK", "OK"),
("T_ERR", "ERR"),
("T_DBM", "dBm"),
("T_INT", " int"),
("T_EXT", " ext"),
("T_HOMING", " 121.5"),
("T_COARSE", " coarse"),
("T_RAWID", " rawID"),
("T_KHZ", " kHz"),
("T_DBMOK", "dBm ok"),
("T_ERRS", " err"),
("T_NOSYNC", " nosync"),
("T_CUT", " cut"),
]
a = Assets("EPIRB406")
ui_size = 0
for name, s in UI:
pad = -(len(s) + 1) % 4 # keep every offset word-aligned
a.text(name, s + "\0" * pad)
ui_size += len(s) + 1 + pad
a.const("UI_SIZE", ui_size) # the UI block read by draw()
a.const("T_TITLE_CHARS", len(TITLE))
a.table("T_PROTO", proto_names()) # 16 location, then 8 user protocols
a.main()
@@ -0,0 +1,6 @@
Filetype: Flipper SubGhz RAW File
Version: 1
Frequency: 433650000
Preset: FuriHalSubGhzPreset2FSKDev238Async
Protocol: RAW
RAW_Data: 161250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -2500 1250 -1250 1250 -1250 2500 -2500 2500 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -2500 1250 -1250 1250 -1250 2500 -1250 1250 -1250 1250 -2500 1250 -1250 1250 -1250 2500 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -2500 1250 -1250 1250 -1250 1250 -1250 2500 -2500 1250 -1250 2500 -2500 1250 -1250 2500 -1250 1250 -1250 1250 -2500 2500 -2500 1250 -1250 1250 -1250 2500 -2500 2500 -2500 2500 -1250 1250 -2500 1250 -1250 1250 -1250 2500 -1250 1250 -2500 1250 -1250 1250 -1250 2500 -2500 2500 -2500 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 2500 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -2500 2500 -1250 1250 -2500 1250 -1250 1250 -1250 1250 -1250 1250 -1250 2500 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -2500 2500 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -2500 2500 -2500 2500 -1250 1250 -2500 1250 -1250 1250 -1250 1250 -1250 2500 -2500 2500 -2500 1250 -1250 2500 -2500 1250 -1250 1250 -1250 1250 -1250 2500 -1250 1250 -1250 1250 -1250 1250 -2500 2500 -1250 1250 -2500 2500 -1250 1250 -2500 2500 -2500 1250 -1250 1250 -1250 2500 -1250 1250 -21250
@@ -0,0 +1,6 @@
Filetype: Flipper SubGhz RAW File
Version: 1
Frequency: 433650000
Preset: FuriHalSubGhzPreset2FSKDev238Async
Protocol: RAW
RAW_Data: 161250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -2500 1250 -1250 1250 -1250 2500 -2500 2500 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -2500 1250 -1250 1250 -1250 2500 -1250 1250 -1250 1250 -2500 1250 -1250 1250 -1250 2500 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -2500 1250 -1250 1250 -1250 1250 -1250 2500 -2500 1250 -1250 2500 -2500 1250 -1250 1250 -1250 2500 -1250 1250 -2500 2500 -2500 1250 -1250 1250 -1250 2500 -2500 2500 -2500 2500 -1250 1250 -2500 1250 -1250 1250 -1250 2500 -1250 1250 -2500 1250 -1250 1250 -1250 2500 -2500 2500 -2500 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 2500 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -2500 2500 -1250 1250 -2500 1250 -1250 1250 -1250 1250 -1250 1250 -1250 2500 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -2500 2500 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -2500 2500 -2500 2500 -1250 1250 -2500 1250 -1250 1250 -1250 1250 -1250 2500 -2500 2500 -2500 1250 -1250 2500 -2500 1250 -1250 1250 -1250 1250 -1250 2500 -1250 1250 -1250 1250 -1250 1250 -2500 2500 -1250 1250 -2500 2500 -1250 1250 -2500 2500 -2500 1250 -1250 1250 -1250 2500 -1250 1250 -21250
@@ -0,0 +1,6 @@
Filetype: Flipper SubGhz RAW File
Version: 1
Frequency: 433650000
Preset: FuriHalSubGhzPreset2FSKDev238Async
Protocol: RAW
RAW_Data: 161250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -2500 2500 -1250 1250 -2500 2500 -2500 1250 -1250 1250 -1250 1250 -1250 2500 -2500 1250 -1250 1250 -1250 2500 -1250 1250 -1250 1250 -2500 1250 -1250 1250 -1250 2500 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -2500 1250 -1250 1250 -1250 1250 -1250 2500 -2500 1250 -1250 2500 -2500 1250 -1250 1250 -1250 2500 -1250 1250 -2500 2500 -2500 1250 -1250 1250 -1250 2500 -2500 2500 -2500 2500 -1250 1250 -2500 1250 -1250 1250 -1250 2500 -1250 1250 -2500 1250 -1250 1250 -1250 2500 -2500 2500 -2500 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 2500 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -2500 2500 -1250 1250 -2500 1250 -1250 1250 -1250 1250 -1250 1250 -1250 2500 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -2500 2500 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -2500 2500 -2500 2500 -1250 1250 -2500 1250 -1250 1250 -1250 1250 -1250 2500 -2500 2500 -2500 1250 -1250 2500 -2500 1250 -1250 1250 -1250 1250 -1250 2500 -1250 1250 -1250 1250 -1250 1250 -2500 2500 -1250 1250 -2500 2500 -1250 1250 -2500 2500 -2500 1250 -1250 1250 -1250 2500 -1250 1250 -21250
@@ -0,0 +1,6 @@
Filetype: Flipper SubGhz RAW File
Version: 1
Frequency: 433650000
Preset: FuriHalSubGhzPreset2FSKDev238Async
Protocol: RAW
RAW_Data: 161250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -2500 1250 -1250 1250 -1250 2500 -2500 2500 -1250 1250 -1250 1250 -1250 1250 -2500 1250 -1250 1250 -1250 1250 -1250 2500 -1250 1250 -1250 1250 -2500 1250 -1250 1250 -1250 2500 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -2500 1250 -1250 1250 -1250 1250 -1250 2500 -2500 1250 -1250 2500 -2500 1250 -1250 1250 -1250 2500 -1250 1250 -2500 2500 -2500 1250 -1250 1250 -1250 2500 -2500 2500 -2500 2500 -1250 1250 -2500 1250 -1250 1250 -1250 2500 -1250 1250 -2500 1250 -1250 1250 -1250 2500 -2500 2500 -2500 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 2500 -1250 1250 -1250 1250 -2500 1250 -1250 1250 -1250 2500 -2500 1250 -1250 2500 -2500 1250 -1250 1250 -1250 2500 -1250 1250 -2500 2500 -1250 1250 -1250 1250 -1250 1250 -2500 2500 -1250 1250 -2500 1250 -1250 1250 -1250 1250 -1250 1250 -1250 1250 -1250 21250
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""Flipper Zero test transmitter for EPIRB 406: write Sub-GHz RAW files (.sub)
that play a first-generation 406 frame on 433.650 MHz (433 MHz SRD band).
The Flipper cannot phase-modulate, so each biphase-L half-bit is sent as one
2-FSK tone (preset 2FSKDev238Async, +/-2.38 kHz, RAW durations of 1250 us).
The receiver's discriminator then outputs the biphase waveform itself instead
of the pulses of a real beacon; the app's leaky integrator still tracks its
sign (checked with a pure-Python model of the receive chain and dec406, down to
~2.5 kHz rms of discriminator noise and with +/-4 kHz carrier offset).
NEVER transmit these files on 406.0-406.1 MHz (distress band).
flipper406.py <out_dir> -> epirb406_long.sub, _selftest, _short, _bch_err
One burst per file: press Send once, wait at least 2 s before the next one
(the app waits for the carrier to drop before re-arming).
"""
import os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from frame406 import build
FREQ = 433650000
PRESET = "FuriHalSubGhzPreset2FSKDev238Async"
HALF_US = 1250 # 400 bps biphase: 800 half-bits per second
LEAD_US = 160000 # unmodulated carrier before the bit sync, as a beacon
TAIL_US = 20000 # carrier kept after the last bit
PER_LINE = 512 # values per RAW_Data line, as the Flipper writes them
FRAMES = {
"long": build(),
"selftest": build(selftest=True),
"short": build(short=True),
"bch_err": build(flip=50), # BCH-1 must show ERR
}
def raw_durations(frame_hex):
"""Signed durations (us): positive = high tone, negative = low tone."""
n = len(frame_hex) * 4
v = int(frame_hex, 16)
levels = []
for i in range(n):
levels += [1, 0] if (v >> (n - 1 - i)) & 1 else [0, 1]
segs = [[1, LEAD_US]] # the lead tone merges with the first "1" half
for lv in levels:
if segs[-1][0] == lv: segs[-1][1] += HALF_US
else: segs.append([lv, HALF_US])
segs[-1][1] += TAIL_US
return [d if lv else -d for lv, d in segs]
def write_sub(path, frame_hex):
d = raw_durations(frame_hex)
lines = ["Filetype: Flipper SubGhz RAW File", "Version: 1",
"Frequency: %d" % FREQ, "Preset: %s" % PRESET, "Protocol: RAW"]
for i in range(0, len(d), PER_LINE):
lines.append("RAW_Data: " + " ".join(str(x) for x in d[i:i + PER_LINE]))
with open(path, "w") as f:
f.write("\n".join(lines) + "\n")
if __name__ == "__main__":
out = sys.argv[1] if len(sys.argv) > 1 else "."
os.makedirs(out, exist_ok=True)
for name, fr in FRAMES.items():
p = os.path.join(out, "epirb406_%s.sub" % name)
write_sub(p, fr)
print(p, fr)
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env python3
"""Build first-generation 406 MHz beacon frames (C/S T.001) as hex, for tests.
Default: the reference frame from the beacon generator used on the bench, a long
standard location test protocol message (country 227, test data 0x123456,
position 49 deg 16'16" N, 0 deg 46'56" E, external source, 121.5 homing),
15-hex ID 1C7C2468ACFFBFF.
frame406.py long normal frame (144 bits, 36 hex)
frame406.py --selftest same message, self-test frame sync
frame406.py --short short message (112 bits, 28 hex)
frame406.py --flip N invert message bit N (25..144), BCH must fail
"""
import argparse
G1 = 0b1001101101100111100011 # x^21+x^18+x^17+x^15+x^14+x^12+x^11+x^8+x^7+x^6+x^5+x+1
G2 = 0b1010100111001 # x^12+x^10+x^8+x^5+x^4+x^3+1
SYNC_NORMAL = [0, 0, 0, 1, 0, 1, 1, 1, 1]
SYNC_SELFTEST = [0, 1, 1, 0, 1, 0, 0, 0, 0]
def bits(v, n):
return [(v >> (n - 1 - i)) & 1 for i in range(n)]
def bch(data, gen, r):
reg = 0
for b in data + [0] * r:
reg = (reg << 1) | b
if reg >> r:
reg ^= gen
return bits(reg, r)
def build(short=False, selftest=False, flip=None):
country, proto, data = 227, 0b1110, 0x123456
lat_c = [0] + bits(49, 7) + bits(1, 2) # N, 49 deg, 1 x 15'
lon_c = [0] + bits(0, 8) + bits(3, 2) # E, 0 deg, 3 x 15'
pdf1 = [0 if short else 1, 0] + bits(country, 10) + bits(proto, 4) + bits(data, 24) + lat_c + lon_c
msg = pdf1 + bch(pdf1, G1, 21) # bits 25..106
if short:
msg += [0] * 6 # bits 107..112, national use
else:
pdf2 = [1, 1, 0, 1] + [0] + [1] \
+ [1] + bits(1, 5) + bits(16 // 4, 4) + [1] + bits(1, 5) + bits(56 // 4, 4)
msg += pdf2 + bch(pdf2, G2, 12) # bits 107..144
if flip is not None:
msg[flip - 25] ^= 1
frame = [1] * 15 + (SYNC_SELFTEST if selftest else SYNC_NORMAL) + msg
return "%0*X" % (len(frame) // 4, int("".join(map(str, frame)), 2))
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--short", action="store_true")
ap.add_argument("--selftest", action="store_true")
ap.add_argument("--flip", type=int)
a = ap.parse_args()
print(build(a.short, a.selftest, a.flip))
Loaded 100 of 258 files, more files were not shown because too many files have changed in this diff. Show more