Files
mckero b855b0b047 Let the page ask the radio whether it sees an installed app
GET /api/apps/radio opens the firmware's serial port and sends 0x0730 for all sixteen slots, so the answer comes from the running firmware rather than from our reading of the file -- which is the check that matters, because the bytes can be right and the firmware still refuse a slot. Measured through the page after installing Beam.app into slot 0: slot 0 -> Beam 1.0, 1100 B, crc 0xd976058, shortcut beam, committed; slots 1..3 -> status 2 with unrelated data, the resource-block overlap the install guard refuses. A button beside the table asks it and shows the answer in its own column.

The server gives its own emulator a serial port (--serial-port, default 4445) and uvk5_slots_serial.Radio gained a public app_info(slot), so nothing reaches into a private helper. uvk5_apps.parse_radio_reply decodes the answer and is tested without a radio. Also recorded: QEMU needs the mingw64 DLLs on PATH, and started by hand without them it exits before opening QMP, which surfaces only as 'QMP socket never appeared'.
2026-10-01 17:25:32 +08:00

36 KiB

UV-K5 V3 emulator

Runs Quansheng UV-K5 V3 / UV-K1 firmware on a PC. The radio uses a Puya PY32F071 (Cortex-M0+), which QEMU has no machine for, so this adds one.

The firmware boots to its main loop in about five seconds, the LCD contents are readable, and the keypad drives the menus. See Status for what is and is not modelled.

中文:README.zh-CN.md · the two are kept in step; change both.

Main screen Menu Navigated with keys
main VFO screen menu at Step menu at RxDCS

Real captures, not mock-ups: tools/screenshot.py reads the firmware's gFrameBuffer out of guest memory and renders it, so these are the pixels the LCD driver actually wrote. Left to right: the dual-watch main screen, the menu opened with key.py MENU (entry 01/79, Step), and 03/79 after key.py DOWN DOWN.

What it is for

Editing firmware and reflashing a radio to test one line is slow, and some bugs are invisible from the outside. A recent example: CW macro recording appeared to do nothing, and the cause was three layers down -- the keyer was being torn down by a later call that recomputed its state from the wrong VFO. On hardware you see "nothing happens"; here you can read the actual variables.

What it does not do is model radio behaviour. It reproduces what the firmware commanded -- frequency, power step, carrier keying in time -- not the analogue result. Keying envelopes, spurious emissions and sensitivity need a real radio and a spectrum analyser. That is not a gap to be closed later; the transceiver chip has no public datasheet, so its driver is the only specification available.

Status

Area State
Boot to main loop works, ~5 s
LCD contents readable via tools/screenshot.py
Display contrast / inversion panel settings, read from the controller; inversion also changes the rendered picture
SPI flash, settings, calibration works, and persists across power cycles
Frequency entry works, stored per band and kept
Keypad and menu navigation works, including waking from power save
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)
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
millis() / TIM2 works; advances at roughly wall-clock rate
Timing accuracy deliberately wrong, see Timing
Analogue RF behaviour not modelled and never will be, see AGENTS.md

A short tools/key.py MENU opens the menu, UP/DOWN move through it, MENU enters a submenu, and typing a menu number jumps straight to that entry. Press duration decides short versus held, which the firmware treats as different events -- see Timing.

Press duration is the thing to get right. A hold of 400 ms or more is a long press, and handlers act on it differently: MAIN_Key_MENU opens the menu on a short release and does nothing on the hold path. If a key seems ignored, shorten the press rather than lengthening it. Waking from power save needs nothing special -- one 200 ms press both wakes the radio and opens the menu, verified after 45 s of idle.

tools/keypad_test.py checks all of this against a throwaway QEMU instance. It exists because the keypad has one non-obvious trap: the keypad model's row_out array must stay volatile, or GCC at -O2 proves the lines are still NULL and deletes every call to keypad_update_rows(), so no row is ever driven and keypresses silently stop working. Run the test after touching that code; AGENTS.md has the object-code evidence.

Layout

qemu/                    QEMU sources to be copied into a QEMU tree
  py32f071.c             the SoC and machine (the bulk of the work)
  armv7m_systick.*.patched  SysTick with the poll-boost property added
assets/                  flash.img, plus pristine/ as the reference copy
  calibration.bin        512-byte dump from a real radio
deploy/                  nginx vhost for the HTTPS front end
docs/reverse-proxy.md    how https://k6v3.mckero.dn42/ is served
*.zh-CN.md               Chinese translations, kept in step
docs/screenshots/        LCD captures used in this README
tools/                   run, screenshot, inject keys, probe state
  bin2elf.py             wrap a release .bin so QEMU can load it as a kernel
  make_flash.py          build assets/flash.img; --blob puts extra data (the
                         font packs a Chinese build needs) at chosen offsets
  keypad_test.py         keypad regression test, boots its own instance
  test_flash_persist.py  flash writes survive a power cycle
  test_freq_entry.py     a typed frequency takes effect and persists
  test_serial_rx.py      the firmware answers programming commands
  test_bk4819.py         BK4819 register interface, RSSI not stuck at zero
  test_bk4819_readback.sh  register reads come back bit-aligned
  test_smeter.py         the S-meter reads a signal when monitoring
  test_ptt.py            PTT keys the radio and releases cleanly
  test_scan.py           a busy band does not stall a scan
  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
  check_docs.py          the docs' claims still match the code
  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
  webui.py               web remote control: live LCD plus clickable keypad
  dn42_firewall.sh       restrict the web UI port to DN42 sources
  restore_flash.sh       roll the flash image back to its pristine state
  uvk5_qmp.py            QMP client
  uvk5_lcd.py            framebuffer decode, PNG encode, frame grabber
  uvk5_keys.py           key names the keypad model accepts
  uvk5_logs.py           shared log buffer, with client-IP attribution
  uvk5_stream.py         the MJPEG-style frame pump behind /stream
  uvk5_supervisor.py     starts, stops and recovers the emulator process
  test_kill_emulator.sh  cleanup never kills an unrelated process
  uvk5_elf.sh            where the probe scripts find a firmware (env, then the checkout)

(plus ad-hoc probe scripts -- scan_trace.sh, gpio_watch.py and friends -- kept because they are quick to reach for, not because they are polished) harness/, stubs/, shim/, tests/ host build of the CW timing chain (stage A)

Running it elsewhere: CI and a container

.github/workflows/unit.yml installs requirements-dev.txt (flask) and runs tools/run_tests.sh -q on every push and pull request: the test_uvk5_*.py unit tests plus check_docs.py, which need no emulator, no firmware and no QEMU build. That path is what keeps the suite honest on a machine that is not this one -- before it existed, the runner's default paths were the author's, and a fresh clone could not run anything without editing it.

Dockerfile builds a box with the same tooling, and can run the emulator tests if you give it a QEMU tree, because tools/setup_qemu.sh patches a tree rather than downloading one:

docker build -t uvk5 . && docker run --rm uvk5                  # unit tests
docker run --rm -v /path/to/qemu-7.2:/qemu-7.2 -e QEMU_SRC=/qemu-7.2 uvk5 \
    bash -lc 'bash tools/setup_qemu.sh && bash tools/run_tests.sh'

The file:line checks in check_docs.py need the firmware sources, which are not in this repository. Without them the checker skips those checks and says so; point UVK5_FW_DIR at a tree to have them run.

What is not in this repository

Two things are deliberately absent, and neither should be committed:

  • Firmware. Released images, localised builds and bootloader dumps belong to whoever made them, not to this project. tools/fetch_firmware.py fetches a release from the upstream archive into assets/firmware/ when a test or a run needs one, and that directory is ignored.
  • Anything read off a real radio. work/data.bin is an EEPROM dump -- settings and calibration from somebody's hardware. It is not a build artifact. It is ignored now, and the tests build their own flash images from assets/pristine/ instead.

assets/pristine/flash-pristine.img.gz and assets/calibration.bin do ship: they are a 2 KB synthetic pair that tools/make_flash.py assembles into a flash image, not data from a radio.

The rest of work/ is scratch -- images, logs, captures from a debugging session. The four scripts in it are tracked on purpose, because they document how this machine is driven; everything else is ignored.

If any of that is already in the history, removing it now is not enough. The objects stay reachable, so publishing this repository needs the history filtered (git filter-repo) or a fresh one. Check before pushing:

git log --stat -- work/data.bin assets/firmware

Quick start

Five minutes from a checkout to a running radio on a web page.

# 1. A QEMU 7.2 tree, patched and built. Building by hand means copying three files
#    into the tree, editing Kconfig and meson.build, then configure and ninja; this
#    is those same steps (see Building below for what it does).
QEMU_SRC=~/src/qemu-7.2 bash tools/setup_qemu.sh

# 2. A firmware to run. Firmware is not redistributed here -- this fetches a release
#    from the upstream project's archive into assets/firmware/ and prints its hash.
python3 tools/fetch_firmware.py

# 3. The external flash image the firmware keeps its settings in.
python3 tools/make_flash.py

# 4. Run it.
python3 tools/webui.py --qemu ~/src/qemu-7.2/build/qemu-system-arm \
    --elf assets/firmware/f4hwn.fieldops.v6.0.0.bin     # then open http://127.0.0.1:8080/

--frame-addr and --status-addr are optional, and normally omitted. The page draws the display controller's own memory, which is the screen for every firmware and needs no addresses at all; those two only serve the guest-RAM fallback, and the addresses are read out of whatever firmware is running by tools/uvk5_buffers.py. Giving one on the command line skips that search. Nothing in this repository holds a build's addresses.

Drop any .bin on the page to boot it. The page's Firmware slots table reads and writes the multi-system firmware's four slots in the flash image, and Multiboot restarts holding MENU so its boot menu comes up -- in a build that has one: the page labels builds that do not, because there the button can do nothing at all.

Then check it still works:

bash tools/run_tests.sh -q     # ~15 s, no emulator
bash tools/run_tests.sh        # everything; needs the tree from step 1

Building

Needs a QEMU 7.2 source tree, meson, ninja, libfdt-dev, libglib2.0-dev, libpixman-1-dev.

# 1. Drop the sources into a QEMU tree
cp qemu/py32f071.c                   $QEMU/hw/arm/
cp qemu/armv7m_systick.c.patched     $QEMU/hw/timer/armv7m_systick.c
cp qemu/armv7m_systick.h.patched     $QEMU/include/hw/timer/armv7m_systick.h

# 2. Register the machine. In $QEMU/hw/arm/Kconfig:
#      config UVK5_V3
#          bool
#          default y
#          depends on TCG && ARM
#          select PY32F071_SOC
#      config PY32F071_SOC
#          bool
#          select ARM_V7M
#          select UNIMP
#    In $QEMU/hw/arm/meson.build:
#      arm_ss.add(when: 'CONFIG_UVK5_V3', if_true: files('py32f071.c'))

# 3. Build just the ARM target
cd $QEMU
./configure --target-list=arm-softmmu --disable-docs --disable-tools
cd build && ninja qemu-system-arm

Then check the build actually works, which takes about a minute:

bash tools/run_tests.sh        # everything, a few minutes
bash tools/run_tests.sh -q     # unit tests only, ~15 s, no emulator

The runner checks the build first and refuses to continue if it fails, because ninja leaves the previous binary in place and the tests would otherwise pass against code that was never compiled. Individual tests still run standalone:

python3 tools/keypad_test.py
python3 tools/test_flash_persist.py
python3 tools/test_freq_entry.py
python3 tools/test_serial_rx.py
python3 tools/test_slot_serial.py
python3 tools/test_bk4819.py
bash tools/test_bk4819_readback.sh
python3 tools/test_smeter.py
python3 tools/test_ptt.py
python3 tools/test_scan.py
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 -- so a clean build is not evidence that keypresses work. The other two cover the flash path, where four separate faults each ended up zeroing stored frequencies without producing any error: details in AGENTS.md.

The rest of the tests:

cd tools && python3 -m unittest discover -p 'test_uvk5*.py' -v  # fast, no emulator
cd tools && python3 -m unittest test_webui -v                   # fast, no emulator
python3 tools/test_webui_e2e.py                                # boots its own emulator

Running

python3 tools/make_flash.py     # once, builds assets/flash.img

The emulator writes to that image, so a session can leave edited settings or a damaged EEPROM behind. assets/pristine/ holds a checksummed copy of the image as first generated, and tools/restore_flash.sh puts it back:

tools/restore_flash.sh --verify   # is the reference copy itself intact
tools/restore_flash.sh --diff     # has the live image changed, and by how much
tools/restore_flash.sh            # restore, saving the current image first

The reference copy is stored gzipped, which takes 2.3 KiB rather than 2 MiB because the image is nearly all 0xFF, so it is small enough to keep in git. The live image stays ignored: it is a build artifact that gets written to.

tools/run.sh                    # starts the machine

tools/where.sh                  # where the firmware is executing
python3 tools/uvk5_buffers.py --qmp 127.0.0.1:4444    # where this firmware keeps them
python3 tools/screenshot.py --frame-addr 0x... --status-addr 0x... \
    --port 1234 --out screen.png
python3 tools/key.py MENU       # inject a keypress
tools/gpiob_dump.sh             # GPIOB registers

The machine exposes a GDB stub on port 1234 and a QMP socket at /tmp/uvk5-qmp.sock. It is headless: the screen is read out of guest memory rather than drawn, so no display backend is needed.

Screenshots need the addresses of gFrameBuffer and gStatusLine, which move between builds. Find them with:

arm-none-eabi-nm firmware.elf | grep -E 'gFrameBuffer|gStatusLine'

Web remote control

tools/webui.py serves the LCD and a clickable keypad, so the radio can be driven from a browser instead of key.py plus screenshot.py.

tools/run.sh                                   # emulator first
python3 tools/webui.py                         # no addresses: it draws the panel

Open http://127.0.0.1:8080/. The keypad is laid out like the radio, with the side keys alongside. Arrow keys, Enter (MENU), Esc (EXIT) and the digits are bound to the physical keys.

Press duration comes from how long you actually hold the button, because the firmware treats anything past 400 ms as a held key and dispatches it as a different event. The browser sends the two edges separately rather than asking the server for a fixed-length press.

Endpoints, if you want to script it:

Route Purpose
GET / the page
GET /stream multipart PNG stream, up to 15 fps
GET /frame.png one frame
POST /api/key {"key": "MENU", "action": "down"} — also up or tap
POST /api/ptt {"held": true} — hold PTT, false to release
POST /api/release-all release every key and PTT, if one ever sticks
POST /api/power/<action> on, off, reset, pause, resume
GET /api/logs?since=N log entries after cursor N, with client IPs
GET /api/status QMP query-status, plus speaker, panel and firmware
GET /api/firmware the loaded image, and how it will be loaded
POST /api/firmware body is a .bin or .elf; boots it and restarts the emulator
GET /api/slots the firmware slots in the flash image the emulator uses
POST /api/slots/<n> body is a .bin; writes it into slot n and restarts
POST /api/slots/<n>/erase erase slot n
GET /api/apps the Labs edition's overlay-app slots in the same flash image
POST /api/apps/<n> body is a .app; installs it into app slot n (add ?force=1 to overwrite data that is not an app)
POST /api/apps/<n>/erase clear app slot n
GET /api/apps/radio ask the running firmware what it sees in each app slot (0x0730)
POST /api/flash body is a flash image; use it from now on

Frames now come from the display controller's own memory: a QMP qom-get on the panel's gram property, so the picture is right whoever wrote the firmware and wherever it keeps its buffers. Builds that share an ancestor still differ in their display logic -- the multi-system release keeps its image somewhere else entirely.

The older path reads gFrameBuffer and gStatusLine out of guest RAM with QMP memsave (~1.35 ms per frame) and remains as the fallback for an emulator built without the panel model. The two cautions below apply to that path:

  • memsave, not pmemsave. The framebuffer symbols are CPU virtual addresses. pmemsave treats its argument as physical and returns a block of zeros, so the screen renders blank with no error anywhere.
  • Not gdb. screenshot.py reads frames through gdb, which halts the guest on every attach. That is unusable for a live stream and it also perturbs key debounce timing.

Two constraints worth knowing before you use it:

  • The QMP socket takes one client. While the server is up, tools/key.py cannot talk to the same emulator.
  • There is no authentication. Anyone who reaches the port has full control of the emulated radio. It binds loopback by default for that reason.

Loading firmware from the page

Drop a .bin anywhere on the page, or use the Firmware control, and the server stores it and boots it. No ELF wrapping, no address to look up.

Two shapes exist and they need different load addresses:

shape how it is recognised load address
application reset handler past 0x08002800 0x08002800
full-flash reset handler inside the bootloader region (0x08000000..0x080027ff) 0x08000000

An .elf carries its own program headers and needs neither. This is read out of the image's first two words -- tools/uvk5_image.py on the host, uvk5_sniff_app_offset() in the machine -- rather than taken from a flag or a file name, because getting it wrong is silent: the image lands 0x2800 bytes off and the first fetch reads whatever data is there. A file that is not a bootable image is refused with 400 and the radio keeps running what it had.

Uploads live in work/firmware/ (UVK5_UPLOAD_DIR moves it), and while the emulator is running an upload restarts it, since the image is chosen when QEMU is spawned.

Both shapes are verified end to end here: an application .bin, an .elf, and a full-flash image whose bootloader entry branches to the application at 0x08002800 all reach the same drawn screen.

Firmware slots, and the multi-system release

The v6.0.0 release keeps a boot menu and four firmware slots in the external flash: hold MENU at power-on and it lists them, and choosing one reflashes the internal flash from that slot and resets. Both halves are reachable from the page.

  • Firmware slots shows one row per slot with the name, version, size and whether the header CRC-32 matches the image. Write a .bin into a slot, or erase one. Edits go to a working copy of the flash image (work/firmware/flash-current.img), never to the file the server was started with, and the emulator is restarted to pick them up.
  • Multiboot (or Shift+M) restarts the emulator with MENU held from reset. The page cannot do that with key events, because the firmware samples the keypad in the first milliseconds after reset. On the machine it is -M uv-k5-v3,boot-key=MENU or UVK5_BOOT_KEY, held for UVK5_BOOT_KEY_MS (8 s by default: the boot path can spend 20 s adopting the running firmware into slot 0 before anything samples the keypad).
  • tools/uvk5_slots.py does the same offline: write a slot into a flash image, and print what each slot holds.

The readback guard, and a draft that is not one yet

tools/test_bk4819_readback.sh guards the reading-shift bug -- a register read delivering its value one bit off. It needs an ARM gdb, so it only runs where one is installed. tools/test_bk4819_readback.py is the portable replacement in progress, and it is not the guard yet: it passes on the working model, but removing the fix does not make it fail, so it does not observe what the guest actually samples. It is deliberately not registered in tools/run_tests.sh, so that a proven guard is not replaced by an unproven one. The observation point has to move to the driver's sampling edge before it takes over.

Which build renders correctly, and how that is decided

The page draws the display controller's own memory, not the firmware's framebuffer, so it does not need to know where a given build keeps its screen -- and tools/panel_dump.py shows the same thing from a shell, one character per pixel, so two builds can be diffed:

tools/panel_dump.py --qmp 127.0.0.1:4444                 # ASCII
tools/panel_dump.py --qmp 127.0.0.1:4444 --png shot.png  # scaled PNG

That path is faithful for the builds measured here. Against its own framebuffer the 5.9.0.CN image agrees 8188 of 8192 pixels, and the fetched 6.0.0 build renders byte-identical to it. Both program the same panel registers -- 0xA1 segment reverse, 0xC0, 0xA6, columns 0..127, start line 0 -- and that is the point: the registers do not decide the mapping, the driver does, because one driver compensates for the panel's segment order in software and another may not. So the mapping cannot be derived from the controller's settings; it has to be measured (--mapping exists for that).

Two things the panel model does not yet honour, either of which will misplace pixels in a build that uses them: the display start line (0x40|n, a vertical scroll), and the controller's 132 columns -- the model stores pixels at col - 4 and wraps the column counter at 128, so a driver that addresses 4..131 loses its first four pixels and shifts the row. Both are on the list; neither affects the builds measured above.

Moto/DFU flashing, and the flag this build does not set

The factory bootloader is on the machine and it does speak the flashing protocol: a real 0x0518 / 0x0530 / 0x0519 exchange at 38400 baud, in the 10 KB before the application. What it will not do is enter that mode from the outside. Measured, not assumed:

an attempt at entering DFU what actually happened
PTT held from reset an ordinary boot. The firmware's own BOOT_GetMode() returns BOOT_MODE_NORMAL without a second key
PTT+SIDE1, PTT+SIDE2, MENU the application's special modes (F_LOCK, AIRCOPY, MULTIBOOT), never the bootloader
a host byte during the boot window, 0x0530 included ignored; the PC never leaves the application region
the firmware's own 0x05DD reset command a plain reset, straight back into the application

The bootloader's decision is one byte: ldrb r0,[r4] with r4 = 0x20000020, compared against 1, 2 and 3, where only 3 reaches the DFU handler. That byte is in SRAM, so it survives a soft reset and nothing else: the program already running has to write it and reset. In the firmware that is overlay_FLASH_RebootToBootloader(), and the 0x05DD path takes it only when the build defines ENABLE_OVERLAY:

case 0x05DD: // reset
    #if defined(ENABLE_OVERLAY)
        overlay_FLASH_RebootToBootloader();
    #else
        NVIC_SystemReset();          <-- what this build does
    #endif

So MOTO flashing is not waiting on the emulator. The bootloader runs, its DFU handler is present, and the entry condition is known and reproducible; this build is simply not compiled with the one flag that reaches it. The multi-system release has the same property for the same reason -- compare the note the page prints for a build with no boot menu. The layout is the firmware's, from App/driver/mb_flash.h: slot 0 at 0x020000 backs up the internal image, slots 1..4 follow at 0x040000 in 128 KiB steps, the image starts one 4 KiB sector into the slot, and the 64-byte header carries magic FMB1, the image size and a CRC-32. The firmware's own 0x0720..0x0727 serial commands write slots the same way, which is what the Windows tools use.

Two behaviours look like the emulator misbehaving and are not. A corrupt active-state marker next to a valid slot 0 makes the firmware halt on a STATE ERROR screen to protect Main, and a missing marker makes it adopt the running firmware into slot 0 -- reflashing the external flash -- before the menu appears. Writing a slot erases those marker sectors so it can decide again. The internal flash is programmable in the model (0x40022000: unlock, page erase, program, EOP, never busy), so restoring a slot really does replace the image the CPU executes after the reset.

Reaching it from elsewhere

The deployment here runs the server on loopback and puts nginx in front of it for TLS, at https://k6v3.mckero.dn42/. See docs/reverse-proxy.md for the vhost, including the two settings that matter for this app: proxy_buffering off (or the frame stream arrives in bursts) and X-Forwarded-For (or every log line is attributed to 127.0.0.1).

Binding directly with --host :: also works, but with no authentication the port then has to be filtered by source address. tools/dn42_firewall.sh restricts it to DN42:

tools/dn42_firewall.sh apply 8080     # DN42 + loopback only
tools/dn42_firewall.sh show  8080     # rules and packet counts
tools/dn42_firewall.sh remove 8080

One detail that is easy to get wrong: this host's INPUT policy is ACCEPT, so a rule that only allows DN42 changes nothing -- the port is already reachable with no rules at all. The rule that does the work is the final DROP. Verify by watching the counters rather than by assuming:

tools/dn42_firewall.sh show 8080
# a rising DROP count means non-DN42 traffic is actually being rejected

The rules do not survive a reboot. Re-run apply, or persist them with iptables-persistent.

PTT is separate from the keypad grid, because the firmware reads its own pin (PB10) rather than scanning it as a matrix key. It has its own button in the UI and its own endpoint, and it is held rather than tapped:

curl -X POST -H 'Content-Type: application/json' \
    -d '{"held": true}' http://127.0.0.1:8080/api/ptt

Anything that ends a session releases it — dragging off the button, closing the tab, or POST /api/release-all — so a client going away cannot leave the radio keyed. The press property still rejects "PTT" as a key name; unknown keys get a 400 rather than being forwarded.

Audio

There is no audio, and there is nothing to add. On the real radio neither the speaker nor the microphone passes through the MCU: receive audio is demodulated inside the BK4819 and leaves it as analogue on its AF output, and transmit audio goes from the microphone straight into the chip's own ADC. The firmware only ever touches three things:

PA8 the amplifier enable, on or off
REG_47 which AF source the chip routes
REG_64 a level the firmware displays

No audio samples exist anywhere in the MCU's address space, so the emulator has nothing to capture or play — and the browser page needs no microphone or playback permission, because there would be nothing for it to carry. Generating sound here would mean inventing data the firmware never produced.

What is real is whether the firmware currently wants sound, which PA8 states exactly. The UI shows it as a speaker glyph next to the power state, and /api/status reports it as speaker. Press SIDE1 to engage monitor and it lights up.

How the machine is put together

Register layouts come from the vendor CMSIS header shipped with the firmware (Drivers/CMSIS/Device/PY32F071/Include/py32f071xB.h), not from guesswork.

FLASH  0x08000000  128 KB   application at +0x2800, bootloader below it
SRAM   0x20000000   16 KB
RCC    0x40021000
GPIO   0x50000000   ports A, B, C, F at 0x400 intervals
SPI1   0x40013000   display
SPI2   0x40003800   flash
ADC1   0x40012400

Modelled: RCC, GPIO, ADC, both SPI controllers, DMA1, TIM2, the PY25Q16 flash, and the ST7565 display controller's own settings (contrast, inversion, display on/off). Everything else answers through a logging catch-all — the log is how the next thing worth modelling gets identified.

Seven things had to be right before the firmware would boot, each found by watching where it stopped:

  • Flash alias at the application offset. The core fetches its vector table from address 0, and the image loads at 0x08002800, so 0 has to alias there and not at the flash base.
  • Clock ready bits. BOARD_Init polls them; each enable bit is mirrored into its ready bit.
  • ADC calibration. CR2.CAL is write-1-to-start and hardware-cleared, so it must never be stored set or the wait loop never exits.
  • SPI flags. Transfers complete inside the register write, so TXE stays asserted and RXNE is raised by the write.
  • DMA. The flash driver never touches the SPI data register — it arms channels 4 and 5, enables the transfer-complete interrupt and spins on a flag its ISR sets.
  • SysTick. See below.
  • Transceiver data line. RADIO_SetupRegisters waits for bit 0 of the BK4819 REG_0C to clear. The bus is bit-banged over GPIO, so PB9 idles low until that bus has a real model, making reads return zero.

Timing

SYSTICK_DelayUs polls the SysTick counter and accumulates differences. On hardware each loop iteration advances the counter by tens of ticks; under emulation a register read costs far more relative to guest time, so the counter barely moves per read. Measured: a 120 ms delay advanced 832 of 5,760,000 required ticks in four seconds — about 7.7 hours to complete.

Lowering the clock does not help, which is worth knowing before trying it: the bottleneck is loop iterations per second, not counter speed. Dropping 48 MHz to 200 Hz gained only 32x.

What works is reporting a counter value that runs ahead of the real one, growing with every read. The poll-boost property on SysTick does that. Two earlier attempts wrote the value back into the timer instead, which made each read re-anchor the count — the reported value stopped changing, the firmware's if (cur != prev) guard never fired, and the loop hung outright.

The consequence is that guest time runs fast during any delay. Fine for exercising menus and control flow; wrong for judging signal timing.

poll-boost accelerates counter reads only. SysTick interrupts still fire at close to real time, and those are what drive SysTick_Handler -> gNextTimeslice -> APP_TimeSlice10ms -> CheckKeys. So the firmware's 10 ms timeslice thresholds hold in wall clock: a key must be down for 20 ms to register and 400 ms makes it a long press.

Keeping those two apart matters. tools/key.py originally held keys for 2500 ms on the assumption that guest time ran fast here too, which turned every press into a long press. Handlers that act on a short release — MAIN_Key_MENU among them — ignored all of it, and the keypad looked broken when it was not.

Stage A: the CW timing chain on the host

harness/, stubs/, shim/ and tests/ compile app/cwkeyer.c and app/cwmacro.c unmodified against stub drivers, with a virtual clock and scripted paddle input. Feed a timeline of contact closures, assert on the decoded characters and element durations.

Firmware sources are compiled as-is on purpose. Editing them to make them build on a host would let the tests drift from what the radio runs. The debounce in CW_ReadKeys is transcribed rather than stubbed, because its asymmetry (three consecutive reads to register a press, immediate release) is part of the timing behaviour under test.

The display controller's own settings

Contrast (SetCtr) and display inversion (SetInv) are commands to the ST7565, not framebuffer content — 0x81 <value> and 0xA6/0xA7 — so gFrameBuffer does not change and anything that renders that buffer shows no effect at all. That is why there is a small TYPE_ST7565 behind SPI1 (A0 on PA6, CS on PB2, the pins App/driver/st7565.c uses). It parses the command stream and exposes three read-only properties:

qom-get /machine/panel invert        # last of 0xA6 / 0xA7
qom-get /machine/panel contrast      # the value following 0x81
qom-get /machine/panel display-on    # last of 0xAE / 0xAF

tools/uvk5_lcd.py applies the inversion when it renders, because that effect is fully determined, so the menu entry is visible in the web UI. Contrast is analogue — how dark the glass gets — and is only reported. /api/status carries all three and the page shows them beside the speaker glyph. display-on is reported but not acted on: whether a software reset (0xE2) clears that latch is not certain, and blanking the screen on a guess would be worse than leaving the image alone.

On Windows

The emulator, the models and the tools are portable; the packaging was not. Four things differ, and all four are handled in-tree now:

  • QMP over TCP. A Windows build of QEMU cannot create a unix socket, so an endpoint may be host:port as well as a path — tools/uvk5_qmp.py, tools/key.py and tools/uvk5_supervisor.py all accept both.
  • One build fix. MSYS2's mingw-w64 packages build QEMU 7.2 as-is except that qemu/py32f071.c needs #include "qapi/visitor.h" for visit_type_uint64, which a stock tree does not pull in transitively.
  • A release .bin is not a kernel image. armv7m_load_kernel() loads a raw binary at the address it is handed, which here is the flash alias, so a .bin lands 0x2800 bytes too high and never boots. tools/bin2elf.py wraps it in an ELF32/ARM header with the right program header, which is what -kernel wants.
  • The Chinese font packs live in the SPI flash, not in the firmware: tools/make_flash.py --blob 0:pack.uf2 places every UF2 block at its own target address. Without that the font area reads as 0xFF.

work/ holds a Windows bring-up record: the launcher scripts, the frame addresses proven against the firmware source, and the failures that cost time.

Licence

Apache 2.0, see LICENSE.

One exception: qemu/py32f071.c is licensed GPL-2.0-or-later, as its header states. It is built into QEMU and derives from QEMU's device models, which are GPL-2.0, so it cannot be anything else. The tools, harness and documentation are Apache 2.0.

Credits

Base firmware: armel/uv-k1-k5v3-firmware-custom. Register definitions from the vendor CMSIS headers.