diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 924a63af..026be6e9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -14,11 +14,14 @@ jobs: - name: Compile firmware run: | - chmod +x compile-with-docker.sh - ./compile-with-docker.sh + chmod +x compile-firmware.sh compile-app.sh + ./compile-firmware.sh All + ./compile-app.sh All - - name: Upload firmware artifact + - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: firmware-artifact - path: compiled-firmware/f4hwn.packed.bin + path: | + build/*/*.bin + build/Apps/*.app diff --git a/.gitignore b/.gitignore index d6d64b8f..865848f0 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,11 @@ __pycache__/ .DS_Store 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/ diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index c72e7ff9..cc4e029e 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -117,11 +117,18 @@ endif() # ---- 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 - driver/bk1080.c - app/fm.c - ui/fmradio.c + driver/bk1080.c + app/fm.c ) +if(ENABLE_FMRADIO AND NOT ENABLE_FEAT_F4HWN_OVERLAY_APPS) + target_sources(App INTERFACE ui/fmradio.c) +endif() enable_feature(ENABLE_AIRCOPY app/aircopy.c ui/aircopy.c @@ -182,9 +189,21 @@ if(NOT ENABLE_UART AND NOT ENABLE_USB) endif() enable_feature(ENABLE_FEAT_F4HWN) -enable_feature(ENABLE_FEAT_F4HWN_GAME - app/breakout.c +# GAME keeps the F+7 hook; the resident breakout.c is compiled only when the +# overlay does NOT provide Breakout as an app (that is the flash we recover). +if(ENABLE_FEAT_F4HWN_GAME) + target_compile_definitions(App INTERFACE ENABLE_FEAT_F4HWN_GAME) + if(NOT ENABLE_FEAT_F4HWN_OVERLAY_APPS) + target_sources(App INTERFACE app/breakout.c) + endif() +endif() +enable_feature(ENABLE_FEAT_F4HWN_OVERLAY_APPS + apps/app_overlay.c + apps/app_menu.c ) +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 k5viewer.c ) diff --git a/App/app/action.c b/App/app/action.c index 012bcdb5..7fa17f02 100644 --- a/App/app/action.c +++ b/App/app/action.c @@ -467,6 +467,16 @@ void ACTION_Handle(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) #ifdef ENABLE_FMRADIO 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) { gInputBoxIndex = 0; @@ -505,6 +515,7 @@ void ACTION_FM(void) gRequestDisplayScreen = DISPLAY_FM; } +#endif /* !ENABLE_FEAT_F4HWN_OVERLAY_APPS */ } static void ACTION_Scan_FM(bool bRestart) diff --git a/App/app/main.c b/App/app/main.c index b44bd4d7..6b04aba6 100644 --- a/App/app/main.c +++ b/App/app/main.c @@ -31,8 +31,11 @@ #include "app/spectrum.h" #endif -#ifdef ENABLE_FEAT_F4HWN_GAME -#include "app/breakout.h" +#if defined(ENABLE_FEAT_F4HWN_GAME) && !defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) +#include "app/breakout.h" // resident game only; the overlay path uses app_menu.h +#endif +#ifdef ENABLE_FEAT_F4HWN_OVERLAY_APPS +#include "apps/app_menu.h" #endif #include "audio.h" @@ -269,9 +272,15 @@ static void processFKeyFunction(const KEY_Code_t Key, const bool beep) break; case KEY_7: -#ifdef ENABLE_FEAT_F4HWN_GAME + // F + 7 opens the overlay-apps menu when that support is built; + // otherwise it launches the resident game (GAME); otherwise VOX. +#if defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) || defined(ENABLE_FEAT_F4HWN_GAME) if (!beep) { - APP_RunBreakout(); +#if defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) + APP_MenuOpen(); // overlay-apps selector +#else + APP_RunBreakout(); // resident game (no overlay support) +#endif } else { #endif #ifdef ENABLE_VOX @@ -279,7 +288,7 @@ static void processFKeyFunction(const KEY_Code_t Key, const bool beep) //#else // toggle_chan_scanlist(); #endif -#ifdef ENABLE_FEAT_F4HWN_GAME +#if defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) || defined(ENABLE_FEAT_F4HWN_GAME) } #endif diff --git a/App/app/uart.c b/App/app/uart.c index 87be1f5b..779feafd 100644 --- a/App/app/uart.c +++ b/App/app/uart.c @@ -49,6 +49,10 @@ #include "driver/mb_flash.h" #endif +#ifdef ENABLE_FEAT_F4HWN_OVERLAY_APPS + #include "apps/app_overlay.h" +#endif + #if defined(ENABLE_OVERLAY) #include "sram-overlay.h" #endif @@ -898,6 +902,7 @@ void UART_HandleCommand(uint32_t Port) // ---- M4 slot management ("Firmware Slots") ------------------------ case 0x0720: // slot info: read the 64-byte header only (fast, no CRC) { + if (pUART_Command->Header.Size < 1u) break; // needs Data[0] (slot) gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s) uint8_t slot = pUART_Command->Data[0]; 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 { + 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] @@ -979,6 +985,7 @@ void UART_HandleCommand(uint32_t Port) case 0x0726: // slot validate: full image CRC-32, no reflash { + if (pUART_Command->Header.Size < 1u) break; // needs Data[0] (slot) gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s) uint8_t slot = pUART_Command->Data[0]; uint32_t crc = 0; @@ -1000,6 +1007,7 @@ void UART_HandleCommand(uint32_t Port) case 0x0728: // config reset: wipe the 64 KiB of a config bank (1..4) { + if (pUART_Command->Header.Size < 6u) break; // needs Data[0] bank + Data[2..5] timestamp gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s) uint8_t bank = pUART_Command->Data[0]; uint32_t ts = (uint32_t)pUART_Command->Data[2] @@ -1022,6 +1030,113 @@ void UART_HandleCommand(uint32_t Port) } #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 case 0x0601: CMD_0601_ReadBK4819Reg(Port, pUART_Command->Buffer); diff --git a/App/apps/app_api.h b/App/apps/app_api.h new file mode 100644 index 00000000..38420e3d --- /dev/null +++ b/App/apps/app_api.h @@ -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 +#include + +/* 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 */ diff --git a/App/apps/app_menu.c b/App/apps/app_menu.c new file mode 100644 index 00000000..18e85e19 --- /dev/null +++ b/App/apps/app_menu.c @@ -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 +#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 */ diff --git a/App/apps/app_menu.h b/App/apps/app_menu.h new file mode 100644 index 00000000..da49c21c --- /dev/null +++ b/App/apps/app_menu.h @@ -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 */ diff --git a/App/apps/app_overlay.c b/App/apps/app_overlay.c new file mode 100644 index 00000000..a996d6c9 --- /dev/null +++ b/App/apps/app_overlay.c @@ -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 +#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 */ diff --git a/App/apps/app_overlay.h b/App/apps/app_overlay.h new file mode 100644 index 00000000..676b1b9d --- /dev/null +++ b/App/apps/app_overlay.h @@ -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 +#include +#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 */ diff --git a/App/apps/beacon/app.ld b/App/apps/beacon/app.ld new file mode 100644 index 00000000..bd7a08d2 --- /dev/null +++ b/App/apps/beacon/app.ld @@ -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.*) } +} diff --git a/App/apps/beacon/beacon_app.c b/App/apps/beacon/beacon_app.c new file mode 100644 index 00000000..c9b59cea --- /dev/null +++ b/App/apps/beacon/beacon_app.c @@ -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 / " 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 +#include +#include +#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 a0) 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;kprint_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;xfb[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;xfb[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;idelay_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]=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); +} diff --git a/App/apps/beacon/build.sh b/App/apps/beacon/build.sh new file mode 100755 index 00000000..1168ee7a --- /dev/null +++ b/App/apps/beacon/build.sh @@ -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" diff --git a/App/apps/breakout/app.ld b/App/apps/breakout/app.ld new file mode 100644 index 00000000..b7439278 --- /dev/null +++ b/App/apps/breakout/app.ld @@ -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.*) } +} diff --git a/App/apps/breakout/breakout_app.c b/App/apps/breakout/breakout_app.c new file mode 100644 index 00000000..493cca86 --- /dev/null +++ b/App/apps/breakout/breakout_app.c @@ -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 +#include +#include +#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(); + } +} diff --git a/App/apps/breakout/build.sh b/App/apps/breakout/build.sh new file mode 100755 index 00000000..34efcbd4 --- /dev/null +++ b/App/apps/breakout/build.sh @@ -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" diff --git a/App/apps/fm/app.ld b/App/apps/fm/app.ld new file mode 100644 index 00000000..11762b66 --- /dev/null +++ b/App/apps/fm/app.ld @@ -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.*) } +} diff --git a/App/apps/fm/build.sh b/App/apps/fm/build.sh new file mode 100755 index 00000000..6fde97c0 --- /dev/null +++ b/App/apps/fm/build.sh @@ -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" diff --git a/App/apps/fm/fm_app.c b/App/apps/fm/fm_app.c new file mode 100644 index 00000000..07fa4927 --- /dev/null +++ b/App/apps/fm/fm_app.c @@ -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 +#include +#include +#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(fhi)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=lo && ch[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;iprint_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){ 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;ifm_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(c1 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_playinghi){ 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_playinghi) st.freq_playing=lo; + if(st.sel_freqhi) 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(); +} diff --git a/App/apps/foxhunt/app.ld b/App/apps/foxhunt/app.ld new file mode 100644 index 00000000..0b5d0a1e --- /dev/null +++ b/App/apps/foxhunt/app.ld @@ -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.*) } +} diff --git a/App/apps/foxhunt/build.sh b/App/apps/foxhunt/build.sh new file mode 100755 index 00000000..24a90d49 --- /dev/null +++ b/App/apps/foxhunt/build.sh @@ -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" diff --git a/App/apps/foxhunt/foxhunt_app.c b/App/apps/foxhunt/foxhunt_app.c new file mode 100644 index 00000000..468f857f --- /dev/null +++ b/App/apps/foxhunt/foxhunt_app.c @@ -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 +#include +#include +#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(++histTickrssi_dbm(); + peakDbm=minDbm=trendRef=curDbm; + trendDelta=0; trendTick=0; + uint8_t lvl=fillCount(curDbm); + for(uint8_t i=0;i0?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;idraw_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)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=(ydraw_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(attSteprx_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;kprint_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]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;idelay_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=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); +} diff --git a/App/apps/pack_app.py b/App/apps/pack_app.py new file mode 100644 index 00000000..cd3b0781 --- /dev/null +++ b/App/apps/pack_app.py @@ -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 ` 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() diff --git a/App/driver/py25q16.c b/App/driver/py25q16.c index 99b71f14..618d08bc 100644 --- a/App/driver/py25q16.c +++ b/App/driver/py25q16.c @@ -416,6 +416,16 @@ void PY25Q16_InvalidateCache(void) 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) { SPI_WriteByte(0xff & (Addr >> 16)); diff --git a/App/driver/py25q16.h b/App/driver/py25q16.h index 2b013393..73631588 100644 --- a/App/driver/py25q16.h +++ b/App/driver/py25q16.h @@ -32,6 +32,11 @@ void PY25Q16_SectorErase(uint32_t Address); * is also called before multiboot reuses the cache storage as a RAM overlay. */ 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 /* * Multiboot per-bank config banking. diff --git a/App/ui/status.c b/App/ui/status.c index e55ad7fe..62e6e71b 100644 --- a/App/ui/status.c +++ b/App/ui/status.c @@ -54,7 +54,7 @@ static void convertTime(uint8_t *line, uint8_t type) #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) #else static __attribute__((always_inline)) inline void UI_DrawStatusBattery(uint8_t *line, char *str) diff --git a/App/ui/status.h b/App/ui/status.h index 10dd9fe7..f2e0d875 100644 --- a/App/ui/status.h +++ b/App/ui/status.h @@ -19,7 +19,7 @@ #include -#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); #endif void UI_DisplayStatus(); diff --git a/App/ui/ui.c b/App/ui/ui.c index 489c7613..8a5aca29 100644 --- a/App/ui/ui.c +++ b/App/ui/ui.c @@ -53,8 +53,14 @@ void (*const UI_DisplayFunctions[])(void) = { [DISPLAY_MENU] = &UI_DisplayMenu, [DISPLAY_SCANNER] = &UI_DisplayScanner, -#ifdef ENABLE_FMRADIO +#if defined(ENABLE_FMRADIO) && !defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) [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 #ifdef ENABLE_AIRCOPY diff --git a/CMakePresets.json b/CMakePresets.json index 1b68bb4b..ebe73235 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -91,6 +91,7 @@ "ENABLE_FEAT_F4HWN_MENU_CAT": false, "ENABLE_FEAT_F4HWN_MULTIBOOT": false, "ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY": false, + "ENABLE_FEAT_F4HWN_OVERLAY_APPS": false, "ENABLE_AGC_SHOW_DATA": false, "ENABLE_UART_RW_BK_REGS": false, "ENABLE_SWD": false, @@ -142,6 +143,7 @@ "ENABLE_FEAT_F4HWN_ACTION_PICKER": true, "ENABLE_FEAT_F4HWN_MULTIBOOT": true, "ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY": true, + "ENABLE_FEAT_F4HWN_OVERLAY_APPS": false, "ENABLE_SWD": true, "EDITION_STRING": "Fusion", "TARGET": "f4hwn.fusion" @@ -168,16 +170,17 @@ } }, { - "name": "Max", + "name": "Labs", "inherits": "Fusion", "cacheVariables": { "ENABLE_FEAT_F4HWN_RESCUE_OPS": true, - "ENABLE_FEAT_F4HWN_FOXHUNT": true, + "ENABLE_FEAT_F4HWN_FOXHUNT": false, "ENABLE_AIRCOPY": true, "ENABLE_FEAT_F4HWN_BEAM": true, - "ENABLE_FEAT_F4HWN_GAME": true, - "EDITION_STRING": "Max", - "TARGET": "f4hwn.max" + "ENABLE_FEAT_F4HWN_GAME": false, + "ENABLE_FEAT_F4HWN_OVERLAY_APPS": true, + "EDITION_STRING": "Labs", + "TARGET": "f4hwn.labs" } } ], @@ -199,8 +202,8 @@ "configurePreset": "FieldOps" }, { - "name": "Max", - "configurePreset": "Max" + "name": "Labs", + "configurePreset": "Labs" } ] } diff --git a/Core/py32f071xb.ld b/Core/py32f071xb.ld index 905d4e9b..8156a024 100644 --- a/Core/py32f071xb.ld +++ b/Core/py32f071xb.ld @@ -162,8 +162,17 @@ SECTIONS /* 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 - FLASH and is copied into the cache only immediately before reflashing. */ - OVERLAY : NOCROSSREFS AT (LOADADDR(.noncacheable) + SIZEOF(.noncacheable)) + FLASH and is copied into the cache only immediately before reflashing. + + 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 { diff --git a/README.md b/README.md index b0b9bdf1..b399c058 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,11 @@ Specialized presets extend Fusion for specific uses: - **Transfer** adds AirCopy and BEAM wireless channel transfer. - **FieldOps** adds first-responder controls, Fox Hunt and Morse Beacon support. -- **Max** packs the broadest practical selection of features that fits safely within - the radio's Flash and RAM limits. It is not a strict superset of every other edition: - features may be exchanged between releases to preserve stability and memory headroom. +- **Labs** is the experimental edition. It carries the broad feature selection of the + other releases and adds the overlay-apps platform (apps loaded from external Flash and + 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. ### 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 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 `build/` directories. @@ -341,7 +343,7 @@ the default generic preset, while specialized builds are generated in their own ### 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. 2. Configures the selected preset with `cmake --fresh`. @@ -351,7 +353,7 @@ The script `compile-with-docker.sh`: ### Usage ```bash -./compile-with-docker.sh [Preset] [extra CMake options] +./compile-firmware.sh [Preset] [extra CMake options] ``` The default preset is **Fusion**. Available presets are: @@ -360,18 +362,18 @@ The default preset is **Fusion**. Available presets are: - **Fusion** - **Transfer** - **FieldOps** -- **Max** -- **All** (Fusion, Transfer, FieldOps and Max) +- **Labs** +- **All** (Fusion, Transfer, FieldOps and Labs) Examples: ```bash -./compile-with-docker.sh -./compile-with-docker.sh Fusion -./compile-with-docker.sh Transfer -./compile-with-docker.sh FieldOps -./compile-with-docker.sh Max -./compile-with-docker.sh All +./compile-firmware.sh +./compile-firmware.sh Fusion +./compile-firmware.sh Transfer +./compile-firmware.sh FieldOps +./compile-firmware.sh Labs +./compile-firmware.sh All ``` ### Passing Additional CMake Options @@ -382,15 +384,15 @@ These are forwarded directly to `cmake --preset` inside the container. Examples: ```bash -./compile-with-docker.sh FieldOps -DENABLE_VOX=OFF -./compile-with-docker.sh Fusion -DENABLE_FEAT_F4HWN_GAME=ON -./compile-with-docker.sh Fusion -DSQL_TONE=600 +./compile-firmware.sh FieldOps -DENABLE_VOX=OFF +./compile-firmware.sh Fusion -DENABLE_FEAT_F4HWN_GAME=ON +./compile-firmware.sh Fusion -DSQL_TONE=600 ``` To prepare the rolling development firmware: ```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 diff --git a/compile-app.sh b/compile-app.sh new file mode 100755 index 00000000..ae5a7319 --- /dev/null +++ b/compile-app.sh @@ -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/.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//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//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 diff --git a/compile-firmware.sh b/compile-firmware.sh new file mode 100755 index 00000000..61c45e09 --- /dev/null +++ b/compile-firmware.sh @@ -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 diff --git a/compile-with-docker.sh b/compile-with-docker.sh index 6ae1831d..4bae1bf5 100755 --- a/compile-with-docker.sh +++ b/compile-with-docker.sh @@ -1,252 +1,8 @@ #!/usr/bin/env bash -set -euo pipefail - -# --------------------------------------------- -# Usage: -# ./compile-with-docker.sh [Preset] [CMake options...] -# Examples: -# ./compile-with-docker.sh Fusion -# ./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 +# --------------------------------------------------------------------------- +# DEPRECATED: this script was renamed to compile-firmware.sh (paired with +# compile-app.sh). This thin wrapper forwards to it for backward compatibility. +# Please update your scripts, docs and habits to call compile-firmware.sh. +# --------------------------------------------------------------------------- +echo "note: 'compile-with-docker.sh' was renamed to 'compile-firmware.sh' - forwarding." >&2 +exec "$(dirname "$0")/compile-firmware.sh" "$@"