Add external-flash overlay app platform

This commit is contained in:
Armel FAUVEAU committed 2026-08-28 00:40:24 +02:00
1 parent 3f958f637d
commit f35408317e
35 files changed
+3253 -293

No files matched your search

+7 -4
View File
@@ -14,11 +14,14 @@ jobs:
- name: Compile firmware - name: Compile firmware
run: | run: |
chmod +x compile-with-docker.sh chmod +x compile-firmware.sh compile-app.sh
./compile-with-docker.sh ./compile-firmware.sh All
./compile-app.sh All
- name: Upload firmware artifact - name: Upload artifacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: firmware-artifact name: firmware-artifact
path: compiled-firmware/f4hwn.packed.bin path: |
build/*/*.bin
build/Apps/*.app
+8
View File
@@ -17,3 +17,11 @@ __pycache__/
.DS_Store .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
build/apps/
+22 -3
View File
@@ -117,11 +117,18 @@ endif()
# ---- STOCK QUANSHENG FEATURES ---- # ---- STOCK QUANSHENG FEATURES ----
# ENABLE_FMRADIO compiles the BK1080 driver and the FM logic/helpers (fm.c).
# The resident FM *screen* (ui/fmradio.c) is redundant with the FM overlay app,
# so it is dropped when OVERLAY_APPS is on - the overlay app reaches the driver
# and the fm.c helpers through the ABI instead, and ACTION_FM's launcher body is
# neutralised in action.c so the resident screen can never open.
enable_feature(ENABLE_FMRADIO enable_feature(ENABLE_FMRADIO
driver/bk1080.c driver/bk1080.c
app/fm.c app/fm.c
ui/fmradio.c
) )
if(ENABLE_FMRADIO AND NOT ENABLE_FEAT_F4HWN_OVERLAY_APPS)
target_sources(App INTERFACE ui/fmradio.c)
endif()
enable_feature(ENABLE_AIRCOPY enable_feature(ENABLE_AIRCOPY
app/aircopy.c app/aircopy.c
ui/aircopy.c ui/aircopy.c
@@ -182,9 +189,21 @@ if(NOT ENABLE_UART AND NOT ENABLE_USB)
endif() endif()
enable_feature(ENABLE_FEAT_F4HWN) enable_feature(ENABLE_FEAT_F4HWN)
enable_feature(ENABLE_FEAT_F4HWN_GAME # GAME keeps the F+7 hook; the resident breakout.c is compiled only when the
app/breakout.c # 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
) )
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()
enable_feature(ENABLE_FEAT_F4HWN_K5VIEWER enable_feature(ENABLE_FEAT_F4HWN_K5VIEWER
k5viewer.c k5viewer.c
) )
+11
View File
@@ -467,6 +467,16 @@ void ACTION_Handle(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld)
#ifdef ENABLE_FMRADIO #ifdef ENABLE_FMRADIO
void ACTION_FM(void) void ACTION_FM(void)
{ {
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_APPS
/* Overlay apps replace the resident FM screen with the FM overlay app, so
this launcher is inert. Every external entry point - the assignable
side-key, the F+0 key, the boot-state resume - funnels through here, and
gFmRadioMode is only ever set from inside this function, so returning
early keeps the resident screen from ever opening. fm.c still links
against ACTION_FM; its own EXIT-path calls are unreachable because the
resident screen never opens. */
return;
#else
if (gCurrentFunction != FUNCTION_TRANSMIT && gCurrentFunction != FUNCTION_MONITOR) if (gCurrentFunction != FUNCTION_TRANSMIT && gCurrentFunction != FUNCTION_MONITOR)
{ {
gInputBoxIndex = 0; gInputBoxIndex = 0;
@@ -505,6 +515,7 @@ void ACTION_FM(void)
gRequestDisplayScreen = DISPLAY_FM; gRequestDisplayScreen = DISPLAY_FM;
} }
#endif /* !ENABLE_FEAT_F4HWN_OVERLAY_APPS */
} }
static void ACTION_Scan_FM(bool bRestart) static void ACTION_Scan_FM(bool bRestart)
+14 -5
View File
@@ -31,8 +31,11 @@
#include "app/spectrum.h" #include "app/spectrum.h"
#endif #endif
#ifdef ENABLE_FEAT_F4HWN_GAME #if defined(ENABLE_FEAT_F4HWN_GAME) && !defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS)
#include "app/breakout.h" #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 #endif
#include "audio.h" #include "audio.h"
@@ -269,9 +272,15 @@ static void processFKeyFunction(const KEY_Code_t Key, const bool beep)
break; break;
case KEY_7: 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) { 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 { } else {
#endif #endif
#ifdef ENABLE_VOX #ifdef ENABLE_VOX
@@ -279,7 +288,7 @@ static void processFKeyFunction(const KEY_Code_t Key, const bool beep)
//#else //#else
// toggle_chan_scanlist(); // toggle_chan_scanlist();
#endif #endif
#ifdef ENABLE_FEAT_F4HWN_GAME #if defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) || defined(ENABLE_FEAT_F4HWN_GAME)
} }
#endif #endif
+115
View File
@@ -49,6 +49,10 @@
#include "driver/mb_flash.h" #include "driver/mb_flash.h"
#endif #endif
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_APPS
#include "apps/app_overlay.h"
#endif
#if defined(ENABLE_OVERLAY) #if defined(ENABLE_OVERLAY)
#include "sram-overlay.h" #include "sram-overlay.h"
#endif #endif
@@ -898,6 +902,7 @@ void UART_HandleCommand(uint32_t Port)
// ---- M4 slot management ("Firmware Slots") ------------------------ // ---- M4 slot management ("Firmware Slots") ------------------------
case 0x0720: // slot info: read the 64-byte header only (fast, no CRC) case 0x0720: // slot info: read the 64-byte header only (fast, no CRC)
{ {
if (pUART_Command->Header.Size < 1u) break; // needs Data[0] (slot)
gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s) gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s)
uint8_t slot = pUART_Command->Data[0]; uint8_t slot = pUART_Command->Data[0];
mb_slot_header_t hdr; mb_slot_header_t hdr;
@@ -920,6 +925,7 @@ void UART_HandleCommand(uint32_t Port)
case 0x0722: // slot erase: wipe the whole 128 KiB slot region 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) gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s)
uint8_t slot = pUART_Command->Data[0]; uint8_t slot = pUART_Command->Data[0];
uint32_t ts = (uint32_t)pUART_Command->Data[2] uint32_t ts = (uint32_t)pUART_Command->Data[2]
@@ -979,6 +985,7 @@ void UART_HandleCommand(uint32_t Port)
case 0x0726: // slot validate: full image CRC-32, no reflash 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) gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s)
uint8_t slot = pUART_Command->Data[0]; uint8_t slot = pUART_Command->Data[0];
uint32_t crc = 0; uint32_t crc = 0;
@@ -1000,6 +1007,7 @@ void UART_HandleCommand(uint32_t Port)
case 0x0728: // config reset: wipe the 64 KiB of a config bank (1..4) 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) gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s)
uint8_t bank = pUART_Command->Data[0]; uint8_t bank = pUART_Command->Data[0];
uint32_t ts = (uint32_t)pUART_Command->Data[2] uint32_t ts = (uint32_t)pUART_Command->Data[2]
@@ -1022,6 +1030,113 @@ void UART_HandleCommand(uint32_t Port)
} }
#endif #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 whole 8 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))
? 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);
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 #ifdef ENABLE_UART_RW_BK_REGS
case 0x0601: case 0x0601:
CMD_0601_ReadBK4819Reg(Port, pUART_Command->Buffer); CMD_0601_ReadBK4819Reg(Port, pUART_Command->Buffer);
+181
View File
@@ -0,0 +1,181 @@
/* 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, so the struct layout
* can never disagree. Bump APP_ABI_VERSION on any incompatible change; the loader
* refuses a blob whose header abi_version does not match.
*/
#ifndef APPS_APP_API_H
#define APPS_APP_API_H
#include <stdint.h>
#include <stdbool.h>
/* ABI 2: the table below (v1 core + the fields once labelled "v2 additions") is a
* single versioned layout. Any change to app_api_t - a reorder, a removal, or an
* append - MUST bump this. Keep in sync with ABI_VERSION in pack_app.py, which
* stamps the blob the loader checks against. */
#define APP_ABI_VERSION 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_INVALID = 19,
};
/* 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;
typedef struct app_api {
uint8_t abi_version; /* == APP_ABI_VERSION */
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 */
void (*backlight_tick)(void); /* BACKLIGHT_UpdateTickless */
/* ---- audio / indicator ---- */
void (*play_tone)(uint16_t tone, uint16_t ms); /* full BK4819 tone burst + AF path */
void (*led)(bool on); /* green GPIO indicator */
/* ---- data provided at launch ---- */
uint32_t seed; /* resident-computed PRNG seed */
/* ==== appended when the ABI moved 1 -> 2; now an integral part of ABI 2.
* A firmware and an app that agree on abi_version agree on this whole layout,
* so these must never be reached through a table that does not include them. == */
/* ---- 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); /* BACKLIGHT_Update (fade step) */
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) */
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_read)(uint8_t reg); /* BK1080_ReadRegister (RSSI / valid) */
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) */
} app_api_t;
/* 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 */
+211
View File
@@ -0,0 +1,211 @@
/* 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 "driver/st7565.h"
#include "driver/keyboard.h"
#include "driver/system.h"
#include "driver/gpio.h"
#include "ui/helper.h"
/* Highlight a row (same rounded-invert look as the multiboot selector). */
static void app_invert_row(uint8_t line)
{
gFrameBuffer[line][0] ^= 0x7Fu;
for (uint8_t x = 1u; x < LCD_WIDTH - 1u; x++)
{
gFrameBuffer[line][x] ^= 0xFFu;
gFrameBuffer[line - 1u][x] ^= 0x80u;
}
gFrameBuffer[line][LCD_WIDTH - 1u] ^= 0x7Fu;
}
/* Debounced blocking key read, then wait for release (mirrors mb_get_key). */
static KEY_Code_t app_get_key(void)
{
for (;;)
{
KEY_Code_t key = KEYBOARD_Poll();
if (key != KEY_INVALID)
{
SYSTEM_DelayMs(30);
if (KEYBOARD_Poll() == key)
{
while (KEYBOARD_Poll() != KEY_INVALID)
SYSTEM_DelayMs(10);
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);
}
}
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';
}
/* Human-readable reason for an APP_LaunchOverlay / APP_ValidateSlot failure. */
static const char *app_err_text(uint8_t rc)
{
switch (rc)
{
case APP_ERR_SLOT: return "BAD SLOT";
case APP_ERR_MAGIC: return "NO APP";
case APP_ERR_ABI: return "ABI MISMATCH";
case APP_ERR_NOT_COMMITTED: return "INCOMPLETE";
case APP_ERR_SIZE: return "BAD SIZE";
case APP_ERR_CRC: return "CRC ERROR";
case APP_ERR_VMA: return "VMA MISMATCH";
case APP_ERR_AUTH: return "AUTH";
default: return "ERROR";
}
}
/* A launch failed: name the app and the reason, then wait for a key. Without this
* an incompatible app would silently "do nothing" when selected. */
static void app_show_error(const char *name, uint8_t rc)
{
char nm[19];
app_copy(nm, sizeof(nm), 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(rc), 2, 0, 4); /* why */
UI_PrintStringSmallNormal("Press any key", 2, 0, 6);
ST7565_BlitStatusLine();
ST7565_BlitFullScreen();
app_get_key(); /* blocking: dismiss on any key */
}
/* Visible app rows (framebuffer lines 1..APP_MENU_ROWS; line 0 is the header). */
#define APP_MENU_ROWS 6u
void APP_MenuOpen(void)
{
/* Apps are installed from UV Studio (0x073x) into physical slots 0..N-1
* (shown to the user as 1..N there). Scan them all; the list is empty until
* the user pushes one ("No apps installed"). */
app_header_t hdr[APP_SLOT_COUNT];
uint8_t list[APP_SLOT_COUNT]; /* slot indices of the committed apps */
uint8_t count = 0;
for (uint8_t slot = 0; slot < APP_SLOT_COUNT; slot++)
{
if (APP_SlotInfo(slot, &hdr[slot]) == APP_OK &&
(hdr[slot].flags & APP_FLAG_COMMITTED))
list[count++] = slot;
}
/* Remember the cursor across open/close of the Apps menu. Clamp in case the
* installed-app set changed since we were last here. */
static uint8_t sel = 0;
static uint8_t top = 0; /* first visible row of the scrolling window */
if (count == 0u || sel >= count)
sel = top = 0u;
app_wait_release();
for (;;)
{
UI_DisplayClear();
UI_StatusClear(); /* wipe the VFO status line (DW, battery, ...) first */
/* 10 glyphs x ~4 px = 40 px wide, centred: x=(128-40)/2=44, endX=x+40. */
GUI_DisplaySmallestInverse("F4HWN APPS", 44, 0, true, true, 84);
if (count == 0)
{
UI_PrintStringSmallNormal("No apps installed", 2, 126, 3);
}
else
{
/* Scrolling window: slide [top, top+APP_MENU_ROWS) so it always holds
* the selection, keeping all APP_SLOT_COUNT apps reachable - not just
* the first APP_MENU_ROWS. */
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 i = top; i < count && (uint8_t)(i - top) < APP_MENU_ROWS; i++)
{
char line[19];
app_copy(line, sizeof(line), hdr[list[i]].name, APP_NAME_LEN);
const uint8_t fbLine = (uint8_t)(i - top + 1u);
UI_PrintStringSmallNormal(line, 2, 0, fbLine);
if (i == sel)
app_invert_row(fbLine);
}
}
ST7565_BlitStatusLine();
ST7565_BlitFullScreen();
const KEY_Code_t key = app_get_key();
if (key == KEY_EXIT)
return;
if (count == 0)
continue;
switch (key)
{
case KEY_UP:
sel = (sel == 0u) ? (uint8_t)(count - 1u) : (uint8_t)(sel - 1u);
break;
case KEY_DOWN:
sel = (uint8_t)((sel + 1u) % count);
break;
case KEY_MENU:
{
const uint8_t rc = APP_LaunchOverlay(list[sel]); /* runs until the app exits */
if (rc != APP_OK)
app_show_error(hdr[list[sel]].name, rc); /* no longer silent */
app_wait_release();
break;
}
default:
break;
}
}
}
#endif /* ENABLE_FEAT_F4HWN_OVERLAY_APPS */
+25
View File
@@ -0,0 +1,25 @@
/* 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 APPS_APP_MENU_H
#define APPS_APP_MENU_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);
#endif /* APPS_APP_MENU_H */
+394
View File
@@ -0,0 +1,394 @@
/* 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 "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/py25q16.h"
#include "driver/st7565.h"
#include "driver/system.h"
#include "driver/backlight.h"
#include "ui/helper.h"
#include "ui/status.h"
#include "board.h"
#include "audio.h"
#include "radio.h"
#include "helper/battery.h"
#include "settings.h"
#include "misc.h" /* dBmCorrTable */
_Static_assert(sizeof(app_header_t) == 64, "app_header_t must be 64 bytes");
/* CRC-32 (zlib) over RAM bytes - matches App/apps/pack_app.py and the firmware's
* mb_ext_image_crc32 (init 0xFFFFFFFF, poly 0xEDB88320, final XOR). */
static uint32_t app_crc32(const uint8_t *p, uint32_t len)
{
uint32_t crc = 0xFFFFFFFFu;
while (len--) {
crc ^= *p++;
for (int k = 0; k < 8; k++)
crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u)));
}
return crc ^ 0xFFFFFFFFu;
}
/* ---- ABI wrappers: the few resident calls that are not a direct signature match ---- */
static void app_display_clear(void) { UI_DisplayClear(); }
static void app_status_clear(void) { UI_StatusClear(); }
static uint8_t app_get_key(void) { return (uint8_t)KEYBOARD_GetKey(); }
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();
}
static uint32_t app_make_seed(void)
{
/* Reproduces the original Breakout seed source. */
return BK4819_ReadRegister(BK4819_REG_67) & 0x01FF
* gBatteryVoltageAverage
* gEeprom.VfoInfo[0].pRX->Frequency;
}
/* ---- v2 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 void app_tones_off_rx(void) { BK4819_TurnsOffTones_TurnsOnRX(); }
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). Erasing/reinstalling a slot resets its config, which is intended. */
#define APP_CFG_OFFSET 0x40u /* config area within the header sector */
static uint8_t app_cfg_buf[16];
static uint8_t app_cfg_len; /* staged length; 0 = nothing to commit */
static uint8_t app_run_slot; /* slot of the app currently running */
static void app_cfg_load(uint8_t *buf, uint8_t len)
{
if (len > sizeof(app_cfg_buf)) len = sizeof(app_cfg_buf);
PY25Q16_ReadBuffer(APP_SLOT_BASE(app_run_slot) + APP_CFG_OFFSET, buf, len);
}
static void app_cfg_save(const uint8_t *buf, uint8_t len)
{
if (len > sizeof(app_cfg_buf)) len = sizeof(app_cfg_buf);
memcpy(app_cfg_buf, buf, len);
app_cfg_len = len; /* mark dirty; the loader commits after the app returns */
}
/* ---- v2 battery / backlight ---- */
static void app_draw_battery(void)
{
char t[8];
UI_DrawStatusBattery(gStatusLine, t);
}
static void app_battery_sample(void)
{
BOARD_ADC_GetBatteryInfo(&gBatteryVoltages[gBatteryVoltageIndex++], &gBatteryCurrent);
if (gBatteryVoltageIndex > 3)
gBatteryVoltageIndex = 0;
BATTERY_GetReadings(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_set_params(void) { RADIO_SetTxParameters(); }
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 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(0x00A0C8u, raw, sizeof(raw)); /* boot message line 1 */
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 void app_fm_set_freq(uint16_t f, uint8_t b) { BK1080_SetFrequency(f, b); }
static uint16_t app_fm_read(uint8_t r) { return BK1080_ReadRegister((BK1080_Register_t)r); }
static uint16_t app_fm_lo(uint8_t b) { return BK1080_GetFreqLoLimit(b); }
static uint16_t app_fm_hi(uint8_t b) { return BK1080_GetFreqHiLimit(b); }
static void app_fm_mute(bool m) { BK1080_Mute(m); }
static int8_t app_fm_valid(uint16_t f, uint16_t lo) { return (int8_t)FM_CheckFrequencyLock(f, lo); }
static bool app_fm_dirty; /* deferred: SETTINGS_SaveFM committed after the app returns */
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_fm_dirty = true; }
#endif
uint8_t APP_ValidateSlot(uint8_t slot, app_header_t *out_header)
{
if (slot >= APP_SLOT_COUNT)
return APP_ERR_SLOT;
app_header_t h;
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_version != APP_ABI_VERSION) return APP_ERR_ABI;
if (!(h.flags & APP_FLAG_COMMITTED)) return APP_ERR_NOT_COMMITTED;
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) */
return APP_ERR_SIZE;
if (out_header)
*out_header = h;
return APP_OK;
}
uint8_t APP_SlotInfo(uint8_t slot, app_header_t *out_header)
{
if (slot >= APP_SLOT_COUNT)
return APP_ERR_SLOT;
app_header_t h;
PY25Q16_ReadBuffer(APP_SLOT_BASE(slot), &h, sizeof(h));
if (out_header)
*out_header = h;
return (h.magic == APP_MAGIC) ? APP_OK : APP_ERR_MAGIC;
}
uint8_t APP_LaunchOverlay(uint8_t slot)
{
app_header_t h;
uint8_t rc = APP_ValidateSlot(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;
/* 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. */
PY25Q16_InvalidateCache();
memset(ws, 0, APP_OVERLAY_MAX);
PY25Q16_ReadBuffer(APP_SLOT_BASE(slot) + APP_CODE_OFFSET, ws, h.code_size);
if (app_crc32(ws, h.code_size) != h.code_crc32) {
PY25Q16_InvalidateCache();
return APP_ERR_CRC;
}
/* Ensure every store to the overlay is visible before we branch into it. */
__DSB();
__ISB();
const app_api_t api = {
.abi_version = APP_ABI_VERSION,
.fb = gFrameBuffer,
.display_clear = app_display_clear,
.status_clear = app_status_clear,
.draw_line = UI_DrawLineBuffer,
.draw_rect = UI_DrawRectangleBuffer,
.print_bold = UI_PrintStringSmallBold,
.print_tiny = GUI_DisplaySmallest,
.blit_full = ST7565_BlitFullScreen,
.blit_line = ST7565_BlitLine,
.blit_status = ST7565_BlitStatusLine,
.get_key = app_get_key,
.delay_ms = SYSTEM_DelayMs,
.backlight_tick = BACKLIGHT_UpdateTickless,
.play_tone = app_play_tone,
.led = app_led,
.seed = app_make_seed(),
/* v2 */
.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 = app_tones_off_rx,
.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 = BACKLIGHT_TurnOn,
.backlight_update = BACKLIGHT_Update,
.status_line = gStatusLine,
.tx_state = app_tx_state,
.tx_set_params = app_tx_set_params,
.tx_tone = app_tx_tone,
.tx_mute = app_tx_mute,
.tx_end = app_tx_end,
.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 = app_fm_set_freq,
.fm_read = app_fm_read,
.fm_lo = app_fm_lo,
.fm_hi = app_fm_hi,
.fm_mute = app_fm_mute,
.fm_valid = app_fm_valid,
.fm_channels = gFM_Channels,
.fm_state = app_fm_state,
.fm_commit = app_fm_commit,
#endif
};
app_run_slot = slot; /* for cfg_load / cfg_save */
app_cfg_len = 0;
#ifdef ENABLE_FMRADIO
app_fm_dirty = false;
#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. gTxVfo is left untouched, so
* Beacon's tx_freq() stays correct too. State is saved and restored on
* return so the resident dual watch resumes cleanly. */
const uint8_t saved_rx_vfo = gEeprom.RX_VFO;
VFO_Info_t *const saved_rx = gRxVfo;
gEeprom.RX_VFO = gEeprom.TX_VFO;
gRxVfo = gTxVfo;
RADIO_SetupRegisters(true);
app_entry_t entry = (app_entry_t)(((uint32_t)ws + h.entry_off) | 1u);
entry(&api);
/* Restore the resident RX/dual-watch tuning the app ran on top of. */
gEeprom.RX_VFO = saved_rx_vfo;
gRxVfo = saved_rx;
RADIO_SetupRegisters(true);
/* The overlay held app code, not a valid config sector. */
PY25Q16_InvalidateCache();
/* Commit any deferred config the app staged (RMW keeps the slot header). */
if (app_cfg_len) {
PY25Q16_WriteBuffer(APP_SLOT_BASE(slot) + APP_CFG_OFFSET, app_cfg_buf, app_cfg_len, false);
PY25Q16_InvalidateCache();
}
#ifdef ENABLE_FMRADIO
/* Commit the FM config + 48 channels the app edited (shared with resident FM). */
if (app_fm_dirty) {
app_fm_dirty = false;
SETTINGS_SaveFM();
PY25Q16_InvalidateCache();
}
#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);
for (uint32_t off = 0; off < APP_SLOT_STRIDE; off += APP_SECTOR_SIZE)
PY25Q16_SectorErase(base + off);
PY25Q16_InvalidateCache();
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();
return APP_OK;
}
#endif /* ENABLE_FEAT_F4HWN_OVERLAY_APPS */
+112
View File
@@ -0,0 +1,112 @@
/* 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 ]
*
* 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 */
/* ---- 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 (1u << 0)
#define APP_NAME_LEN 16
#define APP_VERSION_LEN 16
typedef struct __attribute__((packed)) {
uint32_t magic; /* APP_MAGIC */
uint16_t hdr_version; /* APP_HDR_VERSION */
uint16_t abi_version; /* ABI the app was built against */
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 */
uint8_t reserved[8]; /* pad to 64 bytes */
} 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 version mismatch */
APP_ERR_NOT_COMMITTED, /* image not marked complete */
APP_ERR_SIZE, /* code_size out of range */
APP_ERR_CRC, /* code CRC-32 mismatch */
APP_ERR_VMA, /* overlay buffer not at the link VMA */
APP_ERR_AUTH, /* host write refused: timestamp mismatch */
};
/* 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);
/* 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);
#endif /* APPS_APP_OVERLAY_H */
+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.*) }
}
+316
View File
@@ -0,0 +1,316 @@
/* 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") as MCW for the TX window, then
* stays silent for the idle gap. The carrier stays up during the window; only the
* tone is keyed per Morse element.
*
* Keys: 1 TX window · 2 idle gap · 3 fox id (F reverses) · MENU restart idle
* long F keypad lock · EXIT quit.
*/
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "../app_api.h"
#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
/* Sentinel-prefixed Morse (leading 1 sentinel, then elements MSB-first: 0 dit, 1 dah). */
static const uint8_t M_LET[26] = {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};
static const uint8_t M_DIG[10] = {0x3F,0x2F,0x27,0x23,0x21,0x20,0x30,0x38,0x3C,0x3E};
static const char FOX_TAIL[5] = {'E','I','S','H','5'};
static const uint8_t BMP_TX[16] = {0x1c,0x22,0x41,0x1c,0x22,0x00,0x08,0x1c,0x1c,0x08,0x00,0x22,0x1c,0x41,0x22,0x1c};
static const uint8_t FONT_LOCK[9] = {0x7c,0x46,0x45,0x45,0x45,0x45,0x45,0x46,0x7c};
static const app_api_t *A;
static bool foxLocked, fArm, fLongDone, running, phaseTx;
static uint16_t fHoldMs;
static uint8_t beaconIdle, idleLeft, idleTick, beaconTx, secShown, charsSent, foxFox;
static uint16_t txMsLeft;
static char foxCall[CALL_MAX+1];
static char msg[17];
static uint8_t prevKey;
static char str[16];
/* ---- 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; }
static char *putu(char *o,uint32_t v){ char t[6]; int8_t n=0; do{t[n++]=(char)('0'+v%10);v/=10;}while(v&&n<6); while(n--)*o++=t[n]; 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; }
/* ---- 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: beaconTx =rangeStep(beaconTx, TX_MIN, TX_MAX, TX_STEP, dir); return true;
case APP_KEY_2: beaconIdle=rangeStep(beaconIdle,IDLE_MIN,IDLE_MAX,IDLE_STEP,dir); return true;
case APP_KEY_3: foxFox=(uint8_t)((foxFox+(dir>0?1:FOX_COUNT-1))%FOX_COUNT); return true;
default: return false;
}
}
/* ---- message ---- */
static void buildMsg(void){
char *o=msg;
if(foxFox==FOX_CALL){
if(foxCall[0]){ o=put(o,foxCall); o=put(o," MOE"); }
else o=put(o,"MOE");
} else if(foxFox==FOX_MO){ o=put(o,"MO"); }
else { o=put(o,"MO"); *o++=FOX_TAIL[foxFox]; }
*o='\0';
}
static void foxLabel(char *out){
char *o=put(out,"FOX ");
if(foxFox==FOX_CALL) o=put(o,"CALL");
else if(foxFox==FOX_MO) o=put(o,"MO");
else { o=put(o,"MO"); *o++=FOX_TAIL[foxFox]; }
*o='\0';
}
static uint8_t morseByte(char c){
if(c>='a'&&c<='z') c-=32;
if(c>='A'&&c<='Z') return M_LET[c-'A'];
if(c>='0'&&c<='9') return M_DIG[c-'0'];
if(c=='/') return 0x32;
return 0;
}
/* ---- 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){
A->display_clear();
A->status_clear();
A->print_inverse("BEACON",2,0,true,true,26);
A->draw_battery();
if(foxLocked) cpy(A->status_line+69,FONT_LOCK,9);
{ char *o=putu(str,A->tx_freq()/100000u); *o++='.'; uint32_t fr=A->tx_freq()%100000u;
for(int8_t d=4;d>=0;d--){ uint32_t p=1; for(int8_t k=0;k<d;k++)p*=10; *o++=(char)('0'+(fr/p)%10);} *o='\0'; }
A->print_normal(str,(uint8_t)(126-slen(str)*7),0,6);
}
static void drawTxSeconds(void){
uint8_t sec=(uint8_t)((txMsLeft+999u)/1000u);
for(uint8_t x=0;x<LCD_WIDTH;x++) A->fb[0][x]=0;
A->print_normal("TX",1,0,0);
char *o=str; put2(&o,sec); *o++='s'; *o='\0';
A->print_normal(str,(uint8_t)(127-slen(str)*7),0,0);
secShown=sec;
}
static void drawMessage(uint8_t vis){
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)/2u),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)/2u),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)/2u),0,MSG_ID,8);
}
}
static void updateProgress(uint8_t vis){
const char *sp=findsp(msg); uint8_t top;
charsSent=vis;
if(!sp) top=MSG_ONE;
else { uint8_t callLen=(uint8_t)(sp-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(charsSent);
A->blit_line(top); A->blit_line(top+1);
}
static void beaconDraw(bool txNow,uint8_t il){
chrome();
if(txNow){
cpy(A->status_line+48,BMP_TX,16);
drawTxSeconds();
drawMessage(charsSent);
} else {
A->print_string("IDLE",0,127,1,10);
char *o=str; put2(&o,il); *o++='s'; *o='\0';
A->print_string(str,0,127,3,10);
}
char *o=put(str,"TX "); o=putu(o,beaconTx); o=put(o,"s"); *o='\0'; tag(str,4,5);
o=put(str,"IDLE "); o=putu(o,beaconIdle); o=put(o,"s"); *o='\0'; tag(str,4,6);
foxLabel(str); tag(str,(uint8_t)(125-slen(str)*4),5);
}
static void blit(void){ A->blit_status(); A->blit_full(); }
/* ---- keypad-lock long-press ---- */
static void lockTrack(uint8_t key,uint16_t ms){
if(key!=APP_KEY_F){ fHoldMs=0; fLongDone=false; return; }
if(fLongDone) return;
fHoldMs+=ms;
if(fHoldMs>=LOCK_HOLD_MS){ fLongDone=true; foxLocked=!foxLocked; fArm=false; A->backlight_on(); }
}
/* ---- 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;
txMsLeft=(txMsLeft>slice)?(uint16_t)(txMsLeft-slice):0;
A->backlight_update();
if((uint8_t)((txMsLeft+999u)/1000u)!=secShown){ drawTxSeconds(); A->blit_line(0); }
uint8_t key=A->get_key();
lockTrack(key,slice);
if(key==APP_KEY_INVALID||key==prevKey){ prevKey=key; continue; }
prevKey=key;
A->backlight_on();
if(foxLocked) continue;
if(key==APP_KEY_EXIT){ running=false; return true; }
if(key==APP_KEY_MENU) return true; /* cut short, restart idle */
if(key==APP_KEY_F){ fArm=!fArm; beaconDraw(true,0); blit(); continue; }
if(settingKey(key,fArm?-1:1)){ fArm=false; beaconDraw(true,0); blit(); }
}
return 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;
A->tx_mute(false);
if(txDelay(on)){ A->tx_mute(true); return true; }
A->tx_mute(true);
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);
txMsLeft=(uint16_t)beaconTx*1000u;
bool stop=false;
while(!stop && txMsLeft>0){
buildMsg();
charsSent=0;
beaconDraw(true,0); blit();
for(uint8_t i=0;!stop && msg[i];i++) stop=morseChar(msg[i],(uint8_t)(i+1));
if(!stop && 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==prevKey){ prevKey=key; return; }
prevKey=key;
A->backlight_on();
if(foxLocked) return;
if(key==APP_KEY_F){ fArm=!fArm; return; }
switch(key){
case APP_KEY_EXIT: running=false; break;
case APP_KEY_MENU: idleLeft=beaconIdle; idleTick=0; break;
default: settingKey(key,fArm?-1:1); break;
}
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[4]; A->cfg_load(c,4);
if(c[0]==CFG_MAGIC){
if(c[1]>=IDLE_MIN&&c[1]<=IDLE_MAX&&(c[1]%IDLE_STEP)==0) beaconIdle=c[1];
if(c[2]<FOX_COUNT) foxFox=c[2];
if(c[3]>=TX_MIN&&c[3]<=TX_MAX&&(c[3]%TX_STEP)==0) beaconTx=c[3];
}
}
static void saveConfig(void){ uint8_t c[4]={CFG_MAGIC,beaconIdle,foxFox,beaconTx}; A->cfg_save(c,4); }
static void txDenied(void){
chrome();
A->print_string("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;
foxLocked=fArm=fLongDone=false; fHoldMs=0;
beaconIdle=IDLE_DEF; beaconTx=TX_DEF; foxFox=FOX_CALL;
prevKey=APP_KEY_INVALID;
A->boot_callsign(foxCall,sizeof(foxCall));
loadConfig();
A->backlight_on();
phaseTx=true; idleLeft=beaconIdle; idleTick=0;
running=true;
while(running){
if(phaseTx){
if(A->tx_state()!=0){ txDenied(); phaseTx=false; idleLeft=beaconIdle; idleTick=0; continue; }
transmit();
phaseTx=false; idleLeft=beaconIdle; idleTick=0;
continue;
}
idleKeys();
if(!running) break;
beaconDraw(false,idleLeft); blit();
if(++idleTick>=(1000/TICK_MS)){ idleTick=0; if(idleLeft>0)idleLeft--; if(idleLeft==0)phaseTx=true; }
A->battery_sample();
tickDelay();
}
saveConfig();
A->tx_end(); /* safety: PA off + RX restored */
A->audio_path(false);
}
+40
View File
@@ -0,0 +1,40 @@
#!/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.0"
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/3] %-8s' "$APP_NAME" "$1" "$2"; }
trap 'printf "\r ❌ %-13s build failed \n" "$APP_NAME"' ERR
step 1 compile ; "$CC" $CFLAGS $LDFLAGS "${APP}_app.c" -lgcc -o "${APP}.elf"
step 2 objcopy ; "$OBJCOPY" -O binary "${APP}.elf" "${APP}.bin"
step 3 pack ; python3 ../pack_app.py "${APP}.bin" "${OUT}.app" \
--name "$APP_NAME" --ver "$APP_VER" --vma "${APP_VMA}" >/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"
+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.*) }
}
+266
View File
@@ -0,0 +1,266 @@
/* 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).
*/
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "../app_api.h"
#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
typedef struct { uint8_t x; uint8_t y; bool destroy; } Brick;
typedef struct { int8_t x; uint8_t p; } Racket;
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; }
/* zero-padded unsigned -> string, width w (<= 5). Replaces sprintf. */
static void u2str(char *out, const char *label, uint16_t v, uint8_t w) {
char *o = out;
while (*label) *o++ = *label++;
char tmp[6]; int8_t n = 0;
do { tmp[n++] = (char)('0' + v % 10); v /= 10; } while (v && n < 6);
while (n < w) tmp[n++] = '0';
while (n--) *o++ = tmp[n];
*o = '\0';
}
/* ---- app-wide handle to the resident ABI, set once at entry ---- */
static const app_api_t *A;
/* ---- game state (lives in the overlay; re-initialised at entry) ---- */
static uint32_t randSeed;
static uint8_t blockAnim;
static bool isInitialized;
static bool isPaused;
static uint8_t levelCountBreakout;
static uint16_t tone;
static uint16_t score;
static int16_t ballCount;
static char str[12];
static uint8_t kbdPrev, kbdCur;
static Brick brick[BRICK_NUMBER];
static Racket racket;
static Ball ball;
static const uint8_t BRICK_ANIM_PATTERNS[4] =
{0b00110001, 0b00101001, 0b00100101, 0b00100011};
static void srand_custom(uint32_t seed) { randSeed = seed ? seed : 1; }
static int rand_custom(void) { randSeed = randSeed * 1103515245u + 12345u; return (randSeed >> 16) & 0x7FFF; }
static int randInt(int min, int max) { return min + (rand_custom() % (max - min + 1)); }
static void reset(void) { ballCount = BALL_NUMBER; levelCountBreakout = 1; score = 0; }
static void playBeep(uint16_t t) { A->play_tone(t, 100); }
static void drawScore(void) {
A->status_clear();
u2str(str, "Level ", levelCountBreakout, 2); A->print_tiny(str, 0, 1, true, true);
u2str(str, "Ball ", (ballCount < 0) ? 0 : ballCount, 2); A->print_tiny(str, 45, 1, true, true);
u2str(str, "Score ", score, 4); A->print_tiny(str, 88, 1, true, true);
}
static void renderBall(bool state) {
A->draw_rect(A->fb, ball.x, ball.y, ball.x + BALL_WIDTH - 1, ball.y + BALL_HEIGHT - 1, state);
A->draw_line(A->fb, ball.x - 1, ball.y + 1, ball.x + BALL_WIDTH, ball.y + 1, state);
}
static void initBall(void) { ball.x = 62; ball.y = 30; ball.dx = 0; ball.dy = 1; renderBall(true); }
static void directionBall(int16_t x, uint8_t w, int8_t num) {
ball.dx = (int16_t)(x + w - ball.x) * (-num - num) / w + num;
ball.dy *= -1;
}
static void initWall(void) {
Brick *current = 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++;
}
}
static void initRacket(void);
static void drawBall(void) {
renderBall(false);
ball.x += ball.dx; ball.y += ball.dy;
if (ball.y <= 0) { ball.dx = randInt(-3, 3); ball.dy = 1; }
else if (ball.x <= 2) { ball.dx = iabs(ball.dx); }
else if (ball.x >= 124) { ball.dx = -iabs(ball.dx); }
if (ball.y == 47) {
if (ball.x + 1 >= racket.x && ball.x - 1 <= racket.x + RACKET_WIDTH) {
directionBall(racket.x, RACKET_WIDTH, 3); tone = 400;
}
} else if (ball.y > 49) {
ballCount--;
A->display_clear();
drawScore();
tone = 800;
if (ballCount < 0) {
reset(); initWall(); /* drawWall drawn by main loop */
isPaused = true;
A->print_bold("GAME OVER", 0, LCD_WIDTH - 1, 4);
}
initRacket(); initBall();
}
renderBall(true);
}
static void drawWall(void) {
for (uint8_t i = 0; i < BRICK_NUMBER; i++) {
if (brick[i].destroy) continue;
uint8_t *fb_ptr = A->fb[brick[i].y / 8] + brick[i].x;
fb_ptr[0] = 0b00011110;
fb_ptr[14] = 0b00011110;
if ((ball.x + 1 >= brick[i].x && ball.x - 1 <= brick[i].x + BRICK_WIDTH) &&
(ball.y + 1 >= brick[i].y && ball.y - 1 <= brick[i].y + BRICK_HEIGHT)) {
brick[i].destroy = true; score++;
directionBall(brick[i].x, BRICK_WIDTH, 2);
A->led(true);
memset(fb_ptr + 1, 0b00111111, 13);
A->blit_line(brick[i].y / 8);
playBeep(600);
memset(fb_ptr + 0, 0b00000000, 15);
A->blit_line(brick[i].y / 8);
A->led(false);
if (score % BRICK_NUMBER == 0) {
levelCountBreakout++; ballCount++; initWall(); return;
}
} else {
for (uint8_t k = 0; k < 13; k++)
fb_ptr[k + 1] = BRICK_ANIM_PATTERNS[(blockAnim + k) % 4];
}
}
}
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) { racket.x = 64 - (RACKET_WIDTH / 2); racket.p = racket.x; renderRacket(racket.x, true); }
static void drawRacket(void) {
if (racket.p != racket.x) {
renderRacket(racket.p, false);
racket.p = racket.x;
renderRacket(racket.x, true);
}
}
static void OnKeyDown(uint8_t key) {
bool wasPaused = isPaused;
switch (key) {
case APP_KEY_4:
case APP_KEY_UP: if (!isPaused && racket.x > 0) racket.x -= 2; isPaused = false; break;
case APP_KEY_0:
case APP_KEY_DOWN: if (!isPaused && racket.x < 102) racket.x += 2; isPaused = false; break;
case APP_KEY_MENU:
isPaused = !isPaused;
if (isPaused) A->print_bold("PAUSE", 0, LCD_WIDTH - 1, 4);
break;
case APP_KEY_EXIT: isPaused = false; isInitialized = false; break;
}
if (wasPaused && !isPaused)
for (uint8_t i = 0; i < 8; i++)
A->draw_line(A->fb, 32, 32 + i, 96, 32 + i, false);
}
static void handleInput(void) {
kbdPrev = kbdCur;
kbdCur = A->get_key();
if (kbdCur == APP_KEY_INVALID) return;
if (kbdCur == APP_KEY_UP || kbdCur == APP_KEY_DOWN ||
kbdCur == APP_KEY_4 || kbdCur == APP_KEY_0 || kbdCur != kbdPrev)
OnKeyDown(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();
A->print_bold("OVL HELLO", 0, LCD_WIDTH - 1, 3);
A->blit_full();
A->delay_ms(2000);
return;
#endif
/* reset all state (the overlay is not persistent across launches) */
blockAnim = 0; isPaused = false; tone = 0; score = 0;
kbdPrev = APP_KEY_INVALID; kbdCur = APP_KEY_INVALID;
uint8_t swap = 0;
srand_custom(api->seed);
A->led(false);
A->backlight_tick();
A->display_clear();
reset(); initWall(); initRacket(); initBall();
A->status_clear();
isInitialized = true;
while (isInitialized) {
handleInput();
if (!isPaused) {
if (swap == 0) blockAnim = (blockAnim + 1) % 4;
swap = (swap + 1) % 4;
drawScore(); drawWall(); drawRacket(); drawBall();
if (tone != 0) { playBeep(tone); tone = 0; }
else A->delay_ms(40 - imin(levelCountBreakout - 1, 20));
}
A->blit_status();
A->blit_full();
}
}
+40
View File
@@ -0,0 +1,40 @@
#!/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.0"
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/3] %-8s' "$APP_NAME" "$1" "$2"; }
trap 'printf "\r ❌ %-13s build failed \n" "$APP_NAME"' ERR
step 1 compile ; "$CC" $CFLAGS $LDFLAGS "${APP}_app.c" -lgcc -o "${APP}.elf"
step 2 objcopy ; "$OBJCOPY" -O binary "${APP}.elf" "${APP}.bin"
step 3 pack ; python3 ../pack_app.py "${APP}.bin" "${OUT}.app" \
--name "$APP_NAME" --ver "$APP_VER" --vma "${APP_VMA}" >/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"
+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,
"Broadcast FM overlay app overflows the 4 KiB overlay")
/DISCARD/ : { *(.ARM.exidx*) *(.ARM.extab*) *(.eh_frame*) *(.comment) *(.note.*) }
}
+40
View File
@@ -0,0 +1,40 @@
#!/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="Broadcast FM" # <-- the only per-app line
APP_VER="1.0"
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/3] %-8s' "$APP_NAME" "$1" "$2"; }
trap 'printf "\r ❌ %-13s build failed \n" "$APP_NAME"' ERR
step 1 compile ; "$CC" $CFLAGS $LDFLAGS "${APP}_app.c" -lgcc -o "${APP}.elf"
step 2 objcopy ; "$OBJCOPY" -O binary "${APP}.elf" "${APP}.bin"
step 3 pack ; python3 ../pack_app.py "${APP}.bin" "${OUT}.app" \
--name "$APP_NAME" --ver "$APP_VER" --vma "${APP_VMA}" >/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"
+359
View File
@@ -0,0 +1,359 @@
/* 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.
*/
/*
* Broadcast FM — overlay app. Sovereign: drives the BK1080 directly and owns the
* radio while running (NO BK4819 dual-watch, unlike the resident FM). Reimplements
* the full resident FM (fm.c / UI_DisplayFM) - VFO + 48 memories - sharing the
* resident channel table (gFM_Channels) and config (gEeprom.FM_*), committed to
* EEPROM on exit. The BK1080 audio is a hardware path, so playback needs no CPU loop.
*
* Keys (iso legacy): 0-9 = frequency (VFO) / channel (MR) entry · UP/DOWN = tune /
* channel step · STAR = manual scan · F+STAR = auto-scan (stores 48) · MENU = save
* (VFO) / delete (MR) · F+1 = band · F+3 = VFO<->MR · F+0 / EXIT = quit.
*/
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "../app_api.h"
#define STEP 1
#define SCAN_SETTLE 100
#define CHMAX APP_FM_CH_MAX
static const char BAND_NAME[4][10] = { "87.5-108M", "76-108M", "76-90M", "64-76M" };
static const uint8_t FONT_F[8] = {0x7f,0x00,0x76,0x76,0x76,0x76,0x7e,0x7f};
static void cpy(uint8_t *d, const uint8_t *s, uint8_t n){ while(n--)*d++=*s++; }
static const app_api_t *A;
static uint16_t *ch; /* shared gFM_Channels[48] */
static app_fm_state_t st; /* freq_playing, sel_freq, band, is_mr, sel_ch */
static uint16_t lo, hi;
static int8_t scanState; /* 0 = off, +1 up, -1 down (non-blocking) */
static bool autoScan;
static uint8_t chPos;
static uint16_t scanTimer; /* ms until the next scan step */
static bool foundFreq;
static bool askSave, askDelete;
static uint8_t savePos;
static bool fArm;
static uint8_t inBox[4], inIdx;
static char str[16];
static char *putu(char *o, uint32_t v){ char t[6]; int8_t n=0; do{t[n++]=(char)('0'+v%10);v/=10;}while(v&&n<6); while(n--)*o++=t[n]; return o; }
static char *put(char *o, const char *s){ while(*s)*o++=*s++; return o; }
static void bandLimits(void){ lo=A->fm_lo(st.band); hi=A->fm_hi(st.band); }
static uint16_t wrap(uint16_t f){ if(f<lo)return hi; if(f>hi)return lo; return f; }
static void tune(void){ A->fm_set_freq(st.freq_playing, st.band); }
static void dirty(void){ /* nothing yet; committed on exit */ }
static bool validCh(uint8_t c){ return c<CHMAX && ch[c]>=lo && ch[c]<hi; }
static uint8_t findNext(uint8_t c, int8_t dir)
{
for(uint8_t i=0;i<CHMAX;i++){
if(c==0xFF) c=CHMAX-1;
else if(c>=CHMAX) c=0;
if(validCh(c)) return c;
c=(uint8_t)(c+dir);
}
return 0xFF;
}
static int configChannel(void)
{
st.freq_playing=st.sel_freq;
if(st.is_mr){
uint8_t c=findNext(st.sel_ch,+1);
if(c==0xFF){ st.is_mr=false; return -1; }
st.sel_ch=c; st.freq_playing=ch[c];
}
return 0;
}
/* ---- drawing (mirror UI_DisplayFM) ---- */
static void freqBig(uint16_t f)
{
uint16_t mhz=f/10u; char *o=str;
if(mhz<100u)*o++=' ';
o=putu(o,mhz); *o++='.'; *o++=(char)('0'+f%10u); *o='\0';
A->display_freq(str,36,1,true);
}
/* append a zero-padded 2-digit number (1..99), return the end pointer */
static char *num2(char *o, uint8_t n){ if(n<10)*o++='0'; return putu(o,n); }
static void draw(void)
{
char b[12], *o;
A->display_clear();
A->status_clear();
A->draw_battery();
if(fArm) cpy(A->status_line+69,FONT_F,8); /* F armed indicator */
A->print_string("FM",2,0,0,8);
A->print_normal(BAND_NAME[st.band],1,0,6);
/* ---- status (line 3) ---- */
if(askSave) { o=put(b,"SAVE?"); *o='\0'; }
else if(askDelete) { o=put(b,"DEL?"); *o='\0'; }
else if(scanState) {
if(autoScan){ o=put(b,"A-SCAN("); o=putu(o,chPos); *o++=')'; } /* count like UI_DisplayFM */
else o=put(b,"M-SCAN");
*o='\0';
}
else if(st.is_mr) { o=put(b,"MR(CH"); o=num2(o,(uint8_t)(st.sel_ch+1)); *o++=')'; *o='\0'; }
else {
o=put(b,"VFO"); *o='\0';
for(uint8_t i=0;i<CHMAX;i++) if(ch[i]==st.freq_playing){ o=put(b,"VFO(CH"); o=num2(o,(uint8_t)(i+1)); *o++=')'; *o='\0'; break; }
}
A->print_string(b,0,127,3,10);
/* ---- line 1: input in progress / channel prompt / frequency ---- */
if(inIdx>0){
if(st.is_mr||askSave){ /* channel number: "CH-XX" */
o=put(b,"CH-");
*o++=(char)('0'+inBox[0]);
*o++=(inIdx>1)?(char)('0'+inBox[1]):'-';
*o='\0';
A->print_string(b,0,127,1,10);
} else { /* VFO frequency: "ddd.d" */
b[0]=(char)('0'+inBox[0]);
b[1]=(inIdx>1)?(char)('0'+inBox[1]):'-';
b[2]=(inIdx>2)?(char)('0'+inBox[2]):'-';
b[3]='.';
b[4]=(inIdx>3)?(char)('0'+inBox[3]):'-';
b[5]='\0';
A->display_freq(b,36,1,false);
}
} else if(askSave){ /* pick a save slot */
o=put(b,"CH-"); o=num2(o,(uint8_t)(savePos+1)); *o='\0';
A->print_string(b,0,127,1,10);
} else if(askDelete){
o=put(b,"CH-"); o=num2(o,(uint8_t)(st.sel_ch+1)); *o='\0';
A->print_string(b,0,127,1,10);
} else {
freqBig(st.freq_playing);
}
}
static void show(void){ draw(); A->blit_status(); A->blit_full(); }
/* ---- non-blocking scan state machine (mirrors ACTION_Scan_FM / FM_Play / FM_Tune) ---- */
static void scanTuneNext(void) /* FM_Tune(freq, scanState, false): step + mute + settle */
{
A->fm_mute(true);
foundFreq=false;
st.freq_playing=wrap((uint16_t)(st.freq_playing+scanState));
tune();
scanTimer=SCAN_SETTLE;
}
static void stopScan(void) /* FM_PlayAndUpdate */
{
if(autoScan){ st.is_mr=true; st.sel_ch=0; }
scanState=0; autoScan=false;
configChannel();
tune();
A->fm_mute(false);
}
static void scanStep(void) /* FM_Play: one step when the settle timer expires */
{
if(A->fm_valid(st.freq_playing,lo)==0){ /* station locked */
if(!autoScan){
scanState=0; foundFreq=true;
if(!st.is_mr) st.sel_freq=st.freq_playing;
A->fm_mute(false); /* audio on */
return;
}
if(chPos<CHMAX) ch[chPos++]=st.freq_playing;
if(chPos>=CHMAX){ stopScan(); return; }
}
if(autoScan && st.freq_playing>=A->fm_hi(1)){ stopScan(); return; }
scanTuneNext();
}
static void startManual(int8_t dir)
{
if(scanState){ stopScan(); return; } /* STAR toggles */
autoScan=false; chPos=0; scanState=dir;
scanTuneNext();
}
static void startAuto(void)
{
if(scanState){ stopScan(); return; }
autoScan=true; chPos=0; scanState=+1;
for(uint8_t i=0;i<CHMAX;i++) ch[i]=0xFFFF;
st.freq_playing=lo;
A->fm_mute(true);
tune(); /* FM_Tune(lo,1,true): no step */
scanTimer=SCAN_SETTLE;
}
/* ---- digit entry ---- */
static void digit(uint8_t d)
{
if(askDelete||scanState) return;
if(askSave || st.is_mr){ /* 2-digit channel */
if(inIdx<2) inBox[inIdx++]=d;
if(inIdx>=2){
uint8_t c=(uint8_t)(inBox[0]*10+inBox[1]-1); inIdx=0;
if(askSave){ if(c<CHMAX) savePos=c; }
else if(validCh(c)){ st.sel_ch=c; st.freq_playing=ch[c]; tune(); }
}
return;
}
/* VFO frequency: 4 digits, with the leading-digit>1 zero-pad trick */
inBox[inIdx++]=d;
if(inIdx==1 && inBox[0]>1){ inBox[1]=inBox[0]; inBox[0]=0; inIdx=2; }
else if(inIdx>3){
uint16_t f=(uint16_t)(inBox[0]*1000+inBox[1]*100+inBox[2]*10+inBox[3]); inIdx=0;
if(f>=lo && f<=hi){ st.sel_freq=f; st.freq_playing=f; tune(); }
}
}
/* ---- MENU: save (VFO) / delete (MR) ---- */
static void menu(void)
{
inIdx=0;
if(!st.is_mr){ /* VFO: save */
if(askSave){ ch[savePos]=st.freq_playing; dirty(); }
askSave=!askSave;
} else { /* MR: delete */
if(askDelete){ ch[st.sel_ch]=0xFFFF; configChannel(); tune(); dirty(); }
askDelete=!askDelete;
}
}
/* ---- UP/DOWN: tune (VFO) or channel step (MR) ---- */
static void upDown(int8_t step)
{
if(scanState){ scanState=step; scanTuneNext(); return; } /* continue scan, new direction */
if(askSave){ savePos=(uint8_t)((savePos+step+CHMAX)%CHMAX); return; }
if(st.is_mr){
uint8_t c=findNext((uint8_t)(st.sel_ch+step),step);
if(c!=0xFF && c!=st.sel_ch){ st.sel_ch=c; st.freq_playing=ch[c]; tune(); }
} else {
st.sel_freq=wrap((uint16_t)(st.sel_freq+step));
st.freq_playing=st.sel_freq; tune();
}
}
static void toggleMr(void)
{
st.is_mr=!st.is_mr;
if(configChannel()!=0){ /* no valid channel: stayed VFO */ }
tune();
askSave=askDelete=false; inIdx=0;
}
static bool running;
static void cycleBand(void)
{
st.band=(uint8_t)((st.band+1)&3u); bandLimits();
if(st.freq_playing<lo||st.freq_playing>hi){ st.freq_playing=lo; st.sel_freq=lo; }
tune();
}
/* F+key and long-press functions (band / VFO<->MR / auto-scan / quit). */
static void func(uint8_t key)
{
switch(key){
case APP_KEY_0: running=false; break;
case APP_KEY_1: cycleBand(); break;
case APP_KEY_3: toggleMr(); break;
case APP_KEY_STAR: startAuto(); break;
default: break;
}
}
static void onShort(uint8_t key)
{
if(key==APP_KEY_F){ fArm=!fArm; return; }
if(fArm){ fArm=false; func(key); return; } /* F + key */
if(key<=APP_KEY_9){ digit(key); return; }
switch(key){
case APP_KEY_UP: upDown(+1); break;
case APP_KEY_DOWN: upDown(-1); break;
case APP_KEY_STAR: startManual(+1); break;
case APP_KEY_MENU: menu(); break;
case APP_KEY_EXIT:
if(inIdx) inIdx--;
else if(askSave||askDelete) askSave=askDelete=false;
else running=false;
break;
default: break;
}
}
static void onLong(uint8_t key){ fArm=false; func(key); } /* long band / MR / scan / quit */
__attribute__((section(".text.entry"),used))
void app_main(const app_api_t *api)
{
A=api;
ch=A->fm_channels;
A->fm_state(&st,false); /* inherit the resident FM state */
if(st.band>3) st.band=0;
bandLimits();
if(st.freq_playing<lo||st.freq_playing>hi) st.freq_playing=lo;
if(st.sel_freq<lo||st.sel_freq>hi) st.sel_freq=st.freq_playing;
if(st.is_mr) configChannel();
askSave=askDelete=fArm=false; inIdx=0; savePos=0;
A->fm_enter(st.freq_playing,st.band);
/* short = on release (held < ~0.5 s) · long = at the 0.5 s threshold ·
UP/DOWN = immediate step on press, then auto-repeat while held. */
running=true;
uint8_t held=APP_KEY_INVALID;
uint16_t heldMs=0;
bool firedLong=false;
bool dirty=true; /* redraw only on change */
while(running){
uint8_t key=A->get_key();
bool rep=(key==APP_KEY_UP||key==APP_KEY_DOWN);
if(key==APP_KEY_INVALID){
if(held!=APP_KEY_INVALID && !firedLong &&
held!=APP_KEY_UP && held!=APP_KEY_DOWN){
onShort(held); dirty=true;
}
held=APP_KEY_INVALID; heldMs=0; firedLong=false;
} else if(key!=held){
held=key; heldMs=0; firedLong=false;
if(rep){ onShort(key); dirty=true; } /* immediate first step */
} else {
heldMs=(uint16_t)(heldMs+50u);
if(rep){
if(heldMs>=300){ onShort(held); dirty=true; } /* auto-repeat */
} else if(!firedLong && heldMs>=400){
firedLong=true; onLong(held); dirty=true;
}
}
/* non-blocking scan: one step each time the settle timer expires */
if(scanState){
if(scanTimer<=50) scanStep();
else scanTimer=(uint16_t)(scanTimer-50);
dirty=true;
}
if(dirty){ show(); dirty=false; }
A->delay_ms(50);
}
/* push our state back to the resident FM and persist (config + 48 channels) */
if(!st.is_mr) st.sel_freq=st.freq_playing;
A->fm_state(&st,true);
A->fm_commit();
A->fm_exit();
}
+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,
"FoxHunt overlay app overflows the 4 KiB overlay")
/DISCARD/ : { *(.ARM.exidx*) *(.ARM.extab*) *(.eh_frame*) *(.comment) *(.note.*) }
}
+40
View File
@@ -0,0 +1,40 @@
#!/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="FoxHunt" # <-- the only per-app line
APP_VER="1.0"
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/3] %-8s' "$APP_NAME" "$1" "$2"; }
trap 'printf "\r ❌ %-13s build failed \n" "$APP_NAME"' ERR
step 1 compile ; "$CC" $CFLAGS $LDFLAGS "${APP}_app.c" -lgcc -o "${APP}.elf"
step 2 objcopy ; "$OBJCOPY" -O binary "${APP}.elf" "${APP}.bin"
step 3 pack ; python3 ../pack_app.py "${APP}.bin" "${OUT}.app" \
--name "$APP_NAME" --ver "$APP_VER" --vma "${APP_VMA}" >/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"
+357
View File
@@ -0,0 +1,357 @@
/* Copyright 2026 Armel F4HWN
* https://github.com/armel
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/*
* FoxHunt (RX) — overlay app. Ported from App/app/foxhunt.c, hunt sub-mode only
* (the beacon becomes a separate app). Signal-strength hunter: corrected dBm with
* a big number, an IARU S-meter staircase or a scrolling history, peak/min hold,
* a 1 s trend, a front-end attenuator ladder, and Geiger/station audio.
*
* Keys: 1 gauge (bar/history) · 2 audio (off/beep/station) · 3 + UP/DOWN attenuator
* MENU reset holds · long F keypad lock · EXIT quit.
*/
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
#include "../app_api.h"
#define LCD_WIDTH 128
#define BK_REG_13 0x13
/* --- tuning (from foxhunt.c) --- */
#define DBM_FLOOR (-141)
#define DBM_CEIL (-53)
#define TICK_MS 50
#define TREND_TICKS 20
#define LOCK_HOLD_MS 500
#define SILENCE_DBM (-120)
#define TONE_MIN 400
#define TONE_MAX 2400
#define RATE_SLOW 20
#define RATE_FAST 2
#define BLIP_MS 50
#define AUDIO_SETTLE 60
#define ATT_SETTLE 40
#define ATT_COUNT 6
#define ATT_BYP0 4
#define SEG_COUNT 13
#define BAR_X0 6
#define SEG_PITCH 9
#define SEG_W 8
#define SEG_BOTTOM 37
#define HIST_LEN 120
#define HIST_DECIM 3
#define GRAPH_X0 4
#define GRAPH_TOP 27
#define GRAPH_BOT 45
#define GRAPH_FLOOR 2
#define AUDIO_OFF 0
#define AUDIO_BEEP 1
#define AUDIO_STATION 2
#define GRAPH_BAR 0
#define GRAPH_HIST 1
#define CFG_MAGIC 0xF1 /* config validity marker */
static const uint16_t ATT_REG13[ATT_COUNT] = { 0x03DF, 0x03DD, 0x03DB, 0x03D9, 0x0379, 0x0139 };
static const uint8_t ATT_DB[ATT_BYP0] = { 0, 6, 15, 27 };
/* Status-bar / trend icons (bytes copied verbatim from App/bitmaps.c). */
static const uint8_t BMP_BARS[11] = {0x40,0x40,0x00,0x70,0x70,0x00,0x7c,0x7c,0x00,0x7f,0x7f};
static const uint8_t BMP_GRAPH[15] = {0x08,0x04,0x02,0x04,0x08,0x10,0x20,0x10,0x08,0x04,0x02,0x04,0x08,0x10,0x20};
static const uint8_t BMP_SIGNAL[10] = {0x08,0x1c,0x1c,0x08,0x00,0x22,0x1c,0x41,0x22,0x1c};
static const uint8_t BMP_SPEAKER[10] = {0x1c,0x1c,0x3e,0x7f,0x00,0x22,0x1c,0x41,0x22,0x1c};
static const uint8_t BMP_UP[11] = {0x20,0x30,0x38,0x3c,0x3e,0x3f,0x3e,0x3c,0x38,0x30,0x20};
static const uint8_t BMP_DOWN[11] = {0x01,0x03,0x07,0x0f,0x1f,0x3f,0x1f,0x0f,0x07,0x03,0x01};
static const uint8_t BMP_FLAT[11] = {0x12,0x12,0x12,0x12,0x12,0x12,0x12,0x12,0x12,0x12,0x12};
static const uint8_t FONT_F[8] = {0x7f,0x00,0x76,0x76,0x76,0x76,0x7e,0x7f};
static const uint8_t FONT_LOCK[9] = {0x7c,0x46,0x45,0x45,0x45,0x45,0x45,0x46,0x7c};
static void cpy(uint8_t *d, const uint8_t *s, uint8_t n){ while(n--)*d++=*s++; }
static const app_api_t *A;
static bool foxLocked, fArm, fLongDone;
static uint16_t fHoldMs;
static uint8_t foxAudioMode, foxGraphMode, attStep, audioTick;
static int16_t curDbm, peakDbm, minDbm, trendRef, trendDelta;
static uint8_t trendTick;
static uint8_t histBuf[HIST_LEN];
static uint8_t histHead, histTick;
static int16_t histEma;
static uint8_t prevKey;
static bool running;
static char str[16];
/* ---- 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; }
static char *puti(char *o,int v){
if(v<0){*o++='-';v=-v;}
char t[6]; int8_t n=0;
do{t[n++]=(char)('0'+v%10);v/=10;}while(v&&n<6);
while(n--)*o++=t[n];
return o;
}
static void i2str(char *out,int v){ *puti(out,v)='\0'; }
/* ---- radio helpers ---- */
static int16_t iabs16(int16_t v){ return v<0?-v:v; }
static void applyAtt(void){
uint16_t reg = A->bk_read(BK_REG_13);
reg = (uint16_t)((reg & ~0x03FFu) | ATT_REG13[attStep]);
A->bk_write(BK_REG_13, reg);
}
static void setAudio(void){
if(foxAudioMode==AUDIO_OFF){ A->audio_path(false); return; }
A->audio_path(true);
A->delay_ms(AUDIO_SETTLE);
A->set_af(APP_AF_MUTE);
}
static int32_t lerp(int16_t dbm,int32_t lo,int32_t hi){
if(dbm<=DBM_FLOOR) return lo;
if(dbm>=DBM_CEIL) return hi;
return lo + ((int32_t)(dbm-DBM_FLOOR)*(hi-lo))/(DBM_CEIL-DBM_FLOOR);
}
static void blip(uint16_t freq){
A->prepare_tone();
A->play_tone_raw(freq, BLIP_MS);
A->tones_off_rx();
A->set_agc(false);
applyAtt();
}
static uint8_t fillCount(int16_t dbm){
int16_t n;
if(dbm<-141) return 0;
if(dbm<=-93) n=1+(dbm+141)/6;
else n=9+(dbm+93)/10;
if(n>SEG_COUNT) n=SEG_COUNT;
return (uint8_t)n;
}
static void buildS(char *out,int16_t dbm){
if(dbm>=-93){ int16_t o=dbm-(-93); if(o>40)o=40; char *p=put(out,"S9+"); if(o<10)*p++='0'; i2str(p,o); }
else if(dbm<-141) put(out,"S0")[0]='\0';
else { char *p=put(out,"S"); i2str(p,(dbm+147)/6); }
}
static void histSample(void){
histEma += ((int16_t)curDbm*8 - histEma)/4;
if(++histTick<HIST_DECIM) return;
histTick=0;
histBuf[histHead]=fillCount((int16_t)(histEma/8));
histHead=(uint8_t)((histHead+1)%HIST_LEN);
}
static void rebase(void){
curDbm=A->rssi_dbm();
peakDbm=minDbm=trendRef=curDbm;
trendDelta=0; trendTick=0;
uint8_t lvl=fillCount(curDbm);
for(uint8_t i=0;i<HIST_LEN;i++) histBuf[i]=lvl;
histHead=0; histTick=0; histEma=(int16_t)(curDbm*8);
}
static void attCycle(int8_t dir){
attStep=(uint8_t)((attStep + (dir>0?1:ATT_COUNT-1))%ATT_COUNT);
applyAtt();
A->delay_ms(ATT_SETTLE);
rebase();
}
/* ---- drawing ---- */
static void fillRect(int16_t x0,int16_t y0,int16_t x1,int16_t y1){
for(int16_t x=x0;x<=x1;x++) A->draw_line(A->fb,x,y0,x,y1,true);
}
static void drawBar(void){
uint8_t n=fillCount(curDbm);
for(uint8_t i=0;i<n;i++){
int16_t sx=BAR_X0+i*SEG_PITCH;
fillRect(sx,SEG_BOTTOM-i,sx+SEG_W-1,SEG_BOTTOM);
}
A->draw_line(A->fb,BAR_X0,SEG_BOTTOM+2,BAR_X0+(SEG_COUNT-1)*SEG_PITCH+SEG_W-1,SEG_BOTTOM+2,true);
}
static void drawHist(void){
const uint8_t floorY=GRAPH_BOT-GRAPH_FLOOR;
const uint8_t span=floorY-GRAPH_TOP;
A->draw_line(A->fb,GRAPH_X0,GRAPH_BOT,GRAPH_X0+HIST_LEN-1,GRAPH_BOT,true);
uint8_t prevY=0, idx=histHead;
for(uint8_t c=0;c<HIST_LEN;c++){
uint8_t lvl=histBuf[idx]; if(++idx>=HIST_LEN)idx=0;
if(lvl>SEG_COUNT)lvl=SEG_COUNT;
uint8_t x=GRAPH_X0+c;
uint8_t y=(uint8_t)(floorY-(lvl*span)/SEG_COUNT);
for(uint8_t yy=y+1;yy<=floorY;yy++) if(((x+yy)&1)==0) A->draw_line(A->fb,x,yy,x,yy,true);
if(c==0){ A->draw_line(A->fb,x,y,x,y,true); }
else { uint8_t lo=(y<prevY)?y:prevY, hi=(y<prevY)?prevY:y; A->draw_line(A->fb,x,lo,x,hi,true); }
prevY=y;
}
}
static void tag(const char *s,uint8_t x,uint8_t line){
A->print_inverse(s,x,line,false,true,(uint8_t)(x+slen(s)*4));
}
static void draw(void){
char big[8], sMeter[8];
A->display_clear();
A->status_clear();
A->print_inverse("FOX HUNT",2,0,true,true,34);
A->draw_battery();
/* status-bar icons: graph mode @38, audio mode @55, F/lock @69 */
if(foxGraphMode==GRAPH_HIST) cpy(A->status_line+38,BMP_GRAPH,15);
else cpy(A->status_line+38,BMP_BARS,11);
if(foxAudioMode==AUDIO_BEEP) cpy(A->status_line+55,BMP_SIGNAL,10);
else if(foxAudioMode==AUDIO_STATION) cpy(A->status_line+55,BMP_SPEAKER,10);
if(foxLocked) cpy(A->status_line+69,FONT_LOCK,9);
else if(fArm) cpy(A->status_line+69,FONT_F,8);
i2str(big,curDbm);
A->display_freq(big,2,0,false);
A->print_normal("dBm",(uint8_t)(slen(big)*13+4),0,1);
/* trend arrow (line 0, right) + signed delta (line 1) */
cpy(A->fb[0]+115, trendDelta>0?BMP_UP:trendDelta<0?BMP_DOWN:BMP_FLAT, 11);
if(trendDelta!=0){
char *p=str; if(trendDelta>0)*p++='+'; else{*p++='-';}
int16_t a=iabs16(trendDelta); if(a<10){*p++='0';} i2str(p,a);
A->print_normal("dBm",127-3*7,0,1);
A->print_normal(str,(uint8_t)(127-3*7-2-slen(str)*7),1,1);
}
i2str(put(str,"PK "),peakDbm);
tag(str,4,2);
i2str(put(str,"MN "),minDbm);
tag(str,(uint8_t)((LCD_WIDTH-slen(str)*4)/2),2);
buildS(sMeter,curDbm);
A->print_inverse(sMeter,(uint8_t)(126-slen(sMeter)*4),2,false,true,125);
if(foxGraphMode==GRAPH_HIST) drawHist();
else drawBar();
if(attStep<ATT_BYP0){ char *o=put(str,"ATT "); o=puti(o,ATT_DB[attStep]); put(o,"dB")[0]='\0'; }
else put(str,(attStep==ATT_BYP0)?"BYP":"BYP+")[0]='\0';
tag(str,4,6);
{ uint32_t f=A->rx_freq(); char *o=puti(str,(int)(f/100000u)); *o++='.';
uint32_t fr=f%100000u; for(int8_t d=4;d>=0;d--){ uint32_t p=1; for(int8_t k=0;k<d;k++)p*=10; *o++=(char)('0'+(fr/p)%10);} *o='\0'; }
A->print_normal(str,(uint8_t)(126-slen(str)*7),0,6);
}
/* ---- config (deferred) ---- */
static void loadConfig(void){
uint8_t c[4];
A->cfg_load(c,4);
if(c[0]==CFG_MAGIC){
if(c[1]<ATT_COUNT) attStep=c[1];
if(c[2]<=GRAPH_HIST) foxGraphMode=c[2];
if(c[3]<=AUDIO_STATION) foxAudioMode=c[3];
}
}
static void saveConfig(void){
uint8_t c[4]={CFG_MAGIC,attStep,foxGraphMode,foxAudioMode};
A->cfg_save(c,4);
}
/* ---- input ---- */
static void handleKeys(void){
uint8_t key=A->get_key();
/* long-press F -> keypad lock */
if(key==APP_KEY_F){
if(!fLongDone){ fHoldMs+=TICK_MS; if(fHoldMs>=LOCK_HOLD_MS){ fLongDone=true; foxLocked=!foxLocked; fArm=false; A->backlight_on(); } }
} else { fHoldMs=0; fLongDone=false; }
if(key==APP_KEY_INVALID||key==prevKey){ prevKey=key; return; }
prevKey=key;
A->backlight_on();
if(foxLocked){
if(key==APP_KEY_UP) attCycle(+1);
if(key==APP_KEY_DOWN) attCycle(-1);
return;
}
if(key==APP_KEY_F){ fArm=!fArm; return; }
int8_t dir=fArm?-1:1;
switch(key){
case APP_KEY_EXIT: running=false; break;
case APP_KEY_1: foxGraphMode^=1; break;
case APP_KEY_2:
foxAudioMode=(uint8_t)((foxAudioMode+(dir>0?1:2))%3); setAudio();
if(foxAudioMode==AUDIO_BEEP){ audioTick=RATE_SLOW; }
break;
case APP_KEY_3: attCycle(dir); break;
case APP_KEY_UP: attCycle(+1); break;
case APP_KEY_DOWN: attCycle(-1); break;
case APP_KEY_MENU: peakDbm=minDbm=trendRef=curDbm; break;
default: break;
}
fArm=false;
}
static void tickDelay(void){
for(uint8_t i=0;i<TICK_MS/10;i++){ A->delay_ms(10); A->backlight_update(); }
}
__attribute__((section(".text.entry"),used))
void app_main(const app_api_t *api){
A=api;
foxLocked=fArm=fLongDone=false; fHoldMs=0;
attStep=0; foxGraphMode=GRAPH_BAR; foxAudioMode=AUDIO_OFF;
prevKey=APP_KEY_INVALID;
loadConfig();
A->backlight_on();
A->set_agc(false);
applyAtt();
setAudio();
A->delay_ms(ATT_SETTLE);
rebase();
running=true;
while(running){
handleKeys();
if(!running) break;
curDbm=A->rssi_dbm();
if(curDbm>peakDbm) peakDbm=curDbm;
if(curDbm<minDbm) minDbm=curDbm;
if(++trendTick>=TREND_TICKS){ trendDelta=curDbm-trendRef; trendRef=curDbm; trendTick=0; }
histSample();
draw();
A->blit_status();
A->blit_full();
if(foxAudioMode==AUDIO_BEEP && curDbm>=SILENCE_DBM){
if(++audioTick>=(uint8_t)lerp(curDbm,RATE_SLOW,RATE_FAST)){ audioTick=0; blip((uint16_t)lerp(curDbm,TONE_MIN,TONE_MAX)); }
} else if(foxAudioMode==AUDIO_STATION){
A->set_af(curDbm>=SILENCE_DBM?APP_AF_FM:APP_AF_MUTE);
}
A->battery_sample();
tickDelay();
}
saveConfig();
A->audio_path(false);
}
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
# Pack an overlay-app raw binary into a .app blob: 64-byte header + code.
# Header layout mirrors app_overlay.h : app_header_t (little-endian, packed).
# The format/version constants (magic, header + ABI version, overlay budget) are
# read straight from the C headers the firmware itself compiles, so there is a
# SINGLE source of truth - the packer can never silently drift from the loader.
# CRC-32 is zlib/PKZIP (init 0xFFFFFFFF, poly 0xEDB88320, final XOR) to match
# the firmware's mb_ext_image_crc32 / mb_crc32_bytes.
#
# ./pack_app.py breakout/breakout.bin breakout/breakout.app --name Breakout --ver 1.0
import argparse, os, re, struct, zlib, sys
HERE = os.path.dirname(os.path.abspath(__file__))
def cdefine(header: str, name: str) -> int:
"""Value of a `#define <name> <int-literal>` in a sibling C header.
Accepts a decimal or 0x-hex literal with optional u/U/l/L suffixes. Keeps the
blob format tied to the firmware's own headers (single source of truth)."""
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")
# Single source of truth: the C headers the firmware also compiles.
MAGIC = cdefine("app_overlay.h", "APP_MAGIC").to_bytes(4, "little") # 0x31504146 -> b"FAP1"
HDR_VERSION = cdefine("app_overlay.h", "APP_HDR_VERSION")
ABI_VERSION = cdefine("app_api.h", "APP_ABI_VERSION")
OVERLAY_MAX = cdefine("app_overlay.h", "APP_OVERLAY_MAX") # 4 KiB overlay budget
FLAG_COMMITTED = 1 # APP_FLAG_COMMITTED, bit 0
def field(s: str, n: int) -> bytes:
b = s.encode("ascii", "strict")[: n - 1]
return b + b"\x00" * (n - len(b))
def main():
ap = argparse.ArgumentParser()
ap.add_argument("infile")
ap.add_argument("outfile")
ap.add_argument("--name", default="app")
ap.add_argument("--ver", default="1.0")
ap.add_argument("--entry", type=lambda x: int(x, 0), default=0)
ap.add_argument("--vma", type=lambda x: int(x, 0), required=True,
help="RAM VMA the app was linked at (must match the firmware overlay)")
a = ap.parse_args()
code = open(a.infile, "rb").read()
if len(code) == 0:
sys.exit("empty input")
if len(code) > OVERLAY_MAX:
sys.exit(f"code {len(code)} B exceeds overlay budget {OVERLAY_MAX} B")
crc = zlib.crc32(code) & 0xFFFFFFFF
header = struct.pack(
"<4sHHIIHH16s16sI8s",
MAGIC, HDR_VERSION, ABI_VERSION,
len(code), crc, a.entry, FLAG_COMMITTED,
field(a.name, 16), field(a.ver, 16), a.vma, b"\x00" * 8,
)
assert len(header) == 64, len(header)
with open(a.outfile, "wb") as f:
f.write(header)
f.write(code)
print(f"{a.outfile}: name={a.name!r} ver={a.ver!r} vma=0x{a.vma:08x} "
f"code={len(code)} B crc32=0x{crc:08x} -> blob {64 + len(code)} B")
if __name__ == "__main__":
main()
+10
View File
@@ -416,6 +416,16 @@ void PY25Q16_InvalidateCache(void)
SectorCacheAddr = 0x1000000; SectorCacheAddr = 0x1000000;
} }
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_APPS
/* Expose the 4 KiB sector cache as the overlay-app workspace. It lives in
* .bss.mb_workspace (the overlay VMA), so an app blob linked there runs in
* place once copied in. The caller InvalidateCache()s around its use. */
uint8_t *PY25Q16_OverlayBuffer(void)
{
return SectorCache;
}
#endif
static inline void WriteAddr(uint32_t Addr) static inline void WriteAddr(uint32_t Addr)
{ {
SPI_WriteByte(0xff & (Addr >> 16)); SPI_WriteByte(0xff & (Addr >> 16));
+5
View File
@@ -32,6 +32,11 @@ void PY25Q16_SectorErase(uint32_t Address);
* is also called before multiboot reuses the cache storage as a RAM overlay. */ * is also called before multiboot reuses the cache storage as a RAM overlay. */
void PY25Q16_InvalidateCache(void); void PY25Q16_InvalidateCache(void);
#ifdef ENABLE_FEAT_F4HWN_OVERLAY_APPS
/* The 4 KiB sector cache, reused as the overlay-app execution workspace. */
uint8_t *PY25Q16_OverlayBuffer(void);
#endif
#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT #ifdef ENABLE_FEAT_F4HWN_MULTIBOOT
/* /*
* Multiboot per-bank config banking. * Multiboot per-bank config banking.
+1 -1
View File
@@ -54,7 +54,7 @@ static void convertTime(uint8_t *line, uint8_t type)
#endif #endif
#endif #endif
#ifdef ENABLE_FEAT_F4HWN_FOXHUNT #if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS)
void UI_DrawStatusBattery(uint8_t *line, char *str) void UI_DrawStatusBattery(uint8_t *line, char *str)
#else #else
static __attribute__((always_inline)) inline void UI_DrawStatusBattery(uint8_t *line, char *str) static __attribute__((always_inline)) inline void UI_DrawStatusBattery(uint8_t *line, char *str)
+1 -1
View File
@@ -19,7 +19,7 @@
#include <stdint.h> #include <stdint.h>
#ifdef ENABLE_FEAT_F4HWN_FOXHUNT #if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS)
void UI_DrawStatusBattery(uint8_t *line, char *str); void UI_DrawStatusBattery(uint8_t *line, char *str);
#endif #endif
void UI_DisplayStatus(); void UI_DisplayStatus();
+7 -1
View File
@@ -53,8 +53,14 @@ void (*const UI_DisplayFunctions[])(void) = {
[DISPLAY_MENU] = &UI_DisplayMenu, [DISPLAY_MENU] = &UI_DisplayMenu,
[DISPLAY_SCANNER] = &UI_DisplayScanner, [DISPLAY_SCANNER] = &UI_DisplayScanner,
#ifdef ENABLE_FMRADIO #if defined(ENABLE_FMRADIO) && !defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS)
[DISPLAY_FM] = &UI_DisplayFM, [DISPLAY_FM] = &UI_DisplayFM,
#elif defined(ENABLE_FMRADIO)
/* The FM overlay app replaces the resident FM screen (ui/fmradio.c is not
compiled). DISPLAY_FM still exists in the enum, so the slot must stay
initialised to keep ARRAY_SIZE == DISPLAY_N_ELEM; point it at a
never-reached stub - the resident FM screen can no longer open. */
[DISPLAY_FM] = &UI_DisplayMain,
#endif #endif
#ifdef ENABLE_AIRCOPY #ifdef ENABLE_AIRCOPY
+10 -7
View File
@@ -91,6 +91,7 @@
"ENABLE_FEAT_F4HWN_MENU_CAT": false, "ENABLE_FEAT_F4HWN_MENU_CAT": false,
"ENABLE_FEAT_F4HWN_MULTIBOOT": false, "ENABLE_FEAT_F4HWN_MULTIBOOT": false,
"ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY": false, "ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY": false,
"ENABLE_FEAT_F4HWN_OVERLAY_APPS": false,
"ENABLE_AGC_SHOW_DATA": false, "ENABLE_AGC_SHOW_DATA": false,
"ENABLE_UART_RW_BK_REGS": false, "ENABLE_UART_RW_BK_REGS": false,
"ENABLE_SWD": false, "ENABLE_SWD": false,
@@ -142,6 +143,7 @@
"ENABLE_FEAT_F4HWN_ACTION_PICKER": true, "ENABLE_FEAT_F4HWN_ACTION_PICKER": true,
"ENABLE_FEAT_F4HWN_MULTIBOOT": true, "ENABLE_FEAT_F4HWN_MULTIBOOT": true,
"ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY": true, "ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY": true,
"ENABLE_FEAT_F4HWN_OVERLAY_APPS": false,
"ENABLE_SWD": true, "ENABLE_SWD": true,
"EDITION_STRING": "Fusion", "EDITION_STRING": "Fusion",
"TARGET": "f4hwn.fusion" "TARGET": "f4hwn.fusion"
@@ -168,16 +170,17 @@
} }
}, },
{ {
"name": "Max", "name": "Labs",
"inherits": "Fusion", "inherits": "Fusion",
"cacheVariables": { "cacheVariables": {
"ENABLE_FEAT_F4HWN_RESCUE_OPS": true, "ENABLE_FEAT_F4HWN_RESCUE_OPS": true,
"ENABLE_FEAT_F4HWN_FOXHUNT": true, "ENABLE_FEAT_F4HWN_FOXHUNT": false,
"ENABLE_AIRCOPY": true, "ENABLE_AIRCOPY": true,
"ENABLE_FEAT_F4HWN_BEAM": true, "ENABLE_FEAT_F4HWN_BEAM": true,
"ENABLE_FEAT_F4HWN_GAME": true, "ENABLE_FEAT_F4HWN_GAME": false,
"EDITION_STRING": "Max", "ENABLE_FEAT_F4HWN_OVERLAY_APPS": true,
"TARGET": "f4hwn.max" "EDITION_STRING": "Labs",
"TARGET": "f4hwn.labs"
} }
} }
], ],
@@ -199,8 +202,8 @@
"configurePreset": "FieldOps" "configurePreset": "FieldOps"
}, },
{ {
"name": "Max", "name": "Labs",
"configurePreset": "Max" "configurePreset": "Labs"
} }
] ]
} }
+11 -2
View File
@@ -162,8 +162,17 @@ SECTIONS
/* Optional multiboot overlay. The restore stub is linked at the same RAM /* Optional multiboot overlay. The restore stub is linked at the same RAM
address as the 4 KiB PY25Q16 sector cache, but its load image remains in address as the 4 KiB PY25Q16 sector cache, but its load image remains in
FLASH and is copied into the cache only immediately before reflashing. */ FLASH and is copied into the cache only immediately before reflashing.
OVERLAY : NOCROSSREFS AT (LOADADDR(.noncacheable) + SIZEOF(.noncacheable))
The VMA is PINNED to a fixed RAM address (ORIGIN + 0x280) so a single
overlay-app blob runs on EVERY preset: without pinning, the sector-cache
VMA shifts with the per-preset .data size (Fusion 0x1dc, Transfer/Max 0x1e0,
...), and a blob linked for one preset is refused by the loader's VMA guard
on another. 0x280 clears the ~0x1ec of RAM used before it; the assert fails
loudly (raise the anchor) if a preset ever needs more. */
ASSERT((ADDR(.noncacheable) + SIZEOF(.noncacheable)) <= (ORIGIN(RAM) + 0x280),
"RAM before the overlay exceeds the fixed 0x280 anchor - raise MB_OVERLAY_VMA")
OVERLAY (ORIGIN(RAM) + 0x280) : NOCROSSREFS AT (LOADADDR(.noncacheable) + SIZEOF(.noncacheable))
{ {
.mb_ramfunc .mb_ramfunc
{ {
+20 -18
View File
@@ -73,9 +73,11 @@ Specialized presets extend Fusion for specific uses:
- **Transfer** adds AirCopy and BEAM wireless channel transfer. - **Transfer** adds AirCopy and BEAM wireless channel transfer.
- **FieldOps** adds first-responder controls, Fox Hunt and Morse Beacon support. - **FieldOps** adds first-responder controls, Fox Hunt and Morse Beacon support.
- **Max** packs the broadest practical selection of features that fits safely within - **Labs** is the experimental edition. It carries the broad feature selection of the
the radio's Flash and RAM limits. It is not a strict superset of every other edition: other releases and adds the overlay-apps platform (apps loaded from external Flash and
features may be exchanged between releases to preserve stability and memory headroom. run in a 4 KiB RAM overlay) — the newest, least-settled work. Expect rough edges. It is
not a strict superset of every other edition: features may be exchanged between releases
to preserve stability and memory headroom.
- **Custom** remains a manually configured build based directly on the hidden technical default. - **Custom** remains a manually configured build based directly on the hidden technical default.
### Radio and signal handling ### Radio and signal handling
@@ -330,7 +332,7 @@ But, they are nice toys for the price, fun to play with.
## Compiling and Building from Docker ## Compiling and Building from Docker
This project provides a Docker-based build system for the UV-K1 and UV-K5 V3. This project provides a Docker-based build system for the UV-K1 and UV-K5 V3.
Everything is handled through the `compile-with-docker.sh` helper script. Fusion is Everything is handled through the `compile-firmware.sh` helper script. Fusion is
the default generic preset, while specialized builds are generated in their own the default generic preset, while specialized builds are generated in their own
`build/<Preset>` directories. `build/<Preset>` directories.
@@ -341,7 +343,7 @@ the default generic preset, while specialized builds are generated in their own
### Build Script Overview ### Build Script Overview
The script `compile-with-docker.sh`: The script `compile-firmware.sh`:
1. Builds the Docker image (`uvk1-uvk5v3`) if it does not already exist. 1. Builds the Docker image (`uvk1-uvk5v3`) if it does not already exist.
2. Configures the selected preset with `cmake --fresh`. 2. Configures the selected preset with `cmake --fresh`.
@@ -351,7 +353,7 @@ The script `compile-with-docker.sh`:
### Usage ### Usage
```bash ```bash
./compile-with-docker.sh [Preset] [extra CMake options] ./compile-firmware.sh [Preset] [extra CMake options]
``` ```
The default preset is **Fusion**. Available presets are: The default preset is **Fusion**. Available presets are:
@@ -360,18 +362,18 @@ The default preset is **Fusion**. Available presets are:
- **Fusion** - **Fusion**
- **Transfer** - **Transfer**
- **FieldOps** - **FieldOps**
- **Max** - **Labs**
- **All** (Fusion, Transfer, FieldOps and Max) - **All** (Fusion, Transfer, FieldOps and Labs)
Examples: Examples:
```bash ```bash
./compile-with-docker.sh ./compile-firmware.sh
./compile-with-docker.sh Fusion ./compile-firmware.sh Fusion
./compile-with-docker.sh Transfer ./compile-firmware.sh Transfer
./compile-with-docker.sh FieldOps ./compile-firmware.sh FieldOps
./compile-with-docker.sh Max ./compile-firmware.sh Labs
./compile-with-docker.sh All ./compile-firmware.sh All
``` ```
### Passing Additional CMake Options ### Passing Additional CMake Options
@@ -382,15 +384,15 @@ These are forwarded directly to `cmake --preset` inside the container.
Examples: Examples:
```bash ```bash
./compile-with-docker.sh FieldOps -DENABLE_VOX=OFF ./compile-firmware.sh FieldOps -DENABLE_VOX=OFF
./compile-with-docker.sh Fusion -DENABLE_FEAT_F4HWN_GAME=ON ./compile-firmware.sh Fusion -DENABLE_FEAT_F4HWN_GAME=ON
./compile-with-docker.sh Fusion -DSQL_TONE=600 ./compile-firmware.sh Fusion -DSQL_TONE=600
``` ```
To prepare the rolling development firmware: To prepare the rolling development firmware:
```bash ```bash
./compile-with-docker.sh Fusion -DDEV=ON ./compile-firmware.sh Fusion -DDEV=ON
``` ```
This keeps the regular build output in `build/Fusion` and also updates This keeps the regular build output in `build/Fusion` and also updates
Executable
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# Build overlay-app blobs (.app), the counterpart of compile-firmware.sh.
#
# Usage:
# ./compile-app.sh # build every app
# ./compile-app.sh All # build every app
# ./compile-app.sh breakout # build one app
# ./compile-app.sh fm foxhunt # build several apps
#
# Each app is compiled in the uvk1-uvk5v3 Docker image, linked at the pinned
# overlay VMA, packed into build/Apps/<Name>.app (named after the app's human
# label, spaces stripped), and reported against the 4 KiB overlay budget. Apps
# are auto-discovered from App/apps/<name>/build.sh.
# ---------------------------------------------------------------------------
set -euo pipefail
IMAGE=uvk1-uvk5v3
APPS_DIR=App/apps
OUT_DIR=build/Apps
OVERLAY_MAX=4096
APP_VMA=${APP_VMA:-0x20000280} # pinned overlay VMA (Core/py32f071xb.ld)
cd "$(dirname "$0")"
# --- discover apps (any App/apps/<name>/build.sh) ---
ALL_APPS=()
for d in "$APPS_DIR"/*/; do
[ -f "${d}build.sh" ] && ALL_APPS+=("$(basename "$d")")
done
if [ "${#ALL_APPS[@]}" -eq 0 ]; then
echo "❌ No apps found under $APPS_DIR/*/build.sh"; exit 1
fi
# --- select targets ---
if [ "$#" -eq 0 ] || [ "${1,,}" = "all" ]; then
TARGETS=("${ALL_APPS[@]}")
else
TARGETS=()
for want in "$@"; do
ok=0
for a in "${ALL_APPS[@]}"; do [ "$a" = "$want" ] && ok=1; done
if [ "$ok" -eq 0 ]; then
echo "❌ Unknown app '$want'. Available: ${ALL_APPS[*]} (or All)"; exit 1
fi
TARGETS+=("$want")
done
fi
command -v docker >/dev/null 2>&1 || { echo "❌ docker not found"; exit 1; }
if [[ "$(docker images -q "$IMAGE")" == "" ]]; then
echo "🐳 Building Docker image..."; docker build -q -t "$IMAGE" . >/dev/null
fi
mkdir -p "$OUT_DIR"
# Give the container a pseudo-TTY when we are interactive, so build.sh's
# single-line [k/3] progress refreshes in place (same trick as the firmware
# build gives Ninja). Batch/redirected runs stay plain and line-oriented.
TTY_ARGS=(); [[ -t 1 ]] && TTY_ARGS=(-t)
echo
echo "🚀 Building overlay apps"
printf ' VMA %s · budget %d B / %d.00 KiB · out %s/\n\n' \
"$APP_VMA" "$OVERLAY_MAX" "$((OVERLAY_MAX / 1024))" "$OUT_DIR"
files=(); sizes=(); vmas=(); states=()
fail=0
read_u32le() { od -An -tx1 -j"$2" -N4 "$1" | awk '{printf "0x%s%s%s%s",$4,$3,$2,$1}'; }
for app in "${TARGETS[@]}"; do
if docker run --rm "${TTY_ARGS[@]}" -u "$(id -u):$(id -g)" \
-v "$PWD":/work -w "/work/$APPS_DIR/$app" \
-e PATH="/opt/toolchain/bin:/usr/bin:/bin" -e APP_VMA="$APP_VMA" \
"$IMAGE" bash ./build.sh; then
appfile=$(ls -1 "$APPS_DIR/$app"/*.app 2>/dev/null | head -1)
if [ -n "$appfile" ] && [ -f "$appfile" ]; then
cp -f "$appfile" "$OUT_DIR/"
base=$(basename "$appfile")
code=$(( $(wc -c < "$appfile") - 64 ))
vma=$(read_u32le "$appfile" 52)
state="✅ OK"; [ "$code" -gt "$OVERLAY_MAX" ] && { state="🚨 OVERFLOW"; fail=1; }
else
base="$app.app"; code=-1; vma="--"; state="❌ NO BLOB"; fail=1
fi
else
base="$app.app"; code=-1; vma="--"; state="❌ BUILD FAIL"; fail=1
fi
files+=("$base"); sizes+=("$code"); vmas+=("$vma"); states+=("$state")
done
# --- memory report (styled like the firmware Flash/RAM tables) ---
echo
printf '📦 Overlay apps (budget: %d B / %d.00 KiB)\n' "$OVERLAY_MAX" "$((OVERLAY_MAX / 1024))"
printf '%-16s | %9s | %9s | %9s | %9s | %7s | %s\n' \
"File" "Code (B)" "Code(KiB)" "Free (B)" "Free(KiB)" "Usage" "Status"
printf '%-16s-+-%9s-+-%9s-+-%9s-+-%9s-+-%7s-+-%s\n' \
"----------------" "---------" "---------" "---------" "---------" "-------" "----------"
for i in "${!files[@]}"; do
c=${sizes[$i]}
if [ "$c" -ge 0 ]; then
free=$(( OVERLAY_MAX - c ))
bp=$(( (c * 10000 + OVERLAY_MAX / 2) / OVERLAY_MAX ))
ck100=$(( (c * 100 + 512) / 1024 )); fk100=$(( (free * 100 + 512) / 1024 ))
printf -v pct '%d.%02d%%' "$((bp/100))" "$((bp%100))"
printf -v ckib '%d.%02d' "$((ck100/100))" "$((ck100%100))"
printf -v fkib '%d.%02d' "$((fk100/100))" "$((fk100%100))"
printf '%-16s | %9d | %9s | %9d | %9s | %7s | %s\n' \
"${files[$i]}" "$c" "$ckib" "$free" "$fkib" "$pct" "${states[$i]}"
else
printf '%-16s | %9s | %9s | %9s | %9s | %7s | %s\n' \
"${files[$i]}" "-" "-" "-" "-" "-" "${states[$i]}"
fi
done
echo
if [ "$fail" -eq 0 ]; then
if [ "${#files[@]}" -eq 1 ]; then
echo "🎉 App ${files[0]} built successfully -> $OUT_DIR/"
else
echo "🎉 All ${#files[@]} apps built successfully -> $OUT_DIR/"
fi
else
echo "⚠️ Some apps failed or overflowed — see the table above."
fi
exit $fail
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env bash
set -euo pipefail
# ---------------------------------------------
# Usage:
# ./compile-firmware.sh [Preset] [CMake options...]
# Examples:
# ./compile-firmware.sh Fusion
# ./compile-firmware.sh Transfer
# ./compile-firmware.sh FieldOps
# ./compile-firmware.sh Labs
# ./compile-firmware.sh Fusion -DDEV=ON
# ./compile-firmware.sh FieldOps -DENABLE_VOX=OFF
# ./compile-firmware.sh All
# Default preset: "Fusion"
# ---------------------------------------------
IMAGE=uvk1-uvk5v3
RELEASE_PRESETS=(Fusion Transfer FieldOps Labs)
FLASH_LIMIT=$((118 * 1024))
RAM_LIMIT=$((16 * 1024))
PRESET=${1:-Fusion}
shift || true # remove preset from arguments if present
# Any remaining args will be treated as CMake cache variables
EXTRA_ARGS=("$@")
# ---------------------------------------------
# Validate preset name
# ---------------------------------------------
if [[ ! "$PRESET" =~ ^(Custom|Fusion|Transfer|FieldOps|Labs|All)$ ]]; then
echo "❌ Unknown preset: '$PRESET'"
echo "Valid presets are: Custom, Fusion, Transfer, FieldOps, Labs, All"
exit 1
fi
QUIET=0
if [[ "$PRESET" == "All" ]]; then
QUIET=1
fi
# Remember whether the script itself was started from an interactive terminal.
# The build output is piped through tee below, so checking stdout later from
# run_preset_build() would always report a non-terminal.
INTERACTIVE=0
if [[ -t 1 ]]; then
INTERACTIVE=1
fi
# ---------------------------------------------
# Build the Docker image (only needed once)
# ---------------------------------------------
if [[ "$(docker images -q "$IMAGE")" == "" ]]; then
echo "Building Docker image..."
docker build -q -t "$IMAGE" . >/dev/null
fi
export MSYS_NO_PATHCONV=1
RESULT_PRESETS=()
RESULT_FLASH_SIZES=()
RESULT_RAM_SIZES=()
run_preset_build() {
local preset="$1"
local -a docker_tty_args=()
# Give Ninja a pseudo-terminal for an interactive single-preset build. This
# lets it refresh its [current/total] progress on one line. Batch/redirected
# builds keep plain line-oriented output suitable for logs and CI.
if (( INTERACTIVE && ! QUIET )); then
docker_tty_args=(-t)
fi
docker run --rm "${docker_tty_args[@]}" \
-u "$(id -u):$(id -g)" \
-v "$PWD":/src -w /src "$IMAGE" \
bash -c 'which arm-none-eabi-gcc && arm-none-eabi-gcc --version &&
cmake --fresh --preset "$1" "${@:2}" &&
cmake --build --preset "$1" -j' \
bash "$preset" "${EXTRA_ARGS[@]}"
}
build_preset() {
local preset="$1"
local preset_slug log_file bin_file flash_size ram_size status
preset_slug="${preset,,}"
log_file="$(mktemp)"
bin_file=""
find "build/${preset}" -maxdepth 1 -type f -name 'f4hwn.*' -delete 2>/dev/null || true
if (( QUIET )); then
printf "Building %-10s ... " "$preset"
if run_preset_build "$preset" >"$log_file" 2>&1; then
status=0
else
status=$?
fi
else
echo ""
echo "=== 🚀 Building preset: ${preset} ==="
echo "---------------------------------------------"
if (( INTERACTIVE )); then
# Do not put a line-oriented filter after tee here: it would buffer
# Ninja's carriage-return progress updates and defeat the TTY display.
if run_preset_build "$preset" 2>&1 | tee "$log_file"; then
status=0
else
status=$?
fi
else
if run_preset_build "$preset" 2>&1 | tee "$log_file" | awk '
/^Memory region[[:space:]]+Used Size[[:space:]]+Region Size/ { next }
/^[[:space:]]+RAM:[[:space:]]/ { next }
/^[[:space:]]+FLASH:[[:space:]]/ { next }
{ print; fflush() }
'; then
status=0
else
status=$?
fi
fi
fi
if (( status != 0 )); then
if (( QUIET )); then
printf "FAILED\n\n"
cat "$log_file"
else
echo "Failed: ${preset}"
fi
rm -f -- "$log_file"
return "$status"
fi
bin_file="build/${preset}/f4hwn.${preset_slug}.bin"
if [[ -z "$bin_file" || ! -f "$bin_file" ]]; then
if (( QUIET )); then
printf "FAILED\n"
fi
echo "Expected binary not found: $bin_file"
if (( QUIET )); then
cat "$log_file"
fi
rm -f -- "$log_file"
return 1
fi
flash_size="$(wc -c < "$bin_file")"
ram_size="$(awk '$1 == "RAM:" {
value = $2
if ($3 == "KB") value *= 1024
else if ($3 == "MB") value *= 1024 * 1024
printf "%.0f\n", value
}' "$log_file" | tail -n 1)"
if [[ ! "$ram_size" =~ ^[0-9]+$ ]]; then
if (( QUIET )); then
printf "FAILED\n"
fi
echo "Could not read RAM usage from linker output"
if (( QUIET )); then
cat "$log_file"
fi
rm -f -- "$log_file"
return 1
fi
RESULT_PRESETS+=("$preset")
RESULT_FLASH_SIZES+=("$flash_size")
RESULT_RAM_SIZES+=("$ram_size")
if (( QUIET )); then
printf "OK\n"
else
echo "✅ Done: ${preset}"
fi
rm -f -- "$log_file"
}
print_summary() {
local i
local flash_used flash_free flash_bp flash_pct
local flash_used_kib100 flash_free_kib100 flash_used_kib flash_free_kib
local ram_used ram_free ram_bp ram_pct
local ram_used_kib100 ram_free_kib100 ram_used_kib ram_free_kib
echo ""
printf "Flash (limit: %d B / %d.00 KiB)\n" "$FLASH_LIMIT" "$((FLASH_LIMIT / 1024))"
printf "%-10s | %10s | %10s | %10s | %10s | %7s\n" \
"Preset" "Used (B)" "Used (KiB)" "Free (B)" "Free (KiB)" "Usage"
printf "%-10s-+-%10s-+-%10s-+-%10s-+-%10s-+-%7s\n" \
"----------" "----------" "----------" "----------" "----------" "-------"
for i in "${!RESULT_PRESETS[@]}"; do
flash_used="${RESULT_FLASH_SIZES[$i]}"
flash_free=$((FLASH_LIMIT - flash_used))
flash_bp=$(((flash_used * 10000 + FLASH_LIMIT / 2) / FLASH_LIMIT))
flash_used_kib100=$(((flash_used * 100 + 512) / 1024))
flash_free_kib100=$(((flash_free * 100 + 512) / 1024))
printf -v flash_pct "%d.%02d%%" "$((flash_bp / 100))" "$((flash_bp % 100))"
printf -v flash_used_kib "%d.%02d" "$((flash_used_kib100 / 100))" "$((flash_used_kib100 % 100))"
printf -v flash_free_kib "%d.%02d" "$((flash_free_kib100 / 100))" "$((flash_free_kib100 % 100))"
printf "%-10s | %10d | %10s | %10d | %10s | %7s\n" \
"${RESULT_PRESETS[$i]}" "$flash_used" "$flash_used_kib" \
"$flash_free" "$flash_free_kib" "$flash_pct"
done
echo ""
printf "RAM (limit: %d B / %d.00 KiB)\n" "$RAM_LIMIT" "$((RAM_LIMIT / 1024))"
printf "%-10s | %10s | %10s | %10s | %10s | %7s\n" \
"Preset" "Used (B)" "Used (KiB)" "Free (B)" "Free (KiB)" "Usage"
printf "%-10s-+-%10s-+-%10s-+-%10s-+-%10s-+-%7s\n" \
"----------" "----------" "----------" "----------" "----------" "-------"
for i in "${!RESULT_PRESETS[@]}"; do
ram_used="${RESULT_RAM_SIZES[$i]}"
ram_free=$((RAM_LIMIT - ram_used))
ram_bp=$(((ram_used * 10000 + RAM_LIMIT / 2) / RAM_LIMIT))
ram_used_kib100=$(((ram_used * 100 + 512) / 1024))
ram_free_kib100=$(((ram_free * 100 + 512) / 1024))
printf -v ram_pct "%d.%02d%%" "$((ram_bp / 100))" "$((ram_bp % 100))"
printf -v ram_used_kib "%d.%02d" "$((ram_used_kib100 / 100))" "$((ram_used_kib100 % 100))"
printf -v ram_free_kib "%d.%02d" "$((ram_free_kib100 / 100))" "$((ram_free_kib100 % 100))"
printf "%-10s | %10d | %10s | %10d | %10s | %7s\n" \
"${RESULT_PRESETS[$i]}" "$ram_used" "$ram_used_kib" \
"$ram_free" "$ram_free_kib" "$ram_pct"
done
echo ""
if (( ${#RESULT_PRESETS[@]} == 1 )); then
echo "🎉 Preset ${RESULT_PRESETS[0]} built successfully!"
else
echo "🎉 All presets built successfully!"
fi
}
# ---------------------------------------------
# Handle 'All' preset
# ---------------------------------------------
if [[ "$PRESET" == "All" ]]; then
for p in "${RELEASE_PRESETS[@]}"; do
build_preset "$p"
done
else
build_preset "$PRESET"
fi
print_summary
+7 -251
View File
@@ -1,252 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail # ---------------------------------------------------------------------------
# DEPRECATED: this script was renamed to compile-firmware.sh (paired with
# --------------------------------------------- # compile-app.sh). This thin wrapper forwards to it for backward compatibility.
# Usage: # Please update your scripts, docs and habits to call compile-firmware.sh.
# ./compile-with-docker.sh [Preset] [CMake options...] # ---------------------------------------------------------------------------
# Examples: echo "note: 'compile-with-docker.sh' was renamed to 'compile-firmware.sh' - forwarding." >&2
# ./compile-with-docker.sh Fusion exec "$(dirname "$0")/compile-firmware.sh" "$@"
# ./compile-with-docker.sh Transfer
# ./compile-with-docker.sh FieldOps
# ./compile-with-docker.sh Max
# ./compile-with-docker.sh Fusion -DDEV=ON
# ./compile-with-docker.sh FieldOps -DENABLE_VOX=OFF
# ./compile-with-docker.sh All
# Default preset: "Fusion"
# ---------------------------------------------
IMAGE=uvk1-uvk5v3
RELEASE_PRESETS=(Fusion Transfer FieldOps Max)
FLASH_LIMIT=$((118 * 1024))
RAM_LIMIT=$((16 * 1024))
PRESET=${1:-Fusion}
shift || true # remove preset from arguments if present
# Any remaining args will be treated as CMake cache variables
EXTRA_ARGS=("$@")
# ---------------------------------------------
# Validate preset name
# ---------------------------------------------
if [[ ! "$PRESET" =~ ^(Custom|Fusion|Transfer|FieldOps|Max|All)$ ]]; then
echo "❌ Unknown preset: '$PRESET'"
echo "Valid presets are: Custom, Fusion, Transfer, FieldOps, Max, All"
exit 1
fi
QUIET=0
if [[ "$PRESET" == "All" ]]; then
QUIET=1
fi
# Remember whether the script itself was started from an interactive terminal.
# The build output is piped through tee below, so checking stdout later from
# run_preset_build() would always report a non-terminal.
INTERACTIVE=0
if [[ -t 1 ]]; then
INTERACTIVE=1
fi
# ---------------------------------------------
# Build the Docker image (only needed once)
# ---------------------------------------------
if [[ "$(docker images -q "$IMAGE")" == "" ]]; then
echo "Building Docker image..."
docker build -q -t "$IMAGE" . >/dev/null
fi
export MSYS_NO_PATHCONV=1
RESULT_PRESETS=()
RESULT_FLASH_SIZES=()
RESULT_RAM_SIZES=()
run_preset_build() {
local preset="$1"
local -a docker_tty_args=()
# Give Ninja a pseudo-terminal for an interactive single-preset build. This
# lets it refresh its [current/total] progress on one line. Batch/redirected
# builds keep plain line-oriented output suitable for logs and CI.
if (( INTERACTIVE && ! QUIET )); then
docker_tty_args=(-t)
fi
docker run --rm "${docker_tty_args[@]}" \
-u "$(id -u):$(id -g)" \
-v "$PWD":/src -w /src "$IMAGE" \
bash -c 'which arm-none-eabi-gcc && arm-none-eabi-gcc --version &&
cmake --fresh --preset "$1" "${@:2}" &&
cmake --build --preset "$1" -j' \
bash "$preset" "${EXTRA_ARGS[@]}"
}
build_preset() {
local preset="$1"
local preset_slug log_file bin_file flash_size ram_size status
preset_slug="${preset,,}"
log_file="$(mktemp)"
bin_file=""
find "build/${preset}" -maxdepth 1 -type f -name 'f4hwn.*' -delete 2>/dev/null || true
if (( QUIET )); then
printf "Building %-10s ... " "$preset"
if run_preset_build "$preset" >"$log_file" 2>&1; then
status=0
else
status=$?
fi
else
echo ""
echo "=== 🚀 Building preset: ${preset} ==="
echo "---------------------------------------------"
if (( INTERACTIVE )); then
# Do not put a line-oriented filter after tee here: it would buffer
# Ninja's carriage-return progress updates and defeat the TTY display.
if run_preset_build "$preset" 2>&1 | tee "$log_file"; then
status=0
else
status=$?
fi
else
if run_preset_build "$preset" 2>&1 | tee "$log_file" | awk '
/^Memory region[[:space:]]+Used Size[[:space:]]+Region Size/ { next }
/^[[:space:]]+RAM:[[:space:]]/ { next }
/^[[:space:]]+FLASH:[[:space:]]/ { next }
{ print; fflush() }
'; then
status=0
else
status=$?
fi
fi
fi
if (( status != 0 )); then
if (( QUIET )); then
printf "FAILED\n\n"
cat "$log_file"
else
echo "Failed: ${preset}"
fi
rm -f -- "$log_file"
return "$status"
fi
bin_file="build/${preset}/f4hwn.${preset_slug}.bin"
if [[ -z "$bin_file" || ! -f "$bin_file" ]]; then
if (( QUIET )); then
printf "FAILED\n"
fi
echo "Expected binary not found: $bin_file"
if (( QUIET )); then
cat "$log_file"
fi
rm -f -- "$log_file"
return 1
fi
flash_size="$(wc -c < "$bin_file")"
ram_size="$(awk '$1 == "RAM:" {
value = $2
if ($3 == "KB") value *= 1024
else if ($3 == "MB") value *= 1024 * 1024
printf "%.0f\n", value
}' "$log_file" | tail -n 1)"
if [[ ! "$ram_size" =~ ^[0-9]+$ ]]; then
if (( QUIET )); then
printf "FAILED\n"
fi
echo "Could not read RAM usage from linker output"
if (( QUIET )); then
cat "$log_file"
fi
rm -f -- "$log_file"
return 1
fi
RESULT_PRESETS+=("$preset")
RESULT_FLASH_SIZES+=("$flash_size")
RESULT_RAM_SIZES+=("$ram_size")
if (( QUIET )); then
printf "OK\n"
else
echo "✅ Done: ${preset}"
fi
rm -f -- "$log_file"
}
print_summary() {
local i
local flash_used flash_free flash_bp flash_pct
local flash_used_kib100 flash_free_kib100 flash_used_kib flash_free_kib
local ram_used ram_free ram_bp ram_pct
local ram_used_kib100 ram_free_kib100 ram_used_kib ram_free_kib
echo ""
printf "Flash (limit: %d B / %d.00 KiB)\n" "$FLASH_LIMIT" "$((FLASH_LIMIT / 1024))"
printf "%-10s | %10s | %10s | %10s | %10s | %7s\n" \
"Preset" "Used (B)" "Used (KiB)" "Free (B)" "Free (KiB)" "Usage"
printf "%-10s-+-%10s-+-%10s-+-%10s-+-%10s-+-%7s\n" \
"----------" "----------" "----------" "----------" "----------" "-------"
for i in "${!RESULT_PRESETS[@]}"; do
flash_used="${RESULT_FLASH_SIZES[$i]}"
flash_free=$((FLASH_LIMIT - flash_used))
flash_bp=$(((flash_used * 10000 + FLASH_LIMIT / 2) / FLASH_LIMIT))
flash_used_kib100=$(((flash_used * 100 + 512) / 1024))
flash_free_kib100=$(((flash_free * 100 + 512) / 1024))
printf -v flash_pct "%d.%02d%%" "$((flash_bp / 100))" "$((flash_bp % 100))"
printf -v flash_used_kib "%d.%02d" "$((flash_used_kib100 / 100))" "$((flash_used_kib100 % 100))"
printf -v flash_free_kib "%d.%02d" "$((flash_free_kib100 / 100))" "$((flash_free_kib100 % 100))"
printf "%-10s | %10d | %10s | %10d | %10s | %7s\n" \
"${RESULT_PRESETS[$i]}" "$flash_used" "$flash_used_kib" \
"$flash_free" "$flash_free_kib" "$flash_pct"
done
echo ""
printf "RAM (limit: %d B / %d.00 KiB)\n" "$RAM_LIMIT" "$((RAM_LIMIT / 1024))"
printf "%-10s | %10s | %10s | %10s | %10s | %7s\n" \
"Preset" "Used (B)" "Used (KiB)" "Free (B)" "Free (KiB)" "Usage"
printf "%-10s-+-%10s-+-%10s-+-%10s-+-%10s-+-%7s\n" \
"----------" "----------" "----------" "----------" "----------" "-------"
for i in "${!RESULT_PRESETS[@]}"; do
ram_used="${RESULT_RAM_SIZES[$i]}"
ram_free=$((RAM_LIMIT - ram_used))
ram_bp=$(((ram_used * 10000 + RAM_LIMIT / 2) / RAM_LIMIT))
ram_used_kib100=$(((ram_used * 100 + 512) / 1024))
ram_free_kib100=$(((ram_free * 100 + 512) / 1024))
printf -v ram_pct "%d.%02d%%" "$((ram_bp / 100))" "$((ram_bp % 100))"
printf -v ram_used_kib "%d.%02d" "$((ram_used_kib100 / 100))" "$((ram_used_kib100 % 100))"
printf -v ram_free_kib "%d.%02d" "$((ram_free_kib100 / 100))" "$((ram_free_kib100 % 100))"
printf "%-10s | %10d | %10s | %10d | %10s | %7s\n" \
"${RESULT_PRESETS[$i]}" "$ram_used" "$ram_used_kib" \
"$ram_free" "$ram_free_kib" "$ram_pct"
done
echo ""
if (( ${#RESULT_PRESETS[@]} == 1 )); then
echo "🎉 Preset ${RESULT_PRESETS[0]} built successfully!"
else
echo "🎉 All presets built successfully!"
fi
}
# ---------------------------------------------
# Handle 'All' preset
# ---------------------------------------------
if [[ "$PRESET" == "All" ]]; then
for p in "${RELEASE_PRESETS[@]}"; do
build_preset "$p"
done
else
build_preset "$PRESET"
fi
print_summary