From a25d2fc75ec3c01ffdb2d0348e220ad04b56afa8 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Tue, 11 Aug 2026 03:14:31 +0200 Subject: [PATCH 01/73] Add mb firmware slots --- App/CMakeLists.txt | 4 + App/app/uart.c | 263 +++++++++++++- App/driver/mb_flash.c | 798 ++++++++++++++++++++++++++++++++++++++++++ App/driver/mb_flash.h | 168 +++++++++ App/driver/py25q16.c | 24 ++ App/driver/py25q16.h | 1 + App/helper/boot.c | 29 +- App/helper/boot.h | 5 +- App/main.c | 10 + App/ui/multiboot.c | 266 ++++++++++++++ App/ui/multiboot.h | 12 + CMakePresets.json | 5 +- Core/py32f071xb.ld | 6 +- tools/mb_test.html | 333 ++++++++++++++++++ 14 files changed, 1907 insertions(+), 17 deletions(-) create mode 100644 App/driver/mb_flash.c create mode 100644 App/driver/mb_flash.h create mode 100644 App/ui/multiboot.c create mode 100644 App/ui/multiboot.h create mode 100644 tools/mb_test.html diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index 3cddbf7f..a3016818 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -230,6 +230,10 @@ endif() if(ENABLE_FEAT_F4HWN_BEAM AND NOT ENABLE_AIRCOPY) message(FATAL_ERROR "ENABLE_FEAT_F4HWN_BEAM requires ENABLE_AIRCOPY (it reuses g_FSK_Buffer, AIRCOPY_Obfuscate and the FSK packet plumbing).") endif() +enable_feature(ENABLE_FEAT_F4HWN_MULTIBOOT + driver/mb_flash.c + ui/multiboot.c +) enable_feature(ENABLE_FEAT_F4HWN_QRCODE) enable_feature(ENABLE_FEAT_F4HWN_LOGO) enable_feature(ENABLE_FEAT_F4HWN_LOGO_SAV) diff --git a/App/app/uart.c b/App/app/uart.c index 5c052430..832c7b51 100644 --- a/App/app/uart.c +++ b/App/app/uart.c @@ -45,6 +45,10 @@ #include "settings.h" #include "version.h" +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + #include "driver/mb_flash.h" +#endif + #if defined(ENABLE_OVERLAY) #include "sram-overlay.h" #endif @@ -201,11 +205,11 @@ static void SendReply_VCP(void *pReply, uint16_t Size) return; } - memcpy(VCP_ReplyBuf + sizeof(Header_t), pReply, Size); + uint8_t *pBody = VCP_ReplyBuf + sizeof(Header_t); + uint8_t *pFooter = pBody + Size; - Header_t *pHeader = (Header_t *)VCP_ReplyBuf; - Footer_t *pFooter = (Footer_t *)(VCP_ReplyBuf + sizeof(Header_t) + Size); - pReply = VCP_ReplyBuf + sizeof(Header_t); + memcpy(pBody, pReply, Size); + pReply = pBody; if (bIsEncrypted) { @@ -215,23 +219,29 @@ static void SendReply_VCP(void *pReply, uint16_t Size) pBytes[i] ^= Obfuscation[i % 16]; } - pHeader->ID = 0xCDAB; - pHeader->Size = Size; + /* Build the transport header/footer byte by byte. The reply body may have + * an odd size, so pFooter is not necessarily half-word aligned; casting it + * to Footer_t and storing ID as uint16_t can HardFault on Cortex-M0+. */ + VCP_ReplyBuf[0] = 0xAB; + VCP_ReplyBuf[1] = 0xCD; + VCP_ReplyBuf[2] = (uint8_t)(Size & 0xFFu); + VCP_ReplyBuf[3] = (uint8_t)(Size >> 8); // VCP_Send((uint8_t *)&Header, sizeof(Header)); // VCP_Send(pReply, Size); - + if (bIsEncrypted) { - pFooter->Padding[0] = Obfuscation[(Size + 0) % 16] ^ 0xFF; - pFooter->Padding[1] = Obfuscation[(Size + 1) % 16] ^ 0xFF; + pFooter[0] = Obfuscation[(Size + 0) % 16] ^ 0xFF; + pFooter[1] = Obfuscation[(Size + 1) % 16] ^ 0xFF; } else { - pFooter->Padding[0] = 0xFF; - pFooter->Padding[1] = 0xFF; + pFooter[0] = 0xFF; + pFooter[1] = 0xFF; } - pFooter->ID = 0xBADC; + pFooter[2] = 0xDC; + pFooter[3] = 0xBA; // VCP_Send((uint8_t *)&Footer, sizeof(Footer)); @@ -782,6 +792,24 @@ bool UART_IsCommandAvailable(uint32_t Port) return CRC_Calculate(pUART_Command->Buffer, Size) == Crc; } +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT +/* Timestamp latched by the device-info handshake (0x0514) for this port. Slot + * writes/erases require it to match, like the EEPROM write command (CMD_051D). */ +static uint32_t mb_port_timestamp(uint32_t Port) +{ +#if defined(ENABLE_UART) + if (Port == UART_PORT_UART) + return UART_Timestamp; +#endif +#if defined(ENABLE_USB) + if (Port == UART_PORT_VCP) + return VCP_Timestamp; +#endif + (void)Port; + return 0; +} +#endif + void UART_HandleCommand(uint32_t Port) { UART_Command_t *pUART_Command; @@ -852,6 +880,217 @@ void UART_HandleCommand(uint32_t Port) #endif break; +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + // Multiboot self-test (developer only, gated build). + case 0x0710: // backup: internal application -> external flash slot 0 + { + uint32_t size = 0, crc = 0; + MB_BackupToSlot0(&size, &crc); + struct __attribute__((packed)) { + Header_t Header; + uint32_t Size; + uint32_t Crc32; + } Reply; + Reply.Header.ID = 0x0711; + Reply.Header.Size = 8; + Reply.Size = size; + Reply.Crc32 = crc; + SendReply(Port, &Reply, sizeof(Reply)); // ack once the backup completed + break; + } + + case 0x0712: // restore: external flash slot 0 -> internal application + reset + { + // Returns only if validation failed (internal flash untouched); + // on success it reflashes and resets, so control never comes back. + uint8_t err = MB_RestoreSlot0(); + struct __attribute__((packed)) { + Header_t Header; + uint8_t Code; + } Reply; + Reply.Header.ID = 0x0713; + Reply.Header.Size = 1; + Reply.Code = err; + SendReply(Port, &Reply, sizeof(Reply)); // restore refused, report why + break; + } + + case 0x0714: // test: corrupt slot 0 image (exercise the CRC-refusal path) + { + uint16_t n = pUART_Command->Header.Size; + if (n > 128) + n = 128; + MB_CorruptSlot0(pUART_Command->Data, n); + struct __attribute__((packed)) { + Header_t Header; + uint16_t Count; + } Reply; + Reply.Header.ID = 0x0715; + Reply.Header.Size = 2; + Reply.Count = n; + SendReply(Port, &Reply, sizeof(Reply)); // ack: bytes corrupted + break; + } + + case 0x0716: // validate slot 0 (no reflash): report result code + computed CRC + { + uint32_t crc = 0; + uint8_t code = MB_ValidateSlot0(&crc); + struct __attribute__((packed)) { + Header_t Header; + uint32_t Crc32; // 4-byte aligned (offset 4): no unaligned write + uint8_t Code; + } Reply; + Reply.Header.ID = 0x0717; + Reply.Header.Size = 5; + Reply.Crc32 = crc; + Reply.Code = code; + SendReply(Port, &Reply, sizeof(Reply)); + break; + } + + case 0x0718: // ground-truth dump of external flash via the DMA driver + { + uint32_t addr = (uint32_t)pUART_Command->Data[0] + | ((uint32_t)pUART_Command->Data[1] << 8) + | ((uint32_t)pUART_Command->Data[2] << 16) + | ((uint32_t)pUART_Command->Data[3] << 24); + uint8_t len = pUART_Command->Data[4]; + if (len > 64) + len = 64; + struct __attribute__((packed)) { + Header_t Header; + uint32_t Addr; + uint8_t Len; + uint8_t Data[64]; + } Reply; + MB_DumpExt(addr, Reply.Data, len); + Reply.Header.ID = 0x0719; + Reply.Header.Size = 5 + len; // Addr(4) + Len(1) + data(len) + Reply.Addr = addr; + Reply.Len = len; + SendReply(Port, &Reply, sizeof(Header_t) + 5 + len); + break; + } + + case 0x071C: // diagnostic: snapshot SPI2 / DMA state (no flash access) + { + uint32_t st[4]; + MB_SpiState(st); + struct __attribute__((packed)) { + Header_t Header; + uint32_t V[4]; + } Reply; + Reply.Header.ID = 0x071D; + Reply.Header.Size = 16; + Reply.V[0] = st[0]; Reply.V[1] = st[1]; + Reply.V[2] = st[2]; Reply.V[3] = st[3]; + SendReply(Port, &Reply, sizeof(Reply)); + break; + } + + // ---- M4 slot management ("Firmware Slots") ------------------------ + case 0x0720: // slot info: read the 64-byte header only (fast, no CRC) + { + gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s) + uint8_t slot = pUART_Command->Data[0]; + mb_slot_header_t hdr; + memset(&hdr, 0, sizeof(hdr)); + uint8_t status = MB_SlotInfo(slot, &hdr); + struct __attribute__((packed)) { + Header_t Header; + uint8_t Slot; + uint8_t Status; + uint8_t Hdr[sizeof(mb_slot_header_t)]; + } Reply; + Reply.Header.ID = 0x0721; + Reply.Header.Size = 2 + sizeof(mb_slot_header_t); + Reply.Slot = slot; + Reply.Status = status; + memcpy(Reply.Hdr, &hdr, sizeof(hdr)); + SendReply(Port, &Reply, sizeof(Reply)); + break; + } + + case 0x0722: // slot erase: wipe the whole 128 KiB slot region + { + gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s) + uint8_t slot = pUART_Command->Data[0]; + uint32_t ts = (uint32_t)pUART_Command->Data[2] + | ((uint32_t)pUART_Command->Data[3] << 8) + | ((uint32_t)pUART_Command->Data[4] << 16) + | ((uint32_t)pUART_Command->Data[5] << 24); + uint8_t status = (ts != mb_port_timestamp(Port)) + ? MB_ERR_AUTH : MB_SlotErase(slot); + struct __attribute__((packed)) { + Header_t Header; + uint8_t Slot; + uint8_t Status; + } Reply; + Reply.Header.ID = 0x0723; + Reply.Header.Size = 2; + Reply.Slot = slot; + Reply.Status = status; + SendReply(Port, &Reply, sizeof(Reply)); + break; + } + + case 0x0724: // slot write: program bytes at slot+offset (slot pre-erased) + { + gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s) + uint8_t slot = pUART_Command->Data[0]; + uint32_t offset = (uint32_t)pUART_Command->Data[2] + | ((uint32_t)pUART_Command->Data[3] << 8) + | ((uint32_t)pUART_Command->Data[4] << 16) + | ((uint32_t)pUART_Command->Data[5] << 24); + uint16_t len = (uint16_t)(pUART_Command->Data[6] + | ((uint16_t)pUART_Command->Data[7] << 8)); + uint32_t ts = (uint32_t)pUART_Command->Data[8] + | ((uint32_t)pUART_Command->Data[9] << 8) + | ((uint32_t)pUART_Command->Data[10] << 16) + | ((uint32_t)pUART_Command->Data[11] << 24); + uint8_t status; + if (ts != mb_port_timestamp(Port)) + status = MB_ERR_AUTH; + else if (len > 240u) // 12-byte prefix + data must fit Data[252] + status = MB_ERR_SIZE; + else + status = MB_SlotWrite(slot, offset, &pUART_Command->Data[12], len); + struct __attribute__((packed)) { + Header_t Header; + uint8_t Slot; + uint8_t Status; + } Reply; + Reply.Header.ID = 0x0725; + Reply.Header.Size = 2; + Reply.Slot = slot; + Reply.Status = status; + SendReply(Port, &Reply, sizeof(Reply)); + break; + } + + case 0x0726: // slot validate: full image CRC-32, no reflash + { + gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s) + uint8_t slot = pUART_Command->Data[0]; + uint32_t crc = 0; + uint8_t status = MB_ValidateSlot(slot, NULL, &crc); + struct __attribute__((packed)) { + Header_t Header; + uint32_t Crc32; // offset 4: 4-byte aligned, no unaligned store + uint8_t Slot; + uint8_t Status; + } Reply; + Reply.Header.ID = 0x0727; + Reply.Header.Size = 6; + Reply.Crc32 = crc; + Reply.Slot = slot; + Reply.Status = status; + SendReply(Port, &Reply, sizeof(Reply)); + break; + } +#endif + #ifdef ENABLE_UART_RW_BK_REGS case 0x0601: CMD_0601_ReadBK4819Reg(Port, pUART_Command->Buffer); diff --git a/App/driver/mb_flash.c b/App/driver/mb_flash.c new file mode 100644 index 00000000..44453cd6 --- /dev/null +++ b/App/driver/mb_flash.c @@ -0,0 +1,798 @@ +/* Copyright 2026 F4HWN + * + * 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 + +#include "driver/mb_flash.h" + +#include "py32f0xx.h" +#include "driver/py25q16.h" +#include "driver/st7565.h" +#include "ui/helper.h" +#include "version.h" + +/* Internal-flash program/erase keys (FLASH_KEY1 / FLASH_KEY2). */ +#define MB_FLASH_KEY1 0x45670123u +#define MB_FLASH_KEY2 0xCDEF89ABu + +/* Internal flash granularity (PY32F071xB): program & page-erase = 256 bytes. */ +#define MB_FLASH_PAGE 256u + +/* External SPI flash chip-select is on PA3 (see driver/py25q16.c). */ +#define MB_CS_PIN (1u << 3) + +/* LCD control pins used only for RAM-resident progress updates. */ +#define MB_LCD_CS_PIN (1u << 2) /* PB2 */ +#define MB_LCD_A0_PIN (1u << 6) /* PA6 */ + +/* Rounded progress gauge geometry, matching ScanProgress_DrawGaugeLine(). */ +#define MB_PROGRESS_COLS 118u +#define MB_PROGRESS_FIRST_COL 5u +#define MB_PROGRESS_FILLED 0x2Du + +/* Number of erase/program retries per page before giving up (and resetting + * anyway - the region is already erased, so USB recovery is the only option). */ +#define MB_PAGE_RETRIES 3u + +/* Bounded waits used by the RAM-only copier. A timeout forces an immediate + * reset instead of hanging forever with IRQs disabled. */ +#define MB_RAM_SPI_TIMEOUT 100000u +#define MB_RAM_FLASH_TIMEOUT 10000000u + +/* + * Factory flash-timing parameter records, held in Puya system memory. + * Mirror of the HAL's _FlashTimmingParam[] table (the HAL module is not built + * in this project). Indexed by the HSI frequency setting (RCC->ICSCR HSI_FS). + * Each entry is the address of a 5-word record read at +0/+8/+16/+24/+32. + */ +static const uint32_t mb_flash_timing[8] = { + 0x1FFF3238, 0x1FFF3260, 0x1FFF3288, 0x1FFF32B0, + 0x1FFF32D8, 0x1FFF3238, 0x1FFF3238, 0x1FFF3238 +}; + +/* -------------------------------------------------------------------------- */ +/* Helpers (flash-resident). */ +/* -------------------------------------------------------------------------- */ + +/* zlib/PNG CRC-32 (poly 0xEDB88320), streaming. Seed 'crc' with 0xFFFFFFFF and + * XOR the final result with 0xFFFFFFFF. No lookup table (saves flash). */ +static uint32_t mb_crc32_update(uint32_t crc, const uint8_t *data, uint32_t len) +{ + while (len--) + { + crc ^= *data++; + for (int k = 0; k < 8; k++) + crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u))); + } + return crc; +} + +static void mb_copy_str(char *dst, uint32_t cap, const char *src) +{ + uint32_t i = 0; + if (src) + for (; i + 1 < cap && src[i]; i++) + dst[i] = src[i]; + for (; i < cap; i++) + dst[i] = 0; +} + +/* -------------------------------------------------------------------------- */ +/* Flash-resident preparation (runs while the flash is still readable). */ +/* -------------------------------------------------------------------------- */ + +static void MB_PrepareInternalFlash(void) +{ + /* Unlock the internal flash control register. */ + if (FLASH->CR & FLASH_CR_LOCK) + { + FLASH->KEYR = MB_FLASH_KEY1; + FLASH->KEYR = MB_FLASH_KEY2; + } + + /* Program/erase timing sequence (factory calibrated), replicating + * __HAL_FLASH_TIMMING_SEQUENCE_CONFIG(). These registers persist, so it is + * enough to set them once here, before the RAM copier starts erasing. */ + uint32_t base = mb_flash_timing[(RCC->ICSCR & RCC_ICSCR_HSI_FS) >> RCC_ICSCR_HSI_FS_Pos]; + uint32_t p0 = *(volatile uint32_t *)(base + 0); + uint32_t p1 = *(volatile uint32_t *)(base + 8); + uint32_t p2 = *(volatile uint32_t *)(base + 16); + uint32_t p3 = *(volatile uint32_t *)(base + 24); + uint32_t p4 = *(volatile uint32_t *)(base + 32); + + FLASH->TS0 = p0 & 0xFFu; + FLASH->TS1 = (p0 >> 16) & 0x1FFu; + FLASH->TS3 = (p0 >> 8) & 0xFFu; + FLASH->TS2P = p1 & 0xFFu; + FLASH->TPS3 = (p1 >> 16) & 0x7FFu; + FLASH->PERTPE = p2 & 0x1FFFFu; + FLASH->SMERTPE = p3 & 0x1FFFFu; + FLASH->PRGTPE = p4 & 0xFFFFu; + FLASH->PRETPE = (p4 >> 16) & 0x3FFFu; +} + +/* -------------------------------------------------------------------------- */ +/* RAM-resident copier. */ +/* */ +/* This runs while the internal application flash is being erased/programmed, */ +/* during which the flash bus is unavailable. It must therefore NOT fetch any */ +/* code from flash nor read any flash data: it uses raw register access only */ +/* (no external calls), reads the source from the external SPI flash in */ +/* polled mode, and resets the MCU when done. It is placed in .RamFunc, which */ +/* the linker stores in flash and the startup copies to RAM alongside .data. */ +/* -------------------------------------------------------------------------- */ + +/* Polled single-byte SPI2 transfer. always_inline keeps all code in .RamFunc. + * The result is returned through out so timeout and received 0xFF remain + * distinguishable. */ +__attribute__((always_inline)) static inline bool mb_ram_spi(uint8_t v, uint8_t *out) +{ + uint32_t timeout = MB_RAM_SPI_TIMEOUT; + while (!(SPI2->SR & SPI_SR_TXE)) + if (!--timeout) + return false; + + *(volatile uint8_t *)&SPI2->DR = v; + + timeout = MB_RAM_SPI_TIMEOUT; + while (!(SPI2->SR & SPI_SR_RXNE)) + if (!--timeout) + return false; + + *out = *(volatile uint8_t *)&SPI2->DR; + return true; +} + +__attribute__((always_inline)) static inline bool mb_ram_flash_idle(void) +{ + uint32_t timeout = MB_RAM_FLASH_TIMEOUT; + while (FLASH->SR & FLASH_SR_BSY) + if (!--timeout) + return false; + return true; +} + +__attribute__((always_inline, noreturn)) static inline void mb_ram_reset(void) +{ + __DSB(); + + SCB->AIRCR = (0x5FAu << SCB_AIRCR_VECTKEY_Pos) | SCB_AIRCR_SYSRESETREQ_Msk; + __DSB(); + for (;;) { } +} + +/* Minimal SPI1 LCD writer. A display timeout merely disables progress updates: + * it must never abort or delay the safety-critical flash copy. */ +__attribute__((always_inline)) static inline bool mb_ram_lcd_spi(uint8_t v) +{ + uint32_t timeout = MB_RAM_SPI_TIMEOUT; + while (!(SPI1->SR & SPI_SR_TXE)) + if (!--timeout) + return false; + *(volatile uint8_t *)&SPI1->DR = v; + + timeout = MB_RAM_SPI_TIMEOUT; + while (!(SPI1->SR & SPI_SR_RXNE)) + if (!--timeout) + return false; + (void)*(volatile uint8_t *)&SPI1->DR; + return true; +} + +__attribute__((always_inline)) static inline bool mb_ram_progress_blit(const uint8_t *line) +{ + uint32_t ok = 1u; + GPIOB->BRR = MB_LCD_CS_PIN; + GPIOA->BRR = MB_LCD_A0_PIN; /* command */ + + if (!mb_ram_lcd_spi(0xB7u) || /* LCD page 7 */ + !mb_ram_lcd_spi(0x10u) || /* column high nibble */ + !mb_ram_lcd_spi(0x04u)) /* visible RAM starts at column 4 */ + ok = 0u; + + GPIOA->BSRR = MB_LCD_A0_PIN; /* data */ + if (ok) + for (uint32_t i = 0; i < 128u; i++) + if (!mb_ram_lcd_spi(line[i])) + { + ok = 0u; + break; + } + GPIOB->BSRR = MB_LCD_CS_PIN; + return ok != 0u; +} + +__attribute__((section(".RamFunc"), noinline, used)) +static void MB_RamReflash(uint32_t intAddr, uint32_t extAddr, uint32_t imageSize, + uint8_t *progressLine) +{ + /* 4-byte aligned so the 64-word page program can read it as uint32_t + * (Cortex-M0+ cannot do unaligned word accesses). */ + uint8_t buf[MB_FLASH_PAGE] __attribute__((aligned(4))); + uint32_t remaining = imageSize; + uint32_t regionRemaining = MB_INT_APP_SIZE; + uint32_t pagesDone = 0; + uint32_t progressAccumulator = 0; + uint32_t progressFilled = 0; + uint32_t lcdEnabled = progressLine != NULL; + + + __disable_irq(); + + if (FLASH->CR & FLASH_CR_LOCK) + { + FLASH->KEYR = MB_FLASH_KEY1; + FLASH->KEYR = MB_FLASH_KEY2; + } + + FLASH->SR = FLASH_SR_EOP | FLASH_SR_WRPERR | FLASH_SR_OPTVERR; + + /* Rebuild the complete application region. Bytes past imageSize are never + * read from the external slot: they are forced to erased 0xFF, preventing + * unvalidated padding or remnants of an older, longer firmware. */ + while (regionRemaining >= MB_FLASH_PAGE) + { + uint32_t readSize = remaining < MB_FLASH_PAGE ? remaining : MB_FLASH_PAGE; + uint32_t needProgram = 0; + uint32_t success = 0; + uint8_t ignored; + + /* Volatile stores prevent GCC from replacing this loop with a call + * to flash-resident memset while the application flash is unavailable. */ + volatile uint8_t *fill = buf; + for (uint32_t i = 0; i < MB_FLASH_PAGE; i++) + fill[i] = 0xFFu; + + if (readSize) + { + /* Read only CRC-validated image bytes. The rest of the page stays + * 0xFF when imageSize is not page-aligned. */ + GPIOA->BRR = MB_CS_PIN; /* CS low */ + if (!mb_ram_spi(0x03u, &ignored) || + !mb_ram_spi((extAddr >> 16) & 0xFFu, &ignored) || + !mb_ram_spi((extAddr >> 8) & 0xFFu, &ignored) || + !mb_ram_spi(extAddr & 0xFFu, &ignored)) + goto fatal_reset; + + for (uint32_t i = 0; i < readSize; i++) + if (!mb_ram_spi(0xFFu, &buf[i])) + goto fatal_reset; + + GPIOA->BSRR = MB_CS_PIN; /* CS high */ + } + + for (uint32_t i = 0; i < MB_FLASH_PAGE; i++) + { + if (buf[i] != 0xFFu) + { + needProgram = 1u; + break; + } + } + + for (uint32_t retry = 0; retry < MB_PAGE_RETRIES; retry++) + { + const uint32_t *src = (const uint32_t *)(const void *)buf; + volatile uint32_t *dst = (volatile uint32_t *)intAddr; + + uint32_t i; + uint32_t ok = 1u; + + /* --- page erase (256 bytes) --- */ + if (!mb_ram_flash_idle()) + goto fatal_reset; + FLASH->CR |= FLASH_CR_PER; + *(volatile uint32_t *)intAddr = 0xFFFFFFFFu; + if (!mb_ram_flash_idle()) + goto fatal_reset; + FLASH->CR &= ~FLASH_CR_PER; + FLASH->SR = FLASH_SR_EOP | FLASH_SR_WRPERR | FLASH_SR_OPTVERR; + + if (needProgram) + { + /* Page program: 64 words, PGSTRT before the last word. */ + FLASH->CR |= FLASH_CR_PG; + for (i = 0; i < 64u; i++) + { + dst[i] = src[i]; + if (i == 62u) + FLASH->CR |= FLASH_CR_PGSTRT; + } + if (!mb_ram_flash_idle()) + goto fatal_reset; + FLASH->CR &= ~FLASH_CR_PG; + FLASH->SR = FLASH_SR_EOP | FLASH_SR_WRPERR | FLASH_SR_OPTVERR; + } + + /* --- verify (read-back compare) --- */ + for (i = 0; i < 64u; i++) + { + if (dst[i] != src[i]) + { + ok = 0u; + break; + } + } + if (ok) + { + success = 1u; + break; + } + } + + /* Never silently continue after an unprogrammable page. Returning to + * flash-resident code is unsafe once the application has been erased. */ + if (!success) + goto fatal_reset; + + /* Advance the gauge without division (which could call a helper + * in erased flash). Refresh once per 8 KiB internal sector. */ + if (lcdEnabled) + { + pagesDone++; + progressAccumulator += MB_PROGRESS_COLS; + while (progressAccumulator >= (MB_INT_APP_SIZE / MB_FLASH_PAGE)) + { + progressAccumulator -= (MB_INT_APP_SIZE / MB_FLASH_PAGE); + if (progressFilled < MB_PROGRESS_COLS) + { + progressLine[MB_PROGRESS_FIRST_COL + progressFilled] = MB_PROGRESS_FILLED; + progressFilled++; + } + } + if ((pagesDone & 31u) == 0u || regionRemaining == MB_FLASH_PAGE) + lcdEnabled = mb_ram_progress_blit(progressLine); + } + + intAddr += MB_FLASH_PAGE; + extAddr += readSize; + remaining -= readSize; + regionRemaining -= MB_FLASH_PAGE; + } + + FLASH->CR |= FLASH_CR_LOCK; + mb_ram_reset(); + +fatal_reset: + /* Release the external flash and reset immediately. If failure happened + * after an erase, the factory USB/DFU bootloader remains the recovery path. */ + GPIOA->BSRR = MB_CS_PIN; + FLASH->CR &= ~(FLASH_CR_PER | FLASH_CR_PG | FLASH_CR_PGSTRT); + FLASH->CR |= FLASH_CR_LOCK; + mb_ram_reset(); +} + +/* -------------------------------------------------------------------------- */ +/* Public API. */ +/* -------------------------------------------------------------------------- */ + +void MB_BackupToSlot0(uint32_t *out_size, uint32_t *out_crc32) +{ + const uint32_t imgBase = MB_SLOT0_EXT_BASE + MB_SLOT_IMG_OFFSET; + const uint32_t size = MB_INT_APP_SIZE; /* full region (self-test) */ + + /* CRC-32 of the internal image (memory-mapped, contiguous read). */ + uint32_t crc = mb_crc32_update(0xFFFFFFFFu, (const uint8_t *)MB_INT_APP_BASE, size) + ^ 0xFFFFFFFFu; + + /* Write the image first ... */ + PY25Q16_WriteBuffer(imgBase, (const void *)MB_INT_APP_BASE, size, false); + + /* ... then a valid header (COMMITTED) last, so a committed header always + * implies a fully written image. */ + mb_slot_header_t hdr; + memset(&hdr, 0, sizeof(hdr)); + hdr.magic = MB_SLOT_MAGIC; + hdr.hdr_version = MB_HDR_VERSION; + hdr.flags = MB_FLAG_COMMITTED; + hdr.image_size = size; + hdr.image_crc32 = crc; +#ifdef ENABLE_FEAT_F4HWN + mb_copy_str(hdr.name, MB_NAME_LEN, Edition); +#else + mb_copy_str(hdr.name, MB_NAME_LEN, "slot0"); +#endif + mb_copy_str(hdr.fw_version, MB_VERSION_LEN, Version); + PY25Q16_WriteBuffer(MB_SLOT0_EXT_BASE, &hdr, sizeof(hdr), false); + + if (out_size) *out_size = size; + if (out_crc32) *out_crc32 = crc; +} + +/* Set when a polled SPI wait below times out (external flash unresponsive). */ +static volatile int mb_spi_err; + +/* Polled single-byte SPI2 transfer (flash-resident; runs in normal context). + * Bounded so a wedged SPI can never freeze the firmware: on timeout it sets + * mb_spi_err and returns 0xFF, letting the caller fail gracefully. */ +#define MB_SPI_TIMEOUT 20000u /* ~a few ms max per byte; a healthy transfer + * completes in well under a microsecond */ +static uint8_t mb_spi_byte(uint8_t v) +{ + uint32_t to = MB_SPI_TIMEOUT; + while (!(SPI2->SR & SPI_SR_TXE)) { if (!--to) { mb_spi_err = 1; return 0xFFu; } } + *(volatile uint8_t *)&SPI2->DR = v; + to = MB_SPI_TIMEOUT; + while (!(SPI2->SR & SPI_SR_RXNE)) { if (!--to) { mb_spi_err = 1; return 0xFFu; } } + return *(volatile uint8_t *)&SPI2->DR; +} + +/* + * Put SPI2 into clean polled mode before a manual read. The flash driver leaves + * SPI2 in "DMA mode": the RX/TX DMA requests stay on and the DMA channels stay + * armed (confirmed by the SPI-state diagnostic: RD.EN=1 WR.EN=1). A polled read + * then loses every received byte to the still-armed DMA, so RXNE never sets and + * the read hangs forever. Disabling the DMA requests + channels and draining the + * RX FIFO restores plain polled behaviour. The next driver operation re-arms DMA + * on its own, so this is safe. + */ +static void mb_spi_polled_mode(void) +{ + SPI2->CR2 &= ~(SPI_CR2_RXDMAEN | SPI_CR2_TXDMAEN); + DMA1_Channel4->CCR &= ~DMA_CCR_EN; + DMA1_Channel5->CCR &= ~DMA_CCR_EN; + /* Drain any pending RX. Bounded: the RX FIFO is only a few bytes deep, so a + * stuck/overrun RXNE (which would otherwise loop forever) can't hang here. */ + for (uint32_t guard = 64; (SPI2->SR & SPI_SR_RXNE) && guard; guard--) + (void)*(volatile uint8_t *)&SPI2->DR; +} + +/* + * CRC-32 of `len` bytes of external flash starting at `addr`, read in ONE + * continuous polled transfer (command 0x03, CS held low, auto-incrementing + * address). This avoids issuing hundreds of tiny back-to-back DMA reads through + * PY25Q16_ReadBuffer(), which is not reliable at that rate. + */ +#define MB_READ_CHUNK 2048u + +static uint32_t mb_ext_image_crc32(uint32_t addr, uint32_t len) +{ + uint32_t crc = 0xFFFFFFFFu; + + mb_spi_polled_mode(); + + /* Read in chunks. IRQs are masked during each chunk's continuous transfer + * (an interrupt mid-transfer desyncs the polled SPI - the same reason the + * RAM copier masks them), but re-enabled between chunks so the USB stack + * keeps being serviced and the reply can go out afterwards. */ + while (len && !mb_spi_err) + { + uint32_t chunk = (len < MB_READ_CHUNK) ? len : MB_READ_CHUNK; + uint32_t primask = __get_PRIMASK(); + __disable_irq(); + + GPIOA->BRR = MB_CS_PIN; /* CS low */ + mb_spi_byte(0x03u); + mb_spi_byte((addr >> 16) & 0xFFu); + mb_spi_byte((addr >> 8) & 0xFFu); + mb_spi_byte(addr & 0xFFu); + for (uint32_t i = 0; i < chunk && !mb_spi_err; i++) + { + crc ^= mb_spi_byte(0xFFu); + for (int k = 0; k < 8; k++) + crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u))); + } + GPIOA->BSRR = MB_CS_PIN; /* CS high */ + + __set_PRIMASK(primask); /* let IRQs / USB breathe */ + addr += chunk; + len -= chunk; + } + + return crc ^ 0xFFFFFFFFu; +} + +/* Polled read of `len` bytes from external flash into `buf` (no DMA), matching + * the technique the RAM copier uses. Sets mb_spi_err on a stuck SPI. */ +static void mb_ext_read(uint32_t addr, uint8_t *buf, uint32_t len) +{ + mb_spi_polled_mode(); + + /* IRQs off during the transfer (see mb_ext_image_crc32); break on timeout. */ + uint32_t primask = __get_PRIMASK(); + __disable_irq(); + + GPIOA->BRR = MB_CS_PIN; + mb_spi_byte(0x03u); + mb_spi_byte((addr >> 16) & 0xFFu); + mb_spi_byte((addr >> 8) & 0xFFu); + mb_spi_byte(addr & 0xFFu); + while (len-- && !mb_spi_err) + *buf++ = mb_spi_byte(0xFFu); + GPIOA->BSRR = MB_CS_PIN; + + __set_PRIMASK(primask); +} + +/* -------------------------------------------------------------------------- */ +/* External flash raw erase/program (polled, flash-resident). */ +/* */ +/* Used only by the M4 slot-management commands. These bypass the stateful */ +/* PY25Q16 driver (its sector cache would desync when we erase and program a */ +/* slot behind its back, and its per-chunk read-modify-write would erase a */ +/* sector on every small chunk). Each CS-framed transaction masks IRQs for */ +/* its own burst only - an interrupt mid-transfer desyncs the polled SPI, the */ +/* same reason mb_ext_image_crc32 masks them - and re-enables them between */ +/* transactions so USB keeps being serviced (notably across the long erase). */ +/* -------------------------------------------------------------------------- */ + +#define MB_EXT_CMD_WREN 0x06u /* write enable */ +#define MB_EXT_CMD_PP 0x02u /* page program (<=256 B) */ +#define MB_EXT_CMD_SE 0x20u /* 4 KiB sector erase */ +#define MB_EXT_CMD_RDSR 0x05u /* read status register 1 */ +#define MB_EXT_SECTOR 0x1000u /* PY25Q16 erase granularity */ +#define MB_EXT_PAGE 0x100u /* PY25Q16 program granularity */ +#define MB_EXT_WIP_TIMEOUT 5000000u /* status polls before giving up (~seconds) */ + +static void mb_ext_wren(void) +{ + uint32_t primask = __get_PRIMASK(); + __disable_irq(); + GPIOA->BRR = MB_CS_PIN; + mb_spi_byte(MB_EXT_CMD_WREN); + GPIOA->BSRR = MB_CS_PIN; + __set_PRIMASK(primask); +} + +/* Poll WIP until the erase/program finishes (or mb_spi_err / timeout). IRQs are + * masked only for each 2-byte status read, not the whole wait, so a ~300 ms + * erase does not starve USB. */ +static bool mb_ext_wait_wip(void) +{ + for (uint32_t i = 0; i < MB_EXT_WIP_TIMEOUT; i++) + { + uint32_t primask = __get_PRIMASK(); + __disable_irq(); + GPIOA->BRR = MB_CS_PIN; + mb_spi_byte(MB_EXT_CMD_RDSR); + uint8_t status = mb_spi_byte(0xFFu); + GPIOA->BSRR = MB_CS_PIN; + __set_PRIMASK(primask); + + if (mb_spi_err) + return false; + if (!(status & 1u)) /* WIP clear */ + return true; + } + return false; +} + +static bool mb_ext_sector_erase(uint32_t addr) +{ + mb_ext_wren(); + if (mb_spi_err) + return false; + + uint32_t primask = __get_PRIMASK(); + __disable_irq(); + GPIOA->BRR = MB_CS_PIN; + mb_spi_byte(MB_EXT_CMD_SE); + mb_spi_byte((addr >> 16) & 0xFFu); + mb_spi_byte((addr >> 8) & 0xFFu); + mb_spi_byte(addr & 0xFFu); + GPIOA->BSRR = MB_CS_PIN; + __set_PRIMASK(primask); + + if (mb_spi_err) + return false; + return mb_ext_wait_wip(); +} + +/* Program up to one 256-byte page; the caller must not cross a page boundary. */ +static bool mb_ext_page_program(uint32_t addr, const uint8_t *data, uint32_t len) +{ + mb_ext_wren(); + if (mb_spi_err) + return false; + + uint32_t primask = __get_PRIMASK(); + __disable_irq(); + GPIOA->BRR = MB_CS_PIN; + mb_spi_byte(MB_EXT_CMD_PP); + mb_spi_byte((addr >> 16) & 0xFFu); + mb_spi_byte((addr >> 8) & 0xFFu); + mb_spi_byte(addr & 0xFFu); + for (uint32_t i = 0; i < len && !mb_spi_err; i++) + mb_spi_byte(data[i]); + GPIOA->BSRR = MB_CS_PIN; + __set_PRIMASK(primask); + + if (mb_spi_err) + return false; + return mb_ext_wait_wip(); +} + +/* Program an arbitrary range, split on 256-byte page boundaries (a page program + * that crosses a page boundary wraps within the page instead of advancing). */ +static bool mb_ext_program(uint32_t addr, const uint8_t *data, uint32_t len) +{ + while (len) + { + uint32_t pageRem = MB_EXT_PAGE - (addr & (MB_EXT_PAGE - 1u)); + uint32_t n = (len < pageRem) ? len : pageRem; + if (!mb_ext_page_program(addr, data, n)) + return false; + addr += n; + data += n; + len -= n; + } + return true; +} + +/* Read + validate a slot header only (no CRC recompute), via polled reads. */ +static uint8_t mb_read_header(uint8_t slot, mb_slot_header_t *hdr) +{ + if (slot >= MB_SLOT_COUNT) + return MB_ERR_SLOT; + + const uint32_t slotBase = MB_SLOT0_EXT_BASE + (uint32_t)slot * MB_SLOT_STRIDE; + + mb_spi_err = 0; + mb_ext_read(slotBase, (uint8_t *)hdr, sizeof(*hdr)); + if (mb_spi_err) return MB_ERR_SPI; + if (hdr->magic != MB_SLOT_MAGIC) return MB_ERR_MAGIC; + if (hdr->hdr_version > MB_HDR_VERSION) return MB_ERR_VERSION; + if (!(hdr->flags & MB_FLAG_COMMITTED)) return MB_ERR_NOT_COMMITTED; + if (hdr->image_size == 0 || hdr->image_size > MB_INT_APP_SIZE) return MB_ERR_SIZE; + return MB_OK; +} + +/* Validate one slot (header + image CRC-32), entirely via polled reads. + * Never touches the internal flash. */ +static uint8_t mb_validate(uint8_t slot, mb_slot_header_t *hdr, uint32_t *crcOut) +{ + uint8_t err = mb_read_header(slot, hdr); + if (err != MB_OK) + return err; + + const uint32_t slotBase = MB_SLOT0_EXT_BASE + (uint32_t)slot * MB_SLOT_STRIDE; + + mb_spi_err = 0; + uint32_t crc = mb_ext_image_crc32(slotBase + MB_SLOT_IMG_OFFSET, hdr->image_size); + if (crcOut) + *crcOut = crc; + if (mb_spi_err) return MB_ERR_SPI; + if (crc != hdr->image_crc32) return MB_ERR_CRC; + return MB_OK; +} + +uint8_t MB_ValidateSlot(uint8_t slot, mb_slot_header_t *out_header, uint32_t *out_crc) +{ + mb_slot_header_t local; + return mb_validate(slot, out_header ? out_header : &local, out_crc); +} + +uint8_t MB_ValidateSlot0(uint32_t *out_crc) +{ + return MB_ValidateSlot(0, NULL, out_crc); +} + +uint8_t MB_RestoreSlot(uint8_t slot, uint8_t *progress_line) +{ + mb_slot_header_t hdr; + uint8_t err = mb_validate(slot, &hdr, NULL); + if (err != MB_OK) + return err; + + const uint32_t slotBase = MB_SLOT0_EXT_BASE + (uint32_t)slot * MB_SLOT_STRIDE; + + /* Valid: the RAM stub copies exactly image_size bytes, pads the partial + * page with 0xFF and erases the remainder of the application region. */ + MB_PrepareInternalFlash(); + + /* Call through a volatile pointer so the compiler emits an absolute 'blx' + * (the RAM copy sits far beyond a Cortex-M0+ 'bl' reach from flash). */ + void (*volatile ramReflash)(uint32_t, uint32_t, uint32_t, uint8_t *) = MB_RamReflash; + ramReflash(MB_INT_APP_BASE, slotBase + MB_SLOT_IMG_OFFSET, + hdr.image_size, progress_line); + + return MB_OK; /* not reached */ +} + +uint8_t MB_RestoreSlot0(void) +{ + return MB_RestoreSlot(0, NULL); +} + +/* -------------------------------------------------------------------------- */ +/* M4 slot management (host tool). External flash only - never brick-critical.*/ +/* -------------------------------------------------------------------------- */ + +uint8_t MB_SlotInfo(uint8_t slot, mb_slot_header_t *out_header) +{ + mb_slot_header_t local; + return mb_read_header(slot, out_header ? out_header : &local); +} + +uint8_t MB_SlotErase(uint8_t slot) +{ + if (slot >= MB_SLOT_COUNT) + return MB_ERR_SLOT; + + const uint32_t base = MB_SLOT0_EXT_BASE + (uint32_t)slot * MB_SLOT_STRIDE; + + mb_spi_err = 0; + mb_spi_polled_mode(); + for (uint32_t off = 0; off < MB_SLOT_STRIDE; off += MB_EXT_SECTOR) + if (!mb_ext_sector_erase(base + off)) + return MB_ERR_SPI; + + return MB_OK; +} + +uint8_t MB_SlotWrite(uint8_t slot, uint32_t offset, const uint8_t *data, uint32_t len) +{ + if (slot >= MB_SLOT_COUNT) + return MB_ERR_SLOT; + if (len == 0) + return MB_OK; + if (offset > MB_SLOT_STRIDE || len > MB_SLOT_STRIDE - offset) + return MB_ERR_SIZE; + + const uint32_t base = MB_SLOT0_EXT_BASE + (uint32_t)slot * MB_SLOT_STRIDE; + + mb_spi_err = 0; + mb_spi_polled_mode(); + if (!mb_ext_program(base + offset, data, len)) + return MB_ERR_SPI; + + return MB_OK; +} + +/* Middle of slot 0's image (32 KiB in, well within the 118 KiB image body). */ +#define MB_CORRUPT_OFFSET 0x8000u +#define MB_CORRUPT_MAX 128u + +void MB_CorruptSlot0(const uint8_t *data, uint32_t len) +{ + if (!data || len == 0) + return; + if (len > MB_CORRUPT_MAX) + len = MB_CORRUPT_MAX; + + /* Constrained to slot 0's image body: this address range cannot reach the + * slot header, nor calibration / EEPROM / RF log / voice regions. */ + PY25Q16_WriteBuffer(MB_SLOT0_EXT_BASE + MB_SLOT_IMG_OFFSET + MB_CORRUPT_OFFSET, + data, len, false); +} + +volatile uint8_t mb_mark_on = 0; + +void MB_Mark(const char *s) +{ + if (!mb_mark_on) + return; + memset(gFrameBuffer, 0, sizeof(gFrameBuffer)); + UI_PrintStringSmallNormal(s, 10, 0, 3); + ST7565_BlitFullScreen(); +} + +void MB_DumpExt(uint32_t addr, uint8_t *buf, uint32_t len) +{ + /* Trace the driver read step by step on the LCD so a freeze reveals where. */ + mb_mark_on = 1; + MB_Mark("DUMP begin"); + PY25Q16_ReadBufferSafe(addr, buf, len); + MB_Mark("DUMP done"); + mb_mark_on = 0; +} + +void MB_SpiState(uint32_t out[4]) +{ + out[0] = SPI2->CR1; /* bit 6 (SPE) = SPI enabled */ + out[1] = SPI2->CR2; /* bit0 RXDMAEN, bit1 TXDMAEN */ + out[2] = SPI2->SR; /* bit0 RXNE, bit1 TXE, bit7 BSY */ + out[3] = DMA1_Channel4->CCR; /* SPI2 RX DMA channel (bit0 EN) */ +} diff --git a/App/driver/mb_flash.h b/App/driver/mb_flash.h new file mode 100644 index 00000000..90a7e5a1 --- /dev/null +++ b/App/driver/mb_flash.h @@ -0,0 +1,168 @@ +/* Copyright 2026 F4HWN + * + * 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. + */ + +/* + * Multiboot flash programmer. + * + * M1 (validated): brick-critical core - reprogram the internal application flash + * from an image held in the external SPI flash, running from RAM. + * + * M2 (this file): slot format + integrity validation. Each slot starts with a + * header (magic, size, CRC32, name, version); a restore validates the header and + * the image CRC32 *before* erasing anything, so an incompatible or corrupt image + * can never brick the radio. There is a single firmware for both the K1 and the + * K5v3 (the keypad difference is handled at runtime by the hidden SetNav menu), + * so no per-model guard is needed. + * + * See docs/multiboot-design.md for the overall design. + */ + +#ifndef DRIVER_MB_FLASH_H +#define DRIVER_MB_FLASH_H + +#include +#include + +/* Internal flash application region (see Core/py32f071xb.ld: + * FLASH origin 0x08002800, length 118 KiB). 0x08002800 is 256-byte aligned, so + * the whole region can be page-erased (256 B granularity) without touching the + * factory bootloader that lives just below it. */ +#define MB_INT_APP_BASE 0x08002800u +#define MB_INT_APP_SIZE 0x0001D800u /* 118 KiB */ + +/* External SPI flash slot layout (see docs/multiboot-design.md). + * Each 128 KiB slot = one header sector (4 KiB) followed by the image. */ +#define MB_SLOT_STRIDE 0x00020000u /* 128 KiB per slot */ +#define MB_SLOT_IMG_OFFSET 0x00001000u /* image starts after header sector */ +#define MB_SLOT0_EXT_BASE 0x00020000u /* slot 0 header base */ +#define MB_SLOT_COUNT 4u + +/* Slot header (stored at the slot base, first 4 KiB sector). 64 bytes. */ +#define MB_SLOT_MAGIC 0x31424D46u /* "FMB1" */ +#define MB_HDR_VERSION 1u +#define MB_FLAG_COMMITTED (1u << 0) /* image written and verified */ +#define MB_NAME_LEN 16 +#define MB_VERSION_LEN 16 + +typedef struct __attribute__((packed)) { + uint32_t magic; /* MB_SLOT_MAGIC */ + uint16_t hdr_version; /* MB_HDR_VERSION */ + uint16_t flags; /* MB_FLAG_COMMITTED, ... */ + uint32_t image_size; /* bytes, <= MB_INT_APP_SIZE */ + uint32_t image_crc32; /* CRC-32 (zlib) over image_size B */ + char name[MB_NAME_LEN]; /* human-readable, NUL-terminated */ + char fw_version[MB_VERSION_LEN]; /* firmware version string */ + uint8_t reserved[16]; /* pad to 64 bytes, future use */ +} mb_slot_header_t; + +/* Restore validation result (MB_OK never returns - the radio resets). */ +enum { + MB_OK = 0, + MB_ERR_MAGIC, /* no/invalid slot header */ + MB_ERR_VERSION, /* header format too new */ + MB_ERR_NOT_COMMITTED,/* image not marked complete */ + MB_ERR_SIZE, /* image_size out of range */ + MB_ERR_CRC, /* image CRC32 mismatch */ + MB_ERR_SPI, /* external flash read/write timed out*/ + MB_ERR_SLOT, /* slot index out of range */ + MB_ERR_AUTH /* write refused: timestamp mismatch */ +}; + +/* + * Copy the live internal application image into external flash slot 0, writing + * the image first and then a valid header (COMMITTED) last. Runs entirely from + * flash and only writes the external SPI flash, so it is safe (no brick risk). + * Reports the stored image size and CRC-32 through the (optional) out params. + */ +void MB_BackupToSlot0(uint32_t *out_size, uint32_t *out_crc32); + +/* + * Validate slot 0 and, if valid, reflash the internal application from it and + * reset. Validation (magic, version, COMMITTED flag, size, image CRC-32) runs + * from flash *before* any erase: on failure it returns an MB_ERR_* code and the + * internal flash is left untouched. On success it NEVER RETURNS (the RAM-resident + * copier reflashes the internal application and triggers a system reset). The + * factory bootloader is never touched, so an interrupted copy is recoverable + * over USB/DFU. + */ +uint8_t MB_RestoreSlot0(void); + +/* Multi-slot API used by the boot selector. Validation always covers the full + * image CRC before restore. progress_line may point to a 128-byte LCD page; the + * RAM copier then fills it while reflashing. Pass NULL to disable LCD updates. */ +uint8_t MB_ValidateSlot(uint8_t slot, mb_slot_header_t *out_header, uint32_t *out_crc); +uint8_t MB_RestoreSlot(uint8_t slot, uint8_t *progress_line); + +/* + * Host-tool slot management (M4, "Firmware Slots" in UV Studio). Everything is + * bounds-checked (slot < MB_SLOT_COUNT, offset+len <= MB_SLOT_STRIDE) and writes + * touch the EXTERNAL flash only, so none of this is brick-critical: a bad slot is + * simply refused at restore by the CRC validation above. + * + * - MB_SlotInfo reads the 64-byte header only (fast, no CRC recompute) and + * returns MB_OK / MB_ERR_* describing the header state. + * - MB_SlotErase erases the whole 128 KiB slot region (header + image). + * - MB_SlotWrite programs `len` bytes at slot_base+offset. The slot MUST have + * been erased first (NOR flash only clears 1->0 bits); the host + * writes the image, then the COMMITTED header last. + * Use MB_ValidateSlot afterwards to confirm the full image CRC. + */ +uint8_t MB_SlotInfo(uint8_t slot, mb_slot_header_t *out_header); +uint8_t MB_SlotErase(uint8_t slot); +uint8_t MB_SlotWrite(uint8_t slot, uint32_t offset, const uint8_t *data, uint32_t len); + + +/* + * Test helper: overwrite up to `len` (capped) bytes in the MIDDLE of slot 0's + * image with the supplied data, so the next MB_RestoreSlot0() fails its CRC-32 + * check and refuses. It writes ONLY inside slot 0's image body - never the + * header, and never anything outside the slot (calibration, EEPROM, RF log...). + * Developer aid to exercise the safe-refusal path without external tooling. + */ +void MB_CorruptSlot0(const uint8_t *data, uint32_t len); + +/* + * Validate slot 0 (header + image CRC-32) WITHOUT reflashing. Same checks as the + * restore path, entirely via polled reads (cannot hang). Returns MB_OK or an + * MB_ERR_* code, and reports the computed image CRC-32 through out_crc. + * Useful as a safe diagnostic from the host tool. + */ +uint8_t MB_ValidateSlot0(uint32_t *out_crc); + +/* + * Read `len` bytes of external flash at `addr` into `buf`, via the DMA driver + * (PY25Q16_ReadBuffer) - the same proven read path the backup uses internally. + * Ground-truth diagnostic to check what is actually stored in a slot. + */ +void MB_DumpExt(uint32_t addr, uint8_t *buf, uint32_t len); + +/* + * Diagnostic: snapshot the SPI2 / DMA hardware state WITHOUT any flash access + * (so it cannot hang). out[0]=SPI2->CR1, [1]=SPI2->SR, [2]=DMA RD chan CCR, + * [3]=DMA WR chan CCR. Reveals whether SPI2 is disabled/busy or a DMA channel + * is left armed when a read command runs. + */ +void MB_SpiState(uint32_t out[4]); + +/* + * On-screen trace marker (debug). Draws `s` on the LCD (SPI1, independent of the + * flash SPI2) so that when a flash read freezes the CPU, the frozen screen shows + * the last step reached. Only draws while mb_mark_on is set (i.e. during a Dump), + * so it does not flash the screen during normal writes. + */ +extern volatile uint8_t mb_mark_on; +void MB_Mark(const char *s); + +#endif /* DRIVER_MB_FLASH_H */ diff --git a/App/driver/py25q16.c b/App/driver/py25q16.c index 3d9990a1..2bc38221 100644 --- a/App/driver/py25q16.c +++ b/App/driver/py25q16.c @@ -27,6 +27,13 @@ #include "external/printf/printf.h" #include "misc.h" +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + #include "driver/mb_flash.h" + #define MBMARK(s) MB_Mark(s) +#else + #define MBMARK(s) +#endif + // #define DEBUG #define SPIx SPI2 @@ -227,17 +234,21 @@ void PY25Q16_Init() void PY25Q16_ReadBuffer(uint32_t Address, void *pBuffer, uint32_t Size) { + MBMARK("RD cmd"); // about to assert CS + send read command CS_Assert(); SPI_WriteByte(0x03); // Send read command + MBMARK("RD addr"); // command sent, about to send address WriteAddr(Address); // Send address (3 bytes) + MBMARK("RD flush"); // address sent, about to flush RX FIFO // CRITICAL: Flush RX FIFO before DMA to remove residual data while (LL_SPI_RX_FIFO_EMPTY != LL_SPI_GetRxFIFOLevel(SPIx)) { LL_SPI_ReceiveData8(SPIx); // Read and discard } + MBMARK("RD data"); // FIFO flushed, about to read the data if (Size >= 16) { SPI_ReadBuf((uint8_t *)pBuffer, Size); } else { @@ -247,9 +258,22 @@ void PY25Q16_ReadBuffer(uint32_t Address, void *pBuffer, uint32_t Size) } } + MBMARK("RD end"); // data read, about to release CS CS_Release(); } +// Like PY25Q16_ReadBuffer, but waits for the flash to be idle first (WIP=0), +// exactly as PY25Q16_WriteBuffer does before its internal reads. A standalone +// read issued while the chip is still busy from a prior program/erase never +// returns the expected data. +void PY25Q16_ReadBufferSafe(uint32_t Address, void *pBuffer, uint32_t Size) +{ + MBMARK("SAFE wip"); // about to WaitWIP() + WaitWIP(); + MBMARK("SAFE rb"); // WaitWIP done, about to ReadBuffer + PY25Q16_ReadBuffer(Address, pBuffer, Size); +} + void PY25Q16_WriteBuffer(uint32_t Address, const void *pBuffer, uint32_t Size, bool Append) { #ifdef DEBUG diff --git a/App/driver/py25q16.h b/App/driver/py25q16.h index fae064e0..4d4d696b 100644 --- a/App/driver/py25q16.h +++ b/App/driver/py25q16.h @@ -22,6 +22,7 @@ void PY25Q16_Init(); void PY25Q16_ReadBuffer(uint32_t Address, void *pBuffer, uint32_t Size); +void PY25Q16_ReadBufferSafe(uint32_t Address, void *pBuffer, uint32_t Size); void PY25Q16_WriteBuffer(uint32_t Address, const void *pBuffer, uint32_t Size, bool Append); void PY25Q16_SectorErase(uint32_t Address); diff --git a/App/helper/boot.c b/App/helper/boot.c index 838cfb13..97a70e87 100644 --- a/App/helper/boot.c +++ b/App/helper/boot.c @@ -29,20 +29,40 @@ #include "settings.h" #include "ui/menu.h" #include "ui/ui.h" +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + #include "ui/multiboot.h" +#endif BOOT_Mode_t BOOT_GetMode(void) { unsigned int i; KEY_Code_t Keys[2]; + bool PttPressed[2]; + /* Poll the keypad even without PTT: holding MENU alone enters multiboot. + * The two samples keep the same debounce rule as the legacy boot modes. */ for (i = 0; i < 2; i++) { - if (!GPIO_IsPttPressed()) - return BOOT_MODE_NORMAL; // PTT not pressed + PttPressed[i] = GPIO_IsPttPressed(); Keys[i] = KEYBOARD_Poll(); SYSTEM_DelayMs(20); } + #ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + if (!PttPressed[0] && !PttPressed[1] && + Keys[0] == KEY_MENU && Keys[1] == KEY_MENU) + { + gKeyReading0 = Keys[0]; + gKeyReading1 = Keys[0]; + gDebounceCounter = 2; + return BOOT_MODE_MULTIBOOT; + } + #endif + + /* All historical special modes still require PTT for both samples. */ + if (!PttPressed[0] || !PttPressed[1]) + return BOOT_MODE_NORMAL; + #ifdef ENABLE_FEAT_F4HWN_RESCUE_OPS if (Keys[0] == (10 + gEeprom.SET_KEY)) { @@ -125,5 +145,10 @@ void BOOT_ProcessMode(BOOT_Mode_t Mode) } #endif + #ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + else if (Mode == BOOT_MODE_MULTIBOOT) + UI_MultibootSelector(); + #endif + GUI_SelectNextDisplay(display); } diff --git a/App/helper/boot.h b/App/helper/boot.h index f36448fd..1c21fcb2 100644 --- a/App/helper/boot.h +++ b/App/helper/boot.h @@ -28,7 +28,10 @@ enum BOOT_Mode_t BOOT_MODE_RESCUE_OPS, #endif #ifdef ENABLE_AIRCOPY - BOOT_MODE_AIRCOPY + BOOT_MODE_AIRCOPY, + #endif + #ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + BOOT_MODE_MULTIBOOT, #endif }; diff --git a/App/main.c b/App/main.c index fba5be1e..ff9d6d24 100644 --- a/App/main.c +++ b/App/main.c @@ -132,6 +132,16 @@ void Main(void) BOOT_Mode_t BootMode = BOOT_GetMode(); +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + /* Run before the welcome screen and the normal application UI. EXIT from + * the selector simply resumes this boot as if no special mode was held. */ + if (BootMode == BOOT_MODE_MULTIBOOT) + { + BOOT_ProcessMode(BootMode); + BootMode = BOOT_MODE_NORMAL; + } +#endif + #ifdef ENABLE_FEAT_F4HWN_RESCUE_OPS if (BootMode == BOOT_MODE_RESCUE_OPS) { diff --git a/App/ui/multiboot.c b/App/ui/multiboot.c new file mode 100644 index 00000000..4de22c38 --- /dev/null +++ b/App/ui/multiboot.c @@ -0,0 +1,266 @@ +/* Copyright 2026 F4HWN + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include "driver/backlight.h" +#include "driver/gpio.h" +#include "driver/keyboard.h" +#include "driver/mb_flash.h" +#include "driver/st7565.h" +#include "driver/system.h" +#include "ui/helper.h" +#include "ui/multiboot.h" + +static const char *mb_error_text(uint8_t err) +{ + switch (err) + { + case MB_OK: return "OK"; + case MB_ERR_MAGIC: return "empty"; + case MB_ERR_VERSION: return "new header"; + case MB_ERR_NOT_COMMITTED: return "incomplete"; + case MB_ERR_SIZE: return "bad size"; + case MB_ERR_CRC: return "CRC ERROR"; + case MB_ERR_SPI: return "SPI ERROR"; + case MB_ERR_SLOT: return "bad slot"; + case MB_ERR_AUTH: return "auth"; + default: return "error"; + } +} + +static void mb_copy_label(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; +} + +static void mb_format_slot_label(char *dst, uint8_t cap, const mb_slot_header_t *header) +{ + const char *version = NULL; + uint8_t version_cap = 0; + uint8_t version_len = 0; + uint8_t name_limit = cap - 1u; + uint8_t n = 0; + + if (!header->name[0]) + { + mb_copy_label(dst, cap, header->fw_version, MB_VERSION_LEN); + return; + } + + for (uint8_t i = 0; i + 1u < MB_VERSION_LEN && header->fw_version[i]; i++) + { + if (header->fw_version[i] == 'v' && + header->fw_version[i + 1u] >= '0' && + header->fw_version[i + 1u] <= '9') + { + version = &header->fw_version[i + 1u]; + version_cap = MB_VERSION_LEN - i - 1u; + break; + } + } + + if (version) + { + while (version_len < version_cap && version[version_len]) + version_len++; + if (version_len + 1u < cap) + name_limit = cap - version_len - 2u; + } + + while (n < name_limit && n < MB_NAME_LEN && header->name[n]) + { + dst[n] = header->name[n]; + n++; + } + if (version && n + version_len + 1u < cap) + { + dst[n++] = ' '; + for (uint8_t i = 0; i < version_len; i++) + dst[n++] = version[i]; + } + dst[n] = 0; +} + +static void mb_show_message(const char *line1, const char *line2, const char *line3) +{ + UI_DisplayClear(); + UI_StatusClear(); + UI_PrintStringSmallNormal("MultiBoot", 2, 126, 0); + if (line1) UI_PrintStringSmallNormal(line1, 2, 126, 2); + if (line2) UI_PrintStringSmallNormal(line2, 2, 126, 4); + if (line3) UI_PrintStringSmallNormal(line3, 2, 126, 6); + ST7565_BlitStatusLine(); + ST7565_BlitFullScreen(); +} + +static void mb_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 KEY_Code_t mb_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 mb_scan_slots(mb_slot_header_t headers[MB_SLOT_COUNT], uint8_t status[MB_SLOT_COUNT]) +{ + mb_show_message("Scanning slots...", NULL, "Please wait"); + for (uint8_t slot = 0; slot < MB_SLOT_COUNT; slot++) + status[slot] = MB_ValidateSlot(slot, &headers[slot], NULL); +} + +static void mb_render_slots(uint8_t selected, + const mb_slot_header_t headers[MB_SLOT_COUNT], + const uint8_t status[MB_SLOT_COUNT]) +{ + char line[19]; /* 18 glyphs max: 18 * 7 px fits from x=2 to x=126. */ + + UI_DisplayClear(); + UI_StatusClear(); + UI_PrintStringSmallNormal("MultiBoot", 2, 126, 0); + + for (uint8_t slot = 0; slot < MB_SLOT_COUNT; slot++) + { + memset(line, 0, sizeof(line)); + line[0] = (slot == selected) ? '>' : ' '; + line[1] = (char)('0' + slot); + line[2] = ' '; + + if (status[slot] == MB_OK) + mb_format_slot_label(&line[3], sizeof(line) - 3u, &headers[slot]); + else + mb_copy_label(&line[3], sizeof(line) - 3u, mb_error_text(status[slot]), 20u); + + UI_PrintStringSmallNormal(line, 2, 0, (uint8_t)(slot + 1u)); + } + + UI_PrintStringSmallNormal("MENU to select", 2, 126, 5); + UI_PrintStringSmallNormal("EXIT to go back", 2, 126, 6); + ST7565_BlitStatusLine(); + ST7565_BlitFullScreen(); +} + +static void mb_prepare_progress(uint8_t slot) +{ + char title[] = "RESTORE SLOT 0"; + title[13] = (char)('0' + slot); + + UI_DisplayClear(); + UI_StatusClear(); + UI_PrintStringSmallNormal(title, 2, 126, 0); + UI_PrintStringSmallNormal("DO NOT POWER OFF", 2, 126, 2); + UI_PrintStringSmallNormal("Writing & Verify", 2, 126, 4); + + /* Same rounded outline and hatch pattern as the scan progress gauge. */ + gFrameBuffer[6][3] = 0x0Cu; + gFrameBuffer[6][4] = 0x12u; + gFrameBuffer[6][123] = 0x12u; + gFrameBuffer[6][124] = 0x0Cu; + for (uint8_t x = 5; x < 123u; x++) + gFrameBuffer[6][x] = 0x21u; + ST7565_BlitStatusLine(); + ST7565_BlitFullScreen(); +} + +void UI_MultibootSelector(void) +{ + mb_slot_header_t headers[MB_SLOT_COUNT]; + uint8_t status[MB_SLOT_COUNT]; + uint8_t selected = 0; + + BACKLIGHT_TurnOn(); + mb_show_message("Release keys", NULL, NULL); + mb_wait_release(); + mb_scan_slots(headers, status); + + for (uint8_t slot = 0; slot < MB_SLOT_COUNT; slot++) + { + if (status[slot] == MB_OK) + { + selected = slot; + break; + } + } + + for (;;) + { + mb_render_slots(selected, headers, status); + KEY_Code_t key = mb_get_key(); + + if (key == KEY_EXIT) + { + /* MENU was latched by BOOT_GetMode(). Do not let that stale boot + * key reach the normal application after leaving the selector. */ + gKeyReading0 = KEY_INVALID; + gKeyReading1 = KEY_INVALID; + gDebounceCounter = 0; + return; + } + if (key == KEY_UP) + { + selected = (uint8_t)((selected + MB_SLOT_COUNT - 1u) % MB_SLOT_COUNT); + continue; + } + if (key == KEY_DOWN) + { + selected = (uint8_t)((selected + 1u) % MB_SLOT_COUNT); + continue; + } + if (key != KEY_MENU) + continue; + + if (status[selected] != MB_OK) + { + mb_show_message("SLOT NOT VALID", mb_error_text(status[selected]), "Press any key"); + (void)mb_get_key(); + continue; + } + + char confirm[] = "Restore slot 0?"; + confirm[13] = (char)('0' + selected); + mb_show_message(confirm, "MENU to confirm", "EXIT to cancel"); + key = mb_get_key(); + if (key != KEY_MENU) + continue; + + mb_prepare_progress(selected); + uint8_t err = MB_RestoreSlot(selected, gFrameBuffer[6]); + + /* Only reached when the final pre-erase validation refused the slot. */ + status[selected] = err; + mb_show_message("RESTORE REFUSED", mb_error_text(err), "Press any key"); + (void)mb_get_key(); + mb_scan_slots(headers, status); + } +} diff --git a/App/ui/multiboot.h b/App/ui/multiboot.h new file mode 100644 index 00000000..c222354c --- /dev/null +++ b/App/ui/multiboot.h @@ -0,0 +1,12 @@ +/* Copyright 2026 F4HWN + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef UI_MULTIBOOT_H +#define UI_MULTIBOOT_H + +/* Blocking boot-time slot selector. Returns only when the user chooses EXIT; + * a successful restore resets the radio from the RAM-resident copier. */ +void UI_MultibootSelector(void); + +#endif diff --git a/CMakePresets.json b/CMakePresets.json index 4bc5baed..f8e8a7bc 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -81,17 +81,20 @@ "ENABLE_FEAT_F4HWN_LOGO": false, "ENABLE_FEAT_F4HWN_LOGO_SAV": false, "ENABLE_FEAT_F4HWN_MENU_CAT": false, + "ENABLE_FEAT_F4HWN_MULTIBOOT": false, "ENABLE_AGC_SHOW_DATA": false, "ENABLE_UART_RW_BK_REGS": false, "ENABLE_SWD": false, "VERSION_STRING_1": "v0.22", - "VERSION_STRING_2": "v5.8.1" + "VERSION_STRING_2": "v5.9.0" } }, { "name": "Custom", "inherits": "default", "cacheVariables": { + "ENABLE_FEAT_F4HWN_QRCODE": true, + "ENABLE_FEAT_F4HWN_MEM": true, "EDITION_STRING": "Custom", "TARGET": "f4hwn.custom" } diff --git a/Core/py32f071xb.ld b/Core/py32f071xb.ld index bedcc0af..62732d35 100644 --- a/Core/py32f071xb.ld +++ b/Core/py32f071xb.ld @@ -134,10 +134,14 @@ SECTIONS _sidata = LOADADDR(.data); /* Initialized data sections goes into RAM, load LMA copy after code */ - .data : + .data : { . = ALIGN(4); _sdata = .; /* create a global symbol at data start */ + + *(.RamFunc) /* functions that must execute from RAM (e.g. internal */ + *(.RamFunc*) /* flash reprogramming); copied to RAM with .data */ + *(.data) /* .data sections */ *(.data*) /* .data* sections */ diff --git a/tools/mb_test.html b/tools/mb_test.html new file mode 100644 index 00000000..f5d32790 --- /dev/null +++ b/tools/mb_test.html @@ -0,0 +1,333 @@ + + + + + +Multiboot M1 - bench test + + + +
+

Multiboot M1 — bench test

+
UV-K1 / UV-K5 V3 · F4HWN · commandes cachées 0x0710 / 0x0712 via Web Serial
+ +
+ Radio sacrifiable uniquement. Nécessite un firmware compilé avec + -DENABLE_FEAT_F4HWN_MULTIBOOT=ON. Le bootloader d'usine n'est jamais touché : + en cas d'échec du restore, re-flasher via USB/DFU dans UV Studio. + Ferme UV Studio / K5Viewer avant de te connecter (le port série ne peut être ouvert qu'une fois). +
+ +
+ Web Serial indisponible. Utilise Chrome, Edge, Brave ou Opera sur ordinateur. + Si tu as ouvert ce fichier en file:// et que ça ne marche pas, sers-le en local : + python -m http.server puis ouvre http://localhost:8000/tools/mb_test.html. +
+ +
+
+ + + + déconnecté +
+
+ +
+
    +
  1. Backup — copie l'app interne vers le slot 0 externe (sans danger). Attends l'ack.
  2. +
  3. Optionnel : dumpe le slot 0 (0x020000) dans UV Studio pour vérifier.
  4. +
  5. Restore + reset — reprogramme l'interne depuis le slot 0. Succès = la radio reboote sur le même firmware.
  6. +
+
+ + +
+
+ + + + +
+
+ Corrompre → le prochain Restore doit être refusé (CRC32). Valider → contrôle header + CRC32 sans rien reflasher, renvoie le verdict et le CRC calculé (ne peut pas se bloquer). +
+
+ +
+
+ Journal + +
+
+
+
+ + + + From a2551d97dc84d0c4a3fbd524e62d2c4ecfe8a2e3 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Tue, 11 Aug 2026 17:24:00 +0200 Subject: [PATCH 02/73] Improve mb --- App/app/uart.c | 108 ------------------------------------- App/driver/mb_flash.c | 122 +----------------------------------------- App/driver/mb_flash.h | 61 --------------------- App/driver/py25q16.c | 9 ++-- App/ui/multiboot.c | 81 +++++++++++++++++++++------- 5 files changed, 67 insertions(+), 314 deletions(-) diff --git a/App/app/uart.c b/App/app/uart.c index 832c7b51..728c16e6 100644 --- a/App/app/uart.c +++ b/App/app/uart.c @@ -881,114 +881,6 @@ void UART_HandleCommand(uint32_t Port) break; #ifdef ENABLE_FEAT_F4HWN_MULTIBOOT - // Multiboot self-test (developer only, gated build). - case 0x0710: // backup: internal application -> external flash slot 0 - { - uint32_t size = 0, crc = 0; - MB_BackupToSlot0(&size, &crc); - struct __attribute__((packed)) { - Header_t Header; - uint32_t Size; - uint32_t Crc32; - } Reply; - Reply.Header.ID = 0x0711; - Reply.Header.Size = 8; - Reply.Size = size; - Reply.Crc32 = crc; - SendReply(Port, &Reply, sizeof(Reply)); // ack once the backup completed - break; - } - - case 0x0712: // restore: external flash slot 0 -> internal application + reset - { - // Returns only if validation failed (internal flash untouched); - // on success it reflashes and resets, so control never comes back. - uint8_t err = MB_RestoreSlot0(); - struct __attribute__((packed)) { - Header_t Header; - uint8_t Code; - } Reply; - Reply.Header.ID = 0x0713; - Reply.Header.Size = 1; - Reply.Code = err; - SendReply(Port, &Reply, sizeof(Reply)); // restore refused, report why - break; - } - - case 0x0714: // test: corrupt slot 0 image (exercise the CRC-refusal path) - { - uint16_t n = pUART_Command->Header.Size; - if (n > 128) - n = 128; - MB_CorruptSlot0(pUART_Command->Data, n); - struct __attribute__((packed)) { - Header_t Header; - uint16_t Count; - } Reply; - Reply.Header.ID = 0x0715; - Reply.Header.Size = 2; - Reply.Count = n; - SendReply(Port, &Reply, sizeof(Reply)); // ack: bytes corrupted - break; - } - - case 0x0716: // validate slot 0 (no reflash): report result code + computed CRC - { - uint32_t crc = 0; - uint8_t code = MB_ValidateSlot0(&crc); - struct __attribute__((packed)) { - Header_t Header; - uint32_t Crc32; // 4-byte aligned (offset 4): no unaligned write - uint8_t Code; - } Reply; - Reply.Header.ID = 0x0717; - Reply.Header.Size = 5; - Reply.Crc32 = crc; - Reply.Code = code; - SendReply(Port, &Reply, sizeof(Reply)); - break; - } - - case 0x0718: // ground-truth dump of external flash via the DMA driver - { - uint32_t addr = (uint32_t)pUART_Command->Data[0] - | ((uint32_t)pUART_Command->Data[1] << 8) - | ((uint32_t)pUART_Command->Data[2] << 16) - | ((uint32_t)pUART_Command->Data[3] << 24); - uint8_t len = pUART_Command->Data[4]; - if (len > 64) - len = 64; - struct __attribute__((packed)) { - Header_t Header; - uint32_t Addr; - uint8_t Len; - uint8_t Data[64]; - } Reply; - MB_DumpExt(addr, Reply.Data, len); - Reply.Header.ID = 0x0719; - Reply.Header.Size = 5 + len; // Addr(4) + Len(1) + data(len) - Reply.Addr = addr; - Reply.Len = len; - SendReply(Port, &Reply, sizeof(Header_t) + 5 + len); - break; - } - - case 0x071C: // diagnostic: snapshot SPI2 / DMA state (no flash access) - { - uint32_t st[4]; - MB_SpiState(st); - struct __attribute__((packed)) { - Header_t Header; - uint32_t V[4]; - } Reply; - Reply.Header.ID = 0x071D; - Reply.Header.Size = 16; - Reply.V[0] = st[0]; Reply.V[1] = st[1]; - Reply.V[2] = st[2]; Reply.V[3] = st[3]; - SendReply(Port, &Reply, sizeof(Reply)); - break; - } - // ---- M4 slot management ("Firmware Slots") ------------------------ case 0x0720: // slot info: read the 64-byte header only (fast, no CRC) { diff --git a/App/driver/mb_flash.c b/App/driver/mb_flash.c index 44453cd6..52abd97a 100644 --- a/App/driver/mb_flash.c +++ b/App/driver/mb_flash.c @@ -13,15 +13,11 @@ * limitations under the License. */ -#include +#include #include "driver/mb_flash.h" #include "py32f0xx.h" -#include "driver/py25q16.h" -#include "driver/st7565.h" -#include "ui/helper.h" -#include "version.h" /* Internal-flash program/erase keys (FLASH_KEY1 / FLASH_KEY2). */ #define MB_FLASH_KEY1 0x45670123u @@ -62,33 +58,6 @@ static const uint32_t mb_flash_timing[8] = { 0x1FFF32D8, 0x1FFF3238, 0x1FFF3238, 0x1FFF3238 }; -/* -------------------------------------------------------------------------- */ -/* Helpers (flash-resident). */ -/* -------------------------------------------------------------------------- */ - -/* zlib/PNG CRC-32 (poly 0xEDB88320), streaming. Seed 'crc' with 0xFFFFFFFF and - * XOR the final result with 0xFFFFFFFF. No lookup table (saves flash). */ -static uint32_t mb_crc32_update(uint32_t crc, const uint8_t *data, uint32_t len) -{ - while (len--) - { - crc ^= *data++; - for (int k = 0; k < 8; k++) - crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u))); - } - return crc; -} - -static void mb_copy_str(char *dst, uint32_t cap, const char *src) -{ - uint32_t i = 0; - if (src) - for (; i + 1 < cap && src[i]; i++) - dst[i] = src[i]; - for (; i < cap; i++) - dst[i] = 0; -} - /* -------------------------------------------------------------------------- */ /* Flash-resident preparation (runs while the flash is still readable). */ /* -------------------------------------------------------------------------- */ @@ -378,39 +347,6 @@ fatal_reset: /* Public API. */ /* -------------------------------------------------------------------------- */ -void MB_BackupToSlot0(uint32_t *out_size, uint32_t *out_crc32) -{ - const uint32_t imgBase = MB_SLOT0_EXT_BASE + MB_SLOT_IMG_OFFSET; - const uint32_t size = MB_INT_APP_SIZE; /* full region (self-test) */ - - /* CRC-32 of the internal image (memory-mapped, contiguous read). */ - uint32_t crc = mb_crc32_update(0xFFFFFFFFu, (const uint8_t *)MB_INT_APP_BASE, size) - ^ 0xFFFFFFFFu; - - /* Write the image first ... */ - PY25Q16_WriteBuffer(imgBase, (const void *)MB_INT_APP_BASE, size, false); - - /* ... then a valid header (COMMITTED) last, so a committed header always - * implies a fully written image. */ - mb_slot_header_t hdr; - memset(&hdr, 0, sizeof(hdr)); - hdr.magic = MB_SLOT_MAGIC; - hdr.hdr_version = MB_HDR_VERSION; - hdr.flags = MB_FLAG_COMMITTED; - hdr.image_size = size; - hdr.image_crc32 = crc; -#ifdef ENABLE_FEAT_F4HWN - mb_copy_str(hdr.name, MB_NAME_LEN, Edition); -#else - mb_copy_str(hdr.name, MB_NAME_LEN, "slot0"); -#endif - mb_copy_str(hdr.fw_version, MB_VERSION_LEN, Version); - PY25Q16_WriteBuffer(MB_SLOT0_EXT_BASE, &hdr, sizeof(hdr), false); - - if (out_size) *out_size = size; - if (out_crc32) *out_crc32 = crc; -} - /* Set when a polled SPI wait below times out (external flash unresponsive). */ static volatile int mb_spi_err; @@ -674,11 +610,6 @@ uint8_t MB_ValidateSlot(uint8_t slot, mb_slot_header_t *out_header, uint32_t *ou return mb_validate(slot, out_header ? out_header : &local, out_crc); } -uint8_t MB_ValidateSlot0(uint32_t *out_crc) -{ - return MB_ValidateSlot(0, NULL, out_crc); -} - uint8_t MB_RestoreSlot(uint8_t slot, uint8_t *progress_line) { mb_slot_header_t hdr; @@ -701,11 +632,6 @@ uint8_t MB_RestoreSlot(uint8_t slot, uint8_t *progress_line) return MB_OK; /* not reached */ } -uint8_t MB_RestoreSlot0(void) -{ - return MB_RestoreSlot(0, NULL); -} - /* -------------------------------------------------------------------------- */ /* M4 slot management (host tool). External flash only - never brick-critical.*/ /* -------------------------------------------------------------------------- */ @@ -750,49 +676,3 @@ uint8_t MB_SlotWrite(uint8_t slot, uint32_t offset, const uint8_t *data, uint32_ return MB_OK; } - -/* Middle of slot 0's image (32 KiB in, well within the 118 KiB image body). */ -#define MB_CORRUPT_OFFSET 0x8000u -#define MB_CORRUPT_MAX 128u - -void MB_CorruptSlot0(const uint8_t *data, uint32_t len) -{ - if (!data || len == 0) - return; - if (len > MB_CORRUPT_MAX) - len = MB_CORRUPT_MAX; - - /* Constrained to slot 0's image body: this address range cannot reach the - * slot header, nor calibration / EEPROM / RF log / voice regions. */ - PY25Q16_WriteBuffer(MB_SLOT0_EXT_BASE + MB_SLOT_IMG_OFFSET + MB_CORRUPT_OFFSET, - data, len, false); -} - -volatile uint8_t mb_mark_on = 0; - -void MB_Mark(const char *s) -{ - if (!mb_mark_on) - return; - memset(gFrameBuffer, 0, sizeof(gFrameBuffer)); - UI_PrintStringSmallNormal(s, 10, 0, 3); - ST7565_BlitFullScreen(); -} - -void MB_DumpExt(uint32_t addr, uint8_t *buf, uint32_t len) -{ - /* Trace the driver read step by step on the LCD so a freeze reveals where. */ - mb_mark_on = 1; - MB_Mark("DUMP begin"); - PY25Q16_ReadBufferSafe(addr, buf, len); - MB_Mark("DUMP done"); - mb_mark_on = 0; -} - -void MB_SpiState(uint32_t out[4]) -{ - out[0] = SPI2->CR1; /* bit 6 (SPE) = SPI enabled */ - out[1] = SPI2->CR2; /* bit0 RXDMAEN, bit1 TXDMAEN */ - out[2] = SPI2->SR; /* bit0 RXNE, bit1 TXE, bit7 BSY */ - out[3] = DMA1_Channel4->CCR; /* SPI2 RX DMA channel (bit0 EN) */ -} diff --git a/App/driver/mb_flash.h b/App/driver/mb_flash.h index 90a7e5a1..0a4afe8d 100644 --- a/App/driver/mb_flash.h +++ b/App/driver/mb_flash.h @@ -80,25 +80,6 @@ enum { MB_ERR_AUTH /* write refused: timestamp mismatch */ }; -/* - * Copy the live internal application image into external flash slot 0, writing - * the image first and then a valid header (COMMITTED) last. Runs entirely from - * flash and only writes the external SPI flash, so it is safe (no brick risk). - * Reports the stored image size and CRC-32 through the (optional) out params. - */ -void MB_BackupToSlot0(uint32_t *out_size, uint32_t *out_crc32); - -/* - * Validate slot 0 and, if valid, reflash the internal application from it and - * reset. Validation (magic, version, COMMITTED flag, size, image CRC-32) runs - * from flash *before* any erase: on failure it returns an MB_ERR_* code and the - * internal flash is left untouched. On success it NEVER RETURNS (the RAM-resident - * copier reflashes the internal application and triggers a system reset). The - * factory bootloader is never touched, so an interrupted copy is recoverable - * over USB/DFU. - */ -uint8_t MB_RestoreSlot0(void); - /* Multi-slot API used by the boot selector. Validation always covers the full * image CRC before restore. progress_line may point to a 128-byte LCD page; the * RAM copier then fills it while reflashing. Pass NULL to disable LCD updates. */ @@ -123,46 +104,4 @@ uint8_t MB_SlotInfo(uint8_t slot, mb_slot_header_t *out_header); uint8_t MB_SlotErase(uint8_t slot); uint8_t MB_SlotWrite(uint8_t slot, uint32_t offset, const uint8_t *data, uint32_t len); - -/* - * Test helper: overwrite up to `len` (capped) bytes in the MIDDLE of slot 0's - * image with the supplied data, so the next MB_RestoreSlot0() fails its CRC-32 - * check and refuses. It writes ONLY inside slot 0's image body - never the - * header, and never anything outside the slot (calibration, EEPROM, RF log...). - * Developer aid to exercise the safe-refusal path without external tooling. - */ -void MB_CorruptSlot0(const uint8_t *data, uint32_t len); - -/* - * Validate slot 0 (header + image CRC-32) WITHOUT reflashing. Same checks as the - * restore path, entirely via polled reads (cannot hang). Returns MB_OK or an - * MB_ERR_* code, and reports the computed image CRC-32 through out_crc. - * Useful as a safe diagnostic from the host tool. - */ -uint8_t MB_ValidateSlot0(uint32_t *out_crc); - -/* - * Read `len` bytes of external flash at `addr` into `buf`, via the DMA driver - * (PY25Q16_ReadBuffer) - the same proven read path the backup uses internally. - * Ground-truth diagnostic to check what is actually stored in a slot. - */ -void MB_DumpExt(uint32_t addr, uint8_t *buf, uint32_t len); - -/* - * Diagnostic: snapshot the SPI2 / DMA hardware state WITHOUT any flash access - * (so it cannot hang). out[0]=SPI2->CR1, [1]=SPI2->SR, [2]=DMA RD chan CCR, - * [3]=DMA WR chan CCR. Reveals whether SPI2 is disabled/busy or a DMA channel - * is left armed when a read command runs. - */ -void MB_SpiState(uint32_t out[4]); - -/* - * On-screen trace marker (debug). Draws `s` on the LCD (SPI1, independent of the - * flash SPI2) so that when a flash read freezes the CPU, the frozen screen shows - * the last step reached. Only draws while mb_mark_on is set (i.e. during a Dump), - * so it does not flash the screen during normal writes. - */ -extern volatile uint8_t mb_mark_on; -void MB_Mark(const char *s); - #endif /* DRIVER_MB_FLASH_H */ diff --git a/App/driver/py25q16.c b/App/driver/py25q16.c index 2bc38221..876cdb53 100644 --- a/App/driver/py25q16.c +++ b/App/driver/py25q16.c @@ -27,12 +27,9 @@ #include "external/printf/printf.h" #include "misc.h" -#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT - #include "driver/mb_flash.h" - #define MBMARK(s) MB_Mark(s) -#else - #define MBMARK(s) -#endif +/* MBMARK was an on-screen SPI trace used while bringing up multiboot (M1/M2). + * The tracer is gone; keep the call sites as no-ops. */ +#define MBMARK(s) // #define DEBUG diff --git a/App/ui/multiboot.c b/App/ui/multiboot.c index 4de22c38..eb8580df 100644 --- a/App/ui/multiboot.c +++ b/App/ui/multiboot.c @@ -89,11 +89,37 @@ static void mb_format_slot_label(char *dst, uint8_t cap, const mb_slot_header_t dst[n] = 0; } +/* "MULTIBOOT" mode banner in the top status bar, shown on every screen - the + * same way the firmware puts mode labels there (inverse 3x5 capsule). */ +static void mb_status_bar(void) +{ + UI_StatusClear(); + GUI_DisplaySmallestInverse("MULTIBOOT", 47, 0, true, true, 83); +} + +/* Bottom key-hint line: each key name as an inverse 3x5 capsule label, its + * action in plain 3x5 text beside it. Drawn on the bottom line + * (gFrameBuffer[6] -> y = 6*8+1 = 49). MENU is pinned to the left and EXIT to + * the right, leaving an airy gap in the middle. "MENU"/"EXIT" are 4 chars + * (16 px); their capsule spans [x-2 .. x+16]. */ +static void mb_key_hints(const char *act_menu, const char *act_exit) +{ + const uint8_t sp = 6u; /* label <-> action gap */ + const uint8_t ae = (uint8_t)strlen(act_exit); + const uint8_t xm = 4u; /* MENU text; capsule at x=2 */ + const uint8_t xe = (uint8_t)(124u - ae * 4u - sp - 16u); /* EXIT action ends at x=124 */ + + GUI_DisplaySmallestInverse("MENU", xm, 6, false, true, (uint8_t)(xm + 16u)); + GUI_DisplaySmallest(act_menu, (uint8_t)(xm + 16u + sp), 49, false, true); + + GUI_DisplaySmallestInverse("EXIT", xe, 6, false, true, (uint8_t)(xe + 16u)); + GUI_DisplaySmallest(act_exit, (uint8_t)(xe + 16u + sp), 49, false, true); +} + static void mb_show_message(const char *line1, const char *line2, const char *line3) { UI_DisplayClear(); - UI_StatusClear(); - UI_PrintStringSmallNormal("MultiBoot", 2, 126, 0); + mb_status_bar(); if (line1) UI_PrintStringSmallNormal(line1, 2, 126, 2); if (line2) UI_PrintStringSmallNormal(line2, 2, 126, 4); if (line3) UI_PrintStringSmallNormal(line3, 2, 126, 6); @@ -147,14 +173,15 @@ static void mb_render_slots(uint8_t selected, char line[19]; /* 18 glyphs max: 18 * 7 px fits from x=2 to x=126. */ UI_DisplayClear(); - UI_StatusClear(); - UI_PrintStringSmallNormal("MultiBoot", 2, 126, 0); + mb_status_bar(); for (uint8_t slot = 0; slot < MB_SLOT_COUNT; slot++) { + const uint8_t fbLine = (uint8_t)(slot + 1u); /* page 1 stays blank */ + memset(line, 0, sizeof(line)); - line[0] = (slot == selected) ? '>' : ' '; - line[1] = (char)('0' + slot); + line[0] = (char)('0' + slot); + line[1] = ' '; line[2] = ' '; if (status[slot] == MB_OK) @@ -162,25 +189,30 @@ static void mb_render_slots(uint8_t selected, else mb_copy_label(&line[3], sizeof(line) - 3u, mb_error_text(status[slot]), 20u); - UI_PrintStringSmallNormal(line, 2, 0, (uint8_t)(slot + 1u)); + UI_PrintStringSmallNormal(line, 2, 0, fbLine); + + /* Selected row: full-width inverse bar, like the firmware menu list. */ + if (slot == selected) + for (uint8_t x = 0; x < LCD_WIDTH; x++) + gFrameBuffer[fbLine][x] ^= 0xFFu; } - UI_PrintStringSmallNormal("MENU to select", 2, 126, 5); - UI_PrintStringSmallNormal("EXIT to go back", 2, 126, 6); + mb_key_hints("SELECT", "QUIT"); + ST7565_BlitStatusLine(); ST7565_BlitFullScreen(); } static void mb_prepare_progress(uint8_t slot) { - char title[] = "RESTORE SLOT 0"; + char title[] = "Restore slot 0"; title[13] = (char)('0' + slot); UI_DisplayClear(); - UI_StatusClear(); - UI_PrintStringSmallNormal(title, 2, 126, 0); - UI_PrintStringSmallNormal("DO NOT POWER OFF", 2, 126, 2); - UI_PrintStringSmallNormal("Writing & Verify", 2, 126, 4); + mb_status_bar(); + UI_PrintStringSmallNormal(title, 2, 126, 1); + UI_PrintStringSmallNormal("DO NOT POWER OFF", 2, 126, 3); + UI_PrintStringSmallNormal("Writing & Verify", 2, 126, 5); /* Same rounded outline and hatch pattern as the scan progress gauge. */ gFrameBuffer[6][3] = 0x0Cu; @@ -193,14 +225,29 @@ static void mb_prepare_progress(uint8_t slot) ST7565_BlitFullScreen(); } +static void mb_confirm_screen(uint8_t slot) +{ + char title[] = "Restore slot 0?"; + title[13] = (char)('0' + slot); + + UI_DisplayClear(); + mb_status_bar(); + UI_PrintStringSmallNormal(title, 2, 126, 3); + mb_key_hints("CONFIRM", "BACK"); + ST7565_BlitStatusLine(); + ST7565_BlitFullScreen(); +} + void UI_MultibootSelector(void) { mb_slot_header_t headers[MB_SLOT_COUNT]; uint8_t status[MB_SLOT_COUNT]; uint8_t selected = 0; - BACKLIGHT_TurnOn(); + /* Clear + blit the LCD BEFORE the backlight comes on, otherwise it reveals + * the random power-on contents of the display RAM for a moment. */ mb_show_message("Release keys", NULL, NULL); + BACKLIGHT_TurnOn(); mb_wait_release(); mb_scan_slots(headers, status); @@ -247,9 +294,7 @@ void UI_MultibootSelector(void) continue; } - char confirm[] = "Restore slot 0?"; - confirm[13] = (char)('0' + selected); - mb_show_message(confirm, "MENU to confirm", "EXIT to cancel"); + mb_confirm_screen(selected); key = mb_get_key(); if (key != KEY_MENU) continue; From 919a687936722d8d36fca9ca5000ff2cdf5819e6 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Wed, 12 Aug 2026 03:25:53 +0200 Subject: [PATCH 03/73] Refine multiboot presets, UI and build output --- App/ui/multiboot.c | 89 ++++++++++------- CMakePresets.json | 171 ++++++++++----------------------- README.md | 50 ++++++---- compile-with-docker.sh | 212 +++++++++++++++++++++++++++++++++++------ 4 files changed, 318 insertions(+), 204 deletions(-) diff --git a/App/ui/multiboot.c b/App/ui/multiboot.c index eb8580df..c270c72e 100644 --- a/App/ui/multiboot.c +++ b/App/ui/multiboot.c @@ -41,52 +41,32 @@ static void mb_copy_label(char *dst, uint8_t cap, const char *src, uint8_t src_c dst[n] = 0; } -static void mb_format_slot_label(char *dst, uint8_t cap, const mb_slot_header_t *header) +static uint8_t mb_copy_slot_version(char *dst, uint8_t cap, const mb_slot_header_t *header) { - const char *version = NULL; - uint8_t version_cap = 0; - uint8_t version_len = 0; - uint8_t name_limit = cap - 1u; uint8_t n = 0; - if (!header->name[0]) - { - mb_copy_label(dst, cap, header->fw_version, MB_VERSION_LEN); - return; - } - + dst[0] = 0; for (uint8_t i = 0; i + 1u < MB_VERSION_LEN && header->fw_version[i]; i++) { if (header->fw_version[i] == 'v' && header->fw_version[i + 1u] >= '0' && header->fw_version[i + 1u] <= '9') { - version = &header->fw_version[i + 1u]; - version_cap = MB_VERSION_LEN - i - 1u; + i++; + while (n + 1u < cap && i < MB_VERSION_LEN) + { + const char c = header->fw_version[i]; + if ((c < '0' || c > '9') && c != '.') + break; + dst[n++] = c; + i++; + } break; } } - if (version) - { - while (version_len < version_cap && version[version_len]) - version_len++; - if (version_len + 1u < cap) - name_limit = cap - version_len - 2u; - } - - while (n < name_limit && n < MB_NAME_LEN && header->name[n]) - { - dst[n] = header->name[n]; - n++; - } - if (version && n + version_len + 1u < cap) - { - dst[n++] = ' '; - for (uint8_t i = 0; i < version_len; i++) - dst[n++] = version[i]; - } dst[n] = 0; + return n; } /* "MULTIBOOT" mode banner in the top status bar, shown on every screen - the @@ -116,6 +96,17 @@ static void mb_key_hints(const char *act_menu, const char *act_exit) GUI_DisplaySmallest(act_exit, (uint8_t)(xe + 16u + sp), 49, false, true); } +static void mb_invert_rounded_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; +} + static void mb_show_message(const char *line1, const char *line2, const char *line3) { UI_DisplayClear(); @@ -171,6 +162,7 @@ static void mb_render_slots(uint8_t selected, const uint8_t status[MB_SLOT_COUNT]) { char line[19]; /* 18 glyphs max: 18 * 7 px fits from x=2 to x=126. */ + char version[MB_VERSION_LEN]; UI_DisplayClear(); mb_status_bar(); @@ -178,23 +170,48 @@ static void mb_render_slots(uint8_t selected, for (uint8_t slot = 0; slot < MB_SLOT_COUNT; slot++) { const uint8_t fbLine = (uint8_t)(slot + 1u); /* page 1 stays blank */ + uint8_t version_len = 0; + uint8_t version_x = 0; memset(line, 0, sizeof(line)); + memset(version, 0, sizeof(version)); line[0] = (char)('0' + slot); line[1] = ' '; line[2] = ' '; if (status[slot] == MB_OK) - mb_format_slot_label(&line[3], sizeof(line) - 3u, &headers[slot]); + { + uint8_t name_cap = sizeof(line) - 3u; + + version_len = mb_copy_slot_version(version, sizeof(version), &headers[slot]); + if (version_len) + { + const uint8_t name_x = 2u + 3u * 7u; + uint8_t available; + + version_x = (uint8_t)(LCD_WIDTH - 2u - version_len * 7u); + available = version_x > name_x + ? (uint8_t)((version_x - name_x) / 7u) + : 0u; + if (available + 1u < name_cap) + name_cap = available + 1u; + } + + if (headers[slot].name[0]) + mb_copy_label(&line[3], name_cap, headers[slot].name, MB_NAME_LEN); + else if (!version_len) + mb_copy_label(&line[3], name_cap, headers[slot].fw_version, MB_VERSION_LEN); + } else mb_copy_label(&line[3], sizeof(line) - 3u, mb_error_text(status[slot]), 20u); UI_PrintStringSmallNormal(line, 2, 0, fbLine); + if (version_len) + UI_PrintStringSmallNormal(version, version_x, 0, fbLine); - /* Selected row: full-width inverse bar, like the firmware menu list. */ + /* Selected row: full-width rounded inverse capsule. */ if (slot == selected) - for (uint8_t x = 0; x < LCD_WIDTH; x++) - gFrameBuffer[fbLine][x] ^= 0xFFu; + mb_invert_rounded_row(fbLine); } mb_key_hints("SELECT", "QUIT"); diff --git a/CMakePresets.json b/CMakePresets.json index f8e8a7bc..08968372 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -99,99 +99,6 @@ "TARGET": "f4hwn.custom" } }, - { - "name": "Bandscope", - "inherits": "default", - "cacheVariables": { - "ENABLE_SPECTRUM": true, - "ENABLE_FMRADIO": false, - "ENABLE_VOX": false, - "ENABLE_AIRCOPY": true, - "ENABLE_FEAT_F4HWN_K5VIEWER": true, - "ENABLE_FEAT_F4HWN_GAME": false, - "ENABLE_FEAT_F4HWN_PMR": true, - "ENABLE_FEAT_F4HWN_GMRS_FRS_MURS": true, - "ENABLE_FEAT_F4HWN_RESCUE_OPS": false, - "EDITION_STRING": "Bandscope", - "TARGET": "f4hwn.bandscope" - } - }, - { - "name": "Broadcast", - "inherits": "default", - "cacheVariables": { - "ENABLE_SPECTRUM": false, - "ENABLE_FMRADIO": true, - "ENABLE_VOX": true, - "ENABLE_AIRCOPY": true, - "ENABLE_FEAT_F4HWN_K5VIEWER": true, - "ENABLE_FEAT_F4HWN_GAME": false, - "ENABLE_FEAT_F4HWN_PMR": true, - "ENABLE_FEAT_F4HWN_GMRS_FRS_MURS": true, - "ENABLE_FEAT_F4HWN_RESCUE_OPS": false, - "EDITION_STRING": "Broadcast", - "TARGET": "f4hwn.broadcast" - } - }, - { - "name": "Basic", - "inherits": "default", - "cacheVariables": { - "ENABLE_SPECTRUM": true, - "ENABLE_FMRADIO": true, - "ENABLE_VOX": false, - "ENABLE_AIRCOPY": false, - "ENABLE_FEAT_F4HWN_K5VIEWER": false, - "ENABLE_FEAT_F4HWN_GAME": false, - "ENABLE_FEAT_F4HWN_SPECTRUM": false, - "ENABLE_FEAT_F4HWN_PMR": true, - "ENABLE_FEAT_F4HWN_GMRS_FRS_MURS": true, - "ENABLE_AUDIO_BAR": false, - "ENABLE_FEAT_F4HWN_RESUME_STATE": false, - "ENABLE_FEAT_F4HWN_CHARGING_C": false, - "ENABLE_FEAT_F4HWN_INV": true, - "ENABLE_FEAT_F4HWN_CTR": false, - "ENABLE_FEAT_F4HWN_NARROWER": true, - "ENABLE_FEAT_F4HWN_RESCUE_OPS": false, - "EDITION_STRING": "Basic", - "TARGET": "f4hwn.basic" - } - }, - { - "name": "RescueOps", - "inherits": "default", - "cacheVariables": { - "ENABLE_SPECTRUM": false, - "ENABLE_FMRADIO": false, - "ENABLE_VOX": true, - "ENABLE_AIRCOPY": true, - "ENABLE_FEAT_F4HWN_K5VIEWER": true, - "ENABLE_FEAT_F4HWN_GAME": false, - "ENABLE_FEAT_F4HWN_PMR": true, - "ENABLE_FEAT_F4HWN_GMRS_FRS_MURS": true, - "ENABLE_NOAA": true, - "ENABLE_FEAT_F4HWN_RESCUE_OPS": true, - "EDITION_STRING": "RescueOps", - "TARGET": "f4hwn.rescueops" - } - }, - { - "name": "Game", - "inherits": "default", - "cacheVariables": { - "ENABLE_SPECTRUM": false, - "ENABLE_FMRADIO": true, - "ENABLE_VOX": false, - "ENABLE_AIRCOPY": true, - "ENABLE_FEAT_F4HWN_K5VIEWER": true, - "ENABLE_FEAT_F4HWN_GAME": true, - "ENABLE_FEAT_F4HWN_PMR": true, - "ENABLE_FEAT_F4HWN_GMRS_FRS_MURS": true, - "ENABLE_FEAT_F4HWN_RESCUE_OPS": false, - "EDITION_STRING": "Game", - "TARGET": "f4hwn.game" - } - }, { "name": "Fusion", "inherits": "default", @@ -199,12 +106,12 @@ "ENABLE_SPECTRUM": true, "ENABLE_FMRADIO": true, "ENABLE_VOX": true, - "ENABLE_AIRCOPY": true, + "ENABLE_AIRCOPY": false, "ENABLE_FEAT_F4HWN_K5VIEWER": true, - "ENABLE_FEAT_F4HWN_GAME": true, + "ENABLE_FEAT_F4HWN_GAME": false, "ENABLE_FEAT_F4HWN_PMR": true, "ENABLE_FEAT_F4HWN_GMRS_FRS_MURS": true, - "ENABLE_FEAT_F4HWN_RESCUE_OPS": true, + "ENABLE_FEAT_F4HWN_RESCUE_OPS": false, "ENABLE_FEAT_F4HWN_VOL": true, "ENABLE_FEAT_F4HWN_AUDIO": true, "ENABLE_FEAT_F4HWN_AUDIO_SCOPE": true, @@ -212,9 +119,8 @@ "ENABLE_FEAT_F4HWN_SCAN_FASTER": true, "ENABLE_FEAT_F4HWN_SCAN_RSSI": true, "ENABLE_FEAT_F4HWN_SCAN_SUBAUDIBLE": true, - "ENABLE_FEAT_F4HWN_BEAM": true, - "ENABLE_FEAT_F4HWN_FOXHUNT": true, - "ENABLE_FEAT_F4HWN_ACTION_PICKER": true, + "ENABLE_FEAT_F4HWN_BEAM": false, + "ENABLE_FEAT_F4HWN_FOXHUNT": false, "ENABLE_FEAT_F4HWN_RXTX_LOG": true, "ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP": false, "ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER": true, @@ -223,10 +129,45 @@ "ENABLE_FEAT_F4HWN_LOGO": true, "ENABLE_FEAT_F4HWN_LOGO_SAV": true, "ENABLE_FEAT_F4HWN_MENU_CAT": true, + "ENABLE_FEAT_F4HWN_ACTION_PICKER": true, + "ENABLE_FEAT_F4HWN_MULTIBOOT": true, "ENABLE_SWD": true, "EDITION_STRING": "Fusion", "TARGET": "f4hwn.fusion" } + }, + { + "name": "Transfer", + "inherits": "Fusion", + "cacheVariables": { + "ENABLE_AIRCOPY": true, + "ENABLE_FEAT_F4HWN_BEAM": true, + "EDITION_STRING": "Transfer", + "TARGET": "f4hwn.transfer" + } + }, + { + "name": "Field", + "inherits": "Fusion", + "cacheVariables": { + "ENABLE_FEAT_F4HWN_RESCUE_OPS": true, + "ENABLE_FEAT_F4HWN_FOXHUNT": true, + "EDITION_STRING": "Field", + "TARGET": "f4hwn.field" + } + }, + { + "name": "Extended", + "inherits": "Fusion", + "cacheVariables": { + "ENABLE_AIRCOPY": true, + "ENABLE_FEAT_F4HWN_GAME": true, + "ENABLE_FEAT_F4HWN_RESCUE_OPS": true, + "ENABLE_FEAT_F4HWN_BEAM": true, + "ENABLE_FEAT_F4HWN_FOXHUNT": true, + "EDITION_STRING": "Extended", + "TARGET": "f4hwn.extended" + } } ], "buildPresets": [ @@ -234,29 +175,21 @@ "name": "Custom", "configurePreset": "Custom" }, - { - "name": "Bandscope", - "configurePreset": "Bandscope" - }, - { - "name": "Broadcast", - "configurePreset": "Broadcast" - }, - { - "name": "Basic", - "configurePreset": "Basic" - }, - { - "name": "RescueOps", - "configurePreset": "RescueOps" - }, - { - "name": "Game", - "configurePreset": "Game" - }, { "name": "Fusion", "configurePreset": "Fusion" + }, + { + "name": "Transfer", + "configurePreset": "Transfer" + }, + { + "name": "Field", + "configurePreset": "Field" + }, + { + "name": "Extended", + "configurePreset": "Extended" } ] } diff --git a/README.md b/README.md index c7b353b1..9ec495af 100644 --- a/README.md +++ b/README.md @@ -59,18 +59,23 @@ Special thanks to Jean-Cyrille F6IWW (3 times), Fabrice 14RC123, David F4BPP, Ol ### Fusion edition -Fusion is the reference edition of the project. It provides an all-in-one firmware for the UV-K1 and UV-K5 V3, including: +Fusion is the generic reference edition for the UV-K1 and UV-K5 V3. It is intended +for everyday use and is the base inherited by the specialized editions. It includes: - Fagci's spectrum analyzer, -- broadcast FM radio, -- VOX and AirCopy, -- BEAM wireless channel transfer, +- broadcast FM radio and VOX, - [UV Studio](https://armel.github.io/uvstudio/) with integrated K5Viewer screen mirroring, screenshots and remote keyboard control, - advanced RX audio profiles and Audio Scope, -- first-responder-oriented controls, - the Breakout game, - automatic RX/TX activity logging with RF Log, -- full Fox Hunt and Morse Beacon support. +- multiboot support. + +Specialized presets extend Fusion for specific uses: + +- **Transfer** adds AirCopy and BEAM wireless channel transfer. +- **Field** adds first-responder controls, Fox Hunt and Morse Beacon support. +- **Extended** combines the Transfer and Field feature sets without claiming to include every optional feature. +- **Custom** remains a manually configured build based directly on the hidden technical default. ### Radio and signal handling @@ -324,9 +329,10 @@ 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 to compile the Fusion firmware for the UV-K1 and UV-K5 V3. Everything is handled through the `compile-with-docker.sh` helper script. - -The documented build output is generated inside `build/Fusion`, using the CMake presets defined in `CMakePresets.json`. +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 +the default generic preset, while specialized builds are generated in their own +`build/` directories. ### Prerequisites @@ -335,29 +341,37 @@ The documented build output is generated inside `build/Fusion`, using the CMake ### Build Script Overview -The script `compile-with-docker.sh` performs the following actions: +The script `compile-with-docker.sh`: 1. Builds the Docker image (`uvk1-uvk5v3`) if it does not already exist. -2. Removes any previous `build` directory to ensure a clean configuration. -3. Runs CMake using the `Fusion` preset inside the Docker container. -4. Builds the firmware and outputs `.elf`, `.bin` and `.hex` files. +2. Configures the selected preset with `cmake --fresh`. +3. Builds the firmware and outputs matching `.elf`, `.bin` and `.hex` files. +4. Displays Flash and RAM usage; `All` keeps the individual build logs quiet. ### Usage ```bash -./compile-with-docker.sh Fusion [extra CMake options] +./compile-with-docker.sh [Preset] [extra CMake options] ``` -### Documented Preset +The default preset is **Fusion**. Available presets are: +- **Custom** - **Fusion** +- **Transfer** +- **Field** +- **Extended** +- **All** (Fusion, Transfer, Field and Extended) -### Examples - -Build Fusion: +Examples: ```bash +./compile-with-docker.sh ./compile-with-docker.sh Fusion +./compile-with-docker.sh Transfer +./compile-with-docker.sh Field +./compile-with-docker.sh Extended +./compile-with-docker.sh All ``` ### Passing Additional CMake Options diff --git a/compile-with-docker.sh b/compile-with-docker.sh index 369acd1a..457e6c21 100755 --- a/compile-with-docker.sh +++ b/compile-with-docker.sh @@ -5,16 +5,21 @@ set -euo pipefail # Usage: # ./compile-with-docker.sh [Preset] [CMake options...] # Examples: -# ./compile-with-docker.sh Custom -# ./compile-with-docker.sh Bandscope -DENABLE_SPECTRUM=ON -# ./compile-with-docker.sh Broadcast -DENABLE_FEAT_F4HWN_GAME=ON -DENABLE_NOAA=ON +# ./compile-with-docker.sh Fusion +# ./compile-with-docker.sh Transfer +# ./compile-with-docker.sh Field -DENABLE_NOAA=ON +# ./compile-with-docker.sh Custom -DENABLE_SPECTRUM=ON # ./compile-with-docker.sh Fusion -DDEV=ON # ./compile-with-docker.sh All -# Default preset: "Custom" +# Default preset: "Fusion" # --------------------------------------------- IMAGE=uvk1-uvk5v3 -PRESET=${1:-Custom} +RELEASE_PRESETS=(Fusion Transfer Field Extended) +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 @@ -23,53 +28,198 @@ EXTRA_ARGS=("$@") # --------------------------------------------- # Validate preset name # --------------------------------------------- -if [[ ! "$PRESET" =~ ^(Custom|Bandscope|Broadcast|Basic|RescueOps|Game|Fusion|All)$ ]]; then +if [[ ! "$PRESET" =~ ^(Custom|Fusion|Transfer|Field|Extended|All)$ ]]; then echo "❌ Unknown preset: '$PRESET'" - echo "Valid presets are: Custom, Bandscope, Broadcast, Basic, RescueOps, Game, Fusion, All" + echo "Valid presets are: Custom, Fusion, Transfer, Field, Extended, All" exit 1 fi +QUIET=0 +if [[ "$PRESET" == "All" ]]; then + QUIET=1 +fi + # --------------------------------------------- # Build the Docker image (only needed once) # --------------------------------------------- -if [[ "$(docker images -q $IMAGE)" == "" ]]; then +if [[ "$(docker images -q "$IMAGE")" == "" ]]; then echo "Building Docker image..." - docker build -t "$IMAGE" . + docker build -q -t "$IMAGE" . >/dev/null fi - -# --------------------------------------------- -# Clean existing CMake cache to ensure toolchain reload -# --------------------------------------------- -rm -rf build export MSYS_NO_PATHCONV=1 -# --------------------------------------------- -# Function to build one preset -# --------------------------------------------- + +RESULT_PRESETS=() +RESULT_FLASH_SIZES=() +RESULT_RAM_SIZES=() + +run_preset_build() { + local preset="$1" + + docker run --rm \ + -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 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 + + 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 "" - echo "=== 🚀 Building preset: ${preset} ===" - echo "---------------------------------------------" - docker run --rm \ - -u $(id -u):$(id -g) \ - -it -v "$PWD":/src -w /src "$IMAGE" \ - bash -c 'which arm-none-eabi-gcc && arm-none-eabi-gcc --version && - cmake --preset "$1" "${@:2}" && - cmake --build --preset "$1" -j' \ - bash "${preset}" ${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"} - echo "✅ Done: ${preset}" + 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 - PRESETS=(Bandscope Broadcast Basic RescueOps Game Fusion) - for p in "${PRESETS[@]}"; do + for p in "${RELEASE_PRESETS[@]}"; do build_preset "$p" done - echo "" - echo "🎉 All presets built successfully!" else build_preset "$PRESET" fi +print_summary From ca06478e243b28a746b2775a8f311a2668de015b Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Wed, 12 Aug 2026 12:40:12 +0200 Subject: [PATCH 04/73] Improve UI --- App/ui/multiboot.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/App/ui/multiboot.c b/App/ui/multiboot.c index c270c72e..1751fe27 100644 --- a/App/ui/multiboot.c +++ b/App/ui/multiboot.c @@ -69,12 +69,12 @@ static uint8_t mb_copy_slot_version(char *dst, uint8_t cap, const mb_slot_header return n; } -/* "MULTIBOOT" mode banner in the top status bar, shown on every screen - the +/* "F4HWN MULTIBOOT" banner in the top status bar, shown on every screen - the * same way the firmware puts mode labels there (inverse 3x5 capsule). */ static void mb_status_bar(void) { UI_StatusClear(); - GUI_DisplaySmallestInverse("MULTIBOOT", 47, 0, true, true, 83); + GUI_DisplaySmallestInverse("F4HWN MULTIBOOT", 34, 0, true, true, 94); } /* Bottom key-hint line: each key name as an inverse 3x5 capsule label, its From bef97f787bd0b2aae9007999f61f4fc844ba1938 Mon Sep 17 00:00:00 2001 From: EthanYan6 Date: Mon, 10 Aug 2026 12:19:45 +0800 Subject: [PATCH 05/73] FM scan ignore channel RX --- App/app/app.c | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/App/app/app.c b/App/app/app.c index bbe1ffc4..5165646f 100644 --- a/App/app/app.c +++ b/App/app/app.c @@ -312,6 +312,13 @@ static void CheckForIncoming(void) if (!g_SquelchLost) return; // squelch is closed +#ifdef ENABLE_FMRADIO + // FM scan in progress: ignore main-channel RX so scanning is not interrupted. + // Normal FM listening (FM_SCAN_OFF) still yields to channel signals as before. + if (gFmRadioMode && gFM_ScanState != FM_SCAN_OFF) + return; +#endif + #ifdef ENABLE_FEAT_F4HWN_LOGO_SAV ScreenSaverExit(); #endif @@ -444,6 +451,12 @@ static void HandleIncoming(void) } #endif +#ifdef ENABLE_FMRADIO + // Defensive: do not leave FM scan for a main-channel signal. + if (gFmRadioMode && gFM_ScanState != FM_SCAN_OFF) + return; +#endif + APP_StartListening(gMonitor ? FUNCTION_MONITOR : FUNCTION_RECEIVE); } From 295d4a2c05b7077cfb4c9568eb25e44c7999b453 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 17 Aug 2026 02:44:28 +0200 Subject: [PATCH 06/73] Change version --- CMakePresets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakePresets.json b/CMakePresets.json index 08968372..11ddae2d 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -86,7 +86,7 @@ "ENABLE_UART_RW_BK_REGS": false, "ENABLE_SWD": false, "VERSION_STRING_1": "v0.22", - "VERSION_STRING_2": "v5.9.0" + "VERSION_STRING_2": "v6.0.0" } }, { From 78f5b76a71ccd612e22679ca810ae36a99ebe773 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Tue, 18 Aug 2026 03:57:29 +0200 Subject: [PATCH 07/73] Multiboot: per-slot config profiles + restorable Main backup --- App/app/uart.c | 23 +++ App/driver/mb_flash.c | 377 +++++++++++++++++++++++++++++++++++++++++- App/driver/mb_flash.h | 131 ++++++++++++++- App/driver/py25q16.c | 46 +++++- App/driver/py25q16.h | 29 ++++ App/main.c | 12 ++ App/ui/multiboot.c | 163 ++++++++++++++++-- App/ui/multiboot.h | 8 + 8 files changed, 763 insertions(+), 26 deletions(-) diff --git a/App/app/uart.c b/App/app/uart.c index 728c16e6..49be1082 100644 --- a/App/app/uart.c +++ b/App/app/uart.c @@ -981,6 +981,29 @@ void UART_HandleCommand(uint32_t Port) SendReply(Port, &Reply, sizeof(Reply)); break; } + + case 0x0728: // profile config reset: wipe the 64 KiB config bank of a slot + { + gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s) + uint8_t slot = pUART_Command->Data[0]; + uint32_t ts = (uint32_t)pUART_Command->Data[2] + | ((uint32_t)pUART_Command->Data[3] << 8) + | ((uint32_t)pUART_Command->Data[4] << 16) + | ((uint32_t)pUART_Command->Data[5] << 24); + uint8_t status = (ts != mb_port_timestamp(Port)) + ? MB_ERR_AUTH : MB_ProfileErase(slot); + struct __attribute__((packed)) { + Header_t Header; + uint8_t Slot; + uint8_t Status; + } Reply; + Reply.Header.ID = 0x0729; + Reply.Header.Size = 2; + Reply.Slot = slot; + Reply.Status = status; + SendReply(Port, &Reply, sizeof(Reply)); + break; + } #endif #ifdef ENABLE_UART_RW_BK_REGS diff --git a/App/driver/mb_flash.c b/App/driver/mb_flash.c index 52abd97a..453636dd 100644 --- a/App/driver/mb_flash.c +++ b/App/driver/mb_flash.c @@ -14,8 +14,10 @@ */ #include +#include #include "driver/mb_flash.h" +#include "driver/py25q16.h" #include "py32f0xx.h" @@ -103,10 +105,14 @@ static void MB_PrepareInternalFlash(void) /* the linker stores in flash and the startup copies to RAM alongside .data. */ /* -------------------------------------------------------------------------- */ -/* Polled single-byte SPI2 transfer. always_inline keeps all code in .RamFunc. - * The result is returned through out so timeout and received 0xFF remain - * distinguishable. */ -__attribute__((always_inline)) static inline bool mb_ram_spi(uint8_t v, uint8_t *out) +/* These primitives are called repeatedly while application flash is offline. + * Keep one copy of each in the copied RAM section: noinline/noclone prevents + * GCC from silently duplicating one back into MB_RamReflash. */ +#define MB_RAM_HELPER __attribute__((section(".RamFunc"), noinline, noclone, used)) + +/* Polled single-byte SPI2 transfer. The result is returned through out so + * timeout and received 0xFF remain distinguishable. */ +MB_RAM_HELPER static bool mb_ram_spi(uint8_t v, uint8_t *out) { uint32_t timeout = MB_RAM_SPI_TIMEOUT; while (!(SPI2->SR & SPI_SR_TXE)) @@ -124,7 +130,7 @@ __attribute__((always_inline)) static inline bool mb_ram_spi(uint8_t v, uint8_t return true; } -__attribute__((always_inline)) static inline bool mb_ram_flash_idle(void) +MB_RAM_HELPER static bool mb_ram_flash_idle(void) { uint32_t timeout = MB_RAM_FLASH_TIMEOUT; while (FLASH->SR & FLASH_SR_BSY) @@ -133,7 +139,7 @@ __attribute__((always_inline)) static inline bool mb_ram_flash_idle(void) return true; } -__attribute__((always_inline, noreturn)) static inline void mb_ram_reset(void) +MB_RAM_HELPER __attribute__((noreturn)) static void mb_ram_reset(void) { __DSB(); @@ -144,7 +150,7 @@ __attribute__((always_inline, noreturn)) static inline void mb_ram_reset(void) /* Minimal SPI1 LCD writer. A display timeout merely disables progress updates: * it must never abort or delay the safety-critical flash copy. */ -__attribute__((always_inline)) static inline bool mb_ram_lcd_spi(uint8_t v) +MB_RAM_HELPER static bool mb_ram_lcd_spi(uint8_t v) { uint32_t timeout = MB_RAM_SPI_TIMEOUT; while (!(SPI1->SR & SPI_SR_TXE)) @@ -183,6 +189,35 @@ __attribute__((always_inline)) static inline bool mb_ram_progress_blit(const uin return ok != 0u; } +/* Blank the whole LCD RAM (all 8 pages) right before the reset. The MCU reset + * leaves the display controller powered and still showing the "Restore slot N" + * screen; it stays visible through the next boot until ST7565_Init re-inits the + * panel. Wiping it here means the reboot window shows nothing instead of a + * stale restore screen. Best-effort: a display timeout just leaves it as-is. */ +__attribute__((always_inline)) static inline void mb_ram_lcd_clear(void) +{ + for (uint8_t page = 0; page < 8u; page++) + { + GPIOB->BRR = MB_LCD_CS_PIN; + GPIOA->BRR = MB_LCD_A0_PIN; /* command */ + if (!mb_ram_lcd_spi((uint8_t)(0xB0u | page)) || /* set page 0..7 */ + !mb_ram_lcd_spi(0x10u) || /* column high nibble */ + !mb_ram_lcd_spi(0x04u)) /* visible RAM starts at column 4 */ + { + GPIOB->BSRR = MB_LCD_CS_PIN; + return; + } + GPIOA->BSRR = MB_LCD_A0_PIN; /* data */ + for (uint32_t i = 0; i < 128u; i++) + if (!mb_ram_lcd_spi(0x00u)) + { + GPIOB->BSRR = MB_LCD_CS_PIN; + return; + } + GPIOB->BSRR = MB_LCD_CS_PIN; + } +} + __attribute__((section(".RamFunc"), noinline, used)) static void MB_RamReflash(uint32_t intAddr, uint32_t extAddr, uint32_t imageSize, uint8_t *progressLine) @@ -332,6 +367,8 @@ static void MB_RamReflash(uint32_t intAddr, uint32_t extAddr, uint32_t imageSize } FLASH->CR |= FLASH_CR_LOCK; + if (lcdEnabled) + mb_ram_lcd_clear(); mb_ram_reset(); fatal_reset: @@ -644,7 +681,8 @@ uint8_t MB_SlotInfo(uint8_t slot, mb_slot_header_t *out_header) uint8_t MB_SlotErase(uint8_t slot) { - if (slot >= MB_SLOT_COUNT) + /* Slot 0 is the firmware-managed base backup: never erasable from the host. */ + if (slot >= MB_SLOT_COUNT || slot == MB_SLOT_BACKUP) return MB_ERR_SLOT; const uint32_t base = MB_SLOT0_EXT_BASE + (uint32_t)slot * MB_SLOT_STRIDE; @@ -660,7 +698,8 @@ uint8_t MB_SlotErase(uint8_t slot) uint8_t MB_SlotWrite(uint8_t slot, uint32_t offset, const uint8_t *data, uint32_t len) { - if (slot >= MB_SLOT_COUNT) + /* Slot 0 is the firmware-managed base backup: never writable from the host. */ + if (slot >= MB_SLOT_COUNT || slot == MB_SLOT_BACKUP) return MB_ERR_SLOT; if (len == 0) return MB_OK; @@ -676,3 +715,323 @@ uint8_t MB_SlotWrite(uint8_t slot, uint32_t offset, const uint8_t *data, uint32_ return MB_OK; } + +/* -------------------------------------------------------------------------- */ +/* Per-profile settings banks. */ +/* */ +/* The banking offset itself lives in the flash driver (PY25Q16_SetProfileBase);*/ +/* here we only own the active-profile marker and the profile->base mapping. */ +/* All external-flash only, never brick-critical. */ +/* -------------------------------------------------------------------------- */ + +static uint32_t mb_crc32_bytes(const uint8_t *p, uint32_t len) +{ + uint32_t crc = 0xFFFFFFFFu; + + for (uint32_t i = 0; i < len; i++) + { + crc ^= p[i]; + for (int k = 0; k < 8; k++) + crc = (crc >> 1) ^ (0xEDB88320u & (0u - (crc & 1u))); + } + return crc ^ 0xFFFFFFFFu; +} + +uint32_t MB_ProfileBase(uint8_t profile) +{ + if (profile == 0) + return 0; /* profile 0 = historical config region */ + if (profile < MB_PROFILE_COUNT) + return MB_PROFILE1_EXT_BASE + (uint32_t)(profile - 1u) * MB_PROFILE_BANK_SIZE; + return 0; /* out of range -> safe default */ +} + +static mb_mark_status_t mb_read_profile_copy(uint32_t base, mb_profile_state_t *st) +{ + mb_spi_err = 0; + mb_ext_read(base, (uint8_t *)st, sizeof(*st)); + if (mb_spi_err) return MB_MARK_IO; + if (st->magic == 0xFFFFFFFFu) return MB_MARK_MISSING; + if (st->magic == MB_PROFILE_LEGACY_MAGIC) + { + /* FMP1 was { magic, index, ~index, reserved[2] }. Preserve its slot + * choice long enough for boot resolution to migrate it to FMP2. */ + const uint8_t legacy_index = ((const uint8_t *)st)[4]; + const uint8_t legacy_inv = ((const uint8_t *)st)[5]; + if ((uint8_t)~legacy_inv != legacy_index || legacy_index >= MB_PROFILE_COUNT) + return MB_MARK_CORRUPT; + memset(st, 0, sizeof(*st)); + st->magic = MB_PROFILE_LEGACY_MAGIC; + st->index = legacy_index; + st->index_inv = legacy_inv; + return MB_MARK_LEGACY; + } + if (st->magic != MB_PROFILE_MAGIC) return MB_MARK_CORRUPT; + if ((uint8_t)~st->index_inv != st->index) return MB_MARK_CORRUPT; + if (st->index >= MB_PROFILE_COUNT) return MB_MARK_CORRUPT; + if (st->image_size == 0u || + st->image_size > MB_INT_APP_SIZE) return MB_MARK_CORRUPT; + if (mb_crc32_bytes((const uint8_t *)st, + sizeof(*st) - sizeof(st->state_crc32)) != st->state_crc32) + return MB_MARK_CORRUPT; + return MB_MARK_VALID; +} + +static bool mb_generation_newer(uint32_t a, uint32_t b) +{ + return (int32_t)(a - b) > 0; +} + +static mb_mark_status_t mb_read_active_profile(mb_profile_state_t *state, + uint32_t *state_base) +{ + mb_profile_state_t a; + mb_profile_state_t b; + mb_mark_status_t sa = mb_read_profile_copy(MB_PROFILE_STATE_A_BASE, &a); + mb_mark_status_t sb = mb_read_profile_copy(MB_PROFILE_STATE_B_BASE, &b); + + /* If either sector could not be read, it might contain the newest record. + * Do not silently select an older state and risk loading the wrong bank. */ + if (sa == MB_MARK_IO || sb == MB_MARK_IO) + return MB_MARK_IO; + + const mb_profile_state_t *chosen = NULL; + uint32_t chosen_base = 0; + if (sa == MB_MARK_VALID && sb == MB_MARK_VALID) + { + if (mb_generation_newer(b.generation, a.generation)) + { + chosen = &b; + chosen_base = MB_PROFILE_STATE_B_BASE; + } + else + { + chosen = &a; + chosen_base = MB_PROFILE_STATE_A_BASE; + } + } + else if (sa == MB_MARK_VALID) + { + chosen = &a; + chosen_base = MB_PROFILE_STATE_A_BASE; + } + else if (sb == MB_MARK_VALID) + { + chosen = &b; + chosen_base = MB_PROFILE_STATE_B_BASE; + } + + if (chosen) + { + if (state) + *state = *chosen; + if (state_base) + *state_base = chosen_base; + return MB_MARK_VALID; + } + + /* A legacy record is usable for migration but has no firmware identity. + * Prefer A if both somehow exist; the next successful repair writes FMP2. */ + if (sa == MB_MARK_LEGACY || sb == MB_MARK_LEGACY) + { + const bool use_b = sa != MB_MARK_LEGACY; + if (state) + *state = use_b ? b : a; + if (state_base) + *state_base = use_b ? MB_PROFILE_STATE_B_BASE : MB_PROFILE_STATE_A_BASE; + return MB_MARK_LEGACY; + } + + return (sa == MB_MARK_MISSING && sb == MB_MARK_MISSING) + ? MB_MARK_MISSING : MB_MARK_CORRUPT; +} + +mb_mark_status_t MB_ReadActiveProfile(mb_profile_state_t *state) +{ + return mb_read_active_profile(state, NULL); +} + +uint8_t MB_GetActiveProfile(void) +{ + mb_profile_state_t st; + mb_mark_status_t status = MB_ReadActiveProfile(&st); + return (status == MB_MARK_VALID || status == MB_MARK_LEGACY) ? st.index : 0; +} + +uint8_t MB_SetActiveProfile(uint8_t profile) +{ + mb_slot_header_t hdr; + mb_profile_state_t st; + mb_profile_state_t current; + uint32_t current_base = 0; + + if (profile >= MB_PROFILE_COUNT) + return MB_ERR_SLOT; + + uint8_t hdr_err = mb_read_header(profile, &hdr); + if (hdr_err != MB_OK) + return hdr_err; + + mb_mark_status_t current_status = mb_read_active_profile(¤t, ¤t_base); + if (current_status == MB_MARK_IO) + return MB_ERR_SPI; + + memset(&st, 0, sizeof(st)); + st.magic = MB_PROFILE_MAGIC; + st.generation = (current_status == MB_MARK_VALID) ? current.generation + 1u : 0u; + st.image_size = hdr.image_size; + st.image_crc32 = hdr.image_crc32; + st.index = profile; + st.index_inv = (uint8_t)~profile; + st.state_crc32 = mb_crc32_bytes((const uint8_t *)&st, + sizeof(st) - sizeof(st.state_crc32)); + + const uint32_t target_base = ((current_status == MB_MARK_VALID || + current_status == MB_MARK_LEGACY) && + current_base == MB_PROFILE_STATE_A_BASE) + ? MB_PROFILE_STATE_B_BASE + : MB_PROFILE_STATE_A_BASE; + + mb_spi_err = 0; + mb_spi_polled_mode(); + if (!mb_ext_sector_erase(target_base)) + return MB_ERR_SPI; + if (!mb_ext_program(target_base, (const uint8_t *)&st, sizeof(st))) + return MB_ERR_SPI; + + /* Verify the new copy directly. The previous valid sector has not been + * touched, so any failure here remains power-loss safe. */ + mb_profile_state_t chk; + mb_mark_status_t chk_status = mb_read_profile_copy(target_base, &chk); + if (chk_status == MB_MARK_IO) return MB_ERR_SPI; + if (chk_status != MB_MARK_VALID || + memcmp(&chk, &st, sizeof(st)) != 0) return MB_ERR_CRC; + + return MB_OK; +} + +uint8_t MB_ProfileErase(uint8_t profile) +{ + /* Profile 0 (the base config) is not resettable from the host: reset it by + * factory-resetting the running base firmware instead. Profiles 1..N live + * in their own 64 KiB banks, clear of the shared calibration at 0x010000. */ + if (profile == 0 || profile >= MB_PROFILE_COUNT) + return MB_ERR_SLOT; + + const uint32_t base = MB_ProfileBase(profile); + + /* Invalidate before the first raw erase so an early failure cannot leave a + * cache entry referring to a sector that was already erased. */ + PY25Q16_InvalidateCache(); + + mb_spi_err = 0; + mb_spi_polled_mode(); + for (uint32_t off = 0; off < MB_PROFILE_BANK_SIZE; off += MB_EXT_SECTOR) + if (!mb_ext_sector_erase(base + off)) + return MB_ERR_SPI; + + return MB_OK; +} + +/* -------------------------------------------------------------------------- */ +/* Slot 0 self-backup (base firmware). */ +/* -------------------------------------------------------------------------- */ + +/* Bounded copy of a NUL-terminated string into a fixed field, zero-padded. */ +static void mb_copy_str(char *dst, uint8_t cap, const char *src) +{ + uint8_t n = 0; + while (n + 1u < cap && src[n]) + { + dst[n] = src[n]; + n++; + } + while (n < cap) + dst[n++] = 0; +} + +/* CRC-32 (zlib) of the internal application flash, which is memory-mapped so + * no SPI is involved. Same polynomial as the external slot CRC. */ +static uint32_t mb_int_image_crc32(uint32_t len) +{ + const uint8_t *p = (const uint8_t *)MB_INT_APP_BASE; + return mb_crc32_bytes(p, len); +} + +static bool mb_internal_matches(uint32_t image_size, uint32_t image_crc32) +{ + return mb_int_image_crc32(image_size) == image_crc32; +} + +mb_fw_match_t MB_InternalMatchesSlot(uint8_t slot) +{ + mb_slot_header_t hdr; + uint8_t err = mb_read_header(slot, &hdr); + + if (err == MB_ERR_SPI) + return MB_FW_IO; /* couldn't read the header: never conclude mismatch */ + if (err != MB_OK) + return MB_FW_MISMATCH; /* no valid header: definitely not this firmware */ + + return mb_internal_matches(hdr.image_size, hdr.image_crc32) + ? MB_FW_MATCH : MB_FW_MISMATCH; +} + +bool MB_InternalMatchesProfile(const mb_profile_state_t *state) +{ + if (!state || state->image_size == 0u || state->image_size > MB_INT_APP_SIZE) + return false; + return mb_internal_matches(state->image_size, state->image_crc32); +} + +uint8_t MB_BackupInternalToSlot0(mb_progress_fn progress) +{ + const uint8_t *img = (const uint8_t *)MB_INT_APP_BASE; + const uint32_t size = MB_INT_APP_SIZE; + const uint32_t base = MB_SLOT0_EXT_BASE + (uint32_t)MB_SLOT_BACKUP * MB_SLOT_STRIDE; + + mb_spi_err = 0; + mb_spi_polled_mode(); + + /* Erase the header sector + image area (rounded up to 4 KiB sectors). */ + for (uint32_t off = 0; off < MB_SLOT_IMG_OFFSET + size; off += MB_EXT_SECTOR) + if (!mb_ext_sector_erase(base + off)) + return MB_ERR_SPI; + + /* Program the whole internal application region verbatim, in chunks, + * reporting progress. It is an exact copy of what executes, so restoring + * it later reproduces the running firmware bit-for-bit. */ + for (uint32_t done = 0; done < size; ) + { + uint32_t n = (size - done < MB_EXT_SECTOR) ? (size - done) : MB_EXT_SECTOR; + if (!mb_ext_program(base + MB_SLOT_IMG_OFFSET + done, img + done, n)) + return MB_ERR_SPI; + done += n; + if (progress) + progress(done, size); + } + + /* Validate the stored image before committing its header. Until that final + * header write the slot remains invalid, so a failed CRC or interrupted + * backup is guaranteed to retry at the next boot. */ + const uint32_t image_crc = mb_int_image_crc32(size); + uint32_t ext_crc = mb_ext_image_crc32(base + MB_SLOT_IMG_OFFSET, size); + if (mb_spi_err) return MB_ERR_SPI; + if (ext_crc != image_crc) return MB_ERR_CRC; + + /* Write the COMMITTED header only after the external image verified. */ + mb_slot_header_t hdr; + memset(&hdr, 0, sizeof(hdr)); + hdr.magic = MB_SLOT_MAGIC; + hdr.hdr_version = MB_HDR_VERSION; + hdr.flags = MB_FLAG_COMMITTED; + hdr.image_size = size; + hdr.image_crc32 = image_crc; + mb_copy_str(hdr.name, MB_NAME_LEN, EDITION_STRING); + mb_copy_str(hdr.fw_version, MB_VERSION_LEN, VERSION_STRING_2); + + if (!mb_ext_program(base, (const uint8_t *)&hdr, sizeof(hdr))) + return MB_ERR_SPI; + + return MB_OK; +} diff --git a/App/driver/mb_flash.h b/App/driver/mb_flash.h index 0a4afe8d..b9c1006c 100644 --- a/App/driver/mb_flash.h +++ b/App/driver/mb_flash.h @@ -43,11 +43,15 @@ #define MB_INT_APP_SIZE 0x0001D800u /* 118 KiB */ /* External SPI flash slot layout (see docs/multiboot-design.md). - * Each 128 KiB slot = one header sector (4 KiB) followed by the image. */ + * Each 128 KiB slot = one header sector (4 KiB) followed by the image. + * Slot 0 is the firmware-managed BACKUP of the normally-flashed firmware + * (written by MB_BackupInternalToSlot0, protected from host writes); slots + * 1..4 are the user slots managed from UV Studio. */ #define MB_SLOT_STRIDE 0x00020000u /* 128 KiB per slot */ #define MB_SLOT_IMG_OFFSET 0x00001000u /* image starts after header sector */ -#define MB_SLOT0_EXT_BASE 0x00020000u /* slot 0 header base */ -#define MB_SLOT_COUNT 4u +#define MB_SLOT0_EXT_BASE 0x00020000u /* slot 0 (backup) header base */ +#define MB_SLOT_COUNT 5u /* slot 0 backup + slots 1..4 */ +#define MB_SLOT_BACKUP 0u /* slot 0 = firmware base backup */ /* Slot header (stored at the slot base, first 4 KiB sector). 64 bytes. */ #define MB_SLOT_MAGIC 0x31424D46u /* "FMB1" */ @@ -104,4 +108,125 @@ uint8_t MB_SlotInfo(uint8_t slot, mb_slot_header_t *out_header); uint8_t MB_SlotErase(uint8_t slot); uint8_t MB_SlotWrite(uint8_t slot, uint32_t offset, const uint8_t *data, uint32_t len); +/* -------------------------------------------------------------------------- */ +/* Per-profile settings banks. */ +/* */ +/* Each firmware slot gets its own copy of the user configuration (memory */ +/* channels, names, VFOs, settings) so switching firmware no longer shares - */ +/* or silently clobbers - settings between editions. The banking itself is a */ +/* single address offset applied in the flash driver (see */ +/* PY25Q16_SetProfileBase); everything below PY25Q16_PROFILE_SHARED_FROM */ +/* (0x010000, the calibration boundary) is per-profile, everything at/above */ +/* stays shared. Slot N is bound to profile N. Profile 0 (slot 0 = base */ +/* backup) reuses the historical config region at 0x000000 - no migration. */ +/* */ +/* External SPI flash (PY25Q16, 2 MiB) map: */ +/* 0x000000 profile 0 config (channels/settings) ] per-profile */ +/* 0x010000 calibration (512 B) ] shared */ +/* 0x011000 boot logo (4 KiB) ] shared */ +/* 0x020000 slot 0 firmware (BACKUP, 128 KiB) ] firmware-managed */ +/* 0x040000 slot 1 firmware (128 KiB) ] */ +/* 0x060000 slot 2 firmware ] user (UV Studio) */ +/* 0x080000 slot 3 firmware ] */ +/* 0x0A0000 slot 4 firmware ] */ +/* 0x0C0000 profile 1 config (64 KiB) ] */ +/* 0x0D0000 profile 2 config (64 KiB) ] per-profile */ +/* 0x0E0000 profile 3 config (64 KiB) ] */ +/* 0x0F0000 profile 4 config (64 KiB) ] */ +/* 0x100000 multiboot state A (4 KiB marker) ] shared */ +/* 0x101000 multiboot state B (4 KiB marker) ] redundant */ +/* 0x102000 -- free ~888 KiB -- */ +/* 0x1E0000 RX/TX log (32 KiB) ] shared */ +/* */ +/* Banks are 64 KiB for headroom; the live config footprint is ~44 KiB (max */ +/* physical config address 0x00A170). Keep the profile banks past the last */ +/* slot if MB_SLOT_COUNT ever grows. */ + +#define MB_PROFILE_COUNT MB_SLOT_COUNT /* one profile per slot (0..4) */ +#define MB_PROFILE_BANK_SIZE 0x00010000u /* 64 KiB per config bank */ +#define MB_PROFILE1_EXT_BASE 0x000C0000u /* profiles 1..4, right after slot 4 */ +#define MB_PROFILE_STATE_A_BASE 0x00100000u /* redundant marker sector A */ +#define MB_PROFILE_STATE_B_BASE 0x00101000u /* redundant marker sector B */ + +/* Active-profile marker: two alternating external-flash sectors, never banked, + * outside the EEPROM logical map. A new record is verified in the inactive + * sector before it supersedes the previous one, so a power loss always leaves + * at least one usable state. The expected firmware identity is stored here as + * well: boot resolution never depends on the slot header remaining readable. */ +#define MB_PROFILE_LEGACY_MAGIC 0x31504D46u /* "FMP1" (single 8-byte record) */ +#define MB_PROFILE_MAGIC 0x32504D46u /* "FMP2" (redundant identity record) */ +typedef struct __attribute__((packed)) { + uint32_t magic; /* MB_PROFILE_MAGIC */ + uint32_t generation; /* monotonically increasing record version */ + uint32_t image_size; /* expected internal image size */ + uint32_t image_crc32; /* expected internal image CRC-32 */ + uint8_t index; /* active profile 0..MB_PROFILE_COUNT-1 */ + uint8_t index_inv; /* ~index, quick integrity check */ + uint8_t reserved[2]; /* fixed zero for deterministic state CRC */ + uint32_t state_crc32; /* CRC-32 over all preceding fields */ +} mb_profile_state_t; + +/* External-flash base of a profile's config bank. Profile 0 -> 0 (historical + * region, identity map); profiles 1..N -> past the slots. Out-of-range -> 0. */ +uint32_t MB_ProfileBase(uint8_t profile); + +/* Result of reading the active-profile marker. A boot must treat these very + * differently: MISSING = fresh radio (adopting the running firmware as Main is + * fine); IO / CORRUPT = uncertain (must NOT overwrite Main). */ +typedef enum { + MB_MARK_VALID = 0, /* read OK, well-formed; *state populated */ + MB_MARK_LEGACY, /* valid FMP1 index; identity not stored */ + MB_MARK_MISSING, /* read OK but both sectors erased */ + MB_MARK_CORRUPT, /* read OK but magic/integrity bad */ + MB_MARK_IO, /* could not be read (SPI error) */ +} mb_mark_status_t; + +/* Read the newest valid active-profile marker, distinguishing the states above. */ +mb_mark_status_t MB_ReadActiveProfile(mb_profile_state_t *state); + +/* Convenience wrapper for non-critical callers (e.g. cursor pre-selection): + * the valid index, or 0 for anything not cleanly VALID. */ +uint8_t MB_GetActiveProfile(void); + +/* Write the active-profile marker into the inactive redundant sector and read it + * back to confirm it landed. The previous valid record is kept intact. The slot + * header supplies the expected internal firmware identity. */ +uint8_t MB_SetActiveProfile(uint8_t profile); + +/* Erase a profile's whole config bank (host "Reset config" for a user slot): + * the next boot on that slot reads 0xFF and re-seeds factory defaults. Profile 0 + * (the base) is refused - reset it by factory-resetting the base firmware. + * External flash only, never brick-critical. */ +uint8_t MB_ProfileErase(uint8_t profile); + +/* -------------------------------------------------------------------------- */ +/* Slot 0 self-backup (base firmware). */ +/* -------------------------------------------------------------------------- */ + +/* Progress callback for the (slow) slot-0 self-backup; may be NULL. */ +typedef void (*mb_progress_fn)(uint32_t done, uint32_t total); + +/* Does the running internal firmware carry the DECLARED identity of `slot` + * (CRC32 over image_size == the slot header's image_crc32)? This checks the + * header's claim, not the stored image itself (that is validated once, right + * after a backup). IO is reported separately so a transient SPI error is never + * mistaken for a mismatch - which would wrongly trigger a self-backup over Main. */ +typedef enum { + MB_FW_MATCH = 0, /* internal carries this slot's declared identity */ + MB_FW_MISMATCH, /* reliably not this slot (or slot has no header) */ + MB_FW_IO, /* slot header unreadable: uncertain */ +} mb_fw_match_t; + +mb_fw_match_t MB_InternalMatchesSlot(uint8_t slot); + +/* Compare internal flash with the identity stored in a validated marker. */ +bool MB_InternalMatchesProfile(const mb_profile_state_t *state); + +/* Back up the running internal firmware into slot 0 (erase + full image + + * COMMITTED header, name=edition, version) and validate the stored image by a + * full external CRC read-back. External flash only, never brick-critical; a + * failure (SPI or CRC) leaves the slot uncommitted and does not advance the + * marker, so boot resolution retries it. progress may be NULL. */ +uint8_t MB_BackupInternalToSlot0(mb_progress_fn progress); + #endif /* DRIVER_MB_FLASH_H */ diff --git a/App/driver/py25q16.c b/App/driver/py25q16.c index 876cdb53..4eeb5ae2 100644 --- a/App/driver/py25q16.c +++ b/App/driver/py25q16.c @@ -47,6 +47,30 @@ static uint8_t SectorCache[SECTOR_SIZE]; static uint8_t BlackHole[4] __attribute__((aligned(4))); static volatile bool TC_Flag; +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT +/* Active settings-profile base (see py25q16.h). 0 = profile 0 / historical + * config region, i.e. an identity mapping. */ +static uint32_t ProfileBase = 0; + +void PY25Q16_SetProfileBase(uint32_t Base) +{ + ProfileBase = Base; +} + +/* Redirect config-region accesses (addr < boundary) into the active bank. + * Calibration/logo/slots/marker (addr >= boundary) are returned unchanged. + * ProfileBase is sector-aligned, so alignment done by callers is preserved. */ +static inline uint32_t ProfileMap(uint32_t Address) +{ + return (Address < PY25Q16_PROFILE_SHARED_FROM) ? (Address + ProfileBase) : Address; +} +#else +static inline uint32_t ProfileMap(uint32_t Address) +{ + return Address; +} +#endif + static inline void CS_Assert() { GPIO_ResetOutputPin(CS_PIN); @@ -222,6 +246,7 @@ static void WriteEnable(); static void SectorErase(uint32_t Addr); static void SectorProgram(uint32_t Addr, const uint8_t *Buf, uint32_t Size); static void PageProgram(uint32_t Addr, const uint8_t *Buf, uint32_t Size); +static void ReadBufferRaw(uint32_t Address, void *pBuffer, uint32_t Size); void PY25Q16_Init() { @@ -229,7 +254,7 @@ void PY25Q16_Init() SPI_Init(); } -void PY25Q16_ReadBuffer(uint32_t Address, void *pBuffer, uint32_t Size) +static void ReadBufferRaw(uint32_t Address, void *pBuffer, uint32_t Size) { MBMARK("RD cmd"); // about to assert CS + send read command CS_Assert(); @@ -259,6 +284,11 @@ void PY25Q16_ReadBuffer(uint32_t Address, void *pBuffer, uint32_t Size) CS_Release(); } +void PY25Q16_ReadBuffer(uint32_t Address, void *pBuffer, uint32_t Size) +{ + ReadBufferRaw(ProfileMap(Address), pBuffer, Size); +} + // Like PY25Q16_ReadBuffer, but waits for the flash to be idle first (WIP=0), // exactly as PY25Q16_WriteBuffer does before its internal reads. A standalone // read issued while the chip is still busy from a prior program/erase never @@ -273,6 +303,8 @@ void PY25Q16_ReadBufferSafe(uint32_t Address, void *pBuffer, uint32_t Size) void PY25Q16_WriteBuffer(uint32_t Address, const void *pBuffer, uint32_t Size, bool Append) { + Address = ProfileMap(Address); /* map once; internal reads use *Raw below */ + #ifdef DEBUG printf("spi flash write: %06x %ld %d\n", Address, Size, Append); #endif @@ -298,7 +330,9 @@ void PY25Q16_WriteBuffer(uint32_t Address, const void *pBuffer, uint32_t Size, b if (SecAddr != SectorCacheAddr) { - PY25Q16_ReadBuffer(SecAddr, SectorCache, SECTOR_SIZE); + /* SecAddr is already in mapped space (Address was mapped above), so + * read raw to avoid mapping a second time. */ + ReadBufferRaw(SecAddr, SectorCache, SECTOR_SIZE); SectorCacheAddr = SecAddr; } @@ -358,6 +392,7 @@ void PY25Q16_WriteBuffer(uint32_t Address, const void *pBuffer, uint32_t Size, b void PY25Q16_SectorErase(uint32_t Address) { + Address = ProfileMap(Address); Address -= (Address % SECTOR_SIZE); SectorErase(Address); if (SectorCacheAddr == Address) @@ -366,6 +401,13 @@ void PY25Q16_SectorErase(uint32_t Address) } } +void PY25Q16_InvalidateCache(void) +{ + /* Same "no sector cached" sentinel as the initial value: the next write + * re-reads its sector from flash instead of trusting SectorCache. */ + SectorCacheAddr = 0x1000000; +} + 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 4d4d696b..ab6ffd2d 100644 --- a/App/driver/py25q16.h +++ b/App/driver/py25q16.h @@ -26,4 +26,33 @@ void PY25Q16_ReadBufferSafe(uint32_t Address, void *pBuffer, uint32_t Size); void PY25Q16_WriteBuffer(uint32_t Address, const void *pBuffer, uint32_t Size, bool Append); void PY25Q16_SectorErase(uint32_t Address); +/* Drop the internal single-sector write cache. Call after erasing/programming + * flash behind the driver's back (e.g. the raw multiboot slot/profile ops) so a + * later write cannot skip or resurrect data based on a stale cached sector. */ +void PY25Q16_InvalidateCache(void); + +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT +/* + * Multiboot per-profile config banking. + * + * Each firmware slot owns a private copy of the user configuration (memory + * channels, names, VFOs, settings). A non-zero profile base transparently + * shifts every flash access BELOW PY25Q16_PROFILE_SHARED_FROM into the active + * profile's bank; calibration, boot logo, firmware slots and the multiboot + * marker all live at/above that boundary and stay shared across every profile. + * + * This is the single choke point: both the EEPROM emulation (eeprom_compat.c) + * and the firmware's direct config reads/writes (settings.c) end up here, so + * one offset covers them all - no per-call-site patching. + * + * Set once at boot, before any settings read, from + * PY25Q16_SetProfileBase(MB_ProfileBase(MB_BootResolveProfile())); + * and never changed again during a session (the profile only changes through a + * reflash + reset), so the banking itself never needs a cache flush. Raw profile + * erases behind the driver explicitly call PY25Q16_InvalidateCache(). + */ +#define PY25Q16_PROFILE_SHARED_FROM 0x00010000u /* calibration boundary (see flash map) */ +void PY25Q16_SetProfileBase(uint32_t Base); +#endif + #endif diff --git a/App/main.c b/App/main.c index ff9d6d24..ad099c23 100644 --- a/App/main.c +++ b/App/main.c @@ -52,6 +52,10 @@ #include "driver/system.h" #include "driver/systick.h" #include "driver/py25q16.h" +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + #include "driver/mb_flash.h" + #include "ui/multiboot.h" +#endif #ifdef ENABLE_UART #include "driver/uart.h" #endif @@ -81,6 +85,14 @@ void Main(void) SYSTICK_Init(); BOARD_Init(); +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + /* Resolve the active settings profile BEFORE any EEPROM/settings access + * below. This also adopts a normally-flashed firmware as slot 0 (discreet + * self-backup) when the running image isn't the slot the marker points to. + * Calibration stays shared regardless of the selected profile. */ + PY25Q16_SetProfileBase(MB_ProfileBase(MB_BootResolveProfile())); +#endif + boot_counter_10ms = 250; // 2.5 sec #ifdef ENABLE_UART diff --git a/App/ui/multiboot.c b/App/ui/multiboot.c index 1751fe27..4f14f7d8 100644 --- a/App/ui/multiboot.c +++ b/App/ui/multiboot.c @@ -75,6 +75,13 @@ static void mb_status_bar(void) { UI_StatusClear(); GUI_DisplaySmallestInverse("F4HWN MULTIBOOT", 34, 0, true, true, 94); + + /* Thin line dressing up the otherwise blank row between the status bar and + * the first content row. Drawn on gFrameBuffer[0] (line 0), which every + * multiboot screen leaves blank, so it shows on all of them. Bit 3 (~mid of + * the row) stays clear of the selected-slot capsule's top edge (bit 7). */ + for (uint8_t x = 2u; x < LCD_WIDTH - 2u; x++) + gFrameBuffer[0][x] |= 0x08u; } /* Bottom key-hint line: each key name as an inverse 3x5 capsule label, its @@ -175,7 +182,9 @@ static void mb_render_slots(uint8_t selected, memset(line, 0, sizeof(line)); memset(version, 0, sizeof(version)); - line[0] = (char)('0' + slot); + /* Slot 0 is the auto-backed-up main firmware: label it 'M' (Main) so it + * reads apart from the numbered user slots 1..4. */ + line[0] = (slot == 0u) ? 'M' : (char)('0' + slot); line[1] = ' '; line[2] = ' '; @@ -220,16 +229,17 @@ static void mb_render_slots(uint8_t selected, ST7565_BlitFullScreen(); } -static void mb_prepare_progress(uint8_t slot) +/* Both restore and initial Main backup use the exact same progress frame. + * Keep it out-of-line: each caller has different text, but duplicating the + * framebuffer setup and the two LCD blits only wastes MCU flash. */ +__attribute__((noinline)) static void mb_prepare_progress_screen(const char *title, + const char *detail) { - char title[] = "Restore slot 0"; - title[13] = (char)('0' + slot); - UI_DisplayClear(); mb_status_bar(); UI_PrintStringSmallNormal(title, 2, 126, 1); UI_PrintStringSmallNormal("DO NOT POWER OFF", 2, 126, 3); - UI_PrintStringSmallNormal("Writing & Verify", 2, 126, 5); + UI_PrintStringSmallNormal(detail, 2, 126, 5); /* Same rounded outline and hatch pattern as the scan progress gauge. */ gFrameBuffer[6][3] = 0x0Cu; @@ -242,10 +252,48 @@ static void mb_prepare_progress(uint8_t slot) ST7565_BlitFullScreen(); } +static void mb_prepare_progress(uint8_t slot) +{ + char slot_title[] = "Restore slot 0"; + slot_title[13] = (char)('0' + slot); + const char *title = (slot == 0u) ? "Restore Main" : slot_title; + + mb_prepare_progress_screen(title, "Writing & Verify"); +} + +/* Discreet "Main backup" screen shown once, at the first boot after a normal + * Flash-Firmware install, while the running firmware is copied into slot 0. */ +static void mb_backup_prepare(void) +{ + mb_prepare_progress_screen("Saving Main", "Slot Main"); +} + +static void mb_backup_progress(uint32_t done, uint32_t total) +{ + uint32_t cols = total ? (done * 118u / total) : 118u; + if (cols > 118u) + cols = 118u; + for (uint32_t i = 0; i < cols; i++) + gFrameBuffer[6][5u + i] = 0x2Du; + ST7565_BlitFullScreen(); +} + +/* With no trustworthy profile, continuing would let normal boot-time settings + * writes modify an arbitrary bank. Keep the radio in a read-only error state; + * a power cycle can recover from a transient SPI fault. */ +__attribute__((noreturn)) static void mb_profile_error_halt(void) +{ + BACKLIGHT_TurnOn(); + mb_show_message("PROFILE ERROR", "Flash state unknown", "Restart radio"); + for (;;) + SYSTEM_DelayMs(100); +} + static void mb_confirm_screen(uint8_t slot) { - char title[] = "Restore slot 0?"; - title[13] = (char)('0' + slot); + char slot_title[] = "Restore slot 0?"; + slot_title[13] = (char)('0' + slot); + const char *title = (slot == 0u) ? "Restore Main?" : slot_title; UI_DisplayClear(); mb_status_bar(); @@ -268,12 +316,19 @@ void UI_MultibootSelector(void) mb_wait_release(); mb_scan_slots(headers, status); - for (uint8_t slot = 0; slot < MB_SLOT_COUNT; slot++) + /* Pre-select the firmware currently running (its slot, from the marker), so + * the cursor lands on "where you are". If that slot isn't restorable (erased, + * bad CRC...), fall back to the first valid slot. */ + selected = MB_GetActiveProfile(); + if (selected >= MB_SLOT_COUNT || status[selected] != MB_OK) { - if (status[slot] == MB_OK) + for (uint8_t slot = 0; slot < MB_SLOT_COUNT; slot++) { - selected = slot; - break; + if (status[slot] == MB_OK) + { + selected = slot; + break; + } } } @@ -316,6 +371,17 @@ void UI_MultibootSelector(void) if (key != KEY_MENU) continue; + /* Bind this slot to its own settings profile BEFORE reflashing. The + * write is verified (read-back); if it can't be confirmed we must NOT + * reflash - otherwise the next boot could resolve to the wrong profile + * (e.g. when two slots hold the same firmware image). */ + if (MB_SetActiveProfile(selected) != MB_OK) + { + mb_show_message("PROFILE ERROR", "Marker not saved", "Press any key"); + (void)mb_get_key(); + continue; + } + mb_prepare_progress(selected); uint8_t err = MB_RestoreSlot(selected, gFrameBuffer[6]); @@ -326,3 +392,76 @@ void UI_MultibootSelector(void) mb_scan_slots(headers, status); } } + +/* Adopt the running internal firmware as Main: back it up into slot 0 and point + * the marker at profile 0. Reached when the firmware was installed outside + * multiboot (fresh radio, or a plain Flash-Firmware). */ +static uint8_t mb_adopt_internal_as_main(void) +{ + BACKLIGHT_TurnOn(); + mb_backup_prepare(); + if (MB_BackupInternalToSlot0(mb_backup_progress) == MB_OK) + (void)MB_SetActiveProfile(MB_SLOT_BACKUP); + return MB_SLOT_BACKUP; +} + +uint8_t MB_BootResolveProfile(void) +{ + mb_profile_state_t mark; + mb_mark_status_t ms = MB_MARK_IO; + + for (uint8_t retry = 0; retry < 3u && ms == MB_MARK_IO; retry++) + { + ms = MB_ReadActiveProfile(&mark); + if (ms == MB_MARK_IO) + SYSTEM_DelayMs(10); + } + + /* A reliably-read marker is authoritative: it carries the expected internal + * identity, so we don't even need the slot header. */ + if (ms == MB_MARK_VALID) + { + if (MB_InternalMatchesProfile(&mark)) + return mark.index; /* running the slot the marker names */ + + /* Marker read fine but internal no longer carries its identity -> the + * firmware was replaced outside multiboot (a plain Flash-Firmware). Adopt + * it as Main. Deliberately NOT a content scan here: a build that merely + * duplicates a user slot (or a marker that already points at such a slot) + * must still refresh Main. */ + return mb_adopt_internal_as_main(); + } + + /* Marker unreliable (MISSING / LEGACY / CORRUPT / IO): identify the running + * firmware by content, and never destroy Main on uncertainty - internal is + * adopted only when it matches no slot AND every read was clean, so a + * transient SPI error or a half-written marker can never destroy Main. */ + bool had_io = false; + for (uint8_t slot = 0; slot < MB_SLOT_COUNT; slot++) + { + mb_fw_match_t m = MB_FW_IO; + for (uint8_t retry = 0; retry < 3u && m == MB_FW_IO; retry++) + m = MB_InternalMatchesSlot(slot); + if (m == MB_FW_MATCH) + { + (void)MB_SetActiveProfile(slot); /* record/repair the marker */ + return slot; + } + if (m == MB_FW_IO) + had_io = true; + } + + if (had_io || ms == MB_MARK_IO || ms == MB_MARK_CORRUPT) + { + /* Halt to protect an existing Main while the flash state is uncertain; + * but if slot 0 holds no valid backup there is nothing to protect, so + * fall through and adopt instead of bricking a first boot. */ + uint8_t main_status = MB_SlotInfo(MB_SLOT_BACKUP, NULL); + bool main_exists = (main_status != MB_ERR_MAGIC && + main_status != MB_ERR_NOT_COMMITTED); + if (main_exists) + mb_profile_error_halt(); + } + + return mb_adopt_internal_as_main(); +} diff --git a/App/ui/multiboot.h b/App/ui/multiboot.h index c222354c..32bba490 100644 --- a/App/ui/multiboot.h +++ b/App/ui/multiboot.h @@ -5,8 +5,16 @@ #ifndef UI_MULTIBOOT_H #define UI_MULTIBOOT_H +#include + /* Blocking boot-time slot selector. Returns only when the user chooses EXIT; * a successful restore resets the radio from the RAM-resident copier. */ void UI_MultibootSelector(void); +/* Resolve the active settings profile at boot, BEFORE any settings read. + * Detects a firmware installed outside multiboot (internal != slot[marker]) and, + * if so, discreetly self-backs it up into slot 0 and adopts profile 0. Returns + * the profile index (0..MB_SLOT_COUNT-1) to feed PY25Q16_SetProfileBase(). */ +uint8_t MB_BootResolveProfile(void); + #endif From c8b65bb9d8f3a8d0a62524b156bc52cedcb16776 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Tue, 18 Aug 2026 17:03:44 +0200 Subject: [PATCH 08/73] Many multiboot fix --- App/board.c | 17 +++++++++++++---- App/driver/st7565.c | 21 +++++++++++++++------ App/ui/multiboot.c | 34 +++++++++++++++++++++------------- 3 files changed, 49 insertions(+), 23 deletions(-) diff --git a/App/board.c b/App/board.c index b1b94efd..e6c62f9b 100644 --- a/App/board.c +++ b/App/board.c @@ -71,9 +71,18 @@ void BOARD_GPIO_Init(void) LL_GPIO_InitTypeDef InitStruct; LL_GPIO_StructInit(&InitStruct); InitStruct.OutputType = LL_GPIO_OUTPUT_PUSHPULL; - InitStruct.Pull = LL_GPIO_PULL_UP; InitStruct.Speed = LL_GPIO_SPEED_FREQ_VERY_HIGH; + /* Keep the backlight dark from the first possible GPIO write. Its pin can + * otherwise float while the rest of the board GPIOs are being configured. */ + LL_GPIO_ResetOutputPin(GPIOF, LL_GPIO_PIN_8); + InitStruct.Mode = LL_GPIO_MODE_OUTPUT; + InitStruct.Pull = LL_GPIO_PULL_NO; + InitStruct.Pin = LL_GPIO_PIN_8; + LL_GPIO_Init(GPIOF, &InitStruct); + + InitStruct.Pull = LL_GPIO_PULL_UP; + // --------------------- // Input pins @@ -123,9 +132,8 @@ void BOARD_GPIO_Init(void) LL_GPIO_Init(GPIOF, &InitStruct); #endif - // Backlight: PF8 // BK4819 CS: PF9 - InitStruct.Pin = LL_GPIO_PIN_9 | LL_GPIO_PIN_8 ; + InitStruct.Pin = LL_GPIO_PIN_9; LL_GPIO_Init(GPIOF, &InitStruct); #ifndef ENABLE_SWD @@ -177,13 +185,14 @@ void BOARD_ADC_GetBatteryInfo(uint16_t *pVoltage, uint16_t *pCurrent) void BOARD_Init(void) { BOARD_GPIO_Init(); + /* Blank and initialise the LCD as soon as its GPIOs are available. */ + ST7565_Init(); BACKLIGHT_InitHardware(); BOARD_ADC_Init(); #ifdef ENABLE_VOICE VOICE_Init(); #endif PY25Q16_Init(); - ST7565_Init(); #ifdef ENABLE_FMRADIO BK1080_Init0(); #endif diff --git a/App/driver/st7565.c b/App/driver/st7565.c index 23d93e48..0736f43c 100644 --- a/App/driver/st7565.c +++ b/App/driver/st7565.c @@ -204,9 +204,11 @@ void ST7565_DrawLine(const unsigned int Column, const unsigned int Line, const u void ST7565_FillScreen(uint8_t value) { CS_Assert(); - for (unsigned i = 0; i < 8; i++) { - // TODO: This is wrong - DrawLine(0, i, NULL, value); + for (uint8_t line = 0; line < 8u; line++) { + ST7565_SelectColumnAndLine(4u, line); + A0_Set(); + for (uint8_t column = 0; column < LCD_WIDTH; column++) + SPI_WriteByte(value); } CS_Release(); } @@ -333,6 +335,10 @@ void ST7565_Init(void) SPI_Init(); ST7565_HardwareReset(); CS_Assert(); + + /* Hide the controller RAM immediately. On K1 there is no usable hardware + * reset pin, so its power-on contents can otherwise briefly reach the LCD. */ + ST7565_WriteByte(ST7565_CMD_DISPLAY_ON_OFF | 0); ST7565_WriteByte(ST7565_CMD_SOFTWARE_RESET); // software reset SYSTEM_DelayMs(120); @@ -354,13 +360,16 @@ void ST7565_Init(void) ST7565_WriteByte(ST7565_CMD_POWER_CIRCUIT | 0b111); // VB=1 VR=1 VF=1 SYSTEM_DelayMs(40); - - ST7565_WriteByte(ST7565_CMD_SET_START_LINE | 0); // line 0 - ST7565_WriteByte(ST7565_CMD_DISPLAY_ON_OFF | 1); // D=1 + ST7565_WriteByte(ST7565_CMD_SET_START_LINE | 0); // line 0 CS_Release(); + /* Clear all eight LCD RAM pages while the display is still disabled. */ ST7565_FillScreen(0x00); + + CS_Assert(); + ST7565_WriteByte(ST7565_CMD_DISPLAY_ON_OFF | 1); // D=1 + CS_Release(); } #ifdef ENABLE_FEAT_F4HWN_SLEEP diff --git a/App/ui/multiboot.c b/App/ui/multiboot.c index 4f14f7d8..9a733de7 100644 --- a/App/ui/multiboot.c +++ b/App/ui/multiboot.c @@ -229,9 +229,18 @@ static void mb_render_slots(uint8_t selected, ST7565_BlitFullScreen(); } -/* Both restore and initial Main backup use the exact same progress frame. - * Keep it out-of-line: each caller has different text, but duplicating the - * framebuffer setup and the two LCD blits only wastes MCU flash. */ +static void mb_draw_progress_outline(void) +{ + /* Same rounded outline and hatch pattern as the scan progress gauge. */ + gFrameBuffer[6][3] = 0x0Cu; + gFrameBuffer[6][4] = 0x12u; + gFrameBuffer[6][123] = 0x12u; + gFrameBuffer[6][124] = 0x0Cu; + for (uint8_t x = 5; x < 123u; x++) + gFrameBuffer[6][x] = 0x21u; +} + +/* Full progress frame used while restoring a firmware slot. */ __attribute__((noinline)) static void mb_prepare_progress_screen(const char *title, const char *detail) { @@ -240,14 +249,7 @@ __attribute__((noinline)) static void mb_prepare_progress_screen(const char *tit UI_PrintStringSmallNormal(title, 2, 126, 1); UI_PrintStringSmallNormal("DO NOT POWER OFF", 2, 126, 3); UI_PrintStringSmallNormal(detail, 2, 126, 5); - - /* Same rounded outline and hatch pattern as the scan progress gauge. */ - gFrameBuffer[6][3] = 0x0Cu; - gFrameBuffer[6][4] = 0x12u; - gFrameBuffer[6][123] = 0x12u; - gFrameBuffer[6][124] = 0x0Cu; - for (uint8_t x = 5; x < 123u; x++) - gFrameBuffer[6][x] = 0x21u; + mb_draw_progress_outline(); ST7565_BlitStatusLine(); ST7565_BlitFullScreen(); } @@ -265,7 +267,13 @@ static void mb_prepare_progress(uint8_t slot) * Flash-Firmware install, while the running firmware is copied into slot 0. */ static void mb_backup_prepare(void) { - mb_prepare_progress_screen("Saving Main", "Slot Main"); + UI_DisplayClear(); + UI_StatusClear(); + UI_PrintStringSmallNormal("Init Main", 2, 126, 1); + UI_PrintStringSmallNormal("DO NOT POWER OFF", 2, 126, 3); + mb_draw_progress_outline(); + ST7565_BlitStatusLine(); + ST7565_BlitFullScreen(); } static void mb_backup_progress(uint32_t done, uint32_t total) @@ -398,8 +406,8 @@ void UI_MultibootSelector(void) * multiboot (fresh radio, or a plain Flash-Firmware). */ static uint8_t mb_adopt_internal_as_main(void) { - BACKLIGHT_TurnOn(); mb_backup_prepare(); + BACKLIGHT_TurnOn(); if (MB_BackupInternalToSlot0(mb_backup_progress) == MB_OK) (void)MB_SetActiveProfile(MB_SLOT_BACKUP); return MB_SLOT_BACKUP; From 4cc5d11142bb6dec6b435b1fb604f1f5116cb403 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Tue, 18 Aug 2026 18:02:25 +0200 Subject: [PATCH 09/73] Fix VCP reply buffer alignment --- App/app/uart.c | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/App/app/uart.c b/App/app/uart.c index 49be1082..177666b5 100644 --- a/App/app/uart.c +++ b/App/app/uart.c @@ -197,7 +197,8 @@ typedef union #ifdef ENABLE_USB static void SendReply_VCP(void *pReply, uint16_t Size) { - static uint8_t VCP_ReplyBuf[MAX_REPLY_SIZE + sizeof(Header_t) + sizeof(Footer_t)]; + static uint8_t VCP_ReplyBuf[MAX_REPLY_SIZE + sizeof(Header_t) + sizeof(Footer_t)] + __attribute__((aligned(4))); // !! if (Size > MAX_REPLY_SIZE) From 94f1f978ff7f83a77e033f235d6ff211aec240be Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Wed, 19 Aug 2026 04:02:39 +0200 Subject: [PATCH 10/73] Improve startup backlight fade visibility --- App/driver/backlight.c | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/App/driver/backlight.c b/App/driver/backlight.c index bbf9bab2..311ab95e 100644 --- a/App/driver/backlight.c +++ b/App/driver/backlight.c @@ -63,6 +63,8 @@ static void BACKLIGHT_SetHardwareBrightness(uint8_t brightness); // STEPS * STEP_MS ~= total fade duration in ms. #define BL_STARTUP_FADE_STEPS 40 #define BL_STARTUP_FADE_STEP_MS 12 + // First PWM value that produces a non-zero duty cycle with 32 levels. + #define BL_STARTUP_VISIBLE_MIN ((255 + DUTY_CYCLE_LEVELS - 1) / DUTY_CYCLE_LEVELS) #endif #ifdef ENABLE_FEAT_F4HWN @@ -143,14 +145,18 @@ void BACKLIGHT_UpdateTickless(void) { #ifdef ENABLE_FEAT_F4HWN // Soft, progressive power-on fade-in. -// Ramps from the current brightness (0 at power-on) to targetBrightness using -// a smoothstep (3x^2 - 2x^3) easing curve. Easing gently at both ends reads as -// a smooth, progressive fade instead of the abrupt linear ramp, and stepping -// through many fine stops keeps it fluid even with the 32-level PWM. +// Starts at the first visible PWM level, then ramps to targetBrightness using +// a smoothstep (3x^2 - 2x^3) easing curve. Avoiding sub-PWM values removes the +// apparent pause between drawing the welcome screen and lighting it. static void BACKLIGHT_FadeInStartup(void) { - const int16_t from = currentBrightness; // 0 at power-on - const int16_t span = targetBrightness - from; // ramp amplitude + const int16_t from = targetBrightness < BL_STARTUP_VISIBLE_MIN + ? targetBrightness + : BL_STARTUP_VISIBLE_MIN; + const int16_t span = targetBrightness - from; + + currentBrightness = from; + BACKLIGHT_SetHardwareBrightness((uint8_t)currentBrightness); if (span <= 0) { gUpdateBacklight = false; From 7d8b95407f9cd986ebd4222ea3be5cfcad70f023 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Wed, 19 Aug 2026 04:17:40 +0200 Subject: [PATCH 11/73] Document support policy for disabled preset options --- CMakePresets.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CMakePresets.json b/CMakePresets.json index 11ddae2d..acfe92c7 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -4,6 +4,16 @@ { "name": "default", "hidden": true, + "vendor": { + "github.com/armel/uv-k1-k5v3-firmware-custom/support-policy/1.0": { + "notice": [ + "SUPPORT POLICY", + "Most options outside ENABLE_FEAT_F4HWN_* that are intentionally set to false in this preset are unsupported unless documented otherwise.", + "Enabling them is at your own risk and may cause bugs, side effects, or instability.", + "Do not expect troubleshooting assistance for such configurations." + ] + } + }, "generator": "Ninja", "binaryDir": "${sourceDir}/build/${presetName}", "toolchainFile": "${sourceDir}/cmake/gcc-arm-none-eabi.cmake", From 024bfbaaf38eca50050660ea6167e44d509c4997 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Wed, 19 Aug 2026 05:06:46 +0200 Subject: [PATCH 12/73] Fix optional UART and USB builds --- App/CMakeLists.txt | 11 +++++++++-- App/app/app.c | 6 ++++++ App/app/uart.c | 2 ++ App/k5viewer.c | 22 +++++++++++++++++++++- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index a3016818..ff065bdb 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -95,8 +95,7 @@ if(ENABLE_AIRCOPY OR ENABLE_UART OR ENABLE_USB) endif() enable_feature(ENABLE_UART - driver/uart.c - app/uart.c + driver/uart.c # driver/aes.c ) @@ -177,6 +176,14 @@ enable_feature(ENABLE_EXTRA_UART_CMD) # ---- F4HWN MODS ---- +if(NOT ENABLE_UART AND NOT ENABLE_USB) + if(ENABLE_FEAT_F4HWN_K5VIEWER OR ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER) + message(STATUS "Disabling K5Viewer because both ENABLE_UART and ENABLE_USB are OFF.") + endif() + set(ENABLE_FEAT_F4HWN_K5VIEWER OFF) + set(ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER OFF) +endif() + enable_feature(ENABLE_FEAT_F4HWN) enable_feature(ENABLE_FEAT_F4HWN_GAME app/breakout.c diff --git a/App/app/app.c b/App/app/app.c index 5165646f..dddf1076 100644 --- a/App/app/app.c +++ b/App/app/app.c @@ -1654,7 +1654,9 @@ void APP_TimeSlice10ms(void) } #ifdef ENABLE_FEAT_F4HWN_LOGO_SAV +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER bool screenSaverRendered = false; +#endif if (gScreenSaverDisplayed) { if (gUpdateDisplayCurrent) { @@ -1668,13 +1670,17 @@ void APP_TimeSlice10ms(void) if (++gScreenSaverTick >= 8u) { gScreenSaverTick = 0; ScreenSaverRenderMatrix(false); +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER screenSaverRendered = true; +#endif } } else if (gSetting_set_sav == SET_SAV_LOGO_PLUS) { if (++gScreenSaverTick >= 16u) { gScreenSaverTick = 0; ScreenSaverRenderLogoPlus(false); +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER screenSaverRendered = true; +#endif } } } diff --git a/App/app/uart.c b/App/app/uart.c index 177666b5..db9b8994 100644 --- a/App/app/uart.c +++ b/App/app/uart.c @@ -260,6 +260,7 @@ static void SendReply(uint32_t Port, void *pReply, uint16_t Size) } #endif +#if defined(ENABLE_UART) Header_t Header; Footer_t Footer; @@ -290,6 +291,7 @@ static void SendReply(uint32_t Port, void *pReply, uint16_t Size) Footer.ID = 0xBADC; UART_Send(&Footer, sizeof(Footer)); +#endif } static void SendVersion(uint32_t Port) diff --git a/App/k5viewer.c b/App/k5viewer.c index 5752ad34..2167dc40 100644 --- a/App/k5viewer.c +++ b/App/k5viewer.c @@ -18,7 +18,12 @@ #include "driver/st7565.h" #include "k5viewer.h" #include "misc.h" +#ifdef ENABLE_UART +#include "driver/uart.h" +#endif +#ifdef ENABLE_USB #include "driver/vcp.h" +#endif #include "driver/keyboard.h" #include "driver/bk4819.h" #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER @@ -67,26 +72,41 @@ void K5VIEWER_ParseInput(void) if (K5VIEWER_IsLocked()) return; +#ifdef ENABLE_UART if (UART_IsCableConnected()) { keepAlive = 15; hasConnectionPing = true; gUSB_K5ViewerEnabled = false; + return; } - else if (VCP_K5ViewerPing()) { +#endif + +#ifdef ENABLE_USB + if (VCP_K5ViewerPing()) { keepAlive = 15; hasConnectionPing = true; gUSB_K5ViewerEnabled = true; } +#endif } static void K5VIEWER_Send(const uint8_t *buf, uint16_t len) { +#if defined(ENABLE_UART) && defined(ENABLE_USB) if (gUSB_K5ViewerEnabled) { cdc_acm_data_send_with_dtr(buf, len); } else { UART_Send(buf, len); } +#elif defined(ENABLE_USB) + cdc_acm_data_send_with_dtr(buf, len); +#elif defined(ENABLE_UART) + UART_Send(buf, len); +#else + (void)buf; + (void)len; +#endif } enum { From 86e48f5f692f5f261c1790c4ae9d09325195e100 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Wed, 19 Aug 2026 14:42:46 +0200 Subject: [PATCH 13/73] Add slot tag to SysInfo --- App/ui/menu.c | 40 ++++++++++++++++++++++++++++++++++++++++ App/ui/multiboot.c | 21 +++++++++++++++++---- App/ui/multiboot.h | 4 ++++ 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/App/ui/menu.c b/App/ui/menu.c index cb16b751..6b048d28 100644 --- a/App/ui/menu.c +++ b/App/ui/menu.c @@ -42,6 +42,9 @@ #include "menu.h" #include "ui.h" #include "welcome.h" +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + #include "multiboot.h" +#endif const t_menu_item MenuList[] = @@ -836,6 +839,35 @@ static void UI_MENU_DrawTopRightRoundedBadge(const char *text, const uint8_t lin UI_PrintStringSmallNormalInverse(text, text_x, 0, line); } +/* Single-line variant for tight gaps: unlike UI_PrintStringSmallNormalInverse, + * the rounded edge stays entirely inside `line` and never touches line - 1. */ +static void UI_MENU_DrawInlineRoundedBadge(const char *text, const uint8_t line, + const uint8_t area_x1, const uint8_t area_x2) +{ + const size_t length = strlen(text); + const size_t char_pitch = ARRAY_SIZE(gFontSmall[0]) + 1u; + const size_t text_width = length * char_pitch; + const size_t capsule_width = text_width + 3u; + + if (length == 0 || line >= FRAME_LINES || area_x2 <= area_x1) { + return; + } + + const size_t area_width = area_x2 - area_x1 + 1u; + if (capsule_width >= area_width) + return; + + const uint8_t capsule_left = (uint8_t)(area_x1 + ((area_width - capsule_width) / 2u)); + const uint8_t text_x = (uint8_t)(capsule_left + 1u); + const uint8_t x_end = (uint8_t)(text_x + text_width + 1u); + + UI_PrintStringSmallNormal(text, text_x, 0, line); + gFrameBuffer[line][text_x - 1u] ^= 0x7Eu; + for (uint8_t x = text_x; x < x_end; x++) + gFrameBuffer[line][x] ^= 0xFFu; + gFrameBuffer[line][x_end] ^= 0x7Eu; +} + void UI_DisplayMenu(void) { const unsigned int menu_list_width = 6; // max no. of characters on the menu list (left side) @@ -1421,6 +1453,14 @@ void UI_DisplayMenu(void) #ifdef ENABLE_FEAT_F4HWN sprintf(String, "%s\n%s", AUTHOR_STRING_2, DISPLAY_VERSION_STRING_2); UI_PrintStringSmallNormal(Edition, menu_item_x1 - 1, menu_item_x2, 6); +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + const uint8_t running_slot = MB_GetRunningSlot(); + char slot_badge[2]; + + slot_badge[0] = (running_slot == 0u) ? 'M' : (char)('0' + running_slot); + slot_badge[1] = '\0'; + UI_MENU_DrawInlineRoundedBadge(slot_badge, 5, menu_item_x1, menu_item_x2); +#endif #else sprintf(String, "%u.%02uV\n%u%%", gBatteryVoltageAverage / 100, gBatteryVoltageAverage % 100, diff --git a/App/ui/multiboot.c b/App/ui/multiboot.c index 9a733de7..ffa34025 100644 --- a/App/ui/multiboot.c +++ b/App/ui/multiboot.c @@ -13,6 +13,19 @@ #include "ui/helper.h" #include "ui/multiboot.h" +static uint8_t gRunningSlot = MB_SLOT_BACKUP; + +static uint8_t mb_remember_running_slot(uint8_t slot) +{ + gRunningSlot = slot; + return slot; +} + +uint8_t MB_GetRunningSlot(void) +{ + return gRunningSlot; +} + static const char *mb_error_text(uint8_t err) { switch (err) @@ -430,14 +443,14 @@ uint8_t MB_BootResolveProfile(void) if (ms == MB_MARK_VALID) { if (MB_InternalMatchesProfile(&mark)) - return mark.index; /* running the slot the marker names */ + return mb_remember_running_slot(mark.index); /* slot named by the marker */ /* Marker read fine but internal no longer carries its identity -> the * firmware was replaced outside multiboot (a plain Flash-Firmware). Adopt * it as Main. Deliberately NOT a content scan here: a build that merely * duplicates a user slot (or a marker that already points at such a slot) * must still refresh Main. */ - return mb_adopt_internal_as_main(); + return mb_remember_running_slot(mb_adopt_internal_as_main()); } /* Marker unreliable (MISSING / LEGACY / CORRUPT / IO): identify the running @@ -453,7 +466,7 @@ uint8_t MB_BootResolveProfile(void) if (m == MB_FW_MATCH) { (void)MB_SetActiveProfile(slot); /* record/repair the marker */ - return slot; + return mb_remember_running_slot(slot); } if (m == MB_FW_IO) had_io = true; @@ -471,5 +484,5 @@ uint8_t MB_BootResolveProfile(void) mb_profile_error_halt(); } - return mb_adopt_internal_as_main(); + return mb_remember_running_slot(mb_adopt_internal_as_main()); } diff --git a/App/ui/multiboot.h b/App/ui/multiboot.h index 32bba490..d7b4d1d1 100644 --- a/App/ui/multiboot.h +++ b/App/ui/multiboot.h @@ -17,4 +17,8 @@ void UI_MultibootSelector(void); * the profile index (0..MB_SLOT_COUNT-1) to feed PY25Q16_SetProfileBase(). */ uint8_t MB_BootResolveProfile(void); +/* Slot selected by MB_BootResolveProfile for the current session. This is kept + * in RAM so UI callers do not have to reread the external-flash marker. */ +uint8_t MB_GetRunningSlot(void); + #endif From ec0d537b696547bb5545f6c7376ee9ea8788b64f Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Wed, 19 Aug 2026 16:19:48 +0200 Subject: [PATCH 14/73] Add ENABLE_NO_HEAP to drop the unused reserved heap --- CMakeLists.txt | 8 ++++++++ Core/py32f071xb.ld | 4 +++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 0c13acd8..4aa8726b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -110,6 +110,14 @@ set_target_properties(${EXE_NAME} PROPERTIES ADDITIONAL_CLEAN_FILES ${EXE_NAME}. # Force linker to ignore RWX segment warning even if cache exists target_link_options(${EXE_NAME} PRIVATE -Wl,--no-warn-rwx-segment) +# ENABLE_NO_HEAP: no malloc/sbrk is linked (libc is discarded in py32f071xb.ld), +# so the reserved heap is dead weight. Zero it to reclaim ~512 B of RAM. This is +# a global toolchain optimization, independent of multiboot. The linker script +# reads __min_heap_size when defined, else keeps its 0x200 default. +if(ENABLE_NO_HEAP) + target_link_options(${EXE_NAME} PRIVATE "-Wl,--defsym=__min_heap_size=0") +endif() + # ----------------------------------- # Post build processing # diff --git a/Core/py32f071xb.ld b/Core/py32f071xb.ld index 62732d35..b35cac18 100644 --- a/Core/py32f071xb.ld +++ b/Core/py32f071xb.ld @@ -55,7 +55,9 @@ ENTRY(Reset_Handler) /* Highest address of the user mode stack */ _estack = ORIGIN(RAM) + LENGTH(RAM); /* end of RAM */ /* Generate a link error if heap and stack don't fit into RAM */ -_Min_Heap_Size = 0x200; /* required amount of heap */ +/* ENABLE_NO_HEAP passes --defsym=__min_heap_size=0 to drop the reserved heap + * (no malloc/sbrk is linked); default stays 0x200 otherwise. */ +_Min_Heap_Size = DEFINED(__min_heap_size) ? __min_heap_size : 0x200; /* required amount of heap */ _Min_Stack_Size = 0x400; /* required amount of stack */ /* Specify the memory areas */ From 4c74e79a40824647f4ffb5ce07ef7f76379a8da7 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Wed, 19 Aug 2026 16:21:06 +0200 Subject: [PATCH 15/73] Gate multiboot restore progress bar behind MULTIBOOT_LOW_RAM --- App/CMakeLists.txt | 6 ++++++ App/driver/mb_flash.c | 12 ++++++++++++ App/driver/mb_flash.h | 5 ++++- 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index ff065bdb..b789438f 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -241,6 +241,12 @@ enable_feature(ENABLE_FEAT_F4HWN_MULTIBOOT driver/mb_flash.c ui/multiboot.c ) +enable_feature(ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM) + +if(ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM AND NOT ENABLE_FEAT_F4HWN_MULTIBOOT) + message(FATAL_ERROR "ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM requires ENABLE_FEAT_F4HWN_MULTIBOOT.") +endif() + enable_feature(ENABLE_FEAT_F4HWN_QRCODE) enable_feature(ENABLE_FEAT_F4HWN_LOGO) enable_feature(ENABLE_FEAT_F4HWN_LOGO_SAV) diff --git a/App/driver/mb_flash.c b/App/driver/mb_flash.c index 453636dd..dcc1cc5d 100644 --- a/App/driver/mb_flash.c +++ b/App/driver/mb_flash.c @@ -31,6 +31,7 @@ /* External SPI flash chip-select is on PA3 (see driver/py25q16.c). */ #define MB_CS_PIN (1u << 3) +#ifndef ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM /* LCD control pins used only for RAM-resident progress updates. */ #define MB_LCD_CS_PIN (1u << 2) /* PB2 */ #define MB_LCD_A0_PIN (1u << 6) /* PA6 */ @@ -39,6 +40,7 @@ #define MB_PROGRESS_COLS 118u #define MB_PROGRESS_FIRST_COL 5u #define MB_PROGRESS_FILLED 0x2Du +#endif /* Number of erase/program retries per page before giving up (and resetting * anyway - the region is already erased, so USB recovery is the only option). */ @@ -148,6 +150,7 @@ MB_RAM_HELPER __attribute__((noreturn)) static void mb_ram_reset(void) for (;;) { } } +#ifndef ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM /* Minimal SPI1 LCD writer. A display timeout merely disables progress updates: * it must never abort or delay the safety-critical flash copy. */ MB_RAM_HELPER static bool mb_ram_lcd_spi(uint8_t v) @@ -217,6 +220,7 @@ __attribute__((always_inline)) static inline void mb_ram_lcd_clear(void) GPIOB->BSRR = MB_LCD_CS_PIN; } } +#endif /* !ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM */ __attribute__((section(".RamFunc"), noinline, used)) static void MB_RamReflash(uint32_t intAddr, uint32_t extAddr, uint32_t imageSize, @@ -227,10 +231,14 @@ static void MB_RamReflash(uint32_t intAddr, uint32_t extAddr, uint32_t imageSize uint8_t buf[MB_FLASH_PAGE] __attribute__((aligned(4))); uint32_t remaining = imageSize; uint32_t regionRemaining = MB_INT_APP_SIZE; +#ifndef ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM uint32_t pagesDone = 0; uint32_t progressAccumulator = 0; uint32_t progressFilled = 0; uint32_t lcdEnabled = progressLine != NULL; +#else + (void)progressLine; +#endif __disable_irq(); @@ -341,6 +349,7 @@ static void MB_RamReflash(uint32_t intAddr, uint32_t extAddr, uint32_t imageSize if (!success) goto fatal_reset; +#ifndef ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM /* Advance the gauge without division (which could call a helper * in erased flash). Refresh once per 8 KiB internal sector. */ if (lcdEnabled) @@ -359,6 +368,7 @@ static void MB_RamReflash(uint32_t intAddr, uint32_t extAddr, uint32_t imageSize if ((pagesDone & 31u) == 0u || regionRemaining == MB_FLASH_PAGE) lcdEnabled = mb_ram_progress_blit(progressLine); } +#endif intAddr += MB_FLASH_PAGE; extAddr += readSize; @@ -367,8 +377,10 @@ static void MB_RamReflash(uint32_t intAddr, uint32_t extAddr, uint32_t imageSize } FLASH->CR |= FLASH_CR_LOCK; +#ifndef ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM if (lcdEnabled) mb_ram_lcd_clear(); +#endif mb_ram_reset(); fatal_reset: diff --git a/App/driver/mb_flash.h b/App/driver/mb_flash.h index b9c1006c..f07ba94d 100644 --- a/App/driver/mb_flash.h +++ b/App/driver/mb_flash.h @@ -86,7 +86,10 @@ enum { /* Multi-slot API used by the boot selector. Validation always covers the full * image CRC before restore. progress_line may point to a 128-byte LCD page; the - * RAM copier then fills it while reflashing. Pass NULL to disable LCD updates. */ + * RAM copier then fills it while reflashing. Pass NULL to disable LCD updates. + * The in-copier LCD progress code is compiled out when + * ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM is defined (to reclaim RAM-resident + * .RamFunc space); in that case progress_line is ignored. */ uint8_t MB_ValidateSlot(uint8_t slot, mb_slot_header_t *out_header, uint32_t *out_crc); uint8_t MB_RestoreSlot(uint8_t slot, uint8_t *progress_line); From 4ffff5149f9c53ba1f8c8165f7c51f03a0790101 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Wed, 19 Aug 2026 16:23:04 +0200 Subject: [PATCH 16/73] Many fix presets and Multiboot --- App/ui/multiboot.c | 11 ++++++++++- CMakePresets.json | 4 ++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/App/ui/multiboot.c b/App/ui/multiboot.c index ffa34025..bc915ec0 100644 --- a/App/ui/multiboot.c +++ b/App/ui/multiboot.c @@ -262,7 +262,12 @@ __attribute__((noinline)) static void mb_prepare_progress_screen(const char *tit UI_PrintStringSmallNormal(title, 2, 126, 1); UI_PrintStringSmallNormal("DO NOT POWER OFF", 2, 126, 3); UI_PrintStringSmallNormal(detail, 2, 126, 5); +#ifndef ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM + /* Empty gauge that the RAM copier fills as it reflashes. Without the + * in-copier progress code the bar would never move, so it is left out and + * the static "DO NOT POWER OFF" screen stands on its own. */ mb_draw_progress_outline(); +#endif ST7565_BlitStatusLine(); ST7565_BlitFullScreen(); } @@ -273,7 +278,7 @@ static void mb_prepare_progress(uint8_t slot) slot_title[13] = (char)('0' + slot); const char *title = (slot == 0u) ? "Restore Main" : slot_title; - mb_prepare_progress_screen(title, "Writing & Verify"); + mb_prepare_progress_screen(title, "Writing / Verify"); } /* Discreet "Main backup" screen shown once, at the first boot after a normal @@ -404,7 +409,11 @@ void UI_MultibootSelector(void) } mb_prepare_progress(selected); +#ifndef ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM uint8_t err = MB_RestoreSlot(selected, gFrameBuffer[6]); +#else + uint8_t err = MB_RestoreSlot(selected, NULL); +#endif /* Only reached when the final pre-erase validation refused the slot. */ status[selected] = err; diff --git a/CMakePresets.json b/CMakePresets.json index acfe92c7..48083701 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -21,6 +21,7 @@ "CMAKE_BUILD_TYPE": "Release", "DEV": false, "ENABLE_LTO": false, + "ENABLE_NO_HEAP": false, "ENABLE_FMRADIO": false, "ENABLE_UART": true, "ENABLE_USB": true, @@ -92,6 +93,7 @@ "ENABLE_FEAT_F4HWN_LOGO_SAV": false, "ENABLE_FEAT_F4HWN_MENU_CAT": false, "ENABLE_FEAT_F4HWN_MULTIBOOT": false, + "ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM": false, "ENABLE_AGC_SHOW_DATA": false, "ENABLE_UART_RW_BK_REGS": false, "ENABLE_SWD": false, @@ -113,6 +115,7 @@ "name": "Fusion", "inherits": "default", "cacheVariables": { + "ENABLE_NO_HEAP": true, "ENABLE_SPECTRUM": true, "ENABLE_FMRADIO": true, "ENABLE_VOX": true, @@ -141,6 +144,7 @@ "ENABLE_FEAT_F4HWN_MENU_CAT": true, "ENABLE_FEAT_F4HWN_ACTION_PICKER": true, "ENABLE_FEAT_F4HWN_MULTIBOOT": true, + "ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM": false, "ENABLE_SWD": true, "EDITION_STRING": "Fusion", "TARGET": "f4hwn.fusion" From 5b6174085babb2e377ad729308cdff90305b6e94 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Wed, 19 Aug 2026 17:52:47 +0200 Subject: [PATCH 17/73] Optimize multiboot RAM with a safe Overlay --- App/CMakeLists.txt | 6 +- App/driver/mb_flash.c | 84 ++++++++++++++++----- App/driver/mb_flash.h | 10 +-- App/driver/py25q16.c | 8 ++ App/driver/py25q16.h | 3 +- App/ui/multiboot.c | 11 +-- App/ui/welcome.c | 17 ++--- CMakeLists.txt | 19 +++++ CMakePresets.json | 4 +- Core/py32f071xb.ld | 51 ++++++++++++- cmake/check_mb_ramfunc.cmake | 138 ++++++++++++++++++++++++++++++++++ cmake/gcc-arm-none-eabi.cmake | 1 + 12 files changed, 299 insertions(+), 53 deletions(-) create mode 100644 cmake/check_mb_ramfunc.cmake diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index b789438f..374716af 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -241,10 +241,10 @@ enable_feature(ENABLE_FEAT_F4HWN_MULTIBOOT driver/mb_flash.c ui/multiboot.c ) -enable_feature(ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM) +enable_feature(ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY) -if(ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM AND NOT ENABLE_FEAT_F4HWN_MULTIBOOT) - message(FATAL_ERROR "ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM requires ENABLE_FEAT_F4HWN_MULTIBOOT.") +if(ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY AND NOT ENABLE_FEAT_F4HWN_MULTIBOOT) + message(FATAL_ERROR "ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY requires ENABLE_FEAT_F4HWN_MULTIBOOT.") endif() enable_feature(ENABLE_FEAT_F4HWN_QRCODE) diff --git a/App/driver/mb_flash.c b/App/driver/mb_flash.c index dcc1cc5d..2b226bee 100644 --- a/App/driver/mb_flash.c +++ b/App/driver/mb_flash.c @@ -31,7 +31,6 @@ /* External SPI flash chip-select is on PA3 (see driver/py25q16.c). */ #define MB_CS_PIN (1u << 3) -#ifndef ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM /* LCD control pins used only for RAM-resident progress updates. */ #define MB_LCD_CS_PIN (1u << 2) /* PB2 */ #define MB_LCD_A0_PIN (1u << 6) /* PA6 */ @@ -40,7 +39,6 @@ #define MB_PROGRESS_COLS 118u #define MB_PROGRESS_FIRST_COL 5u #define MB_PROGRESS_FILLED 0x2Du -#endif /* Number of erase/program retries per page before giving up (and resetting * anyway - the region is already erased, so USB recovery is the only option). */ @@ -103,14 +101,21 @@ static void MB_PrepareInternalFlash(void) /* during which the flash bus is unavailable. It must therefore NOT fetch any */ /* code from flash nor read any flash data: it uses raw register access only */ /* (no external calls), reads the source from the external SPI flash in */ -/* polled mode, and resets the MCU when done. It is placed in .RamFunc, which */ -/* the linker stores in flash and the startup copies to RAM alongside .data. */ +/* polled mode, and resets the MCU when done. Normally it is placed in */ +/* .RamFunc, which startup copies to RAM alongside .data. With the overlay */ +/* enabled it is linked in .MBRamFunc and copied over the PY25Q16 sector cache */ +/* only immediately before use. */ /* -------------------------------------------------------------------------- */ /* These primitives are called repeatedly while application flash is offline. * Keep one copy of each in the copied RAM section: noinline/noclone prevents * GCC from silently duplicating one back into MB_RamReflash. */ -#define MB_RAM_HELPER __attribute__((section(".RamFunc"), noinline, noclone, used)) +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY + #define MB_RAM_SECTION ".MBRamFunc" +#else + #define MB_RAM_SECTION ".RamFunc" +#endif +#define MB_RAM_HELPER __attribute__((section(MB_RAM_SECTION), noinline, noclone, used)) /* Polled single-byte SPI2 transfer. The result is returned through out so * timeout and received 0xFF remain distinguishable. */ @@ -150,7 +155,6 @@ MB_RAM_HELPER __attribute__((noreturn)) static void mb_ram_reset(void) for (;;) { } } -#ifndef ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM /* Minimal SPI1 LCD writer. A display timeout merely disables progress updates: * it must never abort or delay the safety-critical flash copy. */ MB_RAM_HELPER static bool mb_ram_lcd_spi(uint8_t v) @@ -220,9 +224,7 @@ __attribute__((always_inline)) static inline void mb_ram_lcd_clear(void) GPIOB->BSRR = MB_LCD_CS_PIN; } } -#endif /* !ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM */ - -__attribute__((section(".RamFunc"), noinline, used)) +__attribute__((section(MB_RAM_SECTION), noinline, used)) static void MB_RamReflash(uint32_t intAddr, uint32_t extAddr, uint32_t imageSize, uint8_t *progressLine) { @@ -231,14 +233,10 @@ static void MB_RamReflash(uint32_t intAddr, uint32_t extAddr, uint32_t imageSize uint8_t buf[MB_FLASH_PAGE] __attribute__((aligned(4))); uint32_t remaining = imageSize; uint32_t regionRemaining = MB_INT_APP_SIZE; -#ifndef ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM uint32_t pagesDone = 0; uint32_t progressAccumulator = 0; uint32_t progressFilled = 0; uint32_t lcdEnabled = progressLine != NULL; -#else - (void)progressLine; -#endif __disable_irq(); @@ -349,7 +347,6 @@ static void MB_RamReflash(uint32_t intAddr, uint32_t extAddr, uint32_t imageSize if (!success) goto fatal_reset; -#ifndef ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM /* Advance the gauge without division (which could call a helper * in erased flash). Refresh once per 8 KiB internal sector. */ if (lcdEnabled) @@ -368,7 +365,6 @@ static void MB_RamReflash(uint32_t intAddr, uint32_t extAddr, uint32_t imageSize if ((pagesDone & 31u) == 0u || regionRemaining == MB_FLASH_PAGE) lcdEnabled = mb_ram_progress_blit(progressLine); } -#endif intAddr += MB_FLASH_PAGE; extAddr += readSize; @@ -377,10 +373,8 @@ static void MB_RamReflash(uint32_t intAddr, uint32_t extAddr, uint32_t imageSize } FLASH->CR |= FLASH_CR_LOCK; -#ifndef ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM if (lcdEnabled) mb_ram_lcd_clear(); -#endif mb_ram_reset(); fatal_reset: @@ -399,6 +393,39 @@ fatal_reset: /* Set when a polled SPI wait below times out (external flash unresponsive). */ static volatile int mb_spi_err; +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY +/* Linker-provided load/execution bounds for the restore stub. Its execution + * address aliases the PY25Q16 sector cache; its load image remains in flash. */ +extern uint8_t __mb_ramfunc_load_start; +extern uint8_t __mb_ramfunc_start; +extern uint8_t __mb_ramfunc_end; + +static bool mb_load_ram_reflash_overlay(void) +{ + const volatile uint8_t *src = &__mb_ramfunc_load_start; + volatile uint8_t *dst = &__mb_ramfunc_start; + volatile uint8_t *end = &__mb_ramfunc_end; + + /* Volatile byte copies prevent a library memcpy call. The copy itself runs + * while internal flash is still fully available. */ + while (dst < end) + *dst++ = *src++; + + /* Cortex-M0+ has no instruction cache, but the barriers ensure that every + * store is visible before the following BLX starts fetching the stub. */ + __DSB(); + __ISB(); + + src = &__mb_ramfunc_load_start; + dst = &__mb_ramfunc_start; + while (dst < end) + if (*dst++ != *src++) + return false; + + return true; +} +#endif + /* Polled single-byte SPI2 transfer (flash-resident; runs in normal context). * Bounded so a wedged SPI can never freeze the firmware: on timeout it sets * mb_spi_err and returns 0xFF, letting the caller fail gracefully. */ @@ -668,8 +695,27 @@ uint8_t MB_RestoreSlot(uint8_t slot, uint8_t *progress_line) const uint32_t slotBase = MB_SLOT0_EXT_BASE + (uint32_t)slot * MB_SLOT_STRIDE; - /* Valid: the RAM stub copies exactly image_size bytes, pads the partial - * page with 0xFF and erases the remainder of the application region. */ +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY + /* No PY25Q16 driver access is allowed after this point: the sector cache is + * about to become executable storage and the RAM stub always ends in reset. + * Mask IRQs first, then explicitly stop SPI2 DMA before overwriting the + * cache. Keeping both operations local avoids relying on validation's + * current polled-SPI implementation and closes any IRQ re-arm window. */ + const uint32_t primask = __get_PRIMASK(); + __disable_irq(); + mb_spi_polled_mode(); + PY25Q16_InvalidateCache(); + if (!mb_load_ram_reflash_overlay()) + { + __set_PRIMASK(primask); + return MB_ERR_RAM_LOAD; + } +#endif + + /* Valid and, for the overlay path, safely loaded: the RAM stub copies + * exactly image_size bytes, pads the partial page with 0xFF and erases the + * remainder of the application region. Keep flash locked until this point + * so an overlay-copy failure can return without changing flash state. */ MB_PrepareInternalFlash(); /* Call through a volatile pointer so the compiler emits an absolute 'blx' diff --git a/App/driver/mb_flash.h b/App/driver/mb_flash.h index f07ba94d..14abc758 100644 --- a/App/driver/mb_flash.h +++ b/App/driver/mb_flash.h @@ -71,7 +71,7 @@ typedef struct __attribute__((packed)) { uint8_t reserved[16]; /* pad to 64 bytes, future use */ } mb_slot_header_t; -/* Restore validation result (MB_OK never returns - the radio resets). */ +/* Multiboot operation result (a successful restore resets before returning). */ enum { MB_OK = 0, MB_ERR_MAGIC, /* no/invalid slot header */ @@ -81,15 +81,15 @@ enum { MB_ERR_CRC, /* image CRC32 mismatch */ MB_ERR_SPI, /* external flash read/write timed out*/ MB_ERR_SLOT, /* slot index out of range */ - MB_ERR_AUTH /* write refused: timestamp mismatch */ + MB_ERR_AUTH, /* write refused: timestamp mismatch */ + MB_ERR_RAM_LOAD /* restore stub RAM copy mismatch */ }; /* Multi-slot API used by the boot selector. Validation always covers the full * image CRC before restore. progress_line may point to a 128-byte LCD page; the * RAM copier then fills it while reflashing. Pass NULL to disable LCD updates. - * The in-copier LCD progress code is compiled out when - * ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM is defined (to reclaim RAM-resident - * .RamFunc space); in that case progress_line is ignored. */ + * With ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY, the copier is loaded over the + * PY25Q16 sector cache only after validation and immediately before this call. */ uint8_t MB_ValidateSlot(uint8_t slot, mb_slot_header_t *out_header, uint32_t *out_crc); uint8_t MB_RestoreSlot(uint8_t slot, uint8_t *progress_line); diff --git a/App/driver/py25q16.c b/App/driver/py25q16.c index 4eeb5ae2..dfde7ac2 100644 --- a/App/driver/py25q16.c +++ b/App/driver/py25q16.c @@ -43,7 +43,15 @@ #define PAGE_SIZE 0x100 static uint32_t SectorCacheAddr = 0x1000000; +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY +/* The restore-only RAM stub is copied over this cache immediately before it + * erases internal flash. A reset always follows, so the cache is never needed + * again after the overlay becomes active. */ +static uint8_t SectorCache[SECTOR_SIZE] + __attribute__((section(".bss.mb_workspace"), aligned(4), used)); +#else static uint8_t SectorCache[SECTOR_SIZE]; +#endif static uint8_t BlackHole[4] __attribute__((aligned(4))); static volatile bool TC_Flag; diff --git a/App/driver/py25q16.h b/App/driver/py25q16.h index ab6ffd2d..5c93b7e3 100644 --- a/App/driver/py25q16.h +++ b/App/driver/py25q16.h @@ -28,7 +28,8 @@ void PY25Q16_SectorErase(uint32_t Address); /* Drop the internal single-sector write cache. Call after erasing/programming * flash behind the driver's back (e.g. the raw multiboot slot/profile ops) so a - * later write cannot skip or resurrect data based on a stale cached sector. */ + * later write cannot skip or resurrect data based on a stale cached sector. It + * is also called before multiboot reuses the cache storage as a RAM overlay. */ void PY25Q16_InvalidateCache(void); #ifdef ENABLE_FEAT_F4HWN_MULTIBOOT diff --git a/App/ui/multiboot.c b/App/ui/multiboot.c index bc915ec0..eedb38b2 100644 --- a/App/ui/multiboot.c +++ b/App/ui/multiboot.c @@ -39,6 +39,7 @@ static const char *mb_error_text(uint8_t err) case MB_ERR_SPI: return "SPI ERROR"; case MB_ERR_SLOT: return "bad slot"; case MB_ERR_AUTH: return "auth"; + case MB_ERR_RAM_LOAD: return "RAM LOAD ERROR"; default: return "error"; } } @@ -262,12 +263,8 @@ __attribute__((noinline)) static void mb_prepare_progress_screen(const char *tit UI_PrintStringSmallNormal(title, 2, 126, 1); UI_PrintStringSmallNormal("DO NOT POWER OFF", 2, 126, 3); UI_PrintStringSmallNormal(detail, 2, 126, 5); -#ifndef ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM - /* Empty gauge that the RAM copier fills as it reflashes. Without the - * in-copier progress code the bar would never move, so it is left out and - * the static "DO NOT POWER OFF" screen stands on its own. */ + /* Empty gauge that the RAM copier fills as it reflashes. */ mb_draw_progress_outline(); -#endif ST7565_BlitStatusLine(); ST7565_BlitFullScreen(); } @@ -409,11 +406,7 @@ void UI_MultibootSelector(void) } mb_prepare_progress(selected); -#ifndef ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM uint8_t err = MB_RestoreSlot(selected, gFrameBuffer[6]); -#else - uint8_t err = MB_RestoreSlot(selected, NULL); -#endif /* Only reached when the final pre-erase validation refused the slot. */ status[selected] = err; diff --git a/App/ui/welcome.c b/App/ui/welcome.c index 0547f468..659079e7 100644 --- a/App/ui/welcome.c +++ b/App/ui/welcome.c @@ -115,15 +115,10 @@ extern uint8_t _edata; // End of .data in RAM extern uint8_t _sbss; // Start of .bss in RAM extern uint8_t _ebss; // End of .bss in RAM -// _eflash_used must be defined in the linker script immediately after the last -// section with a FLASH load address (after .noncacheable). Example: -// -// .noncacheable : { -// ... -// } > RAM AT> FLASH -// _eflash_used = LOADADDR(.noncacheable) + SIZEOF(.noncacheable); -// -// This gives the exact byte count that the linker reports as FLASH used. +// _eflash_used is defined by the linker at the end of the final section with a +// FLASH load image. This is currently .mb_ramfunc (empty without the overlay), +// after the load images for .data and .noncacheable. It therefore gives the +// exact byte count that the linker reports as FLASH used. extern uint8_t _eflash_used; // Absolute symbols: their *address* IS the numeric size value (ARM/CMSIS convention). @@ -152,8 +147,8 @@ static void build_usage(uint32_t* ram_used, uint32_t* flash_used) const uint32_t stack_size = (uint32_t)(uintptr_t)&_Min_Stack_Size; *ram_used = span(&_sdata, &_ebss) + heap_size + stack_size; - // FLASH: _eflash_used is placed by the linker script right after the last - // section copied to FLASH (.data LMA + .noncacheable LMA). + // FLASH: _eflash_used follows the final FLASH load image (.mb_ramfunc, + // after the .data and .noncacheable load images). // Note: _etext is NOT usable here because this linker script places .rodata // sections AFTER _etext, making it an unreliable end-of-flash marker. *flash_used = span((void*)FLASH_BASE, &_eflash_used); diff --git a/CMakeLists.txt b/CMakeLists.txt index 4aa8726b..9e1fd523 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -122,6 +122,25 @@ endif() # Post build processing # +# The restore stub executes while application flash is unavailable. Reject an +# overlay build if the linked stub branches outside its RAM section or if its +# input section refers to any external code/data. This is deliberately a hard +# build gate: either condition could make a restore unrecoverable without DFU. +if(ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY) + add_custom_command( + TARGET ${EXE_NAME} + POST_BUILD + COMMAND ${CMAKE_COMMAND} + "-DOBJDUMP=${CMAKE_OBJDUMP}" + "-DELF=$" + "-DMAP=${CMAKE_CURRENT_BINARY_DIR}/${EXE_NAME}.map" + "-DBINARY_DIR=${CMAKE_CURRENT_BINARY_DIR}" + -P "${CMAKE_SOURCE_DIR}/cmake/check_mb_ramfunc.cmake" + COMMENT "Checking multiboot RAM stub isolation" + VERBATIM + ) +endif() + # Generate .bin file add_custom_command( TARGET ${EXE_NAME} diff --git a/CMakePresets.json b/CMakePresets.json index 48083701..7424af34 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -93,7 +93,7 @@ "ENABLE_FEAT_F4HWN_LOGO_SAV": false, "ENABLE_FEAT_F4HWN_MENU_CAT": false, "ENABLE_FEAT_F4HWN_MULTIBOOT": false, - "ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM": false, + "ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY": false, "ENABLE_AGC_SHOW_DATA": false, "ENABLE_UART_RW_BK_REGS": false, "ENABLE_SWD": false, @@ -144,7 +144,7 @@ "ENABLE_FEAT_F4HWN_MENU_CAT": true, "ENABLE_FEAT_F4HWN_ACTION_PICKER": true, "ENABLE_FEAT_F4HWN_MULTIBOOT": true, - "ENABLE_FEAT_F4HWN_MULTIBOOT_LOW_RAM": false, + "ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY": true, "ENABLE_SWD": true, "EDITION_STRING": "Fusion", "TARGET": "f4hwn.fusion" diff --git a/Core/py32f071xb.ld b/Core/py32f071xb.ld index b35cac18..905d4e9b 100644 --- a/Core/py32f071xb.ld +++ b/Core/py32f071xb.ld @@ -159,7 +159,54 @@ SECTIONS *(.noncacheable*) . = ALIGN(4); } >RAM AT> FLASH - _eflash_used = LOADADDR(.noncacheable) + SIZEOF(.noncacheable); + + /* 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)) + { + .mb_ramfunc + { + . = ALIGN(4); + __mb_ramfunc_start = .; + KEEP(*(.MBRamFunc)) + KEEP(*(.MBRamFunc*)) + . = ALIGN(4); + __mb_ramfunc_end = .; + } + + .mb_workspace + { + . = ALIGN(4); + __mb_workspace_start = .; + KEEP(*(.bss.mb_workspace)) + KEEP(*(.bss.mb_workspace*)) + . = ALIGN(4); + __mb_workspace_end = .; + } + } >RAM + + __mb_ramfunc_load_start = LOADADDR(.mb_ramfunc); + __mb_ramfunc_size = SIZEOF(.mb_ramfunc); + __mb_workspace_size = SIZEOF(.mb_workspace); + + ASSERT(__mb_ramfunc_size <= __mb_workspace_size, + "Multiboot RAM stub does not fit in PY25Q16 sector-cache overlay") + ASSERT((__mb_ramfunc_size == 0) || (__mb_workspace_size == 0x1000), + "Multiboot overlay workspace is not the 4 KiB sector cache") + ASSERT((__mb_ramfunc_size == 0) || + (ADDR(.mb_ramfunc) == ADDR(.mb_workspace)), + "Multiboot RAM stub and workspace do not share the same VMA") + ASSERT((__mb_ramfunc_size == 0) || + (LOADADDR(.mb_ramfunc) != ADDR(.mb_ramfunc)), + "Multiboot RAM stub load and execution addresses must differ") + ASSERT((LOADADDR(.mb_ramfunc) + SIZEOF(.mb_ramfunc)) <= + (ORIGIN(FLASH) + LENGTH(FLASH)), + "FLASH overflowed by multiboot RAM-stub load image") + + /* .mb_ramfunc is the final section with a FLASH load image. The workspace + member is NOLOAD/BSS storage and therefore contributes RAM only. */ + _eflash_used = LOADADDR(.mb_ramfunc) + SIZEOF(.mb_ramfunc); /* Uninitialized data section */ . = ALIGN(4); @@ -200,5 +247,3 @@ SECTIONS .ARM.attributes 0 : { *(.ARM.attributes) } } - - diff --git a/cmake/check_mb_ramfunc.cmake b/cmake/check_mb_ramfunc.cmake new file mode 100644 index 00000000..50757a6c --- /dev/null +++ b/cmake/check_mb_ramfunc.cmake @@ -0,0 +1,138 @@ +# Validate the multiboot restore stub after linking. The application flash is +# unavailable while this code runs, so every direct control-flow target and +# every symbolic code/data reference must remain inside .mb_ramfunc. + +foreach(required OBJDUMP ELF MAP BINARY_DIR) + if(NOT DEFINED ${required} OR "${${required}}" STREQUAL "") + message(FATAL_ERROR "check_mb_ramfunc: missing -D${required}=...") + endif() +endforeach() + +if(NOT EXISTS "${ELF}") + message(FATAL_ERROR "check_mb_ramfunc: ELF not found: ${ELF}") +endif() +if(NOT EXISTS "${MAP}") + message(FATAL_ERROR "check_mb_ramfunc: map file not found: ${MAP}") +endif() + +execute_process( + COMMAND "${OBJDUMP}" -h "${ELF}" + RESULT_VARIABLE headers_result + OUTPUT_VARIABLE headers + ERROR_VARIABLE headers_error +) +if(NOT headers_result EQUAL 0) + message(FATAL_ERROR + "check_mb_ramfunc: objdump section scan failed (${headers_result}):\n${headers_error}") +endif() + +string(REGEX MATCH + "[ \t]\\.mb_ramfunc[ \t]+([0-9A-Fa-f]+)[ \t]+([0-9A-Fa-f]+)" + section_header "${headers}") +if(section_header STREQUAL "") + message(FATAL_ERROR "check_mb_ramfunc: .mb_ramfunc not found in ${ELF}") +endif() +set(section_size_hex "${CMAKE_MATCH_1}") +set(section_start_hex "${CMAKE_MATCH_2}") +math(EXPR section_size "0x${section_size_hex}") +math(EXPR section_start "0x${section_start_hex}") +math(EXPR section_end "${section_start} + ${section_size}") +math(EXPR section_end_hex "${section_end}" OUTPUT_FORMAT HEXADECIMAL) +if(section_size EQUAL 0) + message(FATAL_ERROR "check_mb_ramfunc: .mb_ramfunc is empty in an overlay build") +endif() + +execute_process( + COMMAND "${OBJDUMP}" --no-show-raw-insn -d -j .mb_ramfunc "${ELF}" + RESULT_VARIABLE disassembly_result + OUTPUT_VARIABLE disassembly + ERROR_VARIABLE disassembly_error +) +if(NOT disassembly_result EQUAL 0) + message(FATAL_ERROR + "check_mb_ramfunc: disassembly failed (${disassembly_result}):\n${disassembly_error}") +endif() + +# Check all Thumb branch forms. An indirect BLX/BX cannot be proven safe; only +# BX LR (a normal function return) is accepted. Direct branches must stay in the +# linked RAM section, including compiler-generated tail calls and veneers. +string(REPLACE "\n" ";" disassembly_lines "${disassembly}") +set(branch_count 0) +foreach(line IN LISTS disassembly_lines) + if(line MATCHES + "^[ \t]*[0-9A-Fa-f]+:[ \t]+([A-Za-z0-9.]+)([ \t]+(.*))?") + set(mnemonic "${CMAKE_MATCH_1}") + set(operands "${CMAKE_MATCH_3}") + + if(mnemonic MATCHES + "^(b|bl|blx|bx|beq|bne|bcs|bcc|bhs|blo|bmi|bpl|bvs|bvc|bhi|bls|bge|blt|bgt|ble)(\\.[nw])?$" + OR mnemonic MATCHES "^(cbz|cbnz)$") + math(EXPR branch_count "${branch_count} + 1") + + if(mnemonic MATCHES "^bx(\\.[nw])?$") + string(STRIP "${operands}" register_name) + if(NOT register_name STREQUAL "lr" AND NOT register_name STREQUAL "r14") + message(FATAL_ERROR + "check_mb_ramfunc: unsafe indirect branch in RAM stub:\n${line}") + endif() + continue() + endif() + + # BL/B operands contain the address directly; CBZ/CBNZ put it after + # the register and comma. In both forms it is the final numeric + # operand before objdump's optional annotation. + if(NOT operands MATCHES + "(^|[, \t])((0[xX])?[0-9A-Fa-f]+)([ \t]+<[^>]+>)?[ \t]*$") + message(FATAL_ERROR + "check_mb_ramfunc: unresolved/indirect branch in RAM stub:\n${line}") + endif() + set(target_hex "${CMAKE_MATCH_2}") + string(REGEX REPLACE "^0[xX]" "" target_hex "${target_hex}") + math(EXPR target "0x${target_hex}") + if(target LESS section_start OR NOT target LESS section_end) + message(FATAL_ERROR + "check_mb_ramfunc: branch leaves .mb_ramfunc:\n${line}\n" + "Allowed range: 0x${section_start_hex}..${section_end_hex}") + endif() + endif() + endif() +endforeach() + +# Locate the exact input object from the linker map rather than assuming a +# generator-specific CMakeFiles path. Any relocation in .MBRamFunc would be an +# external symbolic reference (code or data), because all approved helpers share +# this same input section and their local branches are assembler-resolved. +file(READ "${MAP}" map_contents) +string(REGEX MATCH + "[^\r\n]*\\.MBRamFunc[^\r\n]*mb_flash\\.c\\.(obj|o)" + object_line "${map_contents}") +if(object_line STREQUAL "") + message(FATAL_ERROR + "check_mb_ramfunc: mb_flash object not found in ${MAP} " + "(the overlay safety gate requires a non-LTO input object)") +endif() +string(REGEX MATCH "[^ \t]+mb_flash\\.c\\.(obj|o)" object_path "${object_line}") +if(NOT IS_ABSOLUTE "${object_path}") + set(object_path "${BINARY_DIR}/${object_path}") +endif() +if(NOT EXISTS "${object_path}") + message(FATAL_ERROR "check_mb_ramfunc: input object not found: ${object_path}") +endif() + +execute_process( + COMMAND "${OBJDUMP}" -r -j .MBRamFunc "${object_path}" + RESULT_VARIABLE relocations_result + OUTPUT_VARIABLE relocations + ERROR_VARIABLE relocations_error +) +if(NOT relocations_result EQUAL 0) + message(FATAL_ERROR + "check_mb_ramfunc: relocation scan failed (${relocations_result}):\n${relocations_error}") +endif() +if(relocations MATCHES "R_ARM_") + message(FATAL_ERROR + "check_mb_ramfunc: .MBRamFunc has an external code/data reference:\n${relocations}") +endif() + +message(STATUS + "Multiboot RAM stub isolation OK: ${section_size} bytes, ${branch_count} checked branches") diff --git a/cmake/gcc-arm-none-eabi.cmake b/cmake/gcc-arm-none-eabi.cmake index 10971efb..962505fe 100644 --- a/cmake/gcc-arm-none-eabi.cmake +++ b/cmake/gcc-arm-none-eabi.cmake @@ -13,6 +13,7 @@ set(CMAKE_ASM_COMPILER ${CMAKE_C_COMPILER}) set(CMAKE_CXX_COMPILER ${TOOLCHAIN_PREFIX}g++) set(CMAKE_LINKER ${TOOLCHAIN_PREFIX}g++) set(CMAKE_OBJCOPY ${TOOLCHAIN_PREFIX}objcopy) +set(CMAKE_OBJDUMP ${TOOLCHAIN_PREFIX}objdump) set(CMAKE_SIZE ${TOOLCHAIN_PREFIX}size) set(CMAKE_EXECUTABLE_SUFFIX_ASM ".elf") From e4a645a5c735bf8143a2f05a58125859caba3c3c Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Fri, 21 Aug 2026 02:56:54 +0200 Subject: [PATCH 18/73] Add SetCfg to pick a config bank independently of the slot --- App/CMakeLists.txt | 3 + App/app/menu.c | 54 ++++++++++ App/app/uart.c | 10 +- App/driver/mb_flash.c | 242 +++++++++++++++++++++++++++--------------- App/driver/mb_flash.h | 134 ++++++++++++----------- App/driver/py25q16.c | 22 ++-- App/driver/py25q16.h | 28 ++--- App/main.c | 6 +- App/ui/menu.c | 96 ++++++++++++----- App/ui/menu.h | 3 + App/ui/multiboot.c | 111 ++++++++++++++----- App/ui/multiboot.h | 36 +++++-- 12 files changed, 502 insertions(+), 243 deletions(-) diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index 374716af..28d91358 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -241,6 +241,9 @@ enable_feature(ENABLE_FEAT_F4HWN_MULTIBOOT driver/mb_flash.c ui/multiboot.c ) +if(ENABLE_FEAT_F4HWN_MULTIBOOT AND NOT ENABLE_FEAT_F4HWN) + message(FATAL_ERROR "ENABLE_FEAT_F4HWN_MULTIBOOT requires ENABLE_FEAT_F4HWN (the SetCfg menu, SysInfo badge and multiboot UI all live under ENABLE_FEAT_F4HWN).") +endif() enable_feature(ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY) if(ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY AND NOT ENABLE_FEAT_F4HWN_MULTIBOOT) diff --git a/App/app/menu.c b/App/app/menu.c index 37c084da..b24af6f7 100644 --- a/App/app/menu.c +++ b/App/app/menu.c @@ -30,6 +30,10 @@ #include "driver/eeprom.h" #include "driver/gpio.h" #include "driver/keyboard.h" +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + #include "driver/mb_flash.h" + #include "ui/multiboot.h" +#endif #include "frequencies.h" #include "helper/battery.h" #include "misc.h" @@ -216,6 +220,13 @@ int MENU_GetLimits(uint8_t menu_id, int32_t *pMin, int32_t *pMax) *pMax = ARRAY_SIZE(gSubMenu_RESET) - 1; break; +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + case MENU_SET_CFG: + //*pMin = 0; + *pMax = MB_BANK_COUNT - 1; + break; +#endif + case MENU_COMPAND: case MENU_ABR_ON_TX_RX: //*pMin = 0; @@ -1092,6 +1103,12 @@ void MENU_ShowCurrentSetting(void) gSubMenuSelection = 0; break; +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + case MENU_SET_CFG: + gSubMenuSelection = MB_GetActiveBank(); + break; +#endif + case MENU_R_DCS: case MENU_R_CTCS: { @@ -2006,6 +2023,9 @@ static void MENU_Key_MENU(const bool bKeyPressed, const bool bKeyHeld) if (m == MENU_RESET || m == MENU_MEM_CH || m == MENU_DEL_CH || +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + m == MENU_SET_CFG || +#endif m == MENU_MEM_NAME) { switch (gAskForConfirmation) @@ -2034,6 +2054,40 @@ static void MENU_Key_MENU(const bool bKeyPressed, const bool bKeyHeld) NVIC_SystemReset(); #endif } +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + else if (m == MENU_SET_CFG) + { + /* Bind the chosen config bank, then reboot so it is mapped + * before any settings are read. Confirming the current bank + * is a no-op: do not wear a marker sector or reboot. */ + if (gSubMenuSelection == MB_GetActiveBank()) + { + gFlagAcceptSetting = false; + gIsInSubMenu = false; + gAskForConfirmation = 0; + SCANNER_Stop(); + return; + } + + const uint8_t err = MB_SetActiveBank(gSubMenuSelection); + if (err != MB_OK) + { + /* The previous redundant marker remains authoritative. + * Explain the failure and keep the selector open. */ + UI_MultibootShowConfigError(err); + gAskForConfirmation = 0; + gRequestDisplayScreen = DISPLAY_MENU; + SCANNER_Stop(); + return; + } + + #if defined(ENABLE_OVERLAY) + overlay_FLASH_RebootToBootloader(); + #else + NVIC_SystemReset(); + #endif + } +#endif gFlagAcceptSetting = true; gIsInSubMenu = false; diff --git a/App/app/uart.c b/App/app/uart.c index db9b8994..e53faed7 100644 --- a/App/app/uart.c +++ b/App/app/uart.c @@ -985,24 +985,24 @@ void UART_HandleCommand(uint32_t Port) break; } - case 0x0728: // profile config reset: wipe the 64 KiB config bank of a slot + case 0x0728: // config reset: wipe the 64 KiB of a config bank (1..4) { gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s) - uint8_t slot = pUART_Command->Data[0]; + uint8_t bank = pUART_Command->Data[0]; uint32_t ts = (uint32_t)pUART_Command->Data[2] | ((uint32_t)pUART_Command->Data[3] << 8) | ((uint32_t)pUART_Command->Data[4] << 16) | ((uint32_t)pUART_Command->Data[5] << 24); uint8_t status = (ts != mb_port_timestamp(Port)) - ? MB_ERR_AUTH : MB_ProfileErase(slot); + ? MB_ERR_AUTH : MB_BankErase(bank); struct __attribute__((packed)) { Header_t Header; - uint8_t Slot; + uint8_t Bank; // echoes the erased bank (same wire layout as slot replies) uint8_t Status; } Reply; Reply.Header.ID = 0x0729; Reply.Header.Size = 2; - Reply.Slot = slot; + Reply.Bank = bank; Reply.Status = status; SendReply(Port, &Reply, sizeof(Reply)); break; diff --git a/App/driver/mb_flash.c b/App/driver/mb_flash.c index 2b226bee..9b97a1c1 100644 --- a/App/driver/mb_flash.c +++ b/App/driver/mb_flash.c @@ -1,4 +1,5 @@ -/* Copyright 2026 F4HWN +/* 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. @@ -775,13 +776,20 @@ uint8_t MB_SlotWrite(uint8_t slot, uint32_t offset, const uint8_t *data, uint32_ } /* -------------------------------------------------------------------------- */ -/* Per-profile settings banks. */ +/* Config banks and the active-state marker. */ /* */ -/* The banking offset itself lives in the flash driver (PY25Q16_SetProfileBase);*/ -/* here we only own the active-profile marker and the profile->base mapping. */ +/* The banking offset itself lives in the flash driver (PY25Q16_SetBankBase); */ +/* here we only own the active-state marker and the bank -> base mapping. */ /* All external-flash only, never brick-critical. */ /* -------------------------------------------------------------------------- */ +_Static_assert(sizeof(mb_state_t) == 24u, + "FMP2/FMP3 marker layout must stay 24 bytes"); +_Static_assert(offsetof(mb_state_t, firmware_slot) == 16u, + "FMP2 migration reads the coupled index at byte 16"); +_Static_assert(offsetof(mb_state_t, state_crc32) == 20u, + "state CRC must cover the first 20 bytes (magic..bank_inv)"); + static uint32_t mb_crc32_bytes(const uint8_t *p, uint32_t len) { uint32_t crc = 0xFFFFFFFFu; @@ -795,38 +803,72 @@ static uint32_t mb_crc32_bytes(const uint8_t *p, uint32_t len) return crc ^ 0xFFFFFFFFu; } -uint32_t MB_ProfileBase(uint8_t profile) +uint32_t MB_BankBase(uint8_t bank) { - if (profile == 0) - return 0; /* profile 0 = historical config region */ - if (profile < MB_PROFILE_COUNT) - return MB_PROFILE1_EXT_BASE + (uint32_t)(profile - 1u) * MB_PROFILE_BANK_SIZE; + if (bank == 0) + return 0; /* bank 0 = historical config region */ + if (bank < MB_BANK_COUNT) + return MB_BANK1_EXT_BASE + (uint32_t)(bank - 1u) * MB_BANK_SIZE; return 0; /* out of range -> safe default */ } -static mb_mark_status_t mb_read_profile_copy(uint32_t base, mb_profile_state_t *st) +static mb_mark_status_t mb_read_state_copy(uint32_t base, mb_state_t *st) { mb_spi_err = 0; mb_ext_read(base, (uint8_t *)st, sizeof(*st)); if (mb_spi_err) return MB_MARK_IO; if (st->magic == 0xFFFFFFFFu) return MB_MARK_MISSING; - if (st->magic == MB_PROFILE_LEGACY_MAGIC) + if (st->magic == MB_STATE_LEGACY_MAGIC) { - /* FMP1 was { magic, index, ~index, reserved[2] }. Preserve its slot - * choice long enough for boot resolution to migrate it to FMP2. */ + /* FMP1 was { magic, index, ~index, reserved[2] }, with index coupling the + * firmware slot and the config bank. Keep it as BOTH fields so boot + * resolution can honour that choice before migrating the record to FMP3. */ const uint8_t legacy_index = ((const uint8_t *)st)[4]; const uint8_t legacy_inv = ((const uint8_t *)st)[5]; - if ((uint8_t)~legacy_inv != legacy_index || legacy_index >= MB_PROFILE_COUNT) + if ((uint8_t)~legacy_inv != legacy_index || legacy_index >= MB_BANK_COUNT) return MB_MARK_CORRUPT; memset(st, 0, sizeof(*st)); - st->magic = MB_PROFILE_LEGACY_MAGIC; - st->index = legacy_index; - st->index_inv = legacy_inv; + st->magic = MB_STATE_LEGACY_MAGIC; + st->firmware_slot = legacy_index; + st->slot_inv = legacy_inv; + st->config_bank = legacy_index; + st->bank_inv = legacy_inv; return MB_MARK_LEGACY; } - if (st->magic != MB_PROFILE_MAGIC) return MB_MARK_CORRUPT; - if ((uint8_t)~st->index_inv != st->index) return MB_MARK_CORRUPT; - if (st->index >= MB_PROFILE_COUNT) return MB_MARK_CORRUPT; + + if (st->magic == MB_STATE_V2_MAGIC) + { + /* FMP2 stored one index for both the firmware slot and config bank at + * byte 16, followed by its inverse and two zeroed reserved bytes. + * Validate the on-flash record before normalizing it in RAM to FMP3. */ + const uint8_t legacy_index = ((const uint8_t *)st)[16]; + const uint8_t legacy_inv = ((const uint8_t *)st)[17]; + if ((uint8_t)~legacy_inv != legacy_index || + legacy_index >= MB_BANK_COUNT) + return MB_MARK_CORRUPT; + if (st->image_size == 0u || st->image_size > MB_INT_APP_SIZE) + return MB_MARK_CORRUPT; + if (mb_crc32_bytes((const uint8_t *)st, + sizeof(*st) - sizeof(st->state_crc32)) != st->state_crc32) + return MB_MARK_CORRUPT; + + st->magic = MB_STATE_MAGIC; + st->firmware_slot = legacy_index; + st->slot_inv = (uint8_t)~legacy_index; + st->config_bank = legacy_index; + st->bank_inv = (uint8_t)~legacy_index; + st->state_crc32 = mb_crc32_bytes((const uint8_t *)st, + sizeof(*st) - sizeof(st->state_crc32)); + return MB_MARK_VALID; + } + + if (st->magic != MB_STATE_MAGIC) return MB_MARK_CORRUPT; + if ((uint8_t)~st->slot_inv != st->firmware_slot) + return MB_MARK_CORRUPT; + if (st->firmware_slot >= MB_SLOT_COUNT) return MB_MARK_CORRUPT; + if ((uint8_t)~st->bank_inv != st->config_bank) + return MB_MARK_CORRUPT; + if (st->config_bank >= MB_BANK_COUNT) return MB_MARK_CORRUPT; if (st->image_size == 0u || st->image_size > MB_INT_APP_SIZE) return MB_MARK_CORRUPT; if (mb_crc32_bytes((const uint8_t *)st, @@ -840,43 +882,43 @@ static bool mb_generation_newer(uint32_t a, uint32_t b) return (int32_t)(a - b) > 0; } -static mb_mark_status_t mb_read_active_profile(mb_profile_state_t *state, - uint32_t *state_base) +static mb_mark_status_t mb_read_active_state(mb_state_t *state, + uint32_t *state_base) { - mb_profile_state_t a; - mb_profile_state_t b; - mb_mark_status_t sa = mb_read_profile_copy(MB_PROFILE_STATE_A_BASE, &a); - mb_mark_status_t sb = mb_read_profile_copy(MB_PROFILE_STATE_B_BASE, &b); + mb_state_t a; + mb_state_t b; + mb_mark_status_t sa = mb_read_state_copy(MB_STATE_A_BASE, &a); + mb_mark_status_t sb = mb_read_state_copy(MB_STATE_B_BASE, &b); /* If either sector could not be read, it might contain the newest record. * Do not silently select an older state and risk loading the wrong bank. */ if (sa == MB_MARK_IO || sb == MB_MARK_IO) return MB_MARK_IO; - const mb_profile_state_t *chosen = NULL; + const mb_state_t *chosen = NULL; uint32_t chosen_base = 0; if (sa == MB_MARK_VALID && sb == MB_MARK_VALID) { if (mb_generation_newer(b.generation, a.generation)) { chosen = &b; - chosen_base = MB_PROFILE_STATE_B_BASE; + chosen_base = MB_STATE_B_BASE; } else { chosen = &a; - chosen_base = MB_PROFILE_STATE_A_BASE; + chosen_base = MB_STATE_A_BASE; } } else if (sa == MB_MARK_VALID) { chosen = &a; - chosen_base = MB_PROFILE_STATE_A_BASE; + chosen_base = MB_STATE_A_BASE; } else if (sb == MB_MARK_VALID) { chosen = &b; - chosen_base = MB_PROFILE_STATE_B_BASE; + chosen_base = MB_STATE_B_BASE; } if (chosen) @@ -889,14 +931,14 @@ static mb_mark_status_t mb_read_active_profile(mb_profile_state_t *state, } /* A legacy record is usable for migration but has no firmware identity. - * Prefer A if both somehow exist; the next successful repair writes FMP2. */ + * Prefer A if both somehow exist; the next successful repair writes FMP3. */ if (sa == MB_MARK_LEGACY || sb == MB_MARK_LEGACY) { const bool use_b = sa != MB_MARK_LEGACY; if (state) *state = use_b ? b : a; if (state_base) - *state_base = use_b ? MB_PROFILE_STATE_B_BASE : MB_PROFILE_STATE_A_BASE; + *state_base = use_b ? MB_STATE_B_BASE : MB_STATE_A_BASE; return MB_MARK_LEGACY; } @@ -904,79 +946,113 @@ static mb_mark_status_t mb_read_active_profile(mb_profile_state_t *state, ? MB_MARK_MISSING : MB_MARK_CORRUPT; } -mb_mark_status_t MB_ReadActiveProfile(mb_profile_state_t *state) +mb_mark_status_t MB_ReadActiveState(mb_state_t *state) { - return mb_read_active_profile(state, NULL); + return mb_read_active_state(state, NULL); } -uint8_t MB_GetActiveProfile(void) +/* Commit a marker whose identity, firmware_slot and config_bank are set: + * fill the volatile fields (magic, generation, inverse bytes, state CRC), pick + * the inactive redundant sector, program it and read it back to confirm. The + * previous valid record is left untouched until the new one verifies, so this is + * power-loss safe. `current`/`current_base` come from mb_read_active_state. */ +static uint8_t mb_commit_state(mb_state_t *st, + mb_mark_status_t current_status, + const mb_state_t *current, + uint32_t current_base) { - mb_profile_state_t st; - mb_mark_status_t status = MB_ReadActiveProfile(&st); - return (status == MB_MARK_VALID || status == MB_MARK_LEGACY) ? st.index : 0; -} - -uint8_t MB_SetActiveProfile(uint8_t profile) -{ - mb_slot_header_t hdr; - mb_profile_state_t st; - mb_profile_state_t current; - uint32_t current_base = 0; - - if (profile >= MB_PROFILE_COUNT) - return MB_ERR_SLOT; - - uint8_t hdr_err = mb_read_header(profile, &hdr); - if (hdr_err != MB_OK) - return hdr_err; - - mb_mark_status_t current_status = mb_read_active_profile(¤t, ¤t_base); - if (current_status == MB_MARK_IO) - return MB_ERR_SPI; - - memset(&st, 0, sizeof(st)); - st.magic = MB_PROFILE_MAGIC; - st.generation = (current_status == MB_MARK_VALID) ? current.generation + 1u : 0u; - st.image_size = hdr.image_size; - st.image_crc32 = hdr.image_crc32; - st.index = profile; - st.index_inv = (uint8_t)~profile; - st.state_crc32 = mb_crc32_bytes((const uint8_t *)&st, - sizeof(st) - sizeof(st.state_crc32)); + st->magic = MB_STATE_MAGIC; + st->generation = (current_status == MB_MARK_VALID) ? current->generation + 1u : 0u; + st->slot_inv = (uint8_t)~st->firmware_slot; + st->bank_inv = (uint8_t)~st->config_bank; + st->state_crc32 = mb_crc32_bytes((const uint8_t *)st, + sizeof(*st) - sizeof(st->state_crc32)); const uint32_t target_base = ((current_status == MB_MARK_VALID || current_status == MB_MARK_LEGACY) && - current_base == MB_PROFILE_STATE_A_BASE) - ? MB_PROFILE_STATE_B_BASE - : MB_PROFILE_STATE_A_BASE; + current_base == MB_STATE_A_BASE) + ? MB_STATE_B_BASE + : MB_STATE_A_BASE; mb_spi_err = 0; mb_spi_polled_mode(); if (!mb_ext_sector_erase(target_base)) return MB_ERR_SPI; - if (!mb_ext_program(target_base, (const uint8_t *)&st, sizeof(st))) + if (!mb_ext_program(target_base, (const uint8_t *)st, sizeof(*st))) return MB_ERR_SPI; /* Verify the new copy directly. The previous valid sector has not been * touched, so any failure here remains power-loss safe. */ - mb_profile_state_t chk; - mb_mark_status_t chk_status = mb_read_profile_copy(target_base, &chk); + mb_state_t chk; + mb_mark_status_t chk_status = mb_read_state_copy(target_base, &chk); if (chk_status == MB_MARK_IO) return MB_ERR_SPI; if (chk_status != MB_MARK_VALID || - memcmp(&chk, &st, sizeof(st)) != 0) return MB_ERR_CRC; + memcmp(&chk, st, sizeof(*st)) != 0) return MB_ERR_CRC; return MB_OK; } -uint8_t MB_ProfileErase(uint8_t profile) +uint8_t MB_SetActiveSlot(uint8_t slot) { - /* Profile 0 (the base config) is not resettable from the host: reset it by - * factory-resetting the running base firmware instead. Profiles 1..N live - * in their own 64 KiB banks, clear of the shared calibration at 0x010000. */ - if (profile == 0 || profile >= MB_PROFILE_COUNT) + mb_slot_header_t hdr; + mb_state_t st; + mb_state_t current; + uint32_t current_base = 0; + + if (slot >= MB_SLOT_COUNT) return MB_ERR_SLOT; - const uint32_t base = MB_ProfileBase(profile); + uint8_t hdr_err = mb_read_header(slot, &hdr); + if (hdr_err != MB_OK) + return hdr_err; + + mb_mark_status_t current_status = mb_read_active_state(¤t, ¤t_base); + if (current_status == MB_MARK_IO) + return MB_ERR_SPI; + + memset(&st, 0, sizeof(st)); + st.image_size = hdr.image_size; /* expect this slot's image (reflash) */ + st.image_crc32 = hdr.image_crc32; + st.firmware_slot = slot; + st.config_bank = slot; + return mb_commit_state(&st, current_status, ¤t, current_base); +} + +uint8_t MB_SetActiveBank(uint8_t bank) +{ + mb_state_t st; + mb_state_t current; + uint32_t current_base = 0; + + if (bank >= MB_BANK_COUNT) + return MB_ERR_SLOT; + + /* Keep the running firmware's identity from the current marker so switching a + * bank never looks like an out-of-multiboot firmware change at the next boot. + * A valid marker is required for that identity - every normal boot leaves one. */ + mb_mark_status_t current_status = mb_read_active_state(¤t, ¤t_base); + if (current_status == MB_MARK_IO) + return MB_ERR_SPI; + if (current_status != MB_MARK_VALID) + return MB_ERR_MAGIC; + + memset(&st, 0, sizeof(st)); + st.image_size = current.image_size; /* keep the running firmware's identity */ + st.image_crc32 = current.image_crc32; + st.firmware_slot = current.firmware_slot; + st.config_bank = bank; + return mb_commit_state(&st, current_status, ¤t, current_base); +} + +uint8_t MB_BankErase(uint8_t bank) +{ + /* Bank 0 (the base config) is not resettable from the host: reset it by + * factory-resetting the running base firmware instead. Banks 1..N live + * in their own 64 KiB banks, clear of the shared calibration at 0x010000. */ + if (bank == 0 || bank >= MB_BANK_COUNT) + return MB_ERR_SLOT; + + const uint32_t base = MB_BankBase(bank); /* Invalidate before the first raw erase so an early failure cannot leave a * cache entry referring to a sector that was already erased. */ @@ -984,7 +1060,7 @@ uint8_t MB_ProfileErase(uint8_t profile) mb_spi_err = 0; mb_spi_polled_mode(); - for (uint32_t off = 0; off < MB_PROFILE_BANK_SIZE; off += MB_EXT_SECTOR) + for (uint32_t off = 0; off < MB_BANK_SIZE; off += MB_EXT_SECTOR) if (!mb_ext_sector_erase(base + off)) return MB_ERR_SPI; @@ -1035,7 +1111,7 @@ mb_fw_match_t MB_InternalMatchesSlot(uint8_t slot) ? MB_FW_MATCH : MB_FW_MISMATCH; } -bool MB_InternalMatchesProfile(const mb_profile_state_t *state) +bool MB_InternalMatchesState(const mb_state_t *state) { if (!state || state->image_size == 0u || state->image_size > MB_INT_APP_SIZE) return false; diff --git a/App/driver/mb_flash.h b/App/driver/mb_flash.h index 14abc758..4c8e1215 100644 --- a/App/driver/mb_flash.h +++ b/App/driver/mb_flash.h @@ -1,4 +1,5 @@ -/* Copyright 2026 F4HWN +/* 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. @@ -25,8 +26,6 @@ * can never brick the radio. There is a single firmware for both the K1 and the * K5v3 (the keypad difference is handled at runtime by the hidden SetNav menu), * so no per-model guard is needed. - * - * See docs/multiboot-design.md for the overall design. */ #ifndef DRIVER_MB_FLASH_H @@ -42,7 +41,7 @@ #define MB_INT_APP_BASE 0x08002800u #define MB_INT_APP_SIZE 0x0001D800u /* 118 KiB */ -/* External SPI flash slot layout (see docs/multiboot-design.md). +/* External SPI flash slot layout. * Each 128 KiB slot = one header sector (4 KiB) followed by the image. * Slot 0 is the firmware-managed BACKUP of the normally-flashed firmware * (written by MB_BackupInternalToSlot0, protected from host writes); slots @@ -80,7 +79,7 @@ enum { MB_ERR_SIZE, /* image_size out of range */ MB_ERR_CRC, /* image CRC32 mismatch */ MB_ERR_SPI, /* external flash read/write timed out*/ - MB_ERR_SLOT, /* slot index out of range */ + MB_ERR_SLOT, /* slot or bank index out of range */ MB_ERR_AUTH, /* write refused: timestamp mismatch */ MB_ERR_RAM_LOAD /* restore stub RAM copy mismatch */ }; @@ -112,95 +111,108 @@ uint8_t MB_SlotErase(uint8_t slot); uint8_t MB_SlotWrite(uint8_t slot, uint32_t offset, const uint8_t *data, uint32_t len); /* -------------------------------------------------------------------------- */ -/* Per-profile settings banks. */ +/* Config banks (one per slot by default, switchable via SetCfg). */ /* */ -/* Each firmware slot gets its own copy of the user configuration (memory */ -/* channels, names, VFOs, settings) so switching firmware no longer shares - */ -/* or silently clobbers - settings between editions. The banking itself is a */ -/* single address offset applied in the flash driver (see */ -/* PY25Q16_SetProfileBase); everything below PY25Q16_PROFILE_SHARED_FROM */ -/* (0x010000, the calibration boundary) is per-profile, everything at/above */ -/* stays shared. Slot N is bound to profile N. Profile 0 (slot 0 = base */ -/* backup) reuses the historical config region at 0x000000 - no migration. */ +/* Each firmware slot gets its own config bank by default (memory channels, */ +/* names, VFOs, settings) so switching firmware does not implicitly share or */ +/* clobber settings between editions. SetCfg deliberately lets the user pick */ +/* another bank after confirmation; compatibility is then the user's concern. */ +/* The banking itself is a single address offset applied in the flash driver */ +/* (PY25Q16_SetBankBase): everything below PY25Q16_BANK_SHARED_FROM */ +/* (0x010000, the calibration boundary) is per-bank, at/above stays shared. */ +/* Restoring slot N selects bank N initially, but slot and bank are tracked */ +/* independently afterwards. Bank 0 reuses the historical config region at */ +/* 0x000000 - no migration. */ /* */ /* External SPI flash (PY25Q16, 2 MiB) map: */ -/* 0x000000 profile 0 config (channels/settings) ] per-profile */ +/* 0x000000 bank 0 config (channels/settings) ] per-bank */ /* 0x010000 calibration (512 B) ] shared */ /* 0x011000 boot logo (4 KiB) ] shared */ -/* 0x020000 slot 0 firmware (BACKUP, 128 KiB) ] firmware-managed */ -/* 0x040000 slot 1 firmware (128 KiB) ] */ -/* 0x060000 slot 2 firmware ] user (UV Studio) */ -/* 0x080000 slot 3 firmware ] */ -/* 0x0A0000 slot 4 firmware ] */ -/* 0x0C0000 profile 1 config (64 KiB) ] */ -/* 0x0D0000 profile 2 config (64 KiB) ] per-profile */ -/* 0x0E0000 profile 3 config (64 KiB) ] */ -/* 0x0F0000 profile 4 config (64 KiB) ] */ +/* 0x020000 slot 0 firmware (BACKUP, 128 KiB) ] firmware-managed */ +/* 0x040000 slot 1 firmware (128 KiB) ] */ +/* 0x060000 slot 2 firmware ] user (UV Studio) */ +/* 0x080000 slot 3 firmware ] */ +/* 0x0A0000 slot 4 firmware ] */ +/* 0x0C0000 bank 1 config (64 KiB) ] */ +/* 0x0D0000 bank 2 config (64 KiB) ] per-bank */ +/* 0x0E0000 bank 3 config (64 KiB) ] */ +/* 0x0F0000 bank 4 config (64 KiB) ] */ /* 0x100000 multiboot state A (4 KiB marker) ] shared */ /* 0x101000 multiboot state B (4 KiB marker) ] redundant */ /* 0x102000 -- free ~888 KiB -- */ -/* 0x1E0000 RX/TX log (32 KiB) ] shared */ +/* 0x1E0000 RX/TX log (32 KiB) ] shared */ /* */ /* Banks are 64 KiB for headroom; the live config footprint is ~44 KiB (max */ -/* physical config address 0x00A170). Keep the profile banks past the last */ +/* physical config address 0x00A170). Keep the config banks past the last */ /* slot if MB_SLOT_COUNT ever grows. */ -#define MB_PROFILE_COUNT MB_SLOT_COUNT /* one profile per slot (0..4) */ -#define MB_PROFILE_BANK_SIZE 0x00010000u /* 64 KiB per config bank */ -#define MB_PROFILE1_EXT_BASE 0x000C0000u /* profiles 1..4, right after slot 4 */ -#define MB_PROFILE_STATE_A_BASE 0x00100000u /* redundant marker sector A */ -#define MB_PROFILE_STATE_B_BASE 0x00101000u /* redundant marker sector B */ +#define MB_BANK_COUNT MB_SLOT_COUNT /* selectable config banks (0..4) */ +#define MB_BANK_SIZE 0x00010000u /* 64 KiB per config bank */ +#define MB_BANK1_EXT_BASE 0x000C0000u /* banks 1..4, right after slot 4 */ +#define MB_STATE_A_BASE 0x00100000u /* redundant marker sector A */ +#define MB_STATE_B_BASE 0x00101000u /* redundant marker sector B */ -/* Active-profile marker: two alternating external-flash sectors, never banked, +/* Active-state marker: two alternating external-flash sectors, never banked, * outside the EEPROM logical map. A new record is verified in the inactive * sector before it supersedes the previous one, so a power loss always leaves * at least one usable state. The expected firmware identity is stored here as * well: boot resolution never depends on the slot header remaining readable. */ -#define MB_PROFILE_LEGACY_MAGIC 0x31504D46u /* "FMP1" (single 8-byte record) */ -#define MB_PROFILE_MAGIC 0x32504D46u /* "FMP2" (redundant identity record) */ +#define MB_STATE_LEGACY_MAGIC 0x31504D46u /* "FMP1" (single 8-byte record) */ +#define MB_STATE_V2_MAGIC 0x32504D46u /* "FMP2" (slot == config bank) */ +#define MB_STATE_MAGIC 0x33504D46u /* "FMP3" (slot + bank separated) */ typedef struct __attribute__((packed)) { - uint32_t magic; /* MB_PROFILE_MAGIC */ - uint32_t generation; /* monotonically increasing record version */ - uint32_t image_size; /* expected internal image size */ - uint32_t image_crc32; /* expected internal image CRC-32 */ - uint8_t index; /* active profile 0..MB_PROFILE_COUNT-1 */ - uint8_t index_inv; /* ~index, quick integrity check */ - uint8_t reserved[2]; /* fixed zero for deterministic state CRC */ - uint32_t state_crc32; /* CRC-32 over all preceding fields */ -} mb_profile_state_t; + uint32_t magic; /* MB_STATE_MAGIC */ + uint32_t generation; /* monotonically increasing record version */ + uint32_t image_size; /* expected internal image size */ + uint32_t image_crc32; /* expected internal image CRC-32 */ + uint8_t firmware_slot; /* exact source slot of the running image */ + uint8_t slot_inv; /* ~firmware_slot, quick integrity check */ + uint8_t config_bank; /* active config bank 0..MB_BANK_COUNT-1 */ + uint8_t bank_inv; /* ~config_bank, quick integrity check */ + uint32_t state_crc32; /* CRC-32 over all preceding fields */ +} mb_state_t; -/* External-flash base of a profile's config bank. Profile 0 -> 0 (historical - * region, identity map); profiles 1..N -> past the slots. Out-of-range -> 0. */ -uint32_t MB_ProfileBase(uint8_t profile); +/* External-flash base of a config bank. Bank 0 -> 0 (historical + * region, identity map); banks 1..N -> past the slots. Out-of-range -> 0. */ +uint32_t MB_BankBase(uint8_t bank); -/* Result of reading the active-profile marker. A boot must treat these very +/* Result of reading the active-state marker. A boot must treat these very * differently: MISSING = fresh radio (adopting the running firmware as Main is * fine); IO / CORRUPT = uncertain (must NOT overwrite Main). */ typedef enum { - MB_MARK_VALID = 0, /* read OK, well-formed; *state populated */ + MB_MARK_VALID = 0, /* valid FMP2/FMP3; *state normalized to FMP3 */ MB_MARK_LEGACY, /* valid FMP1 index; identity not stored */ MB_MARK_MISSING, /* read OK but both sectors erased */ MB_MARK_CORRUPT, /* read OK but magic/integrity bad */ MB_MARK_IO, /* could not be read (SPI error) */ } mb_mark_status_t; -/* Read the newest valid active-profile marker, distinguishing the states above. */ -mb_mark_status_t MB_ReadActiveProfile(mb_profile_state_t *state); +/* Read the newest valid active-state marker, distinguishing the states above. */ +mb_mark_status_t MB_ReadActiveState(mb_state_t *state); -/* Convenience wrapper for non-critical callers (e.g. cursor pre-selection): - * the valid index, or 0 for anything not cleanly VALID. */ -uint8_t MB_GetActiveProfile(void); - -/* Write the active-profile marker into the inactive redundant sector and read it +/* Write the active-state marker into the inactive redundant sector and read it * back to confirm it landed. The previous valid record is kept intact. The slot - * header supplies the expected internal firmware identity. */ -uint8_t MB_SetActiveProfile(uint8_t profile); + * header supplies the expected internal firmware identity. Use this right before + * reflashing to `slot`: both the exact firmware slot and the initial config + * bank become `slot`. */ +uint8_t MB_SetActiveSlot(uint8_t slot); -/* Erase a profile's whole config bank (host "Reset config" for a user slot): - * the next boot on that slot reads 0xFF and re-seeds factory defaults. Profile 0 +/* Switch ONLY the active settings bank, keeping the firmware that is running. + * Unlike MB_SetActiveSlot (which records that slot's image as the + * expected identity, for the imminent reflash to that slot), this preserves the + * running firmware's identity taken from the current marker and changes only the + * config bank. The next boot therefore maps a different bank with no + * reflash and is never mistaken for an out-of-multiboot firmware change (which + * would self-backup + reset to bank 0). Requires a currently valid marker - + * what every normal boot leaves behind - else MB_ERR_SPI / MB_ERR_MAGIC. The + * caller resets the MCU afterwards; the new bank takes effect at the next boot. */ +uint8_t MB_SetActiveBank(uint8_t bank); + +/* Erase a whole config bank (host "Reset config" for a user slot): + * the next boot using that bank reads 0xFF and re-seeds factory defaults. Bank 0 * (the base) is refused - reset it by factory-resetting the base firmware. * External flash only, never brick-critical. */ -uint8_t MB_ProfileErase(uint8_t profile); +uint8_t MB_BankErase(uint8_t bank); /* -------------------------------------------------------------------------- */ /* Slot 0 self-backup (base firmware). */ @@ -223,7 +235,7 @@ typedef enum { mb_fw_match_t MB_InternalMatchesSlot(uint8_t slot); /* Compare internal flash with the identity stored in a validated marker. */ -bool MB_InternalMatchesProfile(const mb_profile_state_t *state); +bool MB_InternalMatchesState(const mb_state_t *state); /* Back up the running internal firmware into slot 0 (erase + full image + * COMMITTED header, name=edition, version) and validate the stored image by a diff --git a/App/driver/py25q16.c b/App/driver/py25q16.c index dfde7ac2..99b71f14 100644 --- a/App/driver/py25q16.c +++ b/App/driver/py25q16.c @@ -56,24 +56,24 @@ static uint8_t BlackHole[4] __attribute__((aligned(4))); static volatile bool TC_Flag; #ifdef ENABLE_FEAT_F4HWN_MULTIBOOT -/* Active settings-profile base (see py25q16.h). 0 = profile 0 / historical +/* Active settings-bank base (see py25q16.h). 0 = bank 0 / historical * config region, i.e. an identity mapping. */ -static uint32_t ProfileBase = 0; +static uint32_t BankBase = 0; -void PY25Q16_SetProfileBase(uint32_t Base) +void PY25Q16_SetBankBase(uint32_t Base) { - ProfileBase = Base; + BankBase = Base; } /* Redirect config-region accesses (addr < boundary) into the active bank. * Calibration/logo/slots/marker (addr >= boundary) are returned unchanged. - * ProfileBase is sector-aligned, so alignment done by callers is preserved. */ -static inline uint32_t ProfileMap(uint32_t Address) + * BankBase is sector-aligned, so alignment done by callers is preserved. */ +static inline uint32_t BankMap(uint32_t Address) { - return (Address < PY25Q16_PROFILE_SHARED_FROM) ? (Address + ProfileBase) : Address; + return (Address < PY25Q16_BANK_SHARED_FROM) ? (Address + BankBase) : Address; } #else -static inline uint32_t ProfileMap(uint32_t Address) +static inline uint32_t BankMap(uint32_t Address) { return Address; } @@ -294,7 +294,7 @@ static void ReadBufferRaw(uint32_t Address, void *pBuffer, uint32_t Size) void PY25Q16_ReadBuffer(uint32_t Address, void *pBuffer, uint32_t Size) { - ReadBufferRaw(ProfileMap(Address), pBuffer, Size); + ReadBufferRaw(BankMap(Address), pBuffer, Size); } // Like PY25Q16_ReadBuffer, but waits for the flash to be idle first (WIP=0), @@ -311,7 +311,7 @@ void PY25Q16_ReadBufferSafe(uint32_t Address, void *pBuffer, uint32_t Size) void PY25Q16_WriteBuffer(uint32_t Address, const void *pBuffer, uint32_t Size, bool Append) { - Address = ProfileMap(Address); /* map once; internal reads use *Raw below */ + Address = BankMap(Address); /* map once; internal reads use *Raw below */ #ifdef DEBUG printf("spi flash write: %06x %ld %d\n", Address, Size, Append); @@ -400,7 +400,7 @@ void PY25Q16_WriteBuffer(uint32_t Address, const void *pBuffer, uint32_t Size, b void PY25Q16_SectorErase(uint32_t Address) { - Address = ProfileMap(Address); + Address = BankMap(Address); Address -= (Address % SECTOR_SIZE); SectorErase(Address); if (SectorCacheAddr == Address) diff --git a/App/driver/py25q16.h b/App/driver/py25q16.h index 5c93b7e3..2b013393 100644 --- a/App/driver/py25q16.h +++ b/App/driver/py25q16.h @@ -27,33 +27,35 @@ void PY25Q16_WriteBuffer(uint32_t Address, const void *pBuffer, uint32_t Size, b void PY25Q16_SectorErase(uint32_t Address); /* Drop the internal single-sector write cache. Call after erasing/programming - * flash behind the driver's back (e.g. the raw multiboot slot/profile ops) so a + * flash behind the driver's back (e.g. the raw multiboot slot/bank ops) so a * later write cannot skip or resurrect data based on a stale cached sector. It * is also called before multiboot reuses the cache storage as a RAM overlay. */ void PY25Q16_InvalidateCache(void); #ifdef ENABLE_FEAT_F4HWN_MULTIBOOT /* - * Multiboot per-profile config banking. + * Multiboot per-bank config banking. * - * Each firmware slot owns a private copy of the user configuration (memory - * channels, names, VFOs, settings). A non-zero profile base transparently - * shifts every flash access BELOW PY25Q16_PROFILE_SHARED_FROM into the active - * profile's bank; calibration, boot logo, firmware slots and the multiboot - * marker all live at/above that boundary and stay shared across every profile. + * Each firmware slot gets its own config bank by default (memory channels, + * names, VFOs, settings), though SetCfg can point the running firmware at a + * different bank. A non-zero bank base transparently shifts every flash access + * BELOW PY25Q16_BANK_SHARED_FROM into the active bank; calibration, boot logo, + * firmware slots and the multiboot marker all live at/above that boundary and + * stay shared across every bank. * * This is the single choke point: both the EEPROM emulation (eeprom_compat.c) * and the firmware's direct config reads/writes (settings.c) end up here, so * one offset covers them all - no per-call-site patching. * * Set once at boot, before any settings read, from - * PY25Q16_SetProfileBase(MB_ProfileBase(MB_BootResolveProfile())); - * and never changed again during a session (the profile only changes through a - * reflash + reset), so the banking itself never needs a cache flush. Raw profile - * erases behind the driver explicitly call PY25Q16_InvalidateCache(). + * PY25Q16_SetBankBase(MB_BankBase(MB_BootResolveState())); + * and never changed again during a session (a slot restore or SetCfg selection + * records the next bank and resets first), so the banking itself never needs a + * cache flush. Raw bank erases behind the driver explicitly call + * PY25Q16_InvalidateCache(). */ -#define PY25Q16_PROFILE_SHARED_FROM 0x00010000u /* calibration boundary (see flash map) */ -void PY25Q16_SetProfileBase(uint32_t Base); +#define PY25Q16_BANK_SHARED_FROM 0x00010000u /* calibration boundary (see flash map) */ +void PY25Q16_SetBankBase(uint32_t Base); #endif #endif diff --git a/App/main.c b/App/main.c index ad099c23..d7d1544f 100644 --- a/App/main.c +++ b/App/main.c @@ -86,11 +86,11 @@ void Main(void) BOARD_Init(); #ifdef ENABLE_FEAT_F4HWN_MULTIBOOT - /* Resolve the active settings profile BEFORE any EEPROM/settings access + /* Resolve the active settings bank BEFORE any EEPROM/settings access * below. This also adopts a normally-flashed firmware as slot 0 (discreet * self-backup) when the running image isn't the slot the marker points to. - * Calibration stays shared regardless of the selected profile. */ - PY25Q16_SetProfileBase(MB_ProfileBase(MB_BootResolveProfile())); + * Calibration stays shared regardless of the selected bank. */ + PY25Q16_SetBankBase(MB_BankBase(MB_BootResolveState())); #endif boot_counter_10ms = 250; // 2.5 sec diff --git a/App/ui/menu.c b/App/ui/menu.c index 6b048d28..be24d18b 100644 --- a/App/ui/menu.c +++ b/App/ui/menu.c @@ -43,6 +43,7 @@ #include "ui.h" #include "welcome.h" #ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + #include "driver/mb_flash.h" #include "multiboot.h" #endif @@ -177,6 +178,9 @@ const t_menu_item MenuList[] = #ifdef ENABLE_FEAT_F4HWN_LOGO_SAV {"SetSav", MENU_SET_SAV }, #endif +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + {"SetCfg", MENU_SET_CFG }, // load another settings bank (reboots) +#endif #endif // hidden menu items from here on // enabled if pressing both the PTT and upper side button at power-on @@ -601,6 +605,9 @@ static const uint8_t CatChannels[] = { #endif MENU_BCL, MENU_COMPAND, MENU_AM, MENU_TX_LOCK, MENU_PTT_ID, MENU_LIST_CH, MENU_MEM_CH, MENU_DEL_CH, MENU_MEM_NAME, +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + MENU_SET_CFG, +#endif }; static const uint8_t CatScan[] = { MENU_S_LIST, MENU_S_PRI, MENU_S_PRI_CH_1, MENU_S_PRI_CH_2, MENU_SC_REV, @@ -839,34 +846,27 @@ static void UI_MENU_DrawTopRightRoundedBadge(const char *text, const uint8_t lin UI_PrintStringSmallNormalInverse(text, text_x, 0, line); } -/* Single-line variant for tight gaps: unlike UI_PrintStringSmallNormalInverse, - * the rounded edge stays entirely inside `line` and never touches line - 1. */ -static void UI_MENU_DrawInlineRoundedBadge(const char *text, const uint8_t line, - const uint8_t area_x1, const uint8_t area_x2) +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT +/* Draw `text` (3x5 font) centred inside a fixed-width rounded inverse capsule: + * left edge `cap_left`, inclusive width `cap_w`, on framebuffer page `line`. Same + * capsule pattern as GUI_DisplaySmallestInverse (0x3E rounded ends, 0x7F body) but + * with the width decoupled from the text length, so two labels of different + * lengths (e.g. "SLOT 2" / "CFG 4") share one width and each stays centred. */ +static void UI_MENU_DrawFixedCapsule(const char *text, uint8_t cap_left, + uint8_t cap_w, uint8_t line) { - const size_t length = strlen(text); - const size_t char_pitch = ARRAY_SIZE(gFontSmall[0]) + 1u; - const size_t text_width = length * char_pitch; - const size_t capsule_width = text_width + 3u; + const uint8_t cap_right = (uint8_t)(cap_left + cap_w - 1u); + const uint8_t text_w = (uint8_t)(strlen(text) * 4u - 1u); /* 3x5 glyphs: 4 px/char, last one 3 px wide */ + const uint8_t tx = (uint8_t)(cap_left + (cap_w - text_w) / 2u); - if (length == 0 || line >= FRAME_LINES || area_x2 <= area_x1) { - return; - } + GUI_DisplaySmallest(text, tx, (uint8_t)(line * 8u + 1u), false, true); - const size_t area_width = area_x2 - area_x1 + 1u; - if (capsule_width >= area_width) - return; - - const uint8_t capsule_left = (uint8_t)(area_x1 + ((area_width - capsule_width) / 2u)); - const uint8_t text_x = (uint8_t)(capsule_left + 1u); - const uint8_t x_end = (uint8_t)(text_x + text_width + 1u); - - UI_PrintStringSmallNormal(text, text_x, 0, line); - gFrameBuffer[line][text_x - 1u] ^= 0x7Eu; - for (uint8_t x = text_x; x < x_end; x++) - gFrameBuffer[line][x] ^= 0xFFu; - gFrameBuffer[line][x_end] ^= 0x7Eu; + gFrameBuffer[line][cap_left] ^= 0x3Eu; + for (uint8_t x = (uint8_t)(cap_left + 1u); x < cap_right; x++) + gFrameBuffer[line][x] ^= 0x7Fu; + gFrameBuffer[line][cap_right] ^= 0x3Eu; } +#endif void UI_DisplayMenu(void) { @@ -1454,12 +1454,39 @@ void UI_DisplayMenu(void) sprintf(String, "%s\n%s", AUTHOR_STRING_2, DISPLAY_VERSION_STRING_2); UI_PrintStringSmallNormal(Edition, menu_item_x1 - 1, menu_item_x2, 6); #ifdef ENABLE_FEAT_F4HWN_MULTIBOOT - const uint8_t running_slot = MB_GetRunningSlot(); - char slot_badge[2]; + /* Two 3x5 inverse-capsule labels on one line (scan-list "label" + * style): the running firmware slot (M = Main) and the active + * config bank. They match unless SetCfg has pointed the bank at a + * different bank (e.g. SLOT 2 / CFG 4). */ + const uint8_t fw_slot = MB_GetRunningSlot(); + const uint8_t bank = MB_GetActiveBank(); + char slot_lbl[8]; + char cfg_lbl[8]; - slot_badge[0] = (running_slot == 0u) ? 'M' : (char)('0' + running_slot); - slot_badge[1] = '\0'; - UI_MENU_DrawInlineRoundedBadge(slot_badge, 5, menu_item_x1, menu_item_x2); + /* Only the last glyph varies (M / digit / ?), so poke it in place + * instead of pulling sprintf for a single character. */ + strcpy(slot_lbl, "SLOT ?"); + if (fw_slot == 0u) + slot_lbl[5] = 'M'; + else if (fw_slot < MB_SLOT_COUNT) + slot_lbl[5] = (char)('0' + fw_slot); + strcpy(cfg_lbl, "CFG M"); /* bank 0 = base config, like SLOT M */ + if (bank != 0u) + cfg_lbl[4] = (char)('0' + bank); + + /* Both capsules share the wider label's width (6-char "SLOT x" -> + * 4*6+3 = 27 px); the shorter CFG text is centred inside its own. + * The two are drawn as one centred pair with a small gap, centred in + * the space between the separator bar (x=48) and the right screen + * edge, so they line up with the centred identity lines above. */ + const uint8_t cap_w = (uint8_t)(4u * 6u + 3u); /* 27 */ + const uint8_t cap_gap = 4u; + const uint8_t pair_w = (uint8_t)(2u * cap_w + cap_gap); /* 58 */ + const uint8_t slot_left = (uint8_t)((48u + LCD_WIDTH - pair_w) / 2u); /* 59 */ + const uint8_t cfg_left = (uint8_t)(slot_left + cap_w + cap_gap); /* 90 */ + + UI_MENU_DrawFixedCapsule(slot_lbl, slot_left, cap_w, 5); + UI_MENU_DrawFixedCapsule(cfg_lbl, cfg_left, cap_w, 5); #endif #else sprintf(String, "%u.%02uV\n%u%%", @@ -1582,6 +1609,14 @@ void UI_DisplayMenu(void) strcpy(String, gSubMenu_SET_NAV[gSubMenuSelection]); break; +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + case MENU_SET_CFG: + strcpy(String, "CFG M"); /* bank 0 = base config, like SysInfo */ + if (gSubMenuSelection != 0) + String[4] = (char)('0' + gSubMenuSelection); + break; +#endif + case MENU_F1SHRT: case MENU_F1LONG: case MENU_F2SHRT: @@ -1837,6 +1872,9 @@ void UI_DisplayMenu(void) if ((m == MENU_RESET || m == MENU_MEM_CH || m == MENU_MEM_NAME || +#ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + m == MENU_SET_CFG || +#endif m == MENU_DEL_CH) && gAskForConfirmation) { // display confirmation char *pPrintStr = (gAskForConfirmation == 1) ? "SURE?" : "WAIT!"; diff --git a/App/ui/menu.h b/App/ui/menu.h index 9cd676ed..12f513c1 100644 --- a/App/ui/menu.h +++ b/App/ui/menu.h @@ -153,6 +153,9 @@ enum MENU_NOAA_S, #endif MENU_SET_NAV, + #ifdef ENABLE_FEAT_F4HWN_MULTIBOOT + MENU_SET_CFG, + #endif #ifdef ENABLE_FEAT_F4HWN_AUDIO MENU_SET_AUD, #endif diff --git a/App/ui/multiboot.c b/App/ui/multiboot.c index eedb38b2..3d7f0a0a 100644 --- a/App/ui/multiboot.c +++ b/App/ui/multiboot.c @@ -1,5 +1,17 @@ -/* Copyright 2026 F4HWN - * SPDX-License-Identifier: Apache-2.0 +/* 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 @@ -13,12 +25,14 @@ #include "ui/helper.h" #include "ui/multiboot.h" -static uint8_t gRunningSlot = MB_SLOT_BACKUP; +static uint8_t gRunningSlot = 0xFFu; +static uint8_t gActiveBank = 0u; -static uint8_t mb_remember_running_slot(uint8_t slot) +static uint8_t mb_remember_boot_state(uint8_t slot, uint8_t bank) { gRunningSlot = slot; - return slot; + gActiveBank = bank; + return bank; } uint8_t MB_GetRunningSlot(void) @@ -26,6 +40,11 @@ uint8_t MB_GetRunningSlot(void) return gRunningSlot; } +uint8_t MB_GetActiveBank(void) +{ + return gActiveBank; +} + static const char *mb_error_text(uint8_t err) { switch (err) @@ -37,7 +56,7 @@ static const char *mb_error_text(uint8_t err) case MB_ERR_SIZE: return "bad size"; case MB_ERR_CRC: return "CRC ERROR"; case MB_ERR_SPI: return "SPI ERROR"; - case MB_ERR_SLOT: return "bad slot"; + case MB_ERR_SLOT: return "bad index"; case MB_ERR_AUTH: return "auth"; case MB_ERR_RAM_LOAD: return "RAM LOAD ERROR"; default: return "error"; @@ -171,6 +190,23 @@ static KEY_Code_t mb_get_key(void) } } +/* Shown from the normal settings menu (SetCfg), not the boot selector, so it does + * NOT paint the "F4HWN MULTIBOOT" status banner - just a plain acknowledged message. */ +void UI_MultibootShowConfigError(uint8_t err) +{ + UI_DisplayClear(); + UI_StatusClear(); + UI_PrintStringSmallNormal("CFG ERROR", 2, 126, 2); + UI_PrintStringSmallNormal(mb_error_text(err), 2, 126, 4); + UI_PrintStringSmallNormal("Press any key", 2, 126, 6); + ST7565_BlitStatusLine(); + ST7565_BlitFullScreen(); + /* The MENU press that confirmed SetCfg may still be down; wait for a clean + * release first so it isn't consumed as the acknowledgement immediately. */ + mb_wait_release(); + (void)mb_get_key(); +} + static void mb_scan_slots(mb_slot_header_t headers[MB_SLOT_COUNT], uint8_t status[MB_SLOT_COUNT]) { mb_show_message("Scanning slots...", NULL, "Please wait"); @@ -301,13 +337,13 @@ static void mb_backup_progress(uint32_t done, uint32_t total) ST7565_BlitFullScreen(); } -/* With no trustworthy profile, continuing would let normal boot-time settings +/* With no trustworthy bank, continuing would let normal boot-time settings * writes modify an arbitrary bank. Keep the radio in a read-only error state; * a power cycle can recover from a transient SPI fault. */ -__attribute__((noreturn)) static void mb_profile_error_halt(void) +__attribute__((noreturn)) static void mb_state_error_halt(void) { BACKLIGHT_TurnOn(); - mb_show_message("PROFILE ERROR", "Flash state unknown", "Restart radio"); + mb_show_message("STATE ERROR", "Flash state unknown", "Restart radio"); for (;;) SYSTEM_DelayMs(100); } @@ -339,10 +375,11 @@ void UI_MultibootSelector(void) mb_wait_release(); mb_scan_slots(headers, status); - /* Pre-select the firmware currently running (its slot, from the marker), so - * the cursor lands on "where you are". If that slot isn't restorable (erased, - * bad CRC...), fall back to the first valid slot. */ - selected = MB_GetActiveProfile(); + /* Pre-select the exact firmware slot resolved at boot, independently of the + * active config bank (SetCfg can point the bank elsewhere), so the cursor + * lands on "where you are". An unknown or now-invalid slot falls back to the + * first valid slot below. */ + selected = MB_GetRunningSlot(); if (selected >= MB_SLOT_COUNT || status[selected] != MB_OK) { for (uint8_t slot = 0; slot < MB_SLOT_COUNT; slot++) @@ -394,13 +431,13 @@ void UI_MultibootSelector(void) if (key != KEY_MENU) continue; - /* Bind this slot to its own settings profile BEFORE reflashing. The + /* Bind this slot to its own settings bank BEFORE reflashing. The * write is verified (read-back); if it can't be confirmed we must NOT - * reflash - otherwise the next boot could resolve to the wrong profile + * reflash - otherwise the next boot could resolve to the wrong bank * (e.g. when two slots hold the same firmware image). */ - if (MB_SetActiveProfile(selected) != MB_OK) + if (MB_SetActiveSlot(selected) != MB_OK) { - mb_show_message("PROFILE ERROR", "Marker not saved", "Press any key"); + mb_show_message("STATE ERROR", "Marker not saved", "Press any key"); (void)mb_get_key(); continue; } @@ -417,25 +454,25 @@ void UI_MultibootSelector(void) } /* Adopt the running internal firmware as Main: back it up into slot 0 and point - * the marker at profile 0. Reached when the firmware was installed outside + * the marker at bank 0. Reached when the firmware was installed outside * multiboot (fresh radio, or a plain Flash-Firmware). */ static uint8_t mb_adopt_internal_as_main(void) { mb_backup_prepare(); BACKLIGHT_TurnOn(); if (MB_BackupInternalToSlot0(mb_backup_progress) == MB_OK) - (void)MB_SetActiveProfile(MB_SLOT_BACKUP); + (void)MB_SetActiveSlot(MB_SLOT_BACKUP); return MB_SLOT_BACKUP; } -uint8_t MB_BootResolveProfile(void) +uint8_t MB_BootResolveState(void) { - mb_profile_state_t mark; + mb_state_t mark; mb_mark_status_t ms = MB_MARK_IO; for (uint8_t retry = 0; retry < 3u && ms == MB_MARK_IO; retry++) { - ms = MB_ReadActiveProfile(&mark); + ms = MB_ReadActiveState(&mark); if (ms == MB_MARK_IO) SYSTEM_DelayMs(10); } @@ -444,21 +481,37 @@ uint8_t MB_BootResolveProfile(void) * identity, so we don't even need the slot header. */ if (ms == MB_MARK_VALID) { - if (MB_InternalMatchesProfile(&mark)) - return mb_remember_running_slot(mark.index); /* slot named by the marker */ + if (MB_InternalMatchesState(&mark)) + return mb_remember_boot_state(mark.firmware_slot, mark.config_bank); /* Marker read fine but internal no longer carries its identity -> the * firmware was replaced outside multiboot (a plain Flash-Firmware). Adopt * it as Main. Deliberately NOT a content scan here: a build that merely * duplicates a user slot (or a marker that already points at such a slot) * must still refresh Main. */ - return mb_remember_running_slot(mb_adopt_internal_as_main()); + return mb_remember_boot_state(mb_adopt_internal_as_main(), MB_SLOT_BACKUP); } /* Marker unreliable (MISSING / LEGACY / CORRUPT / IO): identify the running * firmware by content, and never destroy Main on uncertainty - internal is * adopted only when it matches no slot AND every read was clean, so a * transient SPI error or a half-written marker can never destroy Main. */ + + /* An FMP1 record still names a coupled slot/bank; honour it before the + * content scan so a duplicate image in a lower slot cannot hijack the + * migration. Falls through to the scan below on mismatch or IO. */ + if (ms == MB_MARK_LEGACY && mark.firmware_slot < MB_SLOT_COUNT) + { + mb_fw_match_t m = MB_FW_IO; + for (uint8_t retry = 0; retry < 3u && m == MB_FW_IO; retry++) + m = MB_InternalMatchesSlot(mark.firmware_slot); + if (m == MB_FW_MATCH) + { + (void)MB_SetActiveSlot(mark.firmware_slot); + return mb_remember_boot_state(mark.firmware_slot, mark.config_bank); + } + } + bool had_io = false; for (uint8_t slot = 0; slot < MB_SLOT_COUNT; slot++) { @@ -467,8 +520,8 @@ uint8_t MB_BootResolveProfile(void) m = MB_InternalMatchesSlot(slot); if (m == MB_FW_MATCH) { - (void)MB_SetActiveProfile(slot); /* record/repair the marker */ - return mb_remember_running_slot(slot); + (void)MB_SetActiveSlot(slot); /* record/repair the marker */ + return mb_remember_boot_state(slot, slot); } if (m == MB_FW_IO) had_io = true; @@ -483,8 +536,8 @@ uint8_t MB_BootResolveProfile(void) bool main_exists = (main_status != MB_ERR_MAGIC && main_status != MB_ERR_NOT_COMMITTED); if (main_exists) - mb_profile_error_halt(); + mb_state_error_halt(); } - return mb_remember_running_slot(mb_adopt_internal_as_main()); + return mb_remember_boot_state(mb_adopt_internal_as_main(), MB_SLOT_BACKUP); } diff --git a/App/ui/multiboot.h b/App/ui/multiboot.h index d7b4d1d1..596af5b6 100644 --- a/App/ui/multiboot.h +++ b/App/ui/multiboot.h @@ -1,5 +1,17 @@ -/* Copyright 2026 F4HWN - * SPDX-License-Identifier: Apache-2.0 +/* 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 UI_MULTIBOOT_H @@ -11,14 +23,20 @@ * a successful restore resets the radio from the RAM-resident copier. */ void UI_MultibootSelector(void); -/* Resolve the active settings profile at boot, BEFORE any settings read. - * Detects a firmware installed outside multiboot (internal != slot[marker]) and, - * if so, discreetly self-backs it up into slot 0 and adopts profile 0. Returns - * the profile index (0..MB_SLOT_COUNT-1) to feed PY25Q16_SetProfileBase(). */ -uint8_t MB_BootResolveProfile(void); +/* Show a blocking, acknowledged error screen for a failed SetCfg operation. */ +void UI_MultibootShowConfigError(uint8_t err); -/* Slot selected by MB_BootResolveProfile for the current session. This is kept - * in RAM so UI callers do not have to reread the external-flash marker. */ +/* Resolve the active config bank at boot, BEFORE any settings read. Detects a + * firmware installed outside multiboot (internal identity != marker) and, if so, + * discreetly self-backs it up into slot 0 and adopts slot 0 / bank 0. Returns the + * config bank (0..MB_BANK_COUNT-1) to feed PY25Q16_SetBankBase(); the exact + * firmware slot and bank are cached too (see MB_GetRunningSlot/MB_GetActiveBank). */ +uint8_t MB_BootResolveState(void); + +/* Exact firmware slot and active config bank resolved for this session. Both + * are cached in RAM so UI callers never have to reread the marker or scan slot + * headers merely to render their state. */ uint8_t MB_GetRunningSlot(void); +uint8_t MB_GetActiveBank(void); #endif From 1bf75db62a45b82c7bffecef4e190088474a024c Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Fri, 21 Aug 2026 03:26:43 +0200 Subject: [PATCH 19/73] Preserve Ninja progress in interactive Docker builds --- compile-with-docker.sh | 44 +++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/compile-with-docker.sh b/compile-with-docker.sh index 457e6c21..dde93b12 100755 --- a/compile-with-docker.sh +++ b/compile-with-docker.sh @@ -39,6 +39,14 @@ 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) # --------------------------------------------- @@ -54,8 +62,16 @@ RESULT_RAM_SIZES=() run_preset_build() { local preset="$1" + local -a docker_tty_args=() - docker run --rm \ + # 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 && @@ -85,15 +101,25 @@ build_preset() { echo "" echo "=== 🚀 Building preset: ${preset} ===" echo "---------------------------------------------" - 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 + 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 - status=$? + 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 From 33dde242261b63a6db5af3c28168b11d9b59a266 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sat, 22 Aug 2026 12:58:34 +0200 Subject: [PATCH 20/73] Remove ENABLE_ALARM and isolate ENABLE_TX1750 --- App/CMakeLists.txt | 1 - App/app/action.c | 37 +++++-------------------- App/app/app.c | 67 +++++++--------------------------------------- App/app/menu.c | 19 ------------- App/functions.c | 40 +++------------------------ App/misc.c | 8 ++---- App/misc.h | 12 +-------- App/radio.c | 11 +++----- App/radio.h | 1 - App/settings.c | 12 +-------- App/settings.h | 11 +------- App/ui/main.c | 22 ++++----------- App/ui/menu.c | 20 -------------- App/ui/menu.h | 6 ----- CMakePresets.json | 1 - 15 files changed, 33 insertions(+), 235 deletions(-) diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index 28d91358..27ef3033 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -131,7 +131,6 @@ enable_feature(ENABLE_VOICE driver/voice.c ) enable_feature(ENABLE_VOX) -enable_feature(ENABLE_ALARM) enable_feature(ENABLE_TX1750) enable_feature(ENABLE_PWRON_PASSWORD ui/lock.c diff --git a/App/app/action.c b/App/app/action.c index 2428b965..f53e14c8 100644 --- a/App/app/action.c +++ b/App/app/action.c @@ -59,10 +59,8 @@ static void ACTION_Scan_FM(bool bRestart); #endif -#if defined(ENABLE_ALARM) || defined(ENABLE_TX1750) -static void ACTION_AlarmOr1750(bool b1750); -inline static void ACTION_Alarm() { ACTION_AlarmOr1750(false); } -inline static void ACTION_1750() { ACTION_AlarmOr1750(true); }; +#ifdef ENABLE_TX1750 +static void ACTION_1750(void); #endif inline static void ACTION_ScanRestart() { ACTION_Scan(true); }; @@ -95,11 +93,7 @@ void (*const action_opt_table[])(void) = { [ACTION_OPT_FM] = &FUNCTION_NOP, #endif -#ifdef ENABLE_ALARM - [ACTION_OPT_ALARM] = &ACTION_Alarm, -#else - [ACTION_OPT_ALARM] = &FUNCTION_NOP, -#endif + [ACTION_OPT_RESERVED_ALARM] = &FUNCTION_NOP, #ifdef ENABLE_TX1750 [ACTION_OPT_1750] = &ACTION_1750, @@ -572,36 +566,19 @@ static void ACTION_Scan_FM(bool bRestart) #endif -#if defined(ENABLE_ALARM) || defined(ENABLE_TX1750) -static void ACTION_AlarmOr1750(const bool b1750) +#ifdef ENABLE_TX1750 +static void ACTION_1750(void) { - if(gEeprom.KEY_LOCK && (gSetting_set_lck & SET_LCK_PTT)) return; - #if defined(ENABLE_ALARM) - const AlarmState_t alarm_mode = (gEeprom.ALARM_MODE == ALARM_MODE_TONE) ? ALARM_STATE_TXALARM : ALARM_STATE_SITE_ALARM; - gAlarmRunningCounter = 0; - #endif - - #if defined(ENABLE_ALARM) && defined(ENABLE_TX1750) - gAlarmState = b1750 ? ALARM_STATE_TX1750 : alarm_mode; - #elif defined(ENABLE_ALARM) - gAlarmState = alarm_mode; - #else - gAlarmState = ALARM_STATE_TX1750; - #endif - - (void)b1750; + gTx1750Active = true; gInputBoxIndex = 0; - - gFlagPrepareTX = gAlarmState != ALARM_STATE_OFF; + gFlagPrepareTX = true; if (gScreenToDisplay != DISPLAY_MENU) // 1of11 .. don't close the menu gRequestDisplayScreen = DISPLAY_MAIN; } - - #endif #ifdef ENABLE_VOX diff --git a/App/app/app.c b/App/app/app.c index dddf1076..9e70b915 100644 --- a/App/app/app.c +++ b/App/app/app.c @@ -1728,52 +1728,6 @@ void APP_TimeSlice10ms(void) #endif if (gCurrentFunction == FUNCTION_TRANSMIT) { -#ifdef ENABLE_ALARM - if (gAlarmState == ALARM_STATE_TXALARM || gAlarmState == ALARM_STATE_SITE_ALARM) { - uint16_t Tone; - - gAlarmRunningCounter++; - gAlarmToneCounter++; - - Tone = 500 + (gAlarmToneCounter * 25); - if (Tone > 1500) { - Tone = 500; - gAlarmToneCounter = 0; - } - - BK4819_SetScrambleFrequencyControlWord(Tone); - - if (gEeprom.ALARM_MODE == ALARM_MODE_TONE && gAlarmRunningCounter == 512) { - gAlarmRunningCounter = 0; - - if (gAlarmState == ALARM_STATE_TXALARM) { - gAlarmState = ALARM_STATE_SITE_ALARM; - - RADIO_SendCssTail(); - BK4819_SetupPowerAmplifier(0, 0); - BK4819_ToggleGpioOut(BK4819_GPIO1_PIN29_PA_ENABLE, false); - BK4819_Enable_AfDac_DiscMode_TxDsp(); - BK4819_ToggleGpioOut(BK4819_GPIO5_PIN1_RED, false); - - GUI_DisplayScreen(); - } - else { - gAlarmState = ALARM_STATE_TXALARM; - - GUI_DisplayScreen(); - - BK4819_ToggleGpioOut(BK4819_GPIO5_PIN1_RED, true); - RADIO_SetTxParameters(); - BK4819_TransmitTone(true, 500); - SYSTEM_DelayMs(2); - AUDIO_AudioPathOn(); - - gEnableSpeaker = true; - gAlarmToneCounter = 0; - } - } - } -#endif // repeater tail tone elimination if (gRTTECountdown_10ms > 0) { if (--gRTTECountdown_10ms == 0) { @@ -2180,17 +2134,15 @@ void APP_TimeSlice500ms(void) #endif } -#if defined(ENABLE_ALARM) || defined(ENABLE_TX1750) -static void ALARM_Off(void) +#ifdef ENABLE_TX1750 +static void TX1750_Off(void) { AUDIO_AudioPathOff(); gEnableSpeaker = false; - if (gAlarmState == ALARM_STATE_TXALARM || gAlarmState == ALARM_STATE_TX1750) { - RADIO_SendEndOfTransmission(); - } + RADIO_SendEndOfTransmission(); - gAlarmState = ALARM_STATE_OFF; + gTx1750Active = false; #ifdef ENABLE_VOX gVoxResumeCountdown = 80; @@ -2454,8 +2406,8 @@ static void ProcessKey(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) } if (gCurrentFunction == FUNCTION_TRANSMIT) { -#if defined(ENABLE_ALARM) || defined(ENABLE_TX1750) - if (gAlarmState == ALARM_STATE_OFF) +#ifdef ENABLE_TX1750 + if (!gTx1750Active) #endif { char Code; @@ -2507,10 +2459,9 @@ static void ProcessKey(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) BK4819_PlayDTMFEx(gEeprom.DTMF_SIDE_TONE, Code); } } -#if defined(ENABLE_ALARM) || defined(ENABLE_TX1750) - // else if ((!bKeyHeld && bKeyPressed) || (gAlarmState == ALARM_STATE_TX1750 && bKeyHeld && !bKeyPressed)) { - else if ((bKeyHeld != bKeyPressed) && (gAlarmState == ALARM_STATE_TX1750 || bKeyPressed)) { - ALARM_Off(); +#ifdef ENABLE_TX1750 + else if (bKeyHeld != bKeyPressed) { + TX1750_Off(); if (gEeprom.REPEATER_TAIL_TONE_ELIMINATION == 0) FUNCTION_Select(FUNCTION_FOREGROUND); diff --git a/App/app/menu.c b/App/app/menu.c index b24af6f7..80fa0d7f 100644 --- a/App/app/menu.c +++ b/App/app/menu.c @@ -208,13 +208,6 @@ int MENU_GetLimits(uint8_t menu_id, int32_t *pMin, int32_t *pMax) *pMax = ARRAY_SIZE(gSubMenu_W_N) - 1; break; - #ifdef ENABLE_ALARM - case MENU_AL_MOD: - //*pMin = 0; - *pMax = ARRAY_SIZE(gSubMenu_AL_MOD) - 1; - break; - #endif - case MENU_RESET: //*pMin = 0; *pMax = ARRAY_SIZE(gSubMenu_RESET) - 1; @@ -781,12 +774,6 @@ void MENU_AcceptSetting(void) gEeprom.SCAN_LIST_ENABLED = gSubMenuSelection; break; - #ifdef ENABLE_ALARM - case MENU_AL_MOD: - gEeprom.ALARM_MODE = gSubMenuSelection; - break; - #endif - case MENU_D_ST: gEeprom.DTMF_SIDE_TONE = gSubMenuSelection; break; @@ -1307,12 +1294,6 @@ void MENU_ShowCurrentSetting(void) gSubMenuSelection = gEeprom.SCANLIST_PRIORITY_CH[1]; break; - #ifdef ENABLE_ALARM - case MENU_AL_MOD: - gSubMenuSelection = gEeprom.ALARM_MODE; - break; - #endif - case MENU_D_ST: gSubMenuSelection = gEeprom.DTMF_SIDE_TONE; break; diff --git a/App/functions.c b/App/functions.c index 32be4045..c416ff15 100644 --- a/App/functions.c +++ b/App/functions.c @@ -161,29 +161,6 @@ void FUNCTION_Transmit() BK1080_Init0(); #endif -#ifdef ENABLE_ALARM - if (gAlarmState == ALARM_STATE_SITE_ALARM) - { - GUI_DisplayScreen(); - - AUDIO_AudioPathOff(); - - SYSTEM_DelayMs(20); - BK4819_PlayTone(500, 0); - SYSTEM_DelayMs(2); - - AUDIO_AudioPathOn(); - - gEnableSpeaker = true; - - SYSTEM_DelayMs(60); - BK4819_ExitTxMute(); - - gAlarmToneCounter = 0; - return; - } -#endif - gUpdateStatus = true; GUI_DisplayScreen(); @@ -198,20 +175,9 @@ void FUNCTION_Transmit() if (gCurrentVfo->DTMF_PTT_ID_TX_MODE == PTT_ID_APOLLO) BK4819_PlaySingleTone(2525, 250, 0, gEeprom.DTMF_SIDE_TONE); -#if defined(ENABLE_ALARM) || defined(ENABLE_TX1750) - if (gAlarmState != ALARM_STATE_OFF) { - #ifdef ENABLE_TX1750 - if (gAlarmState == ALARM_STATE_TX1750) - BK4819_TransmitTone(true, 1750); - #endif - - #ifdef ENABLE_ALARM - if (gAlarmState == ALARM_STATE_TXALARM) - BK4819_TransmitTone(true, 500); - - gAlarmToneCounter = 0; - #endif - +#ifdef ENABLE_TX1750 + if (gTx1750Active) { + BK4819_TransmitTone(true, 1750); SYSTEM_DelayMs(2); AUDIO_AudioPathOn(); gEnableSpeaker = true; diff --git a/App/misc.c b/App/misc.c index 63242354..5d7b118e 100644 --- a/App/misc.c +++ b/App/misc.c @@ -244,8 +244,8 @@ bool gCssBackgroundScan; volatile bool gScheduleScanListen = true; volatile uint16_t gScanPauseDelayIn_10ms; -#if defined(ENABLE_ALARM) || defined(ENABLE_TX1750) - AlarmState_t gAlarmState; +#ifdef ENABLE_TX1750 + bool gTx1750Active; #endif uint16_t gMenuCountdown; bool gPttWasReleased; @@ -288,10 +288,6 @@ uint16_t gNextMrChannel; ReceptionMode_t gRxReceptionMode; bool gRxVfoIsActive; -#ifdef ENABLE_ALARM - uint8_t gAlarmToneCounter; - uint16_t gAlarmRunningCounter; -#endif bool gKeyBeingHeld; bool gPttIsPressed; uint8_t gPttDebounceCounter; diff --git a/App/misc.h b/App/misc.h index 465aa296..e00dd71b 100644 --- a/App/misc.h +++ b/App/misc.h @@ -65,14 +65,6 @@ enum { VFO_CONFIGURE_RELOAD }; -enum AlarmState_t { - ALARM_STATE_OFF = 0, - ALARM_STATE_TXALARM, - ALARM_STATE_SITE_ALARM, - ALARM_STATE_TX1750 -}; -typedef enum AlarmState_t AlarmState_t; - enum ReceptionMode_t { RX_MODE_NONE = 0, // squelch close ? RX_MODE_DETECTED, // signal detected @@ -393,7 +385,7 @@ enum extern volatile bool gScheduleScanListen; extern volatile uint16_t gScanPauseDelayIn_10ms; -extern AlarmState_t gAlarmState; +extern bool gTx1750Active; extern uint16_t gMenuCountdown; extern bool gPttWasReleased; extern bool gPttWasPressed; @@ -438,8 +430,6 @@ extern ReceptionMode_t gRxReceptionMode; //TRUE when dual watch is momentarly suspended and RX_VFO is locked to either last TX or RX extern bool gRxVfoIsActive; -extern uint8_t gAlarmToneCounter; -extern uint16_t gAlarmRunningCounter; extern bool gKeyBeingHeld; extern bool gPttIsPressed; extern uint8_t gPttDebounceCounter; diff --git a/App/radio.c b/App/radio.c index d1cc3cd3..b32e600b 100644 --- a/App/radio.c +++ b/App/radio.c @@ -1208,9 +1208,6 @@ void RADIO_PrepareTX(void) if(TX_freq_check(gCurrentVfo->pTX->Frequency) != 0 #ifdef ENABLE_FEAT_F4HWN && gCurrentVfo->TX_LOCK == true -#endif -#if defined(ENABLE_ALARM) || defined(ENABLE_TX1750) - && gAlarmState != ALARM_STATE_SITE_ALARM #endif ){ // TX frequency not allowed @@ -1246,8 +1243,8 @@ void RADIO_PrepareTX(void) // TX not allowed RADIO_SetVfoState(State); -#if defined(ENABLE_ALARM) || defined(ENABLE_TX1750) - gAlarmState = ALARM_STATE_OFF; +#ifdef ENABLE_TX1750 + gTx1750Active = false; #endif #ifdef ENABLE_DTMF_CALLING @@ -1281,8 +1278,8 @@ void RADIO_PrepareTX(void) gTxTimerCountdown_500ms = 0; // no timeout - #if defined(ENABLE_ALARM) || defined(ENABLE_TX1750) - if (gAlarmState == ALARM_STATE_OFF) + #ifdef ENABLE_TX1750 + if (!gTx1750Active) #endif { diff --git a/App/radio.h b/App/radio.h index 5d53b399..cb61f582 100644 --- a/App/radio.h +++ b/App/radio.h @@ -50,7 +50,6 @@ enum VfoState_t VFO_STATE_BAT_LOW, VFO_STATE_TX_DISABLE, VFO_STATE_TIMEOUT, - VFO_STATE_ALARM, VFO_STATE_VOLTAGE_HIGH, _VFO_STATE_LAST_ELEMENT }; diff --git a/App/settings.c b/App/settings.c index 6cd43647..1c781b96 100644 --- a/App/settings.c +++ b/App/settings.c @@ -288,9 +288,6 @@ gEeprom.FreqChannel[1] = IS_FREQ_CHANNEL(Data16[5]) ? Data16[5] : (FREQ_CHANNE // 0EA8..0EAF PY25Q16_ReadBuffer(0x00A0A8 + 0x18, Data, 8); - #ifdef ENABLE_ALARM - gEeprom.ALARM_MODE = (Data[0] < 2) ? Data[0] : true; - #endif gEeprom.ROGER = (Data[1] < 3) ? Data[1] : ROGER_MODE_OFF; gEeprom.REPEATER_TAIL_TONE_ELIMINATION = (Data[2] < 11) ? Data[2] : 0; gEeprom.TX_VFO = (Data[3] < 2) ? Data[3] : 0; @@ -1000,11 +997,7 @@ void SETTINGS_SaveSettings(void) // 0x0EA8 State = SecBuf + 0x18; - #if defined(ENABLE_ALARM) || defined(ENABLE_TX1750) - State[0] = gEeprom.ALARM_MODE; - #else - State[0] = false; - #endif + State[0] = false; State[1] = gEeprom.ROGER; State[2] = gEeprom.REPEATER_TAIL_TONE_ELIMINATION; State[3] = gEeprom.TX_VFO; @@ -1291,9 +1284,6 @@ State[0] = 0 #ifdef ENABLE_VOX | (1 << 3) #endif -#ifdef ENABLE_ALARM - | (1 << 4) -#endif #ifdef ENABLE_TX1750 | (1 << 5) #endif diff --git a/App/settings.h b/App/settings.h index a4e4bcb2..a314b087 100644 --- a/App/settings.h +++ b/App/settings.h @@ -106,7 +106,7 @@ enum ACTION_OPT_t { ACTION_OPT_MONITOR, ACTION_OPT_SCAN, ACTION_OPT_VOX, - ACTION_OPT_ALARM, + ACTION_OPT_RESERVED_ALARM, // Reserved EEPROM ID; removed in v6.0.0 ACTION_OPT_FM, ACTION_OPT_1750, ACTION_OPT_KEYLOCK, @@ -149,12 +149,6 @@ enum ACTION_OPT_t { typedef enum VOICE_Prompt_t VOICE_Prompt_t; #endif -enum ALARM_Mode_t { - ALARM_MODE_SITE = 0, - ALARM_MODE_TONE -}; -typedef enum ALARM_Mode_t ALARM_Mode_t; - enum ROGER_Mode_t { ROGER_MODE_OFF = 0, ROGER_MODE_ROGER, @@ -237,9 +231,6 @@ typedef struct { uint8_t field38_0x33; uint8_t AUTO_KEYPAD_LOCK; -#if defined(ENABLE_ALARM) || defined(ENABLE_TX1750) - ALARM_Mode_t ALARM_MODE; -#endif POWER_OnDisplayMode_t POWER_ON_DISPLAY_MODE; ROGER_Mode_t ROGER; uint8_t REPEATER_TAIL_TONE_ELIMINATION; diff --git a/App/ui/main.c b/App/ui/main.c index 4202d29f..ff677f48 100644 --- a/App/ui/main.c +++ b/App/ui/main.c @@ -149,7 +149,6 @@ const char *const VfoStateStr[] = { [VFO_STATE_BAT_LOW]="BAT LOW", [VFO_STATE_TX_DISABLE]="TX DISABLE", [VFO_STATE_TIMEOUT]="TIMEOUT", - [VFO_STATE_ALARM]="ALARM", [VFO_STATE_VOLTAGE_HIGH]="VOLT HIGH" }; @@ -818,8 +817,8 @@ void UI_DisplayAudioBar(void) return; // screen is in use } -#if defined(ENABLE_ALARM) || defined(ENABLE_TX1750) - if (gAlarmState != ALARM_STATE_OFF) +#ifdef ENABLE_TX1750 + if (gTx1750Active) return; #endif static uint8_t barsOld = 0; @@ -919,8 +918,8 @@ void UI_DisplayAudioScope(void) ) return; -#if defined(ENABLE_ALARM) || defined(ENABLE_TX1750) - if (gAlarmState != ALARM_STATE_OFF) +#ifdef ENABLE_TX1750 + if (gTx1750Active) return; #endif @@ -1566,11 +1565,6 @@ void UI_DisplayMain(void) if (gCurrentFunction == FUNCTION_TRANSMIT) { // transmitting -#ifdef ENABLE_ALARM - if (gAlarmState == ALARM_STATE_SITE_ALARM) - mode = VFO_MODE_RX; - else -#endif { if (activeTxVFO == vfo_num) { // show the TX symbol @@ -1732,12 +1726,6 @@ void UI_DisplayMain(void) enum VfoState_t state = VfoState[vfo_num]; -#ifdef ENABLE_ALARM - if (gCurrentFunction == FUNCTION_TRANSMIT && gAlarmState == ALARM_STATE_SITE_ALARM) { - if (activeTxVFO == vfo_num) - state = VFO_STATE_ALARM; - } -#endif if (state != VFO_STATE_NORMAL) { if (state < ARRAY_SIZE(VfoStateStr)) @@ -2113,7 +2101,7 @@ void UI_DisplayMain(void) UI_PrintStringSmallNormal(s, LCD_WIDTH + 24, 0, line + 1); #endif - if (state == VFO_STATE_NORMAL || state == VFO_STATE_ALARM) + if (state == VFO_STATE_NORMAL) { // show the TX power uint8_t currentPower = vfoInfo->OUTPUT_POWER % 8; uint8_t arrowPos = 19; diff --git a/App/ui/menu.c b/App/ui/menu.c index be24d18b..12a028e9 100644 --- a/App/ui/menu.c +++ b/App/ui/menu.c @@ -110,9 +110,6 @@ const t_menu_item MenuList[] = {"STE", MENU_STE }, {"RP STE", MENU_RP_STE }, {"1 Call", MENU_1_CALL }, -#ifdef ENABLE_ALARM - {"AlarmT", MENU_AL_MOD }, -#endif #ifdef ENABLE_DTMF_CALLING {"ANI ID", MENU_ANI_ID }, #endif @@ -265,14 +262,6 @@ const char* const gSubMenu_MDF[] = "NAME\n+\nFREQ" }; -#ifdef ENABLE_ALARM - const char* const gSubMenu_AL_MOD[] = - { - "SITE", - "TONE" - }; -#endif - #ifdef ENABLE_DTMF_CALLING const char* const gSubMenu_D_RSP[] = { @@ -497,9 +486,6 @@ const t_sidefunction gSubMenu_SIDEFUNCTIONS[] = #ifdef ENABLE_VOX {"VOX", ACTION_OPT_VOX}, #endif -#ifdef ENABLE_ALARM - {"ALARM", ACTION_OPT_ALARM}, -#endif #ifdef ENABLE_FMRADIO {"FM RADIO", ACTION_OPT_FM}, #endif @@ -1370,12 +1356,6 @@ void UI_DisplayMenu(void) } break; - #ifdef ENABLE_ALARM - case MENU_AL_MOD: - sprintf(String, gSubMenu_AL_MOD[gSubMenuSelection]); - break; - #endif - #ifdef ENABLE_DTMF_CALLING case MENU_ANI_ID: strcpy(String, gEeprom.ANI_DTMF_ID); diff --git a/App/ui/menu.h b/App/ui/menu.h index 12f513c1..fc21a58f 100644 --- a/App/ui/menu.h +++ b/App/ui/menu.h @@ -76,9 +76,6 @@ enum MENU_S_PRI, MENU_S_PRI_CH_1, MENU_S_PRI_CH_2, -#ifdef ENABLE_ALARM - MENU_AL_MOD, -#endif #ifdef ENABLE_DTMF_CALLING MENU_ANI_ID, #endif @@ -218,9 +215,6 @@ extern const char* const gSubMenu_RXMode[4]; extern const char* const gSubMenu_VOICE[3]; #endif extern const char* const gSubMenu_MDF[4]; -#ifdef ENABLE_ALARM - extern const char* const gSubMenu_AL_MOD[2]; -#endif #ifdef ENABLE_DTMF_CALLING extern const char* const gSubMenu_D_RSP[4]; #endif diff --git a/CMakePresets.json b/CMakePresets.json index 7424af34..6fca3a1f 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -29,7 +29,6 @@ "ENABLE_NOAA": false, "ENABLE_VOICE": false, "ENABLE_VOX": true, - "ENABLE_ALARM": false, "ENABLE_TX1750": true, "ENABLE_PWRON_PASSWORD": false, "ENABLE_DTMF_CALLING": false, From 7d550585d059c7e84be1881a9166ff19b24397b9 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sat, 22 Aug 2026 13:18:06 +0200 Subject: [PATCH 21/73] Remove ENABLE_BLMIN_TMP_OFF --- App/CMakeLists.txt | 1 - App/app/action.c | 18 +----------------- App/app/action.h | 4 ---- App/driver/backlight.c | 11 ----------- App/driver/backlight.h | 8 -------- App/settings.c | 3 --- App/settings.h | 5 +---- App/ui/menu.c | 3 --- CMakePresets.json | 1 - 9 files changed, 2 insertions(+), 52 deletions(-) diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index 27ef3033..201e1b89 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -165,7 +165,6 @@ enable_feature(ENABLE_FEAT_F4HWN_AUDIO_SCOPE) enable_feature(ENABLE_COPY_CHAN_TO_VFO) enable_feature(ENABLE_REDUCE_LOW_MID_TX_POWER) enable_feature(ENABLE_BYP_RAW_DEMODULATORS) -enable_feature(ENABLE_BLMIN_TMP_OFF) enable_feature(ENABLE_SCAN_RANGES) # ---- CONTRIB MODS ---- diff --git a/App/app/action.c b/App/app/action.c index f53e14c8..837c0876 100644 --- a/App/app/action.c +++ b/App/app/action.c @@ -101,11 +101,7 @@ void (*const action_opt_table[])(void) = { [ACTION_OPT_1750] = &FUNCTION_NOP, #endif -#ifdef ENABLE_BLMIN_TMP_OFF - [ACTION_OPT_BLMIN_TMP_OFF] = &ACTION_BlminTmpOff, -#else - [ACTION_OPT_BLMIN_TMP_OFF] = &FUNCTION_NOP, -#endif + [ACTION_OPT_RESERVED_BLMIN_TMP_OFF] = &FUNCTION_NOP, #ifdef ENABLE_FEAT_F4HWN [ACTION_OPT_RXMODE] = &ACTION_RxMode, @@ -595,18 +591,6 @@ void ACTION_Vox(void) } #endif -#ifdef ENABLE_BLMIN_TMP_OFF -void ACTION_BlminTmpOff(void) -{ - if(++gEeprom.BACKLIGHT_MIN_STAT == BLMIN_STAT_UNKNOWN) { - gEeprom.BACKLIGHT_MIN_STAT = BLMIN_STAT_ON; - BACKLIGHT_SetBrightness(gEeprom.BACKLIGHT_MIN); - } else { - BACKLIGHT_SetBrightness(0); - } -} -#endif - #ifdef ENABLE_FEAT_F4HWN void ACTION_Update(void) { diff --git a/App/app/action.h b/App/app/action.h index 2684d0e9..f6dd3fa7 100644 --- a/App/app/action.h +++ b/App/app/action.h @@ -31,10 +31,6 @@ void ACTION_Scan(bool bRestart); #endif void ACTION_SwitchDemodul(void); -#ifdef ENABLE_BLMIN_TMP_OFF - void ACTION_BlminTmpOff(void); -#endif - #ifdef ENABLE_FEAT_F4HWN void ACTION_RxMode(void); void ACTION_MainOnly(void); diff --git a/App/driver/backlight.c b/App/driver/backlight.c index 311ab95e..584c7da0 100644 --- a/App/driver/backlight.c +++ b/App/driver/backlight.c @@ -233,18 +233,7 @@ void BACKLIGHT_TurnOn(void) void BACKLIGHT_TurnOff() { -#ifdef ENABLE_BLMIN_TMP_OFF - register uint8_t tmp; - - if (gEeprom.BACKLIGHT_MIN_STAT == BLMIN_STAT_ON) - tmp = gEeprom.BACKLIGHT_MIN; - else - tmp = 0; - - BACKLIGHT_SetBrightness(tmp); -#else BACKLIGHT_SetBrightness(gEeprom.BACKLIGHT_MIN); -#endif gBacklightCountdown_500ms = 0; backlightOn = false; } diff --git a/App/driver/backlight.h b/App/driver/backlight.h index b0d0e7b9..f64e2609 100644 --- a/App/driver/backlight.h +++ b/App/driver/backlight.h @@ -31,14 +31,6 @@ extern uint8_t gBacklightBrightness; extern uint16_t gSleepModeCountdown_500ms; #endif -#ifdef ENABLE_BLMIN_TMP_OFF -typedef enum { - BLMIN_STAT_ON, - BLMIN_STAT_OFF, - BLMIN_STAT_UNKNOWN -} BLMIN_STAT_t; -#endif - void BACKLIGHT_InitHardware(); void BACKLIGHT_UpdateTickless(void); void BACKLIGHT_TurnOn(); diff --git a/App/settings.c b/App/settings.c index 1c781b96..3cf3f801 100644 --- a/App/settings.c +++ b/App/settings.c @@ -164,9 +164,6 @@ void SETTINGS_InitEEPROM(void) PY25Q16_ReadBuffer(0x00A008, Data, 8); gEeprom.BACKLIGHT_MAX = (Data[0] & 0xF) <= 10 ? (Data[0] & 0xF) : 10; gEeprom.BACKLIGHT_MIN = (Data[0] >> 4) < gEeprom.BACKLIGHT_MAX ? (Data[0] >> 4) : 0; -#ifdef ENABLE_BLMIN_TMP_OFF - gEeprom.BACKLIGHT_MIN_STAT = BLMIN_STAT_ON; -#endif gEeprom.CHANNEL_DISPLAY_MODE = (Data[1] < 4) ? Data[1] : MDF_FREQUENCY; // 4 instead of 3 - extra display mode gEeprom.CROSS_BAND_RX_TX = (Data[2] < 3) ? Data[2] : CROSS_BAND_OFF; gEeprom.BATTERY_SAVE = (Data[3] < 6) ? Data[3] : 4; diff --git a/App/settings.h b/App/settings.h index a314b087..da3d6ca3 100644 --- a/App/settings.h +++ b/App/settings.h @@ -113,7 +113,7 @@ enum ACTION_OPT_t { ACTION_OPT_A_B, ACTION_OPT_VFO_MR, ACTION_OPT_SWITCH_DEMODUL, - ACTION_OPT_BLMIN_TMP_OFF, //BackLight Minimum Temporay OFF + ACTION_OPT_RESERVED_BLMIN_TMP_OFF, // Reserved EEPROM ID; removed in v6.0.0 #ifdef ENABLE_FEAT_F4HWN ACTION_OPT_RXMODE, ACTION_OPT_MAINONLY, @@ -292,9 +292,6 @@ typedef struct { uint8_t KEY_M_LONG_PRESS_ACTION; uint8_t BACKLIGHT_MIN; -#ifdef ENABLE_BLMIN_TMP_OFF - BLMIN_STAT_t BACKLIGHT_MIN_STAT; -#endif uint8_t BACKLIGHT_MAX; BATTERY_Type_t BATTERY_TYPE; #ifdef ENABLE_RSSI_BAR diff --git a/App/ui/menu.c b/App/ui/menu.c index 12a028e9..71c9b6cd 100644 --- a/App/ui/menu.c +++ b/App/ui/menu.c @@ -496,9 +496,6 @@ const t_sidefunction gSubMenu_SIDEFUNCTIONS[] = {"VFO A\nVFO B", ACTION_OPT_A_B}, {"VFO\nMEM", ACTION_OPT_VFO_MR}, {"MODE", ACTION_OPT_SWITCH_DEMODUL}, -#ifdef ENABLE_BLMIN_TMP_OFF - {"BLMIN\nTMP OFF", ACTION_OPT_BLMIN_TMP_OFF}, //BackLight Minimum Temporary OFF -#endif #ifdef ENABLE_FEAT_F4HWN {"RX MODE", ACTION_OPT_RXMODE}, {"MAIN ONLY", ACTION_OPT_MAINONLY}, diff --git a/CMakePresets.json b/CMakePresets.json index 6fca3a1f..8f36208a 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -53,7 +53,6 @@ "ENABLE_COPY_CHAN_TO_VFO": true, "ENABLE_REDUCE_LOW_MID_TX_POWER": false, "ENABLE_BYP_RAW_DEMODULATORS": false, - "ENABLE_BLMIN_TMP_OFF": false, "ENABLE_SCAN_RANGES": true, "ENABLE_EXTRA_UART_CMD": false, "ENABLE_FEAT_F4HWN": true, From 66fb8b1ed4f9d29e3221edadb855f29c5f80b357 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sat, 22 Aug 2026 13:52:04 +0200 Subject: [PATCH 22/73] Remove ENABLE_AM_FIX --- App/am_fix.c | 397 ------------------------------------------ App/am_fix.h | 35 ---- App/app/action.c | 6 +- App/app/app.c | 7 - App/app/chFrScanner.c | 4 - App/app/menu.c | 23 --- App/app/spectrum.c | 11 -- App/debugging.h | 1 - App/main.c | 8 - App/misc.c | 4 - App/misc.h | 4 - App/radio.c | 23 +-- App/settings.c | 16 +- App/ui/main.c | 26 --- App/ui/main.h | 1 - App/ui/menu.c | 10 -- App/ui/menu.h | 3 - README.md | 3 +- tools/misc/main.cpp | 328 ---------------------------------- 19 files changed, 7 insertions(+), 903 deletions(-) delete mode 100644 App/am_fix.c delete mode 100644 App/am_fix.h diff --git a/App/am_fix.c b/App/am_fix.c deleted file mode 100644 index 3c4c9e45..00000000 --- a/App/am_fix.c +++ /dev/null @@ -1,397 +0,0 @@ - -/* Copyright 2023 OneOfEleven - * https://github.com/DualTachyon - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// code to 'try' and reduce the AM demodulator saturation problem -// -// that is until someone works out how to properly configure the BK chip ! - -#include - -#include "am_fix.h" -#include "app/main.h" -#include "board.h" -#include "driver/bk4819.h" -#include "external/printf/printf.h" -#include "frequencies.h" -#include "functions.h" -#include "misc.h" -#include "settings.h" -#ifdef ENABLE_AGC_SHOW_DATA -#include "ui/main.h" -#endif - -#ifdef ENABLE_AM_FIX - -typedef struct -{ - uint16_t reg_val; - int8_t gain_dB; -} __attribute__((packed)) t_gain_table; - -// REG_10 AGC gain table -// -// <15:10> ??? -// -// <9:8> = LNA Gain Short -// 3 = 0dB < original value -// 2 = -19dB // was -11 -// 1 = -24dB // was -16 -// 0 = -28dB // was -19 -// -// <7:5> = LNA Gain -// 7 = 0dB -// 6 = -2dB -// 5 = -4dB < original value -// 4 = -6dB -// 3 = -9dB -// 2 = -14dB -// 1 = -19dB -// 0 = -24dB -// -// <4:3> = MIXER Gain -// 3 = 0dB < original value -// 2 = -3dB -// 1 = -6dB -// 0 = -8dB -// -// <2:0> = PGA Gain -// 7 = 0dB -// 6 = -3dB < original value -// 5 = -6dB -// 4 = -9dB -// 3 = -15dB -// 2 = -21dB -// 1 = -27dB -// 0 = -33dB - -// front end register dB values -// -// these values need to be accurate for the code to properly/reliably switch -// between table entries when adjusting the front end registers. -// -// these 4 tables need a measuring/calibration update -// -//// static const int16_t lna_short_dB[] = { -19, -16, -11, 0}; // was (but wrong) -// static const int16_t lna_short_dB[] = { (-28), (-24), (-19), 0}; // corrected'ish -// static const int16_t lna_dB[] = { (-24), (-19), (-14), ( -9), (-6), (-4), (-2), 0}; -// static const int16_t mixer_dB[] = { ( -8), ( -6), ( -3), 0}; -// static const int16_t pga_dB[] = { (-33), (-27), (-21), (-15), (-9), (-6), (-3), 0}; - -// lookup table is hugely easier than writing code to do the same -// - -#define LOOKUP_TABLE 1 - -#if LOOKUP_TABLE -static const t_gain_table gain_table[] = -{ - {0x03BE, -7}, // 0 .. 3 5 3 6 .. 0dB -4dB 0dB -3dB .. -7dB original - - {0x0000,-93}, // 1 .. 0 0 0 0 .. -28dB -24dB -8dB -33dB .. -93dB - {0x0008,-91}, // 2 .. 0 0 1 0 .. -28dB -24dB -6dB -33dB .. -91dB - {0x0010,-88}, // 3 .. 0 0 2 0 .. -28dB -24dB -3dB -33dB .. -88dB - {0x0001,-87}, // 4 .. 0 0 0 1 .. -28dB -24dB -8dB -27dB .. -87dB - {0x0009,-85}, // 5 .. 0 0 1 1 .. -28dB -24dB -6dB -27dB .. -85dB - {0x0011,-82}, // 6 .. 0 0 2 1 .. -28dB -24dB -3dB -27dB .. -82dB - {0x0002,-81}, // 7 .. 0 0 0 2 .. -28dB -24dB -8dB -21dB .. -81dB - {0x000A,-79}, // 8 .. 0 0 1 2 .. -28dB -24dB -6dB -21dB .. -79dB - {0x0012,-76}, // 9 .. 0 0 2 2 .. -28dB -24dB -3dB -21dB .. -76dB - {0x0003,-75}, // 10 .. 0 0 0 3 .. -28dB -24dB -8dB -15dB .. -75dB - {0x000B,-73}, // 11 .. 0 0 1 3 .. -28dB -24dB -6dB -15dB .. -73dB - {0x0013,-70}, // 12 .. 0 0 2 3 .. -28dB -24dB -3dB -15dB .. -70dB - {0x0004,-69}, // 13 .. 0 0 0 4 .. -28dB -24dB -8dB -9dB .. -69dB - {0x000C,-67}, // 14 .. 0 0 1 4 .. -28dB -24dB -6dB -9dB .. -67dB - {0x000D,-64}, // 15 .. 0 0 1 5 .. -28dB -24dB -6dB -6dB .. -64dB - {0x001C,-61}, // 16 .. 0 0 3 4 .. -28dB -24dB 0dB - 9dB .. -61dB - {0x001D,-58}, // 17 .. 0 0 3 5 .. -28dB -24dB 0dB -6dB .. -58dB - {0x001E,-55}, // 18 .. 0 0 3 6 .. -28dB -24dB 0dB -3dB .. -55dB - {0x001F,-52}, // 19 .. 0 0 3 7 .. -28dB -24dB 0dB 0dB .. -52dB - {0x003E,-50}, // 20 .. 0 1 3 6 .. -28dB -19dB 0dB -3dB .. -50dB - {0x003F,-47}, // 21 .. 0 1 3 7 .. -28dB -19dB 0dB 0dB .. -47dB - {0x005E,-45}, // 22 .. 0 2 3 6 .. -28dB -14dB 0dB -3dB .. -45dB - {0x005F,-42}, // 23 .. 0 2 3 7 .. -28dB -14dB 0dB 0dB .. -42dB - {0x007E,-40}, // 24 .. 0 3 3 6 .. -28dB -9dB 0dB -3dB .. -40dB - {0x007F,-37}, // 25 .. 0 3 3 7 .. -28dB -9dB 0dB 0dB .. -37dB - {0x009F,-34}, // 26 .. 0 4 3 7 .. -28dB -6dB 0dB 0dB .. -34dB - {0x00BF,-32}, // 27 .. 0 5 3 7 .. -28dB -4dB 0dB 0dB .. -32dB - {0x00DF,-30}, // 28 .. 0 6 3 7 .. -28dB -2dB 0dB 0dB .. -30dB - {0x00FF,-28}, // 29 .. 0 7 3 7 .. -28dB 0dB 0dB 0dB .. -28dB - {0x01DF,-26}, // 30 .. 1 6 3 7 .. -24dB -2dB 0dB 0dB .. -26dB - {0x01FF,-24}, // 31 .. 1 7 3 7 .. -24dB 0dB 0dB 0dB .. -24dB - {0x02BF,-23}, // 32 .. 2 5 3 7 .. -19dB -4dB 0dB 0dB .. -23dB - {0x02DF,-21}, // 33 .. 2 6 3 7 .. -19dB -2dB 0dB -0dB .. -21dB - {0x02FF,-19}, // 34 .. 2 7 3 7 .. -19dB 0dB 0dB 0dB .. -19dB - {0x035E,-17}, // 35 .. 3 2 3 6 .. 0dB -14dB 0dB -3dB .. -17dB - {0x035F,-14}, // 36 .. 3 2 3 7 .. 0dB -14dB 0dB 0dB .. -14dB - {0x037E,-12}, // 37 .. 3 3 3 6 .. 0dB -9dB 0dB -3dB .. -12dB - {0x037F,-9}, // 38 .. 3 3 3 7 .. 0dB -9dB 0dB 0dB .. -9dB - {0x038F,-6}, // 39 .. 3 4 3 7 .. 0dB - 6dB 0dB 0dB .. -6dB - {0x03BF,-4}, // 40 .. 3 5 3 7 .. 0dB -4dB 0dB 0dB .. -4dB - {0x03DF,-2}, // 41 .. 3 6 3 7 .. 0dB - 2dB 0dB 0dB .. -2dB - {0x03FF,0} // 42 .. 3 7 3 7 .. 0dB 0dB 0dB 0dB .. 0dB -}; - -const uint8_t gain_table_size = ARRAY_SIZE(gain_table); -#else - -t_gain_table gain_table[100] = {{0x03BE, -7}}; //original -uint8_t gain_table_size = 0; - -void CreateTable() -{ -typedef union { - struct { - uint8_t pgaIdx:3; - uint8_t mixerIdx:2; - uint8_t lnaIdx:3; - uint8_t lnaSIdx:2; - }; - uint16_t __raw; -} GainData; - - static const int8_t lna_short_dB[] = {-28, -24, -19, 0}; // corrected'ish - static const int8_t lna_dB[] = {-24, -19, -14, -9, -6, -4, -2, 0}; - static const int8_t mixer_dB[] = { -8, -6, -3, 0}; - static const int8_t pga_dB[] = {-33, -27, -21, -15, -9, -6, -3, 0}; - - unsigned i; - for (uint8_t lnaSIdx = 0; lnaSIdx < ARRAY_SIZE(lna_short_dB); lnaSIdx++) { - for (uint8_t lnaIdx = 0; lnaIdx < ARRAY_SIZE(lna_dB); lnaIdx++) { - for (uint8_t mixerIdx = 0; mixerIdx < ARRAY_SIZE(mixer_dB); mixerIdx++) { - for (uint8_t pgaIdx = 0; pgaIdx < ARRAY_SIZE(pga_dB); pgaIdx++) { - int16_t db = lna_short_dB[lnaSIdx] + lna_dB[lnaIdx] + mixer_dB[mixerIdx] + pga_dB[pgaIdx]; - GainData gainData = {{ - pgaIdx, - mixerIdx, - lnaIdx, - lnaSIdx, - }}; - - for (i = 1; i < ARRAY_SIZE(gain_table); i++) { - t_gain_table * gain = &gain_table[i]; - if (db == gain->gain_dB) - break; - if (db > gain->gain_dB) - continue; - if (db < gain->gain_dB) { - if(gain->gain_dB) - memmove(gain + 1, gain, 100 - i); - gain->gain_dB = db; - gain->reg_val = gainData.__raw; - break; - } - gain->gain_dB = db; - gain->reg_val = gainData.__raw; - break; - } - } - } - } - } - - gain_table_size = i+1; -} -#endif - - -#ifdef ENABLE_AM_FIX_SHOW_DATA - // display update rate - static const unsigned int display_update_rate = 250 / 10; // max 250ms display update rate - unsigned int counter = 0; -#endif - -unsigned int gain_table_index[2] = {0, 0}; -// used simply to detect a changed gain setting -unsigned int gain_table_index_prev[2] = {0, 0}; -// holds the previous RSSI level .. we do an average of old + new RSSI reading -int16_t prev_rssi[2] = {0, 0}; -// to help reduce gain hunting, peak hold count down tick -unsigned int hold_counter[2] = {0, 0}; -// -89dBm, any higher and the AM demodulator starts to saturate/clip/distort -const int16_t desired_rssi = (-89 + 160) * 2; - -int8_t currentGainDiff; -bool enabled = true; - -void AM_fix_init(void) -{ // called at boot-up - for (int i = 0; i < 2; i++) { - gain_table_index[i] = 0; // re-start with original QS setting - } -#if !LOOKUP_TABLE - CreateTable(); -#endif -} - -void AM_fix_reset(const unsigned vfo) -{ // reset the AM fixer upper - if (vfo > 1) - return; - - #ifdef ENABLE_AM_FIX_SHOW_DATA - counter = 0; - #endif - - prev_rssi[vfo] = 0; - hold_counter[vfo] = 0; - gain_table_index_prev[vfo] = 0; -} - -// adjust the RX gain to try and prevent the AM demodulator from -// saturating/overloading/clipping (distorted AM audio) -// -// we're actually doing the BK4819's job for it here, but as the chip -// won't/don't do it for itself, we're left to bodging it ourself by -// playing with the RF front end gain setting -// -void AM_fix_10ms(const unsigned vfo) -{ - if(!gSetting_AM_fix || !enabled || vfo > 1 ) - return; - - if (gCurrentFunction != FUNCTION_FOREGROUND && !FUNCTION_IsRx()) { -#ifdef ENABLE_AM_FIX_SHOW_DATA - counter = display_update_rate; // queue up a display update as soon as we switch to RX mode -#endif - return; - } - -#ifdef ENABLE_AM_FIX_SHOW_DATA - if (counter > 0) { - if (++counter >= display_update_rate) { // trigger a display update - counter = 0; - gUpdateDisplay = true; - } - } -#endif - - static uint32_t lastFreq[2]; - if(gEeprom.VfoInfo[vfo].pRX->Frequency != lastFreq[vfo]) { - lastFreq[vfo] = gEeprom.VfoInfo[vfo].pRX->Frequency; - AM_fix_reset(vfo); - } - - int16_t rssi; - { // sample the current RSSI level - // average it with the previous rssi (a bit of noise/spike immunity) - const int16_t new_rssi = BK4819_GetRSSI(); - rssi = (prev_rssi[vfo] > 0) ? (prev_rssi[vfo] + new_rssi) / 2 : new_rssi; - prev_rssi[vfo] = new_rssi; - } - -#ifdef ENABLE_AM_FIX_SHOW_DATA - { - static int16_t lastRssi; - - if (lastRssi != rssi) { // rssi changed - lastRssi = rssi; - - if (counter == 0) { - counter = 1; - gUpdateDisplay = true; // trigger a display update - } - } - } -#endif - - // automatically adjust the RF RX gain - - // update the gain hold counter - if (hold_counter[vfo] > 0) - hold_counter[vfo]--; - - // dB difference between actual and desired RSSI level - int16_t diff_dB = (rssi - desired_rssi) / 2; - - if (diff_dB > 0) { // decrease gain - unsigned int index = gain_table_index[vfo]; // current position we're at - - if (diff_dB >= 10) { // jump immediately to a new gain setting - // this greatly speeds up initial gain reduction (but reduces noise/spike immunity) - - const int16_t desired_gain_dB = (int16_t)gain_table[index].gain_dB - diff_dB + 8; // get no closer than 8dB (bit of noise/spike immunity) - - // scan the table to see what index to jump straight too - while (index > 1) - if (gain_table[--index].gain_dB <= desired_gain_dB) - break; - } - else - { // incrementally reduce the gain .. taking it slow improves noise/spike immunity - if (index > 1) - index--; // slow step-by-step gain reduction - } - - index = MAX(1u, index); - - if (gain_table_index[vfo] != index) - { - gain_table_index[vfo] = index; - hold_counter[vfo] = 30; // 300ms hold - } - } - - if (diff_dB >= -6) // 6dB hysterisis (help reduce gain hunting) - hold_counter[vfo] = 30; // 300ms hold - - if (hold_counter[vfo] == 0) - { // hold has been released, we're free to increase gain - const unsigned int index = gain_table_index[vfo] + 1; // move up to next gain index - gain_table_index[vfo] = MIN(index, gain_table_size - 1u); - } - - - { // apply the new settings to the front end registers - const unsigned int index = gain_table_index[vfo]; - - // remember the new table index - gain_table_index_prev[vfo] = index; - currentGainDiff = gain_table[0].gain_dB - gain_table[index].gain_dB; - BK4819_WriteRegister(BK4819_REG_13, gain_table[index].reg_val); -#ifdef ENABLE_AGC_SHOW_DATA - UI_MAIN_PrintAGC(true); -#endif - } - -#ifdef ENABLE_AM_FIX_SHOW_DATA - if (counter == 0) { - counter = 1; - gUpdateDisplay = true; - } -#endif -} - -#ifdef ENABLE_AM_FIX_SHOW_DATA -void AM_fix_print_data(const unsigned vfo, char *s) { - if (s != NULL && vfo < ARRAY_SIZE(gain_table_index)) { - const unsigned int index = gain_table_index[vfo]; - sprintf(s, "%2u %4ddB %3u", index, gain_table[index].gain_dB, prev_rssi[vfo]); - counter = 0; - } -} -#endif - -int8_t AM_fix_get_gain_diff() -{ - return currentGainDiff; -} - -void AM_fix_enable(bool on) -{ - enabled = on; -} -#endif diff --git a/App/am_fix.h b/App/am_fix.h deleted file mode 100644 index 3667301f..00000000 --- a/App/am_fix.h +++ /dev/null @@ -1,35 +0,0 @@ - -/* Copyright 2023 OneOfEleven - * https://github.com/DualTachyon - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -#ifndef AM_FIXH - -#include -#include - -#ifdef ENABLE_AM_FIX - void AM_fix_init(void); - void AM_fix_reset(const unsigned vfo); - void AM_fix_10ms(const unsigned vfo); - #ifdef ENABLE_AM_FIX_SHOW_DATA - void AM_fix_print_data(const unsigned vfo, char *s); - #endif - int8_t AM_fix_get_gain_diff(); - void AM_fix_enable(bool on); - -#endif - -#endif diff --git a/App/app/action.c b/App/app/action.c index 837c0876..564452d9 100644 --- a/App/app/action.c +++ b/App/app/action.c @@ -675,11 +675,7 @@ void ACTION_Wn(void) } #endif - #ifdef ENABLE_AM_FIX - BK4819_SetFilterBandwidth(bw, true); - #else - BK4819_SetFilterBandwidth(bw, false); - #endif + BK4819_SetFilterBandwidth(bw, false); } void ACTION_BackLight(void) diff --git a/App/app/app.c b/App/app/app.c index 9e70b915..618680e9 100644 --- a/App/app/app.c +++ b/App/app/app.c @@ -18,7 +18,6 @@ #include #include -#include "am_fix.h" #include "app/action.h" #ifdef ENABLE_AIRCOPY @@ -1605,12 +1604,6 @@ void APP_TimeSlice10ms(void) gFlashLightBlinkCounter++; -#ifdef ENABLE_AM_FIX - if (gRxVfo->Modulation == MODULATION_AM) { - AM_fix_10ms(gEeprom.RX_VFO); - } -#endif - #ifdef ENABLE_UART if (UART_IsCommandAvailable(UART_PORT_UART)) { // SCHEDULER_Disable(); diff --git a/App/app/chFrScanner.c b/App/app/chFrScanner.c index c1a25996..d663049d 100644 --- a/App/app/chFrScanner.c +++ b/App/app/chFrScanner.c @@ -553,11 +553,7 @@ static void ScanFastApplyChannelShape(ModulationMode_t modulation) } else { -#ifdef ENABLE_AM_FIX - BK4819_SetFilterBandwidth(BK4819_FILTER_BW_WIDE, true); -#else BK4819_SetFilterBandwidth(BK4819_FILTER_BW_WIDE, false); -#endif } if (modulationChanged) diff --git a/App/app/menu.c b/App/app/menu.c index 80fa0d7f..d5f905c8 100644 --- a/App/app/menu.c +++ b/App/app/menu.c @@ -233,11 +233,6 @@ int MENU_GetLimits(uint8_t menu_id, int32_t *pMin, int32_t *pMax) break; #endif - #ifndef ENABLE_FEAT_F4HWN - #ifdef ENABLE_AM_FIX - case MENU_AM_FIX: - #endif - #endif #ifdef ENABLE_AUDIO_BAR case MENU_MIC_BAR: #endif @@ -844,16 +839,6 @@ void MENU_AcceptSetting(void) gRequestSaveChannel = 1; return; - #ifndef ENABLE_FEAT_F4HWN - #ifdef ENABLE_AM_FIX - case MENU_AM_FIX: - gSetting_AM_fix = gSubMenuSelection; - gVfoConfigureMode = VFO_CONFIGURE_RELOAD; - gFlagResetVfos = true; - break; - #endif - #endif - #ifdef ENABLE_NOAA case MENU_NOAA_S: gEeprom.NOAA_AUTO_SCAN = gSubMenuSelection; @@ -1344,14 +1329,6 @@ void MENU_ShowCurrentSetting(void) gSubMenuSelection = gTxVfo->Modulation; break; -#ifndef ENABLE_FEAT_F4HWN - #ifdef ENABLE_AM_FIX - case MENU_AM_FIX: - gSubMenuSelection = gSetting_AM_fix; - break; - #endif -#endif - #ifdef ENABLE_NOAA case MENU_NOAA_S: gSubMenuSelection = gEeprom.NOAA_AUTO_SCAN; diff --git a/App/app/spectrum.c b/App/app/spectrum.c index f86cb8a1..0661dc13 100644 --- a/App/app/spectrum.c +++ b/App/app/spectrum.c @@ -14,7 +14,6 @@ * limitations under the License. */ #include "app/spectrum.h" -#include "am_fix.h" #include "audio.h" #include "misc.h" @@ -608,10 +607,6 @@ uint16_t GetRssi() // Discard first read (AGC may still be transitioning), keep second BK4819_GetRSSI(); uint16_t rssi = BK4819_GetRSSI(); -#ifdef ENABLE_AM_FIX - if (settings.modulationType == MODULATION_AM && gSetting_AM_fix) - rssi += AM_fix_get_gain_diff() * 2; -#endif return rssi; } @@ -2474,12 +2469,6 @@ static void Tick() if (gNextTimeslice) { gNextTimeslice = false; -#ifdef ENABLE_AM_FIX - if (settings.modulationType == MODULATION_AM && !lockAGC) - { - AM_fix_10ms(vfo); // allow AM_Fix to apply its AGC action - } -#endif BACKLIGHT_Update(); } diff --git a/App/debugging.h b/App/debugging.h index aa0bb692..6679bbc2 100644 --- a/App/debugging.h +++ b/App/debugging.h @@ -7,7 +7,6 @@ #include "driver/bk4819.h" #include "string.h" #include "external/printf/printf.h" -#include "am_fix.h" static inline void LogUart(const char *const str) { diff --git a/App/main.c b/App/main.c index d7d1544f..c5d95d0e 100644 --- a/App/main.c +++ b/App/main.c @@ -18,10 +18,6 @@ #include #include // NULL -#ifdef ENABLE_AM_FIX - #include "am_fix.h" -#endif - #include "audio.h" #include "board.h" #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG @@ -138,10 +134,6 @@ void Main(void) BATTERY_GetReadings(false); -#ifdef ENABLE_AM_FIX - AM_fix_init(); -#endif - BOOT_Mode_t BootMode = BOOT_GetMode(); #ifdef ENABLE_FEAT_F4HWN_MULTIBOOT diff --git a/App/misc.c b/App/misc.c index 5d7b118e..f49ba7d9 100644 --- a/App/misc.c +++ b/App/misc.c @@ -107,10 +107,6 @@ bool gSetting_ScrambleEnable; enum BacklightOnRxTx_t gSetting_backlight_on_tx_rx; -#ifdef ENABLE_AM_FIX - bool gSetting_AM_fix = true; -#endif - #ifdef ENABLE_FEAT_F4HWN_SLEEP uint8_t gSetting_set_off = 1; bool gWakeUp = false; diff --git a/App/misc.h b/App/misc.h index e00dd71b..461f636d 100644 --- a/App/misc.h +++ b/App/misc.h @@ -159,10 +159,6 @@ extern bool gSetting_ScrambleEnable; extern enum BacklightOnRxTx_t gSetting_backlight_on_tx_rx; -#ifdef ENABLE_AM_FIX - extern bool gSetting_AM_fix; -#endif - #ifdef ENABLE_FEAT_F4HWN_SLEEP extern uint8_t gSetting_set_off; extern bool gWakeUp; diff --git a/App/radio.c b/App/radio.c index b32e600b..6fe4974b 100644 --- a/App/radio.c +++ b/App/radio.c @@ -17,7 +17,6 @@ #include "driver/bk4819-regs.h" #include -#include "am_fix.h" #include "app/dtmf.h" #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG #include "app/rxtx_log.h" @@ -767,12 +766,7 @@ void RADIO_SetupRegisters(bool switchToForeground) case BK4819_FILTER_BW_WIDE: case BK4819_FILTER_BW_NARROW: case BK4819_FILTER_BW_NARROWER: - #ifdef ENABLE_AM_FIX - // BK4819_SetFilterBandwidth(Bandwidth, gRxVfo->Modulation == MODULATION_AM && gSetting_AM_fix); - BK4819_SetFilterBandwidth(Bandwidth, true); - #else - BK4819_SetFilterBandwidth(Bandwidth, false); - #endif + BK4819_SetFilterBandwidth(Bandwidth, false); break; } } @@ -994,12 +988,7 @@ void RADIO_SetTxParameters(void) case BK4819_FILTER_BW_WIDE: case BK4819_FILTER_BW_NARROW: case BK4819_FILTER_BW_NARROWER: - #ifdef ENABLE_AM_FIX -// BK4819_SetFilterBandwidth(Bandwidth, gCurrentVfo->Modulation == MODULATION_AM && gSetting_AM_fix); - BK4819_SetFilterBandwidth(Bandwidth, true); - #else - BK4819_SetFilterBandwidth(Bandwidth, false); - #endif + BK4819_SetFilterBandwidth(Bandwidth, false); break; } @@ -1150,14 +1139,6 @@ void RADIO_SetupAGC(bool listeningAM, bool disable) return; lastSettings = newSettings; -#ifdef ENABLE_AM_FIX - if (listeningAM && gSetting_AM_fix) { - BK4819_SetAGC(0); - AM_fix_enable(!disable); - return; - } -#endif - BK4819_SetAGC(!disable); BK4819_InitAGC(listeningAM); } diff --git a/App/settings.c b/App/settings.c index 3cf3f801..41f2e548 100644 --- a/App/settings.c +++ b/App/settings.c @@ -375,11 +375,7 @@ gEeprom.FreqChannel[1] = IS_FREQ_CHANNEL(Data16[5]) ? Data16[5] : (FREQ_CHANNE #ifdef ENABLE_AUDIO_BAR gSetting_mic_bar = !!(Data[7] & (1u << 4)); #endif - #ifndef ENABLE_FEAT_F4HWN - #ifdef ENABLE_AM_FIX - gSetting_AM_fix = !!(Data[7] & (1u << 5)); - #endif - #endif + // Data[7] bit 5 is reserved (legacy ENABLE_AM_FIX). gSetting_backlight_on_tx_rx = (Data[7] >> 6) & 3u; if (!gEeprom.VFO_OPEN) @@ -1076,11 +1072,7 @@ void SETTINGS_SaveSettings(void) #ifdef ENABLE_AUDIO_BAR if (!gSetting_mic_bar) State[7] &= ~(1u << 4); #endif - #ifndef ENABLE_FEAT_F4HWN - #ifdef ENABLE_AM_FIX - if (!gSetting_AM_fix) State[7] &= ~(1u << 5); - #endif - #endif + // State[7] bit 5 is preserved (legacy ENABLE_AM_FIX). State[7] = (State[7] & ~(3u << 6)) | ((gSetting_backlight_on_tx_rx & 3u) << 6); PY25Q16_WriteBuffer(0x00A150, SecBuf, 8, false); @@ -1305,9 +1297,7 @@ State[1] = 0 #ifdef ENABLE_FEAT_F4HWN_GAME | (1 << 3) #endif -#ifdef ENABLE_AM_FIX - | (1 << 4) -#endif +// Bit 4 is reserved (legacy ENABLE_AM_FIX). #ifdef ENABLE_SPECTRUM | (1 << 5) #endif diff --git a/App/ui/main.c b/App/ui/main.c index ff677f48..faf004cd 100644 --- a/App/ui/main.c +++ b/App/ui/main.c @@ -28,9 +28,6 @@ #include "app/beam.h" #endif -#ifdef ENABLE_AM_FIX - #include "am_fix.h" -#endif #include "bitmaps.h" #include "board.h" #include "driver/bk4819.h" @@ -1074,9 +1071,6 @@ void DisplayRSSIBar(const bool now) #ifdef ENABLE_FEAT_F4HWN int16_t rssi_dBm = BK4819_GetRSSI_dBm() -#ifdef ENABLE_AM_FIX - + ((gSetting_AM_fix && gRxVfo->Modulation == MODULATION_AM) ? AM_fix_get_gain_diff() : 0) -#endif + dBmCorrTable[gRxVfo->Band]; // IARU VHF/UHF S-meter: S9 = -93 dBm, 1 S-unit = 6 dB @@ -1113,9 +1107,6 @@ void DisplayRSSIBar(const bool now) const int16_t s0_dBm = -gEeprom.S0_LEVEL; // S0 .. base level const int16_t rssi_dBm = BK4819_GetRSSI_dBm() -#ifdef ENABLE_AM_FIX - + ((gSetting_AM_fix && gRxVfo->Modulation == MODULATION_AM) ? AM_fix_get_gain_diff() : 0) -#endif + dBmCorrTable[gRxVfo->Band]; int s0_9 = gEeprom.S0_LEVEL - gEeprom.S9_LEVEL; @@ -2346,23 +2337,6 @@ void UI_DisplayMain(void) else #endif -#if defined(ENABLE_AM_FIX) && defined(ENABLE_AM_FIX_SHOW_DATA) - if (rx && gEeprom.VfoInfo[gEeprom.RX_VFO].Modulation == MODULATION_AM && gSetting_AM_fix) - { - if (gScreenToDisplay != DISPLAY_MAIN -#ifdef ENABLE_DTMF_CALLING - || gDTMF_CallState != DTMF_CALL_STATE_NONE -#endif - ) - return; - - center_line = CENTER_LINE_AM_FIX_DATA; - AM_fix_print_data(gEeprom.RX_VFO, String); - UI_PrintStringSmallNormal(String, 2, 0, 3); - } - else -#endif - #ifdef ENABLE_RSSI_BAR if (rx) { center_line = CENTER_LINE_RSSI; diff --git a/App/ui/main.h b/App/ui/main.h index db839d06..84033f05 100644 --- a/App/ui/main.h +++ b/App/ui/main.h @@ -27,7 +27,6 @@ enum center_line_t { CENTER_LINE_AUDIO_BAR, CENTER_LINE_AUDIO_SCOPE, CENTER_LINE_RSSI, - CENTER_LINE_AM_FIX_DATA, CENTER_LINE_DTMF_DEC, CENTER_LINE_CHARGE_DATA, #ifdef ENABLE_FEAT_F4HWN_BEAM diff --git a/App/ui/menu.c b/App/ui/menu.c index 71c9b6cd..7eab8922 100644 --- a/App/ui/menu.c +++ b/App/ui/menu.c @@ -127,11 +127,6 @@ const t_menu_item MenuList[] = {"D List", MENU_D_LIST }, #endif {"D Live", MENU_D_LIVE_DEC }, // live DTMF decoder -#ifndef ENABLE_FEAT_F4HWN - #ifdef ENABLE_AM_FIX - {"AM Fix", MENU_AM_FIX }, - #endif -#endif {"VOX", MENU_VOX }, #ifdef ENABLE_FEAT_F4HWN {"SysInf", MENU_VOL }, // was "VOL" @@ -1147,11 +1142,6 @@ void UI_DisplayMenu(void) strcpy(String, gSubMenu_RX_TX[gSubMenuSelection]); break; - #ifndef ENABLE_FEAT_F4HWN - #ifdef ENABLE_AM_FIX - case MENU_AM_FIX: - #endif - #endif case MENU_BCL: case MENU_BEEP: case MENU_STE: diff --git a/App/ui/menu.h b/App/ui/menu.h index fc21a58f..2f75f736 100644 --- a/App/ui/menu.h +++ b/App/ui/menu.h @@ -98,9 +98,6 @@ enum MENU_VOL, MENU_BAT_TXT, MENU_AM, -#ifdef ENABLE_AM_FIX - MENU_AM_FIX, -#endif #ifndef ENABLE_FEAT_F4HWN #ifdef ENABLE_NOAA MENU_NOAA_S, diff --git a/README.md b/README.md index 9ec495af..5847bb1f 100644 --- a/README.md +++ b/README.md @@ -274,11 +274,10 @@ Specialized presets extend Fusion for specific uses: - Improved VFO persistence and restoration. - Extensive code refactoring and memory optimization. - DTMF calling and the scrambler remain disabled in Fusion. -- Legacy AM Fix code is not used by this firmware. +- Legacy AM Fix support has been removed. ## Main features from Egzumer: * many of OneOfEleven mods: - * AM fix, huge improvement in reception quality * long press buttons functions replicating F+ action * fast scanning * channel name editing in the menu diff --git a/tools/misc/main.cpp b/tools/misc/main.cpp index ec5d917a..03097329 100644 --- a/tools/misc/main.cpp +++ b/tools/misc/main.cpp @@ -20,332 +20,6 @@ #include #include -// ************************************************************************ -// create a front end gain table for the firmware - - // <9:8> = LNA Gain Short - // 3 = 0dB < original value - // 2 = -24dB // was -11 - // 1 = -30dB // was -16 - // 0 = -33dB // was -19 - // - // <7:5> = LNA Gain - // 7 = 0dB - // 6 = -2dB - // 5 = -4dB - // 4 = -6dB - // 3 = -9dB - // 2 = -14dB < original value - // 1 = -19dB - // 0 = -24dB - // - // <4:3> = MIXER Gain - // 3 = 0dB < original value - // 2 = -3dB - // 1 = -6dB - // 0 = -8dB - // - // <2:0> = PGA Gain - // 7 = 0dB - // 6 = -3dB < original value - // 5 = -6dB - // 4 = -9dB - // 3 = -15dB - // 2 = -21dB - // 1 = -27dB - // 0 = -33dB - -typedef struct -{ - uint8_t lna_short; - uint8_t lna; - uint8_t mixer; - uint8_t pga; - int16_t lna_short_dB; - int16_t lna_dB; - int16_t mixer_dB; - int16_t pga_dB; - int16_t sum_dB; -} t_gain_table; - -void create_gain_table(const char *filename) -{ - std::vector gain_table; - - if (filename == NULL) - return; - - // front end register dB values -// const int16_t lna_short_dB[4] = { (-19), (-16), (-11), (0)}; // was - const int16_t lna_short_dB[4] = { (-33), (-30), (-24), (0)}; // corrected - const int16_t lna_dB[8] = { (-24), (-19), (-14), (-9), (-6), (-4), (-2), (0)}; - const int16_t mixer_dB[4] = { (-8), (-6), (-3), (0)}; - const int16_t pga_dB[8] = { (-33), (-27), (-21), (-15), (-9), (-6), (-3), (0)}; - - const uint8_t orig_lna_short = 3; - const uint8_t orig_lna = 2; - const uint8_t orig_mixer = 3; - const uint8_t orig_pga = 6; - - const int16_t orig_gain_dB = - lna_short_dB[orig_lna_short] + - lna_dB[orig_lna] + - mixer_dB[orig_mixer] + - pga_dB[orig_pga]; - - #if 1 - // full table - const uint8_t lna_short_min = 0; // 0 - const uint8_t lna_min = 0; // 0 - const uint8_t mixer_min = 0; // 0 - const uint8_t pga_min = 0; // 0 - - const uint8_t lna_short_max = 3; // 3 - const uint8_t lna_max = 7; // 5 - const uint8_t mixer_max = 3; // 3 - const uint8_t pga_max = 7; // 7 - #else - // just one register changes - const uint8_t lna_short_min = 0; - const uint8_t lna_min = 2; - const uint8_t mixer_min = 3; - const uint8_t pga_min = 6; - - const uint8_t lna_short_max = 3; - const uint8_t lna_max = 2; - const uint8_t mixer_max = 3; - const uint8_t pga_max = 6; - #endif - - uint8_t lna_short = lna_short_min; - uint8_t lna = lna_min; - uint8_t mixer = mixer_min; - uint8_t pga = pga_min; - - unsigned int original_index = 0; - - while (true) - { - t_gain_table entry; - - entry.lna_short = lna_short; - entry.lna = lna; - entry.mixer = mixer; - entry.pga = pga; - - entry.lna_short_dB = lna_short_dB[lna_short]; - entry.lna_dB = lna_dB[lna]; - entry.mixer_dB = mixer_dB[mixer]; - entry.pga_dB = pga_dB[pga]; - - entry.sum_dB = lna_short_dB[lna_short] + lna_dB[lna] + mixer_dB[mixer] + pga_dB[pga]; - - if (entry.sum_dB != orig_gain_dB) - gain_table.push_back(entry); - else - if (lna_short == orig_lna_short && lna == orig_lna && mixer == orig_mixer && pga == orig_pga) - gain_table.push_back(entry); - - if (++pga <= pga_max) - continue; - pga = pga_min; - - if (++mixer <= mixer_max) - continue; - mixer = mixer_min; - - if (++lna <= lna_max) - continue; - lna = lna_min; - - if (++lna_short <= lna_short_max) - continue; -// lna_short = lna_short_min; - - break; - } - - // sort the table according top the sum dB - for (unsigned int i = 0; i < (gain_table.size() - 1); i++) - { - t_gain_table entry1 = gain_table[i]; - for (unsigned int k = i + 1; k < gain_table.size(); k++) - { - t_gain_table entry2 = gain_table[k]; - if (entry2.sum_dB < entry1.sum_dB) - { // swap - const t_gain_table entry = entry1; - entry1 = entry2; - entry2 = entry; - gain_table[i] = entry1; - gain_table[k] = entry2; - } - } - } - - { // remove sum_dB duplicates - unsigned int i = 0; - while (i < gain_table.size()) - { - const t_gain_table entry1 = gain_table[i++]; - - if (entry1.lna_short == orig_lna_short && - entry1.lna == orig_lna && - entry1.mixer == orig_mixer && - entry1.pga == orig_pga) - continue; // leave the original inplace - - while (i < gain_table.size()) - { - const t_gain_table entry2 = gain_table[i]; - - if (entry2.lna_short == orig_lna_short && - entry2.lna == orig_lna && - entry2.mixer == orig_mixer && - entry2.pga == orig_pga) - break; // leave the original inplace - - if (entry2.sum_dB != entry1.sum_dB) - break; - - gain_table.erase(gain_table.begin() + i, gain_table.begin() + i + 1); - } - } - } - - // find the index for the original Quansheng register settings - for (int i = (int)gain_table.size() - 1; i >= 0; i--) - { - const t_gain_table entry = gain_table[i]; - - if (entry.sum_dB != orig_gain_dB) - continue; - - if (entry.lna_short != orig_lna_short || - entry.lna != orig_lna || - entry.mixer != orig_mixer || - entry.pga != orig_pga) - continue; - - original_index = i; - break; - } - - // *************************** - // save the table to a file - -/* - typedef struct - { - #if 1 - // bitfields take up less flash bytes - uint8_t lna_short:2; // 0 ~ 3 - uint8_t lna:3; // 0 ~ 7 - uint8_t mixer:2; // 0 ~ 3 - uint8_t pga:3; // 0 ~ 7 - #else - uint8_t lna_short; // 0 ~ 3 - uint8_t lna; // 0 ~ 7 - uint8_t mixer; // 0 ~ 3 - uint8_t pga; // 0 ~ 7 - #endif - } t_am_fix_gain_table; - //} __attribute__((packed)) t_am_fix_gain_table; -*/ - - FILE *file = fopen(filename, "w"); - if (file == NULL) - return; - - fprintf(file, "\n"); - fprintf(file, "\tstatic const t_am_fix_gain_table am_fix_gain_table[] =\n"); - fprintf(file, "\t{\n"); - - #if 0 - fprintf(file, "\t\t{.lna_short = 3, .lna = 2, .mixer = 3, .pga = 6}, // 0 0dB -14dB 0dB -3dB .. -17dB original\n\n"); - - for (unsigned int i = 0; i < gain_table.size(); i++) - { - char s[1024]; - - const t_gain_table entry = gain_table[i]; - - sprintf(s, "\t\t{%u, %u, %u, %u}, // %3u .. %3ddB %3ddB %2ddB %3ddB .. %3ddB", - entry.lna_short, - entry.lna, - entry.mixer, - entry.pga, - 1 + i, - entry.lna_short_dB, - entry.lna_dB, - entry.mixer_dB, - entry.pga_dB, - entry.sum_dB); - - if (i == original_index) - strcat(s, " original"); - - fprintf(file, "%s\n", s); - } - #else - { - //BK4819_WriteRegister(BK4819_REG_13, ((uint16_t)gains.lna_short << 8) | ((uint16_t)gains.lna << 5) | ((uint16_t)gains.mixer << 3) | ((uint16_t)gains.pga << 0)); - - uint16_t reg_val; - int16_t sum_dB; - - reg_val = ((uint16_t)orig_lna_short << 8) | ((uint16_t)orig_lna << 5) | ((uint16_t)orig_mixer << 3) | ((uint16_t)orig_pga << 0); - sum_dB = lna_short_dB[orig_lna_short] + lna_dB[orig_lna] + mixer_dB[orig_mixer] + pga_dB[orig_pga]; - fprintf(file, "\t\t{0x%04X, %-3d}, // 0 .. %u %u %u %u .. 0dB -14dB 0dB -3dB .. -17dB original\n\n", - reg_val, - sum_dB, - orig_lna_short, - orig_lna, - orig_mixer, - orig_pga); - - for (unsigned int i = 0; i < gain_table.size(); i++) - { - char s[1024]; - - const t_gain_table entry = gain_table[i]; - - reg_val = ((uint16_t)entry.lna_short << 8) | ((uint16_t)entry.lna << 5) | ((uint16_t)entry.mixer << 3) | ((uint16_t)entry.pga << 0); - sum_dB = lna_short_dB[entry.lna_short] + lna_dB[entry.lna] + mixer_dB[entry.mixer] + pga_dB[entry.pga]; - - sprintf(s, "\t\t{0x%04X, %-3d}, // %3u .. %u %u %u %u .. %3ddB %3ddB %2ddB %3ddB .. %3ddB", - reg_val, - sum_dB, - - 1 + i, - - entry.lna_short, - entry.lna, - entry.mixer, - entry.pga, - - entry.lna_short_dB, - entry.lna_dB, - entry.mixer_dB, - entry.pga_dB, - - entry.sum_dB); - - if (i == original_index) - strcat(s, " original"); - - fprintf(file, "%s\n", s); - } - } - #endif - - fprintf(file, "\t};\n\n"); - - fprintf(file, "\tstatic const unsigned int original_index = %u;\n", 1 + original_index); - - fclose(file); -} - // ************************************************************************ // "rotate_font()" has nothing to do with this program at all, I just needed // to write a bit of code to rotate some fonts I've drawn @@ -464,8 +138,6 @@ void rotate_font(const char *filename1, const char *filename2) #pragma argsused int main(int argc, char* argv[]) { - create_gain_table("gain_table.c"); - rotate_font("uv-k5_small.bin", "uv-k5_small.c"); rotate_font("uv-k5_small_bold.bin", "uv-k5_small_bold.c"); From 941693a0d81f5625ab9ad316d103b9badabe4149 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sat, 22 Aug 2026 13:58:06 +0200 Subject: [PATCH 23/73] Remove ENABLE_TX_WHEN_AM --- App/CMakeLists.txt | 1 - App/app/foxhunt.c | 2 -- App/radio.c | 4 +--- CMakePresets.json | 1 - 4 files changed, 1 insertion(+), 7 deletions(-) diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index 201e1b89..c72e7ff9 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -150,7 +150,6 @@ enable_feature(ENABLE_SMALL_BOLD) enable_feature(ENABLE_CUSTOM_MENU_LAYOUT) enable_feature(ENABLE_KEEP_MEM_NAME) enable_feature(ENABLE_WIDE_RX) -enable_feature(ENABLE_TX_WHEN_AM) enable_feature(ENABLE_F_CAL_MENU) enable_feature(ENABLE_CTCSS_TAIL_PHASE_SHIFT) enable_feature(ENABLE_BOOT_BEEPS) diff --git a/App/app/foxhunt.c b/App/app/foxhunt.c index 1563e8da..f7ca6596 100644 --- a/App/app/foxhunt.c +++ b/App/app/foxhunt.c @@ -841,10 +841,8 @@ static VfoState_t FOXHUNT_TxState(void) return VFO_STATE_BAT_LOW; if (gBatteryDisplayLevel > 6) return VFO_STATE_VOLTAGE_HIGH; -#ifndef ENABLE_TX_WHEN_AM if (gTxVfo->Modulation != MODULATION_FM) return VFO_STATE_TX_DISABLE; -#endif return VFO_STATE_NORMAL; } diff --git a/App/radio.c b/App/radio.c index 6fe4974b..69cc6bf7 100644 --- a/App/radio.c +++ b/App/radio.c @@ -1213,12 +1213,10 @@ void RADIO_PrepareTX(void) State = VFO_STATE_TX_DISABLE; } #endif -#ifndef ENABLE_TX_WHEN_AM else if (gCurrentVfo->Modulation != MODULATION_FM) { - // not allowed to TX if in AM mode + // AM and other non-FM modes are receive-only. State = VFO_STATE_TX_DISABLE; } -#endif if (State != VFO_STATE_NORMAL) { // TX not allowed diff --git a/CMakePresets.json b/CMakePresets.json index 8f36208a..9a8e4caa 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -39,7 +39,6 @@ "ENABLE_CUSTOM_MENU_LAYOUT": true, "ENABLE_KEEP_MEM_NAME": true, "ENABLE_WIDE_RX": true, - "ENABLE_TX_WHEN_AM": false, "ENABLE_F_CAL_MENU": false, "ENABLE_CTCSS_TAIL_PHASE_SHIFT": false, "ENABLE_BOOT_BEEPS": false, From ec7b4083c026640a09aa12471c5d724c375d10bb Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sat, 22 Aug 2026 14:25:56 +0200 Subject: [PATCH 24/73] Stabilize action IDs and handle unavailable actions --- App/app/action.c | 46 ++++++++++------------------------ App/app/action.h | 4 +++ App/settings.h | 65 +++++++++++++++++++++--------------------------- App/ui/main.c | 2 ++ App/ui/menu.c | 45 ++++++++++++++------------------- 5 files changed, 65 insertions(+), 97 deletions(-) diff --git a/App/app/action.c b/App/app/action.c index 564452d9..7cafb6bd 100644 --- a/App/app/action.c +++ b/App/app/action.c @@ -65,7 +65,7 @@ static void ACTION_1750(void); inline static void ACTION_ScanRestart() { ACTION_Scan(true); }; -void (*const action_opt_table[])(void) = { +void (*const action_opt_table[ACTION_OPT_LEN])(void) = { [ACTION_OPT_NONE] = &FUNCTION_NOP, [ACTION_OPT_POWER] = &ACTION_Power, [ACTION_OPT_MONITOR] = &ACTION_Monitor, @@ -77,38 +77,25 @@ void (*const action_opt_table[])(void) = { #ifdef ENABLE_FLASHLIGHT [ACTION_OPT_FLASHLIGHT] = &ACTION_FlashLight, -#else - [ACTION_OPT_FLASHLIGHT] = &FUNCTION_NOP, #endif #ifdef ENABLE_VOX [ACTION_OPT_VOX] = &ACTION_Vox, -#else - [ACTION_OPT_VOX] = &FUNCTION_NOP, #endif #ifdef ENABLE_FMRADIO [ACTION_OPT_FM] = &ACTION_FM, -#else - [ACTION_OPT_FM] = &FUNCTION_NOP, #endif - [ACTION_OPT_RESERVED_ALARM] = &FUNCTION_NOP, - #ifdef ENABLE_TX1750 [ACTION_OPT_1750] = &ACTION_1750, -#else - [ACTION_OPT_1750] = &FUNCTION_NOP, #endif - [ACTION_OPT_RESERVED_BLMIN_TMP_OFF] = &FUNCTION_NOP, - #ifdef ENABLE_FEAT_F4HWN [ACTION_OPT_RXMODE] = &ACTION_RxMode, [ACTION_OPT_MAINONLY] = &ACTION_MainOnly, [ACTION_OPT_PTT] = &ACTION_Ptt, [ACTION_OPT_WN] = &ACTION_Wn, - [ACTION_OPT_BACKLIGHT] = &ACTION_BackLight, //#if !defined(ENABLE_SPECTRUM) || !defined(ENABLE_FMRADIO) [ACTION_OPT_MUTE] = &ACTION_Mute, //#else @@ -116,16 +103,12 @@ void (*const action_opt_table[])(void) = { //#endif #ifdef ENABLE_FEAT_F4HWN_AUDIO [ACTION_OPT_RXA] = &ACTION_RxA, - #else - [ACTION_OPT_RXA] = &FUNCTION_NOP, #endif #ifdef ENABLE_FEAT_F4HWN_RESCUE_OPS [ACTION_OPT_POWER_HIGH] = &ACTION_Power_High, [ACTION_OPT_REMOVE_OFFSET] = &ACTION_Remove_Offset, #endif -#else - [ACTION_OPT_RXMODE] = &FUNCTION_NOP, #endif #ifdef ENABLE_FEAT_F4HWN_BEAM [ACTION_OPT_BEAM] = &ACTION_Beam, @@ -139,6 +122,16 @@ void (*const action_opt_table[])(void) = { }; static_assert(ARRAY_SIZE(action_opt_table) == ACTION_OPT_LEN); +static_assert(ACTION_OPT_RXTX_LOG == 18); +static_assert(ACTION_OPT_BEAM == 19); +static_assert(ACTION_OPT_POWER_HIGH == 20); +static_assert(ACTION_OPT_REMOVE_OFFSET == 21); +static_assert(ACTION_OPT_FOXHUNT == 22); + +bool ACTION_IsAvailable(uint8_t action) +{ + return action < ACTION_OPT_LEN && action_opt_table[action] != NULL; +} void ACTION_Power(void) { @@ -330,10 +323,9 @@ inline static bool ACTION_IsBlockedInFM(uint8_t action) } #endif -#ifdef ENABLE_FEAT_F4HWN_ACTION_PICKER static void ACTION_Execute(uint8_t action) { - if (action >= ACTION_OPT_LEN || action_opt_table[action] == NULL) { + if (!ACTION_IsAvailable(action)) { gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL; return; } @@ -349,6 +341,7 @@ static void ACTION_Execute(uint8_t action) action_opt_table[action](); } +#ifdef ENABLE_FEAT_F4HWN_ACTION_PICKER uint8_t gActionPickerKey; uint8_t gActionPickerSelection[2] = {1, 1}; uint8_t gActionPickerTimeout_500ms; @@ -467,20 +460,7 @@ void ACTION_Handle(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) } // held or released after short press -#ifdef ENABLE_FEAT_F4HWN_ACTION_PICKER ACTION_Execute(func); -#else - gBeepToPlay = BEEP_1KHZ_60MS_OPTIONAL; - -#ifdef ENABLE_FMRADIO - if (gFmRadioMode && ACTION_IsBlockedInFM(func)) { - gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL; - return; - } -#endif - - action_opt_table[func](); -#endif } diff --git a/App/app/action.h b/App/app/action.h index f6dd3fa7..6c01465f 100644 --- a/App/app/action.h +++ b/App/app/action.h @@ -17,6 +17,9 @@ #ifndef APP_ACTION_H #define APP_ACTION_H +#include +#include + #include "driver/keyboard.h" void ACTION_Power(void); @@ -58,6 +61,7 @@ extern uint8_t gActionPickerSelection[2]; extern uint8_t gActionPickerTimeout_500ms; bool ACTION_PickerProcessKey(KEY_Code_t key, bool isPressed, bool isHeld); #endif +bool ACTION_IsAvailable(uint8_t action); void ACTION_Handle(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld); #endif diff --git a/App/settings.h b/App/settings.h index da3d6ca3..b1176484 100644 --- a/App/settings.h +++ b/App/settings.h @@ -100,43 +100,34 @@ enum { }; enum ACTION_OPT_t { - ACTION_OPT_NONE = 0, - ACTION_OPT_FLASHLIGHT, - ACTION_OPT_POWER, - ACTION_OPT_MONITOR, - ACTION_OPT_SCAN, - ACTION_OPT_VOX, - ACTION_OPT_RESERVED_ALARM, // Reserved EEPROM ID; removed in v6.0.0 - ACTION_OPT_FM, - ACTION_OPT_1750, - ACTION_OPT_KEYLOCK, - ACTION_OPT_A_B, - ACTION_OPT_VFO_MR, - ACTION_OPT_SWITCH_DEMODUL, - ACTION_OPT_RESERVED_BLMIN_TMP_OFF, // Reserved EEPROM ID; removed in v6.0.0 -#ifdef ENABLE_FEAT_F4HWN - ACTION_OPT_RXMODE, - ACTION_OPT_MAINONLY, - ACTION_OPT_PTT, - ACTION_OPT_WN, - ACTION_OPT_BACKLIGHT, - ACTION_OPT_MUTE, - ACTION_OPT_RXA, - #ifdef ENABLE_FEAT_F4HWN_RESCUE_OPS - ACTION_OPT_POWER_HIGH, - ACTION_OPT_REMOVE_OFFSET, - #endif -#endif -#ifdef ENABLE_FEAT_F4HWN_BEAM - ACTION_OPT_BEAM, -#endif -#ifdef ENABLE_FEAT_F4HWN_RXTX_LOG - ACTION_OPT_RXTX_LOG, -#endif -#ifdef ENABLE_FEAT_F4HWN_FOXHUNT - ACTION_OPT_FOXHUNT, -#endif - ACTION_OPT_LEN + /* Persisted in EEPROM: never renumber or make these values conditional. */ + ACTION_OPT_NONE = 0, + ACTION_OPT_FLASHLIGHT = 1, + ACTION_OPT_POWER = 2, + ACTION_OPT_MONITOR = 3, + ACTION_OPT_SCAN = 4, + ACTION_OPT_VOX = 5, + ACTION_OPT_FM = 6, + ACTION_OPT_1750 = 7, + ACTION_OPT_KEYLOCK = 8, + ACTION_OPT_A_B = 9, + ACTION_OPT_VFO_MR = 10, + ACTION_OPT_SWITCH_DEMODUL = 11, + ACTION_OPT_RXMODE = 12, + ACTION_OPT_MAINONLY = 13, + ACTION_OPT_PTT = 14, + ACTION_OPT_WN = 15, + ACTION_OPT_MUTE = 16, + ACTION_OPT_RXA = 17, + + /* Preset-specific actions keep their IDs even when not compiled. */ + ACTION_OPT_RXTX_LOG = 18, + ACTION_OPT_BEAM = 19, + ACTION_OPT_POWER_HIGH = 20, + ACTION_OPT_REMOVE_OFFSET = 21, + ACTION_OPT_FOXHUNT = 22, + + ACTION_OPT_LEN = 23 }; #ifdef ENABLE_VOICE diff --git a/App/ui/main.c b/App/ui/main.c index faf004cd..5405312f 100644 --- a/App/ui/main.c +++ b/App/ui/main.c @@ -1366,6 +1366,8 @@ void UI_DisplayMain(void) UI_PrintActionPickerLabel(previous, 1, false); UI_PrintActionPickerLabel(selection, 2, true); UI_PrintActionPickerLabel(next, 4, false); + if (!ACTION_IsAvailable(gSubMenu_SIDEFUNCTIONS[selection].id)) + UI_PrintStringSmallNormalInverse("N/A", 53, 0, 6); ST7565_BlitFullScreen(); return; } diff --git a/App/ui/menu.c b/App/ui/menu.c index 7eab8922..2e7a0a30 100644 --- a/App/ui/menu.c +++ b/App/ui/menu.c @@ -14,9 +14,11 @@ * limitations under the License. */ +#include #include #include +#include "../app/action.h" #include "../app/dtmf.h" #include "../app/menu.h" #include "../bitmaps.h" @@ -472,51 +474,32 @@ const char* const gSubMenu_SCRAMBLER[] = const t_sidefunction gSubMenu_SIDEFUNCTIONS[] = { {"NONE", ACTION_OPT_NONE}, -#ifdef ENABLE_FLASHLIGHT {"FLASH\nLIGHT", ACTION_OPT_FLASHLIGHT}, -#endif {"POWER", ACTION_OPT_POWER}, {"MONITOR", ACTION_OPT_MONITOR}, {"SCAN", ACTION_OPT_SCAN}, -#ifdef ENABLE_VOX {"VOX", ACTION_OPT_VOX}, -#endif -#ifdef ENABLE_FMRADIO {"FM RADIO", ACTION_OPT_FM}, -#endif -#ifdef ENABLE_TX1750 {"1750Hz", ACTION_OPT_1750}, -#endif {"LOCK\nKEYPAD", ACTION_OPT_KEYLOCK}, {"VFO A\nVFO B", ACTION_OPT_A_B}, {"VFO\nMEM", ACTION_OPT_VFO_MR}, {"MODE", ACTION_OPT_SWITCH_DEMODUL}, -#ifdef ENABLE_FEAT_F4HWN {"RX MODE", ACTION_OPT_RXMODE}, {"MAIN ONLY", ACTION_OPT_MAINONLY}, {"PTT", ACTION_OPT_PTT}, {"WIDE\nNARROW", ACTION_OPT_WN}, {"MUTE", ACTION_OPT_MUTE}, - #ifdef ENABLE_FEAT_F4HWN_AUDIO - {"RxA", ACTION_OPT_RXA}, - #endif - #ifdef ENABLE_FEAT_F4HWN_RESCUE_OPS - {"POWER\nHIGH", ACTION_OPT_POWER_HIGH}, - {"REMOVE\nOFFSET", ACTION_OPT_REMOVE_OFFSET}, - #endif - #ifdef ENABLE_FEAT_F4HWN_BEAM - {"BEAM", ACTION_OPT_BEAM}, - #endif - #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG - {"RF LOG", ACTION_OPT_RXTX_LOG}, - #endif - #ifdef ENABLE_FEAT_F4HWN_FOXHUNT - {"FOX HUNT\nBEACON", ACTION_OPT_FOXHUNT}, - #endif -#endif + {"RxA", ACTION_OPT_RXA}, + {"RF LOG", ACTION_OPT_RXTX_LOG}, + {"BEAM", ACTION_OPT_BEAM}, + {"POWER\nHIGH", ACTION_OPT_POWER_HIGH}, + {"REMOVE\nOFFSET", ACTION_OPT_REMOVE_OFFSET}, + {"FOX HUNT\nBEACON", ACTION_OPT_FOXHUNT}, }; const uint8_t gSubMenu_SIDEFUNCTIONS_size = ARRAY_SIZE(gSubMenu_SIDEFUNCTIONS); +static_assert(ARRAY_SIZE(gSubMenu_SIDEFUNCTIONS) == ACTION_OPT_LEN); bool gIsInSubMenu; uint8_t gMenuCursor; @@ -854,6 +837,7 @@ void UI_DisplayMenu(void) unsigned int i; char String[64]; // bigger cuz we can now do multi-line in one string (use '\n' char) char top_right_badge[16]; + uint8_t top_right_badge_line = 1; #ifdef ENABLE_FEAT_F4HWN_MENU_CAT if (gMenuLevel == MENU_LEVEL_CAT) @@ -1589,8 +1573,15 @@ void UI_DisplayMenu(void) case MENU_F2SHRT: case MENU_F2LONG: case MENU_MLONG: + { + const uint8_t action = gSubMenu_SIDEFUNCTIONS[gSubMenuSelection].id; strcpy(String, gSubMenu_SIDEFUNCTIONS[gSubMenuSelection].name); + if (!ACTION_IsAvailable(action)) { + strcpy(top_right_badge, "N/A"); + top_right_badge_line = 5; + } break; + } #ifdef ENABLE_FEAT_F4HWN_SLEEP case MENU_SET_OFF: @@ -1833,7 +1824,7 @@ void UI_DisplayMenu(void) #endif if (top_right_badge[0] != '\0') { - UI_MENU_DrawTopRightRoundedBadge(top_right_badge, 1, true, menu_item_x1, menu_item_x2); + UI_MENU_DrawTopRightRoundedBadge(top_right_badge, top_right_badge_line, true, menu_item_x1, menu_item_x2); } if ((m == MENU_RESET || From 751c6064d1479772c5ef2cbaf0d1b58b0eeda729 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sat, 22 Aug 2026 15:55:32 +0200 Subject: [PATCH 25/73] Replace build options with CHIRP capabilities --- App/main.c | 2 +- App/settings.c | 59 ++++++++------------------------------------------ App/settings.h | 2 +- 3 files changed, 11 insertions(+), 52 deletions(-) diff --git a/App/main.c b/App/main.c index c5d95d0e..90b28c1f 100644 --- a/App/main.c +++ b/App/main.c @@ -119,7 +119,7 @@ void Main(void) gCB = gEeprom.CROSS_BAND_RX_TX; #endif - SETTINGS_WriteBuildOptions(); + SETTINGS_WriteChirpCapabilities(); SETTINGS_LoadCalibration(); RADIO_ConfigureChannel(0, VFO_CONFIGURE_RELOAD); diff --git a/App/settings.c b/App/settings.c index 41f2e548..330feb59 100644 --- a/App/settings.c +++ b/App/settings.c @@ -1251,61 +1251,20 @@ void SETTINGS_UpdateChannel(uint16_t channel, const VFO_Info_t *pVFO, bool keep) SETTINGS_SaveChannelName(channel, ""); } -void SETTINGS_WriteBuildOptions(void) +void SETTINGS_WriteChirpCapabilities(void) { - uint8_t State[8]; + enum { + CHIRP_CAP_RESCUE_OPS = 1u << 0, + }; -#ifdef ENABLE_FEAT_F4HWN - // 0x1FF0 - PY25Q16_ReadBuffer(0x00A158, State, sizeof(State)); -#endif - -State[0] = 0 -#ifdef ENABLE_FMRADIO - | (1 << 0) -#endif -#ifdef ENABLE_NOAA - | (1 << 1) -#endif -#ifdef ENABLE_VOICE - | (1 << 2) -#endif -#ifdef ENABLE_VOX - | (1 << 3) -#endif -#ifdef ENABLE_TX1750 - | (1 << 5) -#endif -#ifdef ENABLE_PWRON_PASSWORD - | (1 << 6) -#endif -#ifdef ENABLE_DTMF_CALLING - | (1 << 7) -#endif -; + uint8_t capabilities[2] = {0}; -State[1] = 0 -#ifdef ENABLE_FLASHLIGHT - | (1 << 0) -#endif -#ifdef ENABLE_WIDE_RX - | (1 << 1) -#endif -#ifdef ENABLE_BYP_RAW_DEMODULATORS - | (1 << 2) -#endif -#ifdef ENABLE_FEAT_F4HWN_GAME - | (1 << 3) -#endif -// Bit 4 is reserved (legacy ENABLE_AM_FIX). -#ifdef ENABLE_SPECTRUM - | (1 << 5) -#endif #ifdef ENABLE_FEAT_F4HWN_RESCUE_OPS - | (1 << 6) + capabilities[0] |= CHIRP_CAP_RESCUE_OPS; #endif -; - PY25Q16_WriteBuffer(0x00A158, State, sizeof(State), false); + + // 0xA158..0xA159 are reserved for the firmware/CHIRP capability contract. + PY25Q16_WriteBuffer(0x00A158, capabilities, sizeof(capabilities), false); } #ifdef ENABLE_FEAT_F4HWN_RESUME_STATE diff --git a/App/settings.h b/App/settings.h index b1176484..791d0556 100644 --- a/App/settings.h +++ b/App/settings.h @@ -329,7 +329,7 @@ void SETTINGS_SaveChannelName(uint16_t channel, const char * name); void SETTINGS_SaveChannel(uint16_t Channel, uint8_t VFO, const VFO_Info_t *pVFO, uint8_t Mode); void SETTINGS_SaveBatteryCalibration(const uint16_t * batteryCalibration); void SETTINGS_UpdateChannel(uint16_t channel, const VFO_Info_t *pVFO, bool keep); -void SETTINGS_WriteBuildOptions(void); +void SETTINGS_WriteChirpCapabilities(void); #ifdef ENABLE_FEAT_F4HWN_RESUME_STATE void SETTINGS_WriteCurrentState(void); #endif From 4315b98aec1dd64f6821faa94649789bf5c2f9f7 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sat, 22 Aug 2026 22:49:14 +0200 Subject: [PATCH 26/73] Preserve RescueOps settings across presets --- App/main.c | 1 - App/settings.c | 26 ++++++++------------------ App/settings.h | 1 - 3 files changed, 8 insertions(+), 20 deletions(-) diff --git a/App/main.c b/App/main.c index 90b28c1f..c28fe525 100644 --- a/App/main.c +++ b/App/main.c @@ -119,7 +119,6 @@ void Main(void) gCB = gEeprom.CROSS_BAND_RX_TX; #endif - SETTINGS_WriteChirpCapabilities(); SETTINGS_LoadCalibration(); RADIO_ConfigureChannel(0, VFO_CONFIGURE_RELOAD); diff --git a/App/settings.c b/App/settings.c index 330feb59..f7836c87 100644 --- a/App/settings.c +++ b/App/settings.c @@ -152,7 +152,9 @@ void SETTINGS_InitEEPROM(void) gEeprom.SET_KEY = ((Data[4] >> 2) & 0x0F) > 4 ? 0 : (Data[4] >> 2) & 0x0F; gEeprom.SET_NAV = (Data[4] & 0x40) != 0; #else - gEeprom.KEY_LOCK = (Data[4] < 2) ? Data[4] : false; + // RescueOps fields can be present in a config bank shared with another + // preset. Read only KEY_LOCK here and leave the other bits untouched. + gEeprom.KEY_LOCK = (Data[4] & 0x01) != 0; #endif #ifdef ENABLE_VOX gEeprom.VOX_SWITCH = (Data[5] < 2) ? Data[5] : false; @@ -896,7 +898,11 @@ void SETTINGS_SaveSettings(void) ((gEeprom.SET_KEY & 0x0F) << 2) | (gEeprom.SET_NAV ? 0x40 : 0); #else - State[4] = gEeprom.KEY_LOCK; + // A non-RescueOps preset owns KEY_LOCK only. Preserve Set RescueOps, + // SetKEY, SetNav and reserved bits from a config created by another + // preset while updating bit 0. + PY25Q16_ReadBuffer(0x00A004, &State[4], 1); + State[4] = (State[4] & 0xFEu) | (gEeprom.KEY_LOCK ? 0x01u : 0u); #endif #ifdef ENABLE_VOX @@ -1251,22 +1257,6 @@ void SETTINGS_UpdateChannel(uint16_t channel, const VFO_Info_t *pVFO, bool keep) SETTINGS_SaveChannelName(channel, ""); } -void SETTINGS_WriteChirpCapabilities(void) -{ - enum { - CHIRP_CAP_RESCUE_OPS = 1u << 0, - }; - - uint8_t capabilities[2] = {0}; - -#ifdef ENABLE_FEAT_F4HWN_RESCUE_OPS - capabilities[0] |= CHIRP_CAP_RESCUE_OPS; -#endif - - // 0xA158..0xA159 are reserved for the firmware/CHIRP capability contract. - PY25Q16_WriteBuffer(0x00A158, capabilities, sizeof(capabilities), false); -} - #ifdef ENABLE_FEAT_F4HWN_RESUME_STATE void SETTINGS_WriteCurrentState(void) { diff --git a/App/settings.h b/App/settings.h index 791d0556..22b003ff 100644 --- a/App/settings.h +++ b/App/settings.h @@ -329,7 +329,6 @@ void SETTINGS_SaveChannelName(uint16_t channel, const char * name); void SETTINGS_SaveChannel(uint16_t Channel, uint8_t VFO, const VFO_Info_t *pVFO, uint8_t Mode); void SETTINGS_SaveBatteryCalibration(const uint16_t * batteryCalibration); void SETTINGS_UpdateChannel(uint16_t channel, const VFO_Info_t *pVFO, bool keep); -void SETTINGS_WriteChirpCapabilities(void); #ifdef ENABLE_FEAT_F4HWN_RESUME_STATE void SETTINGS_WriteCurrentState(void); #endif From 04a80916a2ba2d9ed88da20d619867dce5a3def6 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 23 Aug 2026 23:57:27 +0200 Subject: [PATCH 27/73] Rename Extended preset to Max --- CMakePresets.json | 30 +++++++++++++++--------------- README.md | 15 ++++++++------- compile-with-docker.sh | 11 ++++++----- 3 files changed, 29 insertions(+), 27 deletions(-) diff --git a/CMakePresets.json b/CMakePresets.json index 9a8e4caa..8dc7ca5a 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -147,16 +147,6 @@ "TARGET": "f4hwn.fusion" } }, - { - "name": "Transfer", - "inherits": "Fusion", - "cacheVariables": { - "ENABLE_AIRCOPY": true, - "ENABLE_FEAT_F4HWN_BEAM": true, - "EDITION_STRING": "Transfer", - "TARGET": "f4hwn.transfer" - } - }, { "name": "Field", "inherits": "Fusion", @@ -168,7 +158,17 @@ } }, { - "name": "Extended", + "name": "Transfer", + "inherits": "Fusion", + "cacheVariables": { + "ENABLE_AIRCOPY": true, + "ENABLE_FEAT_F4HWN_BEAM": true, + "EDITION_STRING": "Transfer", + "TARGET": "f4hwn.transfer" + } + }, + { + "name": "Max", "inherits": "Fusion", "cacheVariables": { "ENABLE_AIRCOPY": true, @@ -176,8 +176,8 @@ "ENABLE_FEAT_F4HWN_RESCUE_OPS": true, "ENABLE_FEAT_F4HWN_BEAM": true, "ENABLE_FEAT_F4HWN_FOXHUNT": true, - "EDITION_STRING": "Extended", - "TARGET": "f4hwn.extended" + "EDITION_STRING": "Max", + "TARGET": "f4hwn.max" } } ], @@ -199,8 +199,8 @@ "configurePreset": "Field" }, { - "name": "Extended", - "configurePreset": "Extended" + "name": "Max", + "configurePreset": "Max" } ] } diff --git a/README.md b/README.md index 5847bb1f..bff5194e 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,6 @@ for everyday use and is the base inherited by the specialized editions. It inclu - broadcast FM radio and VOX, - [UV Studio](https://armel.github.io/uvstudio/) with integrated K5Viewer screen mirroring, screenshots and remote keyboard control, - advanced RX audio profiles and Audio Scope, -- the Breakout game, - automatic RX/TX activity logging with RF Log, - multiboot support. @@ -74,7 +73,9 @@ Specialized presets extend Fusion for specific uses: - **Transfer** adds AirCopy and BEAM wireless channel transfer. - **Field** adds first-responder controls, Fox Hunt and Morse Beacon support. -- **Extended** combines the Transfer and Field feature sets without claiming to include every optional feature. +- **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. - **Custom** remains a manually configured build based directly on the hidden technical default. ### Radio and signal handling @@ -359,8 +360,8 @@ The default preset is **Fusion**. Available presets are: - **Fusion** - **Transfer** - **Field** -- **Extended** -- **All** (Fusion, Transfer, Field and Extended) +- **Max** +- **All** (Fusion, Transfer, Field and Max) Examples: @@ -369,7 +370,7 @@ Examples: ./compile-with-docker.sh Fusion ./compile-with-docker.sh Transfer ./compile-with-docker.sh Field -./compile-with-docker.sh Extended +./compile-with-docker.sh Max ./compile-with-docker.sh All ``` @@ -381,8 +382,8 @@ These are forwarded directly to `cmake --preset` inside the container. Examples: ```bash -./compile-with-docker.sh Fusion -DENABLE_SPECTRUM=ON -./compile-with-docker.sh Fusion -DENABLE_FEAT_F4HWN_GAME=ON -DENABLE_NOAA=ON +./compile-with-docker.sh Field -DENABLE_VOX=OFF +./compile-with-docker.sh Fusion -DENABLE_FEAT_F4HWN_GAME=ON ./compile-with-docker.sh Fusion -DSQL_TONE=600 ``` diff --git a/compile-with-docker.sh b/compile-with-docker.sh index dde93b12..c1cacc9e 100755 --- a/compile-with-docker.sh +++ b/compile-with-docker.sh @@ -7,15 +7,16 @@ set -euo pipefail # Examples: # ./compile-with-docker.sh Fusion # ./compile-with-docker.sh Transfer -# ./compile-with-docker.sh Field -DENABLE_NOAA=ON -# ./compile-with-docker.sh Custom -DENABLE_SPECTRUM=ON +# ./compile-with-docker.sh Field +# ./compile-with-docker.sh Max # ./compile-with-docker.sh Fusion -DDEV=ON +# ./compile-with-docker.sh Field -DENABLE_VOX=OFF # ./compile-with-docker.sh All # Default preset: "Fusion" # --------------------------------------------- IMAGE=uvk1-uvk5v3 -RELEASE_PRESETS=(Fusion Transfer Field Extended) +RELEASE_PRESETS=(Fusion Transfer Field Max) FLASH_LIMIT=$((118 * 1024)) RAM_LIMIT=$((16 * 1024)) @@ -28,9 +29,9 @@ EXTRA_ARGS=("$@") # --------------------------------------------- # Validate preset name # --------------------------------------------- -if [[ ! "$PRESET" =~ ^(Custom|Fusion|Transfer|Field|Extended|All)$ ]]; then +if [[ ! "$PRESET" =~ ^(Custom|Fusion|Transfer|Field|Max|All)$ ]]; then echo "❌ Unknown preset: '$PRESET'" - echo "Valid presets are: Custom, Fusion, Transfer, Field, Extended, All" + echo "Valid presets are: Custom, Fusion, Transfer, Field, Max, All" exit 1 fi From e1dfbde590dc7531dbb0d5cc9adffab58e9f259b Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 24 Aug 2026 00:14:40 +0200 Subject: [PATCH 28/73] Prevent FM start during active VFO RX --- App/app/action.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/App/app/action.c b/App/app/action.c index 7cafb6bd..012bcdb5 100644 --- a/App/app/action.c +++ b/App/app/action.c @@ -482,6 +482,14 @@ void ACTION_FM(void) return; } + // Do not start broadcast FM while a VFO reception is already active. + // Keeping this check after the block above ensures EXIT can still + // turn FM off if the UI ever reaches DISPLAY_MAIN with FM mode active. + if (FUNCTION_IsRx()) { + gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL; + return; + } + gMonitor = false; if (gScanStateDir != SCAN_OFF) { From 92c380dd7ad2f6fc696f25107a71c1868ccad012 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 24 Aug 2026 00:48:31 +0200 Subject: [PATCH 29/73] Rename Field preset to FieldOps --- CMakePresets.json | 14 +++++++------- README.md | 10 +++++----- compile-with-docker.sh | 10 +++++----- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/CMakePresets.json b/CMakePresets.json index 8dc7ca5a..5f53fa05 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -148,12 +148,12 @@ } }, { - "name": "Field", + "name": "FieldOps", "inherits": "Fusion", "cacheVariables": { "ENABLE_FEAT_F4HWN_RESCUE_OPS": true, "ENABLE_FEAT_F4HWN_FOXHUNT": true, - "EDITION_STRING": "Field", + "EDITION_STRING": "FieldOps", "TARGET": "f4hwn.field" } }, @@ -171,11 +171,11 @@ "name": "Max", "inherits": "Fusion", "cacheVariables": { - "ENABLE_AIRCOPY": true, - "ENABLE_FEAT_F4HWN_GAME": true, "ENABLE_FEAT_F4HWN_RESCUE_OPS": true, - "ENABLE_FEAT_F4HWN_BEAM": true, "ENABLE_FEAT_F4HWN_FOXHUNT": true, + "ENABLE_AIRCOPY": true, + "ENABLE_FEAT_F4HWN_BEAM": true, + "ENABLE_FEAT_F4HWN_GAME": true, "EDITION_STRING": "Max", "TARGET": "f4hwn.max" } @@ -195,8 +195,8 @@ "configurePreset": "Transfer" }, { - "name": "Field", - "configurePreset": "Field" + "name": "FieldOps", + "configurePreset": "FieldOps" }, { "name": "Max", diff --git a/README.md b/README.md index bff5194e..b0b9bdf1 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ for everyday use and is the base inherited by the specialized editions. It inclu Specialized presets extend Fusion for specific uses: - **Transfer** adds AirCopy and BEAM wireless channel transfer. -- **Field** adds first-responder controls, Fox Hunt and Morse Beacon support. +- **FieldOps** adds first-responder controls, Fox Hunt and Morse Beacon support. - **Max** packs the broadest practical selection of features that fits safely within 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. @@ -359,9 +359,9 @@ The default preset is **Fusion**. Available presets are: - **Custom** - **Fusion** - **Transfer** -- **Field** +- **FieldOps** - **Max** -- **All** (Fusion, Transfer, Field and Max) +- **All** (Fusion, Transfer, FieldOps and Max) Examples: @@ -369,7 +369,7 @@ Examples: ./compile-with-docker.sh ./compile-with-docker.sh Fusion ./compile-with-docker.sh Transfer -./compile-with-docker.sh Field +./compile-with-docker.sh FieldOps ./compile-with-docker.sh Max ./compile-with-docker.sh All ``` @@ -382,7 +382,7 @@ These are forwarded directly to `cmake --preset` inside the container. Examples: ```bash -./compile-with-docker.sh Field -DENABLE_VOX=OFF +./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 ``` diff --git a/compile-with-docker.sh b/compile-with-docker.sh index c1cacc9e..6ae1831d 100755 --- a/compile-with-docker.sh +++ b/compile-with-docker.sh @@ -7,16 +7,16 @@ set -euo pipefail # Examples: # ./compile-with-docker.sh Fusion # ./compile-with-docker.sh Transfer -# ./compile-with-docker.sh Field +# ./compile-with-docker.sh FieldOps # ./compile-with-docker.sh Max # ./compile-with-docker.sh Fusion -DDEV=ON -# ./compile-with-docker.sh Field -DENABLE_VOX=OFF +# ./compile-with-docker.sh FieldOps -DENABLE_VOX=OFF # ./compile-with-docker.sh All # Default preset: "Fusion" # --------------------------------------------- IMAGE=uvk1-uvk5v3 -RELEASE_PRESETS=(Fusion Transfer Field Max) +RELEASE_PRESETS=(Fusion Transfer FieldOps Max) FLASH_LIMIT=$((118 * 1024)) RAM_LIMIT=$((16 * 1024)) @@ -29,9 +29,9 @@ EXTRA_ARGS=("$@") # --------------------------------------------- # Validate preset name # --------------------------------------------- -if [[ ! "$PRESET" =~ ^(Custom|Fusion|Transfer|Field|Max|All)$ ]]; then +if [[ ! "$PRESET" =~ ^(Custom|Fusion|Transfer|FieldOps|Max|All)$ ]]; then echo "❌ Unknown preset: '$PRESET'" - echo "Valid presets are: Custom, Fusion, Transfer, Field, Max, All" + echo "Valid presets are: Custom, Fusion, Transfer, FieldOps, Max, All" exit 1 fi From 88d17655deb847e783081e9ffeae7dceaa69b1bd Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 24 Aug 2026 00:53:35 +0200 Subject: [PATCH 30/73] Rename Field preset to FieldOps --- CMakePresets.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakePresets.json b/CMakePresets.json index 5f53fa05..1b68bb4b 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -154,7 +154,7 @@ "ENABLE_FEAT_F4HWN_RESCUE_OPS": true, "ENABLE_FEAT_F4HWN_FOXHUNT": true, "EDITION_STRING": "FieldOps", - "TARGET": "f4hwn.field" + "TARGET": "f4hwn.fieldops" } }, { From 265f83a4470419a3a89915b4cd07b572e148e5bb Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 24 Aug 2026 02:46:58 +0200 Subject: [PATCH 31/73] Reject oversized EEPROM read commands (Thanks @BB-KING777) --- App/app/uart.c | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/App/app/uart.c b/App/app/uart.c index e53faed7..54eb91b0 100644 --- a/App/app/uart.c +++ b/App/app/uart.c @@ -403,6 +403,10 @@ static void CMD_051B(uint32_t Port, const uint8_t *pBuffer) gFmRadioCountdown_500ms = fm_radio_countdown_500ms; #endif + // Reject reads that do not fit in the fixed-size reply buffer. + if (pCmd->Size > sizeof(Reply.Data.Data)) + return; + memset(&Reply, 0, sizeof(Reply)); Reply.Header.ID = 0x051C; Reply.Header.Size = pCmd->Size + 4; @@ -416,7 +420,7 @@ static void CMD_051B(uint32_t Port, const uint8_t *pBuffer) { EEPROM_ReadBuffer(pCmd->Offset, Reply.Data.Data, pCmd->Size); } - + SendReply(Port, &Reply, pCmd->Size + 8); } From ce6bf08b23ce18ddbc26f4808e74708d4439df01 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 24 Aug 2026 19:41:20 +0200 Subject: [PATCH 32/73] Strengthening input boundary validation and preventing memory disclosure --- App/app/aircopy.c | 16 +++++++++------- App/app/beam.c | 4 +--- App/app/uart.c | 15 ++++++++++++--- App/ui/welcome.c | 6 ++++-- 4 files changed, 26 insertions(+), 15 deletions(-) diff --git a/App/app/aircopy.c b/App/app/aircopy.c index b9790346..e0a4e356 100644 --- a/App/app/aircopy.c +++ b/App/app/aircopy.c @@ -88,13 +88,14 @@ DECLARE_AIRCOPY_BANK(1) // For settings only static const AIRCOPY_Segment_t AIRCOPY_Segments_Settings[] = { - { 0xA000, 0xA170, AIRCOPY_WRITE_BYTES }, - { 0x880E, 0x886E, AIRCOPY_WRITE_BYTES }, - { 0x9000, 0x90E5, AIRCOPY_WRITE_BYTES }, // VFO area (full 14 VFOs 0x9000..0x90E0) + - // Fox Hunt settings tail 0x90E0..0x90E5 + // Ends are rounded to full Aircopy blocks. The extra bytes are unmapped + // EEPROM-compat holes (0x90E0..0x90E6 is the complete Fox Hunt tail). + { 0xA000, 0xA180, AIRCOPY_WRITE_BYTES }, + { 0x880E, 0x888E, AIRCOPY_WRITE_BYTES }, + { 0x9000, 0x9100, AIRCOPY_WRITE_BYTES }, }; -// total_blocks = ceil(0x170/64) + ceil(0x60/64) + ceil(0xE5/64) = 6 + 2 + 4 = 12 +// total_blocks = 0x180/64 + 0x80/64 + 0x100/64 = 6 + 2 + 4 = 12 static const AIRCOPY_TransferMap_t AIRCOPY_Map_Settings = { .segments = AIRCOPY_Segments_Settings, .num_segments = 3, @@ -154,7 +155,8 @@ static inline const AIRCOPY_Segment_t *AIRCOPY_FindSegmentForOffset(uint16_t off { const AIRCOPY_Segment_t *seg = &map->segments[i]; - if (off >= seg->start_offset && off < seg->end_offset) + if (off >= seg->start_offset && off < seg->end_offset && + ((off - seg->start_offset) & (AIRCOPY_BLOCK_SIZE - 1u)) == 0u) return seg; } @@ -426,4 +428,4 @@ void AIRCOPY_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) gRequestDisplayScreen = DISPLAY_AIRCOPY; } -#endif \ No newline at end of file +#endif diff --git a/App/app/beam.c b/App/app/beam.c index 0f024757..fab2923d 100644 --- a/App/app/beam.c +++ b/App/app/beam.c @@ -174,7 +174,6 @@ static void BEAM_SavePayloadToFirstFreeChannel(const BEAM_Payload_t *payload) VFO_Info_t vfo; RADIO_InitInfo(&vfo, channel, payload->rx_frequency); - // CRC + magic + version already validate the payload — no need to clamp fields. vfo.TX_OFFSET_FREQUENCY = payload->tx_offset_frequency; vfo.freq_config_RX.Code = payload->rx_code; vfo.freq_config_TX.Code = payload->tx_code; @@ -191,10 +190,9 @@ static void BEAM_SavePayloadToFirstFreeChannel(const BEAM_Payload_t *payload) #ifdef ENABLE_DTMF_CALLING vfo.DTMF_DECODING_ENABLE = payload->dtmf_decoding_enable; #endif - vfo.STEP_SETTING = payload->step_setting; + vfo.STEP_SETTING = payload->step_setting < STEP_N_ELEM ? payload->step_setting : STEP_12_5kHz; vfo.StepFrequency = gStepFrequencyTable[vfo.STEP_SETTING]; vfo.SCRAMBLING_TYPE = payload->scrambling_type; - vfo.Band = payload->band; vfo.SCANLIST_PARTICIPATION = payload->scanlist; vfo.Compander = payload->compander; diff --git a/App/app/uart.c b/App/app/uart.c index 54eb91b0..a298cefb 100644 --- a/App/app/uart.c +++ b/App/app/uart.c @@ -298,9 +298,10 @@ static void SendVersion(uint32_t Port) { REPLY_0514_t Reply; + Reply.Data.Padding[0] = Reply.Data.Padding[1] = 0; Reply.Header.ID = 0x0515; Reply.Header.Size = sizeof(Reply.Data); - strcpy(Reply.Data.Version, Version); + strncpy(Reply.Data.Version, Version, sizeof(Reply.Data.Version)); Reply.Data.bHasCustomAesKey = bHasCustomAesKey; Reply.Data.bIsInLockScreen = bIsInLockScreen; Reply.Data.Challenge[0] = gChallenge[0]; @@ -434,6 +435,9 @@ static void CMD_051D(uint32_t Port, const uint8_t *pBuffer) uint32_t Timestamp = 0; + if ((pCmd->Size & 7u) || pCmd->Header.Size < 8u + pCmd->Size) + return; + if(0) {} #if defined(ENABLE_UART) else if (Port == UART_PORT_UART) @@ -560,6 +564,7 @@ static void CMD_052D(uint32_t Port, const uint8_t *pBuffer) gIsLocked = bIsLocked; Reply.Data.bIsLocked = bIsLocked; + Reply.Data.Padding[0] = Reply.Data.Padding[1] = Reply.Data.Padding[2] = 0; SendReply(Port, &Reply, sizeof(Reply)); } @@ -796,7 +801,9 @@ bool UART_IsCommandAvailable(uint32_t Port) Crc = pUART_Command->Buffer[Size] | (pUART_Command->Buffer[Size + 1] << 8); - return CRC_Calculate(pUART_Command->Buffer, Size) == Crc; + return Size >= sizeof(Header_t) && + pUART_Command->Header.Size <= Size - sizeof(Header_t) && + CRC_Calculate(pUART_Command->Buffer, Size) == Crc; } #ifdef ENABLE_FEAT_F4HWN_MULTIBOOT @@ -936,6 +943,8 @@ void UART_HandleCommand(uint32_t Port) case 0x0724: // slot write: program bytes at slot+offset (slot pre-erased) { + if (pUART_Command->Header.Size < 12u) + break; gSerialConfigCountDown_500ms = 12; // keep serial mode alive (6 s) uint8_t slot = pUART_Command->Data[0]; uint32_t offset = (uint32_t)pUART_Command->Data[2] @@ -951,7 +960,7 @@ void UART_HandleCommand(uint32_t Port) uint8_t status; if (ts != mb_port_timestamp(Port)) status = MB_ERR_AUTH; - else if (len > 240u) // 12-byte prefix + data must fit Data[252] + else if (len > pUART_Command->Header.Size - 12u) status = MB_ERR_SIZE; else status = MB_SlotWrite(slot, offset, &pUART_Command->Data[12], len); diff --git a/App/ui/welcome.c b/App/ui/welcome.c index 659079e7..37e288a8 100644 --- a/App/ui/welcome.c +++ b/App/ui/welcome.c @@ -255,15 +255,17 @@ void UI_DisplayWelcome(void) } #endif else { - char WelcomeString0[16]; - char WelcomeString1[16]; + char WelcomeString0[17]; + char WelcomeString1[17]; char WelcomeString2[16]; char WelcomeString3[32]; // 0x0EB0 PY25Q16_ReadBuffer(0x00A0C8, WelcomeString0, 16); + WelcomeString0[16] = '\0'; // 0x0EC0 PY25Q16_ReadBuffer(0x00A0D8, WelcomeString1, 16); + WelcomeString1[16] = '\0'; sprintf(WelcomeString2, "%u.%02uV %u%%", gBatteryVoltageAverage / 100, From 285f543d465ae563b220e006fab02248f0a31f11 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Tue, 25 Aug 2026 13:05:20 +0200 Subject: [PATCH 33/73] Make Aircopy transfers reliable with ACK/RESEND recovery and leaner FSK handling --- App/app/aircopy.c | 422 +++++++++++++++++++------------------ App/app/aircopy.h | 51 +---- App/app/app.c | 64 +++--- App/app/beam.c | 13 +- App/app/beam.h | 1 - App/app/menu.c | 2 +- App/app/uart.c | 2 +- App/driver/eeprom.c | 37 ++-- App/driver/eeprom.h | 3 +- App/driver/eeprom_compat.c | 5 +- App/misc.c | 3 - App/misc.h | 3 - App/ui/aircopy.c | 105 +++------ 13 files changed, 322 insertions(+), 389 deletions(-) diff --git a/App/app/aircopy.c b/App/app/aircopy.c index e0a4e356..6d6aa7bd 100644 --- a/App/app/aircopy.c +++ b/App/app/aircopy.c @@ -21,6 +21,7 @@ #include "driver/bk4819.h" #include "driver/crc.h" #include "driver/eeprom.h" +#include "driver/system.h" #include "frequencies.h" #include "misc.h" #include "radio.h" @@ -29,6 +30,7 @@ #include "ui/ui.h" #include "settings.h" #include +#include #ifdef ENABLE_FEAT_F4HWN_K5VIEWER #include "k5viewer.h" @@ -43,140 +45,73 @@ bool gAirCopyIsSendMode; uint16_t g_FSK_Buffer[36]; -// ============================================================================ -// Transfer Maps Definition -// ============================================================================ +// Stop-and-wait protocol. Every frame keeps the original 64-byte payload: +// DATA is acknowledged only after storage, ACK confirms that offset, and +// RESEND requests the same offset immediately instead of waiting for timeout. +#define AIRCOPY_PACKET_DATA 0xABCDu +#define AIRCOPY_PACKET_ACK 0xABCEu +#define AIRCOPY_PACKET_RESEND 0xABCFu +#define AIRCOPY_PACKET_END 0xDCBAu +#define AIRCOPY_ACK_TIMEOUT_10MS 400u +#define AIRCOPY_RX_TIMEOUT_10MS 2000u +#define AIRCOPY_RX_LINGER_10MS 500u +#define AIRCOPY_MAX_RETRIES 3u -#define AIRCOPY_BANK_SEGMENTS(bank) \ -{ \ - { 0x0000 + (bank)*0x0800, 0x0000 + (bank)*0x0800 + 0x0800, AIRCOPY_WRITE_STRUCT }, \ - { 0x4000 + (bank)*0x0800, 0x4000 + (bank)*0x0800 + 0x0800, AIRCOPY_WRITE_STRUCT }, \ - { 0x8000 + (bank)*0x0100, 0x8000 + (bank)*0x0100 + 0x0100, AIRCOPY_WRITE_BYTES }, \ -} +static uint16_t AircopyCountdown; +static uint8_t AircopyRetries; -#define AIRCOPY_STD_MAP(seg_array) \ -{ \ - .segments = seg_array, \ - .num_segments = 3, \ - .total_blocks = 68 \ -} - -// total_blocks = 68 (blocs of 64 bytes) because (16 bytes + 16 bytes + 2 bytes) * 128 = 4352 / 64 = 68 - -#define DECLARE_AIRCOPY_BANK(n) \ - static const AIRCOPY_Segment_t AIRCOPY_Segments_Bank##n[] = \ - AIRCOPY_BANK_SEGMENTS(n); \ - \ - static const AIRCOPY_TransferMap_t AIRCOPY_Map_Bank##n = \ - AIRCOPY_STD_MAP(AIRCOPY_Segments_Bank##n); - -DECLARE_AIRCOPY_BANK(0) -DECLARE_AIRCOPY_BANK(1) -#if AIRCOPY_NUM_BANKS >= 4 // if 512 MR CHANNEL - DECLARE_AIRCOPY_BANK(2) - DECLARE_AIRCOPY_BANK(3) -#endif -#if AIRCOPY_NUM_BANKS >= 6 // if 758 MR CHANNEL - DECLARE_AIRCOPY_BANK(4) - DECLARE_AIRCOPY_BANK(5) -#endif -#if AIRCOPY_NUM_BANKS >= 8 // if 1024 MR CHANNEL - DECLARE_AIRCOPY_BANK(6) - DECLARE_AIRCOPY_BANK(7) -#endif - -// For settings only - -static const AIRCOPY_Segment_t AIRCOPY_Segments_Settings[] = { - // Ends are rounded to full Aircopy blocks. The extra bytes are unmapped - // EEPROM-compat holes (0x90E0..0x90E6 is the complete Fox Hunt tail). - { 0xA000, 0xA180, AIRCOPY_WRITE_BYTES }, - { 0x880E, 0x888E, AIRCOPY_WRITE_BYTES }, - { 0x9000, 0x9100, AIRCOPY_WRITE_BYTES }, -}; - -// total_blocks = 0x180/64 + 0x80/64 + 0x100/64 = 6 + 2 + 4 = 12 -static const AIRCOPY_TransferMap_t AIRCOPY_Map_Settings = { - .segments = AIRCOPY_Segments_Settings, - .num_segments = 3, - .total_blocks = 12 -}; - -// Finally - -static const AIRCOPY_TransferMap_t *AIRCOPY_AvailableMaps[] = { - &AIRCOPY_Map_Bank0, - &AIRCOPY_Map_Bank1, - #if AIRCOPY_NUM_BANKS >= 4 // if 512 MR CHANNEL - &AIRCOPY_Map_Bank2, - &AIRCOPY_Map_Bank3, - #endif - #if AIRCOPY_NUM_BANKS >= 6 // if 758 MR CHANNEL - &AIRCOPY_Map_Bank4, - &AIRCOPY_Map_Bank5, - #endif - #if AIRCOPY_NUM_BANKS >= 8 // if 1024 MR CHANNEL - &AIRCOPY_Map_Bank6, - &AIRCOPY_Map_Bank7, - #endif - &AIRCOPY_Map_Settings, -}; - -#define AIRCOPY_NUM_MAPS (sizeof(AIRCOPY_AvailableMaps) / sizeof(AIRCOPY_AvailableMaps[0])) +#define AIRCOPY_NUM_MAPS (AIRCOPY_NUM_BANKS + 1u) +#define AIRCOPY_BANK_BLOCKS 68u +#define AIRCOPY_SETTINGS_BLOCKS 12u // ============================================================================ // Helper Functions // ============================================================================ -const AIRCOPY_TransferMap_t* AIRCOPY_GetCurrentMap(void) +uint8_t AIRCOPY_GetTotalBlocks(void) { - if (gAircopyCurrentMapIndex >= AIRCOPY_NUM_MAPS) { - gAircopyCurrentMapIndex = 0; + return gAircopyCurrentMapIndex == AIRCOPY_NUM_BANKS + ? AIRCOPY_SETTINGS_BLOCKS + : AIRCOPY_BANK_BLOCKS; +} + +static uint16_t AIRCOPY_GetBlockOffset(uint16_t block) +{ + if (gAircopyCurrentMapIndex == AIRCOPY_NUM_BANKS) + { + // Settings: 6 blocks at 0xA000, 2 at 0x880E and 4 at 0x9000. + if (block < 6u) + return 0xA000u + block * AIRCOPY_BLOCK_SIZE; + if (block < 8u) + return 0x880Eu + (block - 6u) * AIRCOPY_BLOCK_SIZE; + return 0x9000u + (block - 8u) * AIRCOPY_BLOCK_SIZE; } - return AIRCOPY_AvailableMaps[gAircopyCurrentMapIndex]; + + // A bank contains 32 frequency, 32 name and 4 attribute blocks. + const uint16_t channelOffset = gAircopyCurrentMapIndex * 0x0800u; + if (block < 32u) + return channelOffset + block * AIRCOPY_BLOCK_SIZE; + if (block < 64u) + return 0x4000u + channelOffset + (block - 32u) * AIRCOPY_BLOCK_SIZE; + return 0x8000u + gAircopyCurrentMapIndex * 0x0100u + + (block - 64u) * AIRCOPY_BLOCK_SIZE; } static void AIRCOPY_clear() { - for (uint8_t i = 0; i < 15; i++) - { - crc[i] = 0; - } #ifdef ENABLE_FEAT_F4HWN_K5VIEWER K5VIEWER_Update(true); #endif } -static inline const AIRCOPY_Segment_t *AIRCOPY_FindSegmentForOffset(uint16_t off) +static void AIRCOPY_Finish(AIRCOPY_State_t state) { - const AIRCOPY_TransferMap_t *map = AIRCOPY_GetCurrentMap(); - - for (uint16_t i = 0; i < map->num_segments; i++) - { - const AIRCOPY_Segment_t *seg = &map->segments[i]; - - if (off >= seg->start_offset && off < seg->end_offset && - ((off - seg->start_offset) & (AIRCOPY_BLOCK_SIZE - 1u)) == 0u) - return seg; - } - - return NULL; -} - -static inline void AIRCOPY_CheckComplete(uint16_t *num) -{ - *num = *num + 1; - - const AIRCOPY_TransferMap_t *map = AIRCOPY_GetCurrentMap(); - uint16_t done = gAirCopyBlockNumber + gErrorsDuringAirCopy; - - if (done >= map->total_blocks) - { - gAircopyState = AIRCOPY_COMPLETE; + AircopyCountdown = 0; + gAircopyState = state; + gUpdateDisplay = true; #ifdef ENABLE_FEAT_F4HWN_K5VIEWER - K5VIEWER_Update(false); + K5VIEWER_Update(false); #endif - } } void AIRCOPY_Obfuscate(unsigned int count) @@ -186,70 +121,97 @@ void AIRCOPY_Obfuscate(unsigned int count) } } +static void AIRCOPY_TransmitBuffer(void) +{ + // Both sides need time to leave TX and re-arm FSK RX before the reply. + SYSTEM_DelayMs(50); + RADIO_SetTxParameters(); + BK4819_SendFSKData(g_FSK_Buffer); + BK4819_SetupPowerAmplifier(0, 0); + BK4819_ToggleGpioOut(BK4819_GPIO1_PIN29_PA_ENABLE, false); +} + +static void AIRCOPY_FinalizeAndSend(void) +{ + g_FSK_Buffer[34] = CRC_Calculate(&g_FSK_Buffer[0], + 4 + AIRCOPY_BLOCK_SIZE); + g_FSK_Buffer[35] = AIRCOPY_PACKET_END; + AIRCOPY_Obfuscate(34); + AIRCOPY_TransmitBuffer(); + gFSKWriteIndex = 0; + BK4819_PrepareFSKReceive(); +} + +static void AIRCOPY_SendControl(uint16_t type, uint16_t offset) +{ + g_FSK_Buffer[0] = type; + g_FSK_Buffer[1] = offset; + memset(&g_FSK_Buffer[2], 0, AIRCOPY_BLOCK_SIZE); + AIRCOPY_FinalizeAndSend(); +} + +static void AIRCOPY_RequestResend(void) +{ + gErrorsDuringAirCopy++; + gUpdateDisplay = true; + AircopyCountdown = AIRCOPY_RX_TIMEOUT_10MS; + AIRCOPY_SendControl(AIRCOPY_PACKET_RESEND, + AIRCOPY_GetBlockOffset(gAirCopyBlockNumber)); +} + +static bool AIRCOPY_Retry(void) +{ + if (AircopyRetries >= AIRCOPY_MAX_RETRIES) + { + AIRCOPY_Finish(AIRCOPY_FAILED); + return false; + } + + AircopyRetries++; + gErrorsDuringAirCopy++; + gUpdateDisplay = true; + AircopyCountdown = 0; + return true; +} + // ============================================================================ // Send/Receive Functions // ============================================================================ bool AIRCOPY_SendMessage(void) { - static uint8_t gAircopySendCountdown = 1; - static uint16_t CurrentOffset = 0; - static uint16_t CurrentSegmentIndex = 0; - if (gAircopyState != AIRCOPY_TRANSFER) { return 1; } - if (--gAircopySendCountdown) { + if (!gAirCopyIsSendMode) + { + if (AircopyCountdown != 0 && --AircopyCountdown == 0) + { + AIRCOPY_Finish(gAirCopyBlockNumber >= AIRCOPY_GetTotalBlocks() + ? AIRCOPY_COMPLETE + : AIRCOPY_FAILED); + return 0; + } return 1; } - const AIRCOPY_TransferMap_t *map = AIRCOPY_GetCurrentMap(); - - // Initialize on first call - if (gAirCopyBlockNumber == 0) { - CurrentSegmentIndex = 0; - CurrentOffset = map->segments[0].start_offset; - } - - // Advance to next segment if current is done - while (CurrentSegmentIndex < map->num_segments && - CurrentOffset >= map->segments[CurrentSegmentIndex].end_offset) + if (AircopyCountdown != 0) { - CurrentSegmentIndex++; - if (CurrentSegmentIndex < map->num_segments) { - CurrentOffset = map->segments[CurrentSegmentIndex].start_offset; - } + if (--AircopyCountdown != 0) + return 1; + if (!AIRCOPY_Retry()) + return 0; } - // Check if transfer is complete - if (CurrentSegmentIndex >= map->num_segments) { - gAircopyState = AIRCOPY_COMPLETE; - #ifdef ENABLE_FEAT_F4HWN_K5VIEWER - K5VIEWER_Update(false); - #endif - return 0; - } + const uint16_t currentOffset = AIRCOPY_GetBlockOffset(gAirCopyBlockNumber); + g_FSK_Buffer[0] = AIRCOPY_PACKET_DATA; + g_FSK_Buffer[1] = currentOffset; + EEPROM_ReadBuffer(currentOffset, &g_FSK_Buffer[2], AIRCOPY_BLOCK_SIZE); + AIRCOPY_FinalizeAndSend(); + AircopyCountdown = AIRCOPY_ACK_TIMEOUT_10MS; - // Send data from current offset - g_FSK_Buffer[1] = CurrentOffset; - EEPROM_ReadBuffer(CurrentOffset, &g_FSK_Buffer[2], 64); - - g_FSK_Buffer[34] = CRC_Calculate(&g_FSK_Buffer[1], 2 + 64); - - AIRCOPY_Obfuscate(34); - - RADIO_SetTxParameters(); - - BK4819_SendFSKData(g_FSK_Buffer); - BK4819_SetupPowerAmplifier(0, 0); - BK4819_ToggleGpioOut(BK4819_GPIO1_PIN29_PA_ENABLE, false); - - CurrentOffset += 64; - gAirCopyBlockNumber++; - gAircopySendCountdown = 30; - - return 0; + return 1; } void AIRCOPY_StorePacket(void) @@ -259,53 +221,111 @@ void AIRCOPY_StorePacket(void) } gFSKWriteIndex = 0; - gUpdateDisplay = true; - uint16_t Status = BK4819_ReadRegister(BK4819_REG_0B); - BK4819_PrepareFSKReceive(); + const uint16_t status = BK4819_ReadRegister(BK4819_REG_0B); + const uint16_t type = g_FSK_Buffer[0]; + bool valid = (status & 0x0010u) == 0 && + (type == AIRCOPY_PACKET_DATA || + type == AIRCOPY_PACKET_ACK || + type == AIRCOPY_PACKET_RESEND) && + g_FSK_Buffer[35] == AIRCOPY_PACKET_END; - if ((Status & 0x0010U) != 0 || g_FSK_Buffer[0] != 0xABCD || g_FSK_Buffer[35] != 0xDCBA) { - BK4819_ResetFSK(); // <- important - BK4819_PrepareFSKReceive(); // <- re-arm proprement - - AIRCOPY_CheckComplete(&gErrorsDuringAirCopy); - return; - } - - AIRCOPY_Obfuscate(34); - - uint16_t Crc = CRC_Calculate(&g_FSK_Buffer[1], 2 + 64); - if (g_FSK_Buffer[34] != Crc) { - AIRCOPY_CheckComplete(&gErrorsDuringAirCopy); - return; - } - - uint16_t Offset = g_FSK_Buffer[1]; - - const AIRCOPY_Segment_t *seg = AIRCOPY_FindSegmentForOffset(Offset); - - if (seg == NULL) { - AIRCOPY_CheckComplete(&gErrorsDuringAirCopy); - return; - } - - const uint8_t *pData = (const uint8_t *)&g_FSK_Buffer[2]; - - for (unsigned int i = 0; i < 8; i++) + if (valid) { - EEPROM_WriteBuffer(Offset + (i * 8), pData + (i * 8)); + AIRCOPY_Obfuscate(34); + valid = g_FSK_Buffer[34] == + CRC_Calculate(&g_FSK_Buffer[0], 4 + AIRCOPY_BLOCK_SIZE); } - AIRCOPY_CheckComplete(&gAirCopyBlockNumber); + if (gAirCopyIsSendMode) + { + if (!valid) + { + BK4819_PrepareFSKReceive(); + return; + } + + const uint16_t offset = g_FSK_Buffer[1]; + const uint16_t currentOffset = AIRCOPY_GetBlockOffset(gAirCopyBlockNumber); + if (type == AIRCOPY_PACKET_ACK && offset == currentOffset) + { + AircopyCountdown = 0; + AircopyRetries = 0; + gAirCopyBlockNumber++; + gUpdateDisplay = true; + if (gAirCopyBlockNumber >= AIRCOPY_GetTotalBlocks()) + AIRCOPY_Finish(AIRCOPY_COMPLETE); + return; + } + + if (type == AIRCOPY_PACKET_RESEND && offset == currentOffset) + { + (void)AIRCOPY_Retry(); + return; + } + + BK4819_PrepareFSKReceive(); + return; + } + + if (!valid) + { + if (type == AIRCOPY_PACKET_DATA) + AIRCOPY_RequestResend(); + else + BK4819_PrepareFSKReceive(); + return; + } + + if (type != AIRCOPY_PACKET_DATA) + { + BK4819_PrepareFSKReceive(); + return; + } + + const uint16_t offset = g_FSK_Buffer[1]; + if (gAirCopyBlockNumber != 0u && + offset == AIRCOPY_GetBlockOffset(gAirCopyBlockNumber - 1u)) + { + AircopyCountdown = gAirCopyBlockNumber >= AIRCOPY_GetTotalBlocks() + ? AIRCOPY_RX_LINGER_10MS + : AIRCOPY_RX_TIMEOUT_10MS; + AIRCOPY_SendControl(AIRCOPY_PACKET_ACK, offset); + return; + } + + if (offset != AIRCOPY_GetBlockOffset(gAirCopyBlockNumber)) + { + AIRCOPY_RequestResend(); + return; + } + + EEPROM_WriteBuffer(offset, &g_FSK_Buffer[2], AIRCOPY_BLOCK_SIZE); + // All pending RX errors concerned this stop-and-wait block. + gErrorsDuringAirCopy = 0; + gAirCopyBlockNumber++; + gUpdateDisplay = true; + + AircopyCountdown = gAirCopyBlockNumber < AIRCOPY_GetTotalBlocks() + ? AIRCOPY_RX_TIMEOUT_10MS + : AIRCOPY_RX_LINGER_10MS; + + AIRCOPY_SendControl(AIRCOPY_PACKET_ACK, offset); } static void AIRCOPY_InitTransfer(bool isSendMode) { - gAircopyStep = 1; + if (gAircopyCurrentMapIndex >= AIRCOPY_NUM_MAPS) + gAircopyCurrentMapIndex = 0; + gFSKWriteIndex = 0; gAirCopyBlockNumber = 0; + gErrorsDuringAirCopy = 0; gInputBoxIndex = 0; gAirCopyIsSendMode = isSendMode; + AircopyCountdown = isSendMode ? 0 : AIRCOPY_RX_TIMEOUT_10MS; + AircopyRetries = 0; + AIRCOPY_clear(); gAircopyState = AIRCOPY_TRANSFER; @@ -359,8 +379,6 @@ static void AIRCOPY_Key_EXIT() { if (gInputBoxIndex == 0) { AIRCOPY_InitTransfer(0); // Mode: Receive - gErrorsDuringAirCopy = lErrorsDuringAirCopy = 0; - BK4819_PrepareFSKReceive(); } else { @@ -371,10 +389,6 @@ static void AIRCOPY_Key_EXIT() static void AIRCOPY_Key_MENU() { AIRCOPY_InitTransfer(1); // Mode: Send - - g_FSK_Buffer[0] = 0xABCD; - g_FSK_Buffer[1] = 0; - g_FSK_Buffer[35] = 0xDCBA; } static void AIRCOPY_Key_UP_DOWN(int8_t Direction) @@ -400,6 +414,14 @@ void AIRCOPY_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) return; } + if (gAircopyState == AIRCOPY_COMPLETE || gAircopyState == AIRCOPY_FAILED) + { + gAircopyState = AIRCOPY_READY; + gUpdateDisplay = true; + gRequestDisplayScreen = DISPLAY_AIRCOPY; + return; + } + if (Key != KEY_PTT) { gBeepToPlay = BEEP_1KHZ_60MS_OPTIONAL; } diff --git a/App/app/aircopy.h b/App/app/aircopy.h index f84f22d0..2dd594d6 100644 --- a/App/app/aircopy.h +++ b/App/app/aircopy.h @@ -28,53 +28,8 @@ #define AIRCOPY_BLOCK_SIZE 0x0040u // 64 bytes per AirCopy block #define AIRCOPY_CHANNELS_PER_BANK 128 #define AIRCOPY_NUM_BANKS MR_CHANNELS_MAX / AIRCOPY_CHANNELS_PER_BANK -#define AIRCOPY_CHANNEL_SIZE 16 // bytes per channel (freq/name) -#define AIRCOPY_BANK_SIZE_BYTES 0x1080u // 0x800 (Freq) + 0x800 (Name) + 0x80 (Attr) #define AIRCOPY_BAR_WIDTH 120 // Visible width of the progress gauge -// ============================================================================ -// Segment write mode -// ============================================================================ - -/* - * Defines how a segment must be written to EEPROM. - * - * - STRUCT: structured data (frequencies, names) - * - BYTES : raw byte stream (attributes, settings, etc.) - */ -typedef enum { - AIRCOPY_WRITE_STRUCT = 0, - AIRCOPY_WRITE_BYTES = 1, -} AIRCOPY_WriteMode_t; - -// ============================================================================ -// Transfer segment structure -// ============================================================================ - -/* - * Describes a contiguous EEPROM region involved in AirCopy. - * The write_mode defines how the RX side must write the data. - */ -typedef struct { - uint16_t start_offset; - uint16_t end_offset; - AIRCOPY_WriteMode_t write_mode; -} AIRCOPY_Segment_t; - -// ============================================================================ -// Transfer map structure -// ============================================================================ - -/* - * A transfer map is a collection of segments describing - * one complete AirCopy operation (bank, settings, etc.). - */ -typedef struct { - const AIRCOPY_Segment_t *segments; - uint16_t num_segments; - uint16_t total_blocks; -} AIRCOPY_TransferMap_t; - // ============================================================================ // AirCopy state // ============================================================================ @@ -82,7 +37,8 @@ typedef struct { typedef enum { AIRCOPY_READY = 0, AIRCOPY_TRANSFER, - AIRCOPY_COMPLETE + AIRCOPY_COMPLETE, + AIRCOPY_FAILED } AIRCOPY_State_t; // ============================================================================ @@ -103,8 +59,7 @@ extern uint16_t g_FSK_Buffer[36]; bool AIRCOPY_SendMessage(void); void AIRCOPY_StorePacket(void); void AIRCOPY_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld); - -const AIRCOPY_TransferMap_t* AIRCOPY_GetCurrentMap(void); +uint8_t AIRCOPY_GetTotalBlocks(void); // XOR-obfuscate `count` words of g_FSK_Buffer starting at index 1. // Self-inverse: applying twice restores the original buffer. diff --git a/App/app/app.c b/App/app/app.c index 618680e9..653be3ff 100644 --- a/App/app/app.c +++ b/App/app/app.c @@ -1007,36 +1007,44 @@ static void CheckRadioInterrupts(void) BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, false); } -#ifdef ENABLE_AIRCOPY - if (interrupts.fskFifoAlmostFull && - gScreenToDisplay == DISPLAY_AIRCOPY && - gAircopyState == AIRCOPY_TRANSFER && - gAirCopyIsSendMode == 0) +#if defined(ENABLE_AIRCOPY) || defined(ENABLE_FEAT_F4HWN_BEAM) + if (interrupts.fskFifoAlmostFull || interrupts.fskRxFinied) { - for (unsigned int i = 0; i < 4; i++) { - g_FSK_Buffer[gFSKWriteIndex++] = BK4819_ReadRegister(BK4819_REG_5F); - } - - AIRCOPY_StorePacket(); - } -#endif + uint8_t fskTarget = 0; #ifdef ENABLE_FEAT_F4HWN_BEAM - if ((interrupts.fskFifoAlmostFull || interrupts.fskRxFinied) && - gBeamActive && - gBeamMode == BEAM_MODE_RX && - (gBeamStatus == BEAM_STATUS_RX_WAIT || gBeamStatus == BEAM_STATUS_ERROR)) - { - const unsigned int wordsToRead = interrupts.fskRxFinied ? (36 - gFSKWriteIndex) : 4; - for (unsigned int i = 0; i < wordsToRead; i++) { - const uint16_t word = BK4819_ReadRegister(BK4819_REG_5F); - if (gFSKWriteIndex < 36) - g_FSK_Buffer[gFSKWriteIndex++] = word; - } + if (gBeamActive && + gBeamMode == BEAM_MODE_RX && + (gBeamStatus == BEAM_STATUS_RX_WAIT || gBeamStatus == BEAM_STATUS_ERROR)) + fskTarget = 2; +#endif +#ifdef ENABLE_AIRCOPY + // Aircopy wins if stale state ever makes both receivers eligible. + if (gScreenToDisplay == DISPLAY_AIRCOPY && + gAircopyState == AIRCOPY_TRANSFER) + fskTarget = 1; +#endif - gBeamRxWordCount = gFSKWriteIndex; - gUpdateDisplay = true; - BEAM_StorePacket(); + if (fskTarget != 0) + { + const unsigned int wordsToRead = interrupts.fskRxFinied + ? (gFSKWriteIndex < 36 ? 36u - gFSKWriteIndex : 0u) + : 4u; + for (unsigned int i = 0; i < wordsToRead; i++) { + const uint16_t word = BK4819_ReadRegister(BK4819_REG_5F); + if (gFSKWriteIndex < 36) + g_FSK_Buffer[gFSKWriteIndex++] = word; + } + +#ifdef ENABLE_AIRCOPY + if (fskTarget == 1) + AIRCOPY_StorePacket(); +#endif +#ifdef ENABLE_FEAT_F4HWN_BEAM + if (fskTarget == 2) + BEAM_StorePacket(); +#endif + } } #endif } @@ -1446,7 +1454,7 @@ void CheckKeys(void) #endif #ifdef ENABLE_AIRCOPY - if (gScreenToDisplay == DISPLAY_AIRCOPY && gAircopyState != AIRCOPY_READY){ + if (gScreenToDisplay == DISPLAY_AIRCOPY && gAircopyState == AIRCOPY_TRANSFER){ return; } #endif @@ -1750,7 +1758,7 @@ void APP_TimeSlice10ms(void) #endif #ifdef ENABLE_AIRCOPY - if (gScreenToDisplay == DISPLAY_AIRCOPY && gAircopyState == AIRCOPY_TRANSFER && gAirCopyIsSendMode == 1) { + if (gScreenToDisplay == DISPLAY_AIRCOPY && gAircopyState == AIRCOPY_TRANSFER) { if (!AIRCOPY_SendMessage()) { GUI_DisplayScreen(); } diff --git a/App/app/beam.c b/App/app/beam.c index fab2923d..ae4b3190 100644 --- a/App/app/beam.c +++ b/App/app/beam.c @@ -69,7 +69,6 @@ static_assert(sizeof(BEAM_Payload_t) <= 64); BEAM_Mode_t gBeamMode = BEAM_MODE_TX; BEAM_Status_t gBeamStatus = BEAM_STATUS_READY; uint16_t gBeamCopiedChannel = 0xFFFFu; -uint8_t gBeamRxWordCount; bool gBeamActive; static VFO_Info_t gBeamRadioVfo; @@ -120,7 +119,6 @@ static void BEAM_SendPacket(void) payload->dtmf_decoding_enable = vfo->DTMF_DECODING_ENABLE; #endif payload->step_setting = vfo->STEP_SETTING; - payload->scrambling_type = vfo->SCRAMBLING_TYPE; payload->band = vfo->Band; payload->scanlist = vfo->SCANLIST_PARTICIPATION; payload->compander = vfo->Compander; @@ -192,18 +190,16 @@ static void BEAM_SavePayloadToFirstFreeChannel(const BEAM_Payload_t *payload) #endif vfo.STEP_SETTING = payload->step_setting < STEP_N_ELEM ? payload->step_setting : STEP_12_5kHz; vfo.StepFrequency = gStepFrequencyTable[vfo.STEP_SETTING]; - vfo.SCRAMBLING_TYPE = payload->scrambling_type; vfo.SCANLIST_PARTICIPATION = payload->scanlist; vfo.Compander = payload->compander; memcpy(vfo.Name, payload->name, sizeof(vfo.Name)); vfo.Name[sizeof(vfo.Name) - 1] = '\0'; - RADIO_ApplyOffset(&vfo); - RADIO_ConfigureSquelchAndOutputPower(&vfo); - SETTINGS_SaveChannel(channel, gEeprom.TX_VFO, &vfo, 3); +#ifndef ENABLE_KEEP_MEM_NAME SETTINGS_SaveChannelName(channel, vfo.Name); +#endif gBeamCopiedChannel = channel; gBeamStatus = BEAM_STATUS_RX_SAVED; @@ -221,7 +217,6 @@ static void BEAM_KeyMenu(void) } else { gBeamStatus = BEAM_STATUS_RX_WAIT; gBeamCopiedChannel = 0xFFFFu; - gBeamRxWordCount = 0; gFSKWriteIndex = 0; BK4819_PrepareFSKReceive(); } @@ -249,7 +244,6 @@ void ACTION_Beam(void) gBeamMode = BEAM_MODE_TX; gBeamStatus = BEAM_STATUS_READY; gBeamCopiedChannel = 0xFFFFu; - gBeamRxWordCount = 0; gBeamActive = true; GUI_SelectNextDisplay(DISPLAY_MAIN); } @@ -267,7 +261,6 @@ void BEAM_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) case KEY_DOWN: gBeamMode ^= 1; // (gBeamMode == BEAM_MODE_TX) ? BEAM_MODE_RX : BEAM_MODE_TX gBeamStatus = BEAM_STATUS_READY; - gBeamRxWordCount = 0; break; case KEY_MENU: BEAM_KeyMenu(); @@ -290,7 +283,6 @@ void BEAM_StorePacket(void) if (gFSKWriteIndex < 36) return; - gBeamRxWordCount = gFSKWriteIndex; gFSKWriteIndex = 0; const uint16_t Status = BK4819_ReadRegister(BK4819_REG_0B); @@ -315,7 +307,6 @@ void BEAM_StorePacket(void) error: gBeamStatus = BEAM_STATUS_ERROR; - gBeamRxWordCount = 0; gUpdateDisplay = true; BACKLIGHT_TurnOn(); } diff --git a/App/app/beam.h b/App/app/beam.h index 6571119e..ca9aafa4 100644 --- a/App/app/beam.h +++ b/App/app/beam.h @@ -42,7 +42,6 @@ typedef enum { extern BEAM_Mode_t gBeamMode; extern BEAM_Status_t gBeamStatus; extern uint16_t gBeamCopiedChannel; -extern uint8_t gBeamRxWordCount; extern bool gBeamActive; void ACTION_Beam(void); diff --git a/App/app/menu.c b/App/app/menu.c index d5f905c8..0b80664b 100644 --- a/App/app/menu.c +++ b/App/app/menu.c @@ -72,7 +72,7 @@ uint8_t gUnlockAllTxConfCnt; // EEPROM_ReadBuffer(0x1F88, &misc, 8); misc.BK4819_XtalFreqLow = value; - EEPROM_WriteBuffer(0x1F88, &misc); + EEPROM_WriteBuffer(0x1F88, &misc, 8); } } #endif diff --git a/App/app/uart.c b/App/app/uart.c index a298cefb..87be1f5b 100644 --- a/App/app/uart.c +++ b/App/app/uart.c @@ -486,7 +486,7 @@ static void CMD_051D(uint32_t Port, const uint8_t *pBuffer) if ((Offset < 0x0E98 || Offset >= 0x0EA0) || !bIsInLockScreen || pCmd->bAllowPassword) { - EEPROM_WriteBuffer(Offset, &pCmd->Data[i * 8U]); + EEPROM_WriteBuffer(Offset, &pCmd->Data[i * 8U], 8); } } diff --git a/App/driver/eeprom.c b/App/driver/eeprom.c index 4357f658..b00a7e1b 100644 --- a/App/driver/eeprom.c +++ b/App/driver/eeprom.c @@ -39,25 +39,30 @@ void EEPROM_ReadBuffer(uint16_t Address, void *pBuffer, uint8_t Size) I2C_Stop(); } -void EEPROM_WriteBuffer(uint16_t Address, const void *pBuffer) +void EEPROM_WriteBuffer(uint16_t Address, const void *pBuffer, uint8_t Size) { - if (pBuffer == NULL || Address >= 0x2000) + if (pBuffer == NULL) return; - uint8_t buffer[8]; - EEPROM_ReadBuffer(Address, buffer, 8); - if (memcmp(pBuffer, buffer, 8) == 0) { - return; + while (Size >= sizeof(buffer) && Address < 0x2000) + { + EEPROM_ReadBuffer(Address, buffer, sizeof(buffer)); + if (memcmp(pBuffer, buffer, sizeof(buffer)) != 0) + { + I2C_Start(); + I2C_Write(0xA0); + I2C_Write((Address >> 8) & 0xFF); + I2C_Write((Address >> 0) & 0xFF); + I2C_WriteBuffer(pBuffer, sizeof(buffer)); + I2C_Stop(); + + // give the EEPROM time to burn the data in (apparently takes 5ms) + SYSTEM_DelayMs(8); + } + + Address += sizeof(buffer); + pBuffer += sizeof(buffer); + Size -= sizeof(buffer); } - - I2C_Start(); - I2C_Write(0xA0); - I2C_Write((Address >> 8) & 0xFF); - I2C_Write((Address >> 0) & 0xFF); - I2C_WriteBuffer(pBuffer, 8); - I2C_Stop(); - - // give the EEPROM time to burn the data in (apparently takes 5ms) - SYSTEM_DelayMs(8); } diff --git a/App/driver/eeprom.h b/App/driver/eeprom.h index 545c48c9..e0213595 100644 --- a/App/driver/eeprom.h +++ b/App/driver/eeprom.h @@ -20,7 +20,6 @@ #include void EEPROM_ReadBuffer(uint16_t Address, void *pBuffer, uint8_t Size); -void EEPROM_WriteBuffer(uint16_t Address, const void *pBuffer); +void EEPROM_WriteBuffer(uint16_t Address, const void *pBuffer, uint8_t Size); #endif - diff --git a/App/driver/eeprom_compat.c b/App/driver/eeprom_compat.c index ed9d9c7c..a2a2215b 100644 --- a/App/driver/eeprom_compat.c +++ b/App/driver/eeprom_compat.c @@ -110,11 +110,8 @@ void EEPROM_ReadBuffer(uint16_t Address, void *pBuffer, uint8_t Size) } } -void EEPROM_WriteBuffer(uint16_t Address, const void *pBuffer) +void EEPROM_WriteBuffer(uint16_t Address, const void *pBuffer, uint8_t Size) { - // Write 8 bytes!! - - uint16_t Size = 8; while (Size) { uint32_t PY_Addr; diff --git a/App/misc.c b/App/misc.c index f49ba7d9..8c5292ad 100644 --- a/App/misc.c +++ b/App/misc.c @@ -144,9 +144,6 @@ enum BacklightOnRxTx_t gSetting_backlight_on_tx_rx; uint8_t gDW = 0; uint8_t gCB = 0; bool gSaveRxMode = false; - uint8_t crc[15] = { 0 }; - uint8_t lErrorsDuringAirCopy = 0; - uint8_t gAircopyStep = 0; uint8_t gAircopyCurrentMapIndex = 0; bool gAirCopyBootMode = 0; #ifdef ENABLE_FEAT_F4HWN_RESCUE_OPS diff --git a/App/misc.h b/App/misc.h index 461f636d..8457a621 100644 --- a/App/misc.h +++ b/App/misc.h @@ -219,9 +219,6 @@ extern enum BacklightOnRxTx_t gSetting_backlight_on_tx_rx; extern uint8_t gDW; extern uint8_t gCB; extern bool gSaveRxMode; - extern uint8_t crc[15]; - extern uint8_t lErrorsDuringAirCopy; - extern uint8_t gAircopyStep; extern uint8_t gAircopyCurrentMapIndex; extern bool gAirCopyBootMode; #ifdef ENABLE_FEAT_F4HWN_RESCUE_OPS diff --git a/App/ui/aircopy.c b/App/ui/aircopy.c index 135d95c5..333820b5 100644 --- a/App/ui/aircopy.c +++ b/App/ui/aircopy.c @@ -27,14 +27,6 @@ #include "ui/helper.h" #include "ui/inputbox.h" -static void set_bit(uint8_t* array, int bit_index) { - array[bit_index / 8] |= (1 << (bit_index % 8)); -} - -static int get_bit(uint8_t* array, int bit_index) { - return (array[bit_index / 8] >> (bit_index % 8)) & 1; -} - void UI_DisplayAircopy(void) { char String[16]; @@ -46,9 +38,10 @@ void UI_DisplayAircopy(void) pPrintStr = "AIR COPY(RDY)"; } else if (gAircopyState == AIRCOPY_TRANSFER) { pPrintStr = "AIR COPY"; + } else if (gAircopyState == AIRCOPY_COMPLETE) { + pPrintStr = "AIR COPY OK"; } else { - pPrintStr = "AIR COPY(CMP)"; - gAircopyState = AIRCOPY_READY; + pPrintStr = "AIR COPY FAIL"; } UI_PrintString(pPrintStr, 2, 127, 0, 8); @@ -67,19 +60,15 @@ void UI_DisplayAircopy(void) // show the main large frequency digits UI_DisplayFrequency(String, 16, 2, false); - // Get the current map and calculate percentage based on its total blocks - const AIRCOPY_TransferMap_t *currentMap = AIRCOPY_GetCurrentMap(); + const uint8_t totalBlocks = AIRCOPY_GetTotalBlocks(); + uint16_t doneBlocks = gAirCopyBlockNumber; - uint16_t doneBlocks = gAirCopyBlockNumber + gErrorsDuringAirCopy; - - if (doneBlocks > currentMap->total_blocks) - doneBlocks = currentMap->total_blocks; + if (doneBlocks > totalBlocks) + doneBlocks = totalBlocks; // Draw memory selection if (gAircopyState == AIRCOPY_READY) { - doneBlocks = 0; - if(gAircopyCurrentMapIndex < AIRCOPY_NUM_BANKS) { sprintf(String, "MEM %03u - %03u", (gAircopyCurrentMapIndex * 128) + 1, (gAircopyCurrentMapIndex + 1) * 128); } else { @@ -89,67 +78,41 @@ void UI_DisplayAircopy(void) } else { - uint16_t percent = (doneBlocks * 10000) / currentMap->total_blocks; + uint16_t percent = (doneBlocks * 10000) / totalBlocks; + const unsigned displayedErrors = gErrorsDuringAirCopy > 99u + ? 99u + : gErrorsDuringAirCopy; - if (gAirCopyIsSendMode == 0) { - sprintf(String, "RCV:%02u.%02u%% E:%d", percent / 100, percent % 100, gErrorsDuringAirCopy); + if (gAircopyState == AIRCOPY_COMPLETE || gAircopyState == AIRCOPY_FAILED) { + sprintf(String, "%s %u/%u %s:%u", + gAircopyState == AIRCOPY_COMPLETE ? "OK" : "KO", + doneBlocks, totalBlocks, + gAirCopyIsSendMode ? "RT" : "ER", + displayedErrors); + } else if (gAirCopyIsSendMode == 0) { + sprintf(String, "RX:%02u.%02u ER:%u", percent / 100, percent % 100, + displayedErrors); } else { - sprintf(String, "SND:%02u.%02u%%", percent / 100, percent % 100); + sprintf(String, "TX:%02u.%02u RT:%u", percent / 100, percent % 100, + displayedErrors); } - // Draw gauge - if(gAircopyStep != 0) - { - UI_PrintString(String, 2, 127, 5, 8); - - gFrameBuffer[4][1] = 0x3c; - gFrameBuffer[4][2] = 0x42; - - for(uint8_t i = 1; i <= AIRCOPY_BAR_WIDTH + 2; i++) - { - gFrameBuffer[4][2 + i] = 0x81; - } - - gFrameBuffer[4][125] = 0x42; - gFrameBuffer[4][126] = 0x3c; - } - } - - if (doneBlocks > 0) - { - // Track CRC errors per real block index - if (gErrorsDuringAirCopy != lErrorsDuringAirCopy) - { - // Mark the last processed block as faulty - set_bit(crc, doneBlocks - 1); - lErrorsDuringAirCopy = gErrorsDuringAirCopy; - } - - uint16_t b = 0; - uint16_t fraction_accumulator = 0; + UI_PrintString(String, 2, 127, 5, 8); + gFrameBuffer[4][1] = 0x3c; + gFrameBuffer[4][2] = 0x42; + gFrameBuffer[4][3] = 0x81; + // Match the former DDA gauge exactly, including its partial first pixel. + const uint8_t filled = (doneBlocks * AIRCOPY_BAR_WIDTH + totalBlocks - 1u) + / totalBlocks; for (uint8_t col = 0; col < AIRCOPY_BAR_WIDTH; col++) - { - bool processed = (b < doneBlocks); - bool error = processed && get_bit(crc, b); - - if (!processed) - gFrameBuffer[4][col + 4] = 0x81; // not yet processed - else if (error) - gFrameBuffer[4][col + 4] = 0x81; // error gap (intentional hole) - else - gFrameBuffer[4][col + 4] = 0xBD; // ok filled - - // DDA/Bresenham algorythm - fraction_accumulator += currentMap->total_blocks; - while (fraction_accumulator >= AIRCOPY_BAR_WIDTH) { - fraction_accumulator -= AIRCOPY_BAR_WIDTH; - b++; - } - } + gFrameBuffer[4][col + 4] = col < filled ? 0xBD : 0x81; + gFrameBuffer[4][124] = 0x81; + gFrameBuffer[4][125] = 0x42; + gFrameBuffer[4][126] = 0x3c; } ST7565_BlitFullScreen(); } -#endif \ No newline at end of file +#endif From 24d3e5283f5fa990efbf0f993b1b336ac0512980 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Tue, 25 Aug 2026 15:25:24 +0200 Subject: [PATCH 34/73] Optimize Broadcast FM display and remove dead channel formatter --- App/ui/fmradio.c | 26 ++++++++++++++++++-------- App/ui/helper.c | 19 ------------------- App/ui/helper.h | 1 - 3 files changed, 18 insertions(+), 28 deletions(-) diff --git a/App/ui/fmradio.c b/App/ui/fmradio.c index 38d9da06..684a182f 100644 --- a/App/ui/fmradio.c +++ b/App/ui/fmradio.c @@ -31,6 +31,13 @@ void UI_DisplayFM(void) { + // Keep these labels in sync with the limits in driver/bk1080.c. + static const char BandNames[][10] = { + "87.5-108M", + "76-108M", + "76-90M", + "64-76M", + }; char String[16]; char *pPrintStr = String; UI_DisplayClear(); @@ -41,13 +48,7 @@ void UI_DisplayFM(void) UI_PrintString("FM", 2, 0, 0, 8); - sprintf(String, "%d%s-%dM", - BK1080_GetFreqLoLimit(gEeprom.FM_Band)/10, - gEeprom.FM_Band == 0 ? ".5" : "", - BK1080_GetFreqHiLimit(gEeprom.FM_Band)/10 - ); - - UI_PrintStringSmallNormal(String, 1, 0, 6); + UI_PrintStringSmallNormal(BandNames[gEeprom.FM_Band], 1, 0, 6); //uint8_t spacings[] = {20,10,5}; //sprintf(String, "%d0k", spacings[gEeprom.FM_Space % 3]); @@ -81,7 +82,16 @@ void UI_DisplayFM(void) UI_PrintString(pPrintStr, 0, 127, 3, 10); // memory, vfo, scan if (gAskToSave || (gEeprom.FM_IsMrMode && gInputBoxIndex > 0)) { - UI_GenerateChannelString(String, gFM_ChannelPosition); + if (gInputBoxIndex == 0) { + sprintf(String, "CH-%02u", gFM_ChannelPosition + 1); + } else { + String[0] = 'C'; + String[1] = 'H'; + String[2] = '-'; + for (unsigned int i = 0; i < 2; i++) + String[i + 3] = (gInputBox[i] == 10) ? '-' : gInputBox[i] + '0'; + String[5] = '\0'; + } } else if (gAskToDelete) { sprintf(String, "CH-%02u", gEeprom.FM_SelectedChannel + 1); } else { diff --git a/App/ui/helper.c b/App/ui/helper.c index 1de0067b..f25f9622 100644 --- a/App/ui/helper.c +++ b/App/ui/helper.c @@ -25,25 +25,6 @@ #include "settings.h" -void UI_GenerateChannelString(char *pString, const uint16_t Channel) -{ - unsigned int i; - - if (gInputBoxIndex == 0) - { - sprintf(pString, "CH-%02u", Channel + 1); - return; - } - - pString[0] = 'C'; - pString[1] = 'H'; - pString[2] = '-'; - for (i = 0; i < 2; i++) - pString[i + 3] = (gInputBox[i] == 10) ? '-' : gInputBox[i] + '0'; - - pString[5] = 0; -} - void UI_GenerateChannelStringEx(char *pString, const bool bShowPrefix, const uint16_t ChannelNumber) { if (gInputBoxIndex > 0) { diff --git a/App/ui/helper.h b/App/ui/helper.h index 0b62abb6..1d2a32a7 100644 --- a/App/ui/helper.h +++ b/App/ui/helper.h @@ -20,7 +20,6 @@ #include #include -void UI_GenerateChannelString(char *pString, const uint16_t Channel); void UI_GenerateChannelStringEx(char *pString, const bool bShowPrefix, const uint16_t ChannelNumber); void UI_PrintString(const char *pString, uint8_t Start, uint8_t End, uint8_t Line, uint8_t Width); void UI_PrintStringSmallNormal(const char *pString, uint8_t Start, uint8_t End, uint8_t Line); From b0143f9f9c4816a56c1c75d975cd374ab0cc6011 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Tue, 25 Aug 2026 18:45:16 +0200 Subject: [PATCH 35/73] Optimize Breakout Flash and RAM usage --- App/app/breakout.c | 58 +++++++++++++++++++++------------------------- App/app/breakout.h | 7 ------ 2 files changed, 26 insertions(+), 39 deletions(-) diff --git a/App/app/breakout.c b/App/app/breakout.c index ea0be553..667f6247 100644 --- a/App/app/breakout.c +++ b/App/app/breakout.c @@ -24,6 +24,14 @@ #include "k5viewer.h" #endif +#define BRICK_WIDTH 14 +#define BRICK_HEIGHT 5 +#define BALL_WIDTH 3 +#define BALL_HEIGHT 3 +#define RACKET_WIDTH 24 +#define RACKET_HEIGHT 2 +#define RACKET_Y 50 + static uint32_t randSeed = 1; static uint8_t blockAnim = 0; @@ -101,16 +109,14 @@ void drawScore() // Render the ball void renderBall(bool state) { - UI_DrawRectangleBuffer(gFrameBuffer, ball.x, ball.y, ball.x + ball.w - 1, ball.y + ball.h - 1, state); - UI_DrawLineBuffer(gFrameBuffer, ball.x - 1, ball.y + 1, ball.x + ball.w, ball.y + 1, state); + UI_DrawRectangleBuffer(gFrameBuffer, ball.x, ball.y, ball.x + BALL_WIDTH - 1, ball.y + BALL_HEIGHT - 1, state); + UI_DrawLineBuffer(gFrameBuffer, ball.x - 1, ball.y + 1, ball.x + BALL_WIDTH, ball.y + 1, state); } // Init ball void initBall() { ball.x = 62; ball.y = 30; - ball.w = 3; - ball.h = 3; ball.dx = 0; ball.dy = 1; @@ -119,7 +125,7 @@ void initBall() { // Calculate the direction of the bounced ball void directionBall(int16_t x, uint8_t w, int8_t num) { - ball.dx = map(x + w - ball.x, 0, w, num, -num); + ball.dx = (int16_t)(x + w - ball.x) * (-num - num) / w + num; ball.dy *= -1; } @@ -145,8 +151,8 @@ void drawBall() { } // And now Down... if (ball.y == 47) { - if (ball.x + 1 >= racket.x && ball.x - 1 <= racket.x + racket.w) { - directionBall(racket.x, racket.w, 3); + if (ball.x + 1 >= racket.x && ball.x - 1 <= racket.x + RACKET_WIDTH) { + directionBall(racket.x, RACKET_WIDTH, 3); tone = 400; } } @@ -172,24 +178,15 @@ void drawBall() { // Init wall void initWall() { - uint8_t offset = 6; - uint8_t i = 0; - uint8_t j = 0; - uint8_t k = 0; + Brick *current = brick; - for (i = 0; i < BRICK_NUMBER; i++) { - if (i % 6 == 0) { - j = 0; - k++; + for (uint8_t y = 0; y < 24; y += 8) { + for (uint8_t x = 6; x < 126; x += 20) { + current->x = x; + current->y = y; + current->destroy = false; + current++; } - - brick[i].x = offset + (20 * j); - brick[i].y = -8 + 8 * k; - brick[i].w = 14; - brick[i].h = 5; - brick[i].destroy = false; - - j++; } } @@ -205,13 +202,13 @@ void drawWall() { fb_ptr[14] = 0b00011110; if ((ball.x + 1 >= brick[i].x && - ball.x - 1 <= brick[i].x + brick[i].w) && + ball.x - 1 <= brick[i].x + BRICK_WIDTH) && ((ball.y + 1 >= brick[i].y && - ball.y - 1 <= brick[i].y + brick[i].h))) { + ball.y - 1 <= brick[i].y + BRICK_HEIGHT))) { brick[i].destroy = true; score++; - directionBall(brick[i].x, brick[i].w, 2); + directionBall(brick[i].x, BRICK_WIDTH, 2); BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, true); memset(fb_ptr + 1, 0b00111111, 13); @@ -238,16 +235,13 @@ void drawWall() { // Render the racket shape void renderRacket(int x, bool state) { - UI_DrawRectangleBuffer(gFrameBuffer, x + 1, racket.y, x + racket.w - 2, racket.y + racket.h, state); - UI_DrawLineBuffer(gFrameBuffer, x, racket.y + 1, x + racket.w - 1, racket.y + 1, state); + UI_DrawRectangleBuffer(gFrameBuffer, x + 1, RACKET_Y, x + RACKET_WIDTH - 2, RACKET_Y + RACKET_HEIGHT, state); + UI_DrawLineBuffer(gFrameBuffer, x, RACKET_Y + 1, x + RACKET_WIDTH - 1, RACKET_Y + 1, state); } // Init racket void initRacket() { - racket.w = 24; - racket.x = (64) - (racket.w / 2); - racket.y = 50; - racket.h = 2; + racket.x = 64 - (RACKET_WIDTH / 2); racket.p = racket.x; renderRacket(racket.x, true); diff --git a/App/app/breakout.h b/App/app/breakout.h index 3f71b194..a11dbbdd 100644 --- a/App/app/breakout.h +++ b/App/app/breakout.h @@ -47,24 +47,17 @@ typedef struct { uint8_t x; // x uint8_t y; // y - uint8_t w; // width - uint8_t h; // height bool destroy; // active, if true, check this button, else bypass } Brick; typedef struct { int8_t x; // x - uint8_t y; // y - uint8_t w; // width - uint8_t h; // height uint8_t p; // previous x } Racket; typedef struct { int16_t x; // x int8_t y; // y - uint8_t w; // width - uint8_t h; // height int8_t dx; // move x int8_t dy; // move y } Ball; From 3f958f637df13e91728ff9fec0ac288412f276f3 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Tue, 25 Aug 2026 22:06:24 +0200 Subject: [PATCH 36/73] Optimize FoxHunt Flash usage --- App/app/foxhunt.c | 28 +++++------------------ App/ui/status.c | 57 ++++++++++++++++++++--------------------------- App/ui/status.h | 6 ++++- 3 files changed, 35 insertions(+), 56 deletions(-) diff --git a/App/app/foxhunt.c b/App/app/foxhunt.c index f7ca6596..b996d548 100644 --- a/App/app/foxhunt.c +++ b/App/app/foxhunt.c @@ -26,6 +26,8 @@ #include "k5viewer.h" #endif +#include "ui/status.h" + // Signal window mapped onto the RSSI bar, in dBm. // Roughly S0 (empty) to S9 + 40 dB (full), IARU VHF/UHF scale. #define FOXHUNT_DBM_FLOOR (-141) @@ -439,24 +441,6 @@ static void FOXHUNT_DrawRightSmall(const char *s, uint8_t rightX, uint8_t line) UI_PrintStringSmallNormal(s, (uint8_t)(rightX - strlen(s) * 7), 0, line); } -// Battery icon plus the optional voltage/percentage text, top-right of the status -// line — shared by the hunt and beacon screens. -static void FOXHUNT_DrawStatusBattery(void) -{ - unsigned int bx = LCD_WIDTH - sizeof(BITMAP_BatteryLevel1); - UI_DrawBattery(gStatusLine + bx, gBatteryDisplayLevel, gLowBatteryBlink); - if (gSetting_battery_text != 0) { - if (gSetting_battery_text == 1) { // voltage - const uint16_t v = MIN(gBatteryVoltageAverage, 999); - sprintf(str, "%u.%02u", v / 100, v % 100); - } else { // percentage - sprintf(str, "%02u%%", BATTERY_VoltsToPercent(gBatteryVoltageAverage)); - } - bx -= 7 * strlen(str); - UI_PrintStringSmallBufferNormal(str, gStatusLine + bx); - } -} - // Right-aligned frequency on the bottom line (line 6), shared by the hunt and // beacon screens. static void FOXHUNT_DrawFreqBR(uint32_t freq) @@ -472,7 +456,7 @@ static void FOXHUNT_BeaconChrome(void) UI_DisplayClear(); UI_StatusClear(); GUI_DisplaySmallestInverse("BEACON", 2, 0, true, true, 26); - FOXHUNT_DrawStatusBattery(); + UI_DrawStatusBattery(gStatusLine, str); FOXHUNT_DrawFKey(); FOXHUNT_DrawFreqBR(gTxVfo->pTX->Frequency); } @@ -490,7 +474,7 @@ static void FOXHUNT_Draw(void) GUI_DisplaySmallestInverse("FOX HUNT", 2, 0, true, true, 34); // Battery (icon + optional percentage/voltage) top-right, as on the main screens. - FOXHUNT_DrawStatusBattery(); + UI_DrawStatusBattery(gStatusLine, str); FOXHUNT_DrawFKey(); // Gauge-mode icon (2 key), between the label and the audio icon: ascending @@ -571,7 +555,7 @@ static void FOXHUNT_Draw(void) // the burst end, fox at the next repeat), so cycling any of them is safe at any time. // Wrap a 0..count-1 index one step forward (dir > 0) or backward, both ways round. -static uint8_t FOXHUNT_WrapStep(uint8_t v, uint8_t count, int8_t dir) +static __attribute__((noinline)) uint8_t FOXHUNT_WrapStep(uint8_t v, uint8_t count, int8_t dir) { return (uint8_t)((v + (dir > 0 ? 1u : (unsigned)(count - 1u))) % count); } @@ -1317,7 +1301,7 @@ static void FOXHUNT_BeaconTick(void) static uint8_t foxCfgSaved[FOXHUNT_CFG_LEN]; // Pack the live settings into the on-flash layout. -static void FOXHUNT_ConfigPack(uint8_t out[FOXHUNT_CFG_LEN]) +static __attribute__((noinline)) void FOXHUNT_ConfigPack(uint8_t out[FOXHUNT_CFG_LEN]) { out[0] = FOXHUNT_CFG_MAGIC; out[1] = attStep; diff --git a/App/ui/status.c b/App/ui/status.c index 94b2e0f4..e55ad7fe 100644 --- a/App/ui/status.c +++ b/App/ui/status.c @@ -54,6 +54,29 @@ static void convertTime(uint8_t *line, uint8_t type) #endif #endif +#ifdef ENABLE_FEAT_F4HWN_FOXHUNT +void UI_DrawStatusBattery(uint8_t *line, char *str) +#else +static __attribute__((always_inline)) inline void UI_DrawStatusBattery(uint8_t *line, char *str) +#endif +{ + unsigned int x = LCD_WIDTH - sizeof(BITMAP_BatteryLevel1); + + UI_DrawBattery(line + x, gBatteryDisplayLevel, gLowBatteryBlink); + + if (gSetting_battery_text == 1) { + const uint16_t voltage = MIN(gBatteryVoltageAverage, 999); + sprintf(str, "%u.%02u", voltage / 100, voltage % 100); + } else if (gSetting_battery_text == 2) { + sprintf(str, "%02u%%", BATTERY_VoltsToPercent(gBatteryVoltageAverage)); + } else { + return; + } + + x -= 7 * strlen(str); + UI_PrintStringSmallBufferNormal(str, line + x); +} + void UI_DisplayStatus() { gUpdateStatus = false; @@ -314,39 +337,7 @@ void UI_DisplayStatus() memcpy(line + x + 1, src, size); } - // Battery - unsigned int x2 = LCD_WIDTH - sizeof(BITMAP_BatteryLevel1) - 0; - - UI_DrawBattery(line + x2, gBatteryDisplayLevel, gLowBatteryBlink); - - switch (gSetting_battery_text) { - default: - case 0: - break; - - case 1: // voltage - case 2: // percentage - if (gSetting_battery_text == 1) { - const uint16_t voltage = MIN(gBatteryVoltageAverage, 999); // limit to 9.99V - sprintf(str, "%u.%02u", voltage / 100, voltage % 100); - } else { - //gBatteryVoltageAverage = 999; - sprintf(str, "%02u%%", BATTERY_VoltsToPercent(gBatteryVoltageAverage)); - } - - x2 -= (7 * strlen(str)); - UI_PrintStringSmallBufferNormal(str, line + x2); - /* - uint8_t shift = (strlen(str) < 5) ? 92 : 88; - GUI_DisplaySmallest(str, shift, 1, true, true); - - for (uint8_t i = shift - 2; i < 110; i++) { - gStatusLine[i] ^= 0x7F; // invert - } - */ - - break; - } + UI_DrawStatusBattery(line, str); // ************** diff --git a/App/ui/status.h b/App/ui/status.h index ae71d1db..10dd9fe7 100644 --- a/App/ui/status.h +++ b/App/ui/status.h @@ -17,7 +17,11 @@ #ifndef UI_STATUS_H #define UI_STATUS_H +#include + +#ifdef ENABLE_FEAT_F4HWN_FOXHUNT +void UI_DrawStatusBattery(uint8_t *line, char *str); +#endif void UI_DisplayStatus(); #endif - From f35408317e59c13311c4ae330121a8e76ad4acb8 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Fri, 28 Aug 2026 00:40:24 +0200 Subject: [PATCH 37/73] Add external-flash overlay app platform --- .github/workflows/main.yml | 11 +- .gitignore | 8 + App/CMakeLists.txt | 29 ++- App/app/action.c | 11 + App/app/main.c | 19 +- App/app/uart.c | 115 +++++++++ App/apps/app_api.h | 181 ++++++++++++++ App/apps/app_menu.c | 211 +++++++++++++++++ App/apps/app_menu.h | 25 ++ App/apps/app_overlay.c | 394 +++++++++++++++++++++++++++++++ App/apps/app_overlay.h | 112 +++++++++ App/apps/beacon/app.ld | 43 ++++ App/apps/beacon/beacon_app.c | 316 +++++++++++++++++++++++++ App/apps/beacon/build.sh | 40 ++++ App/apps/breakout/app.ld | 43 ++++ App/apps/breakout/breakout_app.c | 266 +++++++++++++++++++++ App/apps/breakout/build.sh | 40 ++++ App/apps/fm/app.ld | 43 ++++ App/apps/fm/build.sh | 40 ++++ App/apps/fm/fm_app.c | 359 ++++++++++++++++++++++++++++ App/apps/foxhunt/app.ld | 43 ++++ App/apps/foxhunt/build.sh | 40 ++++ App/apps/foxhunt/foxhunt_app.c | 357 ++++++++++++++++++++++++++++ App/apps/pack_app.py | 73 ++++++ App/driver/py25q16.c | 10 + App/driver/py25q16.h | 5 + App/ui/status.c | 2 +- App/ui/status.h | 2 +- App/ui/ui.c | 8 +- CMakePresets.json | 17 +- Core/py32f071xb.ld | 13 +- README.md | 38 +-- compile-app.sh | 126 ++++++++++ compile-firmware.sh | 252 ++++++++++++++++++++ compile-with-docker.sh | 258 +------------------- 35 files changed, 3255 insertions(+), 295 deletions(-) create mode 100644 App/apps/app_api.h create mode 100644 App/apps/app_menu.c create mode 100644 App/apps/app_menu.h create mode 100644 App/apps/app_overlay.c create mode 100644 App/apps/app_overlay.h create mode 100644 App/apps/beacon/app.ld create mode 100644 App/apps/beacon/beacon_app.c create mode 100755 App/apps/beacon/build.sh create mode 100644 App/apps/breakout/app.ld create mode 100644 App/apps/breakout/breakout_app.c create mode 100755 App/apps/breakout/build.sh create mode 100644 App/apps/fm/app.ld create mode 100755 App/apps/fm/build.sh create mode 100644 App/apps/fm/fm_app.c create mode 100644 App/apps/foxhunt/app.ld create mode 100755 App/apps/foxhunt/build.sh create mode 100644 App/apps/foxhunt/foxhunt_app.c create mode 100644 App/apps/pack_app.py create mode 100755 compile-app.sh create mode 100755 compile-firmware.sh 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" "$@" From 678cb9b73aad68b3f89d65512b3cab88bfe8b9e2 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Fri, 28 Aug 2026 17:13:13 +0200 Subject: [PATCH 38/73] Add full memory and settings transfer --- App/app/aircopy.c | 45 +++++++++++++++++++++++++++++++++++++-------- App/app/aircopy.h | 6 +++++- App/ui/aircopy.c | 20 ++++++++++++++++---- 3 files changed, 58 insertions(+), 13 deletions(-) diff --git a/App/app/aircopy.c b/App/app/aircopy.c index 6d6aa7bd..7c2fadc6 100644 --- a/App/app/aircopy.c +++ b/App/app/aircopy.c @@ -42,6 +42,7 @@ AIRCOPY_State_t gAircopyState; uint16_t gAirCopyBlockNumber; uint16_t gErrorsDuringAirCopy; bool gAirCopyIsSendMode; +bool gAircopyAll; uint16_t g_FSK_Buffer[36]; @@ -60,24 +61,51 @@ uint16_t g_FSK_Buffer[36]; static uint16_t AircopyCountdown; static uint8_t AircopyRetries; -#define AIRCOPY_NUM_MAPS (AIRCOPY_NUM_BANKS + 1u) #define AIRCOPY_BANK_BLOCKS 68u #define AIRCOPY_SETTINGS_BLOCKS 12u +#define AIRCOPY_ALL_BLOCKS (AIRCOPY_NUM_BANKS * AIRCOPY_BANK_BLOCKS + AIRCOPY_SETTINGS_BLOCKS) // ============================================================================ // Helper Functions // ============================================================================ -uint8_t AIRCOPY_GetTotalBlocks(void) +uint16_t AIRCOPY_GetTotalBlocks(void) { + if (gAircopyAll) + return AIRCOPY_ALL_BLOCKS; // banks + settings, one continuous run return gAircopyCurrentMapIndex == AIRCOPY_NUM_BANKS ? AIRCOPY_SETTINGS_BLOCKS : AIRCOPY_BANK_BLOCKS; } +// Resolve the map that a (possibly global, in All mode) block index lands in. +// On return, *block is rewritten to the block index within that map. +static uint8_t AIRCOPY_ResolveMap(uint16_t *block) +{ + if (!gAircopyAll) + return gAircopyCurrentMapIndex; + + uint8_t map = 0; + while (map < AIRCOPY_NUM_BANKS && *block >= AIRCOPY_BANK_BLOCKS) + { + *block -= AIRCOPY_BANK_BLOCKS; + map++; + } + return map; // AIRCOPY_NUM_BANKS once the banks are exhausted (settings map) +} + +// Map index of the block currently in progress, for the All-mode slice label. +uint8_t AIRCOPY_CurrentSliceMap(void) +{ + uint16_t block = gAirCopyBlockNumber; + return AIRCOPY_ResolveMap(&block); +} + static uint16_t AIRCOPY_GetBlockOffset(uint16_t block) { - if (gAircopyCurrentMapIndex == AIRCOPY_NUM_BANKS) + const uint8_t map = AIRCOPY_ResolveMap(&block); + + if (map == AIRCOPY_NUM_BANKS) { // Settings: 6 blocks at 0xA000, 2 at 0x880E and 4 at 0x9000. if (block < 6u) @@ -88,12 +116,12 @@ static uint16_t AIRCOPY_GetBlockOffset(uint16_t block) } // A bank contains 32 frequency, 32 name and 4 attribute blocks. - const uint16_t channelOffset = gAircopyCurrentMapIndex * 0x0800u; + const uint16_t channelOffset = map * 0x0800u; if (block < 32u) return channelOffset + block * AIRCOPY_BLOCK_SIZE; if (block < 64u) return 0x4000u + channelOffset + (block - 32u) * AIRCOPY_BLOCK_SIZE; - return 0x8000u + gAircopyCurrentMapIndex * 0x0100u + return 0x8000u + map * 0x0100u + (block - 64u) * AIRCOPY_BLOCK_SIZE; } @@ -314,8 +342,9 @@ void AIRCOPY_StorePacket(void) static void AIRCOPY_InitTransfer(bool isSendMode) { - if (gAircopyCurrentMapIndex >= AIRCOPY_NUM_MAPS) + if (gAircopyCurrentMapIndex > AIRCOPY_ALL_INDEX) gAircopyCurrentMapIndex = 0; + gAircopyAll = (gAircopyCurrentMapIndex == AIRCOPY_ALL_INDEX); gFSKWriteIndex = 0; gAirCopyBlockNumber = 0; @@ -400,10 +429,10 @@ static void AIRCOPY_Key_UP_DOWN(int8_t Direction) switch(Direction) { case 1: - gAircopyCurrentMapIndex = (gAircopyCurrentMapIndex + 1) % AIRCOPY_NUM_MAPS; + gAircopyCurrentMapIndex = (gAircopyCurrentMapIndex + 1) % (AIRCOPY_NUM_MAPS + 1u); break; case -1: - gAircopyCurrentMapIndex = (gAircopyCurrentMapIndex + AIRCOPY_NUM_MAPS - 1) % AIRCOPY_NUM_MAPS; + gAircopyCurrentMapIndex = (gAircopyCurrentMapIndex + AIRCOPY_NUM_MAPS) % (AIRCOPY_NUM_MAPS + 1u); break; } } diff --git a/App/app/aircopy.h b/App/app/aircopy.h index 2dd594d6..1092bbc0 100644 --- a/App/app/aircopy.h +++ b/App/app/aircopy.h @@ -28,6 +28,8 @@ #define AIRCOPY_BLOCK_SIZE 0x0040u // 64 bytes per AirCopy block #define AIRCOPY_CHANNELS_PER_BANK 128 #define AIRCOPY_NUM_BANKS MR_CHANNELS_MAX / AIRCOPY_CHANNELS_PER_BANK +#define AIRCOPY_NUM_MAPS (AIRCOPY_NUM_BANKS + 1u) // banks + one settings map +#define AIRCOPY_ALL_INDEX AIRCOPY_NUM_MAPS // selection sentinel: send/receive everything #define AIRCOPY_BAR_WIDTH 120 // Visible width of the progress gauge // ============================================================================ @@ -49,6 +51,7 @@ extern AIRCOPY_State_t gAircopyState; extern uint16_t gAirCopyBlockNumber; extern uint16_t gErrorsDuringAirCopy; extern bool gAirCopyIsSendMode; +extern bool gAircopyAll; // All mode: banks + settings in one pass extern uint16_t g_FSK_Buffer[36]; @@ -59,7 +62,8 @@ extern uint16_t g_FSK_Buffer[36]; bool AIRCOPY_SendMessage(void); void AIRCOPY_StorePacket(void); void AIRCOPY_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld); -uint8_t AIRCOPY_GetTotalBlocks(void); +uint16_t AIRCOPY_GetTotalBlocks(void); +uint8_t AIRCOPY_CurrentSliceMap(void); // map index of the block in progress (All slice label) // XOR-obfuscate `count` words of g_FSK_Buffer starting at index 1. // Self-inverse: applying twice restores the original buffer. diff --git a/App/ui/aircopy.c b/App/ui/aircopy.c index 333820b5..daba1a45 100644 --- a/App/ui/aircopy.c +++ b/App/ui/aircopy.c @@ -37,7 +37,17 @@ void UI_DisplayAircopy(void) if (gAircopyState == AIRCOPY_READY) { pPrintStr = "AIR COPY(RDY)"; } else if (gAircopyState == AIRCOPY_TRANSFER) { - pPrintStr = "AIR COPY"; + if (gAircopyAll) { + // All mode: show the slice being replicated in place of the title. + const uint8_t m = AIRCOPY_CurrentSliceMap(); + if (m < AIRCOPY_NUM_BANKS) + sprintf(String, "MEM %03u-%03u", (m * 128) + 1, (m + 1) * 128); + else + strcpy(String, "SETTINGS"); + pPrintStr = String; + } else { + pPrintStr = "AIR COPY"; + } } else if (gAircopyState == AIRCOPY_COMPLETE) { pPrintStr = "AIR COPY OK"; } else { @@ -60,7 +70,7 @@ void UI_DisplayAircopy(void) // show the main large frequency digits UI_DisplayFrequency(String, 16, 2, false); - const uint8_t totalBlocks = AIRCOPY_GetTotalBlocks(); + const uint16_t totalBlocks = AIRCOPY_GetTotalBlocks(); uint16_t doneBlocks = gAirCopyBlockNumber; if (doneBlocks > totalBlocks) @@ -69,10 +79,12 @@ void UI_DisplayAircopy(void) // Draw memory selection if (gAircopyState == AIRCOPY_READY) { - if(gAircopyCurrentMapIndex < AIRCOPY_NUM_BANKS) { + if(gAircopyCurrentMapIndex < AIRCOPY_NUM_BANKS) { sprintf(String, "MEM %03u - %03u", (gAircopyCurrentMapIndex * 128) + 1, (gAircopyCurrentMapIndex + 1) * 128); + } else if(gAircopyCurrentMapIndex == AIRCOPY_NUM_BANKS) { + strcpy(String, "Settings"); } else { - strcpy(String, "Settings"); + strcpy(String, "All (Mem+Set)"); } UI_PrintString(String, 2, 127, 5, 8); } From f4811cbe1ebae768c30ed993bebfdb60e70352a6 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Fri, 28 Aug 2026 20:49:23 +0200 Subject: [PATCH 39/73] Add Plasma app, just for fun --- App/apps/plasma/app.ld | 43 +++++++++ App/apps/plasma/build.sh | 40 +++++++++ App/apps/plasma/plasma_app.c | 164 +++++++++++++++++++++++++++++++++++ 3 files changed, 247 insertions(+) create mode 100644 App/apps/plasma/app.ld create mode 100755 App/apps/plasma/build.sh create mode 100644 App/apps/plasma/plasma_app.c diff --git a/App/apps/plasma/app.ld b/App/apps/plasma/app.ld new file mode 100644 index 00000000..c315d2af --- /dev/null +++ b/App/apps/plasma/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, + "Plasma overlay app overflows the 4 KiB overlay") + + /DISCARD/ : { *(.ARM.exidx*) *(.ARM.extab*) *(.eh_frame*) *(.comment) *(.note.*) } +} diff --git a/App/apps/plasma/build.sh b/App/apps/plasma/build.sh new file mode 100755 index 00000000..2bf77d50 --- /dev/null +++ b/App/apps/plasma/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="Plasma" # <-- 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/plasma/plasma_app.c b/App/apps/plasma/plasma_app.c new file mode 100644 index 00000000..182d8c2a --- /dev/null +++ b/App/apps/plasma/plasma_app.c @@ -0,0 +1,164 @@ +/* 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. + */ + +/* + * Plasma — overlay app. A full-screen demoscene plasma on the 1-bit 128x64 LCD. + * The scalar field sums four scrolled sines: horizontal (x), vertical (y), + * diagonal (x+y) and a radial ripple around a slowly drifting centre. Because + * (x-cx)^2 + (y-cy)^2 is separable, the whole field is still built from per-column + * and per-row tables, so each of the 8192 pixels costs only a handful of adds. + * Rendered either with an ordered 4x4 Bayer dither (stipple) or as sweeping bands. + * Pure compute + framebuffer, no radio — it just works. + * + * Keys: UP/DOWN speed · 1-5 pattern · STAR stipple/bands · F auto-cycle · + * MENU pause · EXIT quit. + */ + +#include +#include +#include "../app_api.h" + +#define W 128 +#define H 64 + +static const app_api_t *A; + +/* 32 * sin(2*pi*i/256): four of these sum to -128..128. */ +static const int8_t SIN[256] = { + 0, 1, 2, 2, 3, 4, 5, 5, 6, 7, 8, 9, 9, 10, 11, 12, + 12, 13, 14, 14, 15, 16, 16, 17, 18, 18, 19, 20, 20, 21, 21, 22, + 23, 23, 24, 24, 25, 25, 26, 26, 27, 27, 27, 28, 28, 29, 29, 29, + 30, 30, 30, 30, 31, 31, 31, 31, 31, 32, 32, 32, 32, 32, 32, 32, + 32, 32, 32, 32, 32, 32, 32, 32, 31, 31, 31, 31, 31, 30, 30, 30, + 30, 29, 29, 29, 28, 28, 27, 27, 27, 26, 26, 25, 25, 24, 24, 23, + 23, 22, 21, 21, 20, 20, 19, 18, 18, 17, 16, 16, 15, 14, 14, 13, + 12, 12, 11, 10, 9, 9, 8, 7, 6, 5, 5, 4, 3, 2, 2, 1, + 0, -1, -2, -2, -3, -4, -5, -5, -6, -7, -8, -9, -9,-10,-11,-12, + -12,-13,-14,-14,-15,-16,-16,-17,-18,-18,-19,-20,-20,-21,-21,-22, + -23,-23,-24,-24,-25,-25,-26,-26,-27,-27,-27,-28,-28,-29,-29,-29, + -30,-30,-30,-30,-31,-31,-31,-31,-31,-32,-32,-32,-32,-32,-32,-32, + -32,-32,-32,-32,-32,-32,-32,-32,-31,-31,-31,-31,-31,-30,-30,-30, + -30,-29,-29,-29,-28,-28,-27,-27,-27,-26,-26,-25,-25,-24,-24,-23, + -23,-22,-21,-21,-20,-20,-19,-18,-18,-17,-16,-16,-15,-14,-14,-13, + -12,-12,-11,-10, -9, -9, -8, -7, -6, -5, -5, -4, -3, -2, -2, -1, +}; + +/* 4x4 ordered-dither matrix, flattened: idx = (y&3)*4 + (x&3), values 0..15. */ +static const uint8_t BAYER[16] = { 0,8,2,10, 12,4,14,6, 3,11,1,9, 15,7,13,5 }; + +/* Pattern presets: {x scale, y scale, diagonal scale, radial ring shift}. */ +#define NVAR 5 +static const uint8_t VAR[NVAR][4] = { + {4,4,3,5}, {6,3,5,4}, {3,7,2,6}, {5,5,4,5}, {2,8,6,4}, +}; + +/* Per-frame separable tables. */ +static int8_t colA[W]; /* horizontal sine, by column x */ +static int8_t rowA[H]; /* vertical sine, by row y */ +static int8_t diagA[W + H]; /* diagonal sine, by (x + y) */ +static uint16_t sqx[W]; /* (x - cx)^2 for the radial term */ +static uint16_t sqy[H]; /* (y - cy)^2 */ + +static uint8_t prevKey; + +__attribute__((section(".text.entry"), used)) +void app_main(const app_api_t *api) +{ + A = api; + A->backlight_on(); + A->status_clear(); + + uint16_t t1 = 0, t2 = 0, t3 = 0, tr = 0, tc = 0, tc2 = 0; + uint16_t autoCtr = 0; + uint8_t speed = 3; + uint8_t var = 0; + bool bands = false; + bool autoc = false; + bool paused = false; + bool running = true; + prevKey = APP_KEY_INVALID; + + while (running) { + const uint8_t sx = VAR[var][0], sy = VAR[var][1]; + const uint8_t sd = VAR[var][2], rsh = VAR[var][3]; + + /* drifting radial centre (gentle Lissajous), then the separable squares */ + const int cx = 64 + SIN[(uint8_t)tc]; /* 32..96 */ + const int cy = 32 + (SIN[(uint8_t)tc2] >> 1);/* 16..48 */ + + for (uint8_t x = 0; x < W; x++) { + colA[x] = SIN[(uint8_t)(x * sx + t1)]; + int dx = (int)x - cx; sqx[x] = (uint16_t)(dx * dx); + } + for (uint8_t y = 0; y < H; y++) { + rowA[y] = SIN[(uint8_t)(y * sy + t2)]; + int dy = (int)y - cy; sqy[y] = (uint16_t)(dy * dy); + } + for (uint16_t s = 0; s < W + H; s++) diagA[s] = SIN[(uint8_t)(s * sd + t3)]; + + /* render all 8 pages: page 0 -> status line (top), pages 1..7 -> fb[0..6] */ + for (uint8_t x = 0; x < W; x++) { + const int8_t cxv = colA[x]; + const uint16_t sxv = sqx[x]; + const uint8_t bx = x & 3u; + for (uint8_t p = 0; p < 8u; p++) { + uint8_t byte = 0; + for (uint8_t b = 0; b < 8u; b++) { + const uint8_t y = (uint8_t)(p * 8u + b); + int v = cxv + rowA[y] + diagA[x + y] + + SIN[(uint8_t)(((sxv + sqy[y]) >> rsh) + tr)]; + v += 128; /* 0..256 */ + bool on; + if (bands) on = ((v >> 4) & 1u) != 0u; /* sweeping stripes */ + else { + int lvl = v >> 4; if (lvl > 15) lvl = 15; /* 0..15 stipple */ + on = lvl > (int)BAYER[((y & 3u) << 2) | bx]; + } + if (on) byte = (uint8_t)(byte | (1u << b)); + } + if (p == 0) A->status_line[x] = byte; + else A->fb[p - 1][x] = byte; + } + } + A->blit_status(); + A->blit_full(); + + if (!paused) { + t1 += speed; t2 += (uint16_t)(speed + 1u); t3 += 1u; + tr += speed; tc += 1u; tc2 += 2u; + if (autoc && ++autoCtr >= 400u) { autoCtr = 0; var = (uint8_t)((var + 1u) % NVAR); } + } + + const uint8_t key = A->get_key(); + if (key != prevKey && key != APP_KEY_INVALID) { + A->backlight_on(); + switch (key) { + case APP_KEY_EXIT: running = false; break; + case APP_KEY_UP: if (speed < 8u) speed++; break; + case APP_KEY_DOWN: if (speed > 1u) speed--; break; + case APP_KEY_MENU: paused = !paused; break; + case APP_KEY_STAR: bands = !bands; break; + case APP_KEY_F: autoc = !autoc; break; + case APP_KEY_1: case APP_KEY_2: case APP_KEY_3: + case APP_KEY_4: case APP_KEY_5: + var = (uint8_t)(key - APP_KEY_1); autoc = false; break; + default: break; + } + } + prevKey = key; + A->backlight_update(); + } +} From 9d6b560a54dcd9db5f5777717d18ff74679de2e4 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Fri, 28 Aug 2026 23:43:42 +0200 Subject: [PATCH 40/73] Apply SetNav in overlay apps and FoxHunt --- App/app/foxhunt.c | 21 ++++++++++++++------- App/apps/app_api.h | 11 +++++++++-- App/apps/app_overlay.c | 14 ++++++++++++++ App/apps/fm/fm_app.c | 4 ++-- App/apps/foxhunt/foxhunt_app.c | 7 +++---- App/apps/plasma/plasma_app.c | 9 +++++++-- 6 files changed, 49 insertions(+), 17 deletions(-) diff --git a/App/app/foxhunt.c b/App/app/foxhunt.c index b996d548..34099575 100644 --- a/App/app/foxhunt.c +++ b/App/app/foxhunt.c @@ -608,6 +608,16 @@ static void FOXHUNT_AttCycle(int8_t dir) FOXHUNT_RebaseMeasurements(); } +// Convert the two navigation keys into the same semantic value direction used +// by the resident menus and by the overlay FoxHunt app: +// UV-K5 UP/DOWN -> +1/-1 +// UV-K1 LEFT/RIGHT -> -1/+1 +static int8_t FOXHUNT_NavDirection(KEY_Code_t key) +{ + int8_t direction = (key == KEY_UP) ? 1 : -1; + return gEeprom.SET_NAV ? direction : -direction; +} + // Fox identifier: MOE -> MOI -> MOS -> MOH -> MO5 -> MO -> CALL (F reverses). static void FOXHUNT_FoxCycle(int8_t dir) { @@ -734,8 +744,8 @@ static void FOXHUNT_HandleKeys(void) // released only by the long-press F handled above. Keeping ATT reachable is the whole // point: on the final approach the sensitivity still has to be pulled down by hand. if (foxLocked) { - if (kbd.current == KEY_UP) FOXHUNT_AttCycle(+1); // more attenuation - if (kbd.current == KEY_DOWN) FOXHUNT_AttCycle(-1); // less attenuation + if (kbd.current == KEY_UP || kbd.current == KEY_DOWN) + FOXHUNT_AttCycle(FOXHUNT_NavDirection(kbd.current)); return; } @@ -765,12 +775,9 @@ static void FOXHUNT_HandleKeys(void) FOXHUNT_AttCycle(dir); break; case KEY_UP: - // Attenuation up one step (also the locked-mode control). - FOXHUNT_AttCycle(+1); - break; case KEY_DOWN: - // Attenuation down one step. - FOXHUNT_AttCycle(-1); + // Follow SetNav: UP/DOWN on K5, LEFT/RIGHT on K1. + FOXHUNT_AttCycle(FOXHUNT_NavDirection(kbd.current)); break; case KEY_MENU: // Reset the peak / min hold and the trend reference (before each body scan). diff --git a/App/apps/app_api.h b/App/apps/app_api.h index 38420e3d..58188e31 100644 --- a/App/apps/app_api.h +++ b/App/apps/app_api.h @@ -36,11 +36,11 @@ #include #include -/* ABI 2: the table below (v1 core + the fields once labelled "v2 additions") is a +/* ABI 3: 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 +#define APP_ABI_VERSION 3u /* 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. */ @@ -167,6 +167,13 @@ typedef struct app_api { uint16_t *fm_channels; /* -> gFM_Channels[APP_FM_CH_MAX], shared RAM r/w */ void (*fm_state)(app_fm_state_t *s, bool write);/* read/write the resident gEeprom.FM_* */ void (*fm_commit)(void); /* deferred SETTINGS_SaveFM (config + channels) */ + + /* ---- navigation (ABI 3) ---- + * Convert a raw APP_KEY_UP/DOWN into a semantic value direction: + * UV-K5 UP/DOWN -> +1/-1 + * UV-K1 LEFT/RIGHT -> -1/+1 + * Returns 0 for any other key. Keep get_key() raw for spatial controls. */ + int8_t (*nav_dir)(uint8_t key); } app_api_t; /* BK4819 AF modes for set_af (mirror driver/bk4819.h values). */ diff --git a/App/apps/app_overlay.c b/App/apps/app_overlay.c index a996d6c9..82190092 100644 --- a/App/apps/app_overlay.c +++ b/App/apps/app_overlay.c @@ -60,6 +60,19 @@ static uint32_t app_crc32(const uint8_t *p, uint32_t len) 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 int8_t app_nav_dir(uint8_t key) +{ + int8_t direction; + + if (key == KEY_UP) + direction = 1; + else if (key == KEY_DOWN) + direction = -1; + else + return 0; + + return gEeprom.SET_NAV ? direction : -direction; +} static void app_led(bool on) { BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, on); } static void app_play_tone(uint16_t tone, uint16_t ms) @@ -320,6 +333,7 @@ uint8_t APP_LaunchOverlay(uint8_t slot) .fm_state = app_fm_state, .fm_commit = app_fm_commit, #endif + .nav_dir = app_nav_dir, }; app_run_slot = slot; /* for cfg_load / cfg_save */ diff --git a/App/apps/fm/fm_app.c b/App/apps/fm/fm_app.c index 07fa4927..85520bec 100644 --- a/App/apps/fm/fm_app.c +++ b/App/apps/fm/fm_app.c @@ -281,8 +281,8 @@ static void onShort(uint8_t key) 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_UP: + case APP_KEY_DOWN: upDown(A->nav_dir(key)); break; case APP_KEY_STAR: startManual(+1); break; case APP_KEY_MENU: menu(); break; case APP_KEY_EXIT: diff --git a/App/apps/foxhunt/foxhunt_app.c b/App/apps/foxhunt/foxhunt_app.c index 468f857f..f906c359 100644 --- a/App/apps/foxhunt/foxhunt_app.c +++ b/App/apps/foxhunt/foxhunt_app.c @@ -285,8 +285,7 @@ static void handleKeys(void){ A->backlight_on(); if(foxLocked){ - if(key==APP_KEY_UP) attCycle(+1); - if(key==APP_KEY_DOWN) attCycle(-1); + if(key==APP_KEY_UP||key==APP_KEY_DOWN) attCycle(A->nav_dir(key)); return; } if(key==APP_KEY_F){ fArm=!fArm; return; } @@ -300,8 +299,8 @@ static void handleKeys(void){ 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_UP: + case APP_KEY_DOWN: attCycle(A->nav_dir(key)); break; case APP_KEY_MENU: peakDbm=minDbm=trendRef=curDbm; break; default: break; } diff --git a/App/apps/plasma/plasma_app.c b/App/apps/plasma/plasma_app.c index 182d8c2a..fdfb4e47 100644 --- a/App/apps/plasma/plasma_app.c +++ b/App/apps/plasma/plasma_app.c @@ -147,8 +147,13 @@ void app_main(const app_api_t *api) A->backlight_on(); switch (key) { case APP_KEY_EXIT: running = false; break; - case APP_KEY_UP: if (speed < 8u) speed++; break; - case APP_KEY_DOWN: if (speed > 1u) speed--; break; + case APP_KEY_UP: + case APP_KEY_DOWN: { + const int8_t direction = A->nav_dir(key); + if (direction > 0 && speed < 8u) speed++; + if (direction < 0 && speed > 1u) speed--; + break; + } case APP_KEY_MENU: paused = !paused; break; case APP_KEY_STAR: bands = !bands; break; case APP_KEY_F: autoc = !autoc; break; From 9358d5d58fc0a12555010cb56c9b531b66c9d538 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sat, 29 Aug 2026 11:58:20 +0200 Subject: [PATCH 41/73] Add Cube3D app, just for fun --- App/apps/cube3d/app.ld | 43 ++++++ App/apps/cube3d/build.sh | 40 +++++ App/apps/cube3d/cube3d_app.c | 286 +++++++++++++++++++++++++++++++++++ 3 files changed, 369 insertions(+) create mode 100644 App/apps/cube3d/app.ld create mode 100755 App/apps/cube3d/build.sh create mode 100644 App/apps/cube3d/cube3d_app.c diff --git a/App/apps/cube3d/app.ld b/App/apps/cube3d/app.ld new file mode 100644 index 00000000..0ea0222e --- /dev/null +++ b/App/apps/cube3d/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, + "Cube3D overlay app overflows the 4 KiB overlay") + + /DISCARD/ : { *(.ARM.exidx*) *(.ARM.extab*) *(.eh_frame*) *(.comment) *(.note.*) } +} diff --git a/App/apps/cube3d/build.sh b/App/apps/cube3d/build.sh new file mode 100755 index 00000000..8175ba03 --- /dev/null +++ b/App/apps/cube3d/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="Cube3D" # <-- 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/cube3d/cube3d_app.c b/App/apps/cube3d/cube3d_app.c new file mode 100644 index 00000000..68f535bf --- /dev/null +++ b/App/apps/cube3d/cube3d_app.c @@ -0,0 +1,286 @@ +/* Copyright 2026 Armel F4HWN + * https://github.com/armel + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * Cube3D — overlay app. A real-time rotating solid on the 1-bit 128x64 LCD. + * Vertices are spun by three axis rotations in Q14 fixed point (Cortex-M0+ has + * no FPU and no hardware divide), then perspective-projected with a single + * divide per vertex. Two looks, toggled with F: + * - SOLID: hidden-line removal by back-face culling (a face is drawn only when + * the signed area of its projected polygon shows it facing us). + * - WIRE : every edge, with the far hemisphere dotted for a depth cue. + * Edges use a self-clipped Bresenham writing the full 64 rows directly, since the + * resident pixel helper does not bound-check. The eight solids' faces (with a + * uniform outward winding) are generated offline by a convex-hull extractor, so + * nothing here has to be hand-wound. Pure compute, no radio. + * + * Keys: UP/DOWN speed · 1-8 shape · STAR next shape · F solid/wire · + * MENU pause · EXIT quit. + */ + +#include +#include +#include "../app_api.h" + +#define W 128 +#define H 64 +#define CX 64 /* projection centre x */ +#define CY 32 /* projection centre y */ +#define DIST 150 /* camera distance along +z (keeps zc > 0) */ +#define FOCAL 80 /* focal length / field-of-view scale */ +#define MAXV 12 /* largest vertex count across the solids */ + +static const app_api_t *A; + +/* Q14 sine: round(16384 * sin(2*pi*i/256)). cos(i) == SIN[(i + 64) & 255]. */ +static const int16_t SIN[256] = { + 0, 402, 804, 1205, 1606, 2006, 2404, 2801, 3196, 3590, 3981, + 4370, 4756, 5139, 5520, 5897, 6270, 6639, 7005, 7366, 7723, 8076, + 8423, 8765, 9102, 9434, 9760, 10080, 10394, 10702, 11003, 11297, 11585, + 11866, 12140, 12406, 12665, 12916, 13160, 13395, 13623, 13842, 14053, 14256, + 14449, 14635, 14811, 14978, 15137, 15286, 15426, 15557, 15679, 15791, 15893, + 15986, 16069, 16143, 16207, 16261, 16305, 16340, 16364, 16379, 16384, 16379, + 16364, 16340, 16305, 16261, 16207, 16143, 16069, 15986, 15893, 15791, 15679, + 15557, 15426, 15286, 15137, 14978, 14811, 14635, 14449, 14256, 14053, 13842, + 13623, 13395, 13160, 12916, 12665, 12406, 12140, 11866, 11585, 11297, 11003, + 10702, 10394, 10080, 9760, 9434, 9102, 8765, 8423, 8076, 7723, 7366, + 7005, 6639, 6270, 5897, 5520, 5139, 4756, 4370, 3981, 3590, 3196, + 2801, 2404, 2006, 1606, 1205, 804, 402, 0, -402, -804, -1205, + -1606, -2006, -2404, -2801, -3196, -3590, -3981, -4370, -4756, -5139, -5520, + -5897, -6270, -6639, -7005, -7366, -7723, -8076, -8423, -8765, -9102, -9434, + -9760,-10080,-10394,-10702,-11003,-11297,-11585,-11866,-12140,-12406,-12665, + -12916,-13160,-13395,-13623,-13842,-14053,-14256,-14449,-14635,-14811,-14978, + -15137,-15286,-15426,-15557,-15679,-15791,-15893,-15986,-16069,-16143,-16207, + -16261,-16305,-16340,-16364,-16379,-16384,-16379,-16364,-16340,-16305,-16261, + -16207,-16143,-16069,-15986,-15893,-15791,-15679,-15557,-15426,-15286,-15137, + -14978,-14811,-14635,-14449,-14256,-14053,-13842,-13623,-13395,-13160,-12916, + -12665,-12406,-12140,-11866,-11585,-11297,-11003,-10702,-10394,-10080, -9760, + -9434, -9102, -8765, -8423, -8076, -7723, -7366, -7005, -6639, -6270, -5897, + -5520, -5139, -4756, -4370, -3981, -3590, -3196, -2801, -2404, -2006, -1606, + -1205, -804, -402, +}; + +/* A face is a polygon of up to 6 vertex indices, wound CCW as seen from outside + * (generated offline by the hull extractor, so the signed-area cull sign is the + * same for every solid). Unused slots are 0-padded and ignored (n gives length). */ +typedef struct { uint8_t n; uint8_t v[6]; } face_t; + +static const int8_t CUBE_V[8][3]={{-26,-26,-26},{26,-26,-26},{26,26,-26},{-26,26,-26},{-26,-26,26},{26,-26,26},{26,26,26},{-26,26,26}}; +static const face_t CUBE_F[6]={{4,{2,1,0,3,0,0}},{4,{4,0,1,5,0,0}},{4,{7,3,0,4,0,0}},{4,{5,1,2,6,0,0}},{4,{6,2,3,7,0,0}},{4,{7,4,5,6,0,0}}}; + +static const int8_t OCTAHEDRON_V[6][3]={{38,0,0},{-38,0,0},{0,38,0},{0,-38,0},{0,0,38},{0,0,-38}}; +static const face_t OCTAHEDRON_F[8]={{3,{4,0,2,0,0,0}},{3,{2,0,5,0,0,0}},{3,{3,0,4,0,0,0}},{3,{5,0,3,0,0,0}},{3,{2,1,4,0,0,0}},{3,{5,1,2,0,0,0}},{3,{4,1,3,0,0,0}},{3,{3,1,5,0,0,0}}}; + +static const int8_t TETRAHEDRON_V[4][3]={{28,28,28},{28,-28,-28},{-28,28,-28},{-28,-28,28}}; +static const face_t TETRAHEDRON_F[4]={{3,{2,0,1,0,0,0}},{3,{1,0,3,0,0,0}},{3,{3,0,2,0,0,0}},{3,{2,1,3,0,0,0}}}; + +static const int8_t DIAMOND_V[8][3]={{30,0,0},{14,26,0},{-14,26,0},{-30,0,0},{-14,-26,0},{14,-26,0},{0,0,40},{0,0,-40}}; +static const face_t DIAMOND_F[12]={{3,{6,0,1,0,0,0}},{3,{1,0,7,0,0,0}},{3,{5,0,6,0,0,0}},{3,{7,0,5,0,0,0}},{3,{6,1,2,0,0,0}},{3,{2,1,7,0,0,0}},{3,{6,2,3,0,0,0}},{3,{3,2,7,0,0,0}},{3,{6,3,4,0,0,0}},{3,{4,3,7,0,0,0}},{3,{6,4,5,0,0,0}},{3,{5,4,7,0,0,0}}}; + +static const int8_t ICOSAHEDRON_V[12][3]={{0,18,29},{0,18,-29},{0,-18,29},{0,-18,-29},{18,29,0},{18,-29,0},{-18,29,0},{-18,-29,0},{29,0,18},{29,0,-18},{-29,0,18},{-29,0,-18}}; +static const face_t ICOSAHEDRON_F[20]={{3,{8,0,2,0,0,0}},{3,{2,0,10,0,0,0}},{3,{6,0,4,0,0,0}},{3,{4,0,8,0,0,0}},{3,{10,0,6,0,0,0}},{3,{3,1,9,0,0,0}},{3,{11,1,3,0,0,0}},{3,{4,1,6,0,0,0}},{3,{9,1,4,0,0,0}},{3,{6,1,11,0,0,0}},{3,{5,2,7,0,0,0}},{3,{8,2,5,0,0,0}},{3,{7,2,10,0,0,0}},{3,{7,3,5,0,0,0}},{3,{5,3,9,0,0,0}},{3,{11,3,7,0,0,0}},{3,{9,4,8,0,0,0}},{3,{8,5,9,0,0,0}},{3,{10,6,11,0,0,0}},{3,{11,7,10,0,0,0}}}; + +static const int8_t CUBOCTA_V[12][3]={{-24,-24,0},{-24,24,0},{24,-24,0},{24,24,0},{-24,0,-24},{-24,0,24},{24,0,-24},{24,0,24},{0,-24,-24},{0,-24,24},{0,24,-24},{0,24,24}}; +static const face_t CUBOCTA_F[14]={{4,{4,0,5,1,0,0}},{4,{2,9,0,8,0,0}},{3,{8,0,4,0,0,0}},{3,{5,0,9,0,0,0}},{4,{10,1,11,3,0,0}},{3,{4,1,10,0,0,0}},{3,{11,1,5,0,0,0}},{4,{3,7,2,6,0,0}},{3,{6,2,8,0,0,0}},{3,{9,2,7,0,0,0}},{3,{10,3,6,0,0,0}},{3,{7,3,11,0,0,0}},{4,{8,4,10,6,0,0}},{4,{7,11,5,9,0,0}}}; + +static const int8_t HEXPRISM_V[12][3]={{26,0,24},{12,22,24},{-12,22,24},{-26,0,24},{-12,-22,24},{12,-22,24},{26,0,-24},{12,22,-24},{-12,22,-24},{-26,0,-24},{-12,-22,-24},{12,-22,-24}}; +static const face_t HEXPRISM_F[8]={{6,{4,5,0,1,2,3}},{4,{7,1,0,6,0,0}},{4,{6,0,5,11,0,0}},{4,{8,2,1,7,0,0}},{4,{9,3,2,8,0,0}},{4,{10,4,3,9,0,0}},{4,{11,5,4,10,0,0}},{6,{9,8,7,6,11,10}}}; + +static const int8_t PENTAGEM_V[7][3]={{28,0,0},{8,26,0},{-22,16,0},{-22,-16,0},{8,-26,0},{0,0,42},{0,0,-42}}; +static const face_t PENTAGEM_F[10]={{3,{5,0,1,0,0,0}},{3,{1,0,6,0,0,0}},{3,{4,0,5,0,0,0}},{3,{6,0,4,0,0,0}},{3,{5,1,2,0,0,0}},{3,{2,1,6,0,0,0}},{3,{5,2,3,0,0,0}},{3,{3,2,6,0,0,0}},{3,{5,3,4,0,0,0}},{3,{4,3,6,0,0,0}}}; + +typedef struct { + const int8_t (*v)[3]; + const face_t *f; + uint8_t nv; + uint8_t nf; + const char *name; + uint8_t namelen; +} shape_t; + +#define NSHAPE 8 +static const shape_t SHAPES[NSHAPE] = { + { CUBE_V, CUBE_F, 8, 6, "CUBE", 4 }, + { OCTAHEDRON_V, OCTAHEDRON_F, 6, 8, "OCTAHEDRON", 10 }, + { TETRAHEDRON_V, TETRAHEDRON_F, 4, 4, "TETRAHEDRON", 11 }, + { DIAMOND_V, DIAMOND_F, 8, 12, "DIAMOND", 7 }, + { ICOSAHEDRON_V, ICOSAHEDRON_F, 12, 20, "ICOSAHEDRON", 11 }, + { CUBOCTA_V, CUBOCTA_F, 12, 14, "CUBOCTA", 7 }, + { HEXPRISM_V, HEXPRISM_F, 12, 8, "HEXPRISM", 8 }, + { PENTAGEM_V, PENTAGEM_F, 7, 10, "PENTAGEM", 8 }, +}; + +/* Per-frame projected screen coords + rotated depth of each vertex. */ +static int16_t px[MAXV], py[MAXV], pz[MAXV]; + +/* Set one pixel across the full 64 rows: 0..7 -> status line, 8..63 -> fb. */ +static void set_pixel(int x, int y) +{ + if ((unsigned)x >= W || (unsigned)y >= H) + return; + const uint8_t bit = (uint8_t)(1u << (y & 7)); + if (y < 8) + A->status_line[x] |= bit; + else + A->fb[(y >> 3) - 1][x] |= bit; +} + +/* Integer Bresenham; dotted skips every other step for the depth cue. */ +static void draw_edge(int x0, int y0, int x1, int y1, bool dotted) +{ + const int dx = (x1 > x0 ? x1 - x0 : x0 - x1); + const int dy = -(y1 > y0 ? y1 - y0 : y0 - y1); + const int sx = (x0 < x1 ? 1 : -1); + const int sy = (y0 < y1 ? 1 : -1); + int err = dx + dy; + unsigned step = 0; + for (;;) { + if (!dotted || (step & 1u) == 0u) + set_pixel(x0, y0); + if (x0 == x1 && y0 == y1) + break; + const int e2 = 2 * err; + if (e2 >= dy) { err += dy; x0 += sx; } + if (e2 <= dx) { err += dx; y0 += sy; } + step++; + } +} + +static void clear_screen(void) +{ + for (uint8_t x = 0; x < W; x++) { + A->status_line[x] = 0; + for (uint8_t p = 0; p < 7u; p++) + A->fb[p][x] = 0; + } +} + +/* Signed area of a face's projected polygon (<0 == facing us, calibrated). */ +static int face_area(const face_t *f) +{ + int sa = 0; + for (uint8_t k = 0; k < f->n; k++) { + const uint8_t a = f->v[k]; + const uint8_t b = f->v[(k + 1u == f->n) ? 0u : k + 1u]; + sa += (int)px[a] * py[b] - (int)px[b] * py[a]; + } + return sa; +} + +__attribute__((section(".text.entry"), used)) +void app_main(const app_api_t *api) +{ + A = api; + A->backlight_on(); + A->status_clear(); + + uint16_t ax = 0, ay = 0, az = 0; /* angles in half-units (512 = full turn) */ + uint8_t shape = 0; + uint8_t speed = 6; /* 1..16, in half-units (6 == old default 3) */ + bool paused = false; + bool wire = false; /* false = solid (hidden-line) */ + bool running = true; + uint8_t prevKey = APP_KEY_INVALID; + + while (running) { + const shape_t *s = &SHAPES[shape]; + const uint8_t ia = (uint8_t)(ax >> 1), ib = (uint8_t)(ay >> 1), ic = (uint8_t)(az >> 1); + const int cx = SIN[(uint8_t)(ia + 64)], sxr = SIN[ia]; + const int cy = SIN[(uint8_t)(ib + 64)], syr = SIN[ib]; + const int cz = SIN[(uint8_t)(ic + 64)], szr = SIN[ic]; + + for (uint8_t i = 0; i < s->nv; i++) { + int x = s->v[i][0], y = s->v[i][1], z = s->v[i][2]; + int ny = (y * cx - z * sxr) >> 14; /* Rx */ + int nz = (y * sxr + z * cx) >> 14; + y = ny; z = nz; + int nx = (x * cy + z * syr) >> 14; /* Ry */ + nz = (z * cy - x * syr) >> 14; + x = nx; z = nz; + nx = (x * cz - y * szr) >> 14; /* Rz */ + ny = (x * szr + y * cz) >> 14; + x = nx; y = ny; + + const int zc = z + DIST; /* always > 0 */ + px[i] = (int16_t)(CX + (x * FOCAL) / zc); + py[i] = (int16_t)(CY + (y * FOCAL) / zc); + pz[i] = (int16_t)z; + } + + clear_screen(); + for (uint8_t i = 0; i < s->nf; i++) { + const face_t *f = &s->f[i]; + if (!wire && face_area(f) >= 0) + continue; /* hidden face culled */ + for (uint8_t k = 0; k < f->n; k++) { + const uint8_t a = f->v[k]; + const uint8_t b = f->v[(k + 1u == f->n) ? 0u : k + 1u]; + const bool dotted = wire && (pz[a] + pz[b] > 0); /* far half */ + draw_edge(px[a], py[a], px[b], py[b], dotted); + } + } + + /* Shape name: inverse label, top-left of the status bar (scan-list look). */ + const uint8_t end = (uint8_t)(2u + 4u * s->namelen); + for (uint8_t i = 0; i <= end; i++) + A->status_line[i] = 0; + A->print_inverse(s->name, 2, 0, true, true, end); + + A->blit_status(); + A->blit_full(); + + if (!paused) { + ax += speed; /* half-units/frame (level 1..16) */ + ay += (uint16_t)(speed + 2u); /* one full unit faster, as before */ + az += 2u; /* fixed 1 unit/frame tumble */ + } + + const uint8_t key = A->get_key(); + if (key != prevKey && key != APP_KEY_INVALID) { + A->backlight_on(); + switch (key) { + case APP_KEY_EXIT: + running = false; + break; + case APP_KEY_UP: + case APP_KEY_DOWN: { + const int8_t dir = A->nav_dir(key); + if (dir > 0 && speed < 16u) speed++; + if (dir < 0 && speed > 1u) speed--; + break; + } + case APP_KEY_MENU: + paused = !paused; + break; + case APP_KEY_STAR: + shape = (uint8_t)((shape + 1u) % NSHAPE); + break; + case APP_KEY_F: + wire = !wire; + break; + case APP_KEY_1: case APP_KEY_2: case APP_KEY_3: case APP_KEY_4: + case APP_KEY_5: case APP_KEY_6: case APP_KEY_7: case APP_KEY_8: + if ((uint8_t)(key - APP_KEY_1) < NSHAPE) + shape = (uint8_t)(key - APP_KEY_1); + break; + default: + break; + } + } + prevKey = key; + A->backlight_update(); + } +} From 104b7fb0b73053c1cf227659bb5c4eb2e9f5c8fe Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sat, 29 Aug 2026 13:54:44 +0200 Subject: [PATCH 42/73] Refine Apps and multiboot UI --- App/apps/app_menu.c | 165 +++++++++++++++++++++++++++++++------------- App/ui/multiboot.c | 58 ++++++++-------- 2 files changed, 144 insertions(+), 79 deletions(-) diff --git a/App/apps/app_menu.c b/App/apps/app_menu.c index 18e85e19..b77f6cd0 100644 --- a/App/apps/app_menu.c +++ b/App/apps/app_menu.c @@ -26,16 +26,48 @@ #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) +/* "F4HWN APPS" banner and the same thin separator used by the multiboot + * selector. Bit 3 leaves room for the selected-row capsule's top edge. */ +static void app_status_bar(void) { - gFrameBuffer[line][0] ^= 0x7Fu; - for (uint8_t x = 1u; x < LCD_WIDTH - 1u; x++) + UI_StatusClear(); + GUI_DisplaySmallestInverse("F4HWN APPS", 44, 0, true, true, 84); + + for (uint8_t x = 2u; x < LCD_WIDTH - 2u; x++) + gFrameBuffer[0][x] |= 0x08u; +} + +/* Bottom key hints, matching the multiboot selector. */ +static void app_key_hints(void) +{ + const uint8_t sp = 6u; + const char *act_exit = "QUIT"; + const uint8_t ae = (uint8_t)strlen(act_exit); + const uint8_t xm = 4u; + const uint8_t xe = (uint8_t)(124u - ae * 4u - sp - 16u); + + GUI_DisplaySmallestInverse("MENU", xm, 6, false, true, (uint8_t)(xm + 16u)); + GUI_DisplaySmallest("RUN", (uint8_t)(xm + 16u + sp), 49, false, true); + + GUI_DisplaySmallestInverse("EXIT", xe, 6, false, true, (uint8_t)(xe + 16u)); + GUI_DisplaySmallest(act_exit, (uint8_t)(xe + 16u + sp), 49, false, true); +} + +/* Fixed selection capsule around the primary information (the app name). + * Slot number stays in the normal font; size is plain 3x5 metadata. */ +#define APP_NAME_BOX_START 12u +#define APP_NAME_BOX_END 102u +#define APP_NAME_TEXT_X 14u + +static void app_invert_name(uint8_t line) +{ + gFrameBuffer[line][APP_NAME_BOX_START] ^= 0x7Fu; + for (uint8_t x = APP_NAME_BOX_START + 1u; x < APP_NAME_BOX_END; x++) { gFrameBuffer[line][x] ^= 0xFFu; gFrameBuffer[line - 1u][x] ^= 0x80u; } - gFrameBuffer[line][LCD_WIDTH - 1u] ^= 0x7Fu; + gFrameBuffer[line][APP_NAME_BOX_END] ^= 0x7Fu; } /* Debounced blocking key read, then wait for release (mirrors mb_get_key). */ @@ -82,6 +114,26 @@ static void app_copy(char *dst, uint8_t cap, const char *src, uint8_t src_cap) dst[n] = '\0'; } +/* Display the code payload rounded up to 0.1 KiB, so the compact value never + * understates the space occupied by the app. The space before "KB" is omitted + * because this secondary value is rendered in the tiny 3x5 font. */ +static void app_format_size(char out[6], uint32_t bytes) +{ + if (bytes > APP_OVERLAY_MAX) + { + memcpy(out, "--KB", 5u); + return; + } + + const uint16_t tenths = (uint16_t)((bytes * 10u + 1023u) / 1024u); + out[0] = (char)('0' + tenths / 10u); + out[1] = '.'; + out[2] = (char)('0' + tenths % 10u); + out[3] = 'K'; + out[4] = 'B'; + out[5] = '\0'; +} + /* Human-readable reason for an APP_LaunchOverlay / APP_ValidateSlot failure. */ static const char *app_err_text(uint8_t rc) { @@ -118,87 +170,102 @@ static void app_show_error(const char *name, uint8_t rc) 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 +/* Five visible slots; line 0 holds the separator and line 6 the key hints. */ +#define APP_MENU_ROWS 5u +#define APP_MENU_SLOT_COUNT 8u + +_Static_assert(APP_MENU_SLOT_COUNT <= APP_SLOT_COUNT, + "APP_MENU_SLOT_COUNT exceeds the physical app slot count"); 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; + /* Apps are installed from UV Studio (0x073x) into physical slots 0..N-1, + * shown here as 1..N. Keep empty slots in the list so their location is + * visible and selectable while scrolling. */ + app_header_t hdr[APP_MENU_SLOT_COUNT]; + bool installed[APP_MENU_SLOT_COUNT]; - for (uint8_t slot = 0; slot < APP_SLOT_COUNT; slot++) + for (uint8_t slot = 0; slot < APP_MENU_SLOT_COUNT; slot++) { - if (APP_SlotInfo(slot, &hdr[slot]) == APP_OK && - (hdr[slot].flags & APP_FLAG_COMMITTED)) - list[count++] = slot; + installed[slot] = APP_SlotInfo(slot, &hdr[slot]) == APP_OK && + (hdr[slot].flags & APP_FLAG_COMMITTED); } - /* Remember the cursor across open/close of the Apps menu. Clamp in case the - * installed-app set changed since we were last here. */ + /* Remember the physical slot and scrolling window across menu openings. */ static uint8_t sel = 0; static uint8_t top = 0; /* first visible row of the scrolling window */ - if (count == 0u || sel >= count) + if (sel >= APP_MENU_SLOT_COUNT || top > APP_MENU_SLOT_COUNT - APP_MENU_ROWS) 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); + app_status_bar(); /* also wipes the VFO status line (DW, battery, ...) */ - 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); + /* Slide [top, top+APP_MENU_ROWS) so it always contains the selection. */ + if (sel < top) + top = sel; + else if (sel >= (uint8_t)(top + APP_MENU_ROWS)) + top = (uint8_t)(sel - APP_MENU_ROWS + 1u); - for (uint8_t i = top; i < count && (uint8_t)(i - top) < APP_MENU_ROWS; i++) + for (uint8_t slot = top; + slot < APP_MENU_SLOT_COUNT && (uint8_t)(slot - top) < APP_MENU_ROWS; + slot++) + { + char number[2]; + char name[14]; + char size[6]; + const uint8_t visible_number = (uint8_t)(slot + 1u); + const uint8_t fbLine = (uint8_t)(slot - top + 1u); + + number[0] = (char)('0' + visible_number); + number[1] = '\0'; + + UI_PrintStringSmallNormal(number, 2u, 0, fbLine); + if (installed[slot]) { - 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); + app_copy(name, sizeof(name), hdr[slot].name, APP_NAME_LEN); + app_format_size(size, hdr[slot].code_size); + UI_PrintStringSmallNormal(name, APP_NAME_TEXT_X, 0, fbLine); + GUI_DisplaySmallest(size, + (uint8_t)(LCD_WIDTH - 2u - strlen(size) * 4u), + (uint8_t)(fbLine * 8u + 1u), false, true); } + else + { + UI_PrintStringSmallNormal("Empty", APP_NAME_TEXT_X, 0, fbLine); + } + + if (slot == sel) + app_invert_name(fbLine); } + app_key_hints(); + 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); + sel = (sel == 0u) ? (uint8_t)(APP_MENU_SLOT_COUNT - 1u) : (uint8_t)(sel - 1u); break; case KEY_DOWN: - sel = (uint8_t)((sel + 1u) % count); + sel = (uint8_t)((sel + 1u) % APP_MENU_SLOT_COUNT); break; case KEY_MENU: { - const uint8_t rc = APP_LaunchOverlay(list[sel]); /* runs until the app exits */ + if (!installed[sel]) + break; + + const uint8_t rc = APP_LaunchOverlay(sel); /* runs until the app exits */ if (rc != APP_OK) - app_show_error(hdr[list[sel]].name, rc); /* no longer silent */ + app_show_error(hdr[sel].name, rc); /* no longer silent */ app_wait_release(); break; } diff --git a/App/ui/multiboot.c b/App/ui/multiboot.c index 3d7f0a0a..8a17232b 100644 --- a/App/ui/multiboot.c +++ b/App/ui/multiboot.c @@ -85,6 +85,8 @@ static uint8_t mb_copy_slot_version(char *dst, uint8_t cap, const mb_slot_header header->fw_version[i + 1u] >= '0' && header->fw_version[i + 1u] <= '9') { + if (n + 1u < cap) + dst[n++] = 'v'; i++; while (n + 1u < cap && i < MB_VERSION_LEN) { @@ -136,15 +138,21 @@ static void mb_key_hints(const char *act_menu, const char *act_exit) GUI_DisplaySmallest(act_exit, (uint8_t)(xe + 16u + sp), 49, false, true); } -static void mb_invert_rounded_row(uint8_t line) +/* Fixed selection capsule around the firmware name. The slot index stays in + * the normal font while the version is plain 3x5 metadata. */ +#define MB_NAME_BOX_START 12u +#define MB_NAME_BOX_END 96u +#define MB_NAME_TEXT_X 14u + +static void mb_invert_name(uint8_t line) { - gFrameBuffer[line][0] ^= 0x7Fu; - for (uint8_t x = 1u; x < LCD_WIDTH - 1u; x++) + gFrameBuffer[line][MB_NAME_BOX_START] ^= 0x7Fu; + for (uint8_t x = MB_NAME_BOX_START + 1u; x < MB_NAME_BOX_END; x++) { gFrameBuffer[line][x] ^= 0xFFu; gFrameBuffer[line - 1u][x] ^= 0x80u; } - gFrameBuffer[line][LCD_WIDTH - 1u] ^= 0x7Fu; + gFrameBuffer[line][MB_NAME_BOX_END] ^= 0x7Fu; } static void mb_show_message(const char *line1, const char *line2, const char *line3) @@ -218,8 +226,8 @@ static void mb_render_slots(uint8_t selected, const mb_slot_header_t headers[MB_SLOT_COUNT], const uint8_t status[MB_SLOT_COUNT]) { - char line[19]; /* 18 glyphs max: 18 * 7 px fits from x=2 to x=126. */ - char version[MB_VERSION_LEN]; + char name[13]; + char version[8]; /* v + up to six version digits/dots in the 3x5 column. */ UI_DisplayClear(); mb_status_bar(); @@ -227,50 +235,40 @@ static void mb_render_slots(uint8_t selected, for (uint8_t slot = 0; slot < MB_SLOT_COUNT; slot++) { const uint8_t fbLine = (uint8_t)(slot + 1u); /* page 1 stays blank */ + char index[2]; uint8_t version_len = 0; uint8_t version_x = 0; - memset(line, 0, sizeof(line)); + memset(name, 0, sizeof(name)); memset(version, 0, sizeof(version)); /* Slot 0 is the auto-backed-up main firmware: label it 'M' (Main) so it * reads apart from the numbered user slots 1..4. */ - line[0] = (slot == 0u) ? 'M' : (char)('0' + slot); - line[1] = ' '; - line[2] = ' '; + index[0] = (slot == 0u) ? 'M' : (char)('0' + slot); + index[1] = '\0'; if (status[slot] == MB_OK) { - uint8_t name_cap = sizeof(line) - 3u; - version_len = mb_copy_slot_version(version, sizeof(version), &headers[slot]); if (version_len) - { - const uint8_t name_x = 2u + 3u * 7u; - uint8_t available; - - version_x = (uint8_t)(LCD_WIDTH - 2u - version_len * 7u); - available = version_x > name_x - ? (uint8_t)((version_x - name_x) / 7u) - : 0u; - if (available + 1u < name_cap) - name_cap = available + 1u; - } + version_x = (uint8_t)(LCD_WIDTH - 2u - version_len * 4u); if (headers[slot].name[0]) - mb_copy_label(&line[3], name_cap, headers[slot].name, MB_NAME_LEN); + mb_copy_label(name, sizeof(name), headers[slot].name, MB_NAME_LEN); else if (!version_len) - mb_copy_label(&line[3], name_cap, headers[slot].fw_version, MB_VERSION_LEN); + mb_copy_label(name, sizeof(name), headers[slot].fw_version, MB_VERSION_LEN); } else - mb_copy_label(&line[3], sizeof(line) - 3u, mb_error_text(status[slot]), 20u); + mb_copy_label(name, sizeof(name), mb_error_text(status[slot]), 20u); - UI_PrintStringSmallNormal(line, 2, 0, fbLine); + UI_PrintStringSmallNormal(index, 2u, 0, fbLine); + UI_PrintStringSmallNormal(name, MB_NAME_TEXT_X, 0, fbLine); if (version_len) - UI_PrintStringSmallNormal(version, version_x, 0, fbLine); + GUI_DisplaySmallest(version, version_x, + (uint8_t)(fbLine * 8u + 1u), false, true); - /* Selected row: full-width rounded inverse capsule. */ + /* Selected row: fixed rounded inverse capsule around the name only. */ if (slot == selected) - mb_invert_rounded_row(fbLine); + mb_invert_name(fbLine); } mb_key_hints("SELECT", "QUIT"); From b86da168e9799454f4c27477a8ad94b55f37994d Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 30 Aug 2026 00:38:51 +0200 Subject: [PATCH 43/73] Improve apps --- App/apps/cube3d/cube3d_app.c | 74 ++++++++++++++++++------------------ App/apps/plasma/plasma_app.c | 2 +- 2 files changed, 39 insertions(+), 37 deletions(-) diff --git a/App/apps/cube3d/cube3d_app.c b/App/apps/cube3d/cube3d_app.c index 68f535bf..2b5a799f 100644 --- a/App/apps/cube3d/cube3d_app.c +++ b/App/apps/cube3d/cube3d_app.c @@ -45,34 +45,27 @@ static const app_api_t *A; -/* Q14 sine: round(16384 * sin(2*pi*i/256)). cos(i) == SIN[(i + 64) & 255]. */ -static const int16_t SIN[256] = { - 0, 402, 804, 1205, 1606, 2006, 2404, 2801, 3196, 3590, 3981, - 4370, 4756, 5139, 5520, 5897, 6270, 6639, 7005, 7366, 7723, 8076, - 8423, 8765, 9102, 9434, 9760, 10080, 10394, 10702, 11003, 11297, 11585, - 11866, 12140, 12406, 12665, 12916, 13160, 13395, 13623, 13842, 14053, 14256, - 14449, 14635, 14811, 14978, 15137, 15286, 15426, 15557, 15679, 15791, 15893, - 15986, 16069, 16143, 16207, 16261, 16305, 16340, 16364, 16379, 16384, 16379, - 16364, 16340, 16305, 16261, 16207, 16143, 16069, 15986, 15893, 15791, 15679, - 15557, 15426, 15286, 15137, 14978, 14811, 14635, 14449, 14256, 14053, 13842, - 13623, 13395, 13160, 12916, 12665, 12406, 12140, 11866, 11585, 11297, 11003, - 10702, 10394, 10080, 9760, 9434, 9102, 8765, 8423, 8076, 7723, 7366, - 7005, 6639, 6270, 5897, 5520, 5139, 4756, 4370, 3981, 3590, 3196, - 2801, 2404, 2006, 1606, 1205, 804, 402, 0, -402, -804, -1205, - -1606, -2006, -2404, -2801, -3196, -3590, -3981, -4370, -4756, -5139, -5520, - -5897, -6270, -6639, -7005, -7366, -7723, -8076, -8423, -8765, -9102, -9434, - -9760,-10080,-10394,-10702,-11003,-11297,-11585,-11866,-12140,-12406,-12665, - -12916,-13160,-13395,-13623,-13842,-14053,-14256,-14449,-14635,-14811,-14978, - -15137,-15286,-15426,-15557,-15679,-15791,-15893,-15986,-16069,-16143,-16207, - -16261,-16305,-16340,-16364,-16379,-16384,-16379,-16364,-16340,-16305,-16261, - -16207,-16143,-16069,-15986,-15893,-15791,-15679,-15557,-15426,-15286,-15137, - -14978,-14811,-14635,-14449,-14256,-14053,-13842,-13623,-13395,-13160,-12916, - -12665,-12406,-12140,-11866,-11585,-11297,-11003,-10702,-10394,-10080, -9760, - -9434, -9102, -8765, -8423, -8076, -7723, -7366, -7005, -6639, -6270, -5897, - -5520, -5139, -4756, -4370, -3981, -3590, -3196, -2801, -2404, -2006, -1606, - -1205, -804, -402, +/* One Q14 sine quadrant. Symmetry recovers the full 256-step wave while saving + * 382 bytes for the renderer. */ +static const int16_t SIN_Q[65] = { + 0, 402, 804, 1205, 1606, 2006, 2404, 2801, 3196, 3590, 3981, + 4370, 4756, 5139, 5520, 5897, 6270, 6639, 7005, 7366, 7723, 8076, + 8423, 8765, 9102, 9434, 9760,10080,10394,10702,11003,11297,11585, + 11866,12140,12406,12665,12916,13160,13395,13623,13842,14053,14256, + 14449,14635,14811,14978,15137,15286,15426,15557,15679,15791,15893, + 15986,16069,16143,16207,16261,16305,16340,16364,16379,16384 }; +static int sin8(uint8_t angle) +{ + const uint8_t quadrant = angle >> 6; + uint8_t i = angle & 63u; + if (quadrant & 1u) + i = (uint8_t)(64u - i); + const int value = SIN_Q[i]; + return quadrant >= 2u ? -value : value; +} + /* A face is a polygon of up to 6 vertex indices, wound CCW as seen from outside * (generated offline by the hull extractor, so the signed-area cull sign is the * same for every solid). Unused slots are 0-padded and ignored (n gives length). */ @@ -126,6 +119,13 @@ static const shape_t SHAPES[NSHAPE] = { /* Per-frame projected screen coords + rotated depth of each vertex. */ static int16_t px[MAXV], py[MAXV], pz[MAXV]; +/* Quarter-half-units per frame. The low end has fractional angular steps; + * level 16 reaches the old 16 half-units/frame once divided by four. */ +static const uint8_t ROT_RATE[16] = { + 1, 2, 3, 4, 6, 8, 10, 12, + 16, 20, 24, 30, 36, 44, 52, 64 +}; + /* Set one pixel across the full 64 rows: 0..7 -> status line, 8..63 -> fb. */ static void set_pixel(int x, int y) { @@ -187,20 +187,20 @@ void app_main(const app_api_t *api) A->backlight_on(); A->status_clear(); - uint16_t ax = 0, ay = 0, az = 0; /* angles in half-units (512 = full turn) */ + uint16_t ax = 0, ay = 0, az = 0; /* Q2 half-units: 2048 = full turn */ uint8_t shape = 0; - uint8_t speed = 6; /* 1..16, in half-units (6 == old default 3) */ + uint8_t speed = 4; /* 1..16, shared by all three rotation axes */ bool paused = false; - bool wire = false; /* false = solid (hidden-line) */ + bool wire = true; /* false = solid (hidden-line) */ bool running = true; uint8_t prevKey = APP_KEY_INVALID; while (running) { const shape_t *s = &SHAPES[shape]; - const uint8_t ia = (uint8_t)(ax >> 1), ib = (uint8_t)(ay >> 1), ic = (uint8_t)(az >> 1); - const int cx = SIN[(uint8_t)(ia + 64)], sxr = SIN[ia]; - const int cy = SIN[(uint8_t)(ib + 64)], syr = SIN[ib]; - const int cz = SIN[(uint8_t)(ic + 64)], szr = SIN[ic]; + const uint8_t ia = (uint8_t)(ax >> 3), ib = (uint8_t)(ay >> 3), ic = (uint8_t)(az >> 3); + const int cx = sin8((uint8_t)(ia + 64u)), sxr = sin8(ia); + const int cy = sin8((uint8_t)(ib + 64u)), syr = sin8(ib); + const int cz = sin8((uint8_t)(ic + 64u)), szr = sin8(ic); for (uint8_t i = 0; i < s->nv; i++) { int x = s->v[i][0], y = s->v[i][1], z = s->v[i][2]; @@ -243,9 +243,10 @@ void app_main(const app_api_t *api) A->blit_full(); if (!paused) { - ax += speed; /* half-units/frame (level 1..16) */ - ay += (uint16_t)(speed + 2u); /* one full unit faster, as before */ - az += 2u; /* fixed 1 unit/frame tumble */ + const uint16_t rate = ROT_RATE[speed - 1u]; + ax += rate; + ay += (uint16_t)(rate + rate / 2u); + az += (uint16_t)((rate + 1u) / 2u); } const uint8_t key = A->get_key(); @@ -282,5 +283,6 @@ void app_main(const app_api_t *api) } prevKey = key; A->backlight_update(); + A->delay_ms((uint32_t)(32u - speed * 2u)); /* slow low end, no added delay at level 16 */ } } diff --git a/App/apps/plasma/plasma_app.c b/App/apps/plasma/plasma_app.c index fdfb4e47..ccc9e826 100644 --- a/App/apps/plasma/plasma_app.c +++ b/App/apps/plasma/plasma_app.c @@ -85,7 +85,7 @@ void app_main(const app_api_t *api) uint16_t autoCtr = 0; uint8_t speed = 3; uint8_t var = 0; - bool bands = false; + bool bands = true; bool autoc = false; bool paused = false; bool running = true; From 8c78d28f0d7be3e54b74c72aa42075f285c3beb6 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 30 Aug 2026 12:45:36 +0200 Subject: [PATCH 44/73] Add TripleVFO, the king of clickbait... --- App/apps/app_api.h | 58 ++++- App/apps/app_overlay.c | 408 +++++++++++++++++++++++++++++++++++ App/apps/trivfo/app.ld | 12 ++ App/apps/trivfo/build.sh | 20 ++ App/apps/trivfo/trivfo_app.c | 254 ++++++++++++++++++++++ 5 files changed, 748 insertions(+), 4 deletions(-) create mode 100644 App/apps/trivfo/app.ld create mode 100755 App/apps/trivfo/build.sh create mode 100644 App/apps/trivfo/trivfo_app.c diff --git a/App/apps/app_api.h b/App/apps/app_api.h index 58188e31..5396f48c 100644 --- a/App/apps/app_api.h +++ b/App/apps/app_api.h @@ -36,11 +36,11 @@ #include #include -/* ABI 3: 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 +/* The table below 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 the + * value read by pack_app.py, which * stamps the blob the loader checks against. */ -#define APP_ABI_VERSION 3u +#define APP_ABI_VERSION 4u /* 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. */ @@ -61,6 +61,7 @@ enum { APP_KEY_EXIT = 13, APP_KEY_STAR = 14, APP_KEY_F = 15, + APP_KEY_PTT = 16, APP_KEY_INVALID = 19, }; @@ -78,6 +79,43 @@ typedef struct { uint8_t sel_ch; /* selected memory channel 0..47 */ } app_fm_state_t; +/* Compact, pointer-free description of one receiver in the resident triple-VFO + * service. Overlay apps must never see VFO_Info_t directly: its layout varies + * with firmware features and contains resident pointers. */ +typedef struct { + uint32_t frequency; /* RX frequency, x10 Hz */ + uint16_t channel; /* memory channel, zero based */ + uint16_t step; /* step, 10 Hz units */ + uint16_t code_value; /* CTCSS x0.1 Hz or DCS octal source value */ + int16_t rssi_dbm; /* last/current corrected RSSI */ + uint8_t modulation; + uint8_t power; + uint8_t bandwidth; + uint8_t code_type; + uint8_t code; + uint8_t offset_direction; + uint8_t reverse; + uint8_t squelch; + uint8_t flags; /* APP_TRIVFO_* below */ + char name[11]; /* channel name, trimmed and NUL terminated */ +} app_trivfo_info_t; + +enum { + APP_TRIVFO_SELECTED = 1u << 0, + APP_TRIVFO_TUNED = 1u << 1, + APP_TRIVFO_RECEIVING = 1u << 2, + APP_TRIVFO_TX = 1u << 3, + APP_TRIVFO_USER_POWER = 1u << 4, + APP_TRIVFO_AUDIO_BAR = 1u << 5, +}; + +enum { + APP_TRIVFO_SCAN = 0, + APP_TRIVFO_RX = 1, + APP_TRIVFO_HOLD = 2, + APP_TRIVFO_TX_STATE = 3, +}; + typedef struct app_api { uint8_t abi_version; /* == APP_ABI_VERSION */ @@ -174,6 +212,18 @@ typedef struct app_api { * UV-K1 LEFT/RIGHT -> -1/+1 * Returns 0 for any other key. Keep get_key() raw for spatial controls. */ int8_t (*nav_dir)(uint8_t key); + + /* ---- triple VFO (ABI 4) ---- + * A and B are the live Main Display VFOs. C is a resident temporary VFO + * loaded from c_channel (or the first valid memory after B when invalid). + * tick is called every 20 ms by the app and returns APP_TRIVFO_* state. */ + uint16_t (*trivfo_enter)(uint16_t c_channel); + void (*trivfo_leave)(void); + void (*trivfo_get)(uint8_t vfo, app_trivfo_info_t *info); + void (*trivfo_select)(uint8_t vfo); + uint16_t (*trivfo_step)(uint8_t vfo, int8_t direction); + uint8_t (*trivfo_tick)(void); + uint8_t (*trivfo_ptt)(bool pressed); /* 0 OK; non-zero TX denial/timeout */ } app_api_t; /* BK4819 AF modes for set_af (mirror driver/bk4819.h values). */ diff --git a/App/apps/app_overlay.c b/App/apps/app_overlay.c index 82190092..15fff980 100644 --- a/App/apps/app_overlay.c +++ b/App/apps/app_overlay.c @@ -32,10 +32,14 @@ #include "driver/st7565.h" #include "driver/system.h" #include "driver/backlight.h" +#include "app/app.h" #include "ui/helper.h" #include "ui/status.h" #include "board.h" #include "audio.h" +#include "dcs.h" +#include "functions.h" +#include "frequencies.h" #include "radio.h" #include "helper/battery.h" #include "settings.h" @@ -91,6 +95,382 @@ static uint32_t app_make_seed(void) * gEeprom.VfoInfo[0].pRX->Frequency; } +/* ---- ABI 4: resident triple-VFO engine --------------------------------- + * The overlay owns the UI and key timing, while this resident engine owns all + * radio details. Keeping VFO_Info_t and BK4819 sequencing on this side makes + * the app independent of feature-dependent firmware layouts. */ +#define APP_TRIVFO_COUNT 3u +#define APP_TRIVFO_TUNE_TICKS 5u /* 100 ms at one tick / 20 ms */ +#define APP_TRIVFO_TX_HOLD_TICKS 125u /* keep the existing 2.5 s TX return */ + +static VFO_Info_t app_trivfo_c; +static VFO_Info_t *app_trivfo_saved_rx; +static VFO_Info_t *app_trivfo_saved_tx; +static VFO_Info_t *app_trivfo_saved_current; +static uint8_t app_trivfo_selected; +static uint8_t app_trivfo_pre_rx_selected; +static uint8_t app_trivfo_current; +static uint8_t app_trivfo_settle; +static uint16_t app_trivfo_hold; +static uint8_t app_trivfo_candidate_wait; +static uint32_t app_trivfo_tx_ticks; +static bool app_trivfo_running; +static bool app_trivfo_receiving; +static bool app_trivfo_transmitting; +static bool app_trivfo_sql_open; +static bool app_trivfo_ctcss_ok; +static bool app_trivfo_cdcss_ok; +static bool app_trivfo_ab_dirty; +static bool app_trivfo_restore_selection; +static uint8_t app_trivfo_freq_dirty; + +static VFO_Info_t *app_trivfo_vfo(uint8_t vfo) +{ + return vfo < 2u ? &gEeprom.VfoInfo[vfo] : &app_trivfo_c; +} + +static bool app_trivfo_load_memory(VFO_Info_t *vfo, uint16_t channel) +{ + ChannelScanDisplayInfo_t info; + if (!IS_MR_CHANNEL(channel) || + !SETTINGS_FetchChannelScanDisplayInfo(channel, &info)) + return false; + + RADIO_InitInfo(vfo, channel, info.rx.Frequency); + vfo->freq_config_RX = info.rx; + vfo->freq_config_TX = info.tx; + vfo->TX_OFFSET_FREQUENCY = info.offset; + vfo->StepFrequency = info.stepFrequency; + vfo->STEP_SETTING = info.stepSetting; + vfo->Modulation = info.modulation; + vfo->TX_OFFSET_FREQUENCY_DIRECTION = info.txOffsetFrequencyDirection; + vfo->OUTPUT_POWER = info.outputPower; + vfo->FrequencyReverse = info.frequencyReverse; + vfo->CHANNEL_BANDWIDTH = info.channelBandwidth; + vfo->BUSY_CHANNEL_LOCK = info.busyChannelLock; + vfo->TX_LOCK = info.txLock; +#ifdef ENABLE_DTMF_CALLING + vfo->DTMF_DECODING_ENABLE = info.dtmfDecodingEnable; +#endif + vfo->DTMF_PTT_ID_TX_MODE = info.dtmfPttIdTxMode; + vfo->Band = FREQUENCY_GetBand(vfo->freq_config_RX.Frequency); + vfo->Compander = MR_GetChannelAttributes(channel)->compander; + SETTINGS_FetchChannelName(vfo->Name, channel); + vfo->pRX = vfo->FrequencyReverse ? &vfo->freq_config_TX : &vfo->freq_config_RX; + vfo->pTX = vfo->FrequencyReverse ? &vfo->freq_config_RX : &vfo->freq_config_TX; + RADIO_ConfigureSquelchAndOutputPower(vfo); + return true; +} + +static uint16_t app_trivfo_next_channel(uint16_t channel, int8_t direction, uint8_t vfo) +{ + if (direction == 0) + direction = 1; + channel = RADIO_FindNextChannel((uint16_t)(channel + direction), direction, + false, vfo < 2u ? vfo : 0u); + return channel; +} + +static void app_trivfo_tune(uint8_t vfo) +{ + app_trivfo_current = vfo % APP_TRIVFO_COUNT; + gRxVfo = app_trivfo_vfo(app_trivfo_current); + gCurrentVfo = gRxVfo; + app_trivfo_receiving = false; + app_trivfo_sql_open = false; + app_trivfo_ctcss_ok = false; + app_trivfo_cdcss_ok = false; + app_trivfo_candidate_wait = 0; + app_trivfo_settle = APP_TRIVFO_TUNE_TICKS; + AUDIO_AudioPathOff(); + gEnableSpeaker = false; + RADIO_SetupRegisters(false); + FUNCTION_Init(); +} + +static void app_trivfo_poll_irq(void) +{ + while (BK4819_ReadRegister(BK4819_REG_0C) & 1u) { + BK4819_WriteRegister(BK4819_REG_02, 0); + const uint16_t irq = BK4819_ReadRegister(BK4819_REG_02); + if (irq & BK4819_REG_02_SQUELCH_LOST) app_trivfo_sql_open = true; + if (irq & BK4819_REG_02_SQUELCH_FOUND) { + app_trivfo_sql_open = false; + app_trivfo_ctcss_ok = false; + app_trivfo_cdcss_ok = false; + } + + /* A BK4819 CSS interrupt is a transition, not a persistent level. + * Depending on the silicon revision and the configured polarity, the + * first transition can be reported as FOUND or LOST. MAIN keeps the + * decoder state across both transitions; do the same here and use any + * CSS transition as proof that the configured decoder has acquired the + * signal. A wrong CTCSS/DCS does not generate either transition. */ + if (irq & (BK4819_REG_02_CTCSS_LOST | BK4819_REG_02_CTCSS_FOUND)) + app_trivfo_ctcss_ok = true; + if (irq & (BK4819_REG_02_CDCSS_LOST | BK4819_REG_02_CDCSS_FOUND)) + app_trivfo_cdcss_ok = true; + } +} + +static bool app_trivfo_qualified(void) +{ + const VFO_Info_t *vfo = app_trivfo_vfo(app_trivfo_current); + if (!app_trivfo_sql_open) + return false; + if (vfo->Modulation != MODULATION_FM || vfo->pRX->CodeType == CODE_TYPE_OFF) + return true; + if (vfo->pRX->CodeType == CODE_TYPE_CONTINUOUS_TONE) + return app_trivfo_ctcss_ok; + return app_trivfo_cdcss_ok; +} + +static uint16_t app_trivfo_enter(uint16_t c_channel) +{ + app_trivfo_saved_rx = gRxVfo; + app_trivfo_saved_tx = gTxVfo; + app_trivfo_saved_current = gCurrentVfo; + + if (!IS_MR_CHANNEL(c_channel) || + !SETTINGS_FetchChannelScanInfo(c_channel, NULL, NULL)) { + uint16_t start = IS_MR_CHANNEL(gEeprom.ScreenChannel[1]) + ? gEeprom.ScreenChannel[1] : gEeprom.MrChannel[1]; + c_channel = app_trivfo_next_channel(start, 1, 2); + } + if (c_channel == 0xFFFFu || !app_trivfo_load_memory(&app_trivfo_c, c_channel)) { + c_channel = RADIO_FindNextChannel(MR_CHANNEL_FIRST, RADIO_CHANNEL_UP, false, 0); + if (c_channel != 0xFFFFu) + app_trivfo_load_memory(&app_trivfo_c, c_channel); + } + + app_trivfo_selected = 0; + app_trivfo_pre_rx_selected = 0; + app_trivfo_restore_selection = false; + app_trivfo_hold = 0; + app_trivfo_running = true; + app_trivfo_transmitting = false; + app_trivfo_ab_dirty = false; + app_trivfo_freq_dirty = 0; + app_trivfo_tune(0); + return c_channel; +} + +static void app_trivfo_leave(void) +{ + if (!app_trivfo_running) + return; + if (app_trivfo_transmitting) { + BK4819_ToggleGpioOut(BK4819_GPIO1_PIN29_PA_ENABLE, false); + BK4819_ToggleGpioOut(BK4819_GPIO5_PIN1_RED, false); + } + AUDIO_AudioPathOff(); + gEnableSpeaker = false; + app_trivfo_running = false; + app_trivfo_transmitting = false; + gRxVfo = app_trivfo_saved_rx; + gTxVfo = app_trivfo_saved_tx; + gCurrentVfo = app_trivfo_saved_current; +} + +static void app_trivfo_get(uint8_t index, app_trivfo_info_t *info) +{ + if (info == NULL || index >= APP_TRIVFO_COUNT) + return; + const VFO_Info_t *vfo = app_trivfo_vfo(index); + memset(info, 0, sizeof(*info)); + info->frequency = vfo->pRX->Frequency; + info->channel = vfo->CHANNEL_SAVE; + info->step = vfo->StepFrequency; + if (vfo->pRX->CodeType == CODE_TYPE_CONTINUOUS_TONE) + info->code_value = CTCSS_Options[vfo->pRX->Code]; + else if (vfo->pRX->CodeType == CODE_TYPE_DIGITAL || + vfo->pRX->CodeType == CODE_TYPE_REVERSE_DIGITAL) + info->code_value = DCS_Options[vfo->pRX->Code]; + info->rssi_dbm = (index == app_trivfo_current) + ? BK4819_GetRSSI_dBm() + dBmCorrTable[vfo->Band] : -160; + info->modulation = vfo->Modulation; + info->power = vfo->OUTPUT_POWER == OUTPUT_POWER_USER + ? (uint8_t)(gSetting_set_pwr + 1u) : vfo->OUTPUT_POWER; + info->bandwidth = vfo->CHANNEL_BANDWIDTH; +#ifdef ENABLE_FEAT_F4HWN_NARROWER + if (info->bandwidth == BANDWIDTH_NARROW && gSetting_set_nfm == 1) + info->bandwidth++; +#endif + info->code_type = vfo->pRX->CodeType; + info->code = vfo->pRX->Code; + info->offset_direction = vfo->TX_OFFSET_FREQUENCY_DIRECTION; + info->reverse = vfo->FrequencyReverse; + info->squelch = gEeprom.SQUELCH_LEVEL; + if (index == app_trivfo_selected) info->flags |= APP_TRIVFO_SELECTED; + if (index == app_trivfo_current) info->flags |= APP_TRIVFO_TUNED; + if (index == app_trivfo_current && app_trivfo_receiving) info->flags |= APP_TRIVFO_RECEIVING; + if (index == app_trivfo_selected && app_trivfo_transmitting) info->flags |= APP_TRIVFO_TX; + if (vfo->OUTPUT_POWER == OUTPUT_POWER_USER) info->flags |= APP_TRIVFO_USER_POWER; +#ifdef ENABLE_AUDIO_BAR + if (gSetting_mic_bar) info->flags |= APP_TRIVFO_AUDIO_BAR; +#endif + if (IS_MR_CHANNEL(vfo->CHANNEL_SAVE)) + memcpy(info->name, vfo->Name, sizeof(info->name) - 1u); +} + +static void app_trivfo_select(uint8_t vfo) +{ + if (vfo < APP_TRIVFO_COUNT) + app_trivfo_selected = vfo; +} + +static uint16_t app_trivfo_step(uint8_t index, int8_t direction) +{ + if (index >= APP_TRIVFO_COUNT || app_trivfo_transmitting) + return 0xFFFFu; + VFO_Info_t *vfo = app_trivfo_vfo(index); + + if (IS_FREQ_CHANNEL(vfo->CHANNEL_SAVE)) { + if (direction == 0) + direction = 1; + const uint32_t frequency = APP_SetFrequencyByStep(vfo, direction); + if (RX_freq_check(frequency) < 0) + return 0xFFFFu; + + vfo->freq_config_RX.Frequency = frequency; + RADIO_ApplyOffset(vfo); + RADIO_ConfigureSquelchAndOutputPower(vfo); + if (index < 2u) + app_trivfo_freq_dirty |= (uint8_t)(1u << index); + app_trivfo_hold = 0; + app_trivfo_tune(index); + return vfo->CHANNEL_SAVE; + } + + uint16_t base = IS_MR_CHANNEL(vfo->CHANNEL_SAVE) ? vfo->CHANNEL_SAVE + : (index < 2u ? gEeprom.MrChannel[index] : gEeprom.MrChannel[1]); + const uint16_t channel = app_trivfo_next_channel(base, direction, index); + if (channel == 0xFFFFu) + return channel; + if (index < 2u) { + gEeprom.ScreenChannel[index] = channel; + gEeprom.MrChannel[index] = channel; + RADIO_ConfigureChannel(index, VFO_CONFIGURE_RELOAD); + app_trivfo_ab_dirty = true; + } else { + app_trivfo_load_memory(&app_trivfo_c, channel); + } + app_trivfo_hold = 0; + app_trivfo_tune(index); + return channel; +} + +static uint8_t app_trivfo_ptt(bool pressed); + +static uint8_t app_trivfo_tick(void) +{ + if (!app_trivfo_running) + return APP_TRIVFO_SCAN; + if (app_trivfo_transmitting) { + const uint32_t timeout = ((uint32_t)gEeprom.TX_TIMEOUT_TIMER + 1u) * 250u; + if (++app_trivfo_tx_ticks >= timeout) { + app_trivfo_ptt(false); + return APP_TRIVFO_HOLD; + } + return APP_TRIVFO_TX_STATE; + } + + app_trivfo_poll_irq(); + if (app_trivfo_settle > 0) { + app_trivfo_settle--; + return APP_TRIVFO_SCAN; + } + + const bool qualified = app_trivfo_qualified(); + if (qualified) { + app_trivfo_hold = 0; + if (!app_trivfo_receiving) { + if (app_trivfo_selected != app_trivfo_current) { + app_trivfo_pre_rx_selected = app_trivfo_selected; + app_trivfo_restore_selection = true; + app_trivfo_selected = app_trivfo_current; + } + app_trivfo_receiving = true; + AUDIO_AudioPathOn(); + gEnableSpeaker = true; + BK4819_SetRxAudioGain(); + /* BK4819_SetupSquelch() ends by selecting AF_MUTE. Mirror + * APP_StartListening(): restore the channel demodulator only once + * the carrier/CSS has qualified. */ + RADIO_SetModulation(gRxVfo->Modulation); + BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, true); + } + return APP_TRIVFO_RX; + } + + if (app_trivfo_receiving) { + app_trivfo_receiving = false; + AUDIO_AudioPathOff(); + gEnableSpeaker = false; + BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, false); + /* Use the same receive-response dwell as resident DWR. The resident + * value is expressed in 10 ms ticks; Triple VFO ticks every 20 ms. */ + app_trivfo_hold = (dual_watch_count_after_2_10ms + 1u) / 2u; + app_trivfo_candidate_wait = 50u; + } + if (app_trivfo_hold > 0) { + app_trivfo_hold--; + if (app_trivfo_hold == 0 && app_trivfo_restore_selection) { + app_trivfo_selected = app_trivfo_pre_rx_selected; + app_trivfo_restore_selection = false; + } + return APP_TRIVFO_HOLD; + } + + /* Like resident dual watch, give a coded carrier time to acquire its CSS. + * A wrong tone/code must not monopolise the receiver indefinitely. */ + if (app_trivfo_sql_open && app_trivfo_candidate_wait < 50u) { + app_trivfo_candidate_wait++; + return APP_TRIVFO_HOLD; + } + + app_trivfo_tune((uint8_t)((app_trivfo_current + 1u) % APP_TRIVFO_COUNT)); + return APP_TRIVFO_SCAN; +} + +static uint8_t app_trivfo_ptt(bool pressed) +{ + if (!app_trivfo_running) + return 1; + if (!pressed) { + if (!app_trivfo_transmitting) + return 0; + RADIO_SendEndOfTransmission(); + app_trivfo_transmitting = false; + BK4819_ToggleGpioOut(BK4819_GPIO5_PIN1_RED, false); + app_trivfo_hold = APP_TRIVFO_TX_HOLD_TICKS; + app_trivfo_tune(app_trivfo_selected); + return 0; + } + if (app_trivfo_transmitting) + return 0; + + VFO_Info_t *vfo = app_trivfo_vfo(app_trivfo_selected); + if ((TX_freq_check(vfo->pTX->Frequency) != 0 && vfo->TX_LOCK) || + vfo->Modulation != MODULATION_FM || + (vfo->BUSY_CHANNEL_LOCK && app_trivfo_receiving) || + gBatteryDisplayLevel == 0 || gBatteryDisplayLevel > 6) + return 1; + + AUDIO_AudioPathOff(); + gEnableSpeaker = false; + BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, false); + gRxVfo = gTxVfo = gCurrentVfo = vfo; + RADIO_SetTxParameters(); + BK4819_ToggleGpioOut(BK4819_GPIO5_PIN1_RED, true); + BK4819_DisableScramble(); + app_trivfo_current = app_trivfo_selected; + app_trivfo_receiving = false; + app_trivfo_transmitting = true; + app_trivfo_tx_ticks = 0; + return 0; +} + /* ---- 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); } @@ -133,6 +513,12 @@ static void app_draw_battery(void) } static void app_battery_sample(void) { + /* The resident scheduler deliberately skips ADC battery updates while the + * PA is keyed. Do the same for Triple VFO: sampling the loaded voltage as + * capacity made an 80% pack appear to fall immediately to about 16%. */ + if (app_trivfo_transmitting) + return; + BOARD_ADC_GetBatteryInfo(&gBatteryVoltages[gBatteryVoltageIndex++], &gBatteryCurrent); if (gBatteryVoltageIndex > 3) gBatteryVoltageIndex = 0; @@ -334,6 +720,13 @@ uint8_t APP_LaunchOverlay(uint8_t slot) .fm_commit = app_fm_commit, #endif .nav_dir = app_nav_dir, + .trivfo_enter = app_trivfo_enter, + .trivfo_leave = app_trivfo_leave, + .trivfo_get = app_trivfo_get, + .trivfo_select = app_trivfo_select, + .trivfo_step = app_trivfo_step, + .trivfo_tick = app_trivfo_tick, + .trivfo_ptt = app_trivfo_ptt, }; app_run_slot = slot; /* for cfg_load / cfg_save */ @@ -359,6 +752,9 @@ uint8_t APP_LaunchOverlay(uint8_t slot) app_entry_t entry = (app_entry_t)(((uint32_t)ws + h.entry_off) | 1u); entry(&api); + /* A defensive leave also covers an app returning through an error path. */ + app_trivfo_leave(); + /* Restore the resident RX/dual-watch tuning the app ran on top of. */ gEeprom.RX_VFO = saved_rx_vfo; gRxVfo = saved_rx; @@ -367,6 +763,18 @@ uint8_t APP_LaunchOverlay(uint8_t slot) /* The overlay held app code, not a valid config sector. */ PY25Q16_InvalidateCache(); + if (app_trivfo_ab_dirty) { + SETTINGS_SaveVfoIndices(); + app_trivfo_ab_dirty = false; + } + + for (uint8_t i = 0; i < 2u; i++) { + if (app_trivfo_freq_dirty & (1u << i)) + SETTINGS_SaveChannel(gEeprom.VfoInfo[i].CHANNEL_SAVE, i, + &gEeprom.VfoInfo[i], 1); + } + app_trivfo_freq_dirty = 0; + /* 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); diff --git a/App/apps/trivfo/app.ld b/App/apps/trivfo/app.ld new file mode 100644 index 00000000..32ba57b3 --- /dev/null +++ b/App/apps/trivfo/app.ld @@ -0,0 +1,12 @@ +APP_VMA = DEFINED(APP_VMA) ? APP_VMA : 0x20000280; +APP_LENGTH = 0x1000; +ENTRY(app_main) +MEMORY { APP (rwx) : ORIGIN = APP_VMA, LENGTH = APP_LENGTH } +SECTIONS { + .app APP_VMA : { + KEEP(*(.text.entry)) *(.text .text.*) *(.rodata .rodata.*) + . = ALIGN(4); *(.data .data.*) . = ALIGN(4); *(.bss .bss.* COMMON) . = ALIGN(4); + } > APP + ASSERT(. <= APP_VMA + APP_LENGTH, "Triple VFO overlay app overflows the 4 KiB overlay") + /DISCARD/ : { *(.ARM.exidx*) *(.ARM.extab*) *(.eh_frame*) *(.comment) *(.note.*) } +} diff --git a/App/apps/trivfo/build.sh b/App/apps/trivfo/build.sh new file mode 100755 index 00000000..dc6cc7a7 --- /dev/null +++ b/App/apps/trivfo/build.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail +APP="$(basename "$PWD")" +APP_NAME="Triple VFO" +APP_VER="1.0" +APP_VMA=${APP_VMA:-0x20000280} +OUT="${APP_NAME// /}" +CC=/opt/toolchain/bin/arm-none-eabi-gcc +OBJCOPY=/opt/toolchain/bin/arm-none-eabi-objcopy +command -v arm-none-eabi-gcc >/dev/null 2>&1 && { CC=arm-none-eabi-gcc; OBJCOPY=arm-none-eabi-objcopy; } +CFLAGS="-mcpu=cortex-m0plus -mthumb -Os -std=gnu11 -ffreestanding -fno-builtin -fno-common -fomit-frame-pointer -ffunction-sections -fdata-sections -Wall -Wextra" +LDFLAGS="-nostdlib -nostartfiles -T app.ld -Wl,--defsym,APP_VMA=${APP_VMA} -Wl,--gc-sections -Wl,-Map=${APP}.map -Wl,--build-id=none -Wl,--no-warn-rwx-segments" +rm -f ./*.app ./*.elf ./*.bin +"$CC" $CFLAGS $LDFLAGS "${APP}_app.c" -lgcc -o "${APP}.elf" +"$OBJCOPY" -O binary "${APP}.elf" "${APP}.bin" +python3 ../pack_app.py "${APP}.bin" "${OUT}.app" --name "$APP_NAME" --ver "$APP_VER" --vma "${APP_VMA}" >/dev/null +BYTES=$(wc -c < "${APP}.bin") +test "$BYTES" -le 4096 +printf ' ✅ %-13s %4d B (%d%% of 4 KiB) -> %s.app\n' \ + "$APP_NAME" "$BYTES" "$(( BYTES * 100 / 4096 ))" "$OUT" diff --git a/App/apps/trivfo/trivfo_app.c b/App/apps/trivfo/trivfo_app.c new file mode 100644 index 00000000..20b8dd78 --- /dev/null +++ b/App/apps/trivfo/trivfo_app.c @@ -0,0 +1,254 @@ +/* Copyright 2026 Armel F4HWN + * SPDX-License-Identifier: Apache-2.0 + * + * Triple VFO — modal overlay receiver/transmitter. A and B are the resident + * Main Display VFOs; C is an app-persistent memory channel. The resident ABI + * performs all RF sequencing while this blob owns the seven-line Tiny UI. + */ +#include +#include +#include "../app_api.h" + +#define TICK_MS 20u +#define LONG_MS 400u +#define CFG_MAGIC 0xC3u +#define VFO_COUNT 3u +#define MR_MAX 1024u +#define SCOPE_SAMPLES 43u +#define SCOPE_FLOOR 200u + +static const app_api_t *A; +static app_trivfo_info_t vi[VFO_COUNT]; +static uint8_t selected, state; +static uint16_t cChannel; +static bool running, fArm, txDenied, channelLabelOn, showFrequency; +static uint16_t scopeBuf[SCOPE_SAMPLES], scopeFloor; +static uint8_t scopeWrite, scopeReady, batteryTicks; +static bool scopeTx; +static char text[16]; + +/* Resident status.c uses this exact 8-column inverted glyph at x=69. Keep + * the overlay copy byte-for-byte identical so F has the same position and + * polarity as every other firmware screen. */ +static const uint8_t fontF[8] = { + 0x7f, 0x00, 0x76, 0x76, 0x76, 0x76, 0x7e, 0x7f +}; + +static uint8_t slen(const char *s){ uint8_t n=0; while(s[n])n++; return n; } +static bool copyName(char *d,const char *s){ + uint8_t n=0; + while(n<10u){ + uint8_t c=(uint8_t)s[n]; + if(c==0u||c==0xffu) break; + d[n++]=(char)c; + } + while(n>0u&&d[n-1u]==' ') n--; + d[n]='\0'; + return n>0u; +} +static char *put(char *o,const char *s){ while(*s)*o++=*s++; return o; } +static char *putu(char *o,uint32_t v){ char t[10]; int8_t n=0; do{t[n++]=(char)('0'+v%10u);v/=10u;}while(v); while(n--)*o++=t[n]; return o; } +static char *puti(char *o,int16_t v){ if(v<0){*o++='-';v=(int16_t)-v;} return putu(o,(uint16_t)v); } +static char *put3(char *o,uint16_t v){ *o++=(char)('0'+(v/100u)%10u); *o++=(char)('0'+(v/10u)%10u); *o++=(char)('0'+v%10u); return o; } +static char *put4(char *o,uint16_t v){ *o++=(char)('0'+(v/1000u)%10u); return put3(o,(uint16_t)(v%1000u)); } + +static void formatFreq(char *s,uint32_t f){ + char *o=putu(s,f/100000u); *o++='.'; f%=100000u; + uint32_t p=10000u; while(p){ *o++=(char)('0'+(f/p)%10u); p/=10u; } *o='\0'; +} +static void formatChannel(char *s,const app_trivfo_info_t *v){ + char *o=s; + if(v->channelchannel+1u)); + else { *o++='F'; o=putu(o,(uint16_t)(v->channel-MR_MAX+1u)); } + *o='\0'; +} +static void formatStep(char *s,uint16_t step){ + char *o=putu(s,step/100u); + if(step%100u){ *o++='.'; *o++=(char)('0'+(step/10u)%10u); *o++=(char)('0'+step%10u); } + *o++='K'; *o='\0'; +} +static void formatCode(char *s,const app_trivfo_info_t *v){ + char *o=s; + if(v->code_type==1u){ o=putu(o,v->code_value/10u); *o++='.'; *o++=(char)('0'+v->code_value%10u); } + else if(v->code_type==2u||v->code_type==3u){ + uint16_t n=v->code_value; *o++=(char)('0'+((n>>6)&7u)); *o++=(char)('0'+((n>>3)&7u)); *o++=(char)('0'+(n&7u)); *o++=(v->code_type==2u?'N':'I'); + } else return formatStep(s,v->step); + *o='\0'; +} + +static void drawMeter(const app_trivfo_info_t *v){ + int16_t dbm=v->rssi_dbm; if(dbm>-53)dbm=-53; + uint8_t s=0,over=0; + if(dbm>=-93){ s=9; over=(uint8_t)(dbm+93); if(over>40)over=40; } + else if(dbm>=-141) s=(uint8_t)((dbm+147)/6); + + char *o=text; if(dbm>-100)*o++=' '; o=puti(o,dbm); o=put(o," dBm"); *o='\0'; + A->print_tiny(text,2,1,false,true); + if(over){ o=text; *o++='+'; if(over<10)*o++='0'; o=putu(o,over); } + else { o=text; *o++='S'; o=putu(o,s); } + *o='\0'; A->print_normal(text,38,0,0); + + uint8_t level=(uint8_t)(s+over/10u); if(level>13u)level=13u; + for(uint8_t i=0;ifb[0][62u+i*5u]; + p[0]=0x3e; p[1]=(i<9u)?0x3e:0x22; p[2]=(i<9u)?0x3e:0x22; p[3]=0x3e; + } +} + +static void drawAudioScope(void){ + if(!scopeTx){ + for(uint8_t i=0;i=7u) scopeBuf[scopeWrite]=A->bk_read(0x64u); + else scopeReady++; + if(scopeBuf[scopeWrite]==0u) scopeBuf[scopeWrite]=SCOPE_FLOOR; + scopeWrite=(uint8_t)((scopeWrite+1u)%SCOPE_SAMPLES); + + uint16_t min=scopeBuf[0], max=scopeBuf[0]; + for(uint8_t i=1;imax) max=scopeBuf[i]; + } + if(scopeFloor>min) scopeFloor=(uint16_t)(scopeFloor-((scopeFloor-min)>>3)-1u); + else scopeFloor=(uint16_t)(scopeFloor+2u); + uint16_t range=max>scopeFloor?(uint16_t)(max-scopeFloor):0u; + + for(uint8_t i=0;i=50u){ + uint16_t value=scopeBuf[idx]>scopeFloor?(uint16_t)(scopeBuf[idx]-scopeFloor):0u; + height=(uint8_t)((uint32_t)value*7u/range); + } + uint8_t mask=height?(uint8_t)((0x7fu<<(7u-height))&0x7fu):0x40u; + A->fb[0][i*3u]=mask; A->fb[0][i*3u+1u]=mask; + } +} + +static void drawVfo(uint8_t n){ + const app_trivfo_info_t *v=&vi[n]; + uint8_t mainLine=(uint8_t)(n*2u+1u), techLine=(uint8_t)(mainLine+1u); + uint8_t techY=(uint8_t)(techLine*8u+1u); + + formatChannel(text,v); + if(!(v->flags&APP_TRIVFO_RECEIVING)||channelLabelOn) + A->print_inverse(text,2,mainLine,false,true,(uint8_t)(slen(text)*4u+3u)); + if(n==selected && txDenied) + A->print_tiny("TX DISABLE",22,(uint8_t)(mainLine*8u+1u),false,true); + else { + bool useName=false; + if(v->channelname); + if(!useName) formatFreq(text,v->frequency); + if(n==selected) + A->print_bold(text,22,0,mainLine); + else + A->print_normal(text,22,0,mainLine); + } + + const char *mod=v->modulation==0u?"FM":v->modulation==1u?"AM":v->modulation==2u?"USB":v->modulation==3u?"BYP":v->modulation==4u?"RAW":"?"; + A->print_tiny(mod,3,techY,false,true); + + static const char *const power[7]={"LOW1","LOW2","LOW3","LOW4","LOW5","MID","HIGH"}; + uint8_t p=(v->power>=1u&&v->power<=7u)?(uint8_t)(v->power-1u):0u; + A->print_tiny(power[p],24,techY,false,true); + if(v->flags&APP_TRIVFO_USER_POWER){ + A->fb[techLine][19]=0x3e; A->fb[techLine][20]=0x1c; A->fb[techLine][21]=0x08; + } + + if(v->offset_direction==1u) A->print_normal("+",41,0,techLine); + else if(v->offset_direction==2u) A->print_normal("-",41,0,techLine); + if(v->reverse) A->print_tiny("R",51,techY,false,true); + + if(v->code_type==1u){ A->print_tiny("CT",58,techY,false,true); formatCode(text,v); A->print_tiny(text,68,techY,false,true); } + else if(v->code_type==2u||v->code_type==3u){ A->print_tiny("DC",58,techY,false,true); formatCode(text,v); A->print_tiny(text,68,techY,false,true); } + else { formatStep(text,v->step); A->print_tiny(text,58,techY,false,true); } + + A->print_tiny(v->bandwidth==0u?"WIDE":v->bandwidth==1u?"NAR":"NAR+",91,techY,false,true); + text[0]='S'; text[1]='Q'; text[2]='L'; text[3]=(char)('0'+(v->squelch%10u)); text[4]='\0'; + A->print_tiny(text,110,techY,false,true); +} + +static void draw(void){ + for(uint8_t i=0;itrivfo_get(i,&vi[i]); + /* The resident scanner can temporarily select the VFO carrying the + * reception. Mirror that selection locally so the bold main field + * follows it immediately. */ + if(vi[i].flags&APP_TRIVFO_SELECTED) selected=i; + } + A->display_clear(); A->status_clear(); + /* Keep the application title in the resident upper-left position. */ + A->draw_battery(); + A->print_inverse("TRIPLE VFO",2,0,true,true,42); + if(fArm){ for(uint8_t i=0;i<8u;i++) A->status_line[69u+i]=fontF[i]; } + for(uint8_t i=0;iblit_status(); A->blit_full(); +} + +static void selectNext(void){ selected=(uint8_t)((selected+1u)%VFO_COUNT); A->trivfo_select(selected); } +static void toggleNameFrequency(void){ showFrequency=!showFrequency; } +static void stepSelected(uint8_t key){ int8_t d=A->nav_dir(key); uint16_t ch=A->trivfo_step(selected,d); if(selected==2u&&ch!=0xFFFFu)cChannel=ch; } + +static void loadCfg(void){ uint8_t c[4]; A->cfg_load(c,4); cChannel=(c[0]==CFG_MAGIC)?(uint16_t)(c[1]|((uint16_t)c[2]<<8)):0xFFFFu; } +static void saveCfg(void){ uint8_t c[3]={CFG_MAGIC,(uint8_t)cChannel,(uint8_t)(cChannel>>8)}; A->cfg_save(c,3); } + +__attribute__((section(".text.entry"),used)) +void app_main(const app_api_t *api){ + A=api; running=true; fArm=false; txDenied=false; channelLabelOn=true; showFrequency=false; selected=scopeWrite=scopeReady=batteryTicks=0; scopeTx=false; scopeFloor=SCOPE_FLOOR; loadCfg(); + cChannel=A->trivfo_enter(cChannel); A->trivfo_select(0); A->backlight_on(); + + uint8_t held=APP_KEY_INVALID, blinkTicks=0; uint16_t heldMs=0; bool longDone=false, ptt=false; + while(running){ + uint8_t key=A->get_key(); + if(key==APP_KEY_PTT){ + if(!ptt){ ptt=true; txDenied=A->trivfo_ptt(true)!=0; } + } else if(ptt){ ptt=false; A->trivfo_ptt(false); txDenied=false; } + + if(key==APP_KEY_INVALID||key==APP_KEY_PTT){ + if(held!=APP_KEY_INVALID&&!longDone){ + if(held==APP_KEY_F) fArm=!fArm; + else if(held==APP_KEY_1&&fArm){ fArm=false; toggleNameFrequency(); } + else if(held==APP_KEY_2&&fArm){ fArm=false; selectNext(); } + else if(held==APP_KEY_EXIT) running=false; + } + held=APP_KEY_INVALID; heldMs=0; longDone=false; + } else if(key!=held){ + held=key; heldMs=0; longDone=false; + if(key==APP_KEY_UP||key==APP_KEY_DOWN){ stepSelected(key); longDone=true; } + } else { + heldMs=(uint16_t)(heldMs+TICK_MS); + if((key==APP_KEY_UP||key==APP_KEY_DOWN)&&heldMs>=300u){ stepSelected(key); } + else if(!longDone&&heldMs>=LONG_MS){ + longDone=true; + /* Long-1 toggles name/frequency and Long-2 selects the next + * VFO; both are deliberately independent of F. */ + if(key==APP_KEY_1){ fArm=false; toggleNameFrequency(); } + else if(key==APP_KEY_2){ fArm=false; selectNext(); } + } + } + + state=A->trivfo_tick(); + if(state==APP_TRIVFO_RX){ + if(++blinkTicks>=25u){ blinkTicks=0; channelLabelOn=!channelLabelOn; } + } else { blinkTicks=0; channelLabelOn=true; } + draw(); + /* Match MAIN: no loaded-voltage samples during TX, then allow one + * second for the pack to recover before refreshing the four-sample + * battery average. */ + if(state==APP_TRIVFO_TX_STATE) batteryTicks=0; + else if(++batteryTicks>=50u){ batteryTicks=0; A->battery_sample(); } + A->delay_ms(TICK_MS); A->backlight_update(); + } + if(ptt) + A->trivfo_ptt(false); + saveCfg(); + A->trivfo_leave(); +} From 88e76b0e9bbfb8e56b1dcada4b09c9f79d79d0ff Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 30 Aug 2026 13:15:16 +0200 Subject: [PATCH 45/73] Optimize overlay ABI and reduce flash footprint --- App/app/app.c | 4 + App/app/foxhunt.c | 5 +- App/apps/app_api.h | 8 +- App/apps/app_overlay.c | 173 +++++++++++++------------------ App/apps/beacon/beacon_app.c | 5 +- App/apps/breakout/breakout_app.c | 7 +- App/apps/fm/fm_app.c | 6 +- App/apps/trivfo/trivfo_app.c | 5 +- App/driver/mb_flash.c | 12 +-- App/driver/mb_flash.h | 5 + App/helper/battery.c | 11 ++ App/helper/battery.h | 3 + App/ui/helper.c | 4 +- App/ui/helper.h | 4 +- 14 files changed, 126 insertions(+), 126 deletions(-) diff --git a/App/app/app.c b/App/app/app.c index 653be3ff..28282d8e 100644 --- a/App/app/app.c +++ b/App/app/app.c @@ -1985,10 +1985,14 @@ void APP_TimeSlice500ms(void) if ((gBatteryCheckCounter & 1) == 0) { +#if defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) || defined(ENABLE_FEAT_F4HWN_FOXHUNT) + BATTERY_Sample(true); +#else BOARD_ADC_GetBatteryInfo(&gBatteryVoltages[gBatteryVoltageIndex++], &gBatteryCurrent); if (gBatteryVoltageIndex > 3) gBatteryVoltageIndex = 0; BATTERY_GetReadings(true); +#endif } } diff --git a/App/app/foxhunt.c b/App/app/foxhunt.c index 34099575..20e7be20 100644 --- a/App/app/foxhunt.c +++ b/App/app/foxhunt.c @@ -683,10 +683,7 @@ static void FOXHUNT_IdleHousekeeping(void) // hunt loop, and the beacon idle gap, never a burst), so the reading is not // pulled down by TX load — keeping the status icon live and letting the // beacon's battery gate react to a pack draining under a long run. - BOARD_ADC_GetBatteryInfo(&gBatteryVoltages[gBatteryVoltageIndex++], &gBatteryCurrent); - if (gBatteryVoltageIndex > 3) - gBatteryVoltageIndex = 0; - BATTERY_GetReadings(false); + BATTERY_Sample(false); // Persist any changed setting within ~0.5 s, so it survives a power-off (not // just a clean EXIT). No-op when nothing changed. diff --git a/App/apps/app_api.h b/App/apps/app_api.h index 5396f48c..e78f41b7 100644 --- a/App/apps/app_api.h +++ b/App/apps/app_api.h @@ -40,7 +40,7 @@ * reorder, a removal, or an append - MUST bump this. Keep in sync with the * value read by pack_app.py, which * stamps the blob the loader checks against. */ -#define APP_ABI_VERSION 4u +#define APP_ABI_VERSION 5u /* 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. */ @@ -139,15 +139,10 @@ typedef struct app_api { /* ---- 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. == */ @@ -197,7 +192,6 @@ typedef struct app_api { 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 */ diff --git a/App/apps/app_overlay.c b/App/apps/app_overlay.c index 15fff980..9270097d 100644 --- a/App/apps/app_overlay.c +++ b/App/apps/app_overlay.c @@ -28,6 +28,7 @@ #include "app/fm.h" #endif #include "driver/keyboard.h" +#include "driver/mb_flash.h" #include "driver/py25q16.h" #include "driver/st7565.h" #include "driver/system.h" @@ -47,22 +48,7 @@ _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 int8_t app_nav_dir(uint8_t key) { @@ -87,14 +73,6 @@ static void app_play_tone(uint16_t tone, uint16_t 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; -} - /* ---- ABI 4: resident triple-VFO engine --------------------------------- * The overlay owns the UI and key timing, while this resident engine owns all * radio details. Keeping VFO_Info_t and BK4819 sequencing on this side makes @@ -519,10 +497,7 @@ static void app_battery_sample(void) if (app_trivfo_transmitting) return; - BOARD_ADC_GetBatteryInfo(&gBatteryVoltages[gBatteryVoltageIndex++], &gBatteryCurrent); - if (gBatteryVoltageIndex > 3) - gBatteryVoltageIndex = 0; - BATTERY_GetReadings(false); + BATTERY_Sample(false); } /* ---- v2 TX (beacon) ---- */ @@ -569,7 +544,6 @@ static void app_fm_exit(void) 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); } @@ -628,6 +602,75 @@ uint8_t APP_SlotInfo(uint8_t slot, app_header_t *out_header) return (h.magic == APP_MAGIC) ? APP_OK : APP_ERR_MAGIC; } +/* All services are immutable. Keeping the table in flash avoids rebuilding a + * roughly quarter-kilobyte automatic object on every launch and removes that + * object from the launcher's stack frame. */ +static const app_api_t app_api = { + .abi_version = APP_ABI_VERSION, + .fb = gFrameBuffer, + .display_clear = UI_DisplayClear, + .status_clear = UI_StatusClear, + .draw_line = UI_DrawLineBuffer, + .draw_rect = UI_DrawRectangleBuffer, + .print_bold = UI_PrintStringSmallBold, + .print_tiny = GUI_DisplaySmallest, + .blit_full = ST7565_BlitFullScreen, + .blit_line = ST7565_BlitLine, + .blit_status = ST7565_BlitStatusLine, + .get_key = app_get_key, + .delay_ms = SYSTEM_DelayMs, + .play_tone = app_play_tone, + .led = app_led, + .print_normal = UI_PrintStringSmallNormal, + .print_inverse = GUI_DisplaySmallestInverse, + .display_freq = UI_DisplayFrequency, + .rssi_dbm = app_rssi_dbm, + .bk_read = app_bk_read, + .bk_write = app_bk_write, + .set_agc = BK4819_SetAGC, + .set_af = app_set_af, + .audio_path = app_audio_path, + .prepare_tone = app_prepare_tone, + .play_tone_raw = app_play_tone_raw, + .tones_off_rx = 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_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 + .nav_dir = app_nav_dir, + .trivfo_enter = app_trivfo_enter, + .trivfo_leave = app_trivfo_leave, + .trivfo_get = app_trivfo_get, + .trivfo_select = app_trivfo_select, + .trivfo_step = app_trivfo_step, + .trivfo_tick = app_trivfo_tick, + .trivfo_ptt = app_trivfo_ptt, +}; + uint8_t APP_LaunchOverlay(uint8_t slot) { app_header_t h; @@ -650,7 +693,7 @@ uint8_t APP_LaunchOverlay(uint8_t slot) 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) { + if (MB_Crc32Bytes(ws, h.code_size) != h.code_crc32) { PY25Q16_InvalidateCache(); return APP_ERR_CRC; } @@ -659,76 +702,6 @@ uint8_t APP_LaunchOverlay(uint8_t slot) __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 - .nav_dir = app_nav_dir, - .trivfo_enter = app_trivfo_enter, - .trivfo_leave = app_trivfo_leave, - .trivfo_get = app_trivfo_get, - .trivfo_select = app_trivfo_select, - .trivfo_step = app_trivfo_step, - .trivfo_tick = app_trivfo_tick, - .trivfo_ptt = app_trivfo_ptt, - }; - app_run_slot = slot; /* for cfg_load / cfg_save */ app_cfg_len = 0; #ifdef ENABLE_FMRADIO @@ -750,7 +723,7 @@ uint8_t APP_LaunchOverlay(uint8_t slot) RADIO_SetupRegisters(true); app_entry_t entry = (app_entry_t)(((uint32_t)ws + h.entry_off) | 1u); - entry(&api); + entry(&app_api); /* A defensive leave also covers an app returning through an error path. */ app_trivfo_leave(); diff --git a/App/apps/beacon/beacon_app.c b/App/apps/beacon/beacon_app.c index c9b59cea..0228c980 100644 --- a/App/apps/beacon/beacon_app.c +++ b/App/apps/beacon/beacon_app.c @@ -91,7 +91,10 @@ 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; + case APP_KEY_3: + if(dir>0){ if(++foxFox>=FOX_COUNT) foxFox=0; } + else foxFox=foxFox?(uint8_t)(foxFox-1u):(uint8_t)(FOX_COUNT-1u); + return true; default: return false; } } diff --git a/App/apps/breakout/breakout_app.c b/App/apps/breakout/breakout_app.c index 493cca86..c85bb99b 100644 --- a/App/apps/breakout/breakout_app.c +++ b/App/apps/breakout/breakout_app.c @@ -242,9 +242,12 @@ void app_main(const app_api_t *api) { kbdPrev = APP_KEY_INVALID; kbdCur = APP_KEY_INVALID; uint8_t swap = 0; - srand_custom(api->seed); + /* The low BK4819 counter bits vary continuously; mix them with the tuned + * frequency instead of carrying a launch-only seed field in every ABI + * table. */ + srand_custom(((uint32_t)A->bk_read(0x67u) << 16) ^ A->rx_freq()); A->led(false); - A->backlight_tick(); + A->backlight_on(); A->display_clear(); reset(); initWall(); initRacket(); initBall(); diff --git a/App/apps/fm/fm_app.c b/App/apps/fm/fm_app.c index 85520bec..dac7aa08 100644 --- a/App/apps/fm/fm_app.c +++ b/App/apps/fm/fm_app.c @@ -238,7 +238,11 @@ static void menu(void) 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(askSave){ + if(step>0){ if(++savePos>=CHMAX) savePos=0; } + else savePos=savePos?(uint8_t)(savePos-1u):(uint8_t)(CHMAX-1u); + 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(); } diff --git a/App/apps/trivfo/trivfo_app.c b/App/apps/trivfo/trivfo_app.c index 20b8dd78..f4fe1896 100644 --- a/App/apps/trivfo/trivfo_app.c +++ b/App/apps/trivfo/trivfo_app.c @@ -80,7 +80,10 @@ static void drawMeter(const app_trivfo_info_t *v){ int16_t dbm=v->rssi_dbm; if(dbm>-53)dbm=-53; uint8_t s=0,over=0; if(dbm>=-93){ s=9; over=(uint8_t)(dbm+93); if(over>40)over=40; } - else if(dbm>=-141) s=(uint8_t)((dbm+147)/6); + /* dbm + 147 is non-negative in this branch. Keep the division unsigned: + * otherwise GCC pulls the ~460-byte signed division helper into the 4 KiB + * overlay even though no signed quotient is required. */ + else if(dbm>=-141) s=(uint8_t)((uint16_t)(dbm+147)/6u); char *o=text; if(dbm>-100)*o++=' '; o=puti(o,dbm); o=put(o," dBm"); *o='\0'; A->print_tiny(text,2,1,false,true); diff --git a/App/driver/mb_flash.c b/App/driver/mb_flash.c index 9b97a1c1..05fd6a5f 100644 --- a/App/driver/mb_flash.c +++ b/App/driver/mb_flash.c @@ -790,7 +790,7 @@ _Static_assert(offsetof(mb_state_t, firmware_slot) == 16u, _Static_assert(offsetof(mb_state_t, state_crc32) == 20u, "state CRC must cover the first 20 bytes (magic..bank_inv)"); -static uint32_t mb_crc32_bytes(const uint8_t *p, uint32_t len) +uint32_t MB_Crc32Bytes(const uint8_t *p, uint32_t len) { uint32_t crc = 0xFFFFFFFFu; @@ -848,7 +848,7 @@ static mb_mark_status_t mb_read_state_copy(uint32_t base, mb_state_t *st) return MB_MARK_CORRUPT; if (st->image_size == 0u || st->image_size > MB_INT_APP_SIZE) return MB_MARK_CORRUPT; - if (mb_crc32_bytes((const uint8_t *)st, + if (MB_Crc32Bytes((const uint8_t *)st, sizeof(*st) - sizeof(st->state_crc32)) != st->state_crc32) return MB_MARK_CORRUPT; @@ -857,7 +857,7 @@ static mb_mark_status_t mb_read_state_copy(uint32_t base, mb_state_t *st) st->slot_inv = (uint8_t)~legacy_index; st->config_bank = legacy_index; st->bank_inv = (uint8_t)~legacy_index; - st->state_crc32 = mb_crc32_bytes((const uint8_t *)st, + st->state_crc32 = MB_Crc32Bytes((const uint8_t *)st, sizeof(*st) - sizeof(st->state_crc32)); return MB_MARK_VALID; } @@ -871,7 +871,7 @@ static mb_mark_status_t mb_read_state_copy(uint32_t base, mb_state_t *st) if (st->config_bank >= MB_BANK_COUNT) return MB_MARK_CORRUPT; if (st->image_size == 0u || st->image_size > MB_INT_APP_SIZE) return MB_MARK_CORRUPT; - if (mb_crc32_bytes((const uint8_t *)st, + if (MB_Crc32Bytes((const uint8_t *)st, sizeof(*st) - sizeof(st->state_crc32)) != st->state_crc32) return MB_MARK_CORRUPT; return MB_MARK_VALID; @@ -965,7 +965,7 @@ static uint8_t mb_commit_state(mb_state_t *st, st->generation = (current_status == MB_MARK_VALID) ? current->generation + 1u : 0u; st->slot_inv = (uint8_t)~st->firmware_slot; st->bank_inv = (uint8_t)~st->config_bank; - st->state_crc32 = mb_crc32_bytes((const uint8_t *)st, + st->state_crc32 = MB_Crc32Bytes((const uint8_t *)st, sizeof(*st) - sizeof(st->state_crc32)); const uint32_t target_base = ((current_status == MB_MARK_VALID || @@ -1089,7 +1089,7 @@ static void mb_copy_str(char *dst, uint8_t cap, const char *src) static uint32_t mb_int_image_crc32(uint32_t len) { const uint8_t *p = (const uint8_t *)MB_INT_APP_BASE; - return mb_crc32_bytes(p, len); + return MB_Crc32Bytes(p, len); } static bool mb_internal_matches(uint32_t image_size, uint32_t image_crc32) diff --git a/App/driver/mb_flash.h b/App/driver/mb_flash.h index 4c8e1215..14b3bdf3 100644 --- a/App/driver/mb_flash.h +++ b/App/driver/mb_flash.h @@ -84,6 +84,11 @@ enum { MB_ERR_RAM_LOAD /* restore stub RAM copy mismatch */ }; +/* CRC-32 (zlib) over a resident memory buffer. Multiboot and overlay apps + * share this implementation; keeping it here avoids carrying two identical + * bitwise CRC loops in the MCU flash. */ +uint32_t MB_Crc32Bytes(const uint8_t *data, uint32_t len); + /* Multi-slot API used by the boot selector. Validation always covers the full * image CRC before restore. progress_line may point to a 128-byte LCD page; the * RAM copier then fills it while reflashing. Pass NULL to disable LCD updates. diff --git a/App/helper/battery.c b/App/helper/battery.c index c02a1814..d3fd2fa9 100644 --- a/App/helper/battery.c +++ b/App/helper/battery.c @@ -17,6 +17,7 @@ #include #include "battery.h" +#include "board.h" #include "driver/backlight.h" #include "driver/st7565.h" #include "functions.h" @@ -129,6 +130,16 @@ unsigned int BATTERY_VoltsToPercent(const unsigned int voltage_10mV) return 0; } +#if defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) || defined(ENABLE_FEAT_F4HWN_FOXHUNT) +void BATTERY_Sample(const bool bDisplayBatteryLevel) +{ + BOARD_ADC_GetBatteryInfo(&gBatteryVoltages[gBatteryVoltageIndex++], &gBatteryCurrent); + if (gBatteryVoltageIndex > 3u) + gBatteryVoltageIndex = 0u; + BATTERY_GetReadings(bDisplayBatteryLevel); +} +#endif + void BATTERY_GetReadings(const bool bDisplayBatteryLevel) { const uint8_t PreviousBatteryLevel = gBatteryDisplayLevel; diff --git a/App/helper/battery.h b/App/helper/battery.h index eb7515a8..6b290ab5 100644 --- a/App/helper/battery.h +++ b/App/helper/battery.h @@ -45,6 +45,9 @@ typedef enum { unsigned int BATTERY_VoltsToPercent(unsigned int voltage_10mV); +#if defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) || defined(ENABLE_FEAT_F4HWN_FOXHUNT) +void BATTERY_Sample(bool bDisplayBatteryLevel); +#endif void BATTERY_GetReadings(bool bDisplayBatteryLevel); void BATTERY_TimeSlice500ms(void); diff --git a/App/ui/helper.c b/App/ui/helper.c index f25f9622..ca4392b2 100644 --- a/App/ui/helper.c +++ b/App/ui/helper.c @@ -402,12 +402,12 @@ void UI_DisplayPopup(const char *string) UI_PrintStringSmallNormal("Press EXIT", 9, 118, 6); } -void UI_DisplayClear() +void UI_DisplayClear(void) { memset(gFrameBuffer, 0, sizeof(gFrameBuffer)); } -void UI_StatusClear() +void UI_StatusClear(void) { memset(gStatusLine, 0, sizeof(gStatusLine)); } diff --git a/App/ui/helper.h b/App/ui/helper.h index 1d2a32a7..5dc4ecea 100644 --- a/App/ui/helper.h +++ b/App/ui/helper.h @@ -44,7 +44,7 @@ void UI_DrawPixelBuffer(uint8_t (*buffer)[128], uint8_t x, uint8_t y, bool black void UI_DrawLineBuffer(uint8_t (*buffer)[128], int16_t x1, int16_t y1, int16_t x2, int16_t y2, bool black); void UI_DrawRectangleBuffer(uint8_t (*buffer)[128], int16_t x1, int16_t y1, int16_t x2, int16_t y2, bool black); -void UI_DisplayClear(); -void UI_StatusClear(); +void UI_DisplayClear(void); +void UI_StatusClear(void); #endif From 048e7e483bd99758e8a265c5534e0996d8905e0b Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 30 Aug 2026 17:02:35 +0200 Subject: [PATCH 46/73] Optimize overlay apps and shared audio scope --- App/apps/app_api.h | 3 +- App/apps/app_overlay.c | 5 +- App/apps/foxhunt/foxhunt_app.c | 45 +++++++++++----- App/apps/trivfo/trivfo_app.c | 48 ++--------------- App/ui/main.c | 94 ++++++++++++++++------------------ App/ui/main.h | 3 ++ 6 files changed, 90 insertions(+), 108 deletions(-) diff --git a/App/apps/app_api.h b/App/apps/app_api.h index e78f41b7..a1384ae0 100644 --- a/App/apps/app_api.h +++ b/App/apps/app_api.h @@ -40,7 +40,7 @@ * reorder, a removal, or an append - MUST bump this. Keep in sync with the * value read by pack_app.py, which * stamps the blob the loader checks against. */ -#define APP_ABI_VERSION 5u +#define APP_ABI_VERSION 6u /* 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. */ @@ -174,6 +174,7 @@ typedef struct app_api { 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) */ + void (*audio_scope)(uint8_t line, bool active); /* shared MAIN microphone scope */ uint8_t *status_line; /* -> gStatusLine (for status-bar icons) */ diff --git a/App/apps/app_overlay.c b/App/apps/app_overlay.c index 9270097d..4fc849f0 100644 --- a/App/apps/app_overlay.c +++ b/App/apps/app_overlay.c @@ -35,6 +35,7 @@ #include "driver/backlight.h" #include "app/app.h" #include "ui/helper.h" +#include "ui/main.h" #include "ui/status.h" #include "board.h" #include "audio.h" @@ -276,7 +277,8 @@ static void app_trivfo_get(uint8_t index, app_trivfo_info_t *info) #endif info->code_type = vfo->pRX->CodeType; info->code = vfo->pRX->Code; - info->offset_direction = vfo->TX_OFFSET_FREQUENCY_DIRECTION; + info->offset_direction = (vfo->freq_config_RX.Frequency != vfo->freq_config_TX.Frequency) + ? vfo->TX_OFFSET_FREQUENCY_DIRECTION : 0u; info->reverse = vfo->FrequencyReverse; info->squelch = gEeprom.SQUELCH_LEVEL; if (index == app_trivfo_selected) info->flags |= APP_TRIVFO_SELECTED; @@ -640,6 +642,7 @@ static const app_api_t app_api = { .battery_sample = app_battery_sample, .backlight_on = BACKLIGHT_TurnOn, .backlight_update = BACKLIGHT_Update, + .audio_scope = UI_DisplayAudioScopeOverlay, .status_line = gStatusLine, .tx_state = app_tx_state, .tx_set_params = app_tx_set_params, diff --git a/App/apps/foxhunt/foxhunt_app.c b/App/apps/foxhunt/foxhunt_app.c index f906c359..1588c584 100644 --- a/App/apps/foxhunt/foxhunt_app.c +++ b/App/apps/foxhunt/foxhunt_app.c @@ -104,9 +104,10 @@ static char str[16]; 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;} + uint32_t u; + if(v<0){*o++='-';u=(uint32_t)(-v);} else u=(uint32_t)v; char t[6]; int8_t n=0; - do{t[n++]=(char)('0'+v%10);v/=10;}while(v&&n<6); + do{t[n++]=(char)('0'+u%10u);u/=10u;}while(u&&n<6); while(n--)*o++=t[n]; return o; } @@ -131,7 +132,11 @@ static void setAudio(void){ 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); + const int32_t delta = hi - lo; + const uint32_t distance = ((uint32_t)(dbm-DBM_FLOOR) * + (uint32_t)(delta < 0 ? -delta : delta)) / + (uint32_t)(DBM_CEIL-DBM_FLOOR); + return lo + (delta < 0 ? -(int32_t)distance : (int32_t)distance); } static void blip(uint16_t freq){ A->prepare_tone(); @@ -144,23 +149,37 @@ static void blip(uint16_t freq){ 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(dbm<=-93) n=(int16_t)(1u+(uint16_t)(dbm+141)/6u); + else n=(int16_t)(9u+(uint16_t)(dbm+93)/10u); 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); } + else { char *p=put(out,"S"); i2str(p,(int)((uint16_t)(dbm+147)/6u)); } +} + +static int16_t div_trunc_pow2(int32_t v, uint8_t shift) +{ + if (v < 0) + return (int16_t)-((uint32_t)(-v) >> shift); + return (int16_t)((uint32_t)v >> shift); +} + +static uint8_t cycleIndex(uint8_t value, uint8_t count, int8_t dir) +{ + if (dir > 0) + return ++value < count ? value : 0u; + return value > 0u ? (uint8_t)(value - 1u) : (uint8_t)(count - 1u); } static void histSample(void){ - histEma += ((int16_t)curDbm*8 - histEma)/4; + histEma += div_trunc_pow2((int32_t)curDbm * 8 - histEma, 2u); if(++histTick=HIST_LEN) histHead=0; } static void rebase(void){ curDbm=A->rssi_dbm(); @@ -171,7 +190,7 @@ static void rebase(void){ histHead=0; histTick=0; histEma=(int16_t)(curDbm*8); } static void attCycle(int8_t dir){ - attStep=(uint8_t)((attStep + (dir>0?1:ATT_COUNT-1))%ATT_COUNT); + attStep=cycleIndex(attStep,ATT_COUNT,dir); applyAtt(); A->delay_ms(ATT_SETTLE); rebase(); @@ -198,7 +217,7 @@ static void drawHist(void){ uint8_t lvl=histBuf[idx]; if(++idx>=HIST_LEN)idx=0; if(lvl>SEG_COUNT)lvl=SEG_COUNT; uint8_t x=GRAPH_X0+c; - uint8_t y=(uint8_t)(floorY-(lvl*span)/SEG_COUNT); + uint8_t y=(uint8_t)(floorY-((uint32_t)lvl*span)/(uint32_t)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); } @@ -240,7 +259,7 @@ static void draw(void){ 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); + tag(str,(uint8_t)(((uint32_t)LCD_WIDTH-(uint32_t)slen(str)*4u)/2u),2); buildS(sMeter,curDbm); A->print_inverse(sMeter,(uint8_t)(126-slen(sMeter)*4),2,false,true,125); @@ -295,7 +314,7 @@ static void handleKeys(void){ 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(); + foxAudioMode=cycleIndex(foxAudioMode,3u,dir); setAudio(); if(foxAudioMode==AUDIO_BEEP){ audioTick=RATE_SLOW; } break; case APP_KEY_3: attCycle(dir); break; diff --git a/App/apps/trivfo/trivfo_app.c b/App/apps/trivfo/trivfo_app.c index f4fe1896..72a09347 100644 --- a/App/apps/trivfo/trivfo_app.c +++ b/App/apps/trivfo/trivfo_app.c @@ -14,17 +14,13 @@ #define CFG_MAGIC 0xC3u #define VFO_COUNT 3u #define MR_MAX 1024u -#define SCOPE_SAMPLES 43u -#define SCOPE_FLOOR 200u static const app_api_t *A; static app_trivfo_info_t vi[VFO_COUNT]; static uint8_t selected, state; static uint16_t cChannel; static bool running, fArm, txDenied, channelLabelOn, showFrequency; -static uint16_t scopeBuf[SCOPE_SAMPLES], scopeFloor; -static uint8_t scopeWrite, scopeReady, batteryTicks; -static bool scopeTx; +static uint8_t batteryTicks; static char text[16]; /* Resident status.c uses this exact 8-column inverted glyph at x=69. Keep @@ -75,7 +71,6 @@ static void formatCode(char *s,const app_trivfo_info_t *v){ } else return formatStep(s,v->step); *o='\0'; } - static void drawMeter(const app_trivfo_info_t *v){ int16_t dbm=v->rssi_dbm; if(dbm>-53)dbm=-53; uint8_t s=0,over=0; @@ -98,37 +93,6 @@ static void drawMeter(const app_trivfo_info_t *v){ } } -static void drawAudioScope(void){ - if(!scopeTx){ - for(uint8_t i=0;i=7u) scopeBuf[scopeWrite]=A->bk_read(0x64u); - else scopeReady++; - if(scopeBuf[scopeWrite]==0u) scopeBuf[scopeWrite]=SCOPE_FLOOR; - scopeWrite=(uint8_t)((scopeWrite+1u)%SCOPE_SAMPLES); - - uint16_t min=scopeBuf[0], max=scopeBuf[0]; - for(uint8_t i=1;imax) max=scopeBuf[i]; - } - if(scopeFloor>min) scopeFloor=(uint16_t)(scopeFloor-((scopeFloor-min)>>3)-1u); - else scopeFloor=(uint16_t)(scopeFloor+2u); - uint16_t range=max>scopeFloor?(uint16_t)(max-scopeFloor):0u; - - for(uint8_t i=0;i=50u){ - uint16_t value=scopeBuf[idx]>scopeFloor?(uint16_t)(scopeBuf[idx]-scopeFloor):0u; - height=(uint8_t)((uint32_t)value*7u/range); - } - uint8_t mask=height?(uint8_t)((0x7fu<<(7u-height))&0x7fu):0x40u; - A->fb[0][i*3u]=mask; A->fb[0][i*3u+1u]=mask; - } -} - static void drawVfo(uint8_t n){ const app_trivfo_info_t *v=&vi[n]; uint8_t mainLine=(uint8_t)(n*2u+1u), techLine=(uint8_t)(mainLine+1u); @@ -151,22 +115,18 @@ static void drawVfo(uint8_t n){ const char *mod=v->modulation==0u?"FM":v->modulation==1u?"AM":v->modulation==2u?"USB":v->modulation==3u?"BYP":v->modulation==4u?"RAW":"?"; A->print_tiny(mod,3,techY,false,true); - static const char *const power[7]={"LOW1","LOW2","LOW3","LOW4","LOW5","MID","HIGH"}; uint8_t p=(v->power>=1u&&v->power<=7u)?(uint8_t)(v->power-1u):0u; A->print_tiny(power[p],24,techY,false,true); if(v->flags&APP_TRIVFO_USER_POWER){ A->fb[techLine][19]=0x3e; A->fb[techLine][20]=0x1c; A->fb[techLine][21]=0x08; } - if(v->offset_direction==1u) A->print_normal("+",41,0,techLine); else if(v->offset_direction==2u) A->print_normal("-",41,0,techLine); if(v->reverse) A->print_tiny("R",51,techY,false,true); - if(v->code_type==1u){ A->print_tiny("CT",58,techY,false,true); formatCode(text,v); A->print_tiny(text,68,techY,false,true); } else if(v->code_type==2u||v->code_type==3u){ A->print_tiny("DC",58,techY,false,true); formatCode(text,v); A->print_tiny(text,68,techY,false,true); } else { formatStep(text,v->step); A->print_tiny(text,58,techY,false,true); } - A->print_tiny(v->bandwidth==0u?"WIDE":v->bandwidth==1u?"NAR":"NAR+",91,techY,false,true); text[0]='S'; text[1]='Q'; text[2]='L'; text[3]=(char)('0'+(v->squelch%10u)); text[4]='\0'; A->print_tiny(text,110,techY,false,true); @@ -187,9 +147,9 @@ static void draw(void){ if(fArm){ for(uint8_t i=0;i<8u;i++) A->status_line[69u+i]=fontF[i]; } for(uint8_t i=0;iaudio_scope(0u,true); else { - scopeTx=false; + A->audio_scope(0u,false); for(uint8_t i=0;itrivfo_enter(cChannel); A->trivfo_select(0); A->backlight_on(); uint8_t held=APP_KEY_INVALID, blinkTicks=0; uint16_t heldMs=0; bool longDone=false, ptt=false; diff --git a/App/ui/main.c b/App/ui/main.c index 5405312f..670fcf90 100644 --- a/App/ui/main.c +++ b/App/ui/main.c @@ -843,15 +843,14 @@ void UI_DisplayAudioBar(void) } #endif -#ifdef ENABLE_FEAT_F4HWN_AUDIO_SCOPE - +#if defined(ENABLE_FEAT_F4HWN_AUDIO_SCOPE) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) #define SCOPE_SAMPLES 43 // number of columns (43 × 3px = 128px wide) #define SCOPE_NOISE_GATE 50u // minimum range below which the display shows baseline #define SCOPE_FLOOR_RISE 2u // floor rise per frame (+100 units/s at 20ms/frame) #define SCOPE_FLOOR_DROP_SHR 3u // floor drop IIR shift: drop by (floor-min) >> N per frame (~160ms to halve) #define SCOPE_VOLUME_MIN 200u // let's assume that the sound level in silence is 200 -void UI_DisplayAudioScope(void) +void UI_DisplayAudioScopeOverlay(const uint8_t line, const bool active) { static uint16_t g_scope_buf[SCOPE_SAMPLES]; static uint8_t g_scope_write = 0; @@ -865,22 +864,11 @@ void UI_DisplayAudioScope(void) static bool s_was_tx = false; - if (gCurrentFunction != FUNCTION_TRANSMIT) { + if (!active) { s_was_tx = false; return; } - // This prevents a sudden spike on the bar caused by release the PTT button - if (!GPIO_IsPttPressed() -#ifdef ENABLE_VOX - && !gEeprom.VOX_SWITCH -#endif -#ifdef ENABLE_FEAT_F4HWN - && !gSetting_set_ptt_session -#endif - ) - return; - if (!s_was_tx) { // TX entry: full reset so every new transmission starts from a clean state for (uint8_t i = 0; i < SCOPE_SAMPLES; i++) g_scope_buf[i] = SCOPE_VOLUME_MIN; @@ -903,32 +891,6 @@ void UI_DisplayAudioScope(void) g_scope_write = (g_scope_write + 1u) % SCOPE_SAMPLES; -// --------------------------------- Refresh display --------------------------------- - - if (gLowBattery && !gLowBatteryConfirmed) - return; - - if (gScreenToDisplay != DISPLAY_MAIN -#ifdef ENABLE_DTMF_CALLING - || gDTMF_CallState != DTMF_CALL_STATE_NONE -#endif - ) - return; - -#ifdef ENABLE_TX1750 - if (gTx1750Active) - return; -#endif - -#ifdef ENABLE_FEAT_F4HWN - RxBlinkLed = 0; - RxBlinkLedCounter = 0; - BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, false); - const unsigned int line = isMainOnly() ? 5 : 3; -#else - const unsigned int line = 3; -#endif - uint8_t *p_line = gFrameBuffer[line]; memset(p_line, 0, LCD_WIDTH); @@ -968,9 +930,50 @@ void UI_DisplayAudioScope(void) } +} + +#ifdef ENABLE_FEAT_F4HWN_AUDIO_SCOPE +void UI_DisplayAudioScope(void) +{ + const unsigned int line = isMainOnly() ? 5u : 3u; + + /* Keep MAIN's original gating and side effects outside the shared renderer. */ + if (gCurrentFunction != FUNCTION_TRANSMIT) { + UI_DisplayAudioScopeOverlay((uint8_t)line, false); + return; + } + if (!GPIO_IsPttPressed() +#ifdef ENABLE_VOX + && !gEeprom.VOX_SWITCH +#endif +#ifdef ENABLE_FEAT_F4HWN + && !gSetting_set_ptt_session +#endif + ) + return; + if (gLowBattery && !gLowBatteryConfirmed) + return; + if (gScreenToDisplay != DISPLAY_MAIN +#ifdef ENABLE_DTMF_CALLING + || gDTMF_CallState != DTMF_CALL_STATE_NONE +#endif + ) + return; +#ifdef ENABLE_TX1750 + if (gTx1750Active) + return; +#endif + +#ifdef ENABLE_FEAT_F4HWN + RxBlinkLed = 0; + RxBlinkLedCounter = 0; + BK4819_ToggleGpioOut(BK4819_GPIO6_PIN2_GREEN, false); +#endif + UI_DisplayAudioScopeOverlay((uint8_t)line, true); ST7565_BlitLine(line); } -#endif // ENABLE_FEAT_F4HWN_AUDIO_SCOPE +#endif +#endif // ENABLE_FEAT_F4HWN_AUDIO_SCOPE || ENABLE_FEAT_F4HWN_OVERLAY_APPS void DisplayRSSIBar(const bool now) { @@ -2086,9 +2089,6 @@ void UI_DisplayMain(void) } GUI_DisplaySmallest(String, 68 + shift, line == 0 ? 17 : 49, false, true); - - //sprintf(String, "%d.%02u", vfoInfo->StepFrequency / 100, vfoInfo->StepFrequency % 100); - //GUI_DisplaySmallest(String, 91, line == 0 ? 2 : 34, false, true); } #else UI_PrintStringSmallNormal(s, LCD_WIDTH + 24, 0, line + 1); @@ -2123,8 +2123,6 @@ void UI_DisplayMain(void) else { const char pwr_long[][5] = {"LOW1", "LOW2", "LOW3", "LOW4", "LOW5", "MID", "HIGH"}; - //sprintf(String, "%s", pwr_long[currentPower]); - //GUI_DisplaySmallest(String, 24, line == 0 ? 17 : 49, false, true); GUI_DisplaySmallest(pwr_long[currentPower], 24, line == 0 ? 17 : 49, false, true); } @@ -2162,9 +2160,7 @@ void UI_DisplayMain(void) { #ifdef ENABLE_FEAT_F4HWN_RESCUE_OPS if(i == 3) - { GUI_DisplaySmallest(dir_list[i], 43, line == 0 ? 17 : 49, false, true); - } else { #endif diff --git a/App/ui/main.h b/App/ui/main.h index 84033f05..309c15b9 100644 --- a/App/ui/main.h +++ b/App/ui/main.h @@ -47,6 +47,9 @@ extern center_line_t center_line; #ifdef ENABLE_AUDIO_BAR void UI_DisplayAudioBar(void); #endif +#if defined(ENABLE_FEAT_F4HWN_AUDIO_SCOPE) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) +void UI_DisplayAudioScopeOverlay(uint8_t line, bool active); +#endif #ifdef ENABLE_FEAT_F4HWN_AUDIO_SCOPE void UI_DisplayAudioScope(void); #endif From 2f80e60c0100bac4cc7545cff39b49ef6286ea24 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 30 Aug 2026 17:28:33 +0200 Subject: [PATCH 47/73] Add SetGUI and PTT support to Triple VFO --- App/apps/app_api.h | 4 ++- App/apps/app_overlay.c | 42 +++++++++++++++++-------- App/apps/trivfo/trivfo_app.c | 60 ++++++++++++++++++++++++++++-------- 3 files changed, 80 insertions(+), 26 deletions(-) diff --git a/App/apps/app_api.h b/App/apps/app_api.h index a1384ae0..7ed8b148 100644 --- a/App/apps/app_api.h +++ b/App/apps/app_api.h @@ -107,6 +107,8 @@ enum { APP_TRIVFO_TX = 1u << 3, APP_TRIVFO_USER_POWER = 1u << 4, APP_TRIVFO_AUDIO_BAR = 1u << 5, + APP_TRIVFO_GUI_CLASSIC = 1u << 6, + APP_TRIVFO_PTT_ONEPUSH = 1u << 7, }; enum { @@ -218,7 +220,7 @@ typedef struct app_api { void (*trivfo_select)(uint8_t vfo); uint16_t (*trivfo_step)(uint8_t vfo, int8_t direction); uint8_t (*trivfo_tick)(void); - uint8_t (*trivfo_ptt)(bool pressed); /* 0 OK; non-zero TX denial/timeout */ + uint8_t (*trivfo_ptt)(bool pressed); /* physical PTT edge; resident applies SetPTT */ } app_api_t; /* BK4819 AF modes for set_af (mirror driver/bk4819.h values). */ diff --git a/App/apps/app_overlay.c b/App/apps/app_overlay.c index 4fc849f0..fad7ba2c 100644 --- a/App/apps/app_overlay.c +++ b/App/apps/app_overlay.c @@ -101,8 +101,11 @@ static bool app_trivfo_ctcss_ok; static bool app_trivfo_cdcss_ok; static bool app_trivfo_ab_dirty; static bool app_trivfo_restore_selection; +static bool app_trivfo_onepush_stop_armed; static uint8_t app_trivfo_freq_dirty; +static void app_trivfo_end_tx(void); + static VFO_Info_t *app_trivfo_vfo(uint8_t vfo) { return vfo < 2u ? &gEeprom.VfoInfo[vfo] : &app_trivfo_c; @@ -228,6 +231,7 @@ static uint16_t app_trivfo_enter(uint16_t c_channel) app_trivfo_hold = 0; app_trivfo_running = true; app_trivfo_transmitting = false; + app_trivfo_onepush_stop_armed = false; app_trivfo_ab_dirty = false; app_trivfo_freq_dirty = 0; app_trivfo_tune(0); @@ -238,10 +242,8 @@ static void app_trivfo_leave(void) { if (!app_trivfo_running) return; - if (app_trivfo_transmitting) { - BK4819_ToggleGpioOut(BK4819_GPIO1_PIN29_PA_ENABLE, false); - BK4819_ToggleGpioOut(BK4819_GPIO5_PIN1_RED, false); - } + if (app_trivfo_transmitting) + app_trivfo_end_tx(); AUDIO_AudioPathOff(); gEnableSpeaker = false; app_trivfo_running = false; @@ -289,6 +291,8 @@ static void app_trivfo_get(uint8_t index, app_trivfo_info_t *info) #ifdef ENABLE_AUDIO_BAR if (gSetting_mic_bar) info->flags |= APP_TRIVFO_AUDIO_BAR; #endif + if (gSetting_set_gui) info->flags |= APP_TRIVFO_GUI_CLASSIC; + if (gSetting_set_ptt_session) info->flags |= APP_TRIVFO_PTT_ONEPUSH; if (IS_MR_CHANNEL(vfo->CHANNEL_SAVE)) memcpy(info->name, vfo->Name, sizeof(info->name) - 1u); } @@ -340,7 +344,17 @@ static uint16_t app_trivfo_step(uint8_t index, int8_t direction) return channel; } -static uint8_t app_trivfo_ptt(bool pressed); +static void app_trivfo_end_tx(void) +{ + if (!app_trivfo_transmitting) + return; + RADIO_SendEndOfTransmission(); + app_trivfo_transmitting = false; + app_trivfo_onepush_stop_armed = false; + BK4819_ToggleGpioOut(BK4819_GPIO5_PIN1_RED, false); + app_trivfo_hold = APP_TRIVFO_TX_HOLD_TICKS; + app_trivfo_tune(app_trivfo_selected); +} static uint8_t app_trivfo_tick(void) { @@ -349,7 +363,7 @@ static uint8_t app_trivfo_tick(void) if (app_trivfo_transmitting) { const uint32_t timeout = ((uint32_t)gEeprom.TX_TIMEOUT_TIMER + 1u) * 250u; if (++app_trivfo_tx_ticks >= timeout) { - app_trivfo_ptt(false); + app_trivfo_end_tx(); return APP_TRIVFO_HOLD; } return APP_TRIVFO_TX_STATE; @@ -420,15 +434,18 @@ static uint8_t app_trivfo_ptt(bool pressed) if (!pressed) { if (!app_trivfo_transmitting) return 0; - RADIO_SendEndOfTransmission(); - app_trivfo_transmitting = false; - BK4819_ToggleGpioOut(BK4819_GPIO5_PIN1_RED, false); - app_trivfo_hold = APP_TRIVFO_TX_HOLD_TICKS; - app_trivfo_tune(app_trivfo_selected); + /* ONEPUSH mirrors the resident PTT sequence: the first release keeps + * TX keyed; the release following the second press ends TX. */ + if (gSetting_set_ptt_session && !app_trivfo_onepush_stop_armed) + return 0; + app_trivfo_end_tx(); return 0; } - if (app_trivfo_transmitting) + if (app_trivfo_transmitting) { + if (gSetting_set_ptt_session) + app_trivfo_onepush_stop_armed = true; return 0; + } VFO_Info_t *vfo = app_trivfo_vfo(app_trivfo_selected); if ((TX_freq_check(vfo->pTX->Frequency) != 0 && vfo->TX_LOCK) || @@ -447,6 +464,7 @@ static uint8_t app_trivfo_ptt(bool pressed) app_trivfo_current = app_trivfo_selected; app_trivfo_receiving = false; app_trivfo_transmitting = true; + app_trivfo_onepush_stop_armed = false; app_trivfo_tx_ticks = 0; return 0; } diff --git a/App/apps/trivfo/trivfo_app.c b/App/apps/trivfo/trivfo_app.c index 72a09347..c5c36142 100644 --- a/App/apps/trivfo/trivfo_app.c +++ b/App/apps/trivfo/trivfo_app.c @@ -14,6 +14,7 @@ #define CFG_MAGIC 0xC3u #define VFO_COUNT 3u #define MR_MAX 1024u +#define STATUS_PTT_X 54u static const app_api_t *A; static app_trivfo_info_t vi[VFO_COUNT]; @@ -30,6 +31,16 @@ static const uint8_t fontF[8] = { 0x7f, 0x00, 0x76, 0x76, 0x76, 0x76, 0x7e, 0x7f }; +/* Same two 2x6 glyphs and same x=54 slot as UI_DisplayStatus(). */ +static const uint8_t fontPttOnePush[12] = { + 0x00, 0x3e, 0x41, 0x41, 0x41, 0x3e, + 0x00, 0x7f, 0x09, 0x09, 0x09, 0x06 +}; +static const uint8_t fontPttClassic[12] = { + 0x00, 0x3e, 0x41, 0x41, 0x41, 0x22, + 0x00, 0x7f, 0x40, 0x40, 0x40, 0x40 +}; + static uint8_t slen(const char *s){ uint8_t n=0; while(s[n])n++; return n; } static bool copyName(char *d,const char *s){ uint8_t n=0; @@ -93,6 +104,11 @@ static void drawMeter(const app_trivfo_info_t *v){ } } +static void drawPttStatus(void){ + const uint8_t *glyph=(vi[0].flags&APP_TRIVFO_PTT_ONEPUSH)?fontPttOnePush:fontPttClassic; + for(uint8_t i=0;i<12u;i++) A->status_line[STATUS_PTT_X+i]=glyph[i]; +} + static void drawVfo(uint8_t n){ const app_trivfo_info_t *v=&vi[n]; uint8_t mainLine=(uint8_t)(n*2u+1u), techLine=(uint8_t)(mainLine+1u); @@ -114,22 +130,39 @@ static void drawVfo(uint8_t n){ } const char *mod=v->modulation==0u?"FM":v->modulation==1u?"AM":v->modulation==2u?"USB":v->modulation==3u?"BYP":v->modulation==4u?"RAW":"?"; - A->print_tiny(mod,3,techY,false,true); static const char *const power[7]={"LOW1","LOW2","LOW3","LOW4","LOW5","MID","HIGH"}; uint8_t p=(v->power>=1u&&v->power<=7u)?(uint8_t)(v->power-1u):0u; - A->print_tiny(power[p],24,techY,false,true); - if(v->flags&APP_TRIVFO_USER_POWER){ - A->fb[techLine][19]=0x3e; A->fb[techLine][20]=0x1c; A->fb[techLine][21]=0x08; + if(v->flags&APP_TRIVFO_GUI_CLASSIC){ + A->print_normal(mod,2,0,techLine); + if(v->modulation==0u&&v->code_type==1u) A->print_normal("CT",22,0,techLine); + else if(v->modulation==0u&&(v->code_type==2u||v->code_type==3u)) A->print_normal("DC",22,0,techLine); + static const char *const powerShort[7]={"L1","L2","L3","L4","L5","M","H"}; + A->print_normal(powerShort[p],42,0,techLine); + if(v->flags&APP_TRIVFO_USER_POWER){ + A->fb[techLine][38]=0x3e; A->fb[techLine][39]=0x1c; A->fb[techLine][40]=0x08; + } + if(v->offset_direction==1u) A->print_normal("+",60,0,techLine); + else if(v->offset_direction==2u) A->print_normal("-",60,0,techLine); + if(v->reverse) A->print_normal("R",68,0,techLine); + A->print_normal(v->bandwidth==0u?"W":v->bandwidth==1u?"N":"N+",80,0,techLine); + text[0]='S'; text[1]='Q'; text[2]='L'; text[3]=(char)('0'+(v->squelch%10u)); text[4]='\0'; + A->print_normal(text,98,0,techLine); + } else { + A->print_tiny(mod,3,techY,false,true); + A->print_tiny(power[p],24,techY,false,true); + if(v->flags&APP_TRIVFO_USER_POWER){ + A->fb[techLine][19]=0x3e; A->fb[techLine][20]=0x1c; A->fb[techLine][21]=0x08; + } + if(v->offset_direction==1u) A->print_normal("+",41,0,techLine); + else if(v->offset_direction==2u) A->print_normal("-",41,0,techLine); + if(v->reverse) A->print_tiny("R",51,techY,false,true); + if(v->code_type==1u){ A->print_tiny("CT",58,techY,false,true); formatCode(text,v); A->print_tiny(text,68,techY,false,true); } + else if(v->code_type==2u||v->code_type==3u){ A->print_tiny("DC",58,techY,false,true); formatCode(text,v); A->print_tiny(text,68,techY,false,true); } + else { formatStep(text,v->step); A->print_tiny(text,58,techY,false,true); } + A->print_tiny(v->bandwidth==0u?"WIDE":v->bandwidth==1u?"NAR":"NAR+",91,techY,false,true); + text[0]='S'; text[1]='Q'; text[2]='L'; text[3]=(char)('0'+(v->squelch%10u)); text[4]='\0'; + A->print_tiny(text,110,techY,false,true); } - if(v->offset_direction==1u) A->print_normal("+",41,0,techLine); - else if(v->offset_direction==2u) A->print_normal("-",41,0,techLine); - if(v->reverse) A->print_tiny("R",51,techY,false,true); - if(v->code_type==1u){ A->print_tiny("CT",58,techY,false,true); formatCode(text,v); A->print_tiny(text,68,techY,false,true); } - else if(v->code_type==2u||v->code_type==3u){ A->print_tiny("DC",58,techY,false,true); formatCode(text,v); A->print_tiny(text,68,techY,false,true); } - else { formatStep(text,v->step); A->print_tiny(text,58,techY,false,true); } - A->print_tiny(v->bandwidth==0u?"WIDE":v->bandwidth==1u?"NAR":"NAR+",91,techY,false,true); - text[0]='S'; text[1]='Q'; text[2]='L'; text[3]=(char)('0'+(v->squelch%10u)); text[4]='\0'; - A->print_tiny(text,110,techY,false,true); } static void draw(void){ @@ -144,6 +177,7 @@ static void draw(void){ /* Keep the application title in the resident upper-left position. */ A->draw_battery(); A->print_inverse("TRIPLE VFO",2,0,true,true,42); + drawPttStatus(); if(fArm){ for(uint8_t i=0;i<8u;i++) A->status_line[69u+i]=fontF[i]; } for(uint8_t i=0;i Date: Sun, 30 Aug 2026 17:39:43 +0200 Subject: [PATCH 48/73] Preserve MAIN VFO selection in Triple VFO --- App/apps/app_overlay.c | 7 ++++--- App/apps/trivfo/trivfo_app.c | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/App/apps/app_overlay.c b/App/apps/app_overlay.c index fad7ba2c..499ea1d0 100644 --- a/App/apps/app_overlay.c +++ b/App/apps/app_overlay.c @@ -225,8 +225,9 @@ static uint16_t app_trivfo_enter(uint16_t c_channel) app_trivfo_load_memory(&app_trivfo_c, c_channel); } - app_trivfo_selected = 0; - app_trivfo_pre_rx_selected = 0; + const uint8_t initial_vfo = gEeprom.TX_VFO < 2u ? gEeprom.TX_VFO : 0u; + app_trivfo_selected = initial_vfo; + app_trivfo_pre_rx_selected = initial_vfo; app_trivfo_restore_selection = false; app_trivfo_hold = 0; app_trivfo_running = true; @@ -234,7 +235,7 @@ static uint16_t app_trivfo_enter(uint16_t c_channel) app_trivfo_onepush_stop_armed = false; app_trivfo_ab_dirty = false; app_trivfo_freq_dirty = 0; - app_trivfo_tune(0); + app_trivfo_tune(initial_vfo); return c_channel; } diff --git a/App/apps/trivfo/trivfo_app.c b/App/apps/trivfo/trivfo_app.c index c5c36142..668ea3a3 100644 --- a/App/apps/trivfo/trivfo_app.c +++ b/App/apps/trivfo/trivfo_app.c @@ -200,7 +200,7 @@ static void saveCfg(void){ uint8_t c[3]={CFG_MAGIC,(uint8_t)cChannel,(uint8_t)(c __attribute__((section(".text.entry"),used)) void app_main(const app_api_t *api){ A=api; running=true; fArm=false; txDenied=false; channelLabelOn=true; showFrequency=false; selected=batteryTicks=0; loadCfg(); - cChannel=A->trivfo_enter(cChannel); A->trivfo_select(0); A->backlight_on(); + cChannel=A->trivfo_enter(cChannel); A->backlight_on(); draw(); uint8_t held=APP_KEY_INVALID, blinkTicks=0; uint16_t heldMs=0; bool longDone=false, ptt=false; while(running){ From 66ca97fa5eca27f85f51ff2dacd39dcd42cba2db Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 30 Aug 2026 18:31:50 +0200 Subject: [PATCH 49/73] Add K5Viewer support to overlay menu and apps --- App/apps/app_menu.c | 21 +++++++++++++++++++++ App/apps/app_overlay.c | 35 ++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/App/apps/app_menu.c b/App/apps/app_menu.c index b77f6cd0..8ea8f942 100644 --- a/App/apps/app_menu.c +++ b/App/apps/app_menu.c @@ -25,6 +25,9 @@ #include "driver/system.h" #include "driver/gpio.h" #include "ui/helper.h" +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER +#include "k5viewer.h" +#endif /* "F4HWN APPS" banner and the same thin separator used by the multiboot * selector. Bit 3 leaves room for the selected-row capsule's top edge. */ @@ -75,6 +78,12 @@ static KEY_Code_t app_get_key(void) { for (;;) { +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER + /* APP_MenuOpen() is modal and does not return to APP_Update(). Keep + * serial key injection and the viewer connection alive while waiting. */ + K5VIEWER_ParseInput(); + K5VIEWER_Update(false); +#endif KEY_Code_t key = KEYBOARD_Poll(); if (key != KEY_INVALID) { @@ -179,6 +188,15 @@ _Static_assert(APP_MENU_SLOT_COUNT <= APP_SLOT_COUNT, void APP_MenuOpen(void) { +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER + /* Detach the modal selector from the key state that triggered F+7. The + * normal K5Viewer updater suppresses frames while a key is held; without + * clearing this stale state, the selector could never publish its first + * frame to the viewer. */ + gKeyReading0 = KEY_INVALID; + gKeyReading1 = KEY_INVALID; +#endif + /* Apps are installed from UV Studio (0x073x) into physical slots 0..N-1, * shown here as 1..N. Keep empty slots in the list so their location is * visible and selectable while scrolling. */ @@ -245,6 +263,9 @@ void APP_MenuOpen(void) ST7565_BlitStatusLine(); ST7565_BlitFullScreen(); +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER + K5VIEWER_Update(false); +#endif const KEY_Code_t key = app_get_key(); if (key == KEY_EXIT) diff --git a/App/apps/app_overlay.c b/App/apps/app_overlay.c index 499ea1d0..4291e6da 100644 --- a/App/apps/app_overlay.c +++ b/App/apps/app_overlay.c @@ -33,6 +33,9 @@ #include "driver/st7565.h" #include "driver/system.h" #include "driver/backlight.h" +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER +#include "k5viewer.h" +#endif #include "app/app.h" #include "ui/helper.h" #include "ui/main.h" @@ -50,7 +53,25 @@ _Static_assert(sizeof(app_header_t) == 64, "app_header_t must be 64 bytes"); /* ---- ABI wrappers: the few resident calls that are not a direct signature match ---- */ -static uint8_t app_get_key(void) { return (uint8_t)KEYBOARD_GetKey(); } +static uint8_t app_get_key(void) +{ +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER + /* Overlay apps run synchronously outside APP_Update(). Keep serial key + * injection alive while an app owns the foreground loop. */ + K5VIEWER_ParseInput(); +#endif + return (uint8_t)KEYBOARD_GetKey(); +} + +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER +static void app_blit_full(void) +{ + ST7565_BlitFullScreen(); + /* The normal loop mirrors completed frames after drawing. Overlay apps + * bypass that loop, so publish the frame from this ABI wrapper. */ + K5VIEWER_Update(false); +} +#endif static int8_t app_nav_dir(uint8_t key) { int8_t direction; @@ -635,7 +656,11 @@ static const app_api_t app_api = { .draw_rect = UI_DrawRectangleBuffer, .print_bold = UI_PrintStringSmallBold, .print_tiny = GUI_DisplaySmallest, +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER + .blit_full = app_blit_full, +#else .blit_full = ST7565_BlitFullScreen, +#endif .blit_line = ST7565_BlitLine, .blit_status = ST7565_BlitStatusLine, .get_key = app_get_key, @@ -730,6 +755,14 @@ uint8_t APP_LaunchOverlay(uint8_t slot) app_fm_dirty = false; #endif +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER + /* The caller enters from a debounced key event, so the resident key state + * still contains that trigger while the modal app is running. Clear it so + * K5Viewer is allowed to mirror overlay frames immediately. */ + gKeyReading0 = KEY_INVALID; + gKeyReading1 = KEY_INVALID; +#endif + /* Pin RX to the user-selected VFO before the app runs. Under dual watch * gRxVfo is whichever VFO the receiver was parked on when F+7 was pressed, * so an RF app (FoxHunt, a future S-meter, ...) would measure and display a From bd049c0467ae86e8b7b594d45e128b203f88b676 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 30 Aug 2026 18:42:02 +0200 Subject: [PATCH 50/73] Add K5Viewer support to multiboot --- App/ui/multiboot.c | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/App/ui/multiboot.c b/App/ui/multiboot.c index 8a17232b..aeb08a05 100644 --- a/App/ui/multiboot.c +++ b/App/ui/multiboot.c @@ -24,10 +24,22 @@ #include "driver/system.h" #include "ui/helper.h" #include "ui/multiboot.h" +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER +#include "k5viewer.h" +#endif static uint8_t gRunningSlot = 0xFFu; static uint8_t gActiveBank = 0u; +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER +static void mb_k5viewer_service(void) +{ + /* The Multiboot selector is modal and does not return to APP_Update(). */ + K5VIEWER_ParseInput(); + K5VIEWER_Update(false); +} +#endif + static uint8_t mb_remember_boot_state(uint8_t slot, uint8_t bank) { gRunningSlot = slot; @@ -164,6 +176,9 @@ static void mb_show_message(const char *line1, const char *line2, const char *li if (line3) UI_PrintStringSmallNormal(line3, 2, 126, 6); ST7565_BlitStatusLine(); ST7565_BlitFullScreen(); +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER + mb_k5viewer_service(); +#endif } static void mb_wait_release(void) @@ -183,6 +198,9 @@ static KEY_Code_t mb_get_key(void) { for (;;) { +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER + mb_k5viewer_service(); +#endif KEY_Code_t key = KEYBOARD_Poll(); if (key != KEY_INVALID) { @@ -202,6 +220,10 @@ static KEY_Code_t mb_get_key(void) * NOT paint the "F4HWN MULTIBOOT" status banner - just a plain acknowledged message. */ void UI_MultibootShowConfigError(uint8_t err) { +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER + gKeyReading0 = KEY_INVALID; + gKeyReading1 = KEY_INVALID; +#endif UI_DisplayClear(); UI_StatusClear(); UI_PrintStringSmallNormal("CFG ERROR", 2, 126, 2); @@ -209,6 +231,9 @@ void UI_MultibootShowConfigError(uint8_t err) UI_PrintStringSmallNormal("Press any key", 2, 126, 6); ST7565_BlitStatusLine(); ST7565_BlitFullScreen(); +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER + mb_k5viewer_service(); +#endif /* The MENU press that confirmed SetCfg may still be down; wait for a clean * release first so it isn't consumed as the acknowledgement immediately. */ mb_wait_release(); @@ -301,6 +326,9 @@ __attribute__((noinline)) static void mb_prepare_progress_screen(const char *tit mb_draw_progress_outline(); ST7565_BlitStatusLine(); ST7565_BlitFullScreen(); +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER + mb_k5viewer_service(); +#endif } static void mb_prepare_progress(uint8_t slot) @@ -366,6 +394,13 @@ void UI_MultibootSelector(void) uint8_t status[MB_SLOT_COUNT]; uint8_t selected = 0; +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER + /* The selector is entered from a boot key event. Clear that stale key so + * K5Viewer is allowed to publish the first selector frame immediately. */ + gKeyReading0 = KEY_INVALID; + gKeyReading1 = KEY_INVALID; +#endif + /* Clear + blit the LCD BEFORE the backlight comes on, otherwise it reveals * the random power-on contents of the display RAM for a moment. */ mb_show_message("Release keys", NULL, NULL); From 4c3847ef34f569329c71805544a2db8bbf908a84 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 30 Aug 2026 22:22:20 +0200 Subject: [PATCH 51/73] Launch Broadcast FM overlay from FM action --- App/app/action.c | 12 +++++------- App/apps/app_overlay.c | 13 +++++++++++++ App/apps/app_overlay.h | 3 +++ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/App/app/action.c b/App/app/action.c index 7fa17f02..5ed05887 100644 --- a/App/app/action.c +++ b/App/app/action.c @@ -28,6 +28,9 @@ #ifdef ENABLE_FMRADIO #include "app/fm.h" #endif +#ifdef ENABLE_FEAT_F4HWN_OVERLAY_APPS + #include "apps/app_overlay.h" +#endif #include "app/scanner.h" #include "audio.h" #ifdef ENABLE_FMRADIO @@ -468,13 +471,8 @@ void ACTION_Handle(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) 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. */ + if (APP_LaunchOverlayByName("Broadcast FM") != APP_OK) + gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL; return; #else if (gCurrentFunction != FUNCTION_TRANSMIT && gCurrentFunction != FUNCTION_MONITOR) diff --git a/App/apps/app_overlay.c b/App/apps/app_overlay.c index 4291e6da..650de0f8 100644 --- a/App/apps/app_overlay.c +++ b/App/apps/app_overlay.c @@ -633,6 +633,19 @@ uint8_t APP_ValidateSlot(uint8_t slot, app_header_t *out_header) return APP_OK; } +uint8_t APP_LaunchOverlayByName(const char *name) +{ + app_header_t h; + + for (uint8_t slot = 0; slot < APP_SLOT_COUNT; slot++) { + if (APP_ValidateSlot(slot, &h) == APP_OK && + strncmp(h.name, name, APP_NAME_LEN) == 0) + return APP_LaunchOverlay(slot); + } + + return APP_ERR_MAGIC; +} + uint8_t APP_SlotInfo(uint8_t slot, app_header_t *out_header) { if (slot >= APP_SLOT_COUNT) diff --git a/App/apps/app_overlay.h b/App/apps/app_overlay.h index 676b1b9d..12465168 100644 --- a/App/apps/app_overlay.h +++ b/App/apps/app_overlay.h @@ -103,6 +103,9 @@ uint8_t APP_ValidateSlot(uint8_t slot, app_header_t *out_header); * Returns when the app exits; the internal flash is never touched. */ uint8_t APP_LaunchOverlay(uint8_t slot); +/* Find the first valid app with this exact header name and launch it. */ +uint8_t APP_LaunchOverlayByName(const char *name); + /* 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); From 0850420fdfd6d56f3b4ac38742769ba377f246f4 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 30 Aug 2026 23:12:27 +0200 Subject: [PATCH 52/73] Decouple FoxHunt and Beacon actions --- App/CMakeLists.txt | 8 +- App/app/action.c | 63 ++++++++++- App/app/app.c | 2 +- App/app/foxhunt.c | 222 +++++++++++++++++-------------------- App/app/foxhunt.h | 15 ++- App/apps/app_overlay.c | 54 +++++++++ App/apps/app_overlay.h | 7 ++ App/bitmaps.c | 2 +- App/bitmaps.h | 2 +- App/driver/eeprom_compat.c | 7 +- App/helper/battery.c | 2 +- App/helper/battery.h | 2 +- App/settings.h | 3 +- App/ui/menu.c | 3 +- App/ui/status.c | 2 +- App/ui/status.h | 2 +- CMakePresets.json | 4 + README.md | 5 +- 18 files changed, 258 insertions(+), 147 deletions(-) diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index cc4e029e..d3b6dd30 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -234,9 +234,11 @@ enable_feature(ENABLE_FEAT_F4HWN_BEAM enable_feature(ENABLE_FEAT_F4HWN_RXTX_LOG app/rxtx_log.c ) -enable_feature(ENABLE_FEAT_F4HWN_FOXHUNT - app/foxhunt.c -) +enable_feature(ENABLE_FEAT_F4HWN_FOXHUNT) +enable_feature(ENABLE_FEAT_F4HWN_BEACON) +if(ENABLE_FEAT_F4HWN_FOXHUNT OR ENABLE_FEAT_F4HWN_BEACON) + target_sources(App INTERFACE app/foxhunt.c) +endif() enable_feature(ENABLE_FEAT_F4HWN_ACTION_PICKER) if(ENABLE_FEAT_F4HWN_ACTION_PICKER AND NOT ENABLE_FEAT_F4HWN) diff --git a/App/app/action.c b/App/app/action.c index 5ed05887..9462bd7f 100644 --- a/App/app/action.c +++ b/App/app/action.c @@ -51,7 +51,7 @@ #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG #include "app/rxtx_log.h" #endif -#ifdef ENABLE_FEAT_F4HWN_FOXHUNT +#if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_BEACON) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) #include "app/foxhunt.h" #endif #ifdef ENABLE_FEAT_F4HWN_ACTION_PICKER @@ -119,9 +119,12 @@ void (*const action_opt_table[ACTION_OPT_LEN])(void) = { #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG [ACTION_OPT_RXTX_LOG] = &ACTION_RxTxLog, #endif -#ifdef ENABLE_FEAT_F4HWN_FOXHUNT +#if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) [ACTION_OPT_FOXHUNT] = &ACTION_FoxHunt, #endif +#if defined(ENABLE_FEAT_F4HWN_BEACON) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) + [ACTION_OPT_BEACON] = &ACTION_Beacon, +#endif }; static_assert(ARRAY_SIZE(action_opt_table) == ACTION_OPT_LEN); @@ -130,10 +133,33 @@ static_assert(ACTION_OPT_BEAM == 19); static_assert(ACTION_OPT_POWER_HIGH == 20); static_assert(ACTION_OPT_REMOVE_OFFSET == 21); static_assert(ACTION_OPT_FOXHUNT == 22); +static_assert(ACTION_OPT_BEACON == 23); bool ACTION_IsAvailable(uint8_t action) { - return action < ACTION_OPT_LEN && action_opt_table[action] != NULL; + if (action >= ACTION_OPT_LEN || action_opt_table[action] == NULL) + return false; + +#ifdef ENABLE_FEAT_F4HWN_OVERLAY_APPS + switch (action) { +#ifdef ENABLE_FMRADIO + case ACTION_OPT_FM: + return (APP_OverlayShortcutMask() & APP_SHORTCUT_FM) != 0; +#endif +#ifndef ENABLE_FEAT_F4HWN_FOXHUNT + case ACTION_OPT_FOXHUNT: + return (APP_OverlayShortcutMask() & APP_SHORTCUT_FOXHUNT) != 0; +#endif +#ifndef ENABLE_FEAT_F4HWN_BEACON + case ACTION_OPT_BEACON: + return (APP_OverlayShortcutMask() & APP_SHORTCUT_BEACON) != 0; +#endif + default: + break; + } +#endif + + return true; } void ACTION_Power(void) @@ -315,8 +341,11 @@ inline static bool ACTION_IsBlockedInFM(uint8_t action) #ifdef ENABLE_FEAT_F4HWN_BEAM case ACTION_OPT_BEAM: #endif -#ifdef ENABLE_FEAT_F4HWN_FOXHUNT +#if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) case ACTION_OPT_FOXHUNT: +#endif +#if defined(ENABLE_FEAT_F4HWN_BEACON) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) + case ACTION_OPT_BEACON: #endif return true; @@ -466,6 +495,32 @@ void ACTION_Handle(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) ACTION_Execute(func); } +#if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) +void ACTION_FoxHunt(void) +{ +#ifdef ENABLE_FEAT_F4HWN_FOXHUNT + APP_RunFoxHunt(); + GUI_SelectNextDisplay(DISPLAY_MAIN); +#else + if (APP_LaunchOverlayByName("FoxHunt") != APP_OK) + gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL; +#endif +} +#endif + +#if defined(ENABLE_FEAT_F4HWN_BEACON) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) +void ACTION_Beacon(void) +{ +#ifdef ENABLE_FEAT_F4HWN_BEACON + APP_RunBeacon(); + GUI_SelectNextDisplay(DISPLAY_MAIN); +#else + if (APP_LaunchOverlayByName("Beacon") != APP_OK) + gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL; +#endif +} +#endif + #ifdef ENABLE_FMRADIO void ACTION_FM(void) diff --git a/App/app/app.c b/App/app/app.c index 28282d8e..fd64c7fa 100644 --- a/App/app/app.c +++ b/App/app/app.c @@ -1985,7 +1985,7 @@ void APP_TimeSlice500ms(void) if ((gBatteryCheckCounter & 1) == 0) { -#if defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) || defined(ENABLE_FEAT_F4HWN_FOXHUNT) +#if defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) || defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_BEACON) BATTERY_Sample(true); #else BOARD_ADC_GetBatteryInfo(&gBatteryVoltages[gBatteryVoltageIndex++], &gBatteryCurrent); diff --git a/App/app/foxhunt.c b/App/app/foxhunt.c index 20e7be20..17dfe0c7 100644 --- a/App/app/foxhunt.c +++ b/App/app/foxhunt.c @@ -16,7 +16,7 @@ #include "app/foxhunt.h" -#ifdef ENABLE_FEAT_F4HWN_FOXHUNT +#if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_BEACON) #if defined(ENABLE_UART) || defined(ENABLE_USB) #include "app/uart.h" @@ -86,7 +86,7 @@ #define FOXHUNT_AUDIO_BEEP 1 #define FOXHUNT_AUDIO_STATION 2 -// --- Beacon (fox) sub-mode --------------------------------------------------- +// --- Beacon (fox) app -------------------------------------------------------- // Turns the radio into the hidden transmitter: each cycle keys up on the TX VFO and // repeats the CW fox identifier (MOE..MO5, or " MOE") in Morse for the TX window, // then stays silent for the (adjustable) idle gap. The carrier stays up during the @@ -201,8 +201,7 @@ static const uint8_t FOXHUNT_MORSE_DIGIT[10] = { // Trailing character of each fox id: MOE / MOI / MOS / MOH / MO5 (index = FOXHUNT_FOX_*). static const char FOXHUNT_FOX_TAIL[5] = {'E', 'I', 'S', 'H', '5'}; -// Beacon sub-mode state. -static bool foxBeacon; // false = hunt (RX), true = beacon (TX) +// Beacon state. static bool beaconPhaseTx; // true = transmit this tick, false = idle gap static uint8_t beaconIdle; // configured silence between IDs (s) static uint8_t beaconIdleLeft; // seconds left in the current silence @@ -782,11 +781,6 @@ static void FOXHUNT_HandleKeys(void) minDbm = curDbm; trendRef = curDbm; break; - case KEY_SIDE1: - case KEY_SIDE2: - // Same shortcut that opened Fox Hunt now toggles to the beacon. - FOXHUNT_EnterBeacon(); - break; default: break; } @@ -794,22 +788,18 @@ static void FOXHUNT_HandleKeys(void) gWasFKeyPressed = false; // any non-F key consumes (or cancels) the reverse arm } -// (Re)enter the hunt (RX) sub-mode: fixed front-end gain, attenuator and audio mode -// re-applied, peak/trend reset. Called at start-up and when leaving the beacon. The -// audio mode is a hunt setting, so it is (re)applied here, not forced off — it must -// survive a trip through the beacon. +// Enter Fox Hunt with fixed front-end gain, the persisted attenuator/audio mode, +// and freshly rebased level tracking. static void FOXHUNT_EnterHunt(void) { - foxBeacon = false; - BK4819_SetAGC(false); FOXHUNT_ApplyAtt(); FOXHUNT_SetAudio(); // (re)apply the current audio mode: off / beep / station // Let the RSSI settle to the gain just applied, then reset the peak/min hold, trend - // reference and signal history onto a fresh reading (also re-primes the sparkline on - // return from the beacon). Same settle as FOXHUNT_AttCycle: without it a restored - // BYP/BYP+ step at start-up or on beacon return would rebase onto the old-gain reading. + // reference and signal history onto a fresh reading. Same settle as + // FOXHUNT_AttCycle: without it a restored + // BYP/BYP+ step at start-up would rebase onto the old-gain reading. // SetAudio already waits when audio is on, but not in the default audio-off case. SYSTEM_DelayMs(FOXHUNT_ATT_SETTLE_MS); FOXHUNT_RebaseMeasurements(); @@ -836,9 +826,8 @@ static VfoState_t FOXHUNT_TxState(void) // Refuse feedback shown when a burst is barred: reuse the beacon screen layout and // the radio's own state label (VfoStateStr, e.g. "TX DISABLE" / "BAT LOW" / "VOLT -// HIGH"), same font as the main screen, for a beat; the caller then falls back to -// the hunt. The RX front-end and audio path are left untouched so the hunt keeps -// reading cleanly. +// HIGH"), same font as the main screen, for a beat; the caller then waits through +// a fresh idle gap before checking again. static void FOXHUNT_TxDeniedNotice(VfoState_t state) { // Frame (clear + BEACON tag + battery + barred TX frequency). @@ -855,12 +844,10 @@ static void FOXHUNT_TxDeniedNotice(VfoState_t state) FOXHUNT_TickDelay(); } -// Enter the beacon (fox) sub-mode. The TX gates are enforced per burst in -// FOXHUNT_BeaconTick (which runs immediately), so entry just arms the state. +// Enter the Beacon app. TX gates are enforced before each burst. static void FOXHUNT_EnterBeacon(void) { - foxBeacon = true; - AUDIO_AudioPathOff(); // no RX audio while beaconing; foxAudioMode kept for the hunt + AUDIO_AudioPathOff(); gCurrentVfo = gTxVfo; // the VFO RADIO_SetTxParameters keys up beaconPhaseTx = true; } @@ -879,8 +866,8 @@ static void FOXHUNT_DrawTxSeconds(void) } // Sleep in short slices while watching the keypad, so a burst stays interactive and the -// TX-window countdown (beaconTxMsLeft) drains in real time. EXIT leaves Fox Hunt and a -// side key aborts back to the hunt (both return true). The 1/2/3 keys edit the beacon +// TX-window countdown (beaconTxMsLeft) drains in real time. EXIT leaves Beacon. The +// 1/2/3 keys edit the beacon // settings live, exactly as in the idle phase: the TX window (1) and idle gap (2) take // effect at the upcoming window / gap, and a FOX change (3) at the next repeat (the // message is rebuilt there, never under the running loop). @@ -926,17 +913,12 @@ static bool FOXHUNT_TxDelay(uint16_t ms) continue; if (kbd.current == KEY_EXIT) { - foxRunning = false; // abort the burst and leave Fox Hunt - return true; - } - if (kbd.current == KEY_SIDE1 || kbd.current == KEY_SIDE2) { - foxBeacon = false; // abort the burst and switch back to hunt + foxRunning = false; // abort the burst and leave Beacon return true; } if (kbd.current == KEY_MENU) { - // Cut the transmission short but stay in Beacon: aborting the burst without - // clearing foxBeacon/foxRunning drops the loop straight into a fresh idle - // gap of the configured IDLE length. + // Cut the transmission short but stay in Beacon: the loop drops straight + // into a fresh idle gap of the configured IDLE length. return true; } if (kbd.current == KEY_F) { // arm / disarm the reverse step @@ -1051,8 +1033,8 @@ static void FOXHUNT_BeaconTransmit(void) stop = FOXHUNT_TxDelay(FOXHUNT_MORSE_UNIT_MS * 7); } - // Key down: mute, drop the PA and restore RX for the silent gap. If the burst - // was interrupted, FOXHUNT_TxDelay already set the target mode (quit or hunt). + // Key down: mute, drop the PA and restore RX for the silent gap. If EXIT + // interrupted the burst, FOXHUNT_TxDelay has already stopped the app. BK4819_EnterTxMute(); BK4819_ToggleGpioOut(BK4819_GPIO1_PIN29_PA_ENABLE, false); RADIO_SetupRegisters(true); @@ -1208,11 +1190,7 @@ static void FOXHUNT_BeaconKeys(void) switch (kbd.current) { case KEY_EXIT: - foxRunning = false; // leave Fox Hunt entirely - break; - case KEY_SIDE1: - case KEY_SIDE2: - foxBeacon = false; // toggle back to the hunt + foxRunning = false; break; case KEY_MENU: // Restart a full idle interval now (the TX-phase M drops here too). @@ -1237,28 +1215,26 @@ static void FOXHUNT_BeaconTick(void) // LOCK / TX LOCK and modulation are fixed for the session, but the battery // is re-sampled over the idle gaps (FOXHUNT_IdleHousekeeping), so a beacon // left running stops keying up once the pack falls low or over-voltage - // instead of transmitting blind. Refuse, notify, and drop back to hunt. + // instead of transmitting blind. Refuse, notify, then retry after an idle gap. VfoState_t state = FOXHUNT_TxState(); if (state != VFO_STATE_NORMAL) { FOXHUNT_TxDeniedNotice(state); - foxBeacon = false; beaconPhaseTx = false; - FOXHUNT_EnterHunt(); + beaconIdleLeft = beaconIdle; + beaconIdleTick = 0; return; } // The burst repeats the ID for the whole TX window, redrawing the screen - // itself each repeat (remaining-window countdown); it may clear foxBeacon - // (side-key abort) or foxRunning (EXIT). + // itself each repeat (remaining-window countdown); EXIT may clear + // foxRunning and abort it. FOXHUNT_BeaconTransmit(); beaconPhaseTx = false; beaconIdleLeft = beaconIdle; beaconIdleTick = 0; - if (foxRunning && !foxBeacon) // side-key switched to hunt - FOXHUNT_EnterHunt(); - return; // EXIT (quit) falls through + return; } // Idle (silent) phase: responsive, editable countdown. @@ -1267,12 +1243,8 @@ static void FOXHUNT_BeaconTick(void) #endif FOXHUNT_BeaconKeys(); - if (!foxRunning) // EXIT: leave Fox Hunt + if (!foxRunning) return; - if (!foxBeacon) { // side-key: back to the hunt - FOXHUNT_EnterHunt(); - return; - } FOXHUNT_BeaconDraw(false, beaconIdleLeft); FOXHUNT_BlitScreen(); @@ -1340,7 +1312,7 @@ static void FOXHUNT_LoadConfig(void) // Write the settings to flash, but only when they changed since the last write. // Called on the 500 ms tick (so any change persists within ~0.5 s and survives a -// power-off, not just a clean EXIT) and once more on leaving Fox Hunt. The RAM +// power-off, not just a clean EXIT) and once more on leaving either app. The RAM // compare avoids re-reading the 4 KB sector on every quiet tick. static void FOXHUNT_SaveConfig(void) { @@ -1354,83 +1326,90 @@ static void FOXHUNT_SaveConfig(void) memcpy(foxCfgSaved, cfg, sizeof(cfg)); } -void APP_RunFoxHunt(void) +static void FOXHUNT_Begin(void) { - // Finish any pending backlight fade, then start with the screen on and a full - // BLTime window (BACKLIGHT_TurnOn also re-arms the sleep countdown). BACKLIGHT_UpdateTickless(); BACKLIGHT_TurnOn(); - // Hunt on the user-selected VFO: dual-watch / cross-band may have left the - // radio listening on the other VFO, so force RX on the selected one and retune. + // Both apps operate on the user-selected VFO. Dual watch may have left the + // receiver on the other one, so establish a deterministic starting point. gEeprom.RX_VFO = gEeprom.TX_VFO; gRxVfo = gTxVfo; gCurrentVfo = gTxVfo; RADIO_SetupRegisters(true); - // Beacon defaults (LoadConfig may override fox / tx / idle below). - beaconIdle = FOXHUNT_BEACON_IDLE_DEF; - beaconTx = FOXHUNT_BEACON_TX_DEF; - foxFox = FOXHUNT_FOX_CALL; // identified beacon by default (legal on ham) - beaconPhaseTx = false; - - // Read the callsign from the boot message (line 1), sanitised to what Morse can - // send; empty/erased leaves foxCall empty (the CALL id then falls back to bare MOE). - { - char call[FOXHUNT_CALLSIGN_MAX + 1]; - uint8_t n = 0; - PY25Q16_ReadBuffer(FOXHUNT_CALLSIGN_ADDR, call, FOXHUNT_CALLSIGN_MAX); - call[FOXHUNT_CALLSIGN_MAX] = '\0'; - for (uint8_t i = 0; i < FOXHUNT_CALLSIGN_MAX; i++) { - char c = call[i]; - if (c == '\0' || (uint8_t)c == 0xFF) - break; - if (c >= 'a' && c <= 'z') - c -= 32; - if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '/') - foxCall[n++] = c; - } - foxCall[n] = '\0'; - } - - // Start in the hunt (RX) sub-mode, S-meter staircase gauge by default, then restore - // any persisted settings (attenuator, gauge, audio, beacon idle / fox / tx). + // Initialise the complete legacy seven-byte configuration before loading it. + // Keeping the layout unchanged preserves existing FieldOps settings and lets + // either independently compiled app update its fields without a migration. attStep = 0; foxGraphMode = FOXHUNT_GRAPH_BAR; foxAudioMode = FOXHUNT_AUDIO_OFF; - FOXHUNT_LoadConfig(); // may override att / gauge / audio / idle / fox / tx - FOXHUNT_EnterHunt(); // applies the restored attStep and audio mode - // beaconMsg is (re)built by FOXHUNT_BeaconTransmit before every burst, and nothing - // reads it before the first burst, so no need to assemble it here. + beaconIdle = FOXHUNT_BEACON_IDLE_DEF; + beaconTx = FOXHUNT_BEACON_TX_DEF; + foxFox = FOXHUNT_FOX_CALL; + beaconPhaseTx = false; + FOXHUNT_LoadConfig(); - // Prime the key state with whatever is held right now, so the side key that - // launched Fox Hunt (still down on a long-press assignment) is not taken for - // a fresh press and does not immediately toggle to the beacon. + // Ignore the key that launched the modal app until it has been released. kbd.prev = kbd.current = KEYBOARD_GetKey(); - gWasFKeyPressed = false; // start with the reverse-step arm cleared - - // Always enter unlocked: the keypad lock is a transient safety toggle, not a - // persisted setting, so a previous session must not leave the pad locked. + gWasFKeyPressed = false; foxLocked = false; fHoldMs = 0; fLongDone = false; - foxRunning = true; +} + +static void FOXHUNT_LoadCallsign(void) +{ + char call[FOXHUNT_CALLSIGN_MAX + 1]; + uint8_t n = 0; + + // Read the callsign from the boot message (line 1), sanitised to what Morse can + // send; empty/erased leaves foxCall empty (the CALL id then falls back to bare MOE). + PY25Q16_ReadBuffer(FOXHUNT_CALLSIGN_ADDR, call, FOXHUNT_CALLSIGN_MAX); + call[FOXHUNT_CALLSIGN_MAX] = '\0'; + for (uint8_t i = 0; i < FOXHUNT_CALLSIGN_MAX; i++) { + char c = call[i]; + if (c == '\0' || (uint8_t)c == 0xFF) + break; + if (c >= 'a' && c <= 'z') + c -= 32; + if ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '/') + foxCall[n++] = c; + } + foxCall[n] = '\0'; +} + +static void FOXHUNT_End(void) +{ + FOXHUNT_SaveConfig(); + gWasFKeyPressed = false; + AUDIO_AudioPathOff(); + BK4819_ToggleGpioOut(BK4819_GPIO1_PIN29_PA_ENABLE, false); + RADIO_SelectVfos(); + RADIO_SetupRegisters(true); +} + +/* Both entry points stay in this translation unit so their small modal services + * can be shared when both features are enabled. The action table references only + * the enabled entry point(s); --gc-sections then drops the complete unused RX or + * Beacon call graph, including its private constants and bitmaps. */ +void APP_RunFoxHunt(void) +{ + FOXHUNT_Begin(); + + FOXHUNT_EnterHunt(); while (foxRunning) { #if defined(ENABLE_UART) || defined(ENABLE_USB) UART_ServiceCommands(); #endif - if (foxBeacon) { - FOXHUNT_BeaconTick(); - continue; - } #ifdef ENABLE_FEAT_F4HWN_K5VIEWER // Keep the K5Viewer link alive and pick up any remote key. K5VIEWER_ParseInput(); #endif FOXHUNT_HandleKeys(); - if (foxBeacon) // just switched to beacon: skip the RX work this tick - continue; + if (!foxRunning) + break; curDbm = FOXHUNT_ReadDbm(); if (curDbm > peakDbm) @@ -1468,24 +1447,23 @@ void APP_RunFoxHunt(void) FOXHUNT_TickDelay(); // tick delay + smooth backlight fade } - // Persist the session's settings on the way out (EXIT clears foxRunning) so - // they survive a later power cycle. - FOXHUNT_SaveConfig(); - - gWasFKeyPressed = false; // don't leak a pending reverse-step arm to the main screen - - // Mute any pending tone, drop the PA (safety), then restore the normal VFO - // selection and RX config. - AUDIO_AudioPathOff(); - BK4819_ToggleGpioOut(BK4819_GPIO1_PIN29_PA_ENABLE, false); - RADIO_SelectVfos(); - RADIO_SetupRegisters(true); + FOXHUNT_End(); } -void ACTION_FoxHunt(void) +void APP_RunBeacon(void) { - APP_RunFoxHunt(); - GUI_SelectNextDisplay(DISPLAY_MAIN); + FOXHUNT_Begin(); + FOXHUNT_LoadCallsign(); + FOXHUNT_EnterBeacon(); + + while (foxRunning) { +#if defined(ENABLE_UART) || defined(ENABLE_USB) + UART_ServiceCommands(); +#endif + FOXHUNT_BeaconTick(); + } + + FOXHUNT_End(); } -#endif // ENABLE_FEAT_F4HWN_FOXHUNT +#endif // ENABLE_FEAT_F4HWN_FOXHUNT || ENABLE_FEAT_F4HWN_BEACON diff --git a/App/app/foxhunt.h b/App/app/foxhunt.h index 3743e6b1..a973d319 100644 --- a/App/app/foxhunt.h +++ b/App/app/foxhunt.h @@ -17,7 +17,7 @@ #ifndef APP_FOXHUNT_H #define APP_FOXHUNT_H -#ifdef ENABLE_FEAT_F4HWN_FOXHUNT +#if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_BEACON) #include "keyboard_state.h" @@ -46,12 +46,21 @@ #include #include #include +#endif -// Entry point wired as an assignable side-key action (see action.c). +#if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_BEACON) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) +// Assignable resident/overlay launchers (see action.c). void ACTION_FoxHunt(void); +void ACTION_Beacon(void); +#endif +#ifdef ENABLE_FEAT_F4HWN_FOXHUNT // Self-contained modal loop that owns the screen and keypad until EXIT. void APP_RunFoxHunt(void); +#endif + +#ifdef ENABLE_FEAT_F4HWN_BEACON +void APP_RunBeacon(void); +#endif -#endif // ENABLE_FEAT_F4HWN_FOXHUNT #endif // APP_FOXHUNT_H diff --git a/App/apps/app_overlay.c b/App/apps/app_overlay.c index 650de0f8..6b90d454 100644 --- a/App/apps/app_overlay.c +++ b/App/apps/app_overlay.c @@ -633,8 +633,60 @@ uint8_t APP_ValidateSlot(uint8_t slot, app_header_t *out_header) return APP_OK; } +static bool app_shortcuts_cached; +static uint8_t app_shortcut_mask; +static uint8_t app_shortcut_slots[3]; + +static int8_t app_shortcut_index(const char *name) +{ + if (strncmp(name, "Broadcast FM", APP_NAME_LEN) == 0) return 0; + if (strncmp(name, "FoxHunt", APP_NAME_LEN) == 0) return 1; + if (strncmp(name, "Beacon", APP_NAME_LEN) == 0) return 2; + return -1; +} + +static void app_cache_shortcuts(void) +{ + if (app_shortcuts_cached) + return; + + app_shortcut_mask = 0; + const uint32_t overlay_vma = (uint32_t)PY25Q16_OverlayBuffer(); + + for (uint8_t slot = 0; slot < APP_SLOT_COUNT; slot++) { + app_header_t h; + if (APP_ValidateSlot(slot, &h) != APP_OK || h.link_vma != overlay_vma) + continue; + + const int8_t index = app_shortcut_index(h.name); + if (index >= 0) { + const uint8_t bit = (uint8_t)(1u << index); + if (!(app_shortcut_mask & bit)) { + app_shortcut_mask |= bit; + app_shortcut_slots[index] = slot; + } + } + } + + app_shortcuts_cached = true; +} + +uint8_t APP_OverlayShortcutMask(void) +{ + app_cache_shortcuts(); + return app_shortcut_mask; +} + uint8_t APP_LaunchOverlayByName(const char *name) { + const int8_t index = app_shortcut_index(name); + if (index >= 0) { + app_cache_shortcuts(); + return (app_shortcut_mask & (1u << index)) + ? APP_LaunchOverlay(app_shortcut_slots[index]) + : APP_ERR_MAGIC; + } + app_header_t h; for (uint8_t slot = 0; slot < APP_SLOT_COUNT; slot++) { @@ -840,6 +892,7 @@ uint8_t APP_SlotErase(uint8_t slot) for (uint32_t off = 0; off < APP_SLOT_STRIDE; off += APP_SECTOR_SIZE) PY25Q16_SectorErase(base + off); PY25Q16_InvalidateCache(); + app_shortcuts_cached = false; return APP_OK; } @@ -851,6 +904,7 @@ uint8_t APP_SlotWrite(uint8_t slot, uint32_t offset, const uint8_t *data, uint32 return APP_ERR_SIZE; PY25Q16_WriteBuffer(APP_SLOT_BASE(slot) + offset, data, len, false); PY25Q16_InvalidateCache(); + app_shortcuts_cached = false; return APP_OK; } diff --git a/App/apps/app_overlay.h b/App/apps/app_overlay.h index 12465168..e2c2e778 100644 --- a/App/apps/app_overlay.h +++ b/App/apps/app_overlay.h @@ -70,6 +70,10 @@ #define APP_NAME_LEN 16 #define APP_VERSION_LEN 16 +#define APP_SHORTCUT_FM (1u << 0) +#define APP_SHORTCUT_FOXHUNT (1u << 1) +#define APP_SHORTCUT_BEACON (1u << 2) + typedef struct __attribute__((packed)) { uint32_t magic; /* APP_MAGIC */ uint16_t hdr_version; /* APP_HDR_VERSION */ @@ -106,6 +110,9 @@ uint8_t APP_LaunchOverlay(uint8_t slot); /* Find the first valid app with this exact header name and launch it. */ uint8_t APP_LaunchOverlayByName(const char *name); +/* Cached availability of apps exposed as resident quick actions. */ +uint8_t APP_OverlayShortcutMask(void); + /* Host-tool slot management (external flash only, never brick-critical). */ uint8_t APP_SlotInfo(uint8_t slot, app_header_t *out_header); uint8_t APP_SlotErase(uint8_t slot); diff --git a/App/bitmaps.c b/App/bitmaps.c index 141a7026..21f8cff1 100644 --- a/App/bitmaps.c +++ b/App/bitmaps.c @@ -301,7 +301,7 @@ const uint8_t BITMAP_NOAA[12] = }; #endif -#ifdef ENABLE_FEAT_F4HWN_FOXHUNT +#if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_BEACON) const uint8_t BITMAP_FoxHuntSignal[10] = { // point source + the speaker bitmap's two sound waves, oriented horizontally 0b00001000, diff --git a/App/bitmaps.h b/App/bitmaps.h index f64c332e..fba73398 100644 --- a/App/bitmaps.h +++ b/App/bitmaps.h @@ -45,7 +45,7 @@ extern const uint8_t BITMAP_compand[6]; extern const uint8_t BITMAP_NOAA[12]; -#ifdef ENABLE_FEAT_F4HWN_FOXHUNT +#if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_BEACON) extern const uint8_t BITMAP_FoxHuntSignal[10]; extern const uint8_t BITMAP_FoxHuntSpeaker[10]; extern const uint8_t BITMAP_FoxHuntUp[11]; diff --git a/App/driver/eeprom_compat.c b/App/driver/eeprom_compat.c index a2a2215b..fd36bb58 100644 --- a/App/driver/eeprom_compat.c +++ b/App/driver/eeprom_compat.c @@ -54,10 +54,9 @@ static const AddrMapping_t ADDR_MAPPINGS[] = { _MK_MAPPING(0x009000, 0x009000, 0x0090E7), // 14 VFO * 16 Bytes = 0x9000 -> 0x90E0 (the old // 0x90D6 bound was 10 B short: it truncated the // 470 MHz VFO1 record at 0x90D0 -> 0x90DF) - // Fox Hunt settings * 7 Bytes 0x90E0 -> 0x90E7 - // (ENABLE_FEAT_F4HWN_FOXHUNT, written directly by - // app/foxhunt.c; concatenated here so aircopy clones - // them together with the VFOs) + // Fox Hunt / Beacon settings * 7 Bytes 0x90E0 -> 0x90E7 + // (written directly by app/foxhunt.c; concatenated + // here so aircopy clones them with the VFOs) _MK_MAPPING(0x00A000, 0x00A000, 0x00A170), // Settings * 16 Bytes (ex 0x004000) 0x00A000 -> 0x00A010 // Settings * 16 Bytes (ex 0x005000) 0x00A010 -> 0x00A020 diff --git a/App/helper/battery.c b/App/helper/battery.c index d3fd2fa9..244078cd 100644 --- a/App/helper/battery.c +++ b/App/helper/battery.c @@ -130,7 +130,7 @@ unsigned int BATTERY_VoltsToPercent(const unsigned int voltage_10mV) return 0; } -#if defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) || defined(ENABLE_FEAT_F4HWN_FOXHUNT) +#if defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) || defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_BEACON) void BATTERY_Sample(const bool bDisplayBatteryLevel) { BOARD_ADC_GetBatteryInfo(&gBatteryVoltages[gBatteryVoltageIndex++], &gBatteryCurrent); diff --git a/App/helper/battery.h b/App/helper/battery.h index 6b290ab5..28fa412e 100644 --- a/App/helper/battery.h +++ b/App/helper/battery.h @@ -45,7 +45,7 @@ typedef enum { unsigned int BATTERY_VoltsToPercent(unsigned int voltage_10mV); -#if defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) || defined(ENABLE_FEAT_F4HWN_FOXHUNT) +#if defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) || defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_BEACON) void BATTERY_Sample(bool bDisplayBatteryLevel); #endif void BATTERY_GetReadings(bool bDisplayBatteryLevel); diff --git a/App/settings.h b/App/settings.h index 22b003ff..4673a56c 100644 --- a/App/settings.h +++ b/App/settings.h @@ -126,8 +126,9 @@ enum ACTION_OPT_t { ACTION_OPT_POWER_HIGH = 20, ACTION_OPT_REMOVE_OFFSET = 21, ACTION_OPT_FOXHUNT = 22, + ACTION_OPT_BEACON = 23, - ACTION_OPT_LEN = 23 + ACTION_OPT_LEN = 24 }; #ifdef ENABLE_VOICE diff --git a/App/ui/menu.c b/App/ui/menu.c index 2e7a0a30..1c355706 100644 --- a/App/ui/menu.c +++ b/App/ui/menu.c @@ -495,7 +495,8 @@ const t_sidefunction gSubMenu_SIDEFUNCTIONS[] = {"BEAM", ACTION_OPT_BEAM}, {"POWER\nHIGH", ACTION_OPT_POWER_HIGH}, {"REMOVE\nOFFSET", ACTION_OPT_REMOVE_OFFSET}, - {"FOX HUNT\nBEACON", ACTION_OPT_FOXHUNT}, + {"FOX HUNT", ACTION_OPT_FOXHUNT}, + {"BEACON", ACTION_OPT_BEACON}, }; const uint8_t gSubMenu_SIDEFUNCTIONS_size = ARRAY_SIZE(gSubMenu_SIDEFUNCTIONS); diff --git a/App/ui/status.c b/App/ui/status.c index 62e6e71b..fe24697b 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 -#if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) +#if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_BEACON) || 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 f2e0d875..84bb71fa 100644 --- a/App/ui/status.h +++ b/App/ui/status.h @@ -19,7 +19,7 @@ #include -#if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) +#if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_BEACON) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) void UI_DrawStatusBattery(uint8_t *line, char *str); #endif void UI_DisplayStatus(); diff --git a/CMakePresets.json b/CMakePresets.json index ebe73235..5c422fd3 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -82,6 +82,7 @@ "ENABLE_FEAT_F4HWN_SCAN_SUBAUDIBLE": false, "ENABLE_FEAT_F4HWN_BEAM": false, "ENABLE_FEAT_F4HWN_FOXHUNT": false, + "ENABLE_FEAT_F4HWN_BEACON": false, "ENABLE_FEAT_F4HWN_ACTION_PICKER": false, "ENABLE_FEAT_F4HWN_RXTX_LOG": false, "ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP": false, @@ -132,6 +133,7 @@ "ENABLE_FEAT_F4HWN_SCAN_SUBAUDIBLE": true, "ENABLE_FEAT_F4HWN_BEAM": false, "ENABLE_FEAT_F4HWN_FOXHUNT": false, + "ENABLE_FEAT_F4HWN_BEACON": false, "ENABLE_FEAT_F4HWN_RXTX_LOG": true, "ENABLE_FEAT_F4HWN_RXTX_LOG_WRAP": false, "ENABLE_FEAT_F4HWN_RXTX_LOG_K5VIEWER": true, @@ -155,6 +157,7 @@ "cacheVariables": { "ENABLE_FEAT_F4HWN_RESCUE_OPS": true, "ENABLE_FEAT_F4HWN_FOXHUNT": true, + "ENABLE_FEAT_F4HWN_BEACON": true, "EDITION_STRING": "FieldOps", "TARGET": "f4hwn.fieldops" } @@ -175,6 +178,7 @@ "cacheVariables": { "ENABLE_FEAT_F4HWN_RESCUE_OPS": true, "ENABLE_FEAT_F4HWN_FOXHUNT": false, + "ENABLE_FEAT_F4HWN_BEACON": false, "ENABLE_AIRCOPY": true, "ENABLE_FEAT_F4HWN_BEAM": true, "ENABLE_FEAT_F4HWN_GAME": false, diff --git a/README.md b/README.md index b399c058..78ebce31 100644 --- a/README.md +++ b/README.md @@ -187,7 +187,7 @@ Specialized presets extend Fusion for specific uses: - selectable RF attenuation, - silent, Geiger-style and received-audio modes, - long-press `F` keypad lock (attenuation stays adjustable with the arrow keys). -- Integrated Morse Beacon transmitter with: +- Independent Morse Beacon transmitter with: - `MOE`, `MOI`, `MOS`, `MOH`, `MO5` and `MO` identifiers, - optional callsign identification, - configurable TX and idle periods, @@ -264,7 +264,8 @@ Specialized presets extend Fusion for specific uses: - offset removal, - BEAM, - RF Log, - - Fox Hunt / Beacon. + - Fox Hunt, + - Beacon. ### Reliability and optimization From 99057f9dd2eb771d1208e8148f50b242f03e4b6a Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 31 Aug 2026 01:33:23 +0200 Subject: [PATCH 53/73] Add BLTime support to overlay apps and Max preset --- App/app/app.c | 91 ++++++++++++++++++++++++++++-------- App/app/app.h | 2 + App/apps/app_api.h | 4 +- App/apps/app_menu.c | 30 ++++++++++++ App/apps/app_overlay.c | 49 +++++++++++++++++-- App/apps/app_overlay.h | 3 +- App/apps/cube3d/build.sh | 2 +- App/apps/cube3d/cube3d_app.c | 77 +++++++++++++++++------------- App/apps/fm/build.sh | 2 +- App/apps/fm/fm_app.c | 19 +++++++- App/apps/pack_app.py | 8 +++- App/apps/plasma/build.sh | 2 +- App/apps/plasma/plasma_app.c | 55 +++++++++++++--------- App/apps/trivfo/build.sh | 2 +- App/apps/trivfo/trivfo_app.c | 15 ++++++ CMakePresets.json | 19 ++++++++ compile-firmware.sh | 6 +-- 17 files changed, 295 insertions(+), 91 deletions(-) diff --git a/App/app/app.c b/App/app/app.c index fd64c7fa..a6946c0e 100644 --- a/App/app/app.c +++ b/App/app/app.c @@ -234,7 +234,7 @@ static void ScreenSaverUpdateViewer(void) #endif } -static bool ScreenSaverCanDisplay(void) +static bool ScreenSaverCanDisplay(bool modal) { if (gSetting_set_sav == SET_SAV_OFF || gEeprom.BACKLIGHT_TIME == 0 || @@ -257,7 +257,7 @@ static bool ScreenSaverCanDisplay(void) return false; } - if (gScreenToDisplay == DISPLAY_MAIN) + if (modal || gScreenToDisplay == DISPLAY_MAIN) return true; #ifdef ENABLE_FMRADIO @@ -268,9 +268,9 @@ static bool ScreenSaverCanDisplay(void) return false; } -static void ScreenSaverTryDisplay(void) +static void ScreenSaverTryDisplay(bool modal) { - if (!ScreenSaverCanDisplay()) + if (!ScreenSaverCanDisplay(modal)) return; if (gSetting_set_sav == SET_SAV_LOGO) @@ -295,6 +295,28 @@ static void ScreenSaverExit(void) gUpdateStatus = true; } } + +static bool ScreenSaverAnimate(void) +{ + if (!gScreenSaverDisplayed) + return false; + + if (gSetting_set_sav == SET_SAV_MATRIX) { + if (++gScreenSaverTick >= 8u) { + gScreenSaverTick = 0; + ScreenSaverRenderMatrix(false); + return true; + } + } else if (gSetting_set_sav == SET_SAV_LOGO_PLUS) { + if (++gScreenSaverTick >= 16u) { + gScreenSaverTick = 0; + ScreenSaverRenderLogoPlus(false); + return true; + } + } + + return false; +} #endif bool APP_IsScreenSaverDisplayed(void) @@ -306,6 +328,47 @@ bool APP_IsScreenSaverDisplayed(void) #endif } +/* Modal foreground loops (resident tools and overlay apps) bypass APP_Update() + * and therefore also bypass the normal 10 ms fade and 500 ms BLTime service. + * Keep that service resident so every overlay app gets identical timing without + * extending the app ABI. Only selected modal screens opt into the saver; the + * others still fade from BLMax to BLMin when BLTime expires. */ +void APP_ModalBacklightTick(bool allowScreenSaver) +{ + if (gNextTimeslice) { + gNextTimeslice = false; + BACKLIGHT_Update(); + +#ifdef ENABLE_FEAT_F4HWN_LOGO_SAV + if (ScreenSaverAnimate()) + ScreenSaverUpdateViewer(); +#endif + } + + if (!gNextTimeslice_500ms) + return; + gNextTimeslice_500ms = false; + + if (gBacklightCountdown_500ms > 0 && + gEeprom.BACKLIGHT_TIME < 61 && + --gBacklightCountdown_500ms == 0) + BACKLIGHT_TurnOff(); + +#ifdef ENABLE_FEAT_F4HWN_LOGO_SAV + if (allowScreenSaver && gBacklightCountdown_500ms == 0) + ScreenSaverTryDisplay(true); +#else + (void)allowScreenSaver; +#endif +} + +void APP_ModalScreenSaverExit(void) +{ +#ifdef ENABLE_FEAT_F4HWN_LOGO_SAV + ScreenSaverExit(); +#endif +} + static void CheckForIncoming(void) { if (!g_SquelchLost) @@ -1667,22 +1730,10 @@ void APP_TimeSlice10ms(void) gUpdateStatus = false; } - if (gSetting_set_sav == SET_SAV_MATRIX) { - if (++gScreenSaverTick >= 8u) { - gScreenSaverTick = 0; - ScreenSaverRenderMatrix(false); + if (ScreenSaverAnimate()) { #ifdef ENABLE_FEAT_F4HWN_K5VIEWER - screenSaverRendered = true; + screenSaverRendered = true; #endif - } - } else if (gSetting_set_sav == SET_SAV_LOGO_PLUS) { - if (++gScreenSaverTick >= 16u) { - gScreenSaverTick = 0; - ScreenSaverRenderLogoPlus(false); -#ifdef ENABLE_FEAT_F4HWN_K5VIEWER - screenSaverRendered = true; -#endif - } } } #endif @@ -1895,7 +1946,7 @@ void APP_TimeSlice500ms(void) ) { BACKLIGHT_TurnOff(); #ifdef ENABLE_FEAT_F4HWN_LOGO_SAV - ScreenSaverTryDisplay(); + ScreenSaverTryDisplay(false); #endif } @@ -2101,7 +2152,7 @@ void APP_TimeSlice500ms(void) !gAskToSave && !gCssBackgroundScan) { - ScreenSaverTryDisplay(); + ScreenSaverTryDisplay(false); } #endif diff --git a/App/app/app.h b/App/app/app.h index 602dcc1c..63b520aa 100644 --- a/App/app/app.h +++ b/App/app/app.h @@ -32,5 +32,7 @@ void APP_Update(void); void APP_TimeSlice10ms(void); void APP_TimeSlice500ms(void); bool APP_IsScreenSaverDisplayed(void); +void APP_ModalBacklightTick(bool allowScreenSaver); +void APP_ModalScreenSaverExit(void); #endif diff --git a/App/apps/app_api.h b/App/apps/app_api.h index 7ed8b148..e3a1899b 100644 --- a/App/apps/app_api.h +++ b/App/apps/app_api.h @@ -63,6 +63,8 @@ enum { APP_KEY_F = 15, APP_KEY_PTT = 16, APP_KEY_INVALID = 19, + APP_KEY_SAVER = 0xFD, /* virtual: saver active, skip this app frame */ + APP_KEY_WAKE = 0xFE, /* virtual: saver dismissed, redraw without action */ }; /* The framebuffer is the resident gFrameBuffer[FRAME_LINES][128]; the app draws @@ -175,7 +177,7 @@ typedef struct app_api { 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) */ + void (*backlight_update)(void); /* resident fade + BLTime service */ void (*audio_scope)(uint8_t line, bool active); /* shared MAIN microphone scope */ uint8_t *status_line; /* -> gStatusLine (for status-bar icons) */ diff --git a/App/apps/app_menu.c b/App/apps/app_menu.c index 8ea8f942..f1b14fa7 100644 --- a/App/apps/app_menu.c +++ b/App/apps/app_menu.c @@ -20,6 +20,8 @@ #include #include "apps/app_menu.h" +#include "app/app.h" +#include "driver/backlight.h" #include "driver/st7565.h" #include "driver/keyboard.h" #include "driver/system.h" @@ -62,6 +64,8 @@ static void app_key_hints(void) #define APP_NAME_BOX_END 102u #define APP_NAME_TEXT_X 14u +static void app_wait_release(void); + static void app_invert_name(uint8_t line) { gFrameBuffer[line][APP_NAME_BOX_START] ^= 0x7Fu; @@ -82,6 +86,24 @@ static KEY_Code_t app_get_key(void) /* APP_MenuOpen() is modal and does not return to APP_Update(). Keep * serial key injection and the viewer connection alive while waiting. */ K5VIEWER_ParseInput(); +#endif + APP_ModalBacklightTick(true); + + if (APP_IsScreenSaverDisplayed()) + { + if (KEYBOARD_GetKey() != KEY_INVALID) + { + APP_ModalScreenSaverExit(); + BACKLIGHT_TurnOn(); + app_wait_release(); + return KEY_INVALID; + } + + SYSTEM_DelayMs(10); + continue; + } + +#ifdef ENABLE_FEAT_F4HWN_K5VIEWER K5VIEWER_Update(false); #endif KEY_Code_t key = KEYBOARD_Poll(); @@ -90,8 +112,12 @@ static KEY_Code_t app_get_key(void) SYSTEM_DelayMs(30); if (KEYBOARD_Poll() == key) { + BACKLIGHT_TurnOn(); while (KEYBOARD_Poll() != KEY_INVALID) + { SYSTEM_DelayMs(10); + APP_ModalBacklightTick(false); + } return key; } } @@ -109,6 +135,7 @@ static void app_wait_release(void) else stable = 0; SYSTEM_DelayMs(10); + APP_ModalBacklightTick(true); } } @@ -188,6 +215,9 @@ _Static_assert(APP_MENU_SLOT_COUNT <= APP_SLOT_COUNT, void APP_MenuOpen(void) { + APP_ModalScreenSaverExit(); + BACKLIGHT_TurnOn(); + #ifdef ENABLE_FEAT_F4HWN_K5VIEWER /* Detach the modal selector from the key state that triggered F+7. The * normal K5Viewer updater suppresses frames while a key is held; without diff --git a/App/apps/app_overlay.c b/App/apps/app_overlay.c index 6b90d454..787214f9 100644 --- a/App/apps/app_overlay.c +++ b/App/apps/app_overlay.c @@ -53,6 +53,20 @@ _Static_assert(sizeof(app_header_t) == 64, "app_header_t must be 64 bytes"); /* ---- ABI wrappers: the few resident calls that are not a direct signature match ---- */ +static bool app_allow_screen_saver; +static bool app_screen_saver_wake; + +static void app_backlight_on(void) +{ + APP_ModalScreenSaverExit(); + BACKLIGHT_TurnOn(); +} + +static void app_backlight_update(void) +{ + APP_ModalBacklightTick(app_allow_screen_saver); +} + static uint8_t app_get_key(void) { #ifdef ENABLE_FEAT_F4HWN_K5VIEWER @@ -60,7 +74,26 @@ static uint8_t app_get_key(void) * injection alive while an app owns the foreground loop. */ K5VIEWER_ParseInput(); #endif - return (uint8_t)KEYBOARD_GetKey(); + const KEY_Code_t key = KEYBOARD_GetKey(); + + if (app_screen_saver_wake) { + if (key == KEY_INVALID) + app_screen_saver_wake = false; + return APP_KEY_INVALID; + } + + if (!APP_IsScreenSaverDisplayed()) + return (uint8_t)key; + + if (key == KEY_INVALID) + return APP_KEY_SAVER; + + app_backlight_on(); + if (key == KEY_PTT) + return APP_KEY_PTT; + + app_screen_saver_wake = true; + return APP_KEY_WAKE; } #ifdef ENABLE_FEAT_F4HWN_K5VIEWER @@ -72,6 +105,7 @@ static void app_blit_full(void) K5VIEWER_Update(false); } #endif + static int8_t app_nav_dir(uint8_t key) { int8_t direction; @@ -749,8 +783,8 @@ static const app_api_t app_api = { .cfg_save = app_cfg_save, .draw_battery = app_draw_battery, .battery_sample = app_battery_sample, - .backlight_on = BACKLIGHT_TurnOn, - .backlight_update = BACKLIGHT_Update, + .backlight_on = app_backlight_on, + .backlight_update = app_backlight_update, .audio_scope = UI_DisplayAudioScopeOverlay, .status_line = gStatusLine, .tx_state = app_tx_state, @@ -820,6 +854,11 @@ uint8_t APP_LaunchOverlay(uint8_t slot) app_fm_dirty = false; #endif + app_allow_screen_saver = (h.flags & APP_FLAG_SCREEN_SAVER) != 0; + app_screen_saver_wake = false; + APP_ModalScreenSaverExit(); + BACKLIGHT_TurnOn(); + #ifdef ENABLE_FEAT_F4HWN_K5VIEWER /* The caller enters from a debounced key event, so the resident key state * still contains that trigger while the modal app is running. Clear it so @@ -845,6 +884,10 @@ uint8_t APP_LaunchOverlay(uint8_t slot) app_entry_t entry = (app_entry_t)(((uint32_t)ws + h.entry_off) | 1u); entry(&app_api); + APP_ModalScreenSaverExit(); + app_allow_screen_saver = false; + app_screen_saver_wake = false; + /* A defensive leave also covers an app returning through an error path. */ app_trivfo_leave(); diff --git a/App/apps/app_overlay.h b/App/apps/app_overlay.h index e2c2e778..3b0881df 100644 --- a/App/apps/app_overlay.h +++ b/App/apps/app_overlay.h @@ -66,7 +66,8 @@ /* ---- 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_FLAG_COMMITTED 0x0001u +#define APP_FLAG_SCREEN_SAVER 0x0002u #define APP_NAME_LEN 16 #define APP_VERSION_LEN 16 diff --git a/App/apps/cube3d/build.sh b/App/apps/cube3d/build.sh index 8175ba03..3e17025a 100755 --- a/App/apps/cube3d/build.sh +++ b/App/apps/cube3d/build.sh @@ -29,7 +29,7 @@ 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 + --name "$APP_NAME" --ver "$APP_VER" --vma "${APP_VMA}" --screensaver >/dev/null trap - ERR BYTES=$(wc -c < "${APP}.bin") diff --git a/App/apps/cube3d/cube3d_app.c b/App/apps/cube3d/cube3d_app.c index 2b5a799f..b588d102 100644 --- a/App/apps/cube3d/cube3d_app.c +++ b/App/apps/cube3d/cube3d_app.c @@ -196,6 +196,50 @@ void app_main(const app_api_t *api) uint8_t prevKey = APP_KEY_INVALID; while (running) { + uint8_t key = A->get_key(); + if (key == APP_KEY_SAVER) { + prevKey = APP_KEY_INVALID; + A->delay_ms(10); + A->backlight_update(); + continue; + } + if (key == APP_KEY_WAKE) + key = APP_KEY_INVALID; + if (key != prevKey && key != APP_KEY_INVALID) { + A->backlight_on(); + switch (key) { + case APP_KEY_EXIT: + running = false; + break; + case APP_KEY_UP: + case APP_KEY_DOWN: { + const int8_t dir = A->nav_dir(key); + if (dir > 0 && speed < 16u) speed++; + if (dir < 0 && speed > 1u) speed--; + break; + } + case APP_KEY_MENU: + paused = !paused; + break; + case APP_KEY_STAR: + shape = (uint8_t)((shape + 1u) % NSHAPE); + break; + case APP_KEY_F: + wire = !wire; + break; + case APP_KEY_1: case APP_KEY_2: case APP_KEY_3: case APP_KEY_4: + case APP_KEY_5: case APP_KEY_6: case APP_KEY_7: case APP_KEY_8: + if ((uint8_t)(key - APP_KEY_1) < NSHAPE) + shape = (uint8_t)(key - APP_KEY_1); + break; + default: + break; + } + } + prevKey = key; + if (!running) + break; + const shape_t *s = &SHAPES[shape]; const uint8_t ia = (uint8_t)(ax >> 3), ib = (uint8_t)(ay >> 3), ic = (uint8_t)(az >> 3); const int cx = sin8((uint8_t)(ia + 64u)), sxr = sin8(ia); @@ -249,39 +293,6 @@ void app_main(const app_api_t *api) az += (uint16_t)((rate + 1u) / 2u); } - const uint8_t key = A->get_key(); - if (key != prevKey && key != APP_KEY_INVALID) { - A->backlight_on(); - switch (key) { - case APP_KEY_EXIT: - running = false; - break; - case APP_KEY_UP: - case APP_KEY_DOWN: { - const int8_t dir = A->nav_dir(key); - if (dir > 0 && speed < 16u) speed++; - if (dir < 0 && speed > 1u) speed--; - break; - } - case APP_KEY_MENU: - paused = !paused; - break; - case APP_KEY_STAR: - shape = (uint8_t)((shape + 1u) % NSHAPE); - break; - case APP_KEY_F: - wire = !wire; - break; - case APP_KEY_1: case APP_KEY_2: case APP_KEY_3: case APP_KEY_4: - case APP_KEY_5: case APP_KEY_6: case APP_KEY_7: case APP_KEY_8: - if ((uint8_t)(key - APP_KEY_1) < NSHAPE) - shape = (uint8_t)(key - APP_KEY_1); - break; - default: - break; - } - } - prevKey = key; A->backlight_update(); A->delay_ms((uint32_t)(32u - speed * 2u)); /* slow low end, no added delay at level 16 */ } diff --git a/App/apps/fm/build.sh b/App/apps/fm/build.sh index 6fde97c0..a35b18da 100755 --- a/App/apps/fm/build.sh +++ b/App/apps/fm/build.sh @@ -29,7 +29,7 @@ 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 + --name "$APP_NAME" --ver "$APP_VER" --vma "${APP_VMA}" --screensaver >/dev/null trap - ERR BYTES=$(wc -c < "${APP}.bin") diff --git a/App/apps/fm/fm_app.c b/App/apps/fm/fm_app.c index dac7aa08..9212af0c 100644 --- a/App/apps/fm/fm_app.c +++ b/App/apps/fm/fm_app.c @@ -303,6 +303,7 @@ __attribute__((section(".text.entry"),used)) void app_main(const app_api_t *api) { A=api; + A->backlight_on(); ch=A->fm_channels; A->fm_state(&st,false); /* inherit the resident FM state */ if(st.band>3) st.band=0; @@ -326,13 +327,20 @@ void app_main(const app_api_t *api) uint8_t key=A->get_key(); bool rep=(key==APP_KEY_UP||key==APP_KEY_DOWN); - if(key==APP_KEY_INVALID){ + if(key==APP_KEY_SAVER){ + held=APP_KEY_INVALID; heldMs=0; firedLong=false; + A->delay_ms(10); A->backlight_update(); + continue; + } else if(key==APP_KEY_WAKE||key==APP_KEY_PTT){ + held=APP_KEY_INVALID; heldMs=0; firedLong=false; dirty=true; + } else 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){ + A->backlight_on(); held=key; heldMs=0; firedLong=false; if(rep){ onShort(key); dirty=true; } /* immediate first step */ } else { @@ -346,13 +354,20 @@ void app_main(const app_api_t *api) /* non-blocking scan: one step each time the settle timer expires */ if(scanState){ + /* Match resident FM: an active scan keeps the display awake. */ + A->backlight_on(); if(scanTimer<=50) scanStep(); else scanTimer=(uint16_t)(scanTimer-50); dirty=true; } if(dirty){ show(); dirty=false; } - A->delay_ms(50); + /* Keep the resident 10 ms fade cadence while retaining this app's + * existing 50 ms key/scan state-machine tick. */ + for(uint8_t i=0;i<5u;i++){ + A->delay_ms(10); + A->backlight_update(); + } } /* push our state back to the resident FM and persist (config + 48 channels) */ diff --git a/App/apps/pack_app.py b/App/apps/pack_app.py index cd3b0781..4ae9575f 100644 --- a/App/apps/pack_app.py +++ b/App/apps/pack_app.py @@ -30,7 +30,8 @@ MAGIC = cdefine("app_overlay.h", "APP_MAGIC").to_bytes(4, "little") # 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 +FLAG_COMMITTED = cdefine("app_overlay.h", "APP_FLAG_COMMITTED") +FLAG_SCREEN_SAVER = cdefine("app_overlay.h", "APP_FLAG_SCREEN_SAVER") def field(s: str, n: int) -> bytes: b = s.encode("ascii", "strict")[: n - 1] @@ -45,6 +46,8 @@ def main(): 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)") + ap.add_argument("--screensaver", action="store_true", + help="allow the resident BLTime screen saver while this app is idle") a = ap.parse_args() code = open(a.infile, "rb").read() @@ -54,10 +57,11 @@ def main(): sys.exit(f"code {len(code)} B exceeds overlay budget {OVERLAY_MAX} B") crc = zlib.crc32(code) & 0xFFFFFFFF + flags = FLAG_COMMITTED | (FLAG_SCREEN_SAVER if a.screensaver else 0) header = struct.pack( "<4sHHIIHH16s16sI8s", MAGIC, HDR_VERSION, ABI_VERSION, - len(code), crc, a.entry, FLAG_COMMITTED, + len(code), crc, a.entry, flags, field(a.name, 16), field(a.ver, 16), a.vma, b"\x00" * 8, ) assert len(header) == 64, len(header) diff --git a/App/apps/plasma/build.sh b/App/apps/plasma/build.sh index 2bf77d50..7066c42e 100755 --- a/App/apps/plasma/build.sh +++ b/App/apps/plasma/build.sh @@ -29,7 +29,7 @@ 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 + --name "$APP_NAME" --ver "$APP_VER" --vma "${APP_VMA}" --screensaver >/dev/null trap - ERR BYTES=$(wc -c < "${APP}.bin") diff --git a/App/apps/plasma/plasma_app.c b/App/apps/plasma/plasma_app.c index ccc9e826..32c036c2 100644 --- a/App/apps/plasma/plasma_app.c +++ b/App/apps/plasma/plasma_app.c @@ -92,6 +92,39 @@ void app_main(const app_api_t *api) prevKey = APP_KEY_INVALID; while (running) { + uint8_t key = A->get_key(); + if (key == APP_KEY_SAVER) { + prevKey = APP_KEY_INVALID; + A->delay_ms(10); + A->backlight_update(); + continue; + } + if (key == APP_KEY_WAKE) + key = APP_KEY_INVALID; + if (key != prevKey && key != APP_KEY_INVALID) { + A->backlight_on(); + switch (key) { + case APP_KEY_EXIT: running = false; break; + case APP_KEY_UP: + case APP_KEY_DOWN: { + const int8_t direction = A->nav_dir(key); + if (direction > 0 && speed < 8u) speed++; + if (direction < 0 && speed > 1u) speed--; + break; + } + case APP_KEY_MENU: paused = !paused; break; + case APP_KEY_STAR: bands = !bands; break; + case APP_KEY_F: autoc = !autoc; break; + case APP_KEY_1: case APP_KEY_2: case APP_KEY_3: + case APP_KEY_4: case APP_KEY_5: + var = (uint8_t)(key - APP_KEY_1); autoc = false; break; + default: break; + } + } + prevKey = key; + if (!running) + break; + const uint8_t sx = VAR[var][0], sy = VAR[var][1]; const uint8_t sd = VAR[var][2], rsh = VAR[var][3]; @@ -142,28 +175,6 @@ void app_main(const app_api_t *api) if (autoc && ++autoCtr >= 400u) { autoCtr = 0; var = (uint8_t)((var + 1u) % NVAR); } } - const uint8_t key = A->get_key(); - if (key != prevKey && key != APP_KEY_INVALID) { - A->backlight_on(); - switch (key) { - case APP_KEY_EXIT: running = false; break; - case APP_KEY_UP: - case APP_KEY_DOWN: { - const int8_t direction = A->nav_dir(key); - if (direction > 0 && speed < 8u) speed++; - if (direction < 0 && speed > 1u) speed--; - break; - } - case APP_KEY_MENU: paused = !paused; break; - case APP_KEY_STAR: bands = !bands; break; - case APP_KEY_F: autoc = !autoc; break; - case APP_KEY_1: case APP_KEY_2: case APP_KEY_3: - case APP_KEY_4: case APP_KEY_5: - var = (uint8_t)(key - APP_KEY_1); autoc = false; break; - default: break; - } - } - prevKey = key; A->backlight_update(); } } diff --git a/App/apps/trivfo/build.sh b/App/apps/trivfo/build.sh index dc6cc7a7..5e78a6e5 100755 --- a/App/apps/trivfo/build.sh +++ b/App/apps/trivfo/build.sh @@ -13,7 +13,7 @@ LDFLAGS="-nostdlib -nostartfiles -T app.ld -Wl,--defsym,APP_VMA=${APP_VMA} -Wl,- rm -f ./*.app ./*.elf ./*.bin "$CC" $CFLAGS $LDFLAGS "${APP}_app.c" -lgcc -o "${APP}.elf" "$OBJCOPY" -O binary "${APP}.elf" "${APP}.bin" -python3 ../pack_app.py "${APP}.bin" "${OUT}.app" --name "$APP_NAME" --ver "$APP_VER" --vma "${APP_VMA}" >/dev/null +python3 ../pack_app.py "${APP}.bin" "${OUT}.app" --name "$APP_NAME" --ver "$APP_VER" --vma "${APP_VMA}" --screensaver >/dev/null BYTES=$(wc -c < "${APP}.bin") test "$BYTES" -le 4096 printf ' ✅ %-13s %4d B (%d%% of 4 KiB) -> %s.app\n' \ diff --git a/App/apps/trivfo/trivfo_app.c b/App/apps/trivfo/trivfo_app.c index 668ea3a3..9bca5e30 100644 --- a/App/apps/trivfo/trivfo_app.c +++ b/App/apps/trivfo/trivfo_app.c @@ -205,6 +205,19 @@ void app_main(const app_api_t *api){ uint8_t held=APP_KEY_INVALID, blinkTicks=0; uint16_t heldMs=0; bool longDone=false, ptt=false; while(running){ uint8_t key=A->get_key(); + if(key==APP_KEY_SAVER){ + state=A->trivfo_tick(); + if(state==APP_TRIVFO_RX){ + A->backlight_on(); + draw(); + } + for(uint8_t i=0;idelay_ms(10); A->backlight_update(); + } + continue; + } + if(key!=APP_KEY_INVALID&&key!=APP_KEY_WAKE) A->backlight_on(); + if(key==APP_KEY_WAKE) key=APP_KEY_INVALID; if(key==APP_KEY_PTT){ if(!ptt){ ptt=true; txDenied=A->trivfo_ptt(true)!=0; } } else if(ptt){ ptt=false; A->trivfo_ptt(false); txDenied=false; } @@ -233,6 +246,8 @@ void app_main(const app_api_t *api){ } state=A->trivfo_tick(); + if(state==APP_TRIVFO_RX||state==APP_TRIVFO_TX_STATE) + A->backlight_on(); if(state==APP_TRIVFO_RX){ if(++blinkTicks>=25u){ blinkTicks=0; channelLabelOn=!channelLabelOn; } } else { blinkTicks=0; channelLabelOn=true; } diff --git a/CMakePresets.json b/CMakePresets.json index 5c422fd3..9ce2fd38 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -186,6 +186,21 @@ "EDITION_STRING": "Labs", "TARGET": "f4hwn.labs" } + }, + { + "name": "Max", + "inherits": "Fusion", + "cacheVariables": { + "ENABLE_FEAT_F4HWN_RESCUE_OPS": true, + "ENABLE_FEAT_F4HWN_FOXHUNT": true, + "ENABLE_FEAT_F4HWN_BEACON": true, + "ENABLE_AIRCOPY": true, + "ENABLE_FEAT_F4HWN_BEAM": true, + "ENABLE_FEAT_F4HWN_GAME": true, + "ENABLE_FEAT_F4HWN_OVERLAY_APPS": false, + "EDITION_STRING": "Max", + "TARGET": "f4hwn.max" + } } ], "buildPresets": [ @@ -208,6 +223,10 @@ { "name": "Labs", "configurePreset": "Labs" + }, + { + "name": "Max", + "configurePreset": "Max" } ] } diff --git a/compile-firmware.sh b/compile-firmware.sh index 61c45e09..a2653570 100755 --- a/compile-firmware.sh +++ b/compile-firmware.sh @@ -16,7 +16,7 @@ set -euo pipefail # --------------------------------------------- IMAGE=uvk1-uvk5v3 -RELEASE_PRESETS=(Fusion Transfer FieldOps Labs) +RELEASE_PRESETS=(Fusion Transfer FieldOps Labs Max) FLASH_LIMIT=$((118 * 1024)) RAM_LIMIT=$((16 * 1024)) @@ -29,9 +29,9 @@ EXTRA_ARGS=("$@") # --------------------------------------------- # Validate preset name # --------------------------------------------- -if [[ ! "$PRESET" =~ ^(Custom|Fusion|Transfer|FieldOps|Labs|All)$ ]]; then +if [[ ! "$PRESET" =~ ^(Custom|Fusion|Transfer|FieldOps|Labs|Max|All)$ ]]; then echo "❌ Unknown preset: '$PRESET'" - echo "Valid presets are: Custom, Fusion, Transfer, FieldOps, Labs, All" + echo "Valid presets are: Custom, Fusion, Transfer, FieldOps, Labs, Max, All" exit 1 fi From 62906636edbba9d45f96f4479c394f0450a72ef4 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 31 Aug 2026 02:03:28 +0200 Subject: [PATCH 54/73] Optimize FM and overlay shortcuts --- App/app/action.c | 6 +++--- App/app/fm.c | 32 ++++++++++++++++++++++++++++- App/apps/app_overlay.c | 42 ++++++++++++++++----------------------- App/apps/app_overlay.h | 12 ++++++----- App/apps/beacon/build.sh | 3 ++- App/apps/fm/build.sh | 3 ++- App/apps/foxhunt/build.sh | 3 ++- App/apps/pack_app.py | 13 +++++++++++- 8 files changed, 76 insertions(+), 38 deletions(-) diff --git a/App/app/action.c b/App/app/action.c index 9462bd7f..65affa67 100644 --- a/App/app/action.c +++ b/App/app/action.c @@ -502,7 +502,7 @@ void ACTION_FoxHunt(void) APP_RunFoxHunt(); GUI_SelectNextDisplay(DISPLAY_MAIN); #else - if (APP_LaunchOverlayByName("FoxHunt") != APP_OK) + if (APP_LaunchOverlayShortcut(APP_SHORTCUT_FOXHUNT) != APP_OK) gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL; #endif } @@ -515,7 +515,7 @@ void ACTION_Beacon(void) APP_RunBeacon(); GUI_SelectNextDisplay(DISPLAY_MAIN); #else - if (APP_LaunchOverlayByName("Beacon") != APP_OK) + if (APP_LaunchOverlayShortcut(APP_SHORTCUT_BEACON) != APP_OK) gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL; #endif } @@ -526,7 +526,7 @@ void ACTION_Beacon(void) void ACTION_FM(void) { #ifdef ENABLE_FEAT_F4HWN_OVERLAY_APPS - if (APP_LaunchOverlayByName("Broadcast FM") != APP_OK) + if (APP_LaunchOverlayShortcut(APP_SHORTCUT_FM) != APP_OK) gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL; return; #else diff --git a/App/app/fm.c b/App/app/fm.c index 78f5c9a1..0d31fbf1 100644 --- a/App/app/fm.c +++ b/App/app/fm.c @@ -52,8 +52,9 @@ const uint8_t BUTTON_EVENT_HELD = BUTTON_STATE_PRESSED | BUTTON_STATE_HELD; const uint8_t BUTTON_EVENT_SHORT = 0; const uint8_t BUTTON_EVENT_LONG = BUTTON_STATE_HELD; - +#ifndef ENABLE_FEAT_F4HWN_OVERLAY_APPS static void Key_FUNC(KEY_Code_t Key, uint8_t state); +#endif bool FM_CheckValidChannel(uint8_t Channel) { @@ -94,6 +95,7 @@ int FM_ConfigureChannelState(void) return 0; } +#ifndef ENABLE_FEAT_F4HWN_OVERLAY_APPS void FM_SetFrequency(void) { BK1080_SetFrequency(gEeprom.FM_FrequencyPlaying, gEeprom.FM_Band/*, gEeprom.FM_Space*/); @@ -196,6 +198,7 @@ void FM_PlayAndUpdate(void) BACKLIGHT_TurnOn(); FM_AudioPathOn(); } +#endif int FM_CheckFrequencyLock(uint16_t Frequency, uint16_t LowerLimit) { @@ -240,6 +243,7 @@ int FM_CheckFrequencyLock(uint16_t Frequency, uint16_t LowerLimit) return 0; } +#ifndef ENABLE_FEAT_F4HWN_OVERLAY_APPS static void Key_DIGITS(KEY_Code_t Key, uint8_t state) { enum { STATE_FREQ_MODE, STATE_MR_MODE, STATE_SAVE }; @@ -662,5 +666,31 @@ void FM_Start(void) SETTINGS_WriteCurrentState(); #endif } +#else +/* + * Broadcast FM is sovereign in Overlay builds: its app owns tuning, scanning, + * audio and persistence through app_api. Keep only the shared channel/state + * loader and FM_CheckFrequencyLock above; these tiny compatibility entry points + * satisfy generic firmware paths which cannot become active because Overlay + * builds never set gFmRadioMode or gFM_ScanState. + */ +void FM_TurnOff(void) {} +void FM_EraseChannels(void) {} +void FM_Tune(uint16_t Frequency, int8_t Step, bool bFlag) +{ + (void)Frequency; + (void)Step; + (void)bFlag; +} +void FM_PlayAndUpdate(void) {} +void FM_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) +{ + (void)Key; + (void)bKeyPressed; + (void)bKeyHeld; +} +void FM_Play(void) {} +void FM_Start(void) {} +#endif #endif diff --git a/App/apps/app_overlay.c b/App/apps/app_overlay.c index 787214f9..3b19c5e1 100644 --- a/App/apps/app_overlay.c +++ b/App/apps/app_overlay.c @@ -671,11 +671,11 @@ static bool app_shortcuts_cached; static uint8_t app_shortcut_mask; static uint8_t app_shortcut_slots[3]; -static int8_t app_shortcut_index(const char *name) +static int8_t app_shortcut_index(uint8_t shortcut) { - if (strncmp(name, "Broadcast FM", APP_NAME_LEN) == 0) return 0; - if (strncmp(name, "FoxHunt", APP_NAME_LEN) == 0) return 1; - if (strncmp(name, "Beacon", APP_NAME_LEN) == 0) return 2; + if (shortcut == APP_SHORTCUT_FM) return 0; + if (shortcut == APP_SHORTCUT_FOXHUNT) return 1; + if (shortcut == APP_SHORTCUT_BEACON) return 2; return -1; } @@ -692,11 +692,12 @@ static void app_cache_shortcuts(void) if (APP_ValidateSlot(slot, &h) != APP_OK || h.link_vma != overlay_vma) continue; - const int8_t index = app_shortcut_index(h.name); + const uint8_t shortcut = (uint8_t)((h.flags & APP_FLAG_SHORTCUT_MASK) >> + APP_FLAG_SHORTCUT_SHIFT); + const int8_t index = app_shortcut_index(shortcut); if (index >= 0) { - const uint8_t bit = (uint8_t)(1u << index); - if (!(app_shortcut_mask & bit)) { - app_shortcut_mask |= bit; + if (!(app_shortcut_mask & shortcut)) { + app_shortcut_mask |= shortcut; app_shortcut_slots[index] = slot; } } @@ -711,25 +712,16 @@ uint8_t APP_OverlayShortcutMask(void) return app_shortcut_mask; } -uint8_t APP_LaunchOverlayByName(const char *name) +uint8_t APP_LaunchOverlayShortcut(uint8_t shortcut) { - const int8_t index = app_shortcut_index(name); - if (index >= 0) { - app_cache_shortcuts(); - return (app_shortcut_mask & (1u << index)) - ? APP_LaunchOverlay(app_shortcut_slots[index]) - : APP_ERR_MAGIC; - } + const int8_t index = app_shortcut_index(shortcut); + if (index < 0) + return APP_ERR_MAGIC; - app_header_t h; - - for (uint8_t slot = 0; slot < APP_SLOT_COUNT; slot++) { - if (APP_ValidateSlot(slot, &h) == APP_OK && - strncmp(h.name, name, APP_NAME_LEN) == 0) - return APP_LaunchOverlay(slot); - } - - return APP_ERR_MAGIC; + app_cache_shortcuts(); + return (app_shortcut_mask & shortcut) + ? APP_LaunchOverlay(app_shortcut_slots[index]) + : APP_ERR_MAGIC; } uint8_t APP_SlotInfo(uint8_t slot, app_header_t *out_header) diff --git a/App/apps/app_overlay.h b/App/apps/app_overlay.h index 3b0881df..93e5174c 100644 --- a/App/apps/app_overlay.h +++ b/App/apps/app_overlay.h @@ -68,12 +68,14 @@ #define APP_HDR_VERSION 1u #define APP_FLAG_COMMITTED 0x0001u #define APP_FLAG_SCREEN_SAVER 0x0002u +#define APP_FLAG_SHORTCUT_SHIFT 8u +#define APP_FLAG_SHORTCUT_MASK 0x0700u #define APP_NAME_LEN 16 #define APP_VERSION_LEN 16 -#define APP_SHORTCUT_FM (1u << 0) -#define APP_SHORTCUT_FOXHUNT (1u << 1) -#define APP_SHORTCUT_BEACON (1u << 2) +#define APP_SHORTCUT_FM 0x01u +#define APP_SHORTCUT_FOXHUNT 0x02u +#define APP_SHORTCUT_BEACON 0x04u typedef struct __attribute__((packed)) { uint32_t magic; /* APP_MAGIC */ @@ -108,8 +110,8 @@ uint8_t APP_ValidateSlot(uint8_t slot, app_header_t *out_header); * Returns when the app exits; the internal flash is never touched. */ uint8_t APP_LaunchOverlay(uint8_t slot); -/* Find the first valid app with this exact header name and launch it. */ -uint8_t APP_LaunchOverlayByName(const char *name); +/* Launch the first installed app advertising this shortcut bit. */ +uint8_t APP_LaunchOverlayShortcut(uint8_t shortcut); /* Cached availability of apps exposed as resident quick actions. */ uint8_t APP_OverlayShortcutMask(void); diff --git a/App/apps/beacon/build.sh b/App/apps/beacon/build.sh index 1168ee7a..905866b6 100755 --- a/App/apps/beacon/build.sh +++ b/App/apps/beacon/build.sh @@ -29,7 +29,8 @@ 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 + --name "$APP_NAME" --ver "$APP_VER" --vma "${APP_VMA}" \ + --shortcut beacon >/dev/null trap - ERR BYTES=$(wc -c < "${APP}.bin") diff --git a/App/apps/fm/build.sh b/App/apps/fm/build.sh index a35b18da..2b452893 100755 --- a/App/apps/fm/build.sh +++ b/App/apps/fm/build.sh @@ -29,7 +29,8 @@ 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}" --screensaver >/dev/null + --name "$APP_NAME" --ver "$APP_VER" --vma "${APP_VMA}" \ + --shortcut fm --screensaver >/dev/null trap - ERR BYTES=$(wc -c < "${APP}.bin") diff --git a/App/apps/foxhunt/build.sh b/App/apps/foxhunt/build.sh index 24a90d49..fa4a86e8 100755 --- a/App/apps/foxhunt/build.sh +++ b/App/apps/foxhunt/build.sh @@ -29,7 +29,8 @@ 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 + --name "$APP_NAME" --ver "$APP_VER" --vma "${APP_VMA}" \ + --shortcut foxhunt >/dev/null trap - ERR BYTES=$(wc -c < "${APP}.bin") diff --git a/App/apps/pack_app.py b/App/apps/pack_app.py index 4ae9575f..eaa4a10c 100644 --- a/App/apps/pack_app.py +++ b/App/apps/pack_app.py @@ -32,6 +32,13 @@ ABI_VERSION = cdefine("app_api.h", "APP_ABI_VERSION") OVERLAY_MAX = cdefine("app_overlay.h", "APP_OVERLAY_MAX") # 4 KiB overlay budget FLAG_COMMITTED = cdefine("app_overlay.h", "APP_FLAG_COMMITTED") FLAG_SCREEN_SAVER = cdefine("app_overlay.h", "APP_FLAG_SCREEN_SAVER") +FLAG_SHORTCUT_SHIFT = cdefine("app_overlay.h", "APP_FLAG_SHORTCUT_SHIFT") +SHORTCUTS = { + "none": 0, + "fm": cdefine("app_overlay.h", "APP_SHORTCUT_FM"), + "foxhunt": cdefine("app_overlay.h", "APP_SHORTCUT_FOXHUNT"), + "beacon": cdefine("app_overlay.h", "APP_SHORTCUT_BEACON"), +} def field(s: str, n: int) -> bytes: b = s.encode("ascii", "strict")[: n - 1] @@ -48,6 +55,8 @@ def main(): help="RAM VMA the app was linked at (must match the firmware overlay)") ap.add_argument("--screensaver", action="store_true", help="allow the resident BLTime screen saver while this app is idle") + ap.add_argument("--shortcut", choices=SHORTCUTS, default="none", + help="resident quick action advertised by this app") a = ap.parse_args() code = open(a.infile, "rb").read() @@ -57,7 +66,9 @@ def main(): sys.exit(f"code {len(code)} B exceeds overlay budget {OVERLAY_MAX} B") crc = zlib.crc32(code) & 0xFFFFFFFF - flags = FLAG_COMMITTED | (FLAG_SCREEN_SAVER if a.screensaver else 0) + flags = (FLAG_COMMITTED | + (FLAG_SCREEN_SAVER if a.screensaver else 0) | + (SHORTCUTS[a.shortcut] << FLAG_SHORTCUT_SHIFT)) header = struct.pack( "<4sHHIIHH16s16sI8s", MAGIC, HDR_VERSION, ABI_VERSION, From 902e19ba8d7e57584aa8213fa40c37d1db0b1538 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 31 Aug 2026 18:28:38 +0200 Subject: [PATCH 55/73] Port BEAM to overlay apps --- App/app/action.c | 20 +++- App/apps/app_api.h | 47 +++++++- App/apps/app_overlay.c | 208 ++++++++++++++++++++++++++++++++- App/apps/app_overlay.h | 3 +- App/apps/beam/app.ld | 30 +++++ App/apps/beam/beam_app.c | 241 +++++++++++++++++++++++++++++++++++++++ App/apps/beam/build.sh | 36 ++++++ App/apps/pack_app.py | 1 + App/driver/bk4819.c | 2 +- App/driver/bk4819.h | 2 +- App/driver/bk4829.c | 2 +- CMakePresets.json | 2 +- 12 files changed, 585 insertions(+), 9 deletions(-) create mode 100644 App/apps/beam/app.ld create mode 100644 App/apps/beam/beam_app.c create mode 100755 App/apps/beam/build.sh diff --git a/App/app/action.c b/App/app/action.c index 65affa67..3e2f25a4 100644 --- a/App/app/action.c +++ b/App/app/action.c @@ -58,6 +58,10 @@ #include "ui/menu.h" #endif +#if defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) && !defined(ENABLE_FEAT_F4HWN_BEAM) +static void ACTION_Beam(void); +#endif + #if defined(ENABLE_FMRADIO) static void ACTION_Scan_FM(bool bRestart); #endif @@ -113,7 +117,7 @@ void (*const action_opt_table[ACTION_OPT_LEN])(void) = { [ACTION_OPT_REMOVE_OFFSET] = &ACTION_Remove_Offset, #endif #endif -#ifdef ENABLE_FEAT_F4HWN_BEAM +#if defined(ENABLE_FEAT_F4HWN_BEAM) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) [ACTION_OPT_BEAM] = &ACTION_Beam, #endif #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG @@ -153,6 +157,10 @@ bool ACTION_IsAvailable(uint8_t action) #ifndef ENABLE_FEAT_F4HWN_BEACON case ACTION_OPT_BEACON: return (APP_OverlayShortcutMask() & APP_SHORTCUT_BEACON) != 0; +#endif +#ifndef ENABLE_FEAT_F4HWN_BEAM + case ACTION_OPT_BEAM: + return (APP_OverlayShortcutMask() & APP_SHORTCUT_BEAM) != 0; #endif default: break; @@ -338,7 +346,7 @@ inline static bool ACTION_IsBlockedInFM(uint8_t action) case ACTION_OPT_REMOVE_OFFSET: #endif #endif -#ifdef ENABLE_FEAT_F4HWN_BEAM +#if defined(ENABLE_FEAT_F4HWN_BEAM) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) case ACTION_OPT_BEAM: #endif #if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) @@ -495,6 +503,14 @@ void ACTION_Handle(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) ACTION_Execute(func); } +#if defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) && !defined(ENABLE_FEAT_F4HWN_BEAM) +static void ACTION_Beam(void) +{ + if (APP_LaunchOverlayShortcut(APP_SHORTCUT_BEAM) != APP_OK) + gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL; +} +#endif + #if defined(ENABLE_FEAT_F4HWN_FOXHUNT) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) void ACTION_FoxHunt(void) { diff --git a/App/apps/app_api.h b/App/apps/app_api.h index e3a1899b..94827c13 100644 --- a/App/apps/app_api.h +++ b/App/apps/app_api.h @@ -40,7 +40,7 @@ * reorder, a removal, or an append - MUST bump this. Keep in sync with the * value read by pack_app.py, which * stamps the blob the loader checks against. */ -#define APP_ABI_VERSION 6u +#define APP_ABI_VERSION 8u /* 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. */ @@ -120,6 +120,41 @@ enum { APP_TRIVFO_TX_STATE = 3, }; +/* Pointer-free BEAM channel description. The resident bridge translates this + * stable ABI type to/from feature-dependent VFO_Info_t. */ +typedef struct { + uint32_t rx_frequency; + uint32_t tx_offset_frequency; + uint8_t rx_code; + uint8_t tx_code; + uint8_t rx_codetype; + uint8_t tx_codetype; + uint8_t modulation; + uint8_t tx_offset_direction; + uint8_t tx_lock; + uint8_t busy_channel_lock; + uint8_t output_power; + uint8_t channel_bandwidth; + uint8_t frequency_reverse; + uint8_t dtmf_ptt_id_mode; + uint8_t dtmf_decoding_enable; + uint8_t step_setting; + uint8_t scrambling_type; + uint8_t band; + uint8_t scanlist; + uint8_t compander; + char name[16]; +} app_beam_channel_t; + +_Static_assert(sizeof(app_beam_channel_t) == 44u, + "BEAM ABI/wire channel layout changed"); + +enum { + APP_BEAM_RX_WAIT = 0, + APP_BEAM_RX_READY = 1, + APP_BEAM_RX_ERROR = 2, +}; + typedef struct app_api { uint8_t abi_version; /* == APP_ABI_VERSION */ @@ -223,6 +258,16 @@ typedef struct app_api { uint16_t (*trivfo_step)(uint8_t vfo, int8_t direction); uint8_t (*trivfo_tick)(void); uint8_t (*trivfo_ptt)(bool pressed); /* physical PTT edge; resident applies SetPTT */ + + /* ---- BEAM channel transfer (ABI 8) ---- */ + void (*beam_prepare)(void); /* tune the fixed narrow-band FSK channel */ + void (*beam_leave)(void); /* defensively stop FSK before app return */ + void (*beam_get)(app_beam_channel_t *channel); /* export selected VFO */ + uint16_t (*beam_save)(const app_beam_channel_t *channel); /* first free MR, or 0xffff */ + void (*beam_send)(uint16_t *packet); /* transmit one 36-word FSK packet */ + void (*beam_rx)(bool start); /* arm or stop FSK reception */ + uint8_t (*beam_rx_poll)(uint16_t *packet); /* APP_BEAM_RX_* */ + void (*beam_draw)(const char *status); /* MAIN display with one BEAM center line */ } app_api_t; /* BK4819 AF modes for set_af (mirror driver/bk4819.h values). */ diff --git a/App/apps/app_overlay.c b/App/apps/app_overlay.c index 3b19c5e1..c5cc843a 100644 --- a/App/apps/app_overlay.c +++ b/App/apps/app_overlay.c @@ -525,6 +525,200 @@ static uint8_t app_trivfo_ptt(bool pressed) return 0; } +/* ---- ABI 8: BEAM radio/channel bridge ------------------------------------ + * The modal app owns the packet format, CRC, UI and state machine. Resident + * code only translates the stable ABI channel structure and performs the FSK + * operations which depend on VFO_Info_t and the BK4819 driver. */ +static VFO_Info_t app_beam_vfo; +static uint8_t app_beam_fsk_index; +static uint16_t app_beam_pending_channel; +static bool app_beam_dirty; + +static void app_beam_prepare(void) +{ + const uint16_t channel = FREQ_CHANNEL_FIRST + BAND6_400MHz; + RADIO_InitInfo(&app_beam_vfo, channel, DEFAULT_FREQ); + app_beam_vfo.CHANNEL_BANDWIDTH = BANDWIDTH_NARROW; + app_beam_vfo.OUTPUT_POWER = OUTPUT_POWER_LOW1; + RADIO_ConfigureSquelchAndOutputPower(&app_beam_vfo); + + gRxVfo = &app_beam_vfo; + gTxVfo = &app_beam_vfo; + gCurrentVfo = &app_beam_vfo; + RADIO_SetupRegisters(true); + BK4819_SetupAircopy(); + BK4819_ResetFSK(); + app_beam_fsk_index = 0; +} + +static void app_beam_leave(void) +{ + BK4819_ResetFSK(); +} + +static void app_beam_get(app_beam_channel_t *out) +{ + if (out == NULL) + return; + memset(out, 0, sizeof(*out)); + const VFO_Info_t *vfo = &gEeprom.VfoInfo[gEeprom.TX_VFO]; + out->rx_frequency = vfo->freq_config_RX.Frequency; + out->tx_offset_frequency = vfo->TX_OFFSET_FREQUENCY; + out->rx_code = vfo->freq_config_RX.Code; + out->tx_code = vfo->freq_config_TX.Code; + out->rx_codetype = vfo->freq_config_RX.CodeType; + out->tx_codetype = vfo->freq_config_TX.CodeType; + out->modulation = vfo->Modulation; + out->tx_offset_direction = vfo->TX_OFFSET_FREQUENCY_DIRECTION; + out->tx_lock = vfo->TX_LOCK; + out->busy_channel_lock = vfo->BUSY_CHANNEL_LOCK; + out->output_power = vfo->OUTPUT_POWER; + out->channel_bandwidth = vfo->CHANNEL_BANDWIDTH; + out->frequency_reverse = vfo->FrequencyReverse; + out->dtmf_ptt_id_mode = vfo->DTMF_PTT_ID_TX_MODE; +#ifdef ENABLE_DTMF_CALLING + out->dtmf_decoding_enable = vfo->DTMF_DECODING_ENABLE; +#endif + out->step_setting = vfo->STEP_SETTING; + out->band = vfo->Band; + out->scanlist = vfo->SCANLIST_PARTICIPATION; + out->compander = vfo->Compander; + if (IS_MR_CHANNEL(vfo->CHANNEL_SAVE)) + SETTINGS_FetchChannelName(out->name, vfo->CHANNEL_SAVE); + else + memcpy(out->name, vfo->Name, sizeof(out->name)); +} + +static uint16_t app_beam_save(const app_beam_channel_t *in) +{ + if (in == NULL) + return 0xFFFFu; + + uint16_t channel = MR_CHANNEL_FIRST; + while (IS_MR_CHANNEL(channel) && RADIO_CheckValidChannel(channel, false, 0)) + channel++; + if (!IS_MR_CHANNEL(channel)) + return 0xFFFFu; + + /* Reuse the temporary BEAM VFO as resident staging RAM. No external-flash + * write is allowed while the overlay still executes from that flash's + * 4 KiB sector cache. */ + RADIO_InitInfo(&app_beam_vfo, channel, in->rx_frequency); + app_beam_vfo.TX_OFFSET_FREQUENCY = in->tx_offset_frequency; + app_beam_vfo.freq_config_RX.Code = in->rx_code; + app_beam_vfo.freq_config_TX.Code = in->tx_code; + app_beam_vfo.freq_config_RX.CodeType = in->rx_codetype; + app_beam_vfo.freq_config_TX.CodeType = in->tx_codetype; + app_beam_vfo.TX_OFFSET_FREQUENCY_DIRECTION = in->tx_offset_direction; + app_beam_vfo.Modulation = in->modulation; + app_beam_vfo.TX_LOCK = in->tx_lock; + app_beam_vfo.BUSY_CHANNEL_LOCK = in->busy_channel_lock; + app_beam_vfo.OUTPUT_POWER = in->output_power; + app_beam_vfo.CHANNEL_BANDWIDTH = in->channel_bandwidth; + app_beam_vfo.FrequencyReverse = in->frequency_reverse; + app_beam_vfo.DTMF_PTT_ID_TX_MODE = in->dtmf_ptt_id_mode; +#ifdef ENABLE_DTMF_CALLING + app_beam_vfo.DTMF_DECODING_ENABLE = in->dtmf_decoding_enable; +#endif + app_beam_vfo.STEP_SETTING = in->step_setting < STEP_N_ELEM ? in->step_setting : STEP_12_5kHz; + app_beam_vfo.StepFrequency = gStepFrequencyTable[app_beam_vfo.STEP_SETTING]; + app_beam_vfo.SCANLIST_PARTICIPATION = in->scanlist; + app_beam_vfo.Compander = in->compander; + memcpy(app_beam_vfo.Name, in->name, sizeof(app_beam_vfo.Name)); + app_beam_vfo.Name[sizeof(app_beam_vfo.Name) - 1u] = '\0'; + + app_beam_pending_channel = channel; + app_beam_dirty = true; + return channel; +} + +/* Called only after the overlay has returned and its code no longer executes + * from the PY25Q16 sector cache. */ +static void app_beam_commit(void) +{ + if (!app_beam_dirty) + return; + app_beam_dirty = false; + + const uint16_t channel = app_beam_pending_channel; + + SETTINGS_SaveChannel(channel, gEeprom.TX_VFO, &app_beam_vfo, 3); +#ifndef ENABLE_KEEP_MEM_NAME + SETTINGS_SaveChannelName(channel, app_beam_vfo.Name); +#endif + + gEeprom.MrChannel[gEeprom.TX_VFO] = channel; + gEeprom.ScreenChannel[gEeprom.TX_VFO] = channel; + RADIO_ConfigureChannel(gEeprom.TX_VFO, VFO_CONFIGURE_RELOAD); + RADIO_SelectVfos(); + RADIO_SetupRegisters(true); + PY25Q16_InvalidateCache(); +} + +static void app_beam_send(uint16_t *packet) +{ + if (packet == NULL) + return; + RADIO_SetTxParameters(); + BK4819_SendFSKData(packet); + BK4819_SetupPowerAmplifier(0, 0); + BK4819_ToggleGpioOut(BK4819_GPIO1_PIN29_PA_ENABLE, false); + RADIO_SelectVfos(); + RADIO_SetupRegisters(true); +} + +static void app_beam_rx(bool start) +{ + app_beam_fsk_index = 0; + if (start) + BK4819_PrepareFSKReceive(); + else + BK4819_ResetFSK(); +} + +static uint8_t app_beam_rx_poll(uint16_t *packet) +{ + if (packet == NULL) + return APP_BEAM_RX_ERROR; + + while (BK4819_ReadRegister(BK4819_REG_0C) & 1u) { + BK4819_WriteRegister(BK4819_REG_02, 0); + const uint16_t irq = BK4819_ReadRegister(BK4819_REG_02); + if (irq & (BK4819_REG_02_FSK_FIFO_ALMOST_FULL | BK4819_REG_02_FSK_RX_FINISHED)) { + const unsigned words = (irq & BK4819_REG_02_FSK_RX_FINISHED) + ? (app_beam_fsk_index < 36u ? 36u - app_beam_fsk_index : 0u) + : 4u; + for (unsigned i = 0; i < words; i++) { + const uint16_t word = BK4819_ReadRegister(BK4819_REG_5F); + if (app_beam_fsk_index < 36u) + packet[app_beam_fsk_index++] = word; + } + } + } + + if (app_beam_fsk_index < 36u) + return APP_BEAM_RX_WAIT; + + app_beam_fsk_index = 0; + const uint16_t status = BK4819_ReadRegister(BK4819_REG_0B); + BK4819_PrepareFSKReceive(); + return (status & 0x0010u) ? APP_BEAM_RX_ERROR : APP_BEAM_RX_READY; +} + +static void app_beam_draw(const char *status) +{ + UI_DisplayStatus(); + UI_DisplayMain(); +#ifdef ENABLE_FEAT_F4HWN + const uint8_t line = (gEeprom.DUAL_WATCH == DUAL_WATCH_OFF && + gEeprom.CROSS_BAND_RX_TX == CROSS_BAND_OFF) ? 5u : 3u; +#else + const uint8_t line = 3u; +#endif + memset(gFrameBuffer[line], 0, LCD_WIDTH); + UI_PrintStringSmallBold(status, 2, LCD_WIDTH - 1u, line); +} + /* ---- 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); } @@ -669,13 +863,14 @@ uint8_t APP_ValidateSlot(uint8_t slot, app_header_t *out_header) static bool app_shortcuts_cached; static uint8_t app_shortcut_mask; -static uint8_t app_shortcut_slots[3]; +static uint8_t app_shortcut_slots[4]; static int8_t app_shortcut_index(uint8_t shortcut) { if (shortcut == APP_SHORTCUT_FM) return 0; if (shortcut == APP_SHORTCUT_FOXHUNT) return 1; if (shortcut == APP_SHORTCUT_BEACON) return 2; + if (shortcut == APP_SHORTCUT_BEAM) return 3; return -1; } @@ -807,6 +1002,14 @@ static const app_api_t app_api = { .trivfo_step = app_trivfo_step, .trivfo_tick = app_trivfo_tick, .trivfo_ptt = app_trivfo_ptt, + .beam_prepare = app_beam_prepare, + .beam_leave = app_beam_leave, + .beam_get = app_beam_get, + .beam_save = app_beam_save, + .beam_send = app_beam_send, + .beam_rx = app_beam_rx, + .beam_rx_poll = app_beam_rx_poll, + .beam_draw = app_beam_draw, }; uint8_t APP_LaunchOverlay(uint8_t slot) @@ -845,6 +1048,7 @@ uint8_t APP_LaunchOverlay(uint8_t slot) #ifdef ENABLE_FMRADIO app_fm_dirty = false; #endif + app_beam_dirty = false; app_allow_screen_saver = (h.flags & APP_FLAG_SCREEN_SAVER) != 0; app_screen_saver_wake = false; @@ -891,6 +1095,8 @@ uint8_t APP_LaunchOverlay(uint8_t slot) /* The overlay held app code, not a valid config sector. */ PY25Q16_InvalidateCache(); + app_beam_commit(); + if (app_trivfo_ab_dirty) { SETTINGS_SaveVfoIndices(); app_trivfo_ab_dirty = false; diff --git a/App/apps/app_overlay.h b/App/apps/app_overlay.h index 93e5174c..c5c7a73c 100644 --- a/App/apps/app_overlay.h +++ b/App/apps/app_overlay.h @@ -69,13 +69,14 @@ #define APP_FLAG_COMMITTED 0x0001u #define APP_FLAG_SCREEN_SAVER 0x0002u #define APP_FLAG_SHORTCUT_SHIFT 8u -#define APP_FLAG_SHORTCUT_MASK 0x0700u +#define APP_FLAG_SHORTCUT_MASK 0x0F00u #define APP_NAME_LEN 16 #define APP_VERSION_LEN 16 #define APP_SHORTCUT_FM 0x01u #define APP_SHORTCUT_FOXHUNT 0x02u #define APP_SHORTCUT_BEACON 0x04u +#define APP_SHORTCUT_BEAM 0x08u typedef struct __attribute__((packed)) { uint32_t magic; /* APP_MAGIC */ diff --git a/App/apps/beam/app.ld b/App/apps/beam/app.ld new file mode 100644 index 00000000..ab4f428b --- /dev/null +++ b/App/apps/beam/app.ld @@ -0,0 +1,30 @@ +/* Overlay-app link script. */ +APP_VMA = DEFINED(APP_VMA) ? APP_VMA : 0x20000280; +APP_LENGTH = 0x1000; + +ENTRY(app_main) + +MEMORY { + APP (rwx) : ORIGIN = APP_VMA, LENGTH = APP_LENGTH +} + +SECTIONS { + .app APP_VMA : { + KEEP(*(.text.entry)) + *(.text .text.*) + *(.rodata .rodata.*) + . = ALIGN(4); + *(.data .data.*) + . = ALIGN(4); + __app_bss_start = .; + *(.bss .bss.* COMMON) + . = ALIGN(4); + __app_bss_end = .; + } > APP + + __app_end = .; + ASSERT(__app_end <= APP_VMA + APP_LENGTH, + "BEAM overlay app overflows the 4 KiB overlay") + + /DISCARD/ : { *(.ARM.exidx*) *(.ARM.extab*) *(.eh_frame*) *(.comment) *(.note.*) } +} diff --git a/App/apps/beam/beam_app.c b/App/apps/beam/beam_app.c new file mode 100644 index 00000000..a49f4a25 --- /dev/null +++ b/App/apps/beam/beam_app.c @@ -0,0 +1,241 @@ +/* Copyright 2026 Armel F4HWN + * Licensed under the Apache License, Version 2.0. + * + * BEAM overlay app. Packet version 2 remains wire-compatible with the + * resident implementation: one selected VFO is sent and a received VFO is + * stored in the first free memory channel. + */ + +#include +#include +#include +#include "../app_api.h" + +#define PACKET_MAGIC 0xBEA5u +#define PACKET_VERSION 2u +#define PACKET_START 0xABCDu +#define PACKET_END 0xDCBAu +#define PACKET_WORDS 36u + +enum { + STATUS_READY = 0, + STATUS_TX_WAIT, + STATUS_TX_DONE, + STATUS_RX_WAIT, + STATUS_RX_SAVED, + STATUS_RX_FULL, + STATUS_ERROR, +}; + +typedef struct { + uint16_t magic; + uint8_t version; + uint8_t pad; + app_beam_channel_t channel; +} beam_payload_t; + +_Static_assert(sizeof(beam_payload_t) == 48u, + "BEAM v2 wire payload layout changed"); + +static const uint16_t OBFUSCATION[8] = { + 0x6C16, 0xE614, 0x912E, 0x400D, 0x3521, 0x40D5, 0x0313, 0x80E9 +}; + +static const app_api_t *A; +static uint32_t packet_store[18]; /* 72 bytes, 4-byte aligned for the payload */ +static uint16_t copiedChannel; +static uint8_t mode, status; +static bool receiving, running, dirty; + +static uint16_t *packet(void) { return (uint16_t *)packet_store; } +static beam_payload_t *payload(void) { return (beam_payload_t *)&packet()[2]; } + +static void clear_bytes(void *ptr, uint8_t count) +{ + uint8_t *p = (uint8_t *)ptr; + while (count--) *p++ = 0; +} + +static uint16_t crc16(const void *buffer, uint16_t size) +{ + const uint8_t *data = (const uint8_t *)buffer; + uint16_t crc = 0; + while (size--) { + crc ^= (uint16_t)*data++ << 8; + for (uint8_t bit = 0; bit < 8u; bit++) + crc = (crc & 0x8000u) ? (uint16_t)((crc << 1) ^ 0x1021u) + : (uint16_t)(crc << 1); + } + return crc; +} + +static void obfuscate(void) +{ + uint16_t *p = packet(); + for (uint8_t i = 0; i < 32u; i++) + p[i + 1u] ^= OBFUSCATION[i & 7u]; +} + +static void draw(void) +{ + const char *state; + switch (status) { + case STATUS_TX_WAIT: state = "SENDING"; break; + case STATUS_TX_DONE: state = "SENT"; break; + case STATUS_RX_WAIT: state = "WAITING"; break; + case STATUS_RX_SAVED: state = "RECEIVED"; break; + case STATUS_RX_FULL: state = "MEM FULL"; break; + case STATUS_ERROR: state = "ERROR"; break; + default: state = mode ? "BEAM RX" : "BEAM TX"; break; + } + A->beam_draw(state); + A->blit_status(); + A->blit_full(); +} + +static void stop_rx(void) +{ + if (receiving) { + A->beam_rx(false); + receiving = false; + } +} + +static void send_packet(void) +{ + uint16_t *p = packet(); + clear_bytes(packet_store, sizeof(packet_store)); + p[0] = PACKET_START; + payload()->magic = PACKET_MAGIC; + payload()->version = PACKET_VERSION; + A->beam_get(&payload()->channel); + p[34] = crc16(&p[1], 2u + 64u); + p[35] = PACKET_END; + obfuscate(); + + status = STATUS_TX_WAIT; + draw(); + A->beam_send(p); + status = STATUS_TX_DONE; + for (uint8_t i = 0; i < 3u; i++) { + A->play_tone(880, 60); + if (i < 2u) A->delay_ms(20); + } + dirty = true; +} + +static void start(void) +{ + stop_rx(); + A->beam_prepare(); + if (!mode) { + send_packet(); + return; + } + copiedChannel = 0xFFFFu; + A->beam_rx(true); + receiving = true; + status = STATUS_RX_WAIT; + dirty = true; +} + +static bool valid_packet(void) +{ + uint16_t *p = packet(); + if (p[0] != PACKET_START || p[35] != PACKET_END) + return false; + obfuscate(); + if (p[34] != crc16(&p[1], 2u + 64u)) + return false; + return payload()->magic == PACKET_MAGIC && payload()->version == PACKET_VERSION; +} + +static void poll_rx(void) +{ + if (!receiving) + return; + const uint8_t result = A->beam_rx_poll(packet()); + if (result == APP_BEAM_RX_WAIT) + return; + if (result == APP_BEAM_RX_ERROR || !valid_packet()) { + status = STATUS_ERROR; + A->backlight_on(); + dirty = true; + return; + } + + copiedChannel = A->beam_save(&payload()->channel); + A->beam_rx(false); + receiving = false; + status = copiedChannel == 0xFFFFu ? STATUS_RX_FULL : STATUS_RX_SAVED; + A->backlight_on(); + dirty = true; +} + +static void key_press(uint8_t key) +{ + switch (key) { + case APP_KEY_UP: + case APP_KEY_DOWN: + stop_rx(); + mode ^= 1u; + status = STATUS_READY; + dirty = true; + break; + case APP_KEY_MENU: + start(); + break; + case APP_KEY_EXIT: + running = false; + break; + case APP_KEY_PTT: + break; + default: + A->play_tone(500, 60); + break; + } +} + +__attribute__((section(".text.entry"), used)) +void app_main(const app_api_t *api) +{ + A = api; + mode = 0; + status = STATUS_READY; + copiedChannel = 0xFFFFu; + receiving = false; + running = true; + dirty = true; + A->backlight_on(); + + uint8_t previous = APP_KEY_INVALID; + uint8_t batteryTicks = 0; + while (running) { + const uint8_t key = A->get_key(); + if (key == APP_KEY_SAVER) { + previous = APP_KEY_INVALID; + } else if (key == APP_KEY_WAKE) { + previous = APP_KEY_INVALID; + dirty = true; + } else { + if (key != previous && key != APP_KEY_INVALID) { + A->backlight_on(); + key_press(key); + } + previous = key; + } + + poll_rx(); + if (dirty) { draw(); dirty = false; } + A->delay_ms(10); + A->backlight_update(); + if (++batteryTicks >= 50u) { + batteryTicks = 0; + A->battery_sample(); + dirty = true; + } + } + + stop_rx(); + A->beam_leave(); +} diff --git a/App/apps/beam/build.sh b/App/apps/beam/build.sh new file mode 100755 index 00000000..ad2fcd6e --- /dev/null +++ b/App/apps/beam/build.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP="$(basename "$PWD")" +APP_NAME="BEAM" +APP_VER="1.0" +APP_VMA=${APP_VMA:-0x20000280} +OUT="${APP_NAME// /}" + +CC=/opt/toolchain/bin/arm-none-eabi-gcc +OBJCOPY=/opt/toolchain/bin/arm-none-eabi-objcopy +command -v arm-none-eabi-gcc >/dev/null 2>&1 && { CC=arm-none-eabi-gcc; OBJCOPY=arm-none-eabi-objcopy; } + +CFLAGS="-mcpu=cortex-m0plus -mthumb -Os -std=gnu11 -ffreestanding -fno-builtin -fno-common \ + -fomit-frame-pointer -ffunction-sections -fdata-sections -Wall -Wextra" +LDFLAGS="-nostdlib -nostartfiles -T app.ld -Wl,--defsym,APP_VMA=${APP_VMA} \ + -Wl,--gc-sections -Wl,-Map=${APP}.map -Wl,--build-id=none -Wl,--no-warn-rwx-segments" + +rm -f ./*.app ./*.elf ./*.bin + +step() { printf '\r 🔨 %-13s [%d/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}" \ + --shortcut beam >/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/pack_app.py b/App/apps/pack_app.py index eaa4a10c..1f0c26cc 100644 --- a/App/apps/pack_app.py +++ b/App/apps/pack_app.py @@ -38,6 +38,7 @@ SHORTCUTS = { "fm": cdefine("app_overlay.h", "APP_SHORTCUT_FM"), "foxhunt": cdefine("app_overlay.h", "APP_SHORTCUT_FOXHUNT"), "beacon": cdefine("app_overlay.h", "APP_SHORTCUT_BEACON"), + "beam": cdefine("app_overlay.h", "APP_SHORTCUT_BEAM"), } def field(s: str, n: int) -> bytes: diff --git a/App/driver/bk4819.c b/App/driver/bk4819.c index 168adf45..2e15dc4e 100644 --- a/App/driver/bk4819.c +++ b/App/driver/bk4819.c @@ -1095,7 +1095,7 @@ void BK4819_TurnsOffTones_TurnsOnRX(void) BK4819_REG_30_ENABLE_RX_DSP); } -#ifdef ENABLE_AIRCOPY +#if defined(ENABLE_AIRCOPY) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) void BK4819_SetupAircopy(void) { BK4819_WriteRegister(BK4819_REG_70, 0x00E0); // Enable Tone2, tuning gain 48 diff --git a/App/driver/bk4819.h b/App/driver/bk4819.h index 9bbab159..79870bff 100644 --- a/App/driver/bk4819.h +++ b/App/driver/bk4819.h @@ -114,7 +114,7 @@ void BK4819_EnterTxMute(void); void BK4819_ExitTxMute(void); void BK4819_Sleep(void); void BK4819_TurnsOffTones_TurnsOnRX(void); -#ifdef ENABLE_AIRCOPY +#if defined(ENABLE_AIRCOPY) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) void BK4819_SetupAircopy(void); #endif void BK4819_ResetFSK(void); diff --git a/App/driver/bk4829.c b/App/driver/bk4829.c index 78c38c9e..27e99d6f 100644 --- a/App/driver/bk4829.c +++ b/App/driver/bk4829.c @@ -1133,7 +1133,7 @@ void BK4819_TurnsOffTones_TurnsOnRX(void) BK4819_REG_30_ENABLE_RX_DSP); } -#ifdef ENABLE_AIRCOPY +#if defined(ENABLE_AIRCOPY) || defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) void BK4819_SetupAircopy(void) { BK4819_WriteRegister(BK4819_REG_70, 0x00C3); // Enable Tone2, tuning gain 48 diff --git a/CMakePresets.json b/CMakePresets.json index 9ce2fd38..f3d571a4 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -180,7 +180,7 @@ "ENABLE_FEAT_F4HWN_FOXHUNT": false, "ENABLE_FEAT_F4HWN_BEACON": false, "ENABLE_AIRCOPY": true, - "ENABLE_FEAT_F4HWN_BEAM": true, + "ENABLE_FEAT_F4HWN_BEAM": false, "ENABLE_FEAT_F4HWN_GAME": false, "ENABLE_FEAT_F4HWN_OVERLAY_APPS": true, "EDITION_STRING": "Labs", From c8da5f99cc758af9cc06d6597b21666457f8ca3b Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 31 Aug 2026 20:51:09 +0200 Subject: [PATCH 56/73] Version overlay app ABI and capabilities --- App/app/foxhunt.c | 4 ++-- App/apps/app_api.h | 42 +++++++++++++++++++++++--------------- App/apps/app_menu.c | 30 ++++++++++++++++----------- App/apps/app_overlay.c | 26 ++++++++++++++++------- App/apps/app_overlay.h | 13 +++++++++--- App/apps/beacon/build.sh | 3 ++- App/apps/beam/build.sh | 3 ++- App/apps/breakout/build.sh | 3 ++- App/apps/cube3d/build.sh | 3 ++- App/apps/fm/build.sh | 5 +++-- App/apps/foxhunt/build.sh | 3 ++- App/apps/pack_app.py | 32 ++++++++++++++++++++++------- App/apps/plasma/build.sh | 3 ++- App/apps/trivfo/build.sh | 3 ++- App/settings.c | 13 +++++++----- App/settings.h | 4 ++++ App/ui/welcome.c | 4 ++-- 17 files changed, 130 insertions(+), 64 deletions(-) diff --git a/App/app/foxhunt.c b/App/app/foxhunt.c index 17dfe0c7..2682d865 100644 --- a/App/app/foxhunt.c +++ b/App/app/foxhunt.c @@ -26,6 +26,7 @@ #include "k5viewer.h" #endif +#include "settings.h" #include "ui/status.h" // Signal window mapped onto the RSSI bar, in dBm. @@ -104,7 +105,6 @@ #define FOXHUNT_BEACON_TX_MIN 5 // shortest TX window (s) #define FOXHUNT_BEACON_TX_MAX 60 // longest TX window (s) #define FOXHUNT_BEACON_TX_STEP 5 // TX adjust step (s) -#define FOXHUNT_CALLSIGN_ADDR 0x00A0C8u // boot message line 1 in SPI flash #define FOXHUNT_CALLSIGN_MAX 12 // maximum boot-message characters used by the beacon // Fox identifier (3 key). MOE..MO5 are the five standard IARU ARDF foxes ("MO" + 1..5 @@ -1366,7 +1366,7 @@ static void FOXHUNT_LoadCallsign(void) // Read the callsign from the boot message (line 1), sanitised to what Morse can // send; empty/erased leaves foxCall empty (the CALL id then falls back to bare MOE). - PY25Q16_ReadBuffer(FOXHUNT_CALLSIGN_ADDR, call, FOXHUNT_CALLSIGN_MAX); + PY25Q16_ReadBuffer(SETTINGS_BOOT_MESSAGE_LINE1_ADDR, call, FOXHUNT_CALLSIGN_MAX); call[FOXHUNT_CALLSIGN_MAX] = '\0'; for (uint8_t i = 0; i < FOXHUNT_CALLSIGN_MAX; i++) { char c = call[i]; diff --git a/App/apps/app_api.h b/App/apps/app_api.h index 94827c13..96355154 100644 --- a/App/apps/app_api.h +++ b/App/apps/app_api.h @@ -25,9 +25,11 @@ * * 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. + * Both the firmware loader and the app include THIS header. Within one ABI + * major, app_api_t is append-only: existing fields may never move, disappear or + * change signature. Append a service and bump APP_API_LEVEL; only apps using + * that service need the new level. A breaking layout change bumps + * APP_ABI_MAJOR and resets APP_API_LEVEL to 1. */ #ifndef APPS_APP_API_H @@ -36,11 +38,10 @@ #include #include -/* The table below 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 the - * value read by pack_app.py, which - * stamps the blob the loader checks against. */ -#define APP_ABI_VERSION 8u +/* First unpublished/public baseline: all services currently present below are + * ABI major 1, API level 1. */ +#define APP_ABI_MAJOR 1u +#define APP_API_LEVEL 1u /* 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. */ @@ -143,7 +144,7 @@ typedef struct { uint8_t band; uint8_t scanlist; uint8_t compander; - char name[16]; + char name[16]; /* fixed-width wire field; NUL termination is not required */ } app_beam_channel_t; _Static_assert(sizeof(app_beam_channel_t) == 44u, @@ -155,8 +156,19 @@ enum { APP_BEAM_RX_ERROR = 2, }; +/* CRITICAL PERSISTENCE RULE + * + * The app executes from the RAM buffer also used as PY25Q16's sector cache. + * Therefore no API callback may erase or write external flash while app_main() + * is running: a read-modify-write would overwrite the executing app. Services + * such as cfg_save, fm_commit and beam_save must only stage resident RAM state; + * APP_LaunchOverlay commits it after app_main() returns. */ + typedef struct app_api { - uint8_t abi_version; /* == APP_ABI_VERSION */ + /* Fixed four-byte prefix; services remain naturally pointer-aligned. */ + uint8_t abi_major; /* == APP_ABI_MAJOR */ + uint8_t api_level; /* == APP_API_LEVEL */ + uint16_t api_size; /* sizeof(app_api_t), for optional probing */ app_fb_t fb; /* -> gFrameBuffer */ @@ -182,10 +194,6 @@ typedef struct app_api { void (*play_tone)(uint16_t tone, uint16_t ms); /* full BK4819 tone burst + AF path */ void (*led)(bool on); /* green GPIO indicator */ - /* ==== 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, @@ -240,14 +248,14 @@ typedef struct app_api { void (*fm_state)(app_fm_state_t *s, bool write);/* read/write the resident gEeprom.FM_* */ void (*fm_commit)(void); /* deferred SETTINGS_SaveFM (config + channels) */ - /* ---- navigation (ABI 3) ---- + /* ---- navigation (API level 1 baseline) ---- * Convert a raw APP_KEY_UP/DOWN into a semantic value direction: * UV-K5 UP/DOWN -> +1/-1 * UV-K1 LEFT/RIGHT -> -1/+1 * Returns 0 for any other key. Keep get_key() raw for spatial controls. */ int8_t (*nav_dir)(uint8_t key); - /* ---- triple VFO (ABI 4) ---- + /* ---- triple VFO (API level 1 baseline) ---- * A and B are the live Main Display VFOs. C is a resident temporary VFO * loaded from c_channel (or the first valid memory after B when invalid). * tick is called every 20 ms by the app and returns APP_TRIVFO_* state. */ @@ -259,7 +267,7 @@ typedef struct app_api { uint8_t (*trivfo_tick)(void); uint8_t (*trivfo_ptt)(bool pressed); /* physical PTT edge; resident applies SetPTT */ - /* ---- BEAM channel transfer (ABI 8) ---- */ + /* ---- BEAM channel transfer (API level 1 baseline) ---- */ void (*beam_prepare)(void); /* tune the fixed narrow-band FSK channel */ void (*beam_leave)(void); /* defensively stop FSK before app return */ void (*beam_get)(app_beam_channel_t *channel); /* export selected VFO */ diff --git a/App/apps/app_menu.c b/App/apps/app_menu.c index f1b14fa7..24f86eed 100644 --- a/App/apps/app_menu.c +++ b/App/apps/app_menu.c @@ -170,35 +170,41 @@ static void app_format_size(char out[6], uint32_t bytes) out[5] = '\0'; } -/* Human-readable reason for an APP_LaunchOverlay / APP_ValidateSlot failure. */ -static const char *app_err_text(uint8_t rc) +/* Human-readable action for an APP_LaunchOverlay / APP_ValidateSlot failure. */ +static const char *app_err_text(const app_header_t *header, uint8_t rc) { switch (rc) { case APP_ERR_SLOT: return "BAD SLOT"; case APP_ERR_MAGIC: return "NO APP"; - case APP_ERR_ABI: 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_ABI: + /* api_min == 0 also identifies pre-reset development blobs whose + * former uint16_t ABI value occupies these two bytes. */ + if (header->api_min == 0u || header->abi_major < APP_ABI_MAJOR) + return "UPDATE APP"; + return "UPDATE FIRMWARE"; + case APP_ERR_NOT_COMMITTED: return "REINSTALL APP"; + case APP_ERR_SIZE: return "UPDATE APP"; + case APP_ERR_CRC: return "REINSTALL APP"; + case APP_ERR_VMA: return "UPDATE APP"; case APP_ERR_AUTH: return "AUTH"; + case APP_ERR_CAP: return "NOT SUPPORTED"; default: return "ERROR"; } } -/* A launch failed: name the app and the reason, then wait for a key. Without this +/* A launch failed: name the app and the action to take, then wait for a key. Without this * an incompatible app would silently "do nothing" when selected. */ -static void app_show_error(const char *name, uint8_t rc) +static void app_show_error(const app_header_t *header, uint8_t rc) { char nm[19]; - app_copy(nm, sizeof(nm), name, APP_NAME_LEN); + app_copy(nm, sizeof(nm), header->name, APP_NAME_LEN); UI_DisplayClear(); UI_StatusClear(); GUI_DisplaySmallestInverse("APP ERROR", 46, 0, true, true, 82); UI_PrintStringSmallNormal(nm, 2, 0, 2); /* which app */ - UI_PrintStringSmallNormal(app_err_text(rc), 2, 0, 4); /* why */ + UI_PrintStringSmallNormal(app_err_text(header, rc), 2, 0, 4); /* action */ UI_PrintStringSmallNormal("Press any key", 2, 0, 6); ST7565_BlitStatusLine(); ST7565_BlitFullScreen(); @@ -316,7 +322,7 @@ void APP_MenuOpen(void) const uint8_t rc = APP_LaunchOverlay(sel); /* runs until the app exits */ if (rc != APP_OK) - app_show_error(hdr[sel].name, rc); /* no longer silent */ + app_show_error(&hdr[sel], rc); /* no longer silent */ app_wait_release(); break; } diff --git a/App/apps/app_overlay.c b/App/apps/app_overlay.c index c5cc843a..1db22709 100644 --- a/App/apps/app_overlay.c +++ b/App/apps/app_overlay.c @@ -51,6 +51,13 @@ #include "misc.h" /* dBmCorrTable */ _Static_assert(sizeof(app_header_t) == 64, "app_header_t must be 64 bytes"); +_Static_assert(sizeof(app_api_t) <= UINT16_MAX, "app_api_t size field overflow"); + +#ifdef ENABLE_FMRADIO + #define APP_AVAILABLE_CAPS APP_CAP_FM +#else + #define APP_AVAILABLE_CAPS 0u +#endif /* ---- ABI wrappers: the few resident calls that are not a direct signature match ---- */ static bool app_allow_screen_saver; @@ -129,7 +136,7 @@ static void app_play_tone(uint16_t tone, uint16_t ms) AUDIO_AudioPathOff(); } -/* ---- ABI 4: resident triple-VFO engine --------------------------------- +/* ---- API level 1: resident triple-VFO engine ---------------------------- * The overlay owns the UI and key timing, while this resident engine owns all * radio details. Keeping VFO_Info_t and BK4819 sequencing on this side makes * the app independent of feature-dependent firmware layouts. */ @@ -525,7 +532,7 @@ static uint8_t app_trivfo_ptt(bool pressed) return 0; } -/* ---- ABI 8: BEAM radio/channel bridge ------------------------------------ +/* ---- API level 1: BEAM radio/channel bridge ------------------------------- * The modal app owns the packet format, CRC, UI and state machine. Resident * code only translates the stable ABI channel structure and performs the FSK * operations which depend on VFO_Info_t and the BK4819 driver. */ @@ -719,7 +726,7 @@ static void app_beam_draw(const char *status) UI_PrintStringSmallBold(status, 2, LCD_WIDTH - 1u, line); } -/* ---- v2 radio wrappers ---- */ +/* ---- 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); } @@ -787,7 +794,7 @@ 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 */ + PY25Q16_ReadBuffer(SETTINGS_BOOT_MESSAGE_LINE1_ADDR, raw, sizeof(raw)); for (uint8_t i = 0; i < sizeof(raw) && (uint8_t)(n + 1) < len; i++) { char c = raw[i]; if (c == '\0' || (uint8_t)c == 0xFFu) break; @@ -849,8 +856,10 @@ uint8_t APP_ValidateSlot(uint8_t slot, app_header_t *out_header) 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.abi_major != APP_ABI_MAJOR || h.api_min == 0u || + h.api_min > APP_API_LEVEL) return APP_ERR_ABI; if (!(h.flags & APP_FLAG_COMMITTED)) return APP_ERR_NOT_COMMITTED; + if (h.required_caps & ~APP_AVAILABLE_CAPS) return APP_ERR_CAP; if (h.code_size < 2u || h.code_size > APP_OVERLAY_MAX || (uint32_t)h.entry_off > h.code_size - 2u || /* leave room for a 2-byte Thumb insn */ (h.entry_off & 1u) != 0u) /* entry must be Thumb-aligned (even) */ @@ -932,9 +941,12 @@ uint8_t APP_SlotInfo(uint8_t slot, app_header_t *out_header) /* All services are immutable. Keeping the table in flash avoids rebuilding a * roughly quarter-kilobyte automatic object on every launch and removes that - * object from the launcher's stack frame. */ + * object from the launcher's stack frame. Callbacks must also obey the ABI's + * no-external-flash-write rule while entry() is running. */ static const app_api_t app_api = { - .abi_version = APP_ABI_VERSION, + .abi_major = APP_ABI_MAJOR, + .api_level = APP_API_LEVEL, + .api_size = sizeof(app_api_t), .fb = gFrameBuffer, .display_clear = UI_DisplayClear, .status_clear = UI_StatusClear, diff --git a/App/apps/app_overlay.h b/App/apps/app_overlay.h index c5c7a73c..8c07e703 100644 --- a/App/apps/app_overlay.h +++ b/App/apps/app_overlay.h @@ -78,10 +78,15 @@ #define APP_SHORTCUT_BEACON 0x04u #define APP_SHORTCUT_BEAM 0x08u +/* Optional resident facilities an app may require. Requirements live in the + * previously reserved header bytes, so app_header_t remains 64 bytes. */ +#define APP_CAP_FM 0x00000001u + 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 */ + uint8_t abi_major; /* required ABI family */ + uint8_t api_min; /* minimum append-only API level */ uint32_t code_size; /* bytes of code, <= APP_OVERLAY_MAX */ uint32_t code_crc32; /* CRC-32 (zlib) over code_size bytes */ uint16_t entry_off; /* entry offset within the code (0) */ @@ -89,19 +94,21 @@ typedef struct __attribute__((packed)) { 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 */ + uint32_t required_caps; /* APP_CAP_* required by this app */ + uint8_t reserved[4]; /* 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_ABI, /* ABI family/API level 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 */ + APP_ERR_CAP, /* required firmware capability missing */ }; /* Read + validate a slot header (no CRC of the code). */ diff --git a/App/apps/beacon/build.sh b/App/apps/beacon/build.sh index 905866b6..bcbf1b2d 100755 --- a/App/apps/beacon/build.sh +++ b/App/apps/beacon/build.sh @@ -9,6 +9,7 @@ set -euo pipefail APP="$(basename "$PWD")" # breakout, foxhunt, beacon, fm, ... APP_NAME="Beacon" # <-- the only per-app line APP_VER="1.0" +APP_API_MIN=1 APP_VMA=${APP_VMA:-0x20000280} # pinned overlay VMA (Core/py32f071xb.ld) OUT="${APP_NAME// /}" # blob basename ("Broadcast FM" -> BroadcastFM) @@ -29,7 +30,7 @@ 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}" \ + --name "$APP_NAME" --ver "$APP_VER" --api-min "$APP_API_MIN" --vma "${APP_VMA}" \ --shortcut beacon >/dev/null trap - ERR diff --git a/App/apps/beam/build.sh b/App/apps/beam/build.sh index ad2fcd6e..9aff338d 100755 --- a/App/apps/beam/build.sh +++ b/App/apps/beam/build.sh @@ -4,6 +4,7 @@ set -euo pipefail APP="$(basename "$PWD")" APP_NAME="BEAM" APP_VER="1.0" +APP_API_MIN=1 APP_VMA=${APP_VMA:-0x20000280} OUT="${APP_NAME// /}" @@ -24,7 +25,7 @@ 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}" \ + --name "$APP_NAME" --ver "$APP_VER" --api-min "$APP_API_MIN" --vma "${APP_VMA}" \ --shortcut beam >/dev/null trap - ERR diff --git a/App/apps/breakout/build.sh b/App/apps/breakout/build.sh index 34efcbd4..a17a7159 100755 --- a/App/apps/breakout/build.sh +++ b/App/apps/breakout/build.sh @@ -9,6 +9,7 @@ set -euo pipefail APP="$(basename "$PWD")" # breakout, foxhunt, beacon, fm, ... APP_NAME="Breakout" # <-- the only per-app line APP_VER="1.0" +APP_API_MIN=1 APP_VMA=${APP_VMA:-0x20000280} # pinned overlay VMA (Core/py32f071xb.ld) OUT="${APP_NAME// /}" # blob basename ("Broadcast FM" -> BroadcastFM) @@ -29,7 +30,7 @@ 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 + --name "$APP_NAME" --ver "$APP_VER" --api-min "$APP_API_MIN" --vma "${APP_VMA}" >/dev/null trap - ERR BYTES=$(wc -c < "${APP}.bin") diff --git a/App/apps/cube3d/build.sh b/App/apps/cube3d/build.sh index 3e17025a..cadc1df6 100755 --- a/App/apps/cube3d/build.sh +++ b/App/apps/cube3d/build.sh @@ -9,6 +9,7 @@ set -euo pipefail APP="$(basename "$PWD")" # breakout, foxhunt, beacon, fm, ... APP_NAME="Cube3D" # <-- the only per-app line APP_VER="1.0" +APP_API_MIN=1 APP_VMA=${APP_VMA:-0x20000280} # pinned overlay VMA (Core/py32f071xb.ld) OUT="${APP_NAME// /}" # blob basename ("Broadcast FM" -> BroadcastFM) @@ -29,7 +30,7 @@ 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}" --screensaver >/dev/null + --name "$APP_NAME" --ver "$APP_VER" --api-min "$APP_API_MIN" --vma "${APP_VMA}" --screensaver >/dev/null trap - ERR BYTES=$(wc -c < "${APP}.bin") diff --git a/App/apps/fm/build.sh b/App/apps/fm/build.sh index 2b452893..5500c65e 100755 --- a/App/apps/fm/build.sh +++ b/App/apps/fm/build.sh @@ -9,6 +9,7 @@ set -euo pipefail APP="$(basename "$PWD")" # breakout, foxhunt, beacon, fm, ... APP_NAME="Broadcast FM" # <-- the only per-app line APP_VER="1.0" +APP_API_MIN=1 APP_VMA=${APP_VMA:-0x20000280} # pinned overlay VMA (Core/py32f071xb.ld) OUT="${APP_NAME// /}" # blob basename ("Broadcast FM" -> BroadcastFM) @@ -29,8 +30,8 @@ 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}" \ - --shortcut fm --screensaver >/dev/null + --name "$APP_NAME" --ver "$APP_VER" --api-min "$APP_API_MIN" --vma "${APP_VMA}" \ + --shortcut fm --require fm --screensaver >/dev/null trap - ERR BYTES=$(wc -c < "${APP}.bin") diff --git a/App/apps/foxhunt/build.sh b/App/apps/foxhunt/build.sh index fa4a86e8..2d77d1f0 100755 --- a/App/apps/foxhunt/build.sh +++ b/App/apps/foxhunt/build.sh @@ -9,6 +9,7 @@ set -euo pipefail APP="$(basename "$PWD")" # breakout, foxhunt, beacon, fm, ... APP_NAME="FoxHunt" # <-- the only per-app line APP_VER="1.0" +APP_API_MIN=1 APP_VMA=${APP_VMA:-0x20000280} # pinned overlay VMA (Core/py32f071xb.ld) OUT="${APP_NAME// /}" # blob basename ("Broadcast FM" -> BroadcastFM) @@ -29,7 +30,7 @@ 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}" \ + --name "$APP_NAME" --ver "$APP_VER" --api-min "$APP_API_MIN" --vma "${APP_VMA}" \ --shortcut foxhunt >/dev/null trap - ERR diff --git a/App/apps/pack_app.py b/App/apps/pack_app.py index 1f0c26cc..8d6a7def 100644 --- a/App/apps/pack_app.py +++ b/App/apps/pack_app.py @@ -1,13 +1,14 @@ #!/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 +# The format/version constants (magic, header + ABI/API levels, 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 +# ./pack_app.py breakout/breakout.bin breakout/breakout.app --name Breakout \ +# --ver 1.0 --vma 0x20000280 --api-min 1 import argparse, os, re, struct, zlib, sys HERE = os.path.dirname(os.path.abspath(__file__)) @@ -28,7 +29,8 @@ def cdefine(header: str, name: str) -> int: # 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") +ABI_MAJOR = cdefine("app_api.h", "APP_ABI_MAJOR") +API_LEVEL = cdefine("app_api.h", "APP_API_LEVEL") OVERLAY_MAX = cdefine("app_overlay.h", "APP_OVERLAY_MAX") # 4 KiB overlay budget FLAG_COMMITTED = cdefine("app_overlay.h", "APP_FLAG_COMMITTED") FLAG_SCREEN_SAVER = cdefine("app_overlay.h", "APP_FLAG_SCREEN_SAVER") @@ -40,6 +42,9 @@ SHORTCUTS = { "beacon": cdefine("app_overlay.h", "APP_SHORTCUT_BEACON"), "beam": cdefine("app_overlay.h", "APP_SHORTCUT_BEAM"), } +CAPABILITIES = { + "fm": cdefine("app_overlay.h", "APP_CAP_FM"), +} def field(s: str, n: int) -> bytes: b = s.encode("ascii", "strict")[: n - 1] @@ -52,14 +57,21 @@ def main(): 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("--api-min", type=int, required=True, + help="minimum append-only firmware API level required by this app") 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)") ap.add_argument("--screensaver", action="store_true", help="allow the resident BLTime screen saver while this app is idle") ap.add_argument("--shortcut", choices=SHORTCUTS, default="none", help="resident quick action advertised by this app") + ap.add_argument("--require", action="append", choices=CAPABILITIES, default=[], + help="resident capability required by this app (repeatable)") a = ap.parse_args() + if not 1 <= a.api_min <= API_LEVEL: + sys.exit(f"--api-min must be between 1 and current API level {API_LEVEL}") + code = open(a.infile, "rb").read() if len(code) == 0: sys.exit("empty input") @@ -70,11 +82,15 @@ def main(): flags = (FLAG_COMMITTED | (FLAG_SCREEN_SAVER if a.screensaver else 0) | (SHORTCUTS[a.shortcut] << FLAG_SHORTCUT_SHIFT)) + required_caps = 0 + for capability in a.require: + required_caps |= CAPABILITIES[capability] header = struct.pack( - "<4sHHIIHH16s16sI8s", - MAGIC, HDR_VERSION, ABI_VERSION, + "<4sHBBIIHH16s16sII4s", + MAGIC, HDR_VERSION, ABI_MAJOR, a.api_min, len(code), crc, a.entry, flags, - field(a.name, 16), field(a.ver, 16), a.vma, b"\x00" * 8, + field(a.name, 16), field(a.ver, 16), a.vma, + required_caps, b"\x00" * 4, ) assert len(header) == 64, len(header) @@ -82,7 +98,9 @@ def main(): f.write(header) f.write(code) - print(f"{a.outfile}: name={a.name!r} ver={a.ver!r} vma=0x{a.vma:08x} " + print(f"{a.outfile}: name={a.name!r} ver={a.ver!r} " + f"abi={ABI_MAJOR} api>={a.api_min} caps=0x{required_caps:08x} " + f"vma=0x{a.vma:08x} " f"code={len(code)} B crc32=0x{crc:08x} -> blob {64 + len(code)} B") if __name__ == "__main__": diff --git a/App/apps/plasma/build.sh b/App/apps/plasma/build.sh index 7066c42e..69959c3d 100755 --- a/App/apps/plasma/build.sh +++ b/App/apps/plasma/build.sh @@ -9,6 +9,7 @@ set -euo pipefail APP="$(basename "$PWD")" # breakout, foxhunt, beacon, fm, ... APP_NAME="Plasma" # <-- the only per-app line APP_VER="1.0" +APP_API_MIN=1 APP_VMA=${APP_VMA:-0x20000280} # pinned overlay VMA (Core/py32f071xb.ld) OUT="${APP_NAME// /}" # blob basename ("Broadcast FM" -> BroadcastFM) @@ -29,7 +30,7 @@ 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}" --screensaver >/dev/null + --name "$APP_NAME" --ver "$APP_VER" --api-min "$APP_API_MIN" --vma "${APP_VMA}" --screensaver >/dev/null trap - ERR BYTES=$(wc -c < "${APP}.bin") diff --git a/App/apps/trivfo/build.sh b/App/apps/trivfo/build.sh index 5e78a6e5..b8bafb88 100755 --- a/App/apps/trivfo/build.sh +++ b/App/apps/trivfo/build.sh @@ -3,6 +3,7 @@ set -euo pipefail APP="$(basename "$PWD")" APP_NAME="Triple VFO" APP_VER="1.0" +APP_API_MIN=1 APP_VMA=${APP_VMA:-0x20000280} OUT="${APP_NAME// /}" CC=/opt/toolchain/bin/arm-none-eabi-gcc @@ -13,7 +14,7 @@ LDFLAGS="-nostdlib -nostartfiles -T app.ld -Wl,--defsym,APP_VMA=${APP_VMA} -Wl,- rm -f ./*.app ./*.elf ./*.bin "$CC" $CFLAGS $LDFLAGS "${APP}_app.c" -lgcc -o "${APP}.elf" "$OBJCOPY" -O binary "${APP}.elf" "${APP}.bin" -python3 ../pack_app.py "${APP}.bin" "${OUT}.app" --name "$APP_NAME" --ver "$APP_VER" --vma "${APP_VMA}" --screensaver >/dev/null +python3 ../pack_app.py "${APP}.bin" "${OUT}.app" --name "$APP_NAME" --ver "$APP_VER" --api-min "$APP_API_MIN" --vma "${APP_VMA}" --screensaver >/dev/null BYTES=$(wc -c < "${APP}.bin") test "$BYTES" -le 4096 printf ' ✅ %-13s %4d B (%d%% of 4 KiB) -> %s.app\n' \ diff --git a/App/settings.c b/App/settings.c index f7836c87..56a051f0 100644 --- a/App/settings.c +++ b/App/settings.c @@ -90,8 +90,10 @@ void SETTINGS_InitEEPROM(void) // 4. Reset logo lines (clear to null for strlen() == 0) - char logoLines[32]; - PY25Q16_ReadBuffer(0x00A0C8, logoLines, sizeof(logoLines)); + /* The two boot-message lines are contiguous in external flash. */ + char bootMessageLines[32]; + PY25Q16_ReadBuffer(SETTINGS_BOOT_MESSAGE_LINE1_ADDR, + bootMessageLines, sizeof(bootMessageLines)); bool needsWrite = false; @@ -99,12 +101,12 @@ void SETTINGS_InitEEPROM(void) int offset = line * 16; for (int i = 0; i < 16; i++) { - char c = logoLines[offset + i]; + char c = bootMessageLines[offset + i]; if (c == 0) { break; } if (c < 0x20 || c > 0x7E) { - memset(logoLines + offset, 0, 16); + memset(bootMessageLines + offset, 0, 16); needsWrite = true; break; } @@ -112,7 +114,8 @@ void SETTINGS_InitEEPROM(void) } if (needsWrite) { - PY25Q16_WriteBuffer(0x00A0C8, logoLines, sizeof(logoLines), false); + PY25Q16_WriteBuffer(SETTINGS_BOOT_MESSAGE_LINE1_ADDR, + bootMessageLines, sizeof(bootMessageLines), false); } // 5. Reset dBmCorrTable diff --git a/App/settings.h b/App/settings.h index 4673a56c..3aa2d734 100644 --- a/App/settings.h +++ b/App/settings.h @@ -25,6 +25,10 @@ #include "radio.h" #include +/* Shared PY25Q16 locations for the two configurable boot-message lines. */ +#define SETTINGS_BOOT_MESSAGE_LINE1_ADDR 0x00A0C8u +#define SETTINGS_BOOT_MESSAGE_LINE2_ADDR 0x00A0D8u + enum POWER_OnDisplayMode_t { #ifdef ENABLE_FEAT_F4HWN POWER_ON_DISPLAY_MODE_ALL, diff --git a/App/ui/welcome.c b/App/ui/welcome.c index 37e288a8..a41f45e4 100644 --- a/App/ui/welcome.c +++ b/App/ui/welcome.c @@ -261,10 +261,10 @@ void UI_DisplayWelcome(void) char WelcomeString3[32]; // 0x0EB0 - PY25Q16_ReadBuffer(0x00A0C8, WelcomeString0, 16); + PY25Q16_ReadBuffer(SETTINGS_BOOT_MESSAGE_LINE1_ADDR, WelcomeString0, 16); WelcomeString0[16] = '\0'; // 0x0EC0 - PY25Q16_ReadBuffer(0x00A0D8, WelcomeString1, 16); + PY25Q16_ReadBuffer(SETTINGS_BOOT_MESSAGE_LINE2_ADDR, WelcomeString1, 16); WelcomeString1[16] = '\0'; sprintf(WelcomeString2, "%u.%02uV %u%%", From b26265c5f08a274bf9d0ff803a93c78747adf519 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 31 Aug 2026 21:49:12 +0200 Subject: [PATCH 57/73] Optimize BEAM channel field copies --- App/apps/app_overlay.c | 112 ++++++++++++++++++++++++++--------------- 1 file changed, 72 insertions(+), 40 deletions(-) diff --git a/App/apps/app_overlay.c b/App/apps/app_overlay.c index 1db22709..9113a274 100644 --- a/App/apps/app_overlay.c +++ b/App/apps/app_overlay.c @@ -19,6 +19,7 @@ #ifdef ENABLE_FEAT_F4HWN_OVERLAY_APPS #include +#include /* offsetof */ #include "py32f0xx.h" #include "driver/bk4819.h" @@ -563,33 +564,75 @@ static void app_beam_leave(void) BK4819_ResetFSK(); } +/* Wire<->VFO fields that are a plain one-byte copy in BOTH directions. Fields + * that differ in width (frequency, offset, band) or are enum-typed on the VFO + * side (modulation, code types, PTT-id) are excluded: enums are int-sized here + * (no -fshort-enums), so a byte copy would truncate them. Those stay as the + * explicit width-converting assignments below. Driving the byte fields from one + * table collapses two near-identical copy blocks into a single shared loop. */ +#ifdef ENABLE_DTMF_CALLING +#define APP_BEAM_BYTE_FIELDS_DTMF(F) F(dtmf_decoding_enable, DTMF_DECODING_ENABLE) +#else +#define APP_BEAM_BYTE_FIELDS_DTMF(F) +#endif +#define APP_BEAM_BYTE_FIELDS(F) \ + F(rx_code, freq_config_RX.Code) \ + F(tx_code, freq_config_TX.Code) \ + F(tx_offset_direction, TX_OFFSET_FREQUENCY_DIRECTION) \ + F(tx_lock, TX_LOCK) \ + F(busy_channel_lock, BUSY_CHANNEL_LOCK) \ + F(output_power, OUTPUT_POWER) \ + F(channel_bandwidth, CHANNEL_BANDWIDTH) \ + F(scanlist, SCANLIST_PARTICIPATION) \ + F(compander, Compander) \ + APP_BEAM_BYTE_FIELDS_DTMF(F) + +typedef struct { uint8_t wire_off, vfo_off; } app_beam_byte_map_t; + +#define APP_BEAM_MAP_ROW(w, v) { offsetof(app_beam_channel_t, w), offsetof(VFO_Info_t, v) }, +static const app_beam_byte_map_t app_beam_byte_map[] = { + APP_BEAM_BYTE_FIELDS(APP_BEAM_MAP_ROW) +}; +#undef APP_BEAM_MAP_ROW + +/* Widening either side of a mapped field must fail to compile here rather than + * silently truncate through the byte copy. */ +#define APP_BEAM_MAP_CHECK(w, v) \ + _Static_assert(sizeof(((app_beam_channel_t *)0)->w) == 1u, #w); \ + _Static_assert(sizeof(((VFO_Info_t *)0)->v) == 1u, #v); +APP_BEAM_BYTE_FIELDS(APP_BEAM_MAP_CHECK) +#undef APP_BEAM_MAP_CHECK + +_Static_assert(sizeof(VFO_Info_t) <= 256u && sizeof(app_beam_channel_t) <= 256u, + "app_beam_byte_map offsets must fit in uint8_t"); + +/* Copy every mapped byte field in one direction (to_vfo = save, else export). */ +static void app_beam_copy_bytes(app_beam_channel_t *wire, VFO_Info_t *vfo, bool to_vfo) +{ + for (unsigned i = 0; i < sizeof(app_beam_byte_map) / sizeof(app_beam_byte_map[0]); i++) { + uint8_t *w = (uint8_t *)wire + app_beam_byte_map[i].wire_off; + uint8_t *v = (uint8_t *)vfo + app_beam_byte_map[i].vfo_off; + if (to_vfo) *v = *w; + else *w = *v; + } +} + static void app_beam_get(app_beam_channel_t *out) { if (out == NULL) return; memset(out, 0, sizeof(*out)); - const VFO_Info_t *vfo = &gEeprom.VfoInfo[gEeprom.TX_VFO]; - out->rx_frequency = vfo->freq_config_RX.Frequency; - out->tx_offset_frequency = vfo->TX_OFFSET_FREQUENCY; - out->rx_code = vfo->freq_config_RX.Code; - out->tx_code = vfo->freq_config_TX.Code; - out->rx_codetype = vfo->freq_config_RX.CodeType; - out->tx_codetype = vfo->freq_config_TX.CodeType; - out->modulation = vfo->Modulation; - out->tx_offset_direction = vfo->TX_OFFSET_FREQUENCY_DIRECTION; - out->tx_lock = vfo->TX_LOCK; - out->busy_channel_lock = vfo->BUSY_CHANNEL_LOCK; - out->output_power = vfo->OUTPUT_POWER; - out->channel_bandwidth = vfo->CHANNEL_BANDWIDTH; - out->frequency_reverse = vfo->FrequencyReverse; - out->dtmf_ptt_id_mode = vfo->DTMF_PTT_ID_TX_MODE; -#ifdef ENABLE_DTMF_CALLING - out->dtmf_decoding_enable = vfo->DTMF_DECODING_ENABLE; -#endif - out->step_setting = vfo->STEP_SETTING; - out->band = vfo->Band; - out->scanlist = vfo->SCANLIST_PARTICIPATION; - out->compander = vfo->Compander; + VFO_Info_t *vfo = &gEeprom.VfoInfo[gEeprom.TX_VFO]; + app_beam_copy_bytes(out, vfo, false); /* plain one-byte fields */ + out->rx_frequency = vfo->freq_config_RX.Frequency; + out->tx_offset_frequency = vfo->TX_OFFSET_FREQUENCY; + out->rx_codetype = vfo->freq_config_RX.CodeType; + out->tx_codetype = vfo->freq_config_TX.CodeType; + out->modulation = vfo->Modulation; + out->frequency_reverse = vfo->FrequencyReverse; + out->dtmf_ptt_id_mode = vfo->DTMF_PTT_ID_TX_MODE; + out->step_setting = vfo->STEP_SETTING; + out->band = vfo->Band; if (IS_MR_CHANNEL(vfo->CHANNEL_SAVE)) SETTINGS_FetchChannelName(out->name, vfo->CHANNEL_SAVE); else @@ -611,26 +654,15 @@ static uint16_t app_beam_save(const app_beam_channel_t *in) * write is allowed while the overlay still executes from that flash's * 4 KiB sector cache. */ RADIO_InitInfo(&app_beam_vfo, channel, in->rx_frequency); - app_beam_vfo.TX_OFFSET_FREQUENCY = in->tx_offset_frequency; - app_beam_vfo.freq_config_RX.Code = in->rx_code; - app_beam_vfo.freq_config_TX.Code = in->tx_code; - app_beam_vfo.freq_config_RX.CodeType = in->rx_codetype; - app_beam_vfo.freq_config_TX.CodeType = in->tx_codetype; - app_beam_vfo.TX_OFFSET_FREQUENCY_DIRECTION = in->tx_offset_direction; - app_beam_vfo.Modulation = in->modulation; - app_beam_vfo.TX_LOCK = in->tx_lock; - app_beam_vfo.BUSY_CHANNEL_LOCK = in->busy_channel_lock; - app_beam_vfo.OUTPUT_POWER = in->output_power; - app_beam_vfo.CHANNEL_BANDWIDTH = in->channel_bandwidth; - app_beam_vfo.FrequencyReverse = in->frequency_reverse; - app_beam_vfo.DTMF_PTT_ID_TX_MODE = in->dtmf_ptt_id_mode; -#ifdef ENABLE_DTMF_CALLING - app_beam_vfo.DTMF_DECODING_ENABLE = in->dtmf_decoding_enable; -#endif + app_beam_copy_bytes((app_beam_channel_t *)in, &app_beam_vfo, true); /* in read-only here */ + app_beam_vfo.TX_OFFSET_FREQUENCY = in->tx_offset_frequency; + app_beam_vfo.freq_config_RX.CodeType = in->rx_codetype; + app_beam_vfo.freq_config_TX.CodeType = in->tx_codetype; + app_beam_vfo.Modulation = in->modulation; + app_beam_vfo.FrequencyReverse = in->frequency_reverse; + app_beam_vfo.DTMF_PTT_ID_TX_MODE = in->dtmf_ptt_id_mode; app_beam_vfo.STEP_SETTING = in->step_setting < STEP_N_ELEM ? in->step_setting : STEP_12_5kHz; app_beam_vfo.StepFrequency = gStepFrequencyTable[app_beam_vfo.STEP_SETTING]; - app_beam_vfo.SCANLIST_PARTICIPATION = in->scanlist; - app_beam_vfo.Compander = in->compander; memcpy(app_beam_vfo.Name, in->name, sizeof(app_beam_vfo.Name)); app_beam_vfo.Name[sizeof(app_beam_vfo.Name) - 1u] = '\0'; From cd99ddc46d09b1067f0f4f70aebff35dd429c6f7 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 31 Aug 2026 21:54:30 +0200 Subject: [PATCH 58/73] Rename BEAM overlay app to Beam --- App/apps/beam/build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/App/apps/beam/build.sh b/App/apps/beam/build.sh index 9aff338d..aaf1c4c9 100755 --- a/App/apps/beam/build.sh +++ b/App/apps/beam/build.sh @@ -2,7 +2,7 @@ set -euo pipefail APP="$(basename "$PWD")" -APP_NAME="BEAM" +APP_NAME="Beam" APP_VER="1.0" APP_API_MIN=1 APP_VMA=${APP_VMA:-0x20000280} From 9019d0df1a7359d9c354406f88b15f115c37262c Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Wed, 2 Sep 2026 12:41:54 +0200 Subject: [PATCH 59/73] Add Tetris app, just for fun --- App/apps/tetris/app.ld | 30 ++ App/apps/tetris/build.sh | 40 +++ App/apps/tetris/tetris_app.c | 562 +++++++++++++++++++++++++++++++++++ 3 files changed, 632 insertions(+) create mode 100644 App/apps/tetris/app.ld create mode 100755 App/apps/tetris/build.sh create mode 100644 App/apps/tetris/tetris_app.c diff --git a/App/apps/tetris/app.ld b/App/apps/tetris/app.ld new file mode 100644 index 00000000..6baa4570 --- /dev/null +++ b/App/apps/tetris/app.ld @@ -0,0 +1,30 @@ +/* Tetris overlay app: code, constants, data and BSS share the 4 KiB workspace. */ +APP_VMA = DEFINED(APP_VMA) ? APP_VMA : 0x20000280; +APP_LENGTH = 0x1000; + +ENTRY(app_main) + +MEMORY { + APP (rwx) : ORIGIN = APP_VMA, LENGTH = APP_LENGTH +} + +SECTIONS { + .app APP_VMA : { + KEEP(*(.text.entry)) + *(.text .text.*) + *(.rodata .rodata.*) + . = ALIGN(4); + *(.data .data.*) + . = ALIGN(4); + __app_bss_start = .; + *(.bss .bss.* COMMON) + . = ALIGN(4); + __app_bss_end = .; + } > APP + + __app_end = .; + ASSERT(__app_end <= APP_VMA + APP_LENGTH, + "Tetris overlay app overflows the 4 KiB overlay") + + /DISCARD/ : { *(.ARM.exidx*) *(.ARM.extab*) *(.eh_frame*) *(.comment) *(.note.*) } +} diff --git a/App/apps/tetris/build.sh b/App/apps/tetris/build.sh new file mode 100755 index 00000000..d543593f --- /dev/null +++ b/App/apps/tetris/build.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +APP="$(basename "$PWD")" +APP_NAME="Tetris" +APP_VER="1.0" +APP_API_MIN=1 +APP_VMA=${APP_VMA:-0x20000280} +OUT="${APP_NAME// /}" + +CC=/opt/toolchain/bin/arm-none-eabi-gcc +OBJCOPY=/opt/toolchain/bin/arm-none-eabi-objcopy +command -v arm-none-eabi-gcc >/dev/null 2>&1 && { CC=arm-none-eabi-gcc; OBJCOPY=arm-none-eabi-objcopy; } + +command -v "$CC" >/dev/null 2>&1 || { echo "❌ ARM compiler not found: $CC"; exit 1; } +command -v "$OBJCOPY" >/dev/null 2>&1 || { echo "❌ ARM objcopy not found: $OBJCOPY"; exit 1; } + +CFLAGS="-mcpu=cortex-m0plus -mthumb -Os -std=gnu11 -ffreestanding -fno-builtin -fno-common \ + -fomit-frame-pointer -ffunction-sections -fdata-sections -Wall -Wextra" +LDFLAGS="-nostdlib -nostartfiles -T app.ld -Wl,--defsym,APP_VMA=${APP_VMA} \ + -Wl,--gc-sections -Wl,-Map=${APP}.map -Wl,--build-id=none -Wl,--no-warn-rwx-segments" + +rm -f ./*.app ./*.elf ./*.bin + +step() { printf '\r 🔨 %-13s [%d/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" --api-min "$APP_API_MIN" \ + --vma "${APP_VMA}" --screensaver >/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/tetris/tetris_app.c b/App/apps/tetris/tetris_app.c new file mode 100644 index 00000000..2ef069a2 --- /dev/null +++ b/App/apps/tetris/tetris_app.c @@ -0,0 +1,562 @@ +/* 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. + */ + +/* + * Tetris — compact overlay game for the 128x64 monochrome LCD. + * + * The 16x16 visible well fills the screen height down the left side; two hidden + * spawn rows sit just above it. Rather than solid walls, the well is framed by + * short corner brackets and a floor line. The narrow right-hand panel shows the + * next piece, score, cleared lines, level and persistent best score. Pieces + * come from a shuffled seven-piece bag; a dotted ghost previews the landing. + * + * Keys: LEFT/RIGHT or 4/6 move, MENU or 2 rotates, 8 soft-drops, + * STAR or 0 hard-drops, F pauses and EXIT quits. + */ + +#include +#include +#include +#include "../app_api.h" + +#define W 128u +#define H 64u +#define COLS 16 +#define VISIBLE_ROWS 16 +#define HIDDEN_ROWS 2 +#define ROWS (VISIBLE_ROWS + HIDDEN_ROWS) +#define CELL 4 +#define WELL_X 3 /* physical x of board column 0 */ +#define WELL_Y 0 /* physical y of visible row 0 (full height) */ +#define WELL_L 1 /* container left rail (corner brackets) */ +#define WELL_R 67 /* container right rail (corner brackets) */ +#define WELL_B 63 /* solid floor line, physical y */ +#define LABEL_X 72 /* left column shared by every panel label */ +#define PREVIEW_CX 108 /* centre of the NEXT-piece preview area */ +#define PREVIEW_CY 15 +#define TICK_MS 20u +#define CFG_MAGIC 0x5445u + +typedef struct { + uint16_t magic; + uint16_t version; + uint32_t best; +} config_t; + +static const app_api_t *A; + +/* GCC may lower aggregate clears to memset even for this freestanding blob. */ +void *memset(void *dst, int value, size_t size) +{ + uint8_t *p = dst; + while (size--) + *p++ = (uint8_t)value; + return dst; +} + +/* Four 4x4 masks for I, O, T, S, Z, J and L. */ +static const uint16_t MASK[7][4] = { + {0x00F0, 0x4444, 0x0F00, 0x2222}, + {0x0066, 0x0066, 0x0066, 0x0066}, + {0x0072, 0x0262, 0x0270, 0x0232}, + {0x0036, 0x0462, 0x0360, 0x0231}, + {0x0063, 0x0264, 0x0630, 0x0132}, + {0x0071, 0x0226, 0x0470, 0x0322}, + {0x0074, 0x0622, 0x0170, 0x0223}, +}; + +static const uint16_t LINE_POINTS[5] = {0, 100, 300, 500, 800}; + +static uint8_t board[ROWS][COLS]; +static uint8_t bag[7], bagPos; +static uint8_t piece, nextPiece, rotation; +static int8_t pieceX, pieceY; +static uint32_t randomState, score, best; +static uint16_t lines, gravityMs; +static uint8_t level; +static bool paused, gameOver, running, saverPaused, saverActive, bestDirty, newBest; +static uint8_t previousKey; +static uint16_t repeatMs; +static char text[9]; + +static void render(bool showPiece, uint32_t clearRows, uint8_t effect); +static void new_game(void); + +/* -------------------------------------------------------------------------- */ +/* RNG and the shuffled 7-piece bag */ +/* -------------------------------------------------------------------------- */ + +static uint32_t random_next(void) +{ + randomState = randomState * 1664525u + 1013904223u; + return randomState; +} + +static void refill_bag(void) +{ + for (uint8_t i = 0; i < 7u; i++) + bag[i] = i; + for (uint8_t i = 6u; i > 0u; i--) { + const uint8_t j = (uint8_t)(random_next() % (uint32_t)(i + 1u)); + const uint8_t t = bag[i]; bag[i] = bag[j]; bag[j] = t; + } + bagPos = 0; +} + +static uint8_t take_piece(void) +{ + if (bagPos >= 7u) + refill_bag(); + return bag[bagPos++]; +} + +/* -------------------------------------------------------------------------- */ +/* Framebuffer drawing: cells, pieces, side panel and dialogs */ +/* -------------------------------------------------------------------------- */ + +static void set_pixel(int x, int y, bool on) +{ + if ((unsigned)x >= W || (unsigned)y >= H) + return; + uint8_t *p; + if (y < 8) + p = &A->status_line[x]; + else + p = &A->fb[(y >> 3) - 1][x]; + const uint8_t bit = (uint8_t)(1u << (y & 7)); + if (on) *p |= bit; else *p &= (uint8_t)~bit; +} + +/* Short straight runs, in physical coordinates so they may cross the + * status-line / framebuffer seam that A->draw_line cannot. */ +static void hline(int x0, int x1, int y) +{ + for (int x = x0; x <= x1; x++) set_pixel(x, y, true); +} + +static void vline(int x, int y0, int y1) +{ + for (int y = y0; y <= y1; y++) set_pixel(x, y, true); +} + +/* Corner brackets plus a solid floor — the well's frame, no vertical walls. */ +static void container(void) +{ + hline(WELL_L, WELL_R, WELL_B); + hline(WELL_L, WELL_L + 5, WELL_Y); + hline(WELL_R - 5, WELL_R, WELL_Y); + vline(WELL_L, WELL_Y, WELL_Y + 5); + vline(WELL_R, WELL_Y, WELL_Y + 5); + vline(WELL_L, WELL_B - 5, WELL_B); + vline(WELL_R, WELL_B - 5, WELL_B); +} + +static void cell(int gx, int gy, uint8_t style) +{ + const int visibleY = gy - HIDDEN_ROWS; + if ((unsigned)gx >= COLS || (unsigned)visibleY >= VISIBLE_ROWS) + return; + const int x = WELL_X + gx * CELL; + const int y = WELL_Y + visibleY * CELL; + if (style == 2u) { + set_pixel(x, y + 1, true); + set_pixel(x + 1, y, true); + set_pixel(x + 2, y + 1, true); + set_pixel(x + 1, y + 2, true); + } else { + const uint8_t size = style == 3u ? CELL : CELL - 1u; + for (uint8_t dy = 0; dy < size; dy++) + for (uint8_t dx = 0; dx < size; dx++) + set_pixel(x + dx, y + dy, true); + } +} + +static bool collision(uint8_t type, uint8_t rot, int8_t px, int8_t py) +{ + const uint16_t mask = MASK[type][rot & 3u]; + for (uint8_t i = 0; i < 16u; i++) { + if (!(mask & (uint16_t)(1u << i))) + continue; + const int x = px + (i & 3u); + const int y = py + (i >> 2); + if (x < 0 || x >= COLS || y >= ROWS) + return true; + if (y >= 0 && board[y][x]) + return true; + } + return false; +} + +static void draw_piece(uint8_t type, uint8_t rot, int8_t px, int8_t py, uint8_t style) +{ + const uint16_t mask = MASK[type][rot & 3u]; + for (uint8_t i = 0; i < 16u; i++) + if (mask & (uint16_t)(1u << i)) + cell(px + (i & 3u), py + (i >> 2), style); +} + +static void number(uint32_t value, uint8_t width) +{ + text[width] = 0; + while (width) { + text[--width] = (char)('0' + value % 10u); + value /= 10u; + } +} + +static void preview(void) +{ + const uint16_t mask = MASK[nextPiece][0]; + uint8_t minX = 3u, maxX = 0u, minY = 3u, maxY = 0u; + for (uint8_t i = 0; i < 16u; i++) { + if (!(mask & (uint16_t)(1u << i))) + continue; + const uint8_t x = i & 3u, y = i >> 2; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + const int width = (maxX - minX + 1u) * CELL - 1u; + const int height = (maxY - minY + 1u) * CELL - 1u; + const int ox = PREVIEW_CX - width / 2 - minX * CELL; + const int oy = PREVIEW_CY - height / 2 - minY * CELL; + for (uint8_t i = 0; i < 16u; i++) { + if (!(mask & (uint16_t)(1u << i))) + continue; + const int x = ox + (i & 3u) * CELL; + const int y = oy + (i >> 2) * CELL; + for (uint8_t dy = 0; dy < CELL - 1u; dy++) + for (uint8_t dx = 0; dx < CELL - 1u; dx++) + set_pixel(x + dx, y + dy, true); + } +} + +/* One "LABEL value" row of the side panel; y is framebuffer-relative + * (physical y - 8), value right-aligned near the screen edge. */ +static void stat(const char *label, uint8_t y, uint32_t value, + uint8_t valueX, uint8_t width) +{ + A->print_tiny(label, LABEL_X, y, false, true); + number(value, width); + A->print_tiny(text, valueX, y, false, true); +} + +static void panel(void) +{ + A->print_tiny("NEXT", LABEL_X, 2, false, true); + preview(); + if (gameOver) + A->print_inverse(newBest ? "NEW BEST!" : "GAME OVER", 82, 2, false, true, 118); + else if (paused) + A->print_inverse("PAUSE", 90, 2, false, true, 110); + stat("SCORE", 24, score > 999999u ? 999999u : score, 101, 6); + stat("LINES", 32, lines > 999u ? 999u : lines, 113, 3); + stat("LEVEL", 40, level, 117, 2); + stat("BEST", 48, best > 999999u ? 999999u : best, 101, 6); +} + +/* effect 0 is normal, 1..8 is the line-clear sweep, 0xFF flashes a lock. */ +static void render(bool showPiece, uint32_t clearRows, uint8_t effect) +{ + A->display_clear(); + A->status_clear(); + A->print_inverse("TETRIS", 74, 0, true, true, 101); + + container(); + + for (uint8_t y = 0; y < ROWS; y++) + for (uint8_t x = 0; x < COLS; x++) + if (board[y][x] && (!(clearRows & (1u << y)) || + (x >= effect && x < COLS - effect))) + cell(x, y, 1u); + + if (showPiece && !gameOver) { + int8_t ghostY = pieceY; + while (!collision(piece, rotation, pieceX, ghostY + 1)) + ghostY++; + if (ghostY != pieceY) + draw_piece(piece, rotation, pieceX, ghostY, 2u); + draw_piece(piece, rotation, pieceX, pieceY, effect == 0xFFu ? 3u : 1u); + } + + panel(); +} + +/* -------------------------------------------------------------------------- */ +/* Board mechanics: scoring, line clears, spawn, lock and moves */ +/* -------------------------------------------------------------------------- */ + +static void update_best(void) +{ + if (score > best) { + best = score; + bestDirty = true; + newBest = true; + } +} + +static uint8_t clear_lines(void) +{ + uint32_t full = 0; + uint8_t count = 0; + for (uint8_t y = 0; y < ROWS; y++) { + bool complete = true; + for (uint8_t x = 0; x < COLS; x++) + if (!board[y][x]) { complete = false; break; } + if (complete) { full |= 1u << y; count++; } + } + if (!count) + return 0; + + A->led(true); + for (uint8_t step = 1u; step <= COLS / 2u; step++) { + render(false, full, step); + A->blit_status(); A->blit_full(); A->delay_ms(28); + } + A->led(false); + + int8_t dst = ROWS - 1; + for (int8_t src = ROWS - 1; src >= 0; src--) { + if (full & (1u << src)) + continue; + if (dst != src) + for (uint8_t x = 0; x < COLS; x++) + board[dst][x] = board[src][x]; + dst--; + } + while (dst >= 0) { + for (uint8_t x = 0; x < COLS; x++) board[dst][x] = 0; + dst--; + } + return count; +} + +static void spawn_piece(void) +{ + piece = nextPiece; + nextPiece = take_piece(); + rotation = 0; + pieceX = COLS / 2 - 2; + pieceY = HIDDEN_ROWS - 1; + if (collision(piece, rotation, pieceX, pieceY)) { + gameOver = true; + update_best(); + } +} + +static void lock_piece(void) +{ + render(true, 0, 0xFFu); + A->blit_status(); A->blit_full(); A->delay_ms(45); + + const uint16_t mask = MASK[piece][rotation]; + bool above = false; + for (uint8_t i = 0; i < 16u; i++) { + if (!(mask & (uint16_t)(1u << i))) + continue; + const int x = pieceX + (i & 3u); + const int y = pieceY + (i >> 2); + if (y < HIDDEN_ROWS) above = true; + else board[y][x] = (uint8_t)(piece + 1u); + } + if (above) { + gameOver = true; + update_best(); + return; + } + + const uint8_t cleared = clear_lines(); + lines = (uint16_t)(lines + cleared); + level = (uint8_t)(lines / 10u + 1u); + if (level > 15u) level = 15u; + score += (uint32_t)LINE_POINTS[cleared] * level; + update_best(); + spawn_piece(); +} + +static bool move_down(bool manual) +{ + if (!collision(piece, rotation, pieceX, pieceY + 1)) { + pieceY++; + if (manual) { score++; update_best(); } + return true; + } + lock_piece(); + return false; +} + +static void rotate_piece(void) +{ + const uint8_t next = (uint8_t)((rotation + 1u) & 3u); + static const int8_t kick[5] = {0, -1, 1, -2, 2}; + for (uint8_t i = 0; i < 5u; i++) + if (!collision(piece, next, pieceX + kick[i], pieceY)) { + pieceX += kick[i]; rotation = next; return; + } + if (!collision(piece, next, pieceX, pieceY - 1)) { + pieceY--; rotation = next; + } +} + +static void hard_drop(void) +{ + uint8_t distance = 0; + while (!collision(piece, rotation, pieceX, pieceY + 1)) { + pieceY++; distance++; + } + score += (uint32_t)distance * 2u; + update_best(); + lock_piece(); +} + +/* -------------------------------------------------------------------------- */ +/* Input, screensaver handling and the main loop */ +/* -------------------------------------------------------------------------- */ + +static void key_action(uint8_t key, bool repeat) +{ + if (key == APP_KEY_EXIT) { running = false; return; } + if (gameOver) { + if (!repeat && (key == APP_KEY_MENU || key == APP_KEY_STAR || key == APP_KEY_0)) + new_game(); + return; + } + if (!repeat && key == APP_KEY_F) { paused = !paused; return; } + if (paused) + return; + + int8_t direction = 0; + if (key == APP_KEY_4) direction = -1; + else if (key == APP_KEY_6) direction = 1; + else if (key == APP_KEY_UP || key == APP_KEY_DOWN) direction = A->nav_dir(key); + if (direction) { + if (!collision(piece, rotation, pieceX + direction, pieceY)) + pieceX += direction; + return; + } + if (key == APP_KEY_8) { move_down(true); return; } + if (repeat) + return; + if (key == APP_KEY_MENU || key == APP_KEY_2) rotate_piece(); + else if (key == APP_KEY_STAR || key == APP_KEY_0) hard_drop(); +} + +static void poll_key(void) +{ + uint8_t key = A->get_key(); + if (key == APP_KEY_SAVER) { + if (!paused && !gameOver) { paused = true; saverPaused = true; } + saverActive = true; + previousKey = APP_KEY_INVALID; + repeatMs = 0; + return; + } + if (key == APP_KEY_WAKE || key == APP_KEY_PTT) { + if (saverPaused) paused = false; + saverPaused = false; + saverActive = false; + key = APP_KEY_INVALID; + } + if (key == APP_KEY_INVALID) { + previousKey = key; + repeatMs = 0; + return; + } + if (key != previousKey) { + key_action(key, false); + repeatMs = 260u; + } else if (repeatMs > TICK_MS) { + repeatMs -= TICK_MS; + } else { + key_action(key, true); + repeatMs = 80u; + } + previousKey = key; +} + +static void new_game(void) +{ + for (uint8_t y = 0; y < ROWS; y++) + for (uint8_t x = 0; x < COLS; x++) + board[y][x] = 0; + score = 0; + lines = 0; + level = 1; + gravityMs = 0; + paused = false; + gameOver = false; + saverPaused = false; + saverActive = false; + newBest = false; + bagPos = 7; + nextPiece = take_piece(); + spawn_piece(); +} + +__attribute__((section(".text.entry"), used)) +void app_main(const app_api_t *api) +{ + A = api; + config_t cfg; + A->cfg_load((uint8_t *)&cfg, sizeof(cfg)); + best = cfg.magic == CFG_MAGIC && cfg.version == 1u ? cfg.best : 0u; + bestDirty = false; + + randomState = ((uint32_t)A->bk_read(0x67u) << 16) ^ A->rx_freq() ^ best; + if (!randomState) randomState = 1; + previousKey = APP_KEY_INVALID; + repeatMs = 0; + running = true; + A->led(false); + A->backlight_on(); + new_game(); + + while (running) { + poll_key(); + if (!running) + break; + if (saverActive) { + A->backlight_update(); + A->delay_ms(TICK_MS); + continue; + } + + if (!paused && !gameOver) { + const uint16_t interval = level >= 15u ? 100u : (uint16_t)(900u - (level - 1u) * 55u); + gravityMs = (uint16_t)(gravityMs + TICK_MS); + if (gravityMs >= interval) { + gravityMs = 0; + move_down(false); + } + } + + render(true, 0, 0); + A->blit_status(); + A->blit_full(); + A->backlight_update(); + A->delay_ms(TICK_MS); + } + + update_best(); + if (bestDirty) { + cfg.magic = CFG_MAGIC; + cfg.version = 1u; + cfg.best = best; + A->cfg_save((const uint8_t *)&cfg, sizeof(cfg)); + } + A->led(false); +} From e7db0bc1dcb5a4f209ed79b3945ba5e67ffd1603 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Wed, 2 Sep 2026 20:22:00 +0200 Subject: [PATCH 60/73] Make Triple VFO and Beam overlay services optional --- App/CMakeLists.txt | 6 ++++++ App/apps/app_api.h | 4 ++-- App/apps/app_overlay.c | 35 ++++++++++++++++++++++++++++++----- App/apps/app_overlay.h | 2 ++ App/apps/beam/build.sh | 2 +- App/apps/pack_app.py | 2 ++ App/apps/trivfo/build.sh | 2 +- CMakePresets.json | 2 ++ 8 files changed, 46 insertions(+), 9 deletions(-) diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index d3b6dd30..e3e5b5d8 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -201,9 +201,15 @@ enable_feature(ENABLE_FEAT_F4HWN_OVERLAY_APPS apps/app_overlay.c apps/app_menu.c ) +enable_feature(ENABLE_FEAT_F4HWN_OVERLAY_TRIVFO) +enable_feature(ENABLE_FEAT_F4HWN_OVERLAY_BEAM) if(ENABLE_FEAT_F4HWN_OVERLAY_APPS AND NOT ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY) message(FATAL_ERROR "ENABLE_FEAT_F4HWN_OVERLAY_APPS requires ENABLE_FEAT_F4HWN_MULTIBOOT_OVERLAY (the 4 KiB overlay workspace).") endif() +if((ENABLE_FEAT_F4HWN_OVERLAY_TRIVFO OR ENABLE_FEAT_F4HWN_OVERLAY_BEAM) AND + NOT ENABLE_FEAT_F4HWN_OVERLAY_APPS) + message(FATAL_ERROR "Overlay Triple VFO/Beam services require ENABLE_FEAT_F4HWN_OVERLAY_APPS.") +endif() enable_feature(ENABLE_FEAT_F4HWN_K5VIEWER k5viewer.c ) diff --git a/App/apps/app_api.h b/App/apps/app_api.h index 96355154..6a286fe2 100644 --- a/App/apps/app_api.h +++ b/App/apps/app_api.h @@ -255,7 +255,7 @@ typedef struct app_api { * Returns 0 for any other key. Keep get_key() raw for spatial controls. */ int8_t (*nav_dir)(uint8_t key); - /* ---- triple VFO (API level 1 baseline) ---- + /* ---- triple VFO (optional resident capability APP_CAP_TRIVFO) ---- * A and B are the live Main Display VFOs. C is a resident temporary VFO * loaded from c_channel (or the first valid memory after B when invalid). * tick is called every 20 ms by the app and returns APP_TRIVFO_* state. */ @@ -267,7 +267,7 @@ typedef struct app_api { uint8_t (*trivfo_tick)(void); uint8_t (*trivfo_ptt)(bool pressed); /* physical PTT edge; resident applies SetPTT */ - /* ---- BEAM channel transfer (API level 1 baseline) ---- */ + /* ---- BEAM channel transfer (optional resident capability APP_CAP_BEAM) ---- */ void (*beam_prepare)(void); /* tune the fixed narrow-band FSK channel */ void (*beam_leave)(void); /* defensively stop FSK before app return */ void (*beam_get)(app_beam_channel_t *channel); /* export selected VFO */ diff --git a/App/apps/app_overlay.c b/App/apps/app_overlay.c index 9113a274..912eb1ee 100644 --- a/App/apps/app_overlay.c +++ b/App/apps/app_overlay.c @@ -54,11 +54,18 @@ _Static_assert(sizeof(app_header_t) == 64, "app_header_t must be 64 bytes"); _Static_assert(sizeof(app_api_t) <= UINT16_MAX, "app_api_t size field overflow"); +enum { + APP_AVAILABLE_CAPS = 0u #ifdef ENABLE_FMRADIO - #define APP_AVAILABLE_CAPS APP_CAP_FM -#else - #define APP_AVAILABLE_CAPS 0u + | APP_CAP_FM #endif +#ifdef ENABLE_FEAT_F4HWN_OVERLAY_TRIVFO + | APP_CAP_TRIVFO +#endif +#ifdef ENABLE_FEAT_F4HWN_OVERLAY_BEAM + | APP_CAP_BEAM +#endif +}; /* ---- ABI wrappers: the few resident calls that are not a direct signature match ---- */ static bool app_allow_screen_saver; @@ -137,7 +144,8 @@ static void app_play_tone(uint16_t tone, uint16_t ms) AUDIO_AudioPathOff(); } -/* ---- API level 1: resident triple-VFO engine ---------------------------- +#ifdef ENABLE_FEAT_F4HWN_OVERLAY_TRIVFO +/* ---- optional resident triple-VFO engine -------------------------------- * The overlay owns the UI and key timing, while this resident engine owns all * radio details. Keeping VFO_Info_t and BK4819 sequencing on this side makes * the app independent of feature-dependent firmware layouts. */ @@ -532,8 +540,10 @@ static uint8_t app_trivfo_ptt(bool pressed) app_trivfo_tx_ticks = 0; return 0; } +#endif -/* ---- API level 1: BEAM radio/channel bridge ------------------------------- +#ifdef ENABLE_FEAT_F4HWN_OVERLAY_BEAM +/* ---- optional BEAM radio/channel bridge ----------------------------------- * The modal app owns the packet format, CRC, UI and state machine. Resident * code only translates the stable ABI channel structure and performs the FSK * operations which depend on VFO_Info_t and the BK4819 driver. */ @@ -757,6 +767,7 @@ static void app_beam_draw(const char *status) memset(gFrameBuffer[line], 0, LCD_WIDTH); UI_PrintStringSmallBold(status, 2, LCD_WIDTH - 1u, line); } +#endif /* ---- radio wrappers ---- */ static int16_t app_rssi_dbm(void) { return BK4819_GetRSSI_dBm() + dBmCorrTable[gRxVfo->Band]; } @@ -803,8 +814,10 @@ static void app_battery_sample(void) /* The resident scheduler deliberately skips ADC battery updates while the * PA is keyed. Do the same for Triple VFO: sampling the loaded voltage as * capacity made an 80% pack appear to fall immediately to about 16%. */ +#ifdef ENABLE_FEAT_F4HWN_OVERLAY_TRIVFO if (app_trivfo_transmitting) return; +#endif BATTERY_Sample(false); } @@ -1039,6 +1052,7 @@ static const app_api_t app_api = { .fm_commit = app_fm_commit, #endif .nav_dir = app_nav_dir, +#ifdef ENABLE_FEAT_F4HWN_OVERLAY_TRIVFO .trivfo_enter = app_trivfo_enter, .trivfo_leave = app_trivfo_leave, .trivfo_get = app_trivfo_get, @@ -1046,6 +1060,8 @@ static const app_api_t app_api = { .trivfo_step = app_trivfo_step, .trivfo_tick = app_trivfo_tick, .trivfo_ptt = app_trivfo_ptt, +#endif +#ifdef ENABLE_FEAT_F4HWN_OVERLAY_BEAM .beam_prepare = app_beam_prepare, .beam_leave = app_beam_leave, .beam_get = app_beam_get, @@ -1054,6 +1070,7 @@ static const app_api_t app_api = { .beam_rx = app_beam_rx, .beam_rx_poll = app_beam_rx_poll, .beam_draw = app_beam_draw, +#endif }; uint8_t APP_LaunchOverlay(uint8_t slot) @@ -1092,7 +1109,9 @@ uint8_t APP_LaunchOverlay(uint8_t slot) #ifdef ENABLE_FMRADIO app_fm_dirty = false; #endif +#ifdef ENABLE_FEAT_F4HWN_OVERLAY_BEAM app_beam_dirty = false; +#endif app_allow_screen_saver = (h.flags & APP_FLAG_SCREEN_SAVER) != 0; app_screen_saver_wake = false; @@ -1129,7 +1148,9 @@ uint8_t APP_LaunchOverlay(uint8_t slot) app_screen_saver_wake = false; /* A defensive leave also covers an app returning through an error path. */ +#ifdef ENABLE_FEAT_F4HWN_OVERLAY_TRIVFO app_trivfo_leave(); +#endif /* Restore the resident RX/dual-watch tuning the app ran on top of. */ gEeprom.RX_VFO = saved_rx_vfo; @@ -1139,8 +1160,11 @@ uint8_t APP_LaunchOverlay(uint8_t slot) /* The overlay held app code, not a valid config sector. */ PY25Q16_InvalidateCache(); +#ifdef ENABLE_FEAT_F4HWN_OVERLAY_BEAM app_beam_commit(); +#endif +#ifdef ENABLE_FEAT_F4HWN_OVERLAY_TRIVFO if (app_trivfo_ab_dirty) { SETTINGS_SaveVfoIndices(); app_trivfo_ab_dirty = false; @@ -1152,6 +1176,7 @@ uint8_t APP_LaunchOverlay(uint8_t slot) &gEeprom.VfoInfo[i], 1); } app_trivfo_freq_dirty = 0; +#endif /* Commit any deferred config the app staged (RMW keeps the slot header). */ if (app_cfg_len) { diff --git a/App/apps/app_overlay.h b/App/apps/app_overlay.h index 8c07e703..89c4f546 100644 --- a/App/apps/app_overlay.h +++ b/App/apps/app_overlay.h @@ -81,6 +81,8 @@ /* Optional resident facilities an app may require. Requirements live in the * previously reserved header bytes, so app_header_t remains 64 bytes. */ #define APP_CAP_FM 0x00000001u +#define APP_CAP_TRIVFO 0x00000002u +#define APP_CAP_BEAM 0x00000004u typedef struct __attribute__((packed)) { uint32_t magic; /* APP_MAGIC */ diff --git a/App/apps/beam/build.sh b/App/apps/beam/build.sh index aaf1c4c9..f8850ed5 100755 --- a/App/apps/beam/build.sh +++ b/App/apps/beam/build.sh @@ -26,7 +26,7 @@ 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" --api-min "$APP_API_MIN" --vma "${APP_VMA}" \ - --shortcut beam >/dev/null + --shortcut beam --require beam >/dev/null trap - ERR BYTES=$(wc -c < "${APP}.bin") diff --git a/App/apps/pack_app.py b/App/apps/pack_app.py index 8d6a7def..077b2d21 100644 --- a/App/apps/pack_app.py +++ b/App/apps/pack_app.py @@ -44,6 +44,8 @@ SHORTCUTS = { } CAPABILITIES = { "fm": cdefine("app_overlay.h", "APP_CAP_FM"), + "trivfo": cdefine("app_overlay.h", "APP_CAP_TRIVFO"), + "beam": cdefine("app_overlay.h", "APP_CAP_BEAM"), } def field(s: str, n: int) -> bytes: diff --git a/App/apps/trivfo/build.sh b/App/apps/trivfo/build.sh index b8bafb88..233a5136 100755 --- a/App/apps/trivfo/build.sh +++ b/App/apps/trivfo/build.sh @@ -14,7 +14,7 @@ LDFLAGS="-nostdlib -nostartfiles -T app.ld -Wl,--defsym,APP_VMA=${APP_VMA} -Wl,- rm -f ./*.app ./*.elf ./*.bin "$CC" $CFLAGS $LDFLAGS "${APP}_app.c" -lgcc -o "${APP}.elf" "$OBJCOPY" -O binary "${APP}.elf" "${APP}.bin" -python3 ../pack_app.py "${APP}.bin" "${OUT}.app" --name "$APP_NAME" --ver "$APP_VER" --api-min "$APP_API_MIN" --vma "${APP_VMA}" --screensaver >/dev/null +python3 ../pack_app.py "${APP}.bin" "${OUT}.app" --name "$APP_NAME" --ver "$APP_VER" --api-min "$APP_API_MIN" --vma "${APP_VMA}" --require trivfo --screensaver >/dev/null BYTES=$(wc -c < "${APP}.bin") test "$BYTES" -le 4096 printf ' ✅ %-13s %4d B (%d%% of 4 KiB) -> %s.app\n' \ diff --git a/CMakePresets.json b/CMakePresets.json index f3d571a4..204c1abe 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -183,6 +183,8 @@ "ENABLE_FEAT_F4HWN_BEAM": false, "ENABLE_FEAT_F4HWN_GAME": false, "ENABLE_FEAT_F4HWN_OVERLAY_APPS": true, + "ENABLE_FEAT_F4HWN_OVERLAY_TRIVFO": false, + "ENABLE_FEAT_F4HWN_OVERLAY_BEAM": true, "EDITION_STRING": "Labs", "TARGET": "f4hwn.labs" } From 74b2397c966a3b5e77ba1937dca4ddbfa91415ae Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Wed, 2 Sep 2026 21:27:14 +0200 Subject: [PATCH 61/73] Optimize Tetris --- App/apps/tetris/tetris_app.c | 61 ++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 34 deletions(-) diff --git a/App/apps/tetris/tetris_app.c b/App/apps/tetris/tetris_app.c index 2ef069a2..f195700f 100644 --- a/App/apps/tetris/tetris_app.c +++ b/App/apps/tetris/tetris_app.c @@ -79,8 +79,9 @@ static const uint16_t MASK[7][4] = { }; static const uint16_t LINE_POINTS[5] = {0, 100, 300, 500, 800}; +static const uint32_t DECIMAL_PLACE[6] = {100000u, 10000u, 1000u, 100u, 10u, 1u}; -static uint8_t board[ROWS][COLS]; +static uint16_t board[ROWS]; static uint8_t bag[7], bagPos; static uint8_t piece, nextPiece, rotation; static int8_t pieceX, pieceY; @@ -110,7 +111,10 @@ static void refill_bag(void) for (uint8_t i = 0; i < 7u; i++) bag[i] = i; for (uint8_t i = 6u; i > 0u; i--) { - const uint8_t j = (uint8_t)(random_next() % (uint32_t)(i + 1u)); + uint8_t j; + do { + j = (uint8_t)(random_next() >> 29); + } while (j > i); const uint8_t t = bag[i]; bag[i] = bag[j]; bag[j] = t; } bagPos = 0; @@ -194,7 +198,7 @@ static bool collision(uint8_t type, uint8_t rot, int8_t px, int8_t py) const int y = py + (i >> 2); if (x < 0 || x >= COLS || y >= ROWS) return true; - if (y >= 0 && board[y][x]) + if (y >= 0 && (board[y] & (uint16_t)(1u << x))) return true; } return false; @@ -210,30 +214,24 @@ static void draw_piece(uint8_t type, uint8_t rot, int8_t px, int8_t py, uint8_t static void number(uint32_t value, uint8_t width) { - text[width] = 0; - while (width) { - text[--width] = (char)('0' + value % 10u); - value /= 10u; + const uint8_t first = (uint8_t)(6u - width); + for (uint8_t i = 0; i < width; i++) { + const uint32_t place = DECIMAL_PLACE[first + i]; + uint8_t digit = 0; + while (value >= place) { + value -= place; + digit++; + } + text[i] = (char)('0' + digit); } + text[width] = 0; } static void preview(void) { const uint16_t mask = MASK[nextPiece][0]; - uint8_t minX = 3u, maxX = 0u, minY = 3u, maxY = 0u; - for (uint8_t i = 0; i < 16u; i++) { - if (!(mask & (uint16_t)(1u << i))) - continue; - const uint8_t x = i & 3u, y = i >> 2; - if (x < minX) minX = x; - if (x > maxX) maxX = x; - if (y < minY) minY = y; - if (y > maxY) maxY = y; - } - const int width = (maxX - minX + 1u) * CELL - 1u; - const int height = (maxY - minY + 1u) * CELL - 1u; - const int ox = PREVIEW_CX - width / 2 - minX * CELL; - const int oy = PREVIEW_CY - height / 2 - minY * CELL; + const int ox = nextPiece < 2u ? PREVIEW_CX - 7 : PREVIEW_CX - 5; + const int oy = nextPiece == 0u ? PREVIEW_CY - 5 : PREVIEW_CY - 3; for (uint8_t i = 0; i < 16u; i++) { if (!(mask & (uint16_t)(1u << i))) continue; @@ -280,7 +278,7 @@ static void render(bool showPiece, uint32_t clearRows, uint8_t effect) for (uint8_t y = 0; y < ROWS; y++) for (uint8_t x = 0; x < COLS; x++) - if (board[y][x] && (!(clearRows & (1u << y)) || + if ((board[y] & (uint16_t)(1u << x)) && (!(clearRows & (1u << y)) || (x >= effect && x < COLS - effect))) cell(x, y, 1u); @@ -314,10 +312,7 @@ static uint8_t clear_lines(void) uint32_t full = 0; uint8_t count = 0; for (uint8_t y = 0; y < ROWS; y++) { - bool complete = true; - for (uint8_t x = 0; x < COLS; x++) - if (!board[y][x]) { complete = false; break; } - if (complete) { full |= 1u << y; count++; } + if (board[y] == 0xFFFFu) { full |= 1u << y; count++; } } if (!count) return 0; @@ -334,12 +329,11 @@ static uint8_t clear_lines(void) if (full & (1u << src)) continue; if (dst != src) - for (uint8_t x = 0; x < COLS; x++) - board[dst][x] = board[src][x]; + board[dst] = board[src]; dst--; } while (dst >= 0) { - for (uint8_t x = 0; x < COLS; x++) board[dst][x] = 0; + board[dst] = 0; dst--; } return count; @@ -371,7 +365,7 @@ static void lock_piece(void) const int x = pieceX + (i & 3u); const int y = pieceY + (i >> 2); if (y < HIDDEN_ROWS) above = true; - else board[y][x] = (uint8_t)(piece + 1u); + else board[y] |= (uint16_t)(1u << x); } if (above) { gameOver = true; @@ -381,8 +375,8 @@ static void lock_piece(void) const uint8_t cleared = clear_lines(); lines = (uint16_t)(lines + cleared); - level = (uint8_t)(lines / 10u + 1u); - if (level > 15u) level = 15u; + while (level < 15u && lines >= (uint16_t)level * 10u) + level++; score += (uint32_t)LINE_POINTS[cleared] * level; update_best(); spawn_piece(); @@ -491,8 +485,7 @@ static void poll_key(void) static void new_game(void) { for (uint8_t y = 0; y < ROWS; y++) - for (uint8_t x = 0; x < COLS; x++) - board[y][x] = 0; + board[y] = 0; score = 0; lines = 0; level = 1; From d95e4efa5729f1405d9b4f734ba3392903855035 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Thu, 3 Sep 2026 00:18:52 +0200 Subject: [PATCH 62/73] Add ENABLE_FMRADIO_EMBEDDED to split resident FM from shared driver --- App/CMakeLists.txt | 9 ++++----- App/app/action.c | 18 +++++++++-------- App/app/app.c | 50 +++++++++++++++++++++++++--------------------- App/app/dtmf.c | 4 ++-- App/app/fm.c | 31 +++------------------------- App/app/fm.h | 21 +++++++++++-------- App/app/generic.c | 14 ++++++------- App/app/main.c | 8 ++++---- App/app/uart.c | 10 +++++----- App/audio.c | 16 +++++++-------- App/functions.c | 10 +++++----- App/misc.c | 6 +++--- App/misc.h | 8 ++++---- App/radio.c | 4 ++-- App/scheduler.c | 4 ++-- App/ui/status.c | 2 +- App/ui/ui.c | 8 ++++---- 17 files changed, 104 insertions(+), 119 deletions(-) diff --git a/App/CMakeLists.txt b/App/CMakeLists.txt index e3e5b5d8..44f71d8f 100644 --- a/App/CMakeLists.txt +++ b/App/CMakeLists.txt @@ -117,16 +117,15 @@ 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_FMRADIO keeps the BK1080 driver and the FM data/helpers shared with the +# overlay. ENABLE_FMRADIO_EMBEDDED additionally selects the legacy resident +# state machine and its integration with the main scheduler. enable_feature(ENABLE_FMRADIO driver/bk1080.c app/fm.c ) if(ENABLE_FMRADIO AND NOT ENABLE_FEAT_F4HWN_OVERLAY_APPS) + target_compile_definitions(App INTERFACE ENABLE_FMRADIO_EMBEDDED) target_sources(App INTERFACE ui/fmradio.c) endif() enable_feature(ENABLE_AIRCOPY diff --git a/App/app/action.c b/App/app/action.c index 3e2f25a4..48c142e9 100644 --- a/App/app/action.c +++ b/App/app/action.c @@ -25,7 +25,7 @@ #ifdef ENABLE_FLASHLIGHT #include "app/flashlight.h" #endif -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED #include "app/fm.h" #endif #ifdef ENABLE_FEAT_F4HWN_OVERLAY_APPS @@ -33,7 +33,7 @@ #endif #include "app/scanner.h" #include "audio.h" -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED #include "driver/bk1080.h" #endif #include "driver/bk4819.h" @@ -62,7 +62,7 @@ static void ACTION_Beam(void); #endif -#if defined(ENABLE_FMRADIO) +#if defined(ENABLE_FMRADIO_EMBEDDED) static void ACTION_Scan_FM(bool bRestart); #endif @@ -215,7 +215,7 @@ void ACTION_Monitor(void) RADIO_SetupRegisters(true); -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gFmRadioMode) { FM_Start(); gRequestDisplayScreen = DISPLAY_FM; @@ -229,7 +229,7 @@ void ACTION_Scan(bool bRestart) { (void)bRestart; -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gFmRadioMode) { ACTION_Scan_FM(bRestart); return; @@ -322,7 +322,7 @@ void ACTION_SwitchDemodul(void) } -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED inline static bool ACTION_IsBlockedInFM(uint8_t action) { switch (action) { @@ -370,7 +370,7 @@ static void ACTION_Execute(uint8_t action) return; } -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gFmRadioMode && ACTION_IsBlockedInFM(action)) { gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL; return; @@ -587,6 +587,7 @@ void ACTION_FM(void) #endif /* !ENABLE_FEAT_F4HWN_OVERLAY_APPS */ } +#ifdef ENABLE_FMRADIO_EMBEDDED static void ACTION_Scan_FM(bool bRestart) { if (FUNCTION_IsRx()) @@ -626,6 +627,7 @@ static void ACTION_Scan_FM(bool bRestart) #endif } +#endif #endif @@ -785,7 +787,7 @@ void ACTION_Mute(void) gMute = !gMute; // Update the registers - #ifdef ENABLE_FMRADIO + #ifdef ENABLE_FMRADIO_EMBEDDED BK1080_WriteRegister(BK1080_REG_05_SYSTEM_CONFIGURATION2, gMute ? 0x0A10 : 0x0A1F); #endif gEeprom.VOLUME_GAIN = gMute ? 0 : gEeprom.VOLUME_GAIN_BACKUP; diff --git a/App/app/app.c b/App/app/app.c index a6946c0e..2853169e 100644 --- a/App/app/app.c +++ b/App/app/app.c @@ -32,7 +32,7 @@ #ifdef ENABLE_FLASHLIGHT #include "app/flashlight.h" #endif -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED #include "app/fm.h" #endif #include "app/generic.h" @@ -53,7 +53,7 @@ // #include "bsp/dp32g030/pwmplus.h" #endif #include "driver/backlight.h" -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED #include "driver/bk1080.h" #endif #include "driver/bk4819.h" @@ -111,8 +111,12 @@ void (*const ProcessKeysFunctions[])(KEY_Code_t Key, bool bKeyPressed, bool bKey [DISPLAY_MENU] = &MENU_ProcessKeys, [DISPLAY_SCANNER] = &SCANNER_ProcessKeys, -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED [DISPLAY_FM] = &FM_ProcessKeys, +#elif defined(ENABLE_FMRADIO) + /* The modal FM overlay handles its own keys. Keep the enum slot populated + for configurations where DISPLAY_FM is the final display entry. */ + [DISPLAY_FM] = &MAIN_ProcessKeys, #endif #ifdef ENABLE_AIRCOPY @@ -246,7 +250,7 @@ static bool ScreenSaverCanDisplay(bool modal) gCurrentFunction == FUNCTION_TRANSMIT || FUNCTION_IsRx() || gPttIsPressed -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED || (gFM_ScanState != FM_SCAN_OFF && !gFM_FoundFrequency) #endif #ifdef ENABLE_FEAT_F4HWN_BEAM @@ -260,7 +264,7 @@ static bool ScreenSaverCanDisplay(bool modal) if (modal || gScreenToDisplay == DISPLAY_MAIN) return true; -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gScreenToDisplay == DISPLAY_FM) return true; #endif @@ -374,7 +378,7 @@ static void CheckForIncoming(void) if (!g_SquelchLost) return; // squelch is closed -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED // FM scan in progress: ignore main-channel RX so scanning is not interrupted. // Normal FM listening (FM_SCAN_OFF) still yields to channel signals as before. if (gFmRadioMode && gFM_ScanState != FM_SCAN_OFF) @@ -513,7 +517,7 @@ static void HandleIncoming(void) } #endif -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED // Defensive: do not leave FM scan for a main-channel signal. if (gFmRadioMode && gFM_ScanState != FM_SCAN_OFF) return; @@ -784,7 +788,7 @@ void APP_StartListening(FUNCTION_Type_t function) return; #endif -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gFmRadioMode) BK1080_Init0(); #endif @@ -853,7 +857,7 @@ void APP_StartListening(FUNCTION_Type_t function) RXTX_LOG_BeginRx(gRxVfo, function); #endif -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (function == FUNCTION_MONITOR || gFmRadioMode) #else if (function == FUNCTION_MONITOR) @@ -1159,7 +1163,7 @@ static void HandleVox(void) gVoxPauseCountdown = 0; } -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gFmRadioMode) return; #endif @@ -1321,7 +1325,7 @@ void APP_Update(void) if (gCurrentFunction != FUNCTION_TRANSMIT) HandleFunction(); -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED // if (gFmRadioCountdown_500ms > 0) if (gFmRadioMode && gFmRadioCountdown_500ms > 0) // 1of11 return; @@ -1366,7 +1370,7 @@ void APP_Update(void) #ifdef ENABLE_VOICE && gVoiceWriteIndex == 0 #endif -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED && !gFmRadioMode #endif #ifdef ENABLE_DTMF_CALLING @@ -1385,7 +1389,7 @@ void APP_Update(void) gScheduleDualWatch = false; } -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gScheduleFM && gFM_ScanState != FM_SCAN_OFF && !FUNCTION_IsRx()) { // switch to FM radio mode FM_Play(); @@ -1405,7 +1409,7 @@ void APP_Update(void) || gScanStateDir != SCAN_OFF || gCssBackgroundScan || gScreenToDisplay != DISPLAY_MAIN -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED || gFmRadioMode #endif #ifdef ENABLE_DTMF_CALLING @@ -1760,7 +1764,7 @@ void APP_TimeSlice10ms(void) // Skipping authentic device checks -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gFmRadioMode && gFmRadioCountdown_500ms > 0) // 1of11 return; #endif @@ -1792,7 +1796,7 @@ void APP_TimeSlice10ms(void) } } -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gFmRadioMode && gFM_RestoreCountdown_10ms > 0) { if (--gFM_RestoreCountdown_10ms == 0) { FM_Start(); // switch back to FM radio mode @@ -1880,7 +1884,7 @@ void APP_TimeSlice500ms(void) { if (IS_MR_CHANNEL(gTxVfo->CHANNEL_SAVE) && (gInputBoxIndex > 0 && gInputBoxIndex < 4) -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED && (!gFmRadioMode) #endif ) @@ -1927,7 +1931,7 @@ void APP_TimeSlice500ms(void) // Skipped authentic device check -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gFmRadioCountdown_500ms > 0) { gFmRadioCountdown_500ms--; @@ -2059,7 +2063,7 @@ void APP_TimeSlice500ms(void) } if (!gCssBackgroundScan && gScanStateDir == SCAN_OFF && !SCANNER_IsScanning() -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED && (gFM_ScanState == FM_SCAN_OFF || gAskToSave) #endif #ifdef ENABLE_AIRCOPY @@ -2114,7 +2118,7 @@ void APP_TimeSlice500ms(void) GUI_DisplayType_t disp = DISPLAY_INVALID; -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gFmRadioMode && ! FUNCTION_IsRx()) { disp = DISPLAY_FM; } @@ -2136,7 +2140,7 @@ void APP_TimeSlice500ms(void) if (!gPttIsPressed && gVFOStateResumeCountdown_500ms > 0 && --gVFOStateResumeCountdown_500ms == 0) { RADIO_SetVfoState(VFO_STATE_NORMAL); -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gFmRadioMode && !FUNCTION_IsRx()) { // switch back to FM radio mode FM_Start(); @@ -2289,7 +2293,7 @@ static void ProcessKey(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) flagSaveSettings = false; } -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gFlagSaveFM) { SETTINGS_SaveFM(); gFlagSaveFM = false; @@ -2583,7 +2587,7 @@ Skip: gUpdateStatus = true; } -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gRequestSaveFM) { gRequestSaveFM = false; if (!bKeyHeld) diff --git a/App/app/dtmf.c b/App/app/dtmf.c index 64dd0477..138103af 100644 --- a/App/app/dtmf.c +++ b/App/app/dtmf.c @@ -18,7 +18,7 @@ #include // NULL #include "app/chFrScanner.h" -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED #include "app/fm.h" #endif #include "app/scanner.h" @@ -279,7 +279,7 @@ void DTMF_HandleRequest(void) gDTMF_ReplyState = DTMF_REPLY_AB; - #ifdef ENABLE_FMRADIO + #ifdef ENABLE_FMRADIO_EMBEDDED if (gFmRadioMode) { FM_TurnOff(); diff --git a/App/app/fm.c b/App/app/fm.c index 0d31fbf1..442f05ea 100644 --- a/App/app/fm.c +++ b/App/app/fm.c @@ -33,6 +33,7 @@ #include "ui/ui.h" uint16_t gFM_Channels[FM_CHANNELS_MAX]; +#ifdef ENABLE_FMRADIO_EMBEDDED bool gFmRadioMode; uint8_t gFmRadioCountdown_500ms; volatile uint16_t gFmPlayCountdown_10ms; @@ -52,7 +53,6 @@ const uint8_t BUTTON_EVENT_HELD = BUTTON_STATE_PRESSED | BUTTON_STATE_HELD; const uint8_t BUTTON_EVENT_SHORT = 0; const uint8_t BUTTON_EVENT_LONG = BUTTON_STATE_HELD; -#ifndef ENABLE_FEAT_F4HWN_OVERLAY_APPS static void Key_FUNC(KEY_Code_t Key, uint8_t state); #endif @@ -95,7 +95,7 @@ int FM_ConfigureChannelState(void) return 0; } -#ifndef ENABLE_FEAT_F4HWN_OVERLAY_APPS +#ifdef ENABLE_FMRADIO_EMBEDDED void FM_SetFrequency(void) { BK1080_SetFrequency(gEeprom.FM_FrequencyPlaying, gEeprom.FM_Band/*, gEeprom.FM_Space*/); @@ -243,7 +243,7 @@ int FM_CheckFrequencyLock(uint16_t Frequency, uint16_t LowerLimit) return 0; } -#ifndef ENABLE_FEAT_F4HWN_OVERLAY_APPS +#ifdef ENABLE_FMRADIO_EMBEDDED static void Key_DIGITS(KEY_Code_t Key, uint8_t state) { enum { STATE_FREQ_MODE, STATE_MR_MODE, STATE_SAVE }; @@ -666,31 +666,6 @@ void FM_Start(void) SETTINGS_WriteCurrentState(); #endif } -#else -/* - * Broadcast FM is sovereign in Overlay builds: its app owns tuning, scanning, - * audio and persistence through app_api. Keep only the shared channel/state - * loader and FM_CheckFrequencyLock above; these tiny compatibility entry points - * satisfy generic firmware paths which cannot become active because Overlay - * builds never set gFmRadioMode or gFM_ScanState. - */ -void FM_TurnOff(void) {} -void FM_EraseChannels(void) {} -void FM_Tune(uint16_t Frequency, int8_t Step, bool bFlag) -{ - (void)Frequency; - (void)Step; - (void)bFlag; -} -void FM_PlayAndUpdate(void) {} -void FM_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) -{ - (void)Key; - (void)bKeyPressed; - (void)bKeyHeld; -} -void FM_Play(void) {} -void FM_Start(void) {} #endif #endif diff --git a/App/app/fm.h b/App/app/fm.h index 6a315a30..1578a2e7 100644 --- a/App/app/fm.h +++ b/App/app/fm.h @@ -19,9 +19,12 @@ #ifdef ENABLE_FMRADIO -#include "driver/keyboard.h" #include "misc.h" +#ifdef ENABLE_FMRADIO_EMBEDDED +#include "driver/keyboard.h" +#endif + #define FM_CHANNEL_UP 0x01 #define FM_CHANNEL_DOWN 0xFF @@ -30,32 +33,34 @@ enum { }; extern uint16_t gFM_Channels[FM_CHANNELS_MAX]; + +bool FM_CheckValidChannel(uint8_t Channel); +// returns first valid channel starting at Channel +uint8_t FM_FindNextChannel(uint8_t Channel, uint8_t Direction); +int FM_ConfigureChannelState(void); +int FM_CheckFrequencyLock(uint16_t Frequency, uint16_t LowerLimit); + +#ifdef ENABLE_FMRADIO_EMBEDDED extern bool gFmRadioMode; extern uint8_t gFmRadioCountdown_500ms; extern volatile uint16_t gFmPlayCountdown_10ms; extern volatile int8_t gFM_ScanState; extern bool gFM_AutoScan; extern uint8_t gFM_ChannelPosition; -// Doubts about whether this should be signed or not -extern uint16_t gFM_FrequencyDeviation; extern bool gFM_FoundFrequency; extern uint16_t gFM_RestoreCountdown_10ms; -bool FM_CheckValidChannel(uint8_t Channel); -// returns first valid channel starting at Channel -uint8_t FM_FindNextChannel(uint8_t Channel, uint8_t Direction); -int FM_ConfigureChannelState(void); void FM_TurnOff(void); void FM_EraseChannels(void); void FM_Tune(uint16_t Frequency, int8_t Step, bool bFlag); void FM_PlayAndUpdate(void); -int FM_CheckFrequencyLock(uint16_t Frequency, uint16_t LowerLimit); void FM_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld); void FM_Play(void); void FM_Start(void); +#endif #endif diff --git a/App/app/generic.c b/App/app/generic.c index f6c907a2..f9e3ff0f 100644 --- a/App/app/generic.c +++ b/App/app/generic.c @@ -20,7 +20,7 @@ #include "app/chFrScanner.h" #include "app/common.h" -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED #include "app/fm.h" #endif @@ -56,7 +56,7 @@ void GENERIC_Key_F(bool bKeyPressed, bool bKeyHeld) COMMON_KeypadLockToggle(); } else { // released -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if ((gFmRadioMode || gScreenToDisplay != DISPLAY_MAIN) && gScreenToDisplay != DISPLAY_FM) return; #else @@ -77,7 +77,7 @@ void GENERIC_Key_F(bool bKeyPressed, bool bKeyHeld) } } else { // short pressed -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gScreenToDisplay != DISPLAY_FM) #endif { @@ -85,7 +85,7 @@ void GENERIC_Key_F(bool bKeyPressed, bool bKeyHeld) return; } -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gFM_ScanState == FM_SCAN_OFF) { // not scanning gBeepToPlay = BEEP_1KHZ_60MS_OPTIONAL; return; @@ -132,7 +132,7 @@ void GENERIC_Key_PTT(bool bKeyPressed) -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gFM_ScanState != FM_SCAN_OFF) { // FM radio is scanning .. stop FM_PlayAndUpdate(); #ifdef ENABLE_VOICE @@ -143,7 +143,7 @@ void GENERIC_Key_PTT(bool bKeyPressed) } #endif -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gScreenToDisplay == DISPLAY_FM) goto start_tx; // listening to the FM radio .. start TX'ing #endif @@ -200,7 +200,7 @@ cancel_tx: done: gPttDebounceCounter = 0; if (gScreenToDisplay != DISPLAY_MENU -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED && gRequestDisplayScreen != DISPLAY_FM #endif ) { diff --git a/App/app/main.c b/App/app/main.c index 6b04aba6..29fc3c5d 100644 --- a/App/app/main.c +++ b/App/app/main.c @@ -20,7 +20,7 @@ #include "app/app.h" #include "app/chFrScanner.h" #include "app/common.h" -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED #include "app/fm.h" #endif #include "app/generic.h" @@ -728,7 +728,7 @@ static void MAIN_Key_EXIT(bool bKeyPressed, bool bKeyHeld) } #endif -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (!gFmRadioMode) #endif { @@ -763,7 +763,7 @@ static void MAIN_Key_EXIT(bool bKeyPressed, bool bKeyHeld) return; } -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED ACTION_FM(); #endif return; @@ -1064,7 +1064,7 @@ static void MAIN_Key_UP_DOWN(bool bKeyPressed, bool bKeyHeld, int8_t Direction) void MAIN_ProcessKeys(KEY_Code_t Key, bool bKeyPressed, bool bKeyHeld) { -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gFmRadioMode && Key != KEY_PTT && Key != KEY_EXIT) { if (!bKeyHeld && bKeyPressed) gBeepToPlay = BEEP_500HZ_60MS_DOUBLE_BEEP_OPTIONAL; diff --git a/App/app/uart.c b/App/app/uart.c index 779feafd..05913893 100644 --- a/App/app/uart.c +++ b/App/app/uart.c @@ -20,7 +20,7 @@ #if !defined(ENABLE_OVERLAY) #include "py32f0xx.h" #endif -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED #include "app/fm.h" #endif #include "app/uart.h" @@ -360,7 +360,7 @@ static void CMD_0514(uint32_t Port, const uint8_t *pBuffer) } #endif -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED gFmRadioCountdown_500ms = fm_radio_countdown_500ms; #endif @@ -404,7 +404,7 @@ static void CMD_051B(uint32_t Port, const uint8_t *pBuffer) gSerialConfigCountDown_500ms = 12; // 6 sec - #ifdef ENABLE_FMRADIO + #ifdef ENABLE_FMRADIO_EMBEDDED gFmRadioCountdown_500ms = fm_radio_countdown_500ms; #endif @@ -467,7 +467,7 @@ static void CMD_051D(uint32_t Port, const uint8_t *pBuffer) bReloadEeprom = false; - #ifdef ENABLE_FMRADIO + #ifdef ENABLE_FMRADIO_EMBEDDED gFmRadioCountdown_500ms = fm_radio_countdown_500ms; #endif @@ -537,7 +537,7 @@ static void CMD_052D(uint32_t Port, const uint8_t *pBuffer) REPLY_052D_t Reply; bool bIsLocked; - #ifdef ENABLE_FMRADIO + #ifdef ENABLE_FMRADIO_EMBEDDED gFmRadioCountdown_500ms = fm_radio_countdown_500ms; #endif Reply.Header.ID = 0x052E; diff --git a/App/audio.c b/App/audio.c index 42308e96..12407f7e 100644 --- a/App/audio.c +++ b/App/audio.c @@ -15,11 +15,11 @@ * limitations under the License. */ -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED #include "app/fm.h" #endif #include "audio.h" -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED #include "driver/bk1080.h" #endif #include "driver/bk4819.h" @@ -70,7 +70,7 @@ void AUDIO_PlayBeep(BEEP_Type_t Beep) if (Beep >= ARRAY_SIZE(BEEP_Classic_array)) return; -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gFmRadioMode) BK1080_Mute(true); #endif @@ -112,7 +112,7 @@ void AUDIO_PlayBeep(BEEP_Type_t Beep) SYSTEM_DelayMs(5); BK4819_WriteRegister(BK4819_REG_71, ToneConfig); -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED const bool isFmRadio = gFmRadioMode; if (isFmRadio) @@ -122,7 +122,7 @@ void AUDIO_PlayBeep(BEEP_Type_t Beep) if (gEnableSpeaker) AUDIO_AudioPathOn(); -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (isFmRadio) BK1080_Mute(false); #endif @@ -275,7 +275,7 @@ void AUDIO_PlaySingleVoice(bool bFlag) if (FUNCTION_IsRx()) // 1of11 BK4819_SetAF(BK4819_AF_MUTE); - #ifdef ENABLE_FMRADIO + #ifdef ENABLE_FMRADIO_EMBEDDED if (gFmRadioMode) BK1080_Mute(true); #endif @@ -299,7 +299,7 @@ void AUDIO_PlaySingleVoice(bool bFlag) if (FUNCTION_IsRx()) // 1of11 RADIO_SetModulation(gRxVfo->Modulation); - #ifdef ENABLE_FMRADIO + #ifdef ENABLE_FMRADIO_EMBEDDED if (gFmRadioMode) BK1080_Mute(false); #endif @@ -440,7 +440,7 @@ void AUDIO_PlayQueuedVoice(void) RADIO_SetModulation(gRxVfo->Modulation); // 1of11 } - #ifdef ENABLE_FMRADIO + #ifdef ENABLE_FMRADIO_EMBEDDED if (gFmRadioMode) BK1080_Mute(false); #endif diff --git a/App/functions.c b/App/functions.c index c416ff15..68a2bbc0 100644 --- a/App/functions.c +++ b/App/functions.c @@ -17,13 +17,13 @@ #include #include "app/dtmf.h" -#if defined(ENABLE_FMRADIO) +#if defined(ENABLE_FMRADIO_EMBEDDED) #include "app/fm.h" #endif #include "audio.h" #include "dcs.h" #include "driver/backlight.h" -#if defined(ENABLE_FMRADIO) +#if defined(ENABLE_FMRADIO_EMBEDDED) #include "driver/bk1080.h" #endif #include "driver/bk4819.h" @@ -103,7 +103,7 @@ void FUNCTION_Foreground(const FUNCTION_Type_t PreviousFunction) return; } -#if defined(ENABLE_FMRADIO) +#if defined(ENABLE_FMRADIO_EMBEDDED) if (gFmRadioMode) gFM_RestoreCountdown_10ms = fm_restore_countdown_10ms; #endif @@ -156,7 +156,7 @@ void FUNCTION_Transmit() gDTMF_RX_live_timeout = 0; DTMF_clear_input_box_memory(); -#if defined(ENABLE_FMRADIO) +#if defined(ENABLE_FMRADIO_EMBEDDED) if (gFmRadioMode) BK1080_Init0(); #endif @@ -284,7 +284,7 @@ void FUNCTION_Select(FUNCTION_Type_t Function) gBatterySaveCountdown_10ms = battery_save_count_10ms; gSchedulePowerSave = false; -#if defined(ENABLE_FMRADIO) +#if defined(ENABLE_FMRADIO_EMBEDDED) if(Function != FUNCTION_INCOMING) gFM_RestoreCountdown_10ms = 0; #endif diff --git a/App/misc.c b/App/misc.c index 8c5292ad..e1b8987e 100644 --- a/App/misc.c +++ b/App/misc.c @@ -251,7 +251,7 @@ bool gFlagResetVfos; bool gRequestSaveVFO; uint16_t gRequestSaveChannel; bool gRequestSaveSettings; -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED bool gRequestSaveFM; #endif bool gFlagPrepareTX; @@ -259,7 +259,7 @@ bool gFlagPrepareTX; bool gFlagAcceptSetting; bool gFlagRefreshSetting; -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED bool gFlagSaveFM; #endif bool g_CDCSS_Lost; @@ -312,7 +312,7 @@ volatile bool gNextTimeslice40ms; volatile bool gScheduleNOAA = true; #endif volatile bool gFlagTailNoteEliminationComplete; -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED volatile bool gScheduleFM; #endif diff --git a/App/misc.h b/App/misc.h index 8457a621..dd5d6e3a 100644 --- a/App/misc.h +++ b/App/misc.h @@ -389,7 +389,7 @@ extern bool gFlagResetVfos; extern bool gRequestSaveVFO; extern uint16_t gRequestSaveChannel; extern bool gRequestSaveSettings; -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED extern bool gRequestSaveFM; #endif extern uint8_t gKeypadLocked; @@ -398,7 +398,7 @@ extern bool gFlagPrepareTX; extern bool gFlagAcceptSetting; // accept menu setting extern bool gFlagRefreshSetting; // refresh menu display -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED extern bool gFlagSaveFM; #endif extern bool g_CDCSS_Lost; @@ -437,7 +437,7 @@ extern uint8_t gFSKWriteIndex; extern volatile bool gNextTimeslice; extern bool gUpdateDisplay; extern bool gF_LOCK; -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED extern uint8_t gFM_ChannelPosition; #endif extern uint8_t gShowChPrefix; @@ -453,7 +453,7 @@ extern volatile bool gNextTimeslice40ms; #endif extern volatile bool gFlagTailNoteEliminationComplete; extern volatile uint8_t gVFOStateResumeCountdown_500ms; -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED extern volatile bool gScheduleFM; #endif extern uint8_t gIsLocked; diff --git a/App/radio.c b/App/radio.c index 69cc6bf7..fb84c2d9 100644 --- a/App/radio.c +++ b/App/radio.c @@ -21,7 +21,7 @@ #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG #include "app/rxtx_log.h" #endif -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED #include "app/fm.h" #endif #include "audio.h" @@ -887,7 +887,7 @@ void RADIO_SetupRegisters(bool switchToForeground) #ifdef ENABLE_NOAA && !IS_NOAA_CHANNEL(gCurrentVfo->CHANNEL_SAVE) #endif -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED && !gFmRadioMode #endif ){ diff --git a/App/scheduler.c b/App/scheduler.c index 0d2031a1..3401e5ad 100644 --- a/App/scheduler.c +++ b/App/scheduler.c @@ -16,7 +16,7 @@ #include "scheduler.h" #include "app/chFrScanner.h" -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED #include "app/fm.h" #endif #include "app/scanner.h" @@ -136,7 +136,7 @@ void SysTick_Handler(void) DECREMENT_AND_TRIGGER(gCountdownToPlayNextVoice_10ms, gFlagPlayQueuedVoice); #endif -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED if (gFM_ScanState != FM_SCAN_OFF && gCurrentFunction != FUNCTION_MONITOR) if (gCurrentFunction != FUNCTION_TRANSMIT && gCurrentFunction != FUNCTION_RECEIVE) DECREMENT_AND_TRIGGER(gFmPlayCountdown_10ms, gScheduleFM); diff --git a/App/ui/status.c b/App/ui/status.c index fe24697b..789deee3 100644 --- a/App/ui/status.c +++ b/App/ui/status.c @@ -21,7 +21,7 @@ #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG #include "app/rxtx_log.h" #endif -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED #include "app/fm.h" #endif #include "app/scanner.h" diff --git a/App/ui/ui.c b/App/ui/ui.c index 8a5aca29..b509d2ec 100644 --- a/App/ui/ui.c +++ b/App/ui/ui.c @@ -19,7 +19,7 @@ #include "app/chFrScanner.h" #include "app/dtmf.h" -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED #include "app/fm.h" #endif #include "driver/keyboard.h" @@ -27,7 +27,7 @@ #ifdef ENABLE_AIRCOPY #include "ui/aircopy.h" #endif -#ifdef ENABLE_FMRADIO +#ifdef ENABLE_FMRADIO_EMBEDDED #include "ui/fmradio.h" #endif #ifdef ENABLE_FEAT_F4HWN_RXTX_LOG @@ -53,7 +53,7 @@ void (*const UI_DisplayFunctions[])(void) = { [DISPLAY_MENU] = &UI_DisplayMenu, [DISPLAY_SCANNER] = &UI_DisplayScanner, -#if defined(ENABLE_FMRADIO) && !defined(ENABLE_FEAT_F4HWN_OVERLAY_APPS) +#ifdef ENABLE_FMRADIO_EMBEDDED [DISPLAY_FM] = &UI_DisplayFM, #elif defined(ENABLE_FMRADIO) /* The FM overlay app replaces the resident FM screen (ui/fmradio.c is not @@ -94,7 +94,7 @@ void GUI_SelectNextDisplay(GUI_DisplayType_t Display) gIsInSubMenu = false; gCssBackgroundScan = false; gScanStateDir = SCAN_OFF; - #ifdef ENABLE_FMRADIO + #ifdef ENABLE_FMRADIO_EMBEDDED gFM_ScanState = FM_SCAN_OFF; #endif gAskForConfirmation = 0; From cc6527afc11673d7eb3970400e2f7be954cf9bba Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Thu, 3 Sep 2026 02:53:38 +0200 Subject: [PATCH 63/73] Fix Beam overlay VFO restore and deferred channel save --- App/apps/app_overlay.c | 53 +++++++++++++++++++++++++--------------- App/apps/beam/beam_app.c | 5 +++- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/App/apps/app_overlay.c b/App/apps/app_overlay.c index 912eb1ee..5134ebf6 100644 --- a/App/apps/app_overlay.c +++ b/App/apps/app_overlay.c @@ -548,6 +548,7 @@ static uint8_t app_trivfo_ptt(bool pressed) * code only translates the stable ABI channel structure and performs the FSK * operations which depend on VFO_Info_t and the BK4819 driver. */ static VFO_Info_t app_beam_vfo; +static app_beam_channel_t app_beam_pending; static uint8_t app_beam_fsk_index; static uint16_t app_beam_pending_channel; static bool app_beam_dirty; @@ -654,28 +655,21 @@ static uint16_t app_beam_save(const app_beam_channel_t *in) if (in == NULL) return 0xFFFFu; + /* Only one external-flash write can be deferred per app run. Preserve the + first successfully received channel if an older app tries to queue more. */ + if (app_beam_dirty) + return 0xFFFFu; + uint16_t channel = MR_CHANNEL_FIRST; while (IS_MR_CHANNEL(channel) && RADIO_CheckValidChannel(channel, false, 0)) channel++; if (!IS_MR_CHANNEL(channel)) return 0xFFFFu; - /* Reuse the temporary BEAM VFO as resident staging RAM. No external-flash - * write is allowed while the overlay still executes from that flash's - * 4 KiB sector cache. */ - RADIO_InitInfo(&app_beam_vfo, channel, in->rx_frequency); - app_beam_copy_bytes((app_beam_channel_t *)in, &app_beam_vfo, true); /* in read-only here */ - app_beam_vfo.TX_OFFSET_FREQUENCY = in->tx_offset_frequency; - app_beam_vfo.freq_config_RX.CodeType = in->rx_codetype; - app_beam_vfo.freq_config_TX.CodeType = in->tx_codetype; - app_beam_vfo.Modulation = in->modulation; - app_beam_vfo.FrequencyReverse = in->frequency_reverse; - app_beam_vfo.DTMF_PTT_ID_TX_MODE = in->dtmf_ptt_id_mode; - app_beam_vfo.STEP_SETTING = in->step_setting < STEP_N_ELEM ? in->step_setting : STEP_12_5kHz; - app_beam_vfo.StepFrequency = gStepFrequencyTable[app_beam_vfo.STEP_SETTING]; - memcpy(app_beam_vfo.Name, in->name, sizeof(app_beam_vfo.Name)); - app_beam_vfo.Name[sizeof(app_beam_vfo.Name) - 1u] = '\0'; - + /* External flash cannot be written while the overlay executes from its + sector-cache RAM. Keep the pointer-free payload separate from the radio + VFO: app_beam_prepare() may reuse that VFO before the app returns. */ + memcpy(&app_beam_pending, in, sizeof(app_beam_pending)); app_beam_pending_channel = channel; app_beam_dirty = true; return channel; @@ -691,6 +685,22 @@ static void app_beam_commit(void) const uint16_t channel = app_beam_pending_channel; + /* The overlay has returned, so the temporary radio VFO is now free to + become the channel-save staging object. */ + RADIO_InitInfo(&app_beam_vfo, channel, app_beam_pending.rx_frequency); + app_beam_copy_bytes(&app_beam_pending, &app_beam_vfo, true); + app_beam_vfo.TX_OFFSET_FREQUENCY = app_beam_pending.tx_offset_frequency; + app_beam_vfo.freq_config_RX.CodeType = app_beam_pending.rx_codetype; + app_beam_vfo.freq_config_TX.CodeType = app_beam_pending.tx_codetype; + app_beam_vfo.Modulation = app_beam_pending.modulation; + app_beam_vfo.FrequencyReverse = app_beam_pending.frequency_reverse; + app_beam_vfo.DTMF_PTT_ID_TX_MODE = app_beam_pending.dtmf_ptt_id_mode; + app_beam_vfo.STEP_SETTING = app_beam_pending.step_setting < STEP_N_ELEM + ? app_beam_pending.step_setting : STEP_12_5kHz; + app_beam_vfo.StepFrequency = gStepFrequencyTable[app_beam_vfo.STEP_SETTING]; + memcpy(app_beam_vfo.Name, app_beam_pending.name, sizeof(app_beam_vfo.Name)); + app_beam_vfo.Name[sizeof(app_beam_vfo.Name) - 1u] = '\0'; + SETTINGS_SaveChannel(channel, gEeprom.TX_VFO, &app_beam_vfo, 3); #ifndef ENABLE_KEEP_MEM_NAME SETTINGS_SaveChannelName(channel, app_beam_vfo.Name); @@ -1131,11 +1141,12 @@ uint8_t APP_LaunchOverlay(uint8_t slot) * 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. */ + * hardware all agree on the selected channel. Save all three pointers: + * radio apps such as BEAM temporarily replace them while they run. */ const uint8_t saved_rx_vfo = gEeprom.RX_VFO; - VFO_Info_t *const saved_rx = gRxVfo; + VFO_Info_t *const saved_rx = gRxVfo; + VFO_Info_t *const saved_tx = gTxVfo; + VFO_Info_t *const saved_current = gCurrentVfo; gEeprom.RX_VFO = gEeprom.TX_VFO; gRxVfo = gTxVfo; RADIO_SetupRegisters(true); @@ -1155,6 +1166,8 @@ uint8_t APP_LaunchOverlay(uint8_t slot) /* Restore the resident RX/dual-watch tuning the app ran on top of. */ gEeprom.RX_VFO = saved_rx_vfo; gRxVfo = saved_rx; + gTxVfo = saved_tx; + gCurrentVfo = saved_current; RADIO_SetupRegisters(true); /* The overlay held app code, not a valid config sector. */ diff --git a/App/apps/beam/beam_app.c b/App/apps/beam/beam_app.c index a49f4a25..33940743 100644 --- a/App/apps/beam/beam_app.c +++ b/App/apps/beam/beam_app.c @@ -127,12 +127,15 @@ static void send_packet(void) static void start(void) { stop_rx(); + /* A channel save is committed only after app_main() returns. Do not let a + second RX overwrite that pending save; exit and relaunch to receive more. */ + if (mode && copiedChannel != 0xFFFFu) + return; A->beam_prepare(); if (!mode) { send_packet(); return; } - copiedChannel = 0xFFFFu; A->beam_rx(true); receiving = true; status = STATUS_RX_WAIT; From a41fb998620fda7eaf8c66aef538ae816645eeeb Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Thu, 3 Sep 2026 19:02:21 +0200 Subject: [PATCH 64/73] Fix CHIRP upload stall on unaligned final EEPROM write block --- App/app/uart.c | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/App/app/uart.c b/App/app/uart.c index 05913893..9d1bf597 100644 --- a/App/app/uart.c +++ b/App/app/uart.c @@ -439,7 +439,12 @@ static void CMD_051D(uint32_t Port, const uint8_t *pBuffer) uint32_t Timestamp = 0; - if ((pCmd->Size & 7u) || pCmd->Header.Size < 8u + pCmd->Size) + /* Bound the write against the received frame: Data[] must hold pCmd->Size + * bytes, otherwise the loop below would read adjacent RAM and persist it to + * EEPROM (memory disclosure). A non-multiple-of-8 Size is NOT rejected: the + * Size/8 loop simply truncates the sub-page tail, matching the historical + * behavior CHIRP relies on for its final (unaligned) config block. */ + if (pCmd->Header.Size < 8u + pCmd->Size) return; if(0) {} From 07226822cdad0fb6a2b0f08cc7c038ea253ceafb Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Thu, 3 Sep 2026 22:27:17 +0200 Subject: [PATCH 65/73] Re-arm BLTime on keypress in overlay apps --- App/apps/app_overlay.c | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/App/apps/app_overlay.c b/App/apps/app_overlay.c index 5134ebf6..e52980d9 100644 --- a/App/apps/app_overlay.c +++ b/App/apps/app_overlay.c @@ -97,8 +97,13 @@ static uint8_t app_get_key(void) return APP_KEY_INVALID; } - if (!APP_IsScreenSaverDisplayed()) + if (!APP_IsScreenSaverDisplayed()) { + if (key != KEY_INVALID) + BACKLIGHT_TurnOn(); /* re-arm BLTime on activity, mirrors ProcessKey(): + overlay apps bypass the resident key handler, so + the saver would otherwise fire mid-use. */ return (uint8_t)key; + } if (key == KEY_INVALID) return APP_KEY_SAVER; From 34ea3fd6af4086ca21b59b9eb36683f0ab4dfe35 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 6 Sep 2026 23:27:41 +0200 Subject: [PATCH 66/73] Add optional CARR carrier-keying mode to beacon --- App/app/foxhunt.c | 74 ++++++++++++++++++++++++++---------- App/apps/app_api.h | 1 + App/apps/app_overlay.c | 2 + App/apps/beacon/beacon_app.c | 37 +++++++++++------- App/driver/eeprom_compat.c | 4 +- 5 files changed, 82 insertions(+), 36 deletions(-) diff --git a/App/app/foxhunt.c b/App/app/foxhunt.c index 2682d865..f80dec65 100644 --- a/App/app/foxhunt.c +++ b/App/app/foxhunt.c @@ -90,8 +90,12 @@ // --- Beacon (fox) app -------------------------------------------------------- // Turns the radio into the hidden transmitter: each cycle keys up on the TX VFO and // repeats the CW fox identifier (MOE..MO5, or " MOE") in Morse for the TX window, -// then stays silent for the (adjustable) idle gap. The carrier stays up during the -// window; only the tone modulation is keyed on/off (EnterTxMute/ExitTxMute) = MCW on FM. +// then stays silent for the (adjustable) idle gap. Two keying modes (KEY_4): +// TONE (default): carrier stays up the whole window, only the tone is keyed on/off +// (EnterTxMute/ExitTxMute) = MCW on FM (F2A). +// CARR: the carrier (PA_ENABLE) is keyed together with the tone, so between +// elements the carrier itself is gone (carrier interruption, the ARDF +// field pattern) — harder to home in on, at the cost of some key clicks. #define FOXHUNT_BEACON_TONE_HZ 1000 // CW tone pitch (Hz) #define FOXHUNT_MORSE_UNIT_MS 100 // one Morse time unit (~12 WPM, ARDF pace) #define FOXHUNT_BEACON_IDLE_DEF 30 // default silence between IDs (s) @@ -212,6 +216,7 @@ static uint8_t beaconTx; // TX window length (s): the ID repeats for th static uint16_t beaconTxMsLeft; // ms left in the current TX window (drained as it plays) static uint8_t beaconTxSecShown; // whole-second value last painted on the TX line static uint8_t foxFox; // selected fox identifier (FOXHUNT_FOX_*) +static bool beaconCarrierKeyed; // false = TONE (F2A, keyed tone); true = CARR (keyed carrier) static char foxCall[FOXHUNT_CALLSIGN_MAX + 1]; // sanitised callsign for the CALL id static void FOXHUNT_EnterHunt(void); @@ -638,14 +643,15 @@ static void FOXHUNT_IdleCycle(int8_t dir) } // Apply a beacon number key in the given direction; returns true when it changed a -// setting so the caller can refresh. Keys follow the on-screen layout: 1 = TX (top-left), -// 2 = IDLE (below it), 3 = FOX (right). Shared by both beacon phases. +// setting so the caller can refresh. Keys: 1 = TX, 2 = IDLE, 3 = FOX, 4 = keying mode +// (TONE/CARR, a plain toggle so dir is moot). Shared by both beacon phases. static bool FOXHUNT_BeaconKey(KEY_Code_t key, int8_t dir) { switch (key) { case KEY_1: FOXHUNT_TxCycle(dir); return true; case KEY_2: FOXHUNT_IdleCycle(dir); return true; case KEY_3: FOXHUNT_FoxCycle(dir); return true; + case KEY_4: beaconCarrierKeyed = !beaconCarrierKeyed; return true; default: return false; } } @@ -946,8 +952,27 @@ static uint8_t FOXHUNT_MorseByte(char c) return 0; } -// Send one character as modulated CW: key the running TX tone on per element, -// muting the modulation (carrier stays up) between them. Returns true if aborted. +// Beacon keying primitive. Both modes key the tone (Enter/ExitTxMute); CARR additionally +// gates the PA in lockstep, so between elements the carrier itself is gone — not just the +// tone. Muting the tone in CARR too is deliberate: gating PA_ENABLE does not perfectly +// kill the carrier, and a tone left riding would bleed through that residual carrier as a +// near-continuous note. The PLL stays locked, so the carrier gate is only a GPIO toggle. +static void FOXHUNT_KeyOn(void) +{ + BK4819_ExitTxMute(); + if (beaconCarrierKeyed) + BK4819_ToggleGpioOut(BK4819_GPIO1_PIN29_PA_ENABLE, true); +} +static void FOXHUNT_KeyOff(void) +{ + BK4819_EnterTxMute(); + if (beaconCarrierKeyed) + BK4819_ToggleGpioOut(BK4819_GPIO1_PIN29_PA_ENABLE, false); +} + +// Send one character as Morse: key each element on (FOXHUNT_KeyOn) for its dit/dah +// duration, off (FOXHUNT_KeyOff) in the gaps. What "key" means depends on the beacon +// mode (tone vs carrier), see above. Returns true if aborted. static bool FOXHUNT_MorseChar(char c, uint8_t visibleChars) { uint8_t code = FOXHUNT_MorseByte(c); @@ -964,9 +989,9 @@ static bool FOXHUNT_MorseChar(char c, uint8_t visibleChars) for (bit >>= 1; bit; bit >>= 1) { // then walk the elements, MSB first const uint16_t on = (code & bit) ? (FOXHUNT_MORSE_UNIT_MS * 3) // dah : FOXHUNT_MORSE_UNIT_MS; // dit - BK4819_ExitTxMute(); - if (FOXHUNT_TxDelay(on)) { BK4819_EnterTxMute(); return true; } - BK4819_EnterTxMute(); + FOXHUNT_KeyOn(); + if (FOXHUNT_TxDelay(on)) { FOXHUNT_KeyOff(); return true; } + FOXHUNT_KeyOff(); if (FOXHUNT_TxDelay(FOXHUNT_MORSE_UNIT_MS)) return true; // intra gap } @@ -985,11 +1010,14 @@ static bool FOXHUNT_MorseChar(char c, uint8_t visibleChars) // keys up for a fixed slot rather than sending a single one-shot. static void FOXHUNT_BeaconTransmit(void) { - RADIO_SetTxParameters(); // key up: carrier + PA + RADIO_SetTxParameters(); // key up: carrier + PA + PLL lock - // Prime the tone generator, then start silent before the first element. + // Prime the tone generator, then open the window silent before the first element: + // tone muted in both modes, and in CARR the carrier dropped too (PLL still locked). BK4819_TransmitTone(false, FOXHUNT_BEACON_TONE_HZ); BK4819_EnterTxMute(); + if (beaconCarrierKeyed) + BK4819_ToggleGpioOut(BK4819_GPIO1_PIN29_PA_ENABLE, false); beaconTxMsLeft = (uint16_t)beaconTx * 1000u; // drained inside FOXHUNT_TxDelay @@ -1151,16 +1179,19 @@ static void FOXHUNT_BeaconDraw(bool txNow, uint8_t idleLeft) UI_PrintString(str, 0, 127, 3, 10); } - // Bottom-left column: 1 = TX window over 2 = IDLE gap. Bottom-right: 3 = FOX id over - // the freq (drawn by FOXHUNT_BeaconChrome). All three are inverse tags; each has its - // own number key, editable in either phase, so there is no cursor to show. + // Settings tags: TX (line 5, left) over IDLE (line 6, left). On line 5, right of TX: + // FOX shifted left with the keying mode (TONE/CARR) right-aligned beside it, so the pair + // spans roughly the width of the TX frequency drawn just below on line 6 + // (FOXHUNT_BeaconChrome). Keys: 1 TX, 2 IDLE, 3 FOX, 4 mode; editable in either phase. sprintf(str, "TX %us", beaconTx); FOXHUNT_Tag(str, 4, 5); sprintf(str, "IDLE %us", beaconIdle); FOXHUNT_Tag(str, 4, 6); - FOXHUNT_FoxLabel(str); - FOXHUNT_Tag(str, (uint8_t)(125 - strlen(str) * 4), 5); + FOXHUNT_Tag(str, 66, 5); + + const char *mode = beaconCarrierKeyed ? "CARR" : "TONE"; + FOXHUNT_Tag(mode, (uint8_t)(125 - strlen(mode) * 4), 5); } static void FOXHUNT_BeaconKeys(void) @@ -1270,7 +1301,7 @@ static void FOXHUNT_BeaconTick(void) // (eeprom_compat.c) so aircopy clones them with the VFOs. A factory reset clears it. #define FOXHUNT_CFG_ADDR 0x0090E0u #define FOXHUNT_CFG_MAGIC 0xF4u // tells a written config from erased flash (0xFF) -#define FOXHUNT_CFG_LEN 7 // magic + att + graph + audio + idle + fox + tx +#define FOXHUNT_CFG_LEN 8 // magic + att + graph + audio + idle + fox + tx + carrier // RAM mirror of the bytes last written to flash, so FOXHUNT_SaveConfig only touches // the flash when a value actually changed. @@ -1286,6 +1317,7 @@ static __attribute__((noinline)) void FOXHUNT_ConfigPack(uint8_t out[FOXHUNT_CFG out[4] = beaconIdle; out[5] = foxFox; out[6] = beaconTx; + out[7] = beaconCarrierKeyed; } // Restore persisted settings; erased/invalid flash leaves the defaults in place. @@ -1305,6 +1337,7 @@ static void FOXHUNT_LoadConfig(void) if (cfg[6] >= FOXHUNT_BEACON_TX_MIN && cfg[6] <= FOXHUNT_BEACON_TX_MAX && (cfg[6] % FOXHUNT_BEACON_TX_STEP) == 0) beaconTx = cfg[6]; + if (cfg[7] <= 1) beaconCarrierKeyed = cfg[7]; // erased (0xFF) legacy config -> keep default } FOXHUNT_ConfigPack(foxCfgSaved); // mirror the loaded (or default) state @@ -1338,15 +1371,16 @@ static void FOXHUNT_Begin(void) gCurrentVfo = gTxVfo; RADIO_SetupRegisters(true); - // Initialise the complete legacy seven-byte configuration before loading it. - // Keeping the layout unchanged preserves existing FieldOps settings and lets - // either independently compiled app update its fields without a migration. + // Initialise the full configuration before loading it. The carrier-keying byte was + // appended (index 7): an older seven-byte config reads 0xFF there and LoadConfig's + // `<= 1` guard rejects it, so it stays at the default below — no migration needed. attStep = 0; foxGraphMode = FOXHUNT_GRAPH_BAR; foxAudioMode = FOXHUNT_AUDIO_OFF; beaconIdle = FOXHUNT_BEACON_IDLE_DEF; beaconTx = FOXHUNT_BEACON_TX_DEF; foxFox = FOXHUNT_FOX_CALL; + beaconCarrierKeyed = false; // TONE (F2A) by default beaconPhaseTx = false; FOXHUNT_LoadConfig(); diff --git a/App/apps/app_api.h b/App/apps/app_api.h index 6a286fe2..58a0de21 100644 --- a/App/apps/app_api.h +++ b/App/apps/app_api.h @@ -231,6 +231,7 @@ typedef struct app_api { void (*tx_tone)(uint16_t hz);/* BK4819_TransmitTone prime (MCW tone) */ void (*tx_mute)(bool on); /* key the tone on(false)/off(true) via TxMute */ void (*tx_end)(void); /* PA off + RADIO_SetupRegisters (back to RX) */ + void (*tx_carrier)(bool on); /* gate the PA on/off (beacon CARR carrier keying) */ uint32_t (*tx_freq)(void); /* current TX VFO frequency (x10 Hz) */ void (*boot_callsign)(char *buf, uint8_t len); /* sanitised callsign from the boot message */ void (*print_string)(const char *s, uint8_t start, uint8_t end, diff --git a/App/apps/app_overlay.c b/App/apps/app_overlay.c index e52980d9..444aa1ad 100644 --- a/App/apps/app_overlay.c +++ b/App/apps/app_overlay.c @@ -850,6 +850,7 @@ 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 void app_tx_carrier(bool on) { BK4819_ToggleGpioOut(BK4819_GPIO1_PIN29_PA_ENABLE, on); } static uint32_t app_tx_freq(void) { return gTxVfo->pTX->Frequency; } static void app_boot_callsign(char *buf, uint8_t len) { @@ -1051,6 +1052,7 @@ static const app_api_t app_api = { .tx_tone = app_tx_tone, .tx_mute = app_tx_mute, .tx_end = app_tx_end, + .tx_carrier = app_tx_carrier, .tx_freq = app_tx_freq, .boot_callsign = app_boot_callsign, .print_string = UI_PrintString, diff --git a/App/apps/beacon/beacon_app.c b/App/apps/beacon/beacon_app.c index 0228c980..b1567aac 100644 --- a/App/apps/beacon/beacon_app.c +++ b/App/apps/beacon/beacon_app.c @@ -17,12 +17,13 @@ /* * 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. + * a CW fox identifier (MOE..MO5 / MO / " MOE") for the TX window, then stays + * silent for the idle gap. Two keying modes (key 4): TONE (default) keeps the carrier + * up and keys only the tone (MCW / F2A); CARR keys the PA together with the tone, so + * between elements the carrier itself is gone (carrier interruption, ARDF field pattern). * - * Keys: 1 TX window · 2 idle gap · 3 fox id (F reverses) · MENU restart idle - * long F keypad lock · EXIT quit. + * Keys: 1 TX window · 2 idle gap · 3 fox id · 4 keying mode TONE/CARR (F reverses) + * MENU restart idle · long F keypad lock · EXIT quit. */ #include @@ -64,7 +65,7 @@ static const uint8_t FONT_LOCK[9] = {0x7c,0x46,0x45,0x45,0x45,0x45,0x45,0x46,0x7 static const app_api_t *A; -static bool foxLocked, fArm, fLongDone, running, phaseTx; +static bool foxLocked, fArm, fLongDone, running, phaseTx, carrier; static uint16_t fHoldMs; static uint8_t beaconIdle, idleLeft, idleTick, beaconTx, secShown, charsSent, foxFox; static uint16_t txMsLeft; @@ -95,6 +96,7 @@ static bool settingKey(uint8_t key,int8_t dir){ if(dir>0){ if(++foxFox>=FOX_COUNT) foxFox=0; } else foxFox=foxFox?(uint8_t)(foxFox-1u):(uint8_t)(FOX_COUNT-1u); return true; + case APP_KEY_4: carrier=!carrier; return true; /* TONE <-> CARR (dir moot) */ default: return false; } } @@ -184,7 +186,8 @@ static void beaconDraw(bool txNow,uint8_t il){ } 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); + foxLabel(str); tag(str,66,5); + { const char *m=carrier?"CARR":"TONE"; tag(m,(uint8_t)(125-slen(m)*4),5); } } static void blit(void){ A->blit_status(); A->blit_full(); } @@ -218,15 +221,19 @@ static bool txDelay(uint16_t ms){ } return false; } +/* Keying: both modes key the tone; CARR gates the PA in lockstep so the carrier is truly + * gone between elements (tone muted too, else it bleeds through residual PA leakage). */ +static void keyOn(void){ A->tx_mute(false); if(carrier) A->tx_carrier(true); } +static void keyOff(void){ A->tx_mute(true); if(carrier) A->tx_carrier(false); } static bool morseChar(char c,uint8_t vis){ uint8_t code=morseByte(c); if(code==0){ if(txDelay(MORSE_UNIT*4)) return true; updateProgress(vis); return false; } uint8_t bit=0x80; while(!(code&bit))bit>>=1; for(bit>>=1;bit;bit>>=1){ uint16_t on=(code&bit)?(MORSE_UNIT*3):MORSE_UNIT; - A->tx_mute(false); - if(txDelay(on)){ A->tx_mute(true); return true; } - A->tx_mute(true); + keyOn(); + if(txDelay(on)){ keyOff(); return true; } + keyOff(); if(txDelay(MORSE_UNIT)) return true; } updateProgress(vis); @@ -235,7 +242,8 @@ static bool morseChar(char c,uint8_t vis){ static void transmit(void){ A->tx_set_params(); A->tx_tone(TONE_HZ); - A->tx_mute(true); + A->tx_mute(true); /* open muted (both modes) */ + if(carrier) A->tx_carrier(false); /* CARR: carrier off until first element */ txMsLeft=(uint16_t)beaconTx*1000u; bool stop=false; while(!stop && txMsLeft>0){ @@ -270,14 +278,15 @@ static void tickDelay(void){ for(uint8_t i=0;idelay_ms(10); /* ---- config (deferred) ---- */ static void loadConfig(void){ - uint8_t c[4]; A->cfg_load(c,4); + uint8_t c[5]; A->cfg_load(c,5); 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]; + if(c[4]<=1) carrier=c[4]; /* erased (0xFF) legacy config -> keep default */ } } -static void saveConfig(void){ uint8_t c[4]={CFG_MAGIC,beaconIdle,foxFox,beaconTx}; A->cfg_save(c,4); } +static void saveConfig(void){ uint8_t c[5]={CFG_MAGIC,beaconIdle,foxFox,beaconTx,(uint8_t)carrier}; A->cfg_save(c,5); } static void txDenied(void){ chrome(); @@ -289,7 +298,7 @@ static void txDenied(void){ __attribute__((section(".text.entry"),used)) void app_main(const app_api_t *api){ A=api; - foxLocked=fArm=fLongDone=false; fHoldMs=0; + foxLocked=fArm=fLongDone=carrier=false; fHoldMs=0; beaconIdle=IDLE_DEF; beaconTx=TX_DEF; foxFox=FOX_CALL; prevKey=APP_KEY_INVALID; A->boot_callsign(foxCall,sizeof(foxCall)); diff --git a/App/driver/eeprom_compat.c b/App/driver/eeprom_compat.c index fd36bb58..a7b751a1 100644 --- a/App/driver/eeprom_compat.c +++ b/App/driver/eeprom_compat.c @@ -51,10 +51,10 @@ static const AddrMapping_t ADDR_MAPPINGS[] = { _MK_MAPPING(0x008000, 0x008000, 0x00886E), // 1024 MR + 7 VFO Attributes * 2 Bytes (ex 0x002000) 0x008000 -> 0x00880E // List name * 4 Bytes 0x00880E -> 0x00886E - _MK_MAPPING(0x009000, 0x009000, 0x0090E7), // 14 VFO * 16 Bytes = 0x9000 -> 0x90E0 (the old + _MK_MAPPING(0x009000, 0x009000, 0x0090E8), // 14 VFO * 16 Bytes = 0x9000 -> 0x90E0 (the old // 0x90D6 bound was 10 B short: it truncated the // 470 MHz VFO1 record at 0x90D0 -> 0x90DF) - // Fox Hunt / Beacon settings * 7 Bytes 0x90E0 -> 0x90E7 + // Fox Hunt / Beacon settings * 8 Bytes 0x90E0 -> 0x90E8 // (written directly by app/foxhunt.c; concatenated // here so aircopy clones them with the VFOs) From d12dbe0bf980b58039583cca88a223b6c2a9a49a Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Sun, 6 Sep 2026 23:39:17 +0200 Subject: [PATCH 67/73] =?UTF-8?q?Fix=20beacon=20carrier=20staying=20off=20?= =?UTF-8?q?after=20a=20mid-window=20CARR=E2=86=92TONE=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- App/app/foxhunt.c | 5 +++-- App/apps/beacon/beacon_app.c | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/App/app/foxhunt.c b/App/app/foxhunt.c index f80dec65..de16abe3 100644 --- a/App/app/foxhunt.c +++ b/App/app/foxhunt.c @@ -960,8 +960,9 @@ static uint8_t FOXHUNT_MorseByte(char c) static void FOXHUNT_KeyOn(void) { BK4819_ExitTxMute(); - if (beaconCarrierKeyed) - BK4819_ToggleGpioOut(BK4819_GPIO1_PIN29_PA_ENABLE, true); + // Assert the carrier in both modes: a no-op in TONE (already up), but it re-arms the PA + // if the mode was switched from CARR mid-window while the carrier happened to be down. + BK4819_ToggleGpioOut(BK4819_GPIO1_PIN29_PA_ENABLE, true); } static void FOXHUNT_KeyOff(void) { diff --git a/App/apps/beacon/beacon_app.c b/App/apps/beacon/beacon_app.c index b1567aac..4e2d9de1 100644 --- a/App/apps/beacon/beacon_app.c +++ b/App/apps/beacon/beacon_app.c @@ -223,7 +223,7 @@ static bool txDelay(uint16_t ms){ } /* Keying: both modes key the tone; CARR gates the PA in lockstep so the carrier is truly * gone between elements (tone muted too, else it bleeds through residual PA leakage). */ -static void keyOn(void){ A->tx_mute(false); if(carrier) A->tx_carrier(true); } +static void keyOn(void){ A->tx_mute(false); A->tx_carrier(true); } /* carrier up both modes; re-arms after a mid-window switch */ static void keyOff(void){ A->tx_mute(true); if(carrier) A->tx_carrier(false); } static bool morseChar(char c,uint8_t vis){ uint8_t code=morseByte(c); From 960d13e120c568520fe26a7dd3c8e00a846bb7ea Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 7 Sep 2026 01:17:11 +0200 Subject: [PATCH 68/73] Don't force backlight off on serial session init --- App/app/uart.c | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/App/app/uart.c b/App/app/uart.c index 9d1bf597..d44d58d9 100644 --- a/App/app/uart.c +++ b/App/app/uart.c @@ -366,8 +366,8 @@ static void CMD_0514(uint32_t Port, const uint8_t *pBuffer) gSerialConfigCountDown_500ms = 12; // 6 sec - if (gEeprom.BACKLIGHT_TIME < 61) // backlight is set to be always on - BACKLIGHT_TurnOff(); // turn the LCD backlight off + // Backlight left untouched: a serial session is neutral, so the normal BLTime + // inactivity countdown keeps running from the last keypress (no forced turn-off). SendVersion(Port); } @@ -624,8 +624,8 @@ static void CMD_052F(uint32_t Port, const uint8_t *pBuffer) } #endif - if (gEeprom.BACKLIGHT_TIME < 61) // backlight is set to be always on - BACKLIGHT_TurnOff(); // turn the LCD backlight off + // Backlight left untouched: a serial session is neutral, so the normal BLTime + // inactivity countdown keeps running from the last keypress (no forced turn-off). SendVersion(Port); } From 749e85695951cb39b94f5143126abbfcfdd3f08c Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 7 Sep 2026 03:19:43 +0200 Subject: [PATCH 69/73] Restyle the F status indicator --- App/app/foxhunt.c | 4 ++-- App/apps/beacon/beacon_app.c | 17 +++++++++++------ App/apps/fm/fm_app.c | 4 ++-- App/apps/foxhunt/foxhunt_app.c | 6 +++--- App/bitmaps.c | 17 +++++++++-------- App/bitmaps.h | 2 +- 6 files changed, 28 insertions(+), 22 deletions(-) diff --git a/App/app/foxhunt.c b/App/app/foxhunt.c index de16abe3..9d08c0c6 100644 --- a/App/app/foxhunt.c +++ b/App/app/foxhunt.c @@ -434,9 +434,9 @@ static void FOXHUNT_Tag(const char *s, uint8_t x, uint8_t line) static void FOXHUNT_DrawFKey(void) { if (foxLocked) - memcpy(gStatusLine + 69, gFontKeyLock, sizeof(gFontKeyLock)); + memcpy(gStatusLine + 70, gFontKeyLock, sizeof(gFontKeyLock)); else if (gWasFKeyPressed) - memcpy(gStatusLine + 69, gFontF, sizeof(gFontF)); + memcpy(gStatusLine + 70, gFontF, sizeof(gFontF)); } // Draw a string right-aligned in the small (7 px) font: its right edge lands at rightX. diff --git a/App/apps/beacon/beacon_app.c b/App/apps/beacon/beacon_app.c index 4e2d9de1..361fdcf0 100644 --- a/App/apps/beacon/beacon_app.c +++ b/App/apps/beacon/beacon_app.c @@ -62,6 +62,7 @@ static const uint8_t M_DIG[10] = {0x3F,0x2F,0x27,0x23,0x21,0x20,0x30,0x38,0x3C,0 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 uint8_t FONT_F[9] = {0x3e,0x7f,0x41,0x75,0x75,0x75,0x7d,0x7f,0x3e}; static const app_api_t *A; @@ -134,7 +135,8 @@ static void chrome(void){ 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); + if(foxLocked) cpy(A->status_line+70,FONT_LOCK,sizeof(FONT_LOCK)); + else if(fArm) cpy(A->status_line+70,FONT_F,sizeof(FONT_F)); { 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); @@ -192,11 +194,14 @@ static void beaconDraw(bool txNow,uint8_t il){ 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; +/* true exactly on the lock/unlock toggle, so a caller that does not redraw every tick + * (the TX loop) can refresh the padlock at once. */ +static bool lockTrack(uint8_t key,uint16_t ms){ + if(key!=APP_KEY_F){ fHoldMs=0; fLongDone=false; return false; } + if(fLongDone) return false; fHoldMs+=ms; - if(fHoldMs>=LOCK_HOLD_MS){ fLongDone=true; foxLocked=!foxLocked; fArm=false; A->backlight_on(); } + if(fHoldMs>=LOCK_HOLD_MS){ fLongDone=true; foxLocked=!foxLocked; fArm=false; A->backlight_on(); return true; } + return false; } /* ---- interactive TX delay: drains the window, keys, live seconds. true = abort ---- */ @@ -209,7 +214,7 @@ static bool txDelay(uint16_t ms){ if((uint8_t)((txMsLeft+999u)/1000u)!=secShown){ drawTxSeconds(); A->blit_line(0); } uint8_t key=A->get_key(); - lockTrack(key,slice); + if(lockTrack(key,slice)){ beaconDraw(true,0); blit(); } /* show the padlock at once, mid-TX */ if(key==APP_KEY_INVALID||key==prevKey){ prevKey=key; continue; } prevKey=key; A->backlight_on(); diff --git a/App/apps/fm/fm_app.c b/App/apps/fm/fm_app.c index 9212af0c..5fa382b1 100644 --- a/App/apps/fm/fm_app.c +++ b/App/apps/fm/fm_app.c @@ -36,7 +36,7 @@ #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 const uint8_t FONT_F[9] = {0x3e,0x7f,0x41,0x75,0x75,0x75,0x7d,0x7f,0x3e}; static void cpy(uint8_t *d, const uint8_t *s, uint8_t n){ while(n--)*d++=*s++; } static const app_api_t *A; @@ -102,7 +102,7 @@ static void draw(void) A->display_clear(); A->status_clear(); A->draw_battery(); - if(fArm) cpy(A->status_line+69,FONT_F,8); /* F armed indicator */ + if(fArm) cpy(A->status_line+70,FONT_F,sizeof(FONT_F)); /* F armed indicator */ A->print_string("FM",2,0,0,8); A->print_normal(BAND_NAME[st.band],1,0,6); diff --git a/App/apps/foxhunt/foxhunt_app.c b/App/apps/foxhunt/foxhunt_app.c index 1588c584..fba3aacf 100644 --- a/App/apps/foxhunt/foxhunt_app.c +++ b/App/apps/foxhunt/foxhunt_app.c @@ -81,7 +81,7 @@ static const uint8_t BMP_SPEAKER[10] = {0x1c,0x1c,0x3e,0x7f,0x00,0x22,0x1c,0x41, 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_F[9] = {0x3e,0x7f,0x41,0x75,0x75,0x75,0x7d,0x7f,0x3e}; 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++; } @@ -240,8 +240,8 @@ static void draw(void){ 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); + if(foxLocked) cpy(A->status_line+70,FONT_LOCK,9); + else if(fArm) cpy(A->status_line+70,FONT_F,sizeof(FONT_F)); i2str(big,curDbm); A->display_freq(big,2,0,false); diff --git a/App/bitmaps.c b/App/bitmaps.c index 21f8cff1..32f1bc52 100644 --- a/App/bitmaps.c +++ b/App/bitmaps.c @@ -22,16 +22,17 @@ const uint8_t gFontPttClassic[2][6] = {0x00, 0x7f, 0x40, 0x40, 0x40, 0x40}, }; -const uint8_t gFontF[8] = +const uint8_t gFontF[9] = { + 0b00111110, 0b01111111, - 0b00000000, - 0b01110110, - 0b01110110, - 0b01110110, - 0b01110110, - 0b01111110, - 0b01111111 + 0b01000001, + 0b01110101, + 0b01110101, + 0b01110101, + 0b01111101, + 0b01111111, + 0b00111110 }; const uint8_t gFontS[6] = diff --git a/App/bitmaps.h b/App/bitmaps.h index fba73398..a79eb7a4 100644 --- a/App/bitmaps.h +++ b/App/bitmaps.h @@ -7,7 +7,7 @@ extern const uint8_t gFontPowerSave[2][6]; extern const uint8_t gFontPttOnePush[2][6]; extern const uint8_t gFontPttClassic[2][6]; -extern const uint8_t gFontF[8]; +extern const uint8_t gFontF[9]; extern const uint8_t gFontS[6]; extern const uint8_t gFontKeyLock[9]; From dbb9d42d2d2acf1934218e625e43e97b7f742918 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Mon, 7 Sep 2026 22:41:50 +0200 Subject: [PATCH 70/73] Count the scan-list name hold in 10 ms ticks, not 500 ms --- App/app/app.c | 2 ++ App/ui/main.c | 35 +++++++++++++++++++++++++---------- App/ui/main.h | 2 ++ 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/App/app/app.c b/App/app/app.c index 2853169e..7946677f 100644 --- a/App/app/app.c +++ b/App/app/app.c @@ -1690,6 +1690,8 @@ void APP_TimeSlice10ms(void) if (gReducedService) return; + UI_MAIN_TimeSlice10ms(); // scan-list name hold countdown (10 ms resolution) + if (gCurrentFunction != FUNCTION_POWER_SAVE || !gRxIdleMode) CheckRadioInterrupts(); #ifdef ENABLE_FEAT_F4HWN_ACTION_PICKER diff --git a/App/ui/main.c b/App/ui/main.c index 670fcf90..ab28353b 100644 --- a/App/ui/main.c +++ b/App/ui/main.c @@ -68,7 +68,10 @@ center_line_t center_line = CENTER_LINE_NONE; #ifdef ENABLE_FEAT_F4HWN_SCAN_PROGRESS #define SCAN_PROGRESS_MR_CHANNEL_BYTES ((MR_CHANNELS_MAX + 7u) / 8u) -#define SCAN_LIST_NAME_HOLD_500MS (2000u / 500u) +// Scan-list name hold, in 10 ms ticks. Counted down on the 10 ms timeslice (not the +// 500 ms one) so the hold is accurate to a single tick instead of +/- 500 ms. Stored +// in a uint8_t, so the practical ceiling is 255 ticks = 2.55 s. +#define SCAN_LIST_NAME_HOLD_10MS (1000u / 10u) static bool gScanProgressSessionActive; static bool gScanProgressSessionIsMemory; @@ -83,7 +86,7 @@ static bool gScanProgressPrevResetVfosFlag; static bool gScanProgressForceRebuild; static uint16_t gScanProgressLastMemoryIndex; static uint8_t gScanProgressPriorityState; -static uint8_t gScanListNameCountdown_500ms; +static uint8_t gScanListNameCountdown_10ms; #define SCAN_PROGRESS_PRIORITY_LABEL_MASK 0x03u #define SCAN_PROGRESS_PRIORITY_SEEN_SHIFT 2 #define SCAN_PROGRESS_PRIORITY_SEEN_MASK 0x1cu @@ -223,7 +226,7 @@ static void ScanProgress_ResetSession(void) gScanProgressForceRebuild = false; gScanProgressLastMemoryIndex = 0; gScanProgressPriorityState = 0; - gScanListNameCountdown_500ms = 0; + gScanListNameCountdown_10ms = 0; } void UI_MAIN_NotifyScanProgressDataChanged(void) @@ -235,7 +238,7 @@ void UI_MAIN_NotifyScanProgressDataChanged(void) void UI_MAIN_NotifyScanListChanged(void) { UI_MAIN_NotifyScanProgressDataChanged(); - gScanListNameCountdown_500ms = SCAN_LIST_NAME_HOLD_500MS; + gScanListNameCountdown_10ms = SCAN_LIST_NAME_HOLD_10MS; gUpdateDisplay = true; } @@ -250,7 +253,7 @@ void UI_MAIN_NotifyScanListChanged(void) // stall ~2 s with nothing on screen to explain the pause. bool UI_MAIN_ShouldHoldScanResume(void) { - return gScanListNameCountdown_500ms > 0 && IS_MR_CHANNEL(gNextMrChannel); + return gScanListNameCountdown_10ms > 0 && IS_MR_CHANNEL(gNextMrChannel); } static inline void ScanProgress_SetBit(uint8_t *map, uint16_t ch) @@ -549,7 +552,7 @@ static bool UI_DrawScanProgress(void) } // Right after a scan-list change, briefly show its name instead of the progress bar - if (show_memory && gScanListNameCountdown_500ms > 0) { + if (show_memory && gScanListNameCountdown_10ms > 0) { UI_MAIN_DrawScanListName(); return true; } @@ -1225,13 +1228,25 @@ void UI_MAIN_PrintAGC(bool now) } #endif +#ifdef ENABLE_FEAT_F4HWN_SCAN_PROGRESS +// Count the scan-list name hold down on the 10 ms tick. It used to ride the 500 ms +// tick, but the countdown is armed at an arbitrary instant, so the first interval was +// anywhere from ~0 to 500 ms - the name could linger up to half a second short of, or +// over, its nominal hold. At 10 ms resolution that error is one tick at most. Gated on +// DISPLAY_MAIN exactly as the old 500 ms path was, so it only ticks while the name can +// actually be on screen. +void UI_MAIN_TimeSlice10ms(void) +{ + if (gScreenToDisplay == DISPLAY_MAIN + && gScanListNameCountdown_10ms > 0 + && --gScanListNameCountdown_10ms == 0) + gUpdateDisplay = true; +} +#endif + void UI_MAIN_TimeSlice500ms(void) { if(gScreenToDisplay==DISPLAY_MAIN) { -#ifdef ENABLE_FEAT_F4HWN_SCAN_PROGRESS - if (gScanListNameCountdown_500ms > 0 && --gScanListNameCountdown_500ms == 0) - gUpdateDisplay = true; -#endif #ifdef ENABLE_AGC_SHOW_DATA UI_MAIN_PrintAGC(true); return; diff --git a/App/ui/main.h b/App/ui/main.h index 309c15b9..e3e7a932 100644 --- a/App/ui/main.h +++ b/App/ui/main.h @@ -60,10 +60,12 @@ void UI_DisplayMain(void); void UI_MAIN_NotifyScanProgressDataChanged(void); void UI_MAIN_NotifyScanListChanged(void); bool UI_MAIN_ShouldHoldScanResume(void); +void UI_MAIN_TimeSlice10ms(void); #else static inline void UI_MAIN_NotifyScanProgressDataChanged(void) {} static inline void UI_MAIN_NotifyScanListChanged(void) {} static inline bool UI_MAIN_ShouldHoldScanResume(void) { return false; } +static inline void UI_MAIN_TimeSlice10ms(void) {} #endif #ifdef ENABLE_AGC_SHOW_DATA From 43e9e1ec677d6bffa3c590464992a7644bd203f5 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Tue, 8 Sep 2026 19:25:22 +0200 Subject: [PATCH 71/73] Support macOS Bash 3.2 in compile scripts --- compile-app.sh | 6 +++--- compile-firmware.sh | 13 ++++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/compile-app.sh b/compile-app.sh index ae5a7319..c89bcfef 100755 --- a/compile-app.sh +++ b/compile-app.sh @@ -33,7 +33,7 @@ if [ "${#ALL_APPS[@]}" -eq 0 ]; then fi # --- select targets --- -if [ "$#" -eq 0 ] || [ "${1,,}" = "all" ]; then +if [ "$#" -eq 0 ] || [[ "${1:-}" == [Aa][Ll][Ll] ]]; then TARGETS=("${ALL_APPS[@]}") else TARGETS=() @@ -56,7 +56,7 @@ 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) +TTY_ARG=""; [[ -t 1 ]] && TTY_ARG="-t" echo echo "🚀 Building overlay apps" @@ -69,7 +69,7 @@ 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)" \ + if docker run --rm ${TTY_ARG:+"$TTY_ARG"} -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 diff --git a/compile-firmware.sh b/compile-firmware.sh index a2653570..6e997594 100755 --- a/compile-firmware.sh +++ b/compile-firmware.sh @@ -63,29 +63,32 @@ RESULT_RAM_SIZES=() run_preset_build() { local preset="$1" - local -a docker_tty_args=() + local docker_tty_arg="" # 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) + docker_tty_arg="-t" fi - docker run --rm "${docker_tty_args[@]}" \ + # The ${var:+...} and ${array[@]+...} forms avoid expanding an empty array, + # which Bash 3.2 treats as an unbound variable when nounset is enabled. + docker run --rm ${docker_tty_arg:+"$docker_tty_arg"} \ -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[@]}" + bash "$preset" ${EXTRA_ARGS[@]+"${EXTRA_ARGS[@]}"} } build_preset() { local preset="$1" local preset_slug log_file bin_file flash_size ram_size status - preset_slug="${preset,,}" + # macOS ships Bash 3.2, which does not support Bash 4's ${var,,} syntax. + preset_slug="$(printf '%s' "$preset" | tr '[:upper:]' '[:lower:]')" log_file="$(mktemp)" bin_file="" From a73a606b4b6abb4e10d4c9da5db1090d00f217b0 Mon Sep 17 00:00:00 2001 From: Armel FAUVEAU Date: Wed, 9 Sep 2026 21:49:25 +0200 Subject: [PATCH 72/73] Prepare new version --- archive/apps/v6.0.0/Beacon.app | Bin 0 -> 3540 bytes archive/apps/v6.0.0/Beam.app | Bin 0 -> 1164 bytes archive/apps/v6.0.0/Breakout.app | Bin 0 -> 2612 bytes archive/apps/v6.0.0/BroadcastFM.app | Bin 0 -> 3296 bytes archive/apps/v6.0.0/Cube3D.app | Bin 0 -> 3000 bytes archive/apps/v6.0.0/FoxHunt.app | Bin 0 -> 3412 bytes archive/apps/v6.0.0/Plasma.app | Bin 0 -> 2224 bytes archive/apps/v6.0.0/Tetris.app | Bin 0 -> 3364 bytes archive/apps/v6.0.0/TripleVFO.app | Bin 0 -> 3596 bytes archive/f4hwn.fieldops.v6.0.0.bin | Bin 0 -> 113020 bytes archive/f4hwn.fusion.v6.0.0.bin | Bin 0 -> 106892 bytes archive/f4hwn.labs.v6.0.0.bin | Bin 0 -> 111908 bytes archive/f4hwn.transfer.v6.0.0.bin | Bin 0 -> 111284 bytes 13 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 archive/apps/v6.0.0/Beacon.app create mode 100644 archive/apps/v6.0.0/Beam.app create mode 100644 archive/apps/v6.0.0/Breakout.app create mode 100644 archive/apps/v6.0.0/BroadcastFM.app create mode 100644 archive/apps/v6.0.0/Cube3D.app create mode 100644 archive/apps/v6.0.0/FoxHunt.app create mode 100644 archive/apps/v6.0.0/Plasma.app create mode 100644 archive/apps/v6.0.0/Tetris.app create mode 100644 archive/apps/v6.0.0/TripleVFO.app create mode 100755 archive/f4hwn.fieldops.v6.0.0.bin create mode 100755 archive/f4hwn.fusion.v6.0.0.bin create mode 100755 archive/f4hwn.labs.v6.0.0.bin create mode 100755 archive/f4hwn.transfer.v6.0.0.bin diff --git a/archive/apps/v6.0.0/Beacon.app b/archive/apps/v6.0.0/Beacon.app new file mode 100644 index 0000000000000000000000000000000000000000..8b65fe0088c69a66e885427f62a441cf52ad192d GIT binary patch literal 3540 zcmb_eYiu0V6+U-n-;V2N67MUSov{HX=@GLQcOtiiWMl9jlB^;Hv8O%%Aq$R#;=K`+P?VqqIE1F>?LY)l zJ7$W@$Sqe?NZmaHRnyuj>8Y3(zwk8Lep>P>NnMbiG3?B6g4_wc1{UNE@SK6zes>&1 zQ&N!2;l+79-?7+9D2zWL#Ts3mk4x?oAcFSzrj3%xnuHRbW%SU zHCJ6iOBK^B?2i%uw!2))KXj&#)LvAXZWr9U$yu2<#sw$Ty|L$a!RjU21+E@##L&ii zjp>EdA7@I2(W}KzwAvM&Wg;A~1?p#$P-QdpUz)kVL8=np z|5b9yiJKswHKGN4Kw2GjDfW1+ zPvMZ%oZX`N!G;+aL9beZ7%dpBepretLyeRK+A+@s~rq=2ccoH2#o0 zGI>p~4X}!-AFD9ls8}#BPby}7o>yp&*!u-}Csxpn?^0hU4)jKH22(>`$KW932d(qDDUXDeBg3W@=+F{eU%YrKj>@Md;~-S|nP=if}KqQ|SBy{|2E*5ivfDX2m}FQYTmkq5|UQ z+SfYu{f3{0&j6fj7wsV>Lj1$(luoOZ{$mwVL;Ptyov;9sO=&qee306By(8?e`Dgq5 zzjL$IK-QSSUY$3#M0RZ^R+NNZ$`-#IsAS#?RfqJxm1=m~+A zKlIM7Kev;>ln&sK(h0Ym3(NN}0TX1?i7XL1p}b@mtyKUy1wBY#!tWyKq2VyiMJlnT z<%ffU+>d>Z`^KkB;APU|3_=g%Tiv{PKkx&%(vJ}lrBzX*S2fYdvNc&56=XaAlR$|a z_Gi4!W}Mttm~gTK9z ztUaLqEHtgLSn*}EG(rQj+ke@qCpw*RBz(`4?42x=B!nC%-7gB;;ICT z!1Ru&XCr2;#rb{JV?EFs`!SBR-X6p8?2ib%gJXWe)Pg(0ju2!wKJypeQ4;1-ORIwu zWm4zv!2QaXRhz+@u<9#K8KUvZPZUad<>n;#3Sv&Bpc7|?T*#X8R~g2xO$2*Eh(`9S4iBiIMB z>C1D)KHyES>@0EJVG~RnqwW!ao~Uh&Jfd2H_JOmJJd7eIjwQ+3i~477Q%^_OeT>B? z+vk4d=V(pvOsFoyni+HZ##}K6y!njj6?e&TV67TXTda+>-9UE>v%R!Dhf0+9~2#9{Uz_K*2&`Llrd|; z*{kBUaYi9R3BZt9oE|p^ujsjyg(|EXWg>dM7b#z0kI(8BZ!Ev=f`B*1{DOM1d z>aXgQGbsmA-lX-APR#hc(n(l$!Yh4Ug7=ox6VvBax?3~$p|fq1e7k%Bd7@LC(Li{w zV{x+!i0^EqxMi`#!Sf+z%r6u*3rYoM;N{F=0m@hqPJ2!?Nz`LgLZGfizxEwFk=GAQR8%n^$Ekvq<0 zvgsGH^-@VZo9>yrHA<`Cp3G(m%A7c}J5#r{24}vVG4p1{1)?nC|tE?lN$rfh7Y|Z8Dl;;PYr5^%+Coz+DDzG_Yh~>H+E-_}z== z(JOp|7;hN3%fO8WmJCci$2Sao9`}O!VCWmT%fNJxWK=S6g9|Q_@V18huoB7YB!NAc zvP?19r%;hjeqUE2G%len4UKJEp?wE5Hf-Al?QL7PK(wi43(jzG*nk{_+wf0gLrV*^ zw$X*Qa2UAOwk@#vc*Bt+M;<-C`B-=h{e_Qt_y!LTHjl>!yr+TpY>94fUVCfxO2NnZ kH`Hx5m$()&h2P3IEzK>qTJn~79X8fpkmGcF;Q#mgFM;lDiU0rr literal 0 HcmV?d00001 diff --git a/archive/apps/v6.0.0/Beam.app b/archive/apps/v6.0.0/Beam.app new file mode 100644 index 0000000000000000000000000000000000000000..39e3941062fad9303c45fde956a12034706bca38 GIT binary patch literal 1164 zcmb_b|7#m%7=PZ&w|;RWnxQqZaLKJ}=-HC?7*6ZT^=xvvzDZirI^((@x*x=wneO^q zI*XhWX+*I~D#itdunoZomS7NunMHpQad0mqJ(t=1;A)jGh+MDC`N9Q)L;VVE)h& zR9A_?)id7>6I~CP|#jkMg^8xL}S-w~qI4&(rKR@dh0-B(@g(j*Z+@uE9 z&mD;5knvkBQn?^qG6MX^2LE41x$lTKdin-f2Y3B$x0=s?1YlVm5=kiig$1!+gYXP} zR7=u0a{Z+vW&~90F+>_H&N>{|+O}6An;D)wX$D~`{o5iDlKfvLGc3`g8D5uY45~V( ze!ha}$LyijS)8Qkql>eiK zm7^~7e+boxZ9zT2hVo_kHZmZr?FlN5JVssNp!Nv8)CN>P+P=r~Sih7Pp)4P2dm|5G zt*i1%+y2*JoN04vpK}YR_Bfs~6(TfiOlbl;fm0n9BfVGy98)3}e!sPySn~q=D&jT7 zcZjoyRYU=yBd%2eiWsl#09?L`oeNzY0N1MB?&Yi9?vi+4v6qlm+ZDTIu8`1FBWWAE=1!-szq1Fm0n=kfo~)4wL@U4{Sv literal 0 HcmV?d00001 diff --git a/archive/apps/v6.0.0/Breakout.app b/archive/apps/v6.0.0/Breakout.app new file mode 100644 index 0000000000000000000000000000000000000000..8fa79657a43b2d57277a45cecff47a3492406062 GIT binary patch literal 2612 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 K^8NAufd2w^I3U#k literal 0 HcmV?d00001 diff --git a/archive/apps/v6.0.0/Cube3D.app b/archive/apps/v6.0.0/Cube3D.app new file mode 100644 index 0000000000000000000000000000000000000000..f007a85b70c36fc8ffc47133a7ff2bb80b9a1a61 GIT binary patch literal 3000 zcmb_eeQZIcHXs9 zG40-xBLPDCjQ}T)o#+8zL3#f0vARb1?AiEq?Ppj60}Qy9 z91GVcTh*(sLtO3HH{#9Sh;2YO3s%|!=+Js%$|IdPRej+czQTAy~Y{&8Stos1bk|!-V+(`0uP@EMs7&2 zbPa5JOMaLkbpti`Gv5#ClI1bGw^J!kW~Jq^o|h!twZmhM9S>mcekrNtOi$arx+I^{ zqK4|T5_l}&JgowQCv!($=^~pZ(lL2)!tNn4;?*Ti?QT&9Bt8i-#%(__8UfFbn~!5B zRP2N|;>2H@3N_XYm;72N+iOYg7bc`=+7c~GPDo)yWl>URP4);`Nlja0y|}Z`FN=#) zM;+dX540}2^Qdwroaaya_px;kEM07 zt$Eg6ntV}d4lyoupOEU7_U|QL%iio>KG~>rD^{nFirS7Se9GT;ND)&`$q-j<`;=B? zwIs$b*%ZYP|2FuQ9*1fxi5cT2^^nr3j5gEC${uaF4C`_le?@Ggt#QBh&MJQVM1g?oKt(&X|{$C!Cp z%(6QOXZy^pLf@Nx;z7emg~K#*Rwj3UgR>#=s=V{=mmJ2C(hDUQ&dRL1HeAk`RZ=%IDZ@uQZAy;I@zOiJ^@ZBg+!7o*B0%*2`s?mut+E4b>%+diMq>^9}iC()#K(Qxyp@ zG8DMc>sF2iKI)3gOvaEHk=Kli31cbB`@EqoV>wsBU%WwHh+IP^ANJNOf9SfICtDt;x8O%)>InPKQanhpLUD*V+}ya`+a z4^WGLn;iOXq5V1zUI5sPJ_Gtj(0(2VhXFqH;j0!7pe^DMOaQIu)1&WWBe?eCFxdgR zigBCK&Kl5H3UH_xcLnWlYCfzHK_5{&gl$;}Cz0Yf;w zQHN>f=3q_&X$~X_5Ef>1Jh^~%3jzVHR8)=AC}Hk`qAbxkAqy%VSCCR9R6L$&by;13 zjKEOs^$bm>$IfVLnm)LlrF=z>rWQ1USb?Y8jaoketSiBK4?bN9pWb@{0(vzUUBlBB zn@zLn3Jmqdo-Z|ns_ZoiQaI@i) NhDQzHLkgeU^Ir#&<8lB1 literal 0 HcmV?d00001 diff --git a/archive/apps/v6.0.0/FoxHunt.app b/archive/apps/v6.0.0/FoxHunt.app new file mode 100644 index 0000000000000000000000000000000000000000..4ece1f61afb05b60337478d3ec9a8a86cb9971b6 GIT binary patch literal 3412 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 RW%b#8`_jMMf&cRk`~&we> literal 0 HcmV?d00001 diff --git a/archive/apps/v6.0.0/Tetris.app b/archive/apps/v6.0.0/Tetris.app new file mode 100644 index 0000000000000000000000000000000000000000..341fa221cf43b63ab1fb9371e24672504600e904 GIT binary patch literal 3364 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 literal 0 HcmV?d00001 diff --git a/archive/apps/v6.0.0/TripleVFO.app b/archive/apps/v6.0.0/TripleVFO.app new file mode 100644 index 0000000000000000000000000000000000000000..017c7139fe5ba03355b26cabf0274705df4dcc30 GIT binary patch literal 3596 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 A&Hw-a literal 0 HcmV?d00001 diff --git a/archive/f4hwn.fieldops.v6.0.0.bin b/archive/f4hwn.fieldops.v6.0.0.bin new file mode 100755 index 0000000000000000000000000000000000000000..ddbcef14fd8076638720e21b50abdcdaa236793d GIT binary patch literal 113020 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 literal 0 HcmV?d00001 diff --git a/archive/f4hwn.labs.v6.0.0.bin b/archive/f4hwn.labs.v6.0.0.bin new file mode 100755 index 0000000000000000000000000000000000000000..a117881d9113b51b475eedc5ed85794a5dbb177b GIT binary patch literal 111908 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 Date: Wed, 9 Sep 2026 21:52:25 +0200 Subject: [PATCH 73/73] Update donors --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 78ebce31..012d28dd 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ Anyway, have fun. # Donations -Special thanks to Jean-Cyrille F6IWW (3 times), Fabrice 14RC123, David F4BPP, Olivier 14RC206, Frédéric F4ESO, Stéphane F5LGW (2 times), Jorge Ornelas (4 times), Laurent F4AXK, Christophe Morel, Clayton W0LED, Pierre Antoine F6FWB, Jean-Claude 14FRS3306, Thierry F4GVO, Eric F1NOU, PricelessToolkit, Ady M6NYJ, Tom McGovern (4 times), Joseph Roth, Pierre-Yves Colin, Frank DJ7FG, Marcel Testaz, Brian Frobisher, Yannick F4JFO, Paolo Bussola, Dirk DL8DF, Levente Szőke (2 times), Bernard-Michel Herrera, Jérôme Saintespes, Paul Davies, RS (3 times), Johan F4WAT, Robert Wörle, Rafael Sundorf, Paul Harker, Peter Fintl, Pascal F4ICR (2 times), Mike DL2MF (3 times), Eric KI1C / F4WFS (3 times), Phil G0ELM, Jérôme Lambert, Eliot Vedel, Alfonso EA7KDF, Jean-François F1EVM, Robert DC1RDB (2 times), Ian KE2CHJ, Daryl VK3AWA, Roberto Brunelli, Robert Boardman, Stephen Oliver, Nicolas F4INE, William Bruno, Daniel OK2VLK, Tayler Chew, Peter DL7RFP, Philippe Kopp, Rune LA6YMA, Jeremy Luna, Steef Wagenaar (2 times), Zhuo BG7SGA, Jamie M0JLB, Antoine LIBERT, Vince K0DKR, Julia DF7JA, Ken 2E0UMK, Victor TI2SYS, Tobi DG9LAY, Deaglan K4DFQ, Catherine PALMER, Brian WA6JFK, Stéphane Hintzy, Roger F1HCN, Marcin Kusaj, Flavio Cottarelli, Bob N1MLZ, Carlos EA1IJ, Brian M7YLF, Giuseppe IT9LLH and 邓 月 for their [donations](https://www.paypal.com/paypalme/F4HWN). That’s so kind of them. Thanks so much 🙏🏻 +Special thanks to Jean-Cyrille F6IWW (3 times), Fabrice 14RC123, David F4BPP, Olivier 14RC206, Frédéric F4ESO, Stéphane F5LGW (2 times), Jorge Ornelas (4 times), Laurent F4AXK, Christophe Morel, Clayton W0LED, Pierre Antoine F6FWB, Jean-Claude 14FRS3306, Thierry F4GVO, Eric F1NOU, PricelessToolkit, Ady M6NYJ, Tom McGovern (4 times), Joseph Roth, Pierre-Yves Colin, Frank DJ7FG, Marcel Testaz, Brian Frobisher, Yannick F4JFO, Paolo Bussola, Dirk DL8DF, Levente Szőke (2 times), Bernard-Michel Herrera, Jérôme Saintespes, Paul Davies, RS (3 times), Johan F4WAT, Robert Wörle, Rafael Sundorf, Paul Harker, Peter Fintl, Pascal F4ICR (2 times), Mike DL2MF (3 times), Eric KI1C / F4WFS (3 times), Phil G0ELM, Jérôme Lambert, Eliot Vedel, Alfonso EA7KDF, Jean-François F1EVM, Robert DC1RDB (2 times), Ian KE2CHJ, Daryl VK3AWA, Roberto Brunelli, Robert Boardman, Stephen Oliver, Nicolas F4INE, William Bruno, Daniel OK2VLK, Tayler Chew, Peter DL7RFP, Philippe Kopp, Rune LA6YMA, Jeremy Luna, Steef Wagenaar (2 times), Zhuo BG7SGA, Jamie M0JLB, Antoine LIBERT, Vince K0DKR, Julia DF7JA, Ken 2E0UMK, Victor TI2SYS, Tobi DG9LAY, Deaglan K4DFQ, Catherine PALMER, Brian WA6JFK, Stéphane Hintzy, Roger F1HCN, Marcin Kusaj, Flavio Cottarelli, Bob N1MLZ, Carlos EA1IJ, Brian M7YLF, Giuseppe IT9LLH, 邓 月 and Jon M1JRH for their [donations](https://www.paypal.com/paypalme/F4HWN). That’s so kind of them. Thanks so much 🙏🏻 ## Table of Contents