From 8b995aa6100a2703192dffa8b1ff98e749a7af22 Mon Sep 17 00:00:00 2001 From: MCKero Date: Sat, 29 Aug 2026 08:52:52 +0100 Subject: [PATCH] Make RSSI depend on tuning instead of being a constant The S-meter had a number to draw, but a fixed RSSI above squelch meant the band was uniformly and permanently occupied. Scanning, squelch, and every "is this channel busy" decision therefore faced a situation that never varied, so none of that logic was really being tested -- the tests passed without testing much. RSSI is now derived from where the firmware tuned. BK4819_SetFrequency splits the frequency across REG_38 and REG_39 (driver/bk4819.c:743), which the model already records; verified against a live guest that 0x0262/0x5A00 reads back as 400.00000 MHz, matching the screen. A small table of virtual stations plus a noise floor and a fade either side of centre gives a band with signals in some places and not others. Measured through the firmware's own tuning path -- typing 410.000 on the keypad rather than poking the registers, so the test does not check the model against itself: 400.000 MHz (station) RSSI 0x01E5 410.000 MHz (empty) RSSI 0x0091 a gap of 85 dB What is honest and what is not, recorded in the code: the shape is real physics, power falls off away from a carrier with a noise floor underneath. The station list is invented. So this reproduces "the firmware copes with a band that is busy in places", which is genuine coverage, and it reproduces no actual radio environment -- a dBm figure from here is not a claim about the world. Also records why backlight PWM is deliberately left stubbed. Intermediate brightness runs TIM7 -> DMA rewriting GPIOA BSRR at 128 kHz, so modelling it costs 128,000 GPIO writes per emulated second and changes nothing observable: backlight is LED brightness and never touches the framebuffer. The two endpoints that are observable, off and full, bypass the timer and already work. Full run: 16 passed, 0 failed. --- AGENTS.md | 18 ++-- README.md | 5 +- qemu/py32f071.c | 88 ++++++++++++++++++- tools/run_tests.sh | 1 + tools/test_spectrum.py | 186 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 288 insertions(+), 10 deletions(-) create mode 100755 tools/test_spectrum.py diff --git a/AGENTS.md b/AGENTS.md index 571d45c..578678d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -541,12 +541,20 @@ the analogue side is not and cannot be**. Frequency, flash, keypad, serial, regi programming, battery — all real. Audio samples and RF behaviour — no data exists to model, in the MCU's address space or in any public datasheet. -Two stubs are worth a look if more coverage is wanted, in order: +`millis()`/TIM2 and the settable ADC closed the two gaps that mattered. What is left, +and why: -1. **TIM** — 23 call sites. `millis()` reads TIM2 as a free-running counter and - `backlight.c` drives PWM. Timeouts and backlight dimming currently cannot be - exercised. -2. **EXTI** — zero call sites today, but any interrupt-driven rework would need it. +**Backlight PWM — deliberately not modelled.** `backlight.c` drives intermediate +brightness with TIM7 triggering DMA channel 7 to rewrite GPIOA `BSRR` from a 32-entry +duty-cycle table, at `PWM_FREQ * DUTY_CYCLE_LEVELS` = 128 kHz. Modelling it means +128,000 GPIO writes and DMA transfers per emulated second, and **nothing observable +changes**: backlight is physical LED brightness and does not touch the framebuffer, so +`frame.png` is byte-identical either way. The two endpoints that do have observable +behaviour — brightness 0 and full — bypass the timer entirely and call +`GPIO_TurnOffBacklight`/`TurnOnBacklight`, which already work. Cost is high, benefit is +zero. + +**EXTI** — zero call sites today. Any interrupt-driven rework would need it first. ### Audio: there is nothing to model, and that is the finding diff --git a/README.md b/README.md index 7fedd8a..025c02b 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,8 @@ has no public datasheet, so its driver is the only specification available. | Serial output (firmware log) | works, appears in the web UI log | | Serial input, CPS programming protocol | works, `-serial` any chardev | | BK4819 register interface | works, RSSI and status readable | -| S-meter | works via monitor (SIDE1); reads -53 dBm, S9+40 | +| S-meter | works via monitor (SIDE1) | +| Signal strength | depends on tuning: virtual stations vs noise floor | | PTT and transmit | works; TX annunciator, timer, and mic level bar | | Speaker / microphone audio | **no samples exist to model**, see [Audio](#audio) | | `millis()` / TIM2 | works; advances at roughly wall-clock rate | @@ -91,6 +92,7 @@ keypresses silently stop working. Run the test after touching that code; test_audio_path.py the amplifier turns on when the firmware wants sound test_battery.py battery level and low-battery follow the ADC test_millis.py millis() advances, so timeouts can expire + test_spectrum.py RSSI depends on tuning, not a constant run_tests.sh runs all of the above, build-checked first test_run_tests.sh that the runner actually notices failures lib_kill_emulator.sh cleanup that only ever kills emulators @@ -151,6 +153,7 @@ that was never compiled. Individual tests still run standalone: python3 tools/test_audio_path.py python3 tools/test_battery.py python3 tools/test_millis.py + python3 tools/test_spectrum.py This matters more than it looks. The keypad can break silently under -O2 without any compiler warning -- see the `volatile` note in [Status](#status) -- so a clean diff --git a/qemu/py32f071.c b/qemu/py32f071.c index 0e34d71..8281e30 100644 --- a/qemu/py32f071.c +++ b/qemu/py32f071.c @@ -838,16 +838,96 @@ static void bk4819_seed_measurements(BK4819State *s) * values rather than on zero -- squelch can open, the S-meter has something to draw, * and a scan can evaluate a channel. */ +/* + * The tuned frequency, in units of 10 Hz, as the firmware programmed it. + * + * BK4819_SetFrequency splits it across two registers (driver/bk4819.c:743): + * + * REG_38 = Frequency & 0xFFFF + * REG_39 = (Frequency >> 16) & 0xFFFF + * + * Verified against a live guest: 0x0262 / 0x5A00 reads back as 40,000,000 -> 400.00000 + * MHz, matching the frequency on screen. + */ +static uint32_t bk4819_tuned_hz10(BK4819State *s) +{ + return ((uint32_t)s->regs[0x39] << 16) | s->regs[0x38]; +} + +/* + * Signal strength for a tuned frequency, from a small table of virtual stations. + * + * This replaces a constant. A fixed RSSI comfortably above squelch meant the meter had + * a number to draw, but scanning, squelch and any "is this channel busy" decision faced + * a band that was uniformly and permanently occupied -- so none of that logic was + * really being exercised. + * + * What is honest here and what is not, stated plainly. The *shape* is real physics: + * received power falls off away from a carrier, and there is a noise floor underneath. + * The station list is invented -- these transmitters do not exist. So this reproduces + * "the firmware handles a band with signals in some places and not others", which is + * genuine behaviour coverage, and it does not reproduce any actual radio environment. + * Do not read a dBm figure here as a claim about the real world. + */ +struct BK4819Station { + uint32_t hz10; /* centre frequency, units of 10 Hz */ + uint16_t peak_rssi; /* REG_67 counts at the centre; 0.25 dB/step from -160 dBm */ +}; + +static const struct BK4819Station bk4819_stations[] = { + { 40000000, 0x01E0 }, /* 400.000 MHz, strong -- about -40 dBm */ + { 40012500, 0x0170 }, /* 400.125 MHz, medium -- about -67 dBm */ + { 43550000, 0x01A8 }, /* 435.500 MHz, strong -- the satellite end of 70 cm */ + { 14550000, 0x0150 }, /* 145.500 MHz, medium -- 2 m */ +}; + +/* Noise floor in REG_67 counts: about -125 dBm, well below any squelch threshold. */ +#define BK4819_NOISE_FLOOR 0x008C + +/* + * How quickly a station fades either side of centre. 12.5 kHz per step means a signal + * is gone within a few channel spacings, so adjacent channels are genuinely quiet and a + * scan has somewhere to stop and somewhere to move on from. + */ +#define BK4819_FADE_STEP_HZ10 1250 +#define BK4819_FADE_PER_STEP 0x30 + +static uint16_t bk4819_rssi_for(BK4819State *s) +{ + const uint32_t tuned = bk4819_tuned_hz10(s); + uint16_t best = BK4819_NOISE_FLOOR; + + if (tuned == 0) { + return best; /* nothing programmed yet */ + } + + for (unsigned i = 0; i < ARRAY_SIZE(bk4819_stations); i++) { + const uint32_t centre = bk4819_stations[i].hz10; + const uint32_t offset = tuned > centre ? tuned - centre : centre - tuned; + const uint32_t steps = offset / BK4819_FADE_STEP_HZ10; + const uint32_t fade = steps * BK4819_FADE_PER_STEP; + + if (fade >= bk4819_stations[i].peak_rssi) { + continue; /* faded into the noise */ + } + const uint16_t level = bk4819_stations[i].peak_rssi - fade; + if (level > best) { + best = level; + } + } + return best; +} + static void bk4819_eval_receiver(BK4819State *s) { s->tick++; /* - * REG_67 counts 0.25 dB/step up from -160 dBm, so this sweeps about -44 to -36 - * dBm: clear of any sane squelch threshold, and visibly varying so the S-meter - * does not look painted on. + * RSSI now depends on where the radio is tuned, plus a little jitter so the meter + * does not look painted on. REG_67 counts 0.25 dB/step up from -160 dBm. */ - const uint16_t rssi = 0x01C0 + ((s->tick * 7) & 0x3F); + const uint16_t base = bk4819_rssi_for(s); + const uint16_t rssi = base + ((s->tick * 7) & 0x07); s->regs[BK4819_REG_RSSI] = rssi; /* Transmit audio amplitude, which UI_DisplayAudioBar reads via REG_64. */ diff --git a/tools/run_tests.sh b/tools/run_tests.sh index 786678a..8e95f69 100755 --- a/tools/run_tests.sh +++ b/tools/run_tests.sh @@ -88,6 +88,7 @@ run "scan" python3 tools/test_scan.py run "audio path" python3 tools/test_audio_path.py run "battery" python3 tools/test_battery.py run "millis" python3 tools/test_millis.py +run "spectrum" python3 tools/test_spectrum.py run "serial receive" python3 tools/test_serial_rx.py run "flash persistence" python3 tools/test_flash_persist.py run "frequency entry" python3 tools/test_freq_entry.py diff --git a/tools/test_spectrum.py b/tools/test_spectrum.py new file mode 100755 index 0000000..53c28e5 --- /dev/null +++ b/tools/test_spectrum.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python3 +"""RSSI must depend on where the radio is tuned, not be a constant. + +Why this matters more than the number on the meter. RSSI used to be a fixed value +comfortably above squelch, which gave the S-meter something to draw but meant the band +was uniformly and permanently occupied. Scanning, squelch, and every "is this channel +busy" decision therefore faced a situation that never varied, so none of that logic was +actually being exercised -- the tests passed without testing anything. + +Scope, stated plainly: the *shape* is real physics -- power falls off away from a +carrier, with a noise floor underneath -- and the station list is invented. This +reproduces "the firmware copes with a band that has signals in some places and not +others". It does not reproduce any real radio environment, and a dBm figure from here +is not a claim about the world. + +Checked here: + 1. tuning to a station gives a strong reading + 2. tuning well away from every station drops to the noise floor + 3. the difference is large enough for squelch to distinguish them +""" + +import gzip +import json +import os +import pathlib +import socket +import subprocess +import sys +import tempfile +import time + +SIM = pathlib.Path(__file__).resolve().parent.parent +QEMU = pathlib.Path(os.environ.get( + "QEMU", "/root/qemu-build/qemu-7.2+dfsg/build/qemu-system-arm")) +ELF = pathlib.Path(os.environ.get( + "ELF", "/root/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf")) +PRISTINE = SIM / "assets/pristine/flash-pristine.img.gz" + +BOOT_SECONDS = 24 +BK_PATH = "/machine/bk4819" + +# A station in the model's table, and a frequency far from all of them. +ON_STATION_HZ10 = 40000000 # 400.000 MHz +OFF_STATION_HZ10 = 41000000 # 410.000 MHz, several MHz clear of anything + + +class Qmp: + def __init__(self, path): + self.s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.s.settimeout(25) + self.s.connect(path) + self.buf = b"" + self._read() + self.cmd("qmp_capabilities") + + def _read(self): + while b"\n" not in self.buf: + chunk = self.s.recv(65536) + if not chunk: + raise RuntimeError("QMP closed") + self.buf += chunk + line, self.buf = self.buf.split(b"\n", 1) + return json.loads(line) + + def cmd(self, name, **args): + msg = {"execute": name} + if args: + msg["arguments"] = args + self.s.sendall(json.dumps(msg).encode() + b"\n") + while True: + reply = self._read() + if "return" in reply or "error" in reply: + return reply + + def reg(self, num): + return self.cmd("qom-get", path=BK_PATH, + property=f"reg{num:02x}").get("return") + + def key(self, name, hold=0.15): + self.cmd("qom-set", path="/machine/keypad", property="press", value=name) + time.sleep(hold) + self.cmd("qom-set", path="/machine/keypad", property="press", value="") + + def type_frequency(self, mhz_digits): + """Enter a frequency on the keypad, as a user would. + + Deliberately not poking REG_38/REG_39 directly: that would test the model + against itself. Going through the firmware means the tuning path is exercised + too. + """ + for ch in mhz_digits: + self.key(ch, hold=0.12) + time.sleep(0.25) + + +def rssi_after_tuning(qmp, digits, settle=4): + qmp.type_frequency(digits) + time.sleep(settle) + # Engage monitor so the receiver is actually running and polling. + qmp.key("SIDE1") + time.sleep(3) + tuned = (qmp.reg(0x39) << 16) | qmp.reg(0x38) + return qmp.reg(0x67), tuned + + +def main(): + for tool in (QEMU, ELF, PRISTINE): + if not tool.exists(): + print(f"SKIP missing {tool}") + return 0 + + with tempfile.TemporaryDirectory() as tmp: + img = pathlib.Path(tmp) / "flash.img" + img.write_bytes(gzip.decompress(PRISTINE.read_bytes())) + sock = pathlib.Path(tmp) / "qmp.sock" + + proc = subprocess.Popen( + [str(QEMU), "-M", f"uv-k5-v3,flash-image={img}", + "-nographic", "-monitor", "none", + "-qmp", f"unix:{sock},server=on,wait=off", + "-kernel", str(ELF)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + for _ in range(BOOT_SECONDS * 4): + if sock.exists(): + break + time.sleep(0.25) + else: + print("FAIL QMP socket never appeared") + return 1 + time.sleep(BOOT_SECONDS) + + qmp = Qmp(str(sock)) + failures = 0 + + # The radio boots tuned to 400.000, which is a station in the table. + on_rssi = qmp.reg(0x67) + tuned = (qmp.reg(0x39) << 16) | qmp.reg(0x38) + print(f"tuned {tuned / 100000:.5f} MHz (a station): RSSI 0x{on_rssi:04X}") + + if tuned != ON_STATION_HZ10: + print(f"note expected {ON_STATION_HZ10 / 100000:.5f} MHz at boot; " + "the comparison below is still valid") + + # Tune away by typing a new frequency: 410.000 MHz. + off_rssi, off_tuned = rssi_after_tuning(qmp, "410000") + print(f"tuned {off_tuned / 100000:.5f} MHz (empty): " + f"RSSI 0x{off_rssi:04X}") + + if off_tuned == tuned: + print("FAIL the frequency did not change; cannot compare") + return 1 + + if on_rssi > off_rssi: + print(f"PASS RSSI depends on tuning " + f"(0x{on_rssi:04X} on station, 0x{off_rssi:04X} off)") + else: + print(f"FAIL RSSI did not drop away from the station " + f"(0x{on_rssi:04X} -> 0x{off_rssi:04X})") + failures += 1 + + # REG_67 is 0.25 dB/step, so 0x80 is 32 dB -- far more than any squelch + # hysteresis, i.e. the two cases are unambiguously distinguishable. + gap = on_rssi - off_rssi + if gap >= 0x80: + print(f"PASS the gap is {gap * 0.25:.0f} dB, enough for squelch " + "to tell them apart") + else: + print(f"FAIL the gap is only {gap * 0.25:.0f} dB; squelch could not " + "reliably distinguish a busy channel from an empty one") + failures += 1 + + if failures: + return 1 + print("\nthe band has signals in some places and not others") + return 0 + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + +if __name__ == "__main__": + sys.exit(main())