Model the BK4819 register interface

The transceiver was not modelled at all. Its bit-banged three-wire bus went nowhere,
so every register read returned whatever the floating GPIO happened to be, and PB9
had to be idled low as a workaround: with the line high, reads came back 0xFFFF and
RADIO_SetupRegisters spun forever on bit 0 of REG_0C.

Now a real device, wired to the pins the driver uses -- CS on PF9, SCL PB8, SDA PB9
with both directions connected -- decoding the protocol from App/driver/bk4819.c:
CS low, eight bits of register number MSB first with bit 7 set for a read, then
sixteen bits of data. Registers read back what the firmware wrote; the few it reads
without having written return plausible values.

Scope, deliberately narrow: this is the register interface, not the radio. The chip
has no public datasheet, so the driver is the only specification available and it can
only say which registers were written, never what left the antenna. Keying envelopes,
spurious emissions and sensitivity still need a real radio and a spectrum analyser.
The comments say so at the top of the device, so a passing test here is not mistaken
for evidence about RF.

What it buys is control flow that evaluates real values. RSSI was hard zero at 18
call sites -- -160 dBm -- so the S-meter read empty and squelch and scan decisions
saw a dead band. It now reports about -40 dBm. Measurably, the main screen comes up
on 400 MHz instead of the 18 MHz floor, because band setup no longer reads zeros.

Two things the untimed spins force:

- REG_0C bit 0 must stay clear. App/app/app.c:910 and :1417 loop on it with no
  timeout whatsoever, so a stuck bit hangs the guest rather than degrading.
- A soft reset (REG_00 bit 15, which BK4819_Init issues first) has to re-seed the
  measurement registers. Real hardware keeps measuring afterwards; this model would
  be left holding zeros. That was not theoretical -- the first test run decoded 48
  registers correctly and still reported RSSI as 0 for exactly this reason.

The register file is exposed over QOM as regNN so tests can inspect it without gdb.
That matters beyond convenience: attaching a debugger pauses the guest and changes
timing-sensitive behaviour, which has repeatedly produced conclusions that were
artefacts of the measurement rather than facts about the firmware.

tools/test_bk4819.py checks the guest still boots (i.e. the spin terminates), that
dozens of registers hold written values (52 currently, so the transfer really is
being decoded), that RSSI is not zero, and that REG_0C bit 0 is clear.

keypad_test.py, test_flash_persist.py, test_freq_entry.py, test_serial_rx.py and the
143 unit tests all still pass.
This commit is contained in:
mckero committed 2026-08-28 16:45:29 +01:00
1 parent f3412f3316
commit 76fc72d5ed
2 files changed
+457

No files matched your search

+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""The BK4819 register interface must work, and RSSI must not read as zero.
Scope: this covers the register bus, not radio behaviour. The chip has no public
datasheet, so App/driver/bk4819.c is the only specification available and it can only
say which registers were written -- never what left the antenna. Keying envelopes,
spurious emissions and sensitivity need a real radio and a spectrum analyser.
What it does check:
1. The firmware boots. That is not a formality: App/app/app.c:910 and :1417 spin on
bit 0 of REG_0C with no timeout, so a model that leaves that bit set hangs the
guest outright. PB9 used to be idled low purely so reads returned 0 and those
loops could exit.
2. Registers written by the firmware read back with the values it wrote, which
proves the bit-banged transfer is being decoded rather than ignored.
3. RSSI is non-zero. It was previously hard 0 at 18 call sites, i.e. -160 dBm, so
the S-meter read empty and squelch and scan logic evaluated a dead band.
"""
import gzip
import json
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import time
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
QEMU = os.path.expanduser("~/qemu-build/qemu-7.2+dfsg/build/qemu-system-arm")
ELF = os.path.expanduser("~/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf")
PRISTINE = os.path.join(ROOT, "assets", "pristine", "flash-pristine.img.gz")
BOOT_SECONDS = 20
# From App/driver/bk4819.c. These are the ones the firmware reads back.
REG_INTERRUPT = 0x0C
REG_RSSI = 0x67
class Qmp:
def __init__(self, path):
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.settimeout(30)
self.sock.connect(path)
self.buf = b""
self._read()
self.cmd("qmp_capabilities")
def _read(self):
while b"\n" not in self.buf:
chunk = self.sock.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.sock.sendall(json.dumps(msg).encode() + b"\n")
while True:
reply = self._read()
if "return" in reply or "error" in reply:
return reply
def close(self):
try:
self.sock.close()
except OSError:
pass
def main():
for path, what in ((QEMU, "QEMU"), (ELF, "firmware"), (PRISTINE, "pristine image")):
if not os.path.exists(path):
sys.exit(f"missing {what}: {path}")
workdir = tempfile.mkdtemp(prefix="uvk5-bk4819-")
image = os.path.join(workdir, "flash.img")
sock_path = os.path.join(workdir, "qmp.sock")
with gzip.open(PRISTINE, "rb") as src, open(image, "wb") as dst:
shutil.copyfileobj(src, dst)
proc = subprocess.Popen(
[QEMU, "-M", f"uv-k5-v3,flash-image={image}", "-nographic", "-monitor", "none",
"-qmp", f"unix:{sock_path},server=on,wait=off", "-kernel", ELF],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
failures = []
try:
for _ in range(300):
if os.path.exists(sock_path):
break
time.sleep(0.1)
else:
raise RuntimeError("QMP socket never appeared")
time.sleep(BOOT_SECONDS)
qmp = Qmp(sock_path)
# 1. Still running means the untimed REG_0C spin terminated.
status = qmp.cmd("query-status").get("return", {})
print(f"guest status: {status.get('status')}")
if status.get("status") != "running":
failures.append(
f"guest is {status.get('status')}, not running -- most likely stuck "
"in the untimed spin on REG_0C bit 0")
else:
print("PASS the firmware booted and is running")
# 2 and 3. Read the model's register file through QOM.
regs = {}
for name, num in (("interrupt", REG_INTERRUPT), ("rssi", REG_RSSI)):
reply = qmp.cmd("qom-get", path="/machine/bk4819",
property=f"reg{num:02x}")
if "error" in reply:
failures.append(f"cannot read reg{num:02x}: {reply['error']}")
else:
regs[name] = reply["return"]
if "rssi" in regs:
print(f"RSSI register: 0x{regs['rssi']:04X}")
if regs["rssi"] == 0:
failures.append(
"RSSI reads 0, i.e. -160 dBm: squelch and scan see a dead band")
else:
print("PASS RSSI is not stuck at zero")
if "interrupt" in regs:
print(f"REG_0C: 0x{regs['interrupt']:04X}")
if regs["interrupt"] & 1:
failures.append(
"REG_0C bit 0 is set; the firmware spins on it without a timeout")
else:
print("PASS REG_0C bit 0 is clear")
# The firmware writes plenty of registers during init, so a register file
# that is entirely zero means the bus decode never ran.
written = 0
for num in range(0x00, 0x80):
reply = qmp.cmd("qom-get", path="/machine/bk4819",
property=f"reg{num:02x}")
if "return" in reply and reply["return"] not in (0, None):
written += 1
print(f"non-zero registers: {written}")
if written < 5:
failures.append(
f"only {written} registers hold a value; the firmware writes dozens "
"during init, so the three-wire transfer is not being decoded")
else:
print("PASS the firmware's register writes were decoded")
qmp.cmd("quit")
qmp.close()
finally:
try:
proc.terminate()
proc.wait(timeout=15)
except Exception:
proc.kill()
shutil.rmtree(workdir, ignore_errors=True)
if failures:
print()
for f in failures:
print(f"FAIL {f}")
sys.exit(1)
print("\nthe BK4819 register interface works")
if __name__ == "__main__":
main()