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.
This commit is contained in:
mckero committed 2026-08-29 08:52:52 +01:00
1 parent fdcbe80056
commit 8b995aa610
5 files changed
+288 -10

No files matched your search

+13 -5
View File
@@ -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
+4 -1
View File
@@ -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
+84 -4
View File
@@ -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. */
+1
View File
@@ -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
+186
View File
@@ -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())