mirror of
https://github.com/MCKero6423/uv-k5-v3-emulator.git
synced 2026-10-02 03:15:36 +00:00
flash controller: store ACR/OPTKEYR instead of swallowing them, which is what stopped the factory bootloader from starting slots over the firmware's own serial protocol (0x0720 family); uvk5_socket/uvk5_testenv so a fresh checkout skips instead of failing; web UI slot table and Multiboot button; quick start, CONTRIBUTING, and stop tracking firmware images and radio dumps
182 lines
6.6 KiB
Python
Executable File
182 lines
6.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""RSSI must depend on where the radio is tuned, not be a constant.
|
|
|
|
Why this matters more than the number on the meter. RSSI used to be a fixed value
|
|
comfortably above squelch, which gave the S-meter something to draw but meant the band
|
|
was uniformly and permanently occupied. Scanning, squelch, and every "is this channel
|
|
busy" decision therefore faced a situation that never varied, so none of that logic was
|
|
actually being exercised -- the tests passed without testing anything.
|
|
|
|
Scope, stated plainly: the *shape* is real physics -- power falls off away from a
|
|
carrier, with a noise floor underneath -- and the station list is invented. This
|
|
reproduces "the firmware copes with a band that has signals in some places and not
|
|
others". It does not reproduce any real radio environment, and a dBm figure from here
|
|
is not a claim about the world.
|
|
|
|
Checked here:
|
|
1. tuning to a station gives a strong reading
|
|
2. tuning well away from every station drops to the noise floor
|
|
3. the difference is large enough for squelch to distinguish them
|
|
"""
|
|
|
|
import gzip
|
|
import json
|
|
import os
|
|
import pathlib
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
|
|
import uvk5_socket
|
|
import uvk5_testenv
|
|
|
|
SIM = pathlib.Path(__file__).resolve().parent.parent
|
|
QEMU = uvk5_testenv.qemu()
|
|
ELF = uvk5_testenv.firmware()
|
|
PRISTINE = SIM / "assets/pristine/flash-pristine.img.gz"
|
|
|
|
BOOT_SECONDS = 24
|
|
BK_PATH = "/machine/bk4819"
|
|
|
|
# A station in the model's table, and a frequency far from all of them.
|
|
ON_STATION_HZ10 = 40000000 # 400.000 MHz
|
|
OFF_STATION_HZ10 = 41000000 # 410.000 MHz, several MHz clear of anything
|
|
|
|
|
|
class Qmp:
|
|
def __init__(self, path):
|
|
self.s = uvk5_socket.connect(path, timeout=25)
|
|
self.buf = b""
|
|
self._read()
|
|
self.cmd("qmp_capabilities")
|
|
|
|
def _read(self):
|
|
while b"\n" not in self.buf:
|
|
chunk = self.s.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.s.sendall(json.dumps(msg).encode() + b"\n")
|
|
while True:
|
|
reply = self._read()
|
|
if "return" in reply or "error" in reply:
|
|
return reply
|
|
|
|
def reg(self, num):
|
|
return self.cmd("qom-get", path=BK_PATH,
|
|
property=f"reg{num:02x}").get("return")
|
|
|
|
def key(self, name, hold=0.15):
|
|
self.cmd("qom-set", path="/machine/keypad", property="press", value=name)
|
|
time.sleep(hold)
|
|
self.cmd("qom-set", path="/machine/keypad", property="press", value="")
|
|
|
|
def type_frequency(self, mhz_digits):
|
|
"""Enter a frequency on the keypad, as a user would.
|
|
|
|
Deliberately not poking REG_38/REG_39 directly: that would test the model
|
|
against itself. Going through the firmware means the tuning path is exercised
|
|
too.
|
|
"""
|
|
for ch in mhz_digits:
|
|
self.key(ch, hold=0.12)
|
|
time.sleep(0.25)
|
|
|
|
|
|
def rssi_after_tuning(qmp, digits, settle=4):
|
|
qmp.type_frequency(digits)
|
|
time.sleep(settle)
|
|
# Engage monitor so the receiver is actually running and polling.
|
|
qmp.key("SIDE1")
|
|
time.sleep(3)
|
|
tuned = (qmp.reg(0x39) << 16) | qmp.reg(0x38)
|
|
return qmp.reg(0x67), tuned
|
|
|
|
|
|
def main():
|
|
for tool, what in ((QEMU, "QEMU"), (ELF, "firmware"), (PRISTINE, "pristine flash image")):
|
|
if tool is None or not tool.exists():
|
|
return uvk5_testenv.skip("%s is missing (%s); see the README Quick start"
|
|
% (what, tool or "not found"))
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
img = pathlib.Path(tmp) / "flash.img"
|
|
img.write_bytes(gzip.decompress(PRISTINE.read_bytes()))
|
|
sock = uvk5_socket.server_endpoint("qmp", directory=str(tmp))
|
|
|
|
proc = subprocess.Popen(
|
|
[str(QEMU), "-M", f"uv-k5-v3,flash-image={img}",
|
|
"-nographic", "-monitor", "none",
|
|
"-qmp", sock,
|
|
"-kernel", str(ELF)],
|
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
try:
|
|
# Qmp() below connects, and uvk5_socket retries until QEMU's QMP answers, so
|
|
# there is no socket path to wait for -- on Windows there would not be one.
|
|
time.sleep(BOOT_SECONDS)
|
|
time.sleep(BOOT_SECONDS)
|
|
|
|
qmp = Qmp(str(sock))
|
|
failures = 0
|
|
|
|
# The radio boots tuned to 400.000, which is a station in the table.
|
|
on_rssi = qmp.reg(0x67)
|
|
tuned = (qmp.reg(0x39) << 16) | qmp.reg(0x38)
|
|
print(f"tuned {tuned / 100000:.5f} MHz (a station): RSSI 0x{on_rssi:04X}")
|
|
|
|
if tuned != ON_STATION_HZ10:
|
|
print(f"note expected {ON_STATION_HZ10 / 100000:.5f} MHz at boot; "
|
|
"the comparison below is still valid")
|
|
|
|
# Tune away by typing a new frequency: 410.000 MHz.
|
|
off_rssi, off_tuned = rssi_after_tuning(qmp, "410000")
|
|
print(f"tuned {off_tuned / 100000:.5f} MHz (empty): "
|
|
f"RSSI 0x{off_rssi:04X}")
|
|
|
|
if off_tuned == tuned:
|
|
print("FAIL the frequency did not change; cannot compare")
|
|
return 1
|
|
|
|
if on_rssi > off_rssi:
|
|
print(f"PASS RSSI depends on tuning "
|
|
f"(0x{on_rssi:04X} on station, 0x{off_rssi:04X} off)")
|
|
else:
|
|
print(f"FAIL RSSI did not drop away from the station "
|
|
f"(0x{on_rssi:04X} -> 0x{off_rssi:04X})")
|
|
failures += 1
|
|
|
|
# REG_67 is 0.25 dB/step, so 0x80 is 32 dB -- far more than any squelch
|
|
# hysteresis, i.e. the two cases are unambiguously distinguishable.
|
|
gap = on_rssi - off_rssi
|
|
if gap >= 0x80:
|
|
print(f"PASS the gap is {gap * 0.25:.0f} dB, enough for squelch "
|
|
"to tell them apart")
|
|
else:
|
|
print(f"FAIL the gap is only {gap * 0.25:.0f} dB; squelch could not "
|
|
"reliably distinguish a busy channel from an empty one")
|
|
failures += 1
|
|
|
|
if failures:
|
|
return 1
|
|
print("\nthe band has signals in some places and not others")
|
|
return 0
|
|
finally:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=10)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|