mirror of
https://github.com/MCKero6423/uv-k5-v3-emulator.git
synced 2026-10-02 03:15:36 +00:00
76fc72d5edf2040f598d9d8f4b622bc34e64c216
10
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |