Commit Graph
11 Commits
Author SHA1 Message Date
mckero 4e29d11810 Update the docs now that the BK4819 is modelled
The "what this cannot do" section said the transceiver was not modelled and treated
that as permanent. Half of it is now wrong: the register interface works. The other
half is still true and worth keeping sharp -- analogue behaviour is out of reach
because the chip has no public datasheet, so the driver is the only specification and
it can only say which registers were written.

Rewritten to separate the two: what is modelled and what it fixed (RSSI was hard zero
at 18 call sites), the two constraints the untimed spin loops impose, and then the
line that does not move. Explicitly warns against reading the new test as evidence
about RF.

Also corrects the GPIO comment about idling PB9 low. It described the pin as a
workaround pending a device model; that model now exists, and the idle level only
covers the window between reset and the bus being wired.
2026-08-28 16:46:55 +01:00
mckero 76fc72d5ed 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.
2026-08-28 16:45:29 +01:00
mckero b84d229326 Implement serial receive, unlocking the UV-K5 programming protocol
Nothing could be sent to the firmware. Three separate pieces were missing.

USART1 was a register stub with no chardev, so there was no source of incoming
bytes. It now takes a chardev property, defaulting to serial0, so -serial works.

The DMA model never serviced USART. App/driver/uart.c receives over a circular
peripheral-to-memory channel and never reads DR; it finds new data with

    write_ptr = sizeof(UART_DMA_Buffer) - LL_DMA_GetDataLength(DMA1, CHANNEL_2)

so leaving CNDTR at its programmed value made the buffer look permanently empty
however many bytes arrived. DMA now drains USART1's queue byte by byte, decrementing
CNDTR and reloading it in circular mode. Channels also remember the length they were
given, since CNDTR counts down and the offset into the buffer has to be derived from
the difference.

Servicing happens on a CNDTR read rather than from a timer: that read is precisely
how the driver looks for data, so no polling is needed and no byte can be delivered
before the guest asks.

And transmit was invisible to the far end. DR writes went to stderr only, so a host
tool would send a command, the firmware would answer, and the answer went nowhere the
tool could see -- indistinguishable from being ignored. This cost a debugging round:
the first test run reported "no reply at all" with 0 bytes of boot output, which
looked like receive failing when the boot banner was in fact being written to stderr
as always. DR now also writes the raw byte to the chardev when one is connected.

SR reports RXNE when bytes are queued and DR consumes one, so a polling firmware
would work too, even though this one uses DMA.

tools/test_serial_rx.py speaks the real wire protocol -- AB CD framing, the fixed XOR
obfuscation, CRC-16/XMODEM -- and checks two exchanges end to end:

    0x0514 hello        -> 0x0515 ack
    0x051B EEPROM read  -> 0x051C with the requested 8 bytes at 0x0E70

Both pass. CPS/CHIRP-style tools can now talk to the emulator. keypad_test.py,
test_flash_persist.py, test_freq_entry.py and the 143 unit tests still pass.
2026-08-28 16:25:37 +01:00
mckero 798905f154 Give DMA the CPU's address space, so a typed frequency sticks
DMA moved bytes through address_space_memory, which cannot decode this SoC's memory
at all: the container region holding flash, SRAM and the peripherals is handed only
to the ARMv7M core and never registered with global system memory. Reads came back
MEMTX_DECODE_ERROR with all-zero data, and writes went nowhere. Proved directly --
an address_space_read of SRAM through it returns result=2 and 00000000, while the
same address read through the container returns the real contents.

This is what "the frequency will not change" and "flash behaves like RAM" had in
common. PY25Q16_WriteBuffer reads a 4 KB sector into SectorCache, patches the part
it wants, and programs the whole sector back. The read looked healthy from the flash
side -- the model handed over real 0xFF bytes -- but DMA dropped them, so the
write-back sourced 4096 zeros and cleared the sector, VFO frequencies at 0x9000
included. RADIO_ConfigureChannel only substitutes a band's lower limit for
0xFFFFFFFF, so a stored zero was used as-is and clamped to BX4819_band1_lower.
That is where the 18.000 MHz came from, every time.

DMA now runs over an AddressSpace built on the SoC container, and refuses to
transfer at all if none is configured rather than silently moving zeros.

tools/test_freq_entry.py covers the whole user-visible path: type 435000, confirm
435.00000 MHz lands in band 5, confirm no other band was zeroed, and confirm it is
still there after a power cycle. It drives QMP with no debugger attached, because
the input box times out in ~2.5 s and a gdb attach takes longer -- that alone
invalidated several earlier investigations.

Verified: 435 MHz now appears at flash 0x90A0 where before the entire sector read
zero. keypad_test.py, test_flash_persist.py and the 141 unit tests all pass.
2026-08-28 15:30:26 +01:00
mckero f114666b42 Start DMA on the peripheral's request, and clock both directions together
Two related faults in the DMA model, both of which corrupted flash reads.

Transfers started when a channel was enabled. On hardware, enabling only arms a
channel; the transfer begins when the peripheral raises its DMA request. The flash
driver's SPI_ReadBuf arms RX, arms TX, then enables SPI and sets TXDMAEN -- so
firing at arm time clocked the bus before the read command had been sent. SPI now
kicks the armed channels from CR1/CR2 when SPE and a DMA request enable are both
set, which covers the read path (SPE last) and the write path alike.

Each channel also ran to completion independently. SPI is duplex: one clocked byte
is simultaneously sent and received, and the driver relies on that, pairing a
memory-to-peripheral channel feeding dummy bytes with a peripheral-to-memory
channel collecting the reply. Running them in sequence meant TX clocked the whole
transfer out before RX looked at the bus, so RX collected nothing. They are now
stepped together, one byte at a time.

Either fault alone made a 4 KB sector read return zeros. PY25Q16_WriteBuffer reads
a sector into SectorCache, patches it, and writes the whole thing back, so a zeroed
read turned into a zeroed sector -- including the per-band VFO frequencies at
0x9000. That is a second, independent cause of typed frequencies reverting to
18 MHz, on top of the missing page wrap fixed in da1ad7e.

Verified: the frequency area stays 0xFF across a boot where it was previously
zeroed every time, and an instrumented build shows the sector read now returning
0xFF rather than 0x00.

test_flash_persist.py now starts from the pristine image rather than
assets/flash.img. A dirty live image left nothing for the boot to write, which
surfaced as "the image is byte-identical" -- a failure that looks like broken
persistence but is really a dirty fixture. Runs twice in a row cleanly now.

keypad_test.py and the 141 unit tests still pass.
2026-08-28 15:05:15 +01:00
mckero da1ad7e1e6 Wrap page-program writes within their 256-byte page
Real SPI NOR latches only the low address bits into its page buffer, so a program
burst that runs past the page boundary continues at the start of the same page. The
model incremented the address straight through instead.

Consequence: the firmware issues a 512-byte burst at 0x008F00 inside a single CS
assertion (measured -- the CS never drops mid-burst), which spilled into 0x009000.
That is the per-band VFO frequency area in eeprom_compat.c's map, so stored
frequencies were zeroed. RADIO_ConfigureChannel only substitutes the band's lower
limit when it reads 0xFFFFFFFF, so a stored 0 was taken literally and clamped to
BX4819_band1_lower -- which is why every typed frequency reverted to 18.000 MHz.

Verified: writes now align to sectors (0x8000-0x9000 and 0xA000-0xB000) and an
instrumented build records zero stores into 0x9000-0x90D6, where before it was
overwritten on every boot.

test_flash_persist.py had encoded the bug in its expectations: it watched 0x008100
and 0x00A100, which were only ever written *because* of the missing wrap. Those move
to 0x008000/0x00A000, and a MUST_NOT_CHANGE guard on the frequency area now fails if
a write spills there again.

keypad_test.py still passes.
2026-08-28 14:41:20 +01:00
mckero 23385d2eba Persist flash writes to the backing file
The SPI NOR model read its image at realize and never wrote back, so the "flash"
was a g_malloc buffer: everything the firmware saved -- settings, edited
frequencies, channel data -- vanished when the QEMU process exited. That is the
"it behaves like RAM" the user reported, and the image on disk still had its
original mtime and was byte-identical to what make_flash.py produces.

Page-program and sector-erase now mark the image dirty, and it is written out when
chip select is released. Flushing there rather than per byte means one file write
per settings save instead of thousands, because the firmware's driver holds CS for
a whole erase-and-program sequence.

Written via a temporary file and rename: an interrupted flush must not leave a
truncated image, since that file is the only copy of the radio's state. A short
write keeps the previous image rather than replacing it with a partial one.

Also flushes from an exit notifier. Deselect covers normal operation, but QMP quit
-- which is what the web UI's power off sends -- can arrive with the chip still
selected, and the last write would be dropped.

tools/test_flash_persist.py covers it end to end on a copy of the image, so it
cannot disturb a running session. It asserts specific regions rather than just a
changed hash: flash 0x008100 (MR/VFO attributes) and 0x00A100 (settings), which are
the two sectors a boot demonstrably writes. Both were 0xFF before and non-0xFF
after, and sha256 moved from 933d6974 to 7cdff6ce.

Three approaches were tried and abandoned first, all for the same reason: driving
the guest from gdb. `call EEPROM_WriteBuffer` and `call SETTINGS_SaveSettings`
both hang, because the main loop is running and the called function waits on
hardware the debugger has frozen. Observing the file is simpler and closer to what
the user actually sees. An earlier version of the test also watched EEPROM offsets
instead of flash offsets and reported "same" for every region while persistence was
in fact working -- the two address spaces are related by the table in
App/driver/eeprom_compat.c, not equal.

keypad_test.py still passes, which matters because this file is where deleting
three fprintfs once silently removed the keypad.
2026-08-28 12:52:06 +01:00
mckero c6d58f5601 Surface firmware serial output instead of dropping it
The firmware has been printing all along and nothing was listening. USART1 has no
real model here -- it is one of the logging catch-all stubs -- so every byte went
into qemu_log_mask(LOG_UNIMP) and vanished.

Two things were needed, and the second was not in the plan:

1. Print USART1 DR writes (+0x04, per the vendor CMSIS header) as SERIAL lines.

2. Report TXE|TC in USART1 SR. This is the part I had missed. UART_Send() in
   App/driver/uart.c spins on LL_USART_IsActiveFlag_TXE() with a bounded timeout
   and *skips the byte* when the flag never sets. A stub returning 0 for SR meant
   the firmware discarded its own output before it ever reached DR -- the only
   write arriving was UART_Init()'s priming zero. So step 1 alone produced
   nothing, which is why the first attempt looked like "the build has no logging".

Then a bug of my own: the priming byte is 0x00, I buffered it, and fprintf("%s")
stopped at that NUL and printed an empty line while all 46 bytes sat behind it.
NULs are now dropped, and a line flushes on CR as well as LF.

Verified: SERIAL UV-K5 Firmware, EGZUMER-F4HWN+NR7Y c91cec95
keypad_test.py still passes, which matters because this file is where removing
three fprintfs once silently deleted the keypad.
2026-08-28 06:27:46 +01:00
mckero 2154f80414 Fix the keypad: row_out must be volatile
The previous commit removed three TRACE fprintfs from py32f071.c as
cleanup. That silently broke the keypad completely -- no press reached the
UI, and nothing warned about it.

Root cause is dead-code elimination, not the printing.
qdev_init_gpio_out_named() is inlinable and only records the row_out array;
the lines are filled in later by qdev_connect_gpio_out_named() from board
code, which GCC cannot see. At -O2 GCC therefore proves every element is
still NULL, sees that qemu_set_irq() returns immediately on a NULL irq, and
deletes the body of keypad_update_rows() along with all five calls to it. No
row line is ever driven and the firmware's scan reads all-high.

From the object code:

  callers reaching keypad_update_rows
    plain     none -- the calls are gone
    volatile  keypad_key_changed, keypad_col_changed, keypad_set_press,
              keypad_reset, uvk5_machine_init

keypad_col_changed compiles to a store and a ret with no call at all; with
volatile it ends in jmp keypad_update_rows. Declaring row_out volatile fixes
it at the cause. 10/10 on the press test, 3/3 on keypad_test.py, no build
warnings.

Scoped rather than assumed: PY32GpioState::out is not affected. Marking it
volatile too gives a byte-identical object file, because py32_gpio_write()
is only reachable through a MemoryRegionOps function-pointer table so GCC
cannot enumerate its callers. It stays plain.

Adds tools/keypad_test.py: boots its own instance on private ports and
checks that a short MENU press opens the menu, DOWN moves the cursor, and a
held key is visible to the scan. This is what should have caught the
breakage before it was pushed.

Docs corrected. The breakage had been written up as "power save stops the
keypad scan" and called a gap in the model; it was neither. AGENTS.md now
records the mechanism, the measurements, the objdump check, and the two
measurement traps that made this hard: reading gKeyReading0 after releasing
the key (always KEY_INVALID), and trusting a gdb breakpoint on
KEYBOARD_Poll (with the guest stopped the scan's delays cost no guest time,
so Poll returns KEY_MENU on a build where it fails when running free).

README screenshots regenerated from the current build.
2026-08-27 18:34:41 +01:00
mckero 1c9a2afe55 Fix key.py hold times; the keypad was never broken
key.py held every key for 2500 ms, on the assumption that guest time runs
fast during delays so a press needs a long wall-clock hold. That is wrong
for this path, and it is why the keypad looked dead.

The two SysTick mechanisms are separate. poll-boost accelerates counter
*reads* so SYSTICK_DelayUs converges; it does not speed up interrupt
delivery. Interrupts drive SysTick_Handler -> gNextTimeslice ->
APP_TimeSlice10ms -> CheckKeys at close to real time, so the firmware's
thresholds hold in wall clock as written: 20 ms to register a press,
400 ms to count as held.

2500 ms is ~250 ticks, six times past the long-press threshold, so every
press was dispatched as a hold. MAIN_Key_MENU acts only on a short release
and returns early when bKeyHeld is set, so nothing happened. Confirmed by
reading gDebounceCounter mid-hold: 317 after a 3 s hold, which also proves
the timeslice was running all along.

Now HOLD_MS=200 and LONG_HOLD_MS=900. Verified with screenshots: the menu
opens and UP/DOWN move through it.

Also here:
- Drop the three TRACE fprintfs. They fired on every keypad poll and
  buried the console; the matrix is confirmed working.
- Drop a redundant forward declaration of py32_spi_xfer_byte, silencing
  the only build warning.
- Document the real remaining gap: power save (~6 s after boot) stops the
  keypad scan and the model does not wake from it. Includes the two dead
  ends already ruled out by experiment, so nobody repeats them.
- Add README screenshots captured from guest memory.
2026-08-27 16:45:54 +01:00
mckero c0a09827ed UV-K5 V3 emulator: QEMU machine for the PY32F071
Adds a QEMU machine for the Puya PY32F071 (Cortex-M0+) so Quansheng UV-K5 V3
firmware can run on a PC. The firmware boots to its main loop in about five
seconds and the LCD contents are readable.

Register layouts come from the vendor CMSIS header shipped with the firmware
rather than guesswork. Modelled: RCC, GPIO, ADC, both SPI controllers, DMA1 and
the PY25Q16 flash; everything else answers through a logging catch-all, which is
how the next thing worth modelling gets identified.

Seven things had to be right before it would boot, each found by watching where
the firmware stopped: flash aliased at the application offset, clock ready bits,
self-clearing ADC calibration, SPI transfer flags, DMA-driven flash reads,
SysTick poll acceleration, and the bit-banged transceiver bus idling low.

SysTick needs explanation. SYSTICK_DelayUs polls the counter and accumulates
differences; under emulation a register read costs far more relative to guest
time, so a measured 120 ms delay would have taken about 7.7 hours. Lowering the
clock does not help because the bottleneck is loop iterations, not counter speed.
Reporting a value that runs ahead of the real counter does, via a new poll-boost
property on SysTick. Guest time therefore runs fast during delays: fine for
exercising menus and control flow, wrong for judging signal timing.

Also includes the host build of the CW timing chain (harness, stubs, shim,
tests), which compiles app/cwkeyer.c and app/cwmacro.c unmodified against stub
drivers with a virtual clock and scripted paddle input.

Known gap: keypad rows reach the firmware's scan and KEYBOARD_Poll returns the
right key code, but the UI does not react yet.

Not modelled, and not intended to be: radio behaviour. The transceiver chip has
no public datasheet, so keying envelopes and emissions need real hardware.
2026-08-27 14:59:21 +01:00