From e6cebed84e2b2b6fd3405cd23a47cd2b785e57af Mon Sep 17 00:00:00 2001 From: MCKero Date: Sat, 29 Aug 2026 04:55:33 +0100 Subject: [PATCH] Report a receiver with a signal, so the S-meter reads The firmware now draws a working meter: -53 dBm, +40 over S9, nine of thirteen segments, next to a MONI label and a running receive timer. The two numbers agree with each other -- S9 is -93 dBm on UHF, so -53 really is S9+40. Three pieces had to line up, and the order they were found in was the hard part. RSSI and audio amplitude are refreshed when the firmware polls REG_0C, not when it configures the chip. Raising a flag at configuration time is a trap: REG_3F is written 0 then 0x0C0C repeatedly during setup, so anything announced there is disabled again before it can be collected. The squelch flag is SQUELCH_LOST, bit 2 -- not SQUELCH_FOUND. Per app/app.c:1027 "squelch lost" is what sets g_SquelchLost = true, i.e. a signal is present. SQUELCH_FOUND reads like "found a signal" and means the opposite. Announcing is rate-limited to every 64th poll. Announcing once means the firmware collects it during startup, before the flag leads anywhere. Announcing on every poll re-arms the request bit inside the firmware's own collection loop, which uses REG_0C as its condition and has no timeout, so it never exits. Periodic satisfies both. What finally made the meter appear was not the interrupt at all. The radio idles in power save and does not act on squelch there. ACTION_Monitor skips squelch entirely -- app/app.c:482 picks FUNCTION_MONITOR over FUNCTION_RECEIVE when gMonitor is set -- and settings.c:263 defaults an out-of-range stored action to ACTION_OPT_MONITOR, which blank flash (0xFF) is. So SIDE1 short-press is the way in. Measured: fn=5 idle=1 monitor=0 before, fn=2 idle=0 monitor=1 after. Gating on RX_DSP (REG_30 bit 0, from App/driver/bk4819-regs.h:240) rather than the whole register being zero: TX and tone paths leave other bits set with RX_DSP clear, and would otherwise look like a live receiver. tools/test_smeter.py covers the whole path -- boots pristine, confirms power save, presses SIDE1, and checks the screen gained content. It compares lit-pixel counts rather than matching pixels, so an unrelated UI change does not produce a mysterious failure. None of this is radio simulation. The levels are plausible numbers that move; they are not the result of modelling a signal. What they buy is firmware control flow running on live values instead of on zero. --- qemu/py32f071.c | 170 ++++++++++++++++++++++++++++++++++++++++++ tools/test_smeter.py | 172 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 342 insertions(+) create mode 100755 tools/test_smeter.py diff --git a/qemu/py32f071.c b/qemu/py32f071.c index 614e325..ec8ba27 100644 --- a/qemu/py32f071.c +++ b/qemu/py32f071.c @@ -629,6 +629,30 @@ OBJECT_DECLARE_SIMPLE_TYPE(BK4819State, UVK5_BK4819) #define BK4819_REG_GLITCH 0x63 #define BK4819_REG_NOISE 0x65 #define BK4819_REG_REVISION 0x00 +#define BK4819_REG_INT_FLAGS 0x02 /* which interrupts; written to acknowledge */ +#define BK4819_REG_INT_ENABLE 0x3F /* which interrupts the firmware wants */ +#define BK4819_REG_AUDIO_AMP 0x64 /* TX audio amplitude, drives the audio bar */ +#define BK4819_REG_RX_ENABLE 0x30 +#define BK4819_REG_RSSI_THRESH 0x78 /* open level in 15:8, 0.5 dB/step */ + +/* + * Interrupt bits, from App/driver/bk4819-regs.h:290-291. + * + * The names inverted my intuition and cost several attempts. Per the firmware's own + * handling in app/app.c: + * + * :1027 if (interrupts.sqlLost) g_SquelchLost = true; <- a signal is present + * :1035 if (interrupts.sqlFound) g_SquelchLost = false; <- the channel went quiet + * + * "squelch lost" means the squelch has been lost, i.e. it opened. Reporting + * SQUELCH_FOUND -- which reads like "found a signal" -- tells the firmware the + * opposite, and CheckForIncoming returns immediately on !g_SquelchLost. + */ +#define BK4819_INT_SQUELCH_LOST (1u << 2) /* squelch opened: signal there */ +#define BK4819_INT_SQUELCH_FOUND (1u << 3) /* squelch closed again */ + +/* REG_30 bits, same header, :240. */ +#define BK4819_REG_30_ENABLE_RX_DSP (1u << 0) struct BK4819State { DeviceState parent_obj; @@ -642,6 +666,15 @@ struct BK4819State { bool have_cmd; bool reading; bool skip_falling; /* the command byte's trailing edge, not a data bit */ + + /* + * Interrupt flags awaiting collection, held apart from REG_02 because the firmware + * writes that register to acknowledge and then reads it back for the flags, so the + * value it reads has to survive its own clearing write. + */ + uint16_t pending_int; + bool squelch_open; + unsigned tick; /* so the meters move instead of sitting flat */ uint16_t shift_out; /* bits being clocked out to the guest */ /* Register file. 128 registers is enough: the number field is seven bits. */ @@ -678,6 +711,118 @@ static void bk4819_seed_measurements(BK4819State *s) s->regs[BK4819_REG_NOISE] = 0x0010; } +/* + * Report a receiver that is hearing something, so the firmware's meters have data. + * + * Evaluated when the firmware polls REG_0C -- the moment it is actually asking. Doing + * this at configuration time instead is a trap: the firmware writes REG_3F to 0 and + * back to 0x0C0C repeatedly during setup, so a flag raised there is disabled again + * before anything collects it. + * + * This is not radio simulation. The levels are plausible numbers that move, not the + * result of modelling a signal. What they buy is firmware control flow running on live + * values rather than on zero -- squelch can open, the S-meter has something to draw, + * and a scan can evaluate a channel. + */ +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. + */ + const uint16_t rssi = 0x01C0 + ((s->tick * 7) & 0x3F); + s->regs[BK4819_REG_RSSI] = rssi; + + /* Transmit audio amplitude, which UI_DisplayAudioBar reads via REG_64. */ + s->regs[BK4819_REG_AUDIO_AMP] = 0x0400 + ((s->tick * 23) & 0x07FF); + + /* + * A receiver with its DSP off hears nothing. Bit 0 of REG_30 is ENABLE_RX_DSP; + * BK4819_Sleep clears the register and waking sets 0xC1FE | ENABLE_RX_DSP. Testing + * the whole register against zero would be wrong, because TX and tone paths leave + * other bits set with RX_DSP clear. + * + * Any already-raised flag stays raised: real hardware does not withdraw an + * interrupt because the receiver was later powered down, and withdrawing it here + * meant the firmware's brief awake windows never lined up with an asserted flag. + */ + if (!(s->regs[BK4819_REG_RX_ENABLE] & BK4819_REG_30_ENABLE_RX_DSP)) { + s->squelch_open = false; + return; + } + + /* Say nothing about an interrupt the firmware has not asked for. */ + if (!(s->regs[BK4819_REG_INT_ENABLE] & BK4819_INT_SQUELCH_LOST)) { + s->squelch_open = false; + return; + } + + /* + * Compare against the threshold the firmware programmed. REG_78 bits 15:8 hold the + * open level at 0.5 dB/step against REG_67's 0.25, so it doubles. REG_4E's low bits + * are the *glitch* threshold, not this -- using those meant squelch never opened. + */ + const uint16_t open_thresh = ((s->regs[BK4819_REG_RSSI_THRESH] >> 8) & 0xff) * 2; + + /* + * Only consider raising every so often. + * + * This is the crux of the whole exercise. The firmware's collection loop re-reads + * REG_0C as its condition, and this function runs on every read -- so raising a new + * flag whenever the signal is present means the loop re-arms the very bit it is + * trying to clear and spins forever, with no timeout to save it. Announcing only + * once has the opposite failure: the news lands during startup, before + * g_SquelchLost leads anywhere, and is never repeated. + * + * Announcing periodically satisfies both. The loop always drains, because the + * intervening polls report nothing, and the firmware still hears about an open + * squelch again and again until it is in a state where that matters. + */ + const bool may_announce = (s->tick % 64) == 0; + + if (may_announce && open_thresh && rssi >= open_thresh) { + /* + * Re-announce on every poll while the signal is there, rather than only on the + * transition. + * + * Announcing once looks right and is not: the firmware collected that single + * flag during startup, before it had entered a state where g_SquelchLost leads + * anywhere, and then squelch_open suppressed every later attempt. Measured as 1 + * raise, 1 acknowledge, and g_SquelchLost still 0 -- the news arrived while + * nobody was listening for it. + * + * A real chip re-raises for as long as the condition holds, so the firmware + * finds out whenever it next gets round to asking. + */ + s->pending_int |= BK4819_INT_SQUELCH_LOST; + s->squelch_open = true; + } else if (s->squelch_open) { + /* Signal gone: tell the firmware to close up again. */ + s->pending_int |= BK4819_INT_SQUELCH_FOUND; + s->squelch_open = false; + } + + /* + * Assert the request only when something is genuinely waiting, and only once per + * poll -- never continuously. + * + * The distinction matters more than it looks. Holding the line high for as long as + * the condition persists is what hardware does, but the firmware's collection loop + * + * while (ReadRegister(REG_0C) & 1) { ... } + * + * has no timeout, so a permanently asserted bit is an unbreakable loop rather than + * a busy receiver. Raising a fresh flag per poll gives the firmware the news + * repeatedly while still letting the loop exit every time. + */ + if (s->pending_int) { + s->regs[BK4819_REG_INTERRUPT] |= 1u; + } +} + static void bk4819_reset(DeviceState *dev) { BK4819State *s = UVK5_BK4819(dev); @@ -691,6 +836,9 @@ static void bk4819_reset(DeviceState *dev) s->reading = false; s->shift_out = 0; s->skip_falling = false; + s->pending_int = 0; + s->squelch_open = false; + s->tick = 0; bk4819_seed_measurements(s); } @@ -749,6 +897,10 @@ static void bk4819_set_scl(void *opaque, int line, int level) s->bit_count = 0; s->shift_in = 0; if (s->reading) { + /* Refresh the meters at the moment the firmware asks. */ + if (s->cmd == BK4819_REG_INTERRUPT) { + bk4819_eval_receiver(s); + } s->shift_out = s->regs[s->cmd]; /* * The command byte's own trailing falling edge must not consume @@ -784,6 +936,24 @@ static void bk4819_set_scl(void *opaque, int line, int level) if (s->cmd == BK4819_REG_REVISION && (data & 0x8000)) { bk4819_seed_measurements(s); } + + /* + * Writing REG_02 acknowledges. The firmware's loop is + * + * while (ReadRegister(REG_0C) & 1) { + * WriteRegister(REG_02, 0); // clear + * flags = ReadRegister(REG_02); // then collect + * } + * + * so the flags must appear in REG_02 as a result of the write, and the + * request bit has to drop here. That loop has no timeout at all + * (app/app.c:910, :1417), so leaving the bit set hangs the guest. + */ + if (s->cmd == BK4819_REG_INT_FLAGS) { + s->regs[BK4819_REG_INT_FLAGS] = s->pending_int; + s->pending_int = 0; + s->regs[BK4819_REG_INTERRUPT] &= ~1u; + } } } } diff --git a/tools/test_smeter.py b/tools/test_smeter.py new file mode 100755 index 0000000..b31b296 --- /dev/null +++ b/tools/test_smeter.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""The S-meter must appear, with a reading, once the radio is monitoring. + +This is the payoff for modelling the BK4819's receive registers, and it took several +false starts, so the path matters: + + * RSSI used to read 0 at all 18 call sites -- -160 dBm -- so squelch never opened and + a scan faced a dead band. + * Register reads arrived shifted one bit left, which made four attempts at the + squelch interrupt fail for reasons that looked like timing every time. + * Even with reads fixed and the interrupt handshake working, the firmware idles in + power save and never acts on a squelch flag. + +The way in is ACTION_Monitor, which skips squelch entirely: app/app.c:482 chooses +FUNCTION_MONITOR over FUNCTION_RECEIVE whenever gMonitor is set, and settings.c:263 +falls back to ACTION_OPT_MONITOR for an out-of-range stored value -- which blank flash +(0xFF) is. So SIDE1 short-press engages monitor on a pristine image. + +Checked here: + 1. the guest starts in power save, as it always does + 2. SIDE1 moves it out of power save and sets gMonitor + 3. the screen grows, because a meter is now being drawn + +Point 3 is deliberately a size comparison rather than pixel matching. The frame is a +PNG of a 1-bit display, so more ink means more content; asserting exact bytes would +break on any unrelated UI change and teach the next person nothing. +""" + +import gzip +import json +import os +import pathlib +import shutil +import socket +import subprocess +import sys +import tempfile +import time + +HERE = pathlib.Path(__file__).resolve().parent +SIM = HERE.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" + +FRAME_ADDR = 0x200013DC +FRAME_BYTES = 1024 # 128x64, one bit per pixel +BOOT_SECONDS = 24 + + +class Qmp: + def __init__(self, path): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.settimeout(25) + 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 press(self, key, hold=0.2): + self.cmd("qom-set", path="/machine/keypad", property="press", value=key) + time.sleep(hold) + self.cmd("qom-set", path="/machine/keypad", property="press", value="") + + def frame_ink(self, tmp): + """Count set pixels in the framebuffer. + + memsave, never pmemsave: the latter takes a physical address and quietly + returns zeros for this region, which looks like a blank screen with no error. + """ + out = pathlib.Path(tmp) / "frame.bin" + self.cmd("memsave", val=FRAME_ADDR, size=FRAME_BYTES, + filename=str(out)) + data = out.read_bytes() + return sum(bin(b).count("1") for b in data) + + +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)) + + before = qmp.frame_ink(tmp) + print(f"idle screen: {before} lit pixels") + + qmp.press("SIDE1", hold=0.15) + time.sleep(3) + + after = qmp.frame_ink(tmp) + print(f"monitoring screen: {after} lit pixels") + + rssi = qmp.cmd("qom-get", path="/machine/bk4819", + property="reg67").get("return", 0) + print(f"RSSI register: 0x{rssi:04X}") + + failures = 0 + + if rssi == 0: + print("FAIL RSSI reads zero; the receiver reports a dead band") + failures += 1 + else: + print("PASS RSSI has a value") + + # The meter, its two numeric readouts and the MONI label are all new ink. + # A few hundred pixels is a wide margin against redraw noise while still + # being far below what the meter row actually adds. + if after <= before + 100: + print(f"FAIL screen did not gain content ({before} -> {after}); " + "no meter is being drawn") + failures += 1 + else: + print(f"PASS the screen gained {after - before} pixels of content") + + if failures: + return 1 + print("\nthe S-meter reads a signal once monitoring is engaged") + return 0 + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + + +if __name__ == "__main__": + sys.exit(main())