diff --git a/App/apps/spectrum3d/app.ld b/App/apps/spectrum3d/app.ld new file mode 100644 index 00000000..eb4da97e --- /dev/null +++ b/App/apps/spectrum3d/app.ld @@ -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.*) } +} diff --git a/App/apps/spectrum3d/build.sh b/App/apps/spectrum3d/build.sh new file mode 100755 index 00000000..223e0428 --- /dev/null +++ b/App/apps/spectrum3d/build.sh @@ -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" diff --git a/App/apps/spectrum3d/gen_assets.py b/App/apps/spectrum3d/gen_assets.py new file mode 100755 index 00000000..59b8b4a9 --- /dev/null +++ b/App/apps/spectrum3d/gen_assets.py @@ -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" +#include +#include +#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 */ +}