mirror of
https://github.com/MCKero6423/uv-k5-v3-emulator.git
synced 2026-10-02 03:15:36 +00:00
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:
1 parent
fdcbe80056
commit
8b995aa610
5 files changed
+288
-10
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+186
@@ -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())
|
||||
Reference in new issue
Block a user