mirror of
https://github.com/armel/uv-k1-k5v3-firmware-custom.git
synced 2026-10-02 11:08:20 +00:00
Add Spectrum3D app
This commit is contained in:
1 parent
9d5b3af7a3
commit
cc1439dcc2
4 files changed
+815
No files matched your search
@@ -0,0 +1,30 @@
|
||||
/* Spectrum3D 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,
|
||||
"Spectrum3D overlay app overflows the 4 KiB overlay")
|
||||
|
||||
/DISCARD/ : { *(.ARM.exidx*) *(.ARM.extab*) *(.eh_frame*) *(.comment) *(.note.*) }
|
||||
}
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
APP="$(basename "$PWD")"
|
||||
APP_NAME="Spectrum3D"
|
||||
APP_VER="1.0"
|
||||
APP_API_MIN=2
|
||||
APP_VMA=${APP_VMA:-0x20000280}
|
||||
OUT="${APP_NAME// /}"
|
||||
|
||||
CC=/opt/toolchain/bin/arm-none-eabi-gcc
|
||||
OBJCOPY=/opt/toolchain/bin/arm-none-eabi-objcopy
|
||||
command -v arm-none-eabi-gcc >/dev/null 2>&1 && { CC=arm-none-eabi-gcc; OBJCOPY=arm-none-eabi-objcopy; }
|
||||
|
||||
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/4] %-8s' "$APP_NAME" "$1" "$2"; }
|
||||
trap 'printf "\r ❌ %-13s build failed \n" "$APP_NAME"' ERR
|
||||
|
||||
step 1 assets ; python3 ./gen_assets.py "${APP}_assets.bin" "${APP}_assets.h"
|
||||
step 2 compile ; "$CC" $CFLAGS $LDFLAGS "${APP}_app.c" -lgcc -o "${APP}.elf"
|
||||
step 3 objcopy ; "$OBJCOPY" -O binary "${APP}.elf" "${APP}.bin"
|
||||
step 4 pack ; python3 ../pack_app.py "${APP}.bin" "${OUT}.app" \
|
||||
--name "$APP_NAME" --ver "$APP_VER" --api-min "$APP_API_MIN" \
|
||||
--vma "${APP_VMA}" --screensaver --assets "${APP}_assets.bin" >/dev/null
|
||||
trap - ERR
|
||||
|
||||
BYTES=$(wc -c < "${APP}.bin")
|
||||
if [ "$BYTES" -gt 4096 ]; then
|
||||
printf '\r 🚨 %-13s OVERFLOWS 4 KiB (%d B) \n' "$APP_NAME" "$BYTES"; exit 1
|
||||
fi
|
||||
printf '\r ✅ %-13s %4d B (%d%% of 4 KiB) -> %s.app \n' \
|
||||
"$APP_NAME" "$BYTES" "$(( BYTES * 100 / 4096 ))" "$OUT"
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
# Spectrum3D read-only assets: texts, bitmaps and the setting tables.
|
||||
#
|
||||
# ./gen_assets.py spectrum3d_assets.bin spectrum3d_assets.h
|
||||
import math, os, struct, sys
|
||||
sys.dont_write_bytecode = True
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
||||
from app_assets import Assets
|
||||
|
||||
# One record per span of the 64-point sweep. The app reads a record whole into
|
||||
# its struct globals, from `step` on, so the fields must keep the order and
|
||||
# sizes of that struct (the app checks the total with a static assert):
|
||||
# step u16 point spacing (x10 Hz)
|
||||
# rx_bw u16 REG_43 RX filter: the resident spectrum's scanStepBWRegValues
|
||||
# for the same steps (App/app/spectrum.h)
|
||||
# substeps u8 measurements per point, 1 or 2 (the app shifts, never
|
||||
# divides): 25 kHz is the widest filter, so the 50 kHz step
|
||||
# also measures halfway and keeps the maximum
|
||||
# close_db u8 listening closes below the sweep floor plus this many dB:
|
||||
# the audio uses the VFO's own filter (up to 25 kHz), whose
|
||||
# noise sits higher than the narrower sweep filters (+6 dB
|
||||
# over 6.25 kHz, +3 dB over 12.5 kHz, on top of 5 dB)
|
||||
# label the span capsule: LABEL_LEN characters + NUL
|
||||
SPANS = [("0.4M", 625, 0x4858, 1, 11), # 6.25 kHz filter
|
||||
("0.8M", 1250, 0x7F08, 1, 8), # 12.5 kHz filter
|
||||
("1.6M", 2500, 0x3628, 1, 5), # 25 kHz filter
|
||||
("3.2M", 5000, 0x3628, 2, 5)] # 25 kHz filter, two points per step
|
||||
LABEL_LEN = 4
|
||||
if any(len(s[0]) != LABEL_LEN or s[3] not in (1, 2) for s in SPANS):
|
||||
sys.exit("SPANS: labels must have LABEL_LEN characters and substeps be 1 or 2")
|
||||
RECORDS = [struct.pack(f"<HHBB{LABEL_LEN + 1}s", step, bw, sub, close, label.encode("ascii"))
|
||||
for label, step, bw, sub, close in SPANS]
|
||||
|
||||
# RX regions the sweep must stay inside (x10 Hz, inclusive): the RF path
|
||||
# switches VHF/UHF at 280 MHz (BK4819_PickRXFilterPathBasedOnFrequency) and the
|
||||
# chip covers 18..630 and 840..1300 MHz (App/frequencies.c, RX_freq_check).
|
||||
REGIONS = [(1800000, 27999999), (28000000, 62999999), (84000000, 130000000)]
|
||||
|
||||
# Saved settings, in the order of the app's struct globals: magic, span,
|
||||
# speed, yaw (signed), pitch. The defaults: 1.6 MHz, a line per sweep, front
|
||||
# view tilted 25 degrees. 0x3E configs stored yaw + 9: the new magic resets them.
|
||||
CFG_MAGIC = 0x3F
|
||||
PITCH_DEF = 5 # 25 degrees
|
||||
SPEED_COUNT = 3 # 1 << speed sweeps per landscape line: 1, 2, 4 (peak-held)
|
||||
|
||||
a = Assets("SPECTRUM3D")
|
||||
a.text("T_TITLE", "SPECTRUM3D")
|
||||
a.text("T_HOLD", "HOLD")
|
||||
a.raw("SPAN_REC", b"".join(RECORDS))
|
||||
a.const("SPAN_REC_SIZE", len(RECORDS[0]))
|
||||
a.const("SPAN_LABEL_LEN", LABEL_LEN)
|
||||
a.const("SPAN_COUNT", len(SPANS))
|
||||
a.u32("REGION", [edge for region in REGIONS for edge in region])
|
||||
a.const("REGION_COUNT", len(REGIONS))
|
||||
a.u8("CFG_DEFAULT", [CFG_MAGIC, 2, 0, 0, PITCH_DEF])
|
||||
a.const("CFG_MAGIC", CFG_MAGIC)
|
||||
a.const("PITCH_DEF", PITCH_DEF)
|
||||
a.const("SPEED_COUNT", SPEED_COUNT)
|
||||
a.u8("BMP_F", [0x3e,0x7f,0x41,0x75,0x75,0x75,0x7d,0x7f,0x3e]) # F-armed icon, as Beacon
|
||||
a.u8("BMP_SPEAKER", [0x1c,0x1c,0x3e,0x7f,0x00,0x22,0x1c,0x41,0x22,0x1c]) # as FoxHunt
|
||||
# sin() in Q8 from -45 to 135 degrees by 5, 0 degrees at SINQ_ZERO: the yaw
|
||||
# (-45..45), the pitch (10..60) and their cosines, sin(90 - angle), index it
|
||||
# directly, with no sign handling in the app.
|
||||
a.i16("SINQ", [round(math.sin(math.radians(d)) * 256) for d in range(-45, 136, 5)])
|
||||
a.const("SINQ_ZERO", 9)
|
||||
a.main()
|
||||
@@ -0,0 +1,678 @@
|
||||
/* 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.
|
||||
*/
|
||||
|
||||
/*
|
||||
* Spectrum3D — overlay app: the band around the VFO as a ridge-line landscape
|
||||
* ("Unknown Pleasures" style). Each sweep of 64 points becomes one line of a
|
||||
* frequency x time grid; the newest line is nearest. The grid is rotated about
|
||||
* its vertical axis (yaw) and tilted (pitch), projected in perspective, and
|
||||
* drawn far to near with hidden-line removal: a line clears everything below
|
||||
* it, so near "mountains" hide the lines behind them. Peaks are stations.
|
||||
*
|
||||
* The receiver runs at fixed gain so a strong signal does not pull the others
|
||||
* down, and heights are compressed (soft saturation above a noise gate over a
|
||||
* floor taken from the quiet points) so weak stations stay visible next to
|
||||
* strong ones. Retuning goes through the radio registers. The window is kept
|
||||
* on the VFO's side of the 280 MHz VHF/UHF RF-path switch and inside the
|
||||
* chip's RX range (never in the 630-840 MHz hole), so the path chosen by the
|
||||
* loader stays valid; the RX filter bandwidth follows the point spacing, as in
|
||||
* the resident spectrum. The history lives on app_main's stack; texts, tables
|
||||
* and bitmaps are read-only assets.
|
||||
*
|
||||
* Keys: 1 / F+1 centre up / down one point (the landscape slides) ·
|
||||
* 3 / F+3 wider / narrower span (UP/DOWN too) · 4/6 rotate · 2/8 tilt ·
|
||||
* 5 reset view · 0 listen to the strongest peak (FM, speaker icon; the
|
||||
* landscape goes on with that station alone) · STAR speed · MENU hold ·
|
||||
* EXIT quit.
|
||||
*/
|
||||
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
#include <stddef.h>
|
||||
#include "../app_api.h"
|
||||
#include "spectrum3d_assets.h" /* generated by gen_assets.py */
|
||||
|
||||
#define W 128u
|
||||
#define FB_H 56u /* frame-buffer rows (below the status line) */
|
||||
#define BINS 64u /* points per sweep, 2 px apart */
|
||||
#define LINES 12u
|
||||
#define ACC LINES /* history row of the next line */
|
||||
/* Projection, tuned on a grid of X -63..63 (frequency) by Z -44..44 (time)
|
||||
* with the frame around it: camera distance, horizontal and vertical focal
|
||||
* lengths, screen centre row. */
|
||||
#define CAM_D 200
|
||||
#define FOCAL_X 140
|
||||
#define FOCAL_Y 200
|
||||
#define CENTRE_Y 26
|
||||
/* The frame around the landscape, on the floor: half width, half depth. */
|
||||
#define BOARD_X 67
|
||||
#define BOARD_Z 48
|
||||
#define HEIGHT_Q 64 /* tallest peak, quarter units */
|
||||
#define COMPRESS 12 /* dB where a peak reaches half */
|
||||
#define NOISE_GATE 4 /* dB above the floor ignored */
|
||||
#define YAW_MAX 9 /* 45 degrees, 5-degree steps */
|
||||
#define PITCH_MIN 2u /* 10 degrees (default: assets) */
|
||||
#define PITCH_MAX 12u /* 60 degrees */
|
||||
#define NO_Y 0x7FFF
|
||||
/* Wait after a retune before reading RSSI: the RSSI register lags the new
|
||||
* frequency, and 1 ms left each reading on the points already passed, which
|
||||
* shifted every signal several points up the sweep (e.g. a station 5 points
|
||||
* below the centre showed at the centre). The lag only matters next to a
|
||||
* signal, so a quiet point is read after SETTLE_FAST_MS; the full SETTLE_MS
|
||||
* is waited after a point above SIGNAL_DB (falling edge) and on a point
|
||||
* that already rises above it (rising edge). */
|
||||
#define SETTLE_MS 3u
|
||||
#define SETTLE_FAST_MS 1u
|
||||
/* Level above the floor that counts as a signal for the settle choice. The
|
||||
* display's NOISE_GATE is too low for this: noise alone crosses it on many
|
||||
* points and each crossing cost two full waits. */
|
||||
#define SIGNAL_DB 10u
|
||||
/* Listening to the strongest peak: audio path settle (as FoxHunt), RSSI check
|
||||
* period (also a landscape line, as a sweep would be) and how many quiet
|
||||
* checks in a row close it (about a second). */
|
||||
#define AUDIO_SETTLE_MS 60u
|
||||
#define LISTEN_TICK_MS 100u
|
||||
#define LISTEN_HANG 8u
|
||||
/* Centring on the station before listening: settle per measurement (well
|
||||
* past the RSSI lag) and the finest offset of the search (2.5 kHz). */
|
||||
#define REFINE_SETTLE_MS 10u
|
||||
#define REFINE_MIN 250u
|
||||
/* The chip's squelch, set up by the loader with the VFO's own thresholds, is
|
||||
* what opens the audio in VFO mode: listening waits up to SQL_WAIT x 10 ms
|
||||
* for it after tuning, and closes when it is lost. */
|
||||
#define SQL_WAIT 15u
|
||||
#define REG_FREQ_LO 0x38u
|
||||
#define REG_FREQ_HI 0x39u
|
||||
#define REG_CTRL 0x30u
|
||||
#define REG_GLITCH 0x63u
|
||||
#define REG_RSSI 0x67u
|
||||
#define REG_RX_BW 0x43u
|
||||
#define REG_STATUS 0x0Cu /* <1>: squelch result, 1 = link */
|
||||
#define CTRL_AF_DAC (1u << 9) /* REG_30: AF DAC enable */
|
||||
#define BUF_LEN 12u /* a text, or "1300.00000" */
|
||||
|
||||
/* History, one instance on app_main's stack (outside the overlay). Row ACC
|
||||
* gathers the next line; its floor stays 0 (the flat edge of a slide). */
|
||||
typedef struct {
|
||||
uint8_t line[LINES + 1u][BINS]; /* dBm + 160, ring buffer + ACC */
|
||||
uint8_t floor[LINES + 1u]; /* each line's quiet level */
|
||||
} state_t;
|
||||
|
||||
/* All the state in one struct: Thumb-1 code then reaches every field from a
|
||||
* single base address (one literal per function, instead of one per global),
|
||||
* byte fields first so their offsets fit ldrb's 0..31 immediate. */
|
||||
struct globals {
|
||||
/* saved settings, in CFG_DEFAULT's order: loaded and saved whole */
|
||||
uint8_t magic, span, speed;
|
||||
int8_t yaw;
|
||||
uint8_t pitch;
|
||||
uint8_t head, sweeps, prev_key, gate, close_lvl, quiet, peak_bin;
|
||||
bool running, hold, saver, abort_sweep, farm, listen, listening;
|
||||
/* the span's SPAN_REC record, read whole: same order and sizes */
|
||||
uint16_t step, scan_bw;
|
||||
uint8_t substeps, close_db;
|
||||
char label[SPAN_LABEL_LEN + 1u];
|
||||
uint16_t saved_bw; /* the VFO's own RX filter */
|
||||
/* words: Thumb-1 loads them with an immediate offset, while a signed
|
||||
halfword needs a register offset or an extra sign extension */
|
||||
int32_t ys, yc, ps, pc; /* view angles (Q8) for project() */
|
||||
int32_t side[4]; /* frame: left x, y, right x, y */
|
||||
const app_api_t *api;
|
||||
state_t *st;
|
||||
uint32_t centre, first, last_ok; /* requested centre, window start, region top */
|
||||
uint32_t peak_f; /* strongest point of the sweep (at peak_bin), 0 = none */
|
||||
};
|
||||
static struct globals g;
|
||||
#define A (g.api)
|
||||
#define S (g.st)
|
||||
|
||||
_Static_assert(offsetof(struct globals, pitch) + 1u - offsetof(struct globals, magic)
|
||||
== CFG_DEFAULT_LEN, "saved settings do not match CFG_DEFAULT");
|
||||
_Static_assert(offsetof(struct globals, label) + SPAN_LABEL_LEN + 1u
|
||||
- offsetof(struct globals, step) == SPAN_REC_SIZE,
|
||||
"span fields do not match SPAN_REC");
|
||||
_Static_assert(offsetof(struct globals, listening) < 32u, "byte fields out of ldrb range");
|
||||
_Static_assert(TEXT_MAX <= BUF_LEN, "texts do not fit the text buffer");
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
static uint8_t slen(const char *s)
|
||||
{
|
||||
uint8_t n = 0;
|
||||
while (s[n])
|
||||
n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
/* Frequency in 10 Hz units as MHz with 5 decimals. Signed arithmetic on
|
||||
* purpose: every division in the app then uses __aeabi_idiv, and the
|
||||
* unsigned one (~280 B of libgcc) stays out of the 4 KiB overlay. */
|
||||
static char *put_freq(char *o, int32_t v)
|
||||
{
|
||||
char t[10];
|
||||
uint8_t n = 0;
|
||||
do { t[n++] = (char)('0' + v % 10); v /= 10; } while (v || n < 6u);
|
||||
while (n) {
|
||||
*o++ = t[--n];
|
||||
if (n == 5u)
|
||||
*o++ = '.';
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
/* ---- radio --------------------------------------------------------------- */
|
||||
|
||||
/* Same retune as the spectrum's SetF(), minus the filter path: the sweep
|
||||
* stays in the VFO's band, whose path the loader already selected. */
|
||||
static void tune(uint32_t f)
|
||||
{
|
||||
A->bk_write(REG_FREQ_LO, (uint16_t)f);
|
||||
A->bk_write(REG_FREQ_HI, (uint16_t)(f >> 16));
|
||||
const uint16_t ctrl = A->bk_read(REG_CTRL);
|
||||
A->bk_write(REG_CTRL, 0);
|
||||
A->bk_write(REG_CTRL, ctrl);
|
||||
}
|
||||
|
||||
/* dBm + 160 after `ms` of settling (as the spectrum's GetRssi()). */
|
||||
static uint8_t measure(uint8_t ms)
|
||||
{
|
||||
A->delay_ms(ms);
|
||||
for (uint8_t guard = 50u; guard && (A->bk_read(REG_GLITCH) & 0xFFu) >= 200u; guard--)
|
||||
;
|
||||
A->bk_read(REG_RSSI); /* first read may still move */
|
||||
const int16_t dbm = A->rssi_dbm();
|
||||
return (uint8_t)(dbm < -160 ? 0 : dbm > 95 ? 255 : dbm + 160);
|
||||
}
|
||||
|
||||
/* Place the 64-point window on `centre`, kept inside the centre's region
|
||||
* (same RF path, valid RX range); `centre` then follows the window. */
|
||||
static void place_window(void)
|
||||
{
|
||||
uint32_t edge[2u * REGION_COUNT]; /* inclusive (lo, hi) pairs */
|
||||
A->asset_read(REGION, edge, sizeof(edge));
|
||||
uint32_t lo = 0, hi = 0; /* in no region: all points flat */
|
||||
for (uint8_t r = 0; r < 2u * REGION_COUNT; r += 2u)
|
||||
if (g.centre >= edge[r] && g.centre <= edge[r + 1u]) {
|
||||
lo = edge[r];
|
||||
hi = edge[r + 1u];
|
||||
}
|
||||
/* A region is far wider than any span, so clamping both ends suffices;
|
||||
* with no region, hi - width wraps high and leaves the window alone. */
|
||||
const uint32_t width = (uint32_t)(BINS - 1u) * g.step;
|
||||
g.first = g.centre - (uint32_t)(BINS / 2u) * g.step;
|
||||
if (g.first < lo || g.first > g.centre) /* below the region (or wrapped) */
|
||||
g.first = lo;
|
||||
if (g.first > hi - width)
|
||||
g.first = hi - width;
|
||||
g.last_ok = hi;
|
||||
g.centre = g.first + (uint32_t)(BINS / 2u) * g.step;
|
||||
}
|
||||
|
||||
static void restart(void)
|
||||
{
|
||||
A->asset_read(SPAN_REC + g.span * SPAN_REC_SIZE, &g.step, SPAN_REC_SIZE);
|
||||
A->bk_write(REG_RX_BW, g.scan_bw); /* filter matched to the spacing */
|
||||
place_window();
|
||||
memset(S, 0, sizeof(*S));
|
||||
g.head = 0;
|
||||
g.sweeps = 0;
|
||||
g.abort_sweep = true;
|
||||
}
|
||||
|
||||
/* Move the centre by one point: the history slides by one bin, and the bin
|
||||
* entering at the edge starts flat (at the line's floor). */
|
||||
static void move_centre(bool up)
|
||||
{
|
||||
const uint32_t old_first = g.first;
|
||||
g.centre = up ? g.centre + g.step : g.centre - g.step;
|
||||
place_window(); /* a region edge may refuse the move */
|
||||
if (g.first == old_first)
|
||||
return;
|
||||
const int8_t d = g.first > old_first ? 1 : -1; /* 1: content moves to bin 0 */
|
||||
for (uint8_t r = 0; r <= LINES; r++) { /* the lines and ACC */
|
||||
uint8_t *p = S->line[r] + (d > 0 ? 0u : BINS - 1u);
|
||||
for (uint8_t n = BINS - 1u; n; n--, p += d)
|
||||
*p = p[d];
|
||||
*p = S->floor[r];
|
||||
}
|
||||
g.abort_sweep = true; /* the sweep in progress used the old grid */
|
||||
}
|
||||
|
||||
static void poll_keys(void);
|
||||
|
||||
/* Every 1 << speed sweeps (or listening checks), ACC becomes the newest line
|
||||
* of the landscape and restarts at `fill`: 0 for the peak hold of a sweep, the
|
||||
* floor while listening, when only the station's own point is measured. */
|
||||
static void commit(uint8_t fill)
|
||||
{
|
||||
if (++g.sweeps < (1u << g.speed)) /* 1, 2 or 4 per line */
|
||||
return;
|
||||
g.sweeps = 0;
|
||||
uint8_t *acc = S->line[ACC], *line = S->line[g.head];
|
||||
/* Floor = mean of the points at or below the mean: a strong station
|
||||
raises the plain mean, not this quiet level. */
|
||||
uint16_t sum = 0, low = 0;
|
||||
uint8_t n = 0;
|
||||
for (uint8_t b = 0; b < BINS; b++)
|
||||
sum += line[b] = acc[b];
|
||||
const uint8_t mean = (uint8_t)(sum / BINS);
|
||||
for (uint8_t b = 0; b < BINS; b++)
|
||||
if (line[b] <= mean) {
|
||||
low += line[b];
|
||||
n++;
|
||||
}
|
||||
/* n >= 1: the lowest point is at or below the mean. Signed casts:
|
||||
operands GCC knows to be non-negative may be divided with
|
||||
__aeabi_uidiv, which is not linked (see put_freq). */
|
||||
S->floor[g.head] = (uint8_t)((int16_t)low / (int8_t)n);
|
||||
memset(acc, fill, BINS);
|
||||
g.head = (uint8_t)(g.head + 1u < LINES ? g.head + 1u : 0u); /* now the oldest */
|
||||
}
|
||||
|
||||
/* One 64-point sweep, peak-held into ACC, then committed. */
|
||||
static void sweep(void)
|
||||
{
|
||||
uint8_t *acc = S->line[ACC];
|
||||
g.abort_sweep = false;
|
||||
/* signal level from the newest line's floor; before the first line the
|
||||
* floor is 0 and every point gets the full wait */
|
||||
g.gate = (uint8_t)(S->floor[g.head ? g.head - 1u : LINES - 1u] + SIGNAL_DB);
|
||||
uint8_t prev = 255u; /* the jump back to the start lags too */
|
||||
uint8_t peak_v = g.gate; /* only a signal can be listened to */
|
||||
g.peak_f = 0;
|
||||
/* substeps is 1 or 2: the spacing is a shift, not a division */
|
||||
const uint32_t sub = (uint32_t)(g.step >> (g.substeps - 1u));
|
||||
for (uint8_t b = 0; b < BINS; b++) {
|
||||
if (!(b & 15u)) {
|
||||
poll_keys();
|
||||
if (g.abort_sweep || g.hold || !g.running) {
|
||||
g.peak_f = 0; /* an unfinished sweep is not listened to */
|
||||
return;
|
||||
}
|
||||
}
|
||||
/* past the region's edge nothing is measured: the point stays flat */
|
||||
uint32_t f = g.first + (uint32_t)b * g.step;
|
||||
for (uint8_t k = 0; k < g.substeps && f <= g.last_ok; k++, f += sub) {
|
||||
tune(f);
|
||||
uint8_t m;
|
||||
if (prev > g.gate) /* leaving a signal: full wait */
|
||||
m = measure(SETTLE_MS);
|
||||
else if ((m = measure(SETTLE_FAST_MS)) > g.gate)
|
||||
m = measure(SETTLE_MS - SETTLE_FAST_MS); /* rising: finish here */
|
||||
prev = m;
|
||||
if (m > peak_v) {
|
||||
peak_v = m;
|
||||
g.peak_f = f;
|
||||
g.peak_bin = b;
|
||||
}
|
||||
if (m > acc[b])
|
||||
acc[b] = m;
|
||||
}
|
||||
}
|
||||
commit(0);
|
||||
}
|
||||
|
||||
/* ---- drawing ------------------------------------------------------------- */
|
||||
|
||||
/* Clear column x from row y down to the bottom: the hidden-line removal. */
|
||||
static void clear_below(uint8_t x, int32_t y)
|
||||
{
|
||||
if (y < 0)
|
||||
y = 0;
|
||||
for (; y < (int32_t)FB_H; y = (y | 7) + 1)
|
||||
A->fb[y >> 3][x] &= (uint8_t)((1u << (y & 7)) - 1u);
|
||||
}
|
||||
|
||||
static void plot(uint8_t x, int32_t y)
|
||||
{
|
||||
if ((uint32_t)y < FB_H)
|
||||
A->fb[y >> 3][x] |= (uint8_t)(1u << (y & 7));
|
||||
}
|
||||
|
||||
/* 3x5 capsule on fb line 0. The frame starts at x - 2, so x >= 2 (x = 1
|
||||
* wrapped to fb[0][255], i.e. a stray mark at fb[1][127]); the landscape
|
||||
* under it is cleared first so the XOR inversion stays clean. */
|
||||
static void capsule(const char *s, uint8_t x)
|
||||
{
|
||||
const uint8_t end = (uint8_t)(x + slen(s) * 4u);
|
||||
for (uint8_t i = (uint8_t)(x - 2u); i <= end; i++)
|
||||
A->fb[0][i] = 0;
|
||||
A->print_inverse(s, x, 0, false, true, end);
|
||||
}
|
||||
|
||||
/* Screen point p = {x, y} of the grid point (x, z) raised by h quarter units. */
|
||||
static void project(int32_t x, int32_t z, int32_t h, int32_t *p)
|
||||
{
|
||||
const int32_t xr = (x * g.yc - z * g.ys) >> 8;
|
||||
const int32_t zr = (x * g.ys + z * g.yc) >> 8;
|
||||
const int32_t den = CAM_D + ((zr * g.pc) >> 8);
|
||||
p[0] = 64 + xr * FOCAL_X / den;
|
||||
p[1] = CENTRE_Y - (zr * g.ps * 4 + h * g.pc) * FOCAL_Y / (den << 10);
|
||||
}
|
||||
|
||||
/* Solid or dotted line (every other step) between the points a and b, stepped
|
||||
* along its longer axis with the division already linked. */
|
||||
static void line(const int32_t *a, const int32_t *b, bool dotted)
|
||||
{
|
||||
const int32_t dx = b[0] - a[0], dy = b[1] - a[1];
|
||||
int32_t n = dx < 0 ? -dx : dx;
|
||||
const int32_t m = dy < 0 ? -dy : dy;
|
||||
if (m > n)
|
||||
n = m;
|
||||
if (!n)
|
||||
n = 1;
|
||||
for (int32_t i = 0; i <= n; i += 1 + dotted) {
|
||||
const int32_t x = a[0] + dx * i / n;
|
||||
if ((uint32_t)x < W)
|
||||
plot((uint8_t)x, a[1] + dy * i / n);
|
||||
}
|
||||
}
|
||||
|
||||
/* The frame's sides from the last depth down to z; the new corners are kept
|
||||
* for the next stretch. Painter's order: each stretch goes before the line in
|
||||
* front of it, which then hides what lies behind. */
|
||||
static void frame_sides(int32_t z)
|
||||
{
|
||||
for (uint8_t s = 0; s < 4u; s += 2u) {
|
||||
int32_t p[2];
|
||||
project(s ? BOARD_X : -BOARD_X, z, 0, p);
|
||||
line(g.side + s, p, false);
|
||||
g.side[s] = p[0];
|
||||
g.side[s + 1u] = p[1];
|
||||
}
|
||||
}
|
||||
|
||||
static void draw(void)
|
||||
{
|
||||
char buf[BUF_LEN];
|
||||
|
||||
/* status line: title (or HOLD), the F-armed and listen icons, the battery */
|
||||
A->status_clear();
|
||||
A->asset_read(g.hold ? T_HOLD : T_TITLE, buf, TEXT_MAX);
|
||||
A->print_inverse(buf, 2, 0, true, true, (uint8_t)(2u + slen(buf) * 4u));
|
||||
if (g.farm)
|
||||
A->asset_read(BMP_F, A->status_line + 70, BMP_F_LEN);
|
||||
if (g.listen)
|
||||
A->asset_read(BMP_SPEAKER, A->status_line + 55, BMP_SPEAKER_LEN);
|
||||
A->draw_battery();
|
||||
|
||||
A->display_clear();
|
||||
|
||||
int16_t sinq[SINQ_LEN / 2u]; /* sin() Q8, -45..135 deg by 5 deg */
|
||||
A->asset_read(SINQ, sinq, sizeof(sinq));
|
||||
g.ys = sinq[SINQ_ZERO + g.yaw];
|
||||
g.yc = sinq[SINQ_ZERO + 18u - g.yaw]; /* cos = sin(90 - angle) */
|
||||
g.ps = sinq[SINQ_ZERO + g.pitch];
|
||||
g.pc = sinq[SINQ_ZERO + 18u - g.pitch];
|
||||
|
||||
/* Far to near: the back corners and edge, a stretch of the sides before
|
||||
* each line, then the nearest stretch and the front edge. */
|
||||
project(-BOARD_X, BOARD_Z, 0, g.side);
|
||||
project(BOARD_X, BOARD_Z, 0, g.side + 2);
|
||||
line(g.side, g.side + 2, true); /* back: dotted */
|
||||
for (uint8_t i = 0; i < LINES; i++) { /* oldest (far) to newest (near) */
|
||||
uint8_t row = (uint8_t)(g.head + i);
|
||||
if (row >= LINES)
|
||||
row -= LINES;
|
||||
const int32_t z = ((int32_t)(LINES - 1u) - 2 * i) * 4;
|
||||
frame_sides(z);
|
||||
int32_t pt[BINS][2];
|
||||
bool lit[BINS]; /* point above the noise gate */
|
||||
for (uint8_t b = 0; b < BINS; b++) {
|
||||
const int32_t d = (int32_t)S->line[row][b] - S->floor[row] - NOISE_GATE;
|
||||
lit[b] = d > 0;
|
||||
project(2 * b - 63, z, d > 0 ? HEIGHT_Q * d / (d + COMPRESS) : 0, pt[b]);
|
||||
}
|
||||
/* The line's top edge in every screen column it crosses, kept as
|
||||
* 2y + 1 on the noise and 2y on a signal: the minimum is the topmost
|
||||
* point, and its low bit tells dotted (noise) from solid (signal). */
|
||||
int16_t top[W];
|
||||
for (uint8_t x = 0; x < W; x++)
|
||||
top[x] = NO_Y;
|
||||
for (uint8_t b = 0; b + 1u < BINS; b++) {
|
||||
const int32_t noise = !(lit[b] || lit[b + 1u]);
|
||||
const int32_t *p0 = pt[b], *p1 = pt[b + 1u];
|
||||
if (p1[0] < p0[0]) {
|
||||
const int32_t *t = p0;
|
||||
p0 = p1;
|
||||
p1 = t;
|
||||
}
|
||||
for (int32_t x = p0[0] < 0 ? 0 : p0[0]; x <= p1[0] && x < (int32_t)W; x++) {
|
||||
const int32_t y = p1[0] == p0[0] ? p0[1]
|
||||
: p0[1] + (p1[1] - p0[1]) * (x - p0[0]) / (p1[0] - p0[0]);
|
||||
const int32_t key = 2 * y + noise;
|
||||
if (key < top[x])
|
||||
top[x] = (int16_t)key;
|
||||
}
|
||||
}
|
||||
/* hide what lies below, then draw the edge and close steep slopes:
|
||||
* solid on signals, every other column on the noise floor */
|
||||
int32_t prev = NO_Y;
|
||||
for (uint8_t x = 0; x < W; x++) {
|
||||
const int32_t key = top[x];
|
||||
if (key == NO_Y) {
|
||||
prev = NO_Y;
|
||||
continue;
|
||||
}
|
||||
const int32_t y = key >> 1;
|
||||
clear_below(x, y + 1);
|
||||
if (!(key & 1) || !(x & 1u)) {
|
||||
plot(x, y);
|
||||
if (prev != NO_Y)
|
||||
for (int32_t yy = (y < prev ? y : prev) + 1; yy < (y < prev ? prev : y); yy++)
|
||||
plot(x, yy);
|
||||
}
|
||||
prev = y;
|
||||
}
|
||||
}
|
||||
frame_sides(-BOARD_Z);
|
||||
line(g.side, g.side + 2, false); /* front */
|
||||
|
||||
/* 3x5 capsules over the landscape: the frequency (centre, or the station
|
||||
* listened to) left, the span right */
|
||||
*put_freq(buf, (int32_t)(g.listening ? g.peak_f : g.centre)) = '\0';
|
||||
capsule(buf, 2);
|
||||
capsule(g.label, (uint8_t)(W - 2u - SPAN_LABEL_LEN * 4u));
|
||||
|
||||
A->blit_status();
|
||||
A->blit_full();
|
||||
}
|
||||
|
||||
/* ---- input --------------------------------------------------------------- */
|
||||
|
||||
static void key_press(uint8_t key)
|
||||
{
|
||||
int8_t dir = A->nav_dir(key); /* UP/DOWN: span */
|
||||
const bool shifted = g.farm;
|
||||
g.farm = key == APP_KEY_F && !shifted; /* F arms the next key */
|
||||
if (key == APP_KEY_3)
|
||||
dir = shifted ? -1 : 1; /* 3 wider, F+3 narrower */
|
||||
if (key == APP_KEY_1) {
|
||||
move_centre(!shifted);
|
||||
} else if (key == APP_KEY_EXIT) {
|
||||
g.running = false;
|
||||
} else if (key == APP_KEY_4 && g.yaw > -YAW_MAX) {
|
||||
g.yaw--;
|
||||
} else if (key == APP_KEY_6 && g.yaw < YAW_MAX) {
|
||||
g.yaw++;
|
||||
} else if (key == APP_KEY_2 && g.pitch < PITCH_MAX) {
|
||||
g.pitch++;
|
||||
} else if (key == APP_KEY_8 && g.pitch > PITCH_MIN) {
|
||||
g.pitch--;
|
||||
} else if (key == APP_KEY_5) {
|
||||
g.yaw = 0;
|
||||
g.pitch = PITCH_DEF;
|
||||
} else if (key == APP_KEY_0) {
|
||||
g.listen = !g.listen;
|
||||
} else if (key == APP_KEY_MENU) {
|
||||
g.hold = !g.hold;
|
||||
} else if (key == APP_KEY_STAR) {
|
||||
g.speed = (uint8_t)(g.speed + 1u < SPEED_COUNT ? g.speed + 1u : 0u);
|
||||
} else if (dir && (uint8_t)(g.span + dir) < SPAN_COUNT) {
|
||||
g.span = (uint8_t)(g.span + dir);
|
||||
restart(); /* keeps the centre */
|
||||
}
|
||||
}
|
||||
|
||||
static void poll_keys(void)
|
||||
{
|
||||
uint8_t key = A->get_key();
|
||||
g.saver = key == APP_KEY_SAVER;
|
||||
if (key == APP_KEY_WAKE || g.saver)
|
||||
key = APP_KEY_INVALID;
|
||||
if (key != g.prev_key && key != APP_KEY_INVALID)
|
||||
key_press(key);
|
||||
g.prev_key = key;
|
||||
}
|
||||
|
||||
/* ---- listening ----------------------------------------------------------- */
|
||||
|
||||
/* Centre on the station: the sweep's peak can sit a point or two late (the
|
||||
* RSSI lags the retune, and a weak station only crosses the fast-read gate
|
||||
* after its own point) or between two points (coarse spans, 12.5 kHz
|
||||
* rasters). Hill-climb around it through the listening filter with a full
|
||||
* settle, halving the offset from two points down to REFINE_MIN. */
|
||||
static void refine(void)
|
||||
{
|
||||
uint32_t f = g.peak_f;
|
||||
tune(f);
|
||||
uint8_t best = measure(REFINE_SETTLE_MS);
|
||||
for (uint32_t d = 2u * g.step; d >= REFINE_MIN; d >>= 1) {
|
||||
const uint32_t c = f;
|
||||
for (int8_t s = -1; s <= 1; s += 2) {
|
||||
const uint32_t t = s < 0 ? c - d : c + d;
|
||||
tune(t);
|
||||
const uint8_t m = measure(REFINE_SETTLE_MS);
|
||||
if (m > best) {
|
||||
best = m;
|
||||
f = t;
|
||||
}
|
||||
}
|
||||
}
|
||||
g.peak_f = f; /* shown in the capsule too */
|
||||
}
|
||||
|
||||
static bool squelch_open(void)
|
||||
{
|
||||
return (A->bk_read(REG_STATUS) & 2u) != 0;
|
||||
}
|
||||
|
||||
/* Centre on the sweep's strongest point with the VFO's own RX filter and, if
|
||||
* the chip's squelch opens there, play it as the resident spectrum does (AF
|
||||
* DAC, AF path, FM demodulation); the main loop then checks it until it goes
|
||||
* quiet or a key stops it. Listening and sweeping share the one receiver, so
|
||||
* meanwhile the landscape goes on with that point alone. */
|
||||
static void start_listen(void)
|
||||
{
|
||||
A->bk_write(REG_RX_BW, g.saved_bw);
|
||||
refine();
|
||||
tune(g.peak_f);
|
||||
for (uint8_t t = SQL_WAIT; !squelch_open(); t--) {
|
||||
if (!t) { /* no station there: back to the sweep */
|
||||
A->bk_write(REG_RX_BW, g.scan_bw);
|
||||
return;
|
||||
}
|
||||
A->delay_ms(10);
|
||||
}
|
||||
/* listening lines start flat at the floor: only the station is measured */
|
||||
memset(S->line[ACC], g.gate - SIGNAL_DB, BINS);
|
||||
g.close_lvl = (uint8_t)(g.close_db + g.gate - SIGNAL_DB);
|
||||
g.quiet = 0;
|
||||
g.listening = true;
|
||||
A->bk_write(REG_CTRL, A->bk_read(REG_CTRL) | CTRL_AF_DAC);
|
||||
A->audio_path(true);
|
||||
A->delay_ms(AUDIO_SETTLE_MS);
|
||||
A->set_af(APP_AF_FM);
|
||||
}
|
||||
|
||||
static void stop_listen(void)
|
||||
{
|
||||
A->set_af(APP_AF_MUTE);
|
||||
A->audio_path(false);
|
||||
g.listening = false;
|
||||
A->bk_write(REG_RX_BW, g.scan_bw);
|
||||
}
|
||||
|
||||
__attribute__((section(".text.entry"), used))
|
||||
void app_main(const app_api_t *api)
|
||||
{
|
||||
state_t state; /* ~0.8 KiB of history, on the stack */
|
||||
A = api;
|
||||
S = &state;
|
||||
|
||||
A->cfg_load(&g.magic, CFG_DEFAULT_LEN);
|
||||
if (g.magic != CFG_MAGIC || g.span >= SPAN_COUNT || g.speed >= SPEED_COUNT ||
|
||||
(uint8_t)(g.yaw + YAW_MAX) > 2u * YAW_MAX ||
|
||||
(uint8_t)(g.pitch - PITCH_MIN) > PITCH_MAX - PITCH_MIN)
|
||||
A->asset_read(CFG_DEFAULT, &g.magic, CFG_DEFAULT_LEN);
|
||||
g.running = true; /* the rest of g starts zeroed (.bss) */
|
||||
g.prev_key = A->get_key();
|
||||
A->backlight_on();
|
||||
A->audio_path(false);
|
||||
A->set_af(APP_AF_MUTE);
|
||||
A->set_agc(false); /* fixed gain: no pumping by a strong signal */
|
||||
g.saved_bw = A->bk_read(REG_RX_BW);
|
||||
g.centre = A->rx_freq();
|
||||
restart();
|
||||
|
||||
while (g.running) {
|
||||
if (g.listening) {
|
||||
A->delay_ms(LISTEN_TICK_MS);
|
||||
poll_keys();
|
||||
/* quiet when the squelch is lost (as in VFO mode) or the level
|
||||
falls to the close level (squelch 0, always open) */
|
||||
const uint8_t m = measure(0);
|
||||
g.quiet = squelch_open() && m > g.close_lvl ? 0u : (uint8_t)(g.quiet + 1u);
|
||||
if (g.quiet >= LISTEN_HANG || !g.listen || g.hold || g.abort_sweep) {
|
||||
stop_listen();
|
||||
} else {
|
||||
/* the landscape goes on with the one point still measured,
|
||||
the station's own ridge; the rest stays flat at the floor */
|
||||
uint8_t *peak = &S->line[ACC][g.peak_bin];
|
||||
if (m > *peak)
|
||||
*peak = m;
|
||||
commit((uint8_t)(g.gate - SIGNAL_DB));
|
||||
}
|
||||
} else if (g.hold) {
|
||||
poll_keys();
|
||||
A->delay_ms(20);
|
||||
} else {
|
||||
sweep(); /* polls the keys as it goes */
|
||||
/* a finished sweep's peak; not before the first line (floor 0) */
|
||||
if (g.listen && g.peak_f && g.gate > SIGNAL_DB)
|
||||
start_listen();
|
||||
}
|
||||
A->battery_sample(); /* keeps the status-bar level live */
|
||||
if (!g.saver)
|
||||
draw();
|
||||
A->backlight_update();
|
||||
}
|
||||
|
||||
stop_listen(); /* EXIT while listening: mute */
|
||||
|
||||
/* The loader's retune keeps its cached AGC state and would leave the fixed
|
||||
gain in place: restore automatic gain explicitly. */
|
||||
A->set_agc(true);
|
||||
A->bk_write(REG_RX_BW, g.saved_bw); /* the VFO's own RX filter */
|
||||
A->cfg_save(&g.magic, CFG_DEFAULT_LEN); /* magic set: loaded or default */
|
||||
}
|
||||
Reference in new issue
Block a user