diff --git a/.gitignore b/.gitignore index a79b0ea..e7d593e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,41 @@ build/ # Python bytecode from the tools tests. __pycache__/ *.pyc + +# Firmware is not ours to redistribute, and the localised builds in particular belong +# to whoever made them. tools/fetch_firmware.py puts a release in assets/firmware/ on +# request; nothing there is committed. +assets/firmware/ + +# The radio's EEPROM dump: settings and calibration from a real radio, which is not a +# build artifact. The tests build their own images from assets/pristine/ instead. +work/data.bin +work/f4hwn/ +work/**/*.img +work/**/*.bin +work/**/*.elf +work/**/*.uf2 + +# Scratch output from a debugging session. The scripts stay: they document how this +# machine is driven, and running one is how you reproduce what they describe. +work/**/*.log +work/**/*.png +work/**/*.js +work/**/*.tmp +work/py*/ +work/serial_probe.py +work/serial_write_probe.py +work/qmp.py +work/qmp_bridge.py + +# Junk that a Windows redirect created once: literal "%SystemDrive%" directories full +# of cache databases. They were never meant to be here. +%SystemDrive%/ +**/%SystemDrive%/ +# work/ is scratch -- images, logs, QEMU stderr, captures. The four scripts and the +# README are the parts worth keeping, so whitelist those rather than trying to list +# every extension a debugging session produces (a rule that missed one let four +# QEMU stderr logs get staged). +work/* +!work/*.ps1 +!work/README.md diff --git a/AGENTS.md b/AGENTS.md index b054480..c0292e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,8 +76,9 @@ save byte, `0x0E70` for the VFO indices, and so on. No metadata, no directory, n checksum -- just an address that the code and the data both have to agree on. When a setting reads back wrong, suspect the offset before suspecting the transport. -The ~15 s to reach the main loop is emulation overhead. A real radio is up in about -a second. +Boot time is emulation overhead. Measured on this machine: first pixels at ~1.6 s and +a drawn main screen at ~3.6 s after QEMU starts, which is the "~5 s" the README quotes. +A real radio is up in about a second. ## Ground rules @@ -147,6 +148,36 @@ Two things about it that matter when working on this repo: Its tests: `tools/test_uvk5_*.py` and `tools/test_webui.py` need no emulator, `tools/test_webui_e2e.py` boots its own. +A firmware can also be loaded from the page rather than from the command line: +`POST /api/firmware` takes the image as its request body, stores it in +`work/firmware/`, and boots it -- restarting the emulator if it was running. The +image's **shape** is read out of the image (`tools/uvk5_image.py` on the host, +`uvk5_sniff_app_offset()` in the machine): an *application* image is linked for +`0x08002800`, a *full-flash* image starts at `0x08000000`, and address 0 has to alias +the matching base. Getting that wrong is silent -- the image lands 0x2800 bytes off and +the first fetch reads whatever data is there -- which is why it is not a flag and not a +file-name convention. A file that is not an image is refused without disturbing the +running radio. + +Two things about that path are worth knowing, both found the hard way: + +- **The flash image travels in the environment, not in `-M`.** Through the launcher, + QEMU rejected `-M uv-k5-v3,flash-image=...` with "unsupported machine type": the + identical argv started fine when run by hand, `-M help` in the *same* context listed + the machine, the argv `repr` was clean, and the environment diffed down to nothing + conclusive. The property still works when it is set, so both are supported; the + launcher now passes the bare machine name plus `UVK5_FLASH_IMAGE`, which the model + reads as a fallback. The root cause is unexplained -- do not "clean this up" without + re-testing a power-on from the page. +- **The screen is read from the display controller, not from guest RAM.** The panel + model keeps the controller's own display RAM (8 pages of 128 columns), and the web + page renders that, so the picture is right for *any* firmware -- builds sharing an + ancestor still differ in their display logic, and the multi-system release keeps its + image somewhere else entirely. Do not apply the driver's `0xA1` segment reverse on + top of the data: measured at one instant against the guest's own framebuffer, 8153 of + 8192 pixels agree with no mirroring and 6557 with it. `memsave` of `gFrameBuffer` + remains the fallback for an emulator built without the panel model. + ## The flash bugs: four faults, one symptom "The frequency will not change" and "flash forgets everything after power off" @@ -251,6 +282,16 @@ reported `IDR=0x0000` for several rounds because its regex did not match gdb's output format at all. The register was fine; the reader was broken. Cross-check with `tools/gpiob_dump.sh`, which uses a different path. +The same trap one layer further out: **a redirect can change the encoding.** Three +probe runs under `qemu ... 2> probe.log` reported zero SPI transfers, zero flash +reads and zero chip-select changes, and "the firmware never touches SPI" was written +down as a finding. PowerShell 5.1 writes `2>` as UTF-16LE, so every ASCII line a +probe printed had a NUL between each character and a `startswith("LCDW")` filter +could never match it. Decoding the same file as UTF-16 showed a complete ST7565 init +sequence and 48 distinct settings reads. Before believing an empty probe, check that +the probe *can* be seen: read the file, count its bytes, or write it from `cmd /c`, +which does not re-encode. + **QMP `pmemsave` is physical, `memsave` is virtual.** The framebuffer symbols are CPU virtual addresses, so `pmemsave` on `gFrameBuffer` returns a block of zeros and reports success -- a blank screen with nothing logged anywhere. The web UI was @@ -259,6 +300,136 @@ benchmark never checked the *contents*. Measure the thing you actually care about: the bug surfaced only when a rendered frame came back with 0 lit pixels where the gdb path reported 1693. +### The page is generated by an f-string, so check the script it serves + +The web UI is one f-string. A stray backslash in a JavaScript string literal therefore +produces a page whose **whole** `", self.html) + + def test_the_script_parses_with_node_when_it_is_available(self): + node = shutil.which("node") + if node is None: + self.skipTest("node is not installed") + bodies = re.findall(r"", self.html, re.S) + self.assertTrue(bodies, "the page has no script block") + with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, + encoding="utf-8") as fh: + fh.write("\n;\n".join(bodies)) + path = fh.name + try: + done = subprocess.run([node, "--check", path], capture_output=True, + text=True, timeout=60) + self.assertEqual(done.returncode, 0, + "the page's script does not parse:\n" + done.stderr) + finally: + os.unlink(path) diff --git a/tools/uvk5_image.py b/tools/uvk5_image.py new file mode 100644 index 0000000..10c3acb --- /dev/null +++ b/tools/uvk5_image.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Which image to boot, and where the loader has to put it. + +Firmware arrives in two shapes and the difference is not cosmetic: + +* an **application** image is linked for 0x08002800. Its first bytes *are* its vector + table, and address 0 aliases the application region so the reset fetch finds it. +* a **full-flash** image starts at 0x08000000 -- a bootloader, whose entry point + lives in the bootloader region (0x08000000..0x080027FF), with the application + embedded behind it. + +Get that wrong and nothing complains: the image lands 0x2800 bytes off and the first +fetch reads whatever data is there (0xFF, usually, so it faults). The shape is +readable from the image itself, so this reads the vector table rather than asking the +user or guessing from a file name. +""" +import os +import struct + +FLASH_BASE = 0x08000000 +APP_OFFSET = 0x2800 +SRAM_BASE = 0x20000000 +SRAM_TOP = 0x20004000 # 16 KB of SRAM, so SP starts at the top +ELF_MAGIC = b"\x7fELF" +MAX_IMAGE = 4 * 1024 * 1024 # a full 2 MB flash dump plus slack + + +class ImageError(ValueError): + """The file is not an image this machine can boot.""" + + +class ImageInfo: + def __init__(self, path, kind, app_offset, size, sp=None, reset=None): + self.path = path + self.kind = kind # "application" | "full-flash" | "elf" + self.app_offset = app_offset # flash offset address 0 must alias + self.size = size + self.sp = sp + self.reset = reset + + def as_dict(self): + return { + "name": os.path.basename(self.path), + "path": self.path, + "kind": self.kind, + "app_offset": self.app_offset, + "size": self.size, + "sp": None if self.sp is None else "0x%08x" % self.sp, + "reset": None if self.reset is None else "0x%08x" % self.reset, + } + + def __repr__(self): + return "" % ( + self.kind, os.path.basename(self.path), self.size, self.app_offset) + + +def _vector_at(data, offset): + """(SP, reset) if a plausible vector table starts at @offset, else None. + + Both words are checked against what the hardware can accept: SP inside SRAM, and + a reset handler inside the image itself. A single plausible word is a + coincidence in code, so a half-match is treated as no match. + """ + if offset + 8 > len(data): + return None + sp, reset = struct.unpack_from(" MAX_IMAGE: + raise ImageError("%d bytes is larger than any UV-K5 image" % size) + with open(path, "rb") as fh: + data = fh.read() + + if data[:4] == ELF_MAGIC: + # An ELF carries its own program headers, so the loader base is irrelevant; + # the app offset only decides what address 0 aliases, which for an + # application ELF is the application region. + return ImageInfo(path, "elf", APP_OFFSET, size) + + vector = _vector_at(data, 0) + if vector is None: + raise ImageError( + "not a bootable image: no vector table at offset 0 (the first word must " + "be an SRAM address and the second a handler inside the file)") + sp, reset = vector + + # A full-flash image's entry point lives in the bootloader region, ahead of where + # the application starts. That is the whole distinction -- not the size, and not + # a second vector table, because an application's code can contain anything at + # offset 0x2800. + if reset < FLASH_BASE + APP_OFFSET: + return ImageInfo(path, "full-flash", 0, size, sp, reset) + return ImageInfo(path, "application", APP_OFFSET, size, sp, reset) + + +class ImageSlot: + """The image the next launch boots. + + Mutable on purpose: an upload has to take effect at the next power-on without + restarting the server, and the launcher reads this at spawn time rather than + closing over a path chosen when the server started. + """ + + def __init__(self, path=None): + self.current = detect(path) if path else None + + def set(self, path_or_info): + """Load an image, or adopt one that has already been detected. + + Accepting an ImageInfo lets a caller validate a file *before* disturbing + anything: an upload that turns out not to be an image must not leave the + radio powered off. + """ + self.current = (path_or_info if isinstance(path_or_info, ImageInfo) + else detect(path_or_info)) + return self.current + + def clear(self): + self.current = None + + @property + def path(self): + return self.current.path if self.current else None + + @property + def app_offset(self): + return self.current.app_offset if self.current else None diff --git a/tools/uvk5_lcd.py b/tools/uvk5_lcd.py index df26e53..7e32c7c 100644 --- a/tools/uvk5_lcd.py +++ b/tools/uvk5_lcd.py @@ -8,6 +8,8 @@ and the CLI screenshotter cannot drift apart. """ import os import struct +import sys +import tempfile import zlib LCD_WIDTH = 128 @@ -59,6 +61,19 @@ def encode_png(pixels, scale: int = 4) -> bytes: + chunk(b"IEND", b"")) +# The display controller's own settings. Inversion and display-on are panel state, +# not framebuffer content, so nothing in guest RAM reflects them -- which is exactly +# why a menu entry that changes them looks like it did nothing. +PANEL_PATH = "/machine/panel" + + +def default_spool_dir() -> str: + """A tmpfs when the host has one, the system temp directory otherwise.""" + if os.path.isdir("/dev/shm"): + return "/dev/shm" + return tempfile.gettempdir() + + class FrameGrabber: """Reads the LCD out of guest memory over QMP. @@ -75,13 +90,18 @@ class FrameGrabber: """ def __init__(self, client, frame_addr: int, status_addr: int, - spool_dir: str = "/dev/shm"): + spool_dir: str = None): self._client = client self._frame_addr = frame_addr self._status_addr = status_addr - # pmemsave writes to a path, so a tmpfs avoids disk I/O every frame. - self._frame_path = os.path.join(spool_dir, "uvk5-frame.bin") - self._status_path = os.path.join(spool_dir, "uvk5-status.bin") + # memsave writes to a path, so a tmpfs avoids disk I/O every frame -- + # where there is one. /dev/shm does not exist on Windows, and naming it + # there makes every frame grab fail, which shows up only as a blank + # screen with "no frame available" from the web UI. + self._panel_warned = False + self._spool_dir = spool_dir or default_spool_dir() + self._frame_path = os.path.join(self._spool_dir, "uvk5-frame.bin") + self._status_path = os.path.join(self._spool_dir, "uvk5-status.bin") def raw(self) -> tuple[bytes, bytes]: """Return (status, frame) exactly as the firmware holds them.""" @@ -95,6 +115,77 @@ class FrameGrabber: status = fh.read(STATUS_BYTES) return status, frame + def panel_state(self): + """(invert, contrast, display_on) as the display controller holds them. + + Read from the panel model rather than the framebuffer: 0xA6/0xA7, 0x81 and + 0xAE/0xAF live in the controller. If the model is not there (an older + emulator build) the defaults describe an ordinary, unobstructed panel. + """ + try: + invert = bool(self._client.command("qom-get", path=PANEL_PATH, + property="invert")) + contrast = int(self._client.command("qom-get", path=PANEL_PATH, + property="contrast")) + display_on = bool(self._client.command("qom-get", path=PANEL_PATH, + property="display-on")) + except Exception as exc: + # Not fatal -- an emulator built without the panel model, or one that + # just went away, still has a framebuffer worth showing. But say so + # once: swallowing this silently is how a wrong render looks like a + # firmware that ignores the setting. (It hid a stub bug in the test + # for this very method.) + if not self._panel_warned: + self._panel_warned = True + print(f"panel state unavailable, rendering as-is: {exc}", + file=sys.stderr) + return (False, 0, True) + return (invert, contrast, display_on) + + def panel_gram(self) -> bytes: + """The display controller's own display RAM: the screen as it is shown. + + Preferred over raw() when the caller does not know where *this* firmware + keeps its buffers. Builds that share an ancestor still differ in their + display logic, and the multi-system release keeps its image somewhere else + entirely -- but every one of them pushes pixels through the same controller. + """ + hexed = self._client.command("qom-get", path=PANEL_PATH, property="gram") + return bytes.fromhex(hexed) + + def panel_pixels(self): + """The screen as pixels, from the controller's own memory. + + No hardware mirroring is applied. The driver programs 0xA1 (segment reverse) + and 0xC0, but the columns arrive in the order the glass needs, so mirroring + on top of the data flips the picture: against the guest's own framebuffer at + the same instant, 8153 of 8192 pixels agree with no mirror and 6557 with + one. The flags stay reported, not acted on. + + Raises if the panel model is absent, which is how the caller knows to fall + back to guest RAM -- see uvk5_stream.FramePump. + """ + gram = self.panel_gram() + if len(gram) != TOTAL_ROWS * LCD_WIDTH: + raise ValueError("panel GRAM is %d bytes, expected %d" + % (len(gram), TOTAL_ROWS * LCD_WIDTH)) + return self._apply_panel(unpack(gram[:STATUS_BYTES], gram[STATUS_BYTES:])) + + def panel_png(self, scale: int = 4) -> bytes: + """The panel's own memory, encoded as a PNG.""" + return encode_png(self.panel_pixels(), scale) + def _apply_panel(self, pixels): + """Apply the panel settings that change the picture (inversion only).""" + invert, _contrast, display_on = self.panel_state() + if invert: + # 0xA7: the panel inverts the whole image, which is a visible, fully + # determined effect -- so the render follows it. Contrast is analogue + # and cannot be rendered; display-on is deliberately *not* acted on + # here: whether a software reset (0xE2) clears the display-on latch is + # not certain, and blanking the screen on a guess would be worse than + # reporting the flag and leaving the image alone. + pixels = [[1 - value for value in row] for row in pixels] + return pixels def png(self, scale: int = 4) -> bytes: status, frame = self.raw() - return encode_png(unpack(status, frame), scale) + return encode_png(self._apply_panel(unpack(status, frame)), scale) diff --git a/tools/uvk5_logs.py b/tools/uvk5_logs.py index 320a491..c76275e 100644 --- a/tools/uvk5_logs.py +++ b/tools/uvk5_logs.py @@ -15,6 +15,29 @@ import collections import threading import time +# Bytes that are safe to show as text. Everything else in a serial line means the +# wire is carrying binary, not output meant to be read. +_TEXT_BYTES = frozenset(range(0x20, 0x7f)) | {0x09} + + +def describe_line(raw: bytes) -> str: + """A line of serial, or a summary when the bytes are not text. + + Serial carries two very different things over one wire: the firmware's readable + output, and the CPS programming protocol, which is binary. Decoding the second + as text filled the pane with control characters and buried the first, so a line + that is mostly non-printable becomes its size plus a hex prefix instead. + """ + body = raw.rstrip(b"\r\n") + if not body: + return "" + unprintable = sum(1 for b in body if b not in _TEXT_BYTES) + if unprintable * 4 <= len(body): + return body.decode("utf-8", "replace") + head = body[:24].hex(" ") + tail = "" if len(body) <= 24 else f" … +{len(body) - 24} bytes" + return f" {head}{tail}" + class LogBuffer: def __init__(self, capacity: int = 500): @@ -56,13 +79,17 @@ class LogBuffer: Decoding is lenient: serial bytes can be garbage before the firmware has configured the port, and losing the whole stream to one bad byte would be - worse than showing a replacement character. + worse than showing it. A line that is mostly binary is summarised rather + than decoded -- see describe_line(). """ for raw in iter(stream.readline, b""): - line = raw.decode("utf-8", "replace").rstrip("\r\n") - if not line: - continue - if line.startswith("SERIAL "): - self.add("serial", line[len("SERIAL "):]) - else: - self.add(default_source, line) + # Strip the model's tag before looking at the bytes: on a binary line the + # hex summary would otherwise hide the prefix and the line would lose its + # "serial" attribution. + source = default_source + if raw.startswith(b"SERIAL "): + source = "serial" + raw = raw[len(b"SERIAL "):] + line = describe_line(raw) + if line: + self.add(source, line) diff --git a/tools/uvk5_qmp.py b/tools/uvk5_qmp.py index 7e28812..4f8c3c7 100644 --- a/tools/uvk5_qmp.py +++ b/tools/uvk5_qmp.py @@ -13,19 +13,33 @@ import socket import threading +def connect(endpoint: str, timeout: float): + """Open the QMP connection. + + Accepts everything QEMU accepts: a bare unix path, host:port, or the full forms + tcp:host:port[,options] and unix:path -- which is what uvk5_socket.listen() hands + back, so the listening and connecting sides cannot drift apart. One parser, in + uvk5_socket, because two of them is how the Windows path broke: this one only + understood host:port, so a tcp:... endpoint fell through to the unix branch and + failed with a missing AF_UNIX. + """ + import uvk5_socket + + return uvk5_socket.connect(endpoint, timeout=timeout) + + class QmpClient: def __init__(self, path: str, timeout: float = 5.0): self._lock = threading.Lock() - self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - self._sock.settimeout(timeout) try: - self._sock.connect(path) + self._sock = connect(path, timeout) except OSError as exc: raise RuntimeError( f"cannot reach the emulator at {path}: {exc}\n" - "Start it with tools/run.sh first. Note the QMP socket takes a " - "single client, so tools/key.py cannot be connected at the same " - "time." + "Start it with tools/run.sh first (on Windows pass --qmp " + "127.0.0.1:4444 and start QEMU with -qmp tcp:...). Note the QMP " + "socket takes a single client, so tools/key.py cannot be " + "connected at the same time." ) from exc self._buf = b"" self._read_json() # greeting diff --git a/tools/uvk5_serial_flash.py b/tools/uvk5_serial_flash.py new file mode 100644 index 0000000..407f975 --- /dev/null +++ b/tools/uvk5_serial_flash.py @@ -0,0 +1,244 @@ +#!/usr/bin/env python3 +"""Program a UV-K5 over its serial bootloader protocol. + +Not the same protocol as the EEPROM read/write commands (0x0514/0x051B). This one is +the firmware-update flow, and its message set and framing come from the firmware's own +host tool (`tools/serialtool` in armel/uv-k1-k5v3-firmware-custom, MIT licensed): + + 0x0518 device -> host UID and bootloader version, announced repeatedly + 0x0530 host -> device the bootloader version we expect (handshake) + 0x0519 host -> device timestamp, page index, page count, then up to 256 bytes + 0x051A device -> host timestamp, page index, error code + +Framing is `AB CD | len | payload | crc | DC BA` with the payload XOR-ed by a fixed +16-byte table. The CRC is only checked on the host side: the device does not send a +useful one, which is why the reference client ignores it. + +The transport here is a socket, because that is what the emulator offers through +`-serial tcp:host:port`. A real radio needs a serial port; pass one as *endpoint* and +pyserial is used instead (`pip install pyserial`). +""" +import argparse +import socket +import struct +import sys +import time + +MSG_NOTIFY_DEV_INFO = 0x0518 +MSG_NOTIFY_BL_VER = 0x0530 +MSG_PROG_FW = 0x0519 +MSG_PROG_FW_RESP = 0x051A + +PAGE_SIZE = 256 +MAGIC = b"\xab\xcd" +END = b"\xdc\xba" +# The obfuscation table, copied from tools/serialtool/msg.py. +OBFUS = bytes([0x16, 0x6C, 0x14, 0xE6, 0x2E, 0x91, 0x0D, 0x40, + 0x21, 0x35, 0xD5, 0x40, 0x13, 0x03, 0xE9, 0x80]) + + +def obfuscate(data: bytes) -> bytes: + return bytes(b ^ OBFUS[i % len(OBFUS)] for i, b in enumerate(data)) + + +def crc16_xmodem(data: bytes) -> int: + crc = 0 + for byte in data: + crc ^= byte << 8 + for _ in range(8): + crc = ((crc << 1) ^ 0x1021) & 0xFFFF if crc & 0x8000 else (crc << 1) & 0xFFFF + return crc + + +def build(msg_type: int, data: bytes = b"") -> bytes: + """One frame: header, payload, CRC, footer, obfuscated in one pass. + + The length field counts the *message* -- type, length and data -- and excludes the + CRC. A device announcement reads `ab cd 24 00` for a 36-byte message (a 32-byte + UID+version body), which is what pins this down. Counting the CRC as well makes + every frame two bytes too long, and the device then drops all of them without a + word: the handshake is ignored and no page is ever acknowledged. + """ + payload = struct.pack(" int: + ap = argparse.ArgumentParser(description="program a UV-K5 over its serial bootloader") + ap.add_argument("--endpoint", default="127.0.0.1:4568", + help="host:port of a socket chardev, or a serial port name") + ap.add_argument("--image", required=True, help="firmware image to program") + ap.add_argument("--pages", type=int, default=None, + help="program only the first N pages (for testing)") + args = ap.parse_args() + + with open(args.image, "rb") as fh: + image = fh.read() + + transport = open_transport(args.endpoint) + flasher = Flasher(transport) + bl_ver = flasher.wait_for_device() + flasher.handshake(bl_ver) + written = flasher.program(image, pages=args.pages) + print("programmed %d page(s) from %s" % (written, args.image)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/uvk5_slots.py b/tools/uvk5_slots.py new file mode 100644 index 0000000..e53efac --- /dev/null +++ b/tools/uvk5_slots.py @@ -0,0 +1,224 @@ +"""External-flash firmware slots, in the format the multi-system firmware reads. + +The layout and the header come from the firmware source, not from a datasheet or from +the vendor's partition map: + + mb_mb_flash.h MB_SLOT_STRIDE 0x20000, slot 1 base 0x040000, backup slot 0 0x020000 + mb_slot_header_t { magic "FMB1", hdr_version, flags, image_size, + image_crc32, name[16], fw_version[16], reserved[16] } + MB_FLAG_COMMITTED = 1 + +The image itself sits one 4 KiB sector past the slot base, so the header owns the first +sector on its own -- MB_ValidateSlot() checks the magic and the CRC over image_size +bytes, and the firmware reflashes itself from exactly this data when a slot is restored. + +Why a small sector of slack: the multiboot code erases and programs in whole sectors, so +a header sharing a sector with the image could not be updated without rewriting the +image. The header's flags field says whether a slot is committed; an erased slot reads +0xFF everywhere and validates as MB_ERR_MAGIC. +""" + +from __future__ import annotations + +import os +import struct +import zlib + +SLOT_MAGIC = 0x31424D46 # "FMB1" +HDR_VERSION = 1 +MB_FLAG_COMMITTED = 1 + +SLOT_BACKUP_BASE = 0x020000 # slot 0: the backup of the internal firmware +SLOT_STRIDE = 0x20000 # 128 KiB per slot +SLOT_IMAGE_OFFSET = 0x1000 # image starts one sector in +SLOT_COUNT = 5 # backup + 4 +SLOT_HEADER_SIZE = 64 + +# From mb_mb_flash.h: the two redundant active-state sectors. +STATE_A_BASE = 0x00100000 +STATE_B_BASE = 0x00101000 +STATE_SIZE = 24 # mb_state_t, packed + + +def slot_base(slot: int) -> int: + """External-flash base of @slot: 0 is the backup, 1..4 follow it.""" + if slot == 0: + return SLOT_BACKUP_BASE + if 1 <= slot < SLOT_COUNT: + return SLOT_BACKUP_BASE + slot * SLOT_STRIDE + raise ValueError("slot %r out of range (0..%d)" % (slot, SLOT_COUNT - 1)) + + +def build_header(image: bytes, name: str = "", fw_version: str = "") -> bytes: + """The 64-byte header for @image, committed.""" + if len(image) > SLOT_STRIDE - SLOT_IMAGE_OFFSET: + raise ValueError("image is %d bytes; a slot holds %d" + % (len(image), SLOT_STRIDE - SLOT_IMAGE_OFFSET)) + return struct.pack( + " None: + """Lay @image into @slot of @buf, with a committed header.""" + base = slot_base(slot) + header = build_header(image, name, fw_version) + buf[base:base + SLOT_HEADER_SIZE] = header + start = base + SLOT_IMAGE_OFFSET + buf[start:start + len(image)] = image + + +def erase_slot(buf: bytearray, slot: int) -> None: + """Leave @slot as an erased (invalid) slot.""" + base = slot_base(slot) + buf[base:base + SLOT_STRIDE] = b"\xff" * SLOT_STRIDE + + +def read_slot(buf: bytes, slot: int): + """(header dict, image bytes) for @slot, or (None, None) when it is not committed.""" + base = slot_base(slot) + magic, ver, flags, size, crc = struct.unpack_from(" None: + """Erase both active-state sectors, so the firmware treats the flash as fresh. + + This matters: a *corrupt* marker plus a valid slot 0 makes the boot path halt with + "STATE ERROR" to protect Main (mb_multiboot.c), while a *missing* one lets it look + at the slots and record what it finds. + """ + buf[STATE_A_BASE:STATE_A_BASE + STATE_SIZE] = b"\xff" * STATE_SIZE + buf[STATE_B_BASE:STATE_B_BASE + STATE_SIZE] = b"\xff" * STATE_SIZE + + +def build(base_image_path: str, slots, out_path: str, erase_state_sectors: bool = True) -> str: + """Copy @base_image_path and put @slots (a list of (slot, image_path, name, version)) in it. + + @slots entries: (slot_number, path_to_bin, name, fw_version). + """ + with open(base_image_path, "rb") as fh: + buf = bytearray(fh.read()) + for slot, image_path, name, version in slots: + with open(image_path, "rb") as fh: + image = fh.read() + write_slot(buf, slot, image, name, version) + if erase_state_sectors: + erase_state(buf) + with open(out_path, "wb") as fh: + fh.write(buf) + return out_path + + +def describe(path: str) -> str: + """A human-readable summary of every slot in a flash image.""" + with open(path, "rb") as fh: + buf = fh.read() + lines = ["%s (%d bytes)" % (path, len(buf))] + for slot in range(SLOT_COUNT): + header, image = read_slot(buf, slot) + if header is None: + lines.append(" slot %d @0x%06x: empty" % (slot, slot_base(slot))) + else: + lines.append(" slot %d @0x%06x: %-16s %-16s %7d bytes crc %08x %s" + % (slot, header["base"], header["name"], header["fw_version"], + header["image_size"], header["image_crc32"], + "ok" if header["crc_ok"] else "MISMATCH")) + return "\n".join(lines) + + +if __name__ == "__main__": + import argparse + + ap = argparse.ArgumentParser(description="inspect or build external-flash firmware slots") + ap.add_argument("image", help="flash image to inspect") + ap.add_argument("--base", help="base image to copy when building") + ap.add_argument("--slot", type=int, action="append", default=[], + help="slot number to fill (repeat; pair with --bin in order)") + ap.add_argument("--bin", action="append", default=[], help="image for the matching --slot") + ap.add_argument("--name", action="append", default=[], help="slot name for the matching --slot") + ap.add_argument("--out", help="where to write the built image") + args = ap.parse_args() + + if args.base and args.out: + slots = [] + for i, slot in enumerate(args.slot): + name = args.name[i] if i < len(args.name) else os.path.basename(args.bin[i]) + slots.append((slot, args.bin[i], name, "")) + print("wrote", build(args.base, slots, args.out)) + print(describe(args.out or args.image)) + + +# --------------------------------------------------------------------- editing files + +def load_image(path: str) -> bytearray: + with open(path, "rb") as fh: + return bytearray(fh.read()) + + +def save_image(path: str, buf: bytes) -> None: + """Write @buf to @path, atomically enough that a crash cannot truncate the image. + + The emulator writes this same file back while it runs, so a half-written image is + a real possibility -- and a truncated flash image looks like a fresh radio. + """ + tmp = path + ".tmp" + with open(tmp, "wb") as fh: + fh.write(buf) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, path) + + +def slots_json(path: str): + """The slot table as plain data, for a page or a JSON API.""" + buf = load_image(path) + slots = [] + for slot in range(SLOT_COUNT): + header, _image = read_slot(buf, slot) + row = {"slot": slot, "base": slot_base(slot), "empty": header is None} + if header is not None: + row.update({k: header[k] for k in + ("name", "fw_version", "image_size", "image_crc32", "crc_ok", + "hdr_version", "flags")}) + slots.append(row) + return {"image": os.path.abspath(path), "name": os.path.basename(path), + "size": len(buf), "slots": slots} + + +def write_slot_file(path: str, slot: int, image: bytes, name: str = "", + fw_version: str = "", erase_state_sectors: bool = True) -> dict: + """Put @image into @slot of the flash image at @path, in place.""" + buf = load_image(path) + write_slot(buf, slot, image, name, fw_version) + if erase_state_sectors: + # The firmware's boot path treats a corrupt active-state marker next to a valid + # slot 0 as "halt and protect Main" (mb_multiboot.c), which reads as the radio + # refusing to boot. Erasing the marker lets it re-decide from the slots. + erase_state(buf) + save_image(path, bytes(buf)) + return slots_json(path)["slots"][slot] + + +def erase_slot_file(path: str, slot: int) -> dict: + buf = load_image(path) + erase_slot(buf, slot) + erase_state(buf) + save_image(path, bytes(buf)) + return slots_json(path)["slots"][slot] diff --git a/tools/uvk5_slots_serial.py b/tools/uvk5_slots_serial.py new file mode 100644 index 0000000..e9339e6 --- /dev/null +++ b/tools/uvk5_slots_serial.py @@ -0,0 +1,302 @@ +"""Write the firmware's own firmware slots over the serial link. + +The multi-system release exposes its slots to a host (App/app/uart.c, "Firmware Slots"): + + 0x0720 slot info Data[0] = slot -> 0x0721 Slot, Status, Hdr[64] + 0x0722 slot erase Data[0] = slot, Data[2..5] = ts -> 0x0723 Slot, Status + 0x0724 slot write Data[0] = slot, Data[2..5] = offset, Data[6..7] = len, + Data[8..11] = ts, Data[12..] = data -> 0x0725 Slot, Status + 0x0726 slot validate Data[0] = slot -> 0x0727 Crc32, Slot, Status + +Every one of them is gated on the timestamp the 0x0514 session handshake latched +(UART_Timestamp), exactly like the EEPROM write (CMD_051D): send a different one and the +firmware answers MB_ERR_AUTH without doing anything. + +The frames are the AB CD .. DC BA protocol the rest of the programming interface uses, so +uvk5_serial_flash builds them and this only adds the message ids and payload layouts. + +Nothing in the emulator's own path needs this: the page writes slots straight into the +flash image. It exists because it is the path a real radio takes, and the only way to +write a slot on hardware. +""" + +from __future__ import annotations + +import argparse +import os +import socket +import struct +import sys +import threading +import time +import zlib + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import uvk5_serial_flash as proto # build(), Frames +import uvk5_slots as slots # the header layout, in one place + +MSG_SLOT_INFO = 0x0720 +MSG_SLOT_INFO_ACK = 0x0721 +MSG_SLOT_ERASE = 0x0722 +MSG_SLOT_ERASE_ACK = 0x0723 +MSG_SLOT_WRITE = 0x0724 +MSG_SLOT_WRITE_ACK = 0x0725 +MSG_SLOT_VALIDATE = 0x0726 +MSG_SLOT_VALIDATE_ACK = 0x0727 + +STATUS = { + 0: "ok", + 1: "no/invalid slot header", + 2: "header format too new", + 3: "image not marked committed", + 4: "image size out of range", + 5: "image CRC mismatch", + 6: "external flash timed out", + 7: "slot index out of range", + 8: "timestamp mismatch", + 9: "restore stub RAM mismatch", +} + + +CHUNK = 200 # see Radio.write + + +class SlotError(RuntimeError): + pass + + +class Radio: + """A connection to the firmware's slot commands.""" + + def __init__(self, endpoint, timeout: float = 6.0, log=print): + """Talk over @endpoint (host:port, or a device path), or an open socket. + + An already-connected socket is accepted because a test that wants QEMU to be + the one connecting has to listen first, and handing the accepted socket in is + simpler than racing on a free port. + """ + self.log = log + if hasattr(endpoint, "recv"): + self.sock = endpoint + self.sock.settimeout(timeout) + else: + self.sock = proto.open_transport(endpoint) + self.sock.settimeout(timeout) + self.frames = proto.Frames() + self.timestamp = (int(time.time() * 100) & 0xFFFFFFFF) + self._lock = threading.Lock() + self._stop = False + # Drain the port on a thread of its own. The firmware streams its screen over + # this same link (K5Viewer), so a client that only reads when it is waiting for + # a reply backs the socket up and the *guest* then blocks writing to it: + # measured, a single 64-byte slot write took six seconds that way, and longer + # transfers lost replies and stalled. Reading continuously is what keeps the + # radio responsive. + self._reader = threading.Thread(target=self._read_loop, daemon=True) + self._reader.start() + + def close(self): + self._stop = True + try: + self.sock.close() + except OSError: + pass + + def _read_loop(self): + while not self._stop: + try: + chunk = self.sock.recv(65536) + except (socket.timeout, OSError): + continue + if not chunk: + return + if os.environ.get("UVK5_SLOT_DEBUG"): + self.log("rx %s" % chunk.hex()[:120]) + with self._lock: + self.frames.feed(chunk) + + def _pump(self, seconds: float, want=None): + """Wait for @want, or @seconds, whichever comes first. + + The reading itself happens on the reader thread; this only looks at what it has + reassembled, so waiting never stops the port being drained. + """ + end = time.monotonic() + seconds + got = {} + while time.monotonic() < end: + with self._lock: + while True: + msg = self.frames.take() + if msg is None: + break + got[msg[0]] = msg[1] + if want is not None and want in got: + return got + time.sleep(0.002) + return got + + def session(self): + """0x0514, which latches our timestamp on the device.""" + # CMD_0514_t: the timestamp is what matters; the rest is padding. + body = struct.pack(" 1 and (chunk_no - 1) % 25 == 0: + # The firmware leaves its serial mode after ~6 s without a session + # handshake (gSerialConfigCountDown_500ms = 12), and this transfer runs + # longer than that. Measured: the writes stop dead at that point and only + # a fresh 0x0514 revives them, so renew well inside the window. + self.session() + data = (bytes([slot, 0]) + struct.pack("= len(blob): + log(" %6d/%d bytes, %.1fs" % (start + len(piece), len(blob), + time.monotonic() - began)) + + def validate(self, slot: int): + ack = self._command(MSG_SLOT_VALIDATE, bytes([slot]), MSG_SLOT_VALIDATE_ACK, + wait=20.0) + if ack is None: + raise SlotError("0x0726 got no reply") + crc = struct.unpack_from(",server=on,wait=off otherwise. QmpClient and the supervisor already +accept both forms; the listener side is what was missing. +""" +from __future__ import annotations + +import os +import socket +import tempfile +import time + +HAVE_UNIX = hasattr(socket, "AF_UNIX") + + +def can_use_unix(): + """True when a unix socket can actually be bound, not merely that the name exists.""" + if not HAVE_UNIX: + return False + probe_dir = tempfile.mkdtemp(prefix="uvk5-probe-") + path = os.path.join(probe_dir, "probe.sock") + try: + probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + probe.bind(path) + finally: + probe.close() + return True + except OSError: + return False + finally: + for cleanup in (lambda: os.unlink(path), lambda: os.rmdir(probe_dir)): + try: + cleanup() + except OSError: + pass + + +def listen(name: str = "qmp", directory: str | None = None): + """A listening socket plus the endpoint for QEMU to connect *out* to. + + Use server_endpoint() instead when QEMU should be the one listening. + + Returns (socket, endpoint). The caller closes the socket and removes the directory + it was given, if any. + """ + keep = directory or tempfile.mkdtemp(prefix="uvk5-%s-" % name) + if can_use_unix(): + path = os.path.join(keep, name + ".sock") + srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + srv.bind(path) + srv.listen(1) + return srv, "unix:" + path + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(("127.0.0.1", 0)) + srv.listen(1) + return srv, "tcp:127.0.0.1:%d" % srv.getsockname()[1] + + +def server_endpoint(name: str = "qmp", directory: str | None = None) -> str: + """An endpoint for QEMU to LISTEN on -- the caller then connects to it. + + This is the direction the emulator tests use, and the opposite of listen(): + with server=on QEMU binds the port itself, so a listener held by the test makes + QEMU fail to start and the test then connects to its own socket and waits for a + greeting that can never come. That mistake cost a debugging round here. + """ + if can_use_unix(): + keep = directory or tempfile.mkdtemp(prefix="uvk5-%s-" % name) + return "unix:" + os.path.join(keep, name + ".sock") + ",server=on,wait=off" + return "tcp:127.0.0.1:%d,server=on,wait=off" % free_port() + + +def free_port() -> int: + """A port nothing is listening on, for a caller that wants to name one itself.""" + probe = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + probe.close() + return port + + +def connect(endpoint: str, timeout: float = 20.0): + """Connect to whatever listen() returned, waiting for it to appear.""" + kind, _, rest = endpoint.partition(":") + if kind not in ("unix", "tcp"): + # A bare "host:port", which is what the supervisor, the web UI and the README + # have always passed. Read as a scheme, its "host" is the address itself and + # the port is empty, so the connect goes to an empty host and hangs until the + # deadline -- which reads as "the emulator never started" while the guest is + # running happily. That shipped once and cost a page that would not power on. + kind, rest = "tcp", endpoint + deadline = time.monotonic() + timeout + last = None + while time.monotonic() < deadline: + try: + if kind == "unix": + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(2.0) + sock.connect(rest.partition(",")[0]) + + return sock + host, _, port = rest.partition(",")[0].rpartition(":") + return socket.create_connection((host, int(port)), timeout=2.0) + except OSError as exc: + last = exc + time.sleep(0.2) + raise OSError("could not connect to %s: %s" % (endpoint, last)) + + +if __name__ == "__main__": + srv, endpoint = listen("demo") + print("unix sockets available:", can_use_unix()) + print("listening on", endpoint) + srv.close() diff --git a/tools/uvk5_stream.py b/tools/uvk5_stream.py index ce19ec7..0b7ff2b 100644 --- a/tools/uvk5_stream.py +++ b/tools/uvk5_stream.py @@ -17,15 +17,17 @@ looks live. import threading import time -from uvk5_lcd import FrameGrabber, encode_png, unpack +from uvk5_lcd import FrameGrabber, default_spool_dir, encode_png, unpack class FramePump: def __init__(self, client, frame_addr: int, status_addr: int, - fps: int = 15, scale: int = 4, spool_dir: str = "/dev/shm"): + fps: int = 15, scale: int = 4, spool_dir: str = None): self._frame_addr = frame_addr self._status_addr = status_addr - self._spool_dir = spool_dir + # Resolved by FrameGrabber: /dev/shm on Linux, the temp directory on + # Windows, where /dev/shm does not exist. + self._spool_dir = spool_dir or default_spool_dir() self._interval = 1.0 / fps self._scale = scale self._lock = threading.Lock() @@ -68,24 +70,45 @@ class FramePump: grabber = self._grabber if grabber is not None: try: - status, frame = grabber.raw() + status, frame, pixels = self._grab(grabber) current = (status, frame) with self._lock: # Re-check: a rebind may have landed mid-read, and its # blanking must not be undone by this stale frame. if self._grabber is grabber and current != self._raw: self._raw = current - self._png = encode_png(unpack(status, frame), - self._scale) + self._png = encode_png(pixels, self._scale) self._generation += 1 except Exception: # A dead emulator must not kill the pump: power may come # back, and latest() keeps serving the last good frame. pass + # A dead emulator must not kill the pump: power may come + # back, and latest() keeps serving the last good frame. + pass slack = self._interval - (time.monotonic() - started) if slack > 0: self._stop.wait(slack) + + def _grab(self, grabber): + """One frame: (status, frame, pixels), from the panel if it is there. + + Every firmware pushes its pixels through the display controller, so the + controller's memory is the screen no matter where that build keeps its own + buffers -- and builds sharing an ancestor still differ in their display + logic, which is why guessing guest addresses does not generalise. + + Guest RAM remains the fallback for an emulator built without the panel + model, and is what the tests stub. + """ + try: + pixels = grabber.panel_pixels() + gram = grabber.panel_gram() + return gram[:STATUS_BYTES], gram[STATUS_BYTES:], pixels + except Exception: + status, frame = grabber.raw() + return status, frame, unpack(status, frame) def latest(self): with self._lock: return self._png diff --git a/tools/uvk5_supervisor.py b/tools/uvk5_supervisor.py index c9dfb69..3967e2c 100644 --- a/tools/uvk5_supervisor.py +++ b/tools/uvk5_supervisor.py @@ -22,20 +22,110 @@ import time DEFAULT_QMP = "/tmp/uvk5-qmp.sock" -def default_launcher(qemu: str, flash: str, elf: str, +def qmp_argument(endpoint: str) -> str: + """The -qmp argument for an endpoint, unix or TCP. + + Linux gets a unix socket. A Windows build of QEMU cannot create one at all, so + "host:port" is passed through as a TCP listener instead -- the same shape the + QMP client and wait_for_socket already accept. + """ + host, _, port = endpoint.rpartition(":") + if host and port.isdigit(): + return f"tcp:{host}:{port},server=on,wait=off" + return f"unix:{endpoint},server=on,wait=off" + + +class FlashSlot: + """The external-flash image the emulator boots from. + + Mutable and read at spawn time, like the image slot: the page writes a firmware + into one of its slots (or swaps the whole image) and powers on, and nobody has to + re-create the launcher or restart the server. + """ + + def __init__(self, path): + self.path = path + + +class BootKey: + """A key to hold from reset on the next power-on, and for how long. + + Mutable and read at spawn time, like the image slot: the page turns it on for one + boot and off again. Getting a boot mode by hand otherwise means pausing the VM, + setting the keypad over QMP and continuing -- which is fine in a script and + unusable from a browser. + """ + + def __init__(self, name=None, hold_ms=1500): + self.name = name + self.hold_ms = hold_ms + + def clear(self): + self.name = None + + +def resolve_image(image): + """(path, app_offset) for whatever was handed to the launcher. + + Accepts a path, an ImageInfo, or an ImageSlot. The slot is what the web UI + passes, and it is read *here*, at spawn time, so uploading a firmware takes + effect at the next power-on without restarting the server. + + app_offset is None when nothing is known, in which case the machine's own + default (an application at 0x2800) is used. + """ + if hasattr(image, "current"): # ImageSlot + image = image.current + if image is None: + return None, None + if isinstance(image, str): + return image, None + return image.path, image.app_offset + + +def default_launcher(qemu: str, flash: str, elf, boot_key=None, qmp_path: str = DEFAULT_QMP, gdb_port: int = 1234, capture_stderr: bool = True): - """Reproduces the command line in tools/run.sh.""" + """Reproduces the command line in tools/run.sh. + + `elf` may be a path, an ImageInfo, or an ImageSlot -- the last is what the web UI + passes so an uploaded firmware takes effect at the next power-on. + """ def launch(): + path, app_offset = resolve_image(elf) + if path is None: + raise RuntimeError("no firmware loaded: upload a .bin first") + + # No app-offset here on purpose: the machine works out the image's shape from + # the image (see uvk5_sniff_app_offset). Passing it as a -machine property + # looked equivalent and was not -- through this launcher QEMU rejected the + # whole machine string with "unsupported machine type", while the identical + # argv run by hand started fine. One less thing to get wrong. + machine = "uv-k5-v3" + + # The flash image travels in the environment rather than as a -machine + # property. Same reasoning as above: -M with properties was rejected by QEMU + # when this launcher spawned it (and only then), and the environment is a + # channel that arrives intact. The model reads UVK5_FLASH_IMAGE as a fallback. + env = dict(os.environ) + env["UVK5_FLASH_IMAGE"] = getattr(flash, "path", flash) + + # Same channel for the boot key: a -machine property list was rejected by + # QEMU through this launcher, and the environment arrives intact. + name = getattr(boot_key, "name", None) if boot_key is not None else None + if name: + env["UVK5_BOOT_KEY"] = name + env["UVK5_BOOT_KEY_MS"] = str(getattr(boot_key, "hold_ms", 1500)) # A stale socket makes QEMU fail to bind, which looks like "power on did - # nothing". Clear it first. - if os.path.exists(qmp_path): + # nothing". Clear it first -- but only for a socket file there is one of. + if ":" not in qmp_path and os.path.exists(qmp_path): os.unlink(qmp_path) return subprocess.Popen( - [qemu, "-M", f"uv-k5-v3,flash-image={flash}", + [qemu, "-M", machine, "-nographic", "-monitor", "none", - "-qmp", f"unix:{qmp_path},server=on,wait=off", - "-kernel", elf, "-gdb", f"tcp::{gdb_port}"], + "-qmp", qmp_argument(qmp_path), + "-kernel", path, "-gdb", "tcp::%d" % gdb_port], + env=env, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE if capture_stderr else subprocess.DEVNULL) return launch @@ -50,9 +140,19 @@ def wait_for_socket(path: str, timeout: float = 15.0) -> bool: ECONNREFUSED -- which surfaces as power on returning 500. Probing with a real connect distinguishes "listening" from "leftover file". """ + # "host:port" is a TCP QMP endpoint, which is all a Windows build of QEMU + # can offer; there is no socket file to stat, so probe the port directly. + host, _, port = path.rpartition(":") + tcp = bool(host) and port.isdigit() deadline = time.monotonic() + timeout while time.monotonic() < deadline: - if os.path.exists(path): + if tcp: + try: + socket.create_connection((host, int(port)), timeout=1.0).close() + return True + except OSError: + pass + elif os.path.exists(path): probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) try: probe.settimeout(1.0) @@ -108,6 +208,47 @@ class Supervisor: self._client = client self._proc = None + def _start_stderr_pump(self, proc) -> bool: + """Forward QEMU's stderr to the log, and never stop draining it. + + This has to read the pipe unconditionally, because QEMU blocks on write when it + fills -- which stops its main loop, and then QMP never answers and the guest looks + dead. The firmware streams its display down this same pipe (the model tags it + SERIAL), so it fills with binary that has no line breaks in it, and 64 KB is + reached in about a second. + + Measured: with the pipe drained the launcher's QEMU accepts QMP in 0.5 s; with it + left unread, the same command line never answers at all. So: read in fixed-size + chunks (a readline() on a stream with no newlines hoards it), decode leniently, and + swallow anything the log throws -- a logging failure must not become a stopped + drain, which is a deadlock rather than a lost line. + """ + stream = getattr(proc, "stderr", None) + if stream is None: + return False + + def drain(): + while True: + try: + chunk = stream.read(65536) + except Exception: + return + if not chunk: + return + if self._log is None: + continue + try: + text = chunk.decode("utf-8", "replace") + for line in text.splitlines(): + if line: + self._log.add("qemu", line[:400]) + except Exception: + pass + + threading.Thread(target=drain, daemon=True).start() + return True + + def power_on(self) -> bool: with self._lock: # A client object is not proof of a live guest. If the process died @@ -125,6 +266,14 @@ class Supervisor: if self._client is not None: return False self._proc = self._launch() + # Drain QEMU's stderr from the moment it starts, not after a successful + # connect. The firmware streams its screen down that pipe -- the model tags + # it SERIAL -- and 64 KB of it fills the pipe while we are still waiting for + # QMP. QEMU then blocks writing to stderr, its main loop stops, and the + # connect times out with the guest perfectly healthy. That reads as "the + # emulator never started", and it is why power-on worked with some firmware + # and not others: only the ones that stream hard fill the pipe in time. + draining = self._start_stderr_pump(self._proc) try: self._client = self._connect() except Exception as exc: @@ -139,16 +288,37 @@ class Supervisor: proc.wait(timeout=5) except Exception: proc.kill() + # Why it died is almost always in its stderr, and until now that + # was read only after a *successful* connect -- so a failed power + # on reported nothing but "the QMP port never appeared", which is + # a symptom. Close the pipe and show what QEMU said. + if getattr(proc, "stderr", None) is not None: + try: + tail = proc.stderr.read(4096).decode("utf-8", "replace") + except Exception: + tail = "" + tail = " ".join(tail.split()) + if tail: + # The end, not the start: a bind failure or an error + # exit is the last thing QEMU says. + # The *whole* message, not a tail: QEMU reports the real + # problem first and then a summary line, so keeping only the + # end hid the cause behind "unsupported machine type" -- which + # is what a rejected -machine property looks like from below. + self._note("qemu said: %s" % tail[:1500]) + # What we actually ran is the first thing anyone asks, and + # until now it was not recorded anywhere. + if proc is not None and getattr(proc, "args", None): + self._note("command: %s" % " ".join(str(a) for a in proc.args)) + self._note("argv repr: %r" % (list(proc.args),)) self._note(f"power on failed: {exc}") raise proc = self._proc self._note("power on") # Forward QEMU's own stderr, which run.sh and the tests used to discard. # Firmware serial arrives here too, tagged SERIAL by the machine model. - if self._log is not None and getattr(proc, "stderr", None) is not None: - threading.Thread( - target=self._log.pump_stream, args=(proc.stderr,), - kwargs={"default_source": "qemu"}, daemon=True).start() + if not draining: + self._start_stderr_pump(proc) return True def power_off(self) -> bool: diff --git a/tools/uvk5_testenv.py b/tools/uvk5_testenv.py new file mode 100644 index 0000000..78feb0a --- /dev/null +++ b/tools/uvk5_testenv.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Where the emulator tests find a QEMU and a firmware, and what to say when they cannot. + +The emulator tests used to name one developer's build directory: + + QEMU = os.path.expanduser("~/qemu-build/qemu-7.2+dfsg/build/qemu-system-arm") + ELF = os.path.expanduser("~/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf") + +which is fine on the machine they were written on and useless anywhere else -- and a +missing file used to be a hard failure, so a checkout without that build could never +see a green run and nobody could tell "broken" from "not set up here". + +Resolution order, for both: + + qemu() env QEMU, env UVK5_QEMU, qemu-system-arm on PATH + firmware() env ELF, env UVK5_FIRMWARE, assets/firmware/*, work/* + +firmware() prefers assets/firmware, which tools/fetch_firmware.py fills from the upstream +project's release archive; that is what makes these tests reproducible rather than +anchored to one private build. +""" +from __future__ import annotations + +import glob +import os +import pathlib +import shutil + +HERE = os.path.dirname(os.path.abspath(__file__)) +ROOT = os.path.dirname(HERE) +FIRMWARE_DIR = os.path.join(ROOT, "assets", "firmware") + + +def qemu(): + """Path to a qemu-system-arm, or None.""" + for var in ("QEMU", "UVK5_QEMU"): + value = os.environ.get(var) + if value and os.path.exists(value): + return pathlib.Path(value) + found = shutil.which("qemu-system-arm") + return pathlib.Path(found) if found else None + + +def gdb(): + """Path to a gdb that speaks ARM, or None. + + A few tests read firmware globals over a gdb attach. That is the one part of the + suite a Windows box cannot do at all, and it used to be a hard failure rather than + a skip -- so those tests reported a regression where there was only a missing tool. + """ + for var in ("GDB", "UVK5_GDB"): + value = os.environ.get(var) + if value and os.path.exists(value): + return pathlib.Path(value) + # Not plain gdb: on most hosts that is the *host* architecture's gdb, which attaches + # to the guest, returns nothing useful, and produces a confusing parse failure + # instead of "no debugger". The two names below can actually read an ARM target. + for name in ("gdb-multiarch", "arm-none-eabi-gdb"): + found = shutil.which(name) + if found: + return pathlib.Path(found) + return None + + +def firmware(): + """Path to a firmware image to run, or None.""" + for var in ("ELF", "UVK5_FIRMWARE", "UVK5_MULTIBOOT_IMAGE"): + value = os.environ.get(var) + if value and os.path.exists(value): + return pathlib.Path(value) + patterns = [os.path.join(FIRMWARE_DIR, "*.bin"), + os.path.join(FIRMWARE_DIR, "*.elf"), + os.path.join(ROOT, "work", "*.bin"), + os.path.join(ROOT, "work", "*.elf")] + for pattern in patterns: + for hit in sorted(glob.glob(pattern)): + return pathlib.Path(hit) + return None + + +def missing(items): + """First thing in @items that is absent, described with how to fix it. + + @items is a list of (path, what, how). Returns None when everything is present. + """ + for path, what, how in items: + if not path or not os.path.exists(path): + return "%s is missing (%s); %s" % (what, path or "not found", how) + return None + + +def skip(message): + """Print a skip line and return the exit code a test should use. + + A missing prerequisite is not a failing test. Saying so loudly beats exiting + non-zero, which is indistinguishable from a regression and is why a Windows or + fresh checkout could never show a green run. + """ + print("SKIP: %s" % message) + return 0 diff --git a/tools/webui.py b/tools/webui.py index 386159b..84c8284 100644 --- a/tools/webui.py +++ b/tools/webui.py @@ -20,15 +20,32 @@ Two things worth knowing: import argparse import json import os +import shutil import time from flask import Flask, Response, jsonify, request +from uvk5_image import ImageError, detect as detect_image +from uvk5_slots import (SLOT_COUNT, erase_slot_file, slots_json, + write_slot_file) from uvk5_keys import KEYS, is_valid, normalise +from uvk5_lcd import PANEL_PATH from uvk5_logs import LogBuffer from uvk5_stream import FramePump KEYPAD_PATH = "/machine/keypad" + +# Uploaded firmware. Kept next to the checkout rather than in the system temp +# directory: a firmware is something the user chose to load, and losing it on +# reboot would mean uploading it again. UVK5_UPLOAD_DIR overrides it. +MAX_UPLOAD_BYTES = 4 * 1024 * 1024 +UPLOAD_DIR = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "work", "firmware") + + +def upload_dir() -> str: + return os.environ.get("UVK5_UPLOAD_DIR", UPLOAD_DIR) AUDIO_PATH = "/machine/audio" # Firmware thresholds, from App/misc.c: @@ -110,8 +127,25 @@ KEY_BINDINGS = { POWER_ACTIONS = ("on", "off", "reset", "pause", "resume") +# The multi-system boot menu lives inside the firmware, not in a separate +# bootloader, and the builds that ship without it (some localised releases are +# built "without multi-system") look identical until you hold MENU at power-on +# and nothing happens. Its banner strings are the quickest tell. +MULTIBOOT_MARKERS = (b"F4HWN MULTIBOOT", b"RESTORE REFUSED", b"SLOT NOT VALID") + + +def image_has_multiboot(path): + """True when @path looks like a build with the boot menu in it.""" + try: + with open(path, "rb") as fh: + blob = fh.read() + except OSError: + return None + return any(marker in blob for marker in MULTIBOOT_MARKERS) + + def create_app(client, frame_addr: int, status_addr: int, scale: int = 4, - supervisor=None, log=None): + supervisor=None, log=None, image=None, boot_key=None, flash=None): app = Flask(__name__) if log is None: @@ -124,6 +158,7 @@ def create_app(client, frame_addr: int, status_addr: int, scale: int = 4, pump.start() app.config["PUMP"] = pump app.config["SUPERVISOR"] = supervisor + app.config["IMAGE"] = image def client_ip(): """The address of whoever made this request. @@ -195,6 +230,35 @@ def create_app(client, frame_addr: int, status_addr: int, scale: int = 4, def index(): return Response(render_index(scale), mimetype="text/html") + def panel_state(): + """The display controller's own settings: invert, contrast, display on/off. + + These are panel state, not framebuffer content, so they are invisible in the + pixels themselves -- a menu entry that changes them otherwise looks like it + did nothing. None of them when the emulator is off. + """ + target = active_client() + if target is None: + return None + try: + return { + "invert": bool(target.command("qom-get", path=PANEL_PATH, + property="invert")), + "contrast": int(target.command("qom-get", path=PANEL_PATH, + property="contrast")), + "display": bool(target.command("qom-get", path=PANEL_PATH, + property="display-on")), + } + except Exception as exc: + log.add("qemu", f"panel state unavailable: {exc}") + return None + + def firmware_info(): + """The loaded image, or None. Its shape and offset come from uvk5_image.""" + if image is None or image.current is None: + return None + return image.current.as_dict() + @app.get("/api/status") def api_status(): target = active_client() @@ -205,7 +269,181 @@ def create_app(client, frame_addr: int, status_addr: int, scale: int = 4, except Exception as exc: # The emulator can die under us; that is a state to report, not a 500. return jsonify(powered=False, status="unreachable", error=str(exc)) - return jsonify(powered=True, speaker=speaker_on(), **info) + return jsonify(powered=True, speaker=speaker_on(), + panel=panel_state(), firmware=firmware_info(), **info) + + @app.get("/api/firmware") + def api_firmware(): + info = firmware_info() + if info is not None and image is not None: + info = dict(info, multiboot=image_has_multiboot(image.path)) + return jsonify(loaded=info is not None, firmware=info) + + @app.post("/api/firmware") + def api_firmware_upload(): + """Boot an uploaded firmware image. + + The request body is the image itself. Its shape is read out of the vector + table (see uvk5_image) rather than taken on trust, because loading an image + at the wrong offset fails silently: it runs 0x2800 bytes off and the first + fetch reads whatever data is there. + """ + if image is None: + return jsonify(error="this server was started without firmware control"), 409 + name = (request.args.get("name") + or request.headers.get("X-Filename") or "firmware.bin") + name = os.path.basename(name.replace("\\", "/")) or "firmware.bin" + data = request.get_data(cache=False, as_text=False) + if not data: + return jsonify(error="no image in the request body"), 400 + if len(data) > MAX_UPLOAD_BYTES: + return jsonify(error="%d bytes is too large" % len(data)), 413 + directory = upload_dir() + os.makedirs(directory, exist_ok=True) + path = os.path.join(directory, name) + with open(path, "wb") as fh: + fh.write(data) + # Validate before touching the running emulator: a file that is not an image + # should leave the radio exactly as it was, not powered off with a 400. + try: + info = detect_image(path) + except ImageError as exc: + return jsonify(error=str(exc), saved=path), 400 + + if supervisor is not None and supervisor.is_running(): + # The image is picked when the process is spawned, so it has to come + # back up to take effect. Off first, then adopt, so nothing boots the + # previous image in between. + supervisor.power_off() + restart = True + else: + restart = False + image.set(info) + log.add("firmware", "%s (%s, %d bytes)" % (name, info.kind, info.size), + ip=client_ip()) + if restart: + try: + supervisor.power_on() + except Exception as exc: + log.add("firmware", "restart failed: %s" % exc) + return jsonify(firmware=info.as_dict(), restarted=False, + error=str(exc)), 500 + body = dict(info.as_dict(), multiboot=image_has_multiboot(path)) + return jsonify(firmware=body, restarted=restart) + + # ------------------------------------------------------------- firmware slots + # + # The multi-system firmware keeps four firmware slots plus a backup of the + # internal image in the external flash, in the layout App/driver/mb_flash.h + # defines (an FMB1 header plus a CRC-32, image one sector into the slot). + # Editing them means editing the flash image the emulator boots from, and that + # image is chosen when the process is spawned -- so an edit powers the emulator + # off, rewrites the image and powers it on again. + # + # It rewrites a working copy, never the file the server was pointed at: that may + # be the radio's real calibration dump. + def _edit_flash(edit): + # Power off, apply edit(path) to a working copy, power on again. + if flash is None: + raise RuntimeError("this server was started without flash control") + work = os.path.join(upload_dir(), "flash-current.img") + running = supervisor is not None and supervisor.is_running() + if running: + # Takes the emulator's own write-back with it, so an edit builds on what + # the firmware actually has rather than on the launch-time file. + supervisor.power_off() + # Wait for the process to actually go, not just for the request to return: + # it holds the image open while it exits, and starting the next instance + # against a file another process still has open fails on Windows -- which + # showed up as a slot write that left the emulator off. + for _ in range(40): + if not supervisor.is_running(): + break + time.sleep(0.25) + if os.path.abspath(work) != os.path.abspath(flash.path): + shutil.copyfile(flash.path, work) + flash.path = work + result = edit(work) + if running: + supervisor.power_on() + return result + + @app.get("/api/slots") + def api_slots(): + """The firmware slots in the flash image the emulator is using.""" + if flash is None: + return jsonify(error="this server was started without flash control"), 409 + try: + return jsonify(slots_json(flash.path)) + except Exception as exc: + return jsonify(error=str(exc)), 500 + + @app.post("/api/slots/") + def api_slot_write(slot): + """Write an uploaded image into a slot (0 is the backup of Main).""" + if not 0 <= slot < SLOT_COUNT: + return jsonify(error="slot %d is out of range (0..%d)" + % (slot, SLOT_COUNT - 1)), 400 + data = request.get_data(cache=False, as_text=False) + if not data: + return jsonify(error="no image in the request body"), 400 + if len(data) > MAX_UPLOAD_BYTES: + return jsonify(error="%d bytes is too large" % len(data)), 413 + name = os.path.basename((request.args.get("name") + or request.headers.get("X-Filename") + or "firmware.bin").replace("\\", "/")) + version = request.args.get("version", "") + try: + row = _edit_flash(lambda p: write_slot_file(p, slot, data, name, version)) + except Exception as exc: + log.add("slots", "slot %d write failed: %s" % (slot, exc), ip=client_ip()) + return jsonify(error=str(exc)), 400 + log.add("slots", "slot %d <- %s (%d bytes, crc %s)" + % (slot, name, len(data), "ok" if row.get("crc_ok") else "MISMATCH"), + ip=client_ip()) + return jsonify(slot=row) + + @app.post("/api/slots//erase") + def api_slot_erase(slot): + """Erase a slot, as the firmware does for its own 0x0722 command.""" + if not 0 <= slot < SLOT_COUNT: + return jsonify(error="slot %d is out of range (0..%d)" + % (slot, SLOT_COUNT - 1)), 400 + try: + row = _edit_flash(lambda p: erase_slot_file(p, slot)) + except Exception as exc: + log.add("slots", "slot %d erase failed: %s" % (slot, exc), ip=client_ip()) + return jsonify(error=str(exc)), 400 + log.add("slots", "slot %d erased" % slot, ip=client_ip()) + return jsonify(slot=row) + + @app.post("/api/flash") + def api_flash_upload(): + """Use an uploaded image as the external flash, slots and all.""" + if flash is None: + return jsonify(error="this server was started without flash control"), 409 + name = os.path.basename((request.args.get("name") + or request.headers.get("X-Filename") + or "flash.img").replace("\\", "/")) + data = request.get_data(cache=False, as_text=False) + if not data: + return jsonify(error="no flash image in the request body"), 400 + if len(data) > MAX_UPLOAD_BYTES: + return jsonify(error="%d bytes is too large" % len(data)), 413 + running = supervisor is not None and supervisor.is_running() + if running: + supervisor.power_off() + directory = upload_dir() + os.makedirs(directory, exist_ok=True) + path = os.path.join(directory, name) + with open(path, "wb") as fh: + fh.write(data) + flash.path = path + log.add("slots", "flash image <- %s (%d bytes)" % (name, len(data)), + ip=client_ip()) + if running: + supervisor.power_on() + return jsonify(slots_json(path)) @app.get("/api/logs") def api_logs(): @@ -232,6 +470,17 @@ def create_app(client, frame_addr: int, status_addr: int, scale: int = 4, # Attribute the action here: the supervisor has no request context, and on # a shared log "who powered it off" is the useful part. + body = request.get_json(silent=True) or {} + if boot_key is not None: + if action == "on" and body.get("boot_key"): + boot_key.name = str(body["boot_key"])[:16] + boot_key.hold_ms = int(body.get("hold_ms") or 1500) + log.add("power", "asking for %s held from reset" % boot_key.name, + ip=client_ip()) + else: + # A plain On must not inherit the last boot mode. + boot_key.clear() + log.add("power", f"{action} requested", ip=client_ip()) try: @@ -428,6 +677,11 @@ def render_index(scale: int) -> str: /* The border lives on .screenwrap so it stays put when the frame is hidden. */ #screen {{ display:block; image-rendering:pixelated; background:#c8d6b9; }} .body {{ display:flex; gap:14px; align-items:flex-start; }} + .fwbar {{ display:flex; gap:8px; align-items:center; flex-wrap:wrap; + font-size:12px; color:#8b949e; max-width:420px; }} + .fwbar input {{ color:#c9d1d9; font-size:12px; max-width:190px; }} + .fwbar .hint {{ opacity:.75; }} + #fwstate {{ color:#c9d1d9; }} .sides {{ display:flex; flex-direction:column; gap:8px; }} .pad {{ display:flex; flex-direction:column; gap:8px; }} .row {{ display:flex; gap:8px; }} @@ -458,6 +712,12 @@ def render_index(scale: int) -> str: */ #speaker {{ font-size:14px; opacity:0.25; transition:opacity 0.15s; }} #speaker.on {{ opacity:1; }} + /* + * The display controller's own settings. They never appear in the pixels -- that + * is the whole reason they need showing: contrast and inversion live in the + * panel, so a menu entry that changes them otherwise looks like it did nothing. + */ + #panel {{ font-size:11px; color:#8b949e; margin-left:8px; letter-spacing:0.3px; }} /* * Powered off is a dark panel, drawn by the wrapper so the frame itself can be * hidden. An earlier attempt put a dark background on the alone, which @@ -475,6 +735,18 @@ def render_index(scale: int) -> str: * of view and you scroll back to read them. min-height matches height so a * nearly empty pane does not jump around as the first lines arrive. */ + #slottable {{ width:100%; border-collapse:collapse; margin:6px 0 0; + font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; + color:#8b949e; }} + #slottable td {{ padding:3px 6px; border-top:1px solid #2d333b; + white-space:nowrap; }} + #slottable td:first-child {{ color:#c9d1d9; width:12em; }} + #slottable input[type=file] {{ color:#8b949e; font:inherit; max-width:14em; }} + #slottable button {{ font:inherit; color:#c9d1d9; background:#2b3138; + border:1px solid #2d333b; border-radius:4px; padding:1px 8px; }} + #slottable button:hover {{ background:#343b44; }} + .mini {{ margin-left:1em; }} + .mini input {{ margin-left:0.5em; }} #logtext {{ height:180px; min-height:180px; overflow-y:auto; margin:6px 0 0; padding:8px; background:#0d1117; border:1px solid #2d333b; border-radius:6px; white-space:pre-wrap; word-break:break-all; @@ -485,11 +757,28 @@ def render_index(scale: int) -> str:
+ - 🔈 +
+
+ + + - + or drop a .bin anywhere on the page +
+
+ + - + + the multi-system slots live in the external flash; + write a .bin into one, then press Multiboot +
+
radio LCD @@ -621,7 +910,13 @@ document.querySelectorAll('.pwr').forEach(btn => {{ }} document.querySelectorAll('.pwr').forEach(b => b.disabled = true); try {{ - const r = await fetch('/api/power/' + action, {{method: 'POST'}}); + // A boot mode needs the key held *from reset*, which only the server can do: + // the firmware samples the keypad in the first milliseconds after reset. + const body = btn.dataset.boot ? {{ boot_key: btn.dataset.boot }} : {{}}; + const r = await fetch('/api/power/' + action, {{ + method: 'POST', + headers: {{ 'Content-Type': 'application/json' }}, + body: JSON.stringify(body) }}); if (!r.ok) {{ const j = await r.json().catch(() => ({{}})); document.getElementById('status').textContent = @@ -635,6 +930,31 @@ document.querySelectorAll('.pwr').forEach(btn => {{ // Restart the stream: the old one ends when the emulator goes away. const img = document.getElementById('screen'); img.src = '/stream?t=' + Date.now(); +// The screen is a long-lived multipart stream. If the server is restarted underneath +// it -- which happens whenever a firmware or slot write restarts the emulator -- the +// keeps showing the last frame it received, and then nothing on the page appears +// to work, because the picture never changes. Reconnect, and fall back to fetching +// single frames if the stream will not come back. +let streamRetries = 0; +const screenEl = document.getElementById('screen'); +function connectStream() {{ + screenEl.src = '/stream?t=' + Date.now(); +}} +screenEl.addEventListener('error', () => {{ + streamRetries += 1; + if (streamRetries <= 2) {{ + setTimeout(connectStream, 1000); + }} else {{ + // Single frames: one plain request each, which always recovers. + setInterval(() => {{ screenEl.src = '/frame.png?t=' + Date.now(); }}, 250); + }} +}}); +setInterval(() => {{ + // A stream that is connected but silent (a stopped guest) still counts as up. + // Restarting after every power action is already handled above; this only covers + // the server having been replaced, which shows up as a request that never lands. + if (screenEl.complete && screenEl.naturalWidth === 0) connectStream(); +}}, 5000); }} }}); }}); @@ -643,6 +963,15 @@ function showSpeaker(on) {{ document.getElementById('speaker').classList.toggle('on', !!on); }} +function showPanel(panel) {{ + const el = document.getElementById('panel'); + if (!panel) {{ el.textContent = ''; return; }} + const bits = ['CTR ' + panel.contrast]; + if (panel.invert) bits.push('INV'); + if (!panel.display) bits.push('PANEL OFF'); + el.textContent = bits.join(' · '); +}} + function showPower(powered) {{ const label = document.getElementById('powerstate'); label.textContent = powered ? 'on' : 'off'; @@ -657,12 +986,14 @@ async function poll() {{ const s = await r.json(); showPower(!!s.powered); showSpeaker(s.speaker); + showPanel(s.panel); document.getElementById('status').textContent = s.powered ? ('guest: ' + (s.status || 'unknown')) : 'powered off -- press On to boot'; }} catch (err) {{ showPower(false); showSpeaker(false); + showPanel(null); document.getElementById('status').textContent = 'server unreachable'; }} }} @@ -710,6 +1041,125 @@ async function pollLogs() {{ }} pollLogs(); setInterval(pollLogs, 2000); + +// Firmware slots. These are the multi-system firmware's own slots in the +// external flash (App/driver/mb_flash.h): slot 0 is the backup of the running +// image, 1..4 are the switchable ones. Writing one rewrites the flash image and +// restarts the emulator, because the image is chosen when it is spawned. +async function loadSlots() {{ + const tb = document.querySelector('#slottable tbody'); + const state = document.getElementById('flashstate'); + try {{ + const j = await (await fetch('/api/slots')).json(); + if (j.error) {{ state.textContent = j.error; return; }} + const base = j.name || j.image || 'flash image'; + state.textContent = base + ' (' + Math.round(j.size / 1024) + ' KiB)'; + tb.innerHTML = ''; + for (const s of j.slots) {{ + const tr = document.createElement('tr'); + const label = s.empty ? 'empty' + : (s.name || '?') + ' ' + (s.fw_version || ''); + const size = s.empty ? '' + : s.image_size + ' B' + (s.crc_ok ? '' : ' CRC MISMATCH'); + tr.innerHTML = 'slot ' + s.slot + + (s.slot === 0 ? ' (Main)' : '') + '' + label + + '' + size + ''; + const td = tr.lastElementChild; + const inp = document.createElement('input'); + inp.type = 'file'; + inp.accept = '.bin'; + inp.addEventListener('change', async () => {{ + const f = inp.files[0]; + if (!f) return; + tr.querySelectorAll('input,button').forEach(b => b.disabled = true); + const r = await fetch('/api/slots/' + s.slot + '?name=' + + encodeURIComponent(f.name), + {{ method: 'POST', body: f }}); + const jj = await r.json(); + if (jj.error) alert('slot ' + s.slot + ': ' + jj.error); + await loadSlots(); poll(); + }}); + const er = document.createElement('button'); + er.textContent = 'Erase'; + er.addEventListener('click', async () => {{ + if (!confirm('Erase slot ' + s.slot + '?')) return; + tr.querySelectorAll('input,button').forEach(b => b.disabled = true); + const r = await fetch('/api/slots/' + s.slot + '/erase', + {{ method: 'POST' }}); + const jj = await r.json(); + if (jj.error) alert('slot ' + s.slot + ': ' + jj.error); + await loadSlots(); poll(); + }}); + td.append(inp, er); + tb.appendChild(tr); + }} + }} catch (err) {{ state.textContent = 'slots unavailable: ' + err; }} +}} +const flashInput = document.getElementById('flashfile'); +if (flashInput) flashInput.addEventListener('change', async () => {{ + const f = flashInput.files[0]; + if (!f) return; + if (!confirm('Use ' + f.name + ' as the external flash image? Slots, settings', + ' and calibration come from it.')) return; + const r = await fetch('/api/flash?name=' + encodeURIComponent(f.name), + {{ method: 'POST', body: f }}); + const j = await r.json(); + if (j.error) alert(j.error); + loadSlots(); poll(); +}}); +loadSlots(); +setInterval(loadSlots, 15000); +// Firmware upload. The file *is* the request body, so the server reads the +// vector table itself and decides the load offset: an application image and a +// full-flash image need different ones, and the wrong one fails silently. +const fwstate = document.getElementById('fwstate'); +const fwfile = document.getElementById('fwfile'); +async function loadFirmware(file) {{ + fwstate.textContent = 'uploading ' + file.name + ' ...'; + try {{ + const res = await fetch('/api/firmware?name=' + encodeURIComponent(file.name), {{ + method: 'POST', + headers: {{ 'Content-Type': 'application/octet-stream' }}, + body: file }}); + const info = await res.json(); + if (!res.ok) {{ fwstate.textContent = info.error || 'upload failed'; return; }} + fwstate.textContent = fwLabel(info.firmware) + + (info.restarted ? ' - rebooting' : ' - press On'); + poll(); + }} catch (err) {{ + fwstate.textContent = 'upload failed: ' + err; + }} +}} +if (fwfile) fwfile.addEventListener('change', () => {{ + if (fwfile.files && fwfile.files[0]) loadFirmware(fwfile.files[0]); +}}); +document.addEventListener('dragover', (e) => e.preventDefault()); +document.addEventListener('drop', (e) => {{ + e.preventDefault(); + const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; + if (f) loadFirmware(f); +}}); +// Say when a build has no multi-system boot menu: the Multiboot button then does +// nothing at all, which reads as the emulator being broken rather than the +// firmware not containing that menu. +function fwLabel(fw) {{ + let s = fw.name + ' (' + fw.kind + ')'; + if (fw.multiboot === false) s += ' - no multi-system menu'; + return s; +}} +async function pollFirmware() {{ + try {{ + const r = await fetch('/api/firmware'); + const info = await r.json(); + if (info.firmware) {{ + fwstate.textContent = fwLabel(info.firmware); + }} else {{ + fwstate.textContent = 'none loaded - drop a .bin here'; + }} + }} catch (err) {{ /* the rest of the page works without this */ }} +}} +pollFirmware(); + """ @@ -730,7 +1180,9 @@ def main() -> int: "this server did not start that process.") ap.add_argument("--qemu", default=os.path.expanduser( "~/qemu-build/qemu-7.2+dfsg/build/qemu-system-arm")) - ap.add_argument("--elf", default=os.path.expanduser( + # Named --elf for history; any .bin or .elf works, and its shape is read out + # of the file rather than assumed. More can be uploaded from the page. + ap.add_argument("--elf", "--firmware", dest="elf", default=os.path.expanduser( "~/uvk5-port/uvk5-sat/build/CW/nr7y.cw.elf")) ap.add_argument("--flash", default=os.path.join( os.path.dirname(os.path.dirname(os.path.abspath(__file__))), @@ -739,6 +1191,8 @@ def main() -> int: args = ap.parse_args() from uvk5_qmp import QmpClient + from uvk5_image import ImageSlot + from uvk5_supervisor import BootKey, FlashSlot from uvk5_supervisor import Supervisor, default_launcher, wait_for_socket def connect(): @@ -749,9 +1203,24 @@ def main() -> int: # One buffer shared by the supervisor and the HTTP layer, so power events, # QEMU stderr and firmware serial all land in the same place. log = LogBuffer() + + # The image the next launch boots. A slot rather than a path so an upload + # from the page takes effect at the next power-on without a restart, and so + # the load offset is decided by the image itself (see uvk5_image). + image = ImageSlot() + boot_key = BootKey() + flash = FlashSlot(args.flash) + try: + image.set(os.path.expanduser(args.elf)) + except ImageError as exc: + # Not fatal: the page can load one, and saying so beats refusing to + # start because a default path from another machine is missing. + log.add("firmware", "no firmware loaded yet: %s" % exc) + print("no firmware loaded yet: %s" % exc) + supervisor = Supervisor( - launch=default_launcher(args.qemu, args.flash, args.elf, args.qmp, - gdb_port=args.gdb_port), + launch=default_launcher(args.qemu, flash, image, boot_key, + qmp_path=args.qmp, gdb_port=args.gdb_port), connect=connect, log=log) if args.attach: @@ -762,7 +1231,8 @@ def main() -> int: # page behaves like walking up to a machine rather than finding it booted. app = create_app(supervisor.client(), args.frame_addr, args.status_addr, - args.scale, supervisor=supervisor, log=log) + args.scale, supervisor=supervisor, log=log, image=image, + boot_key=boot_key, flash=flash) print(f"serving on http://{args.host}:{args.port}/") print("attached to a running emulator" if args.attach else "emulator is OFF; press On in the browser to boot it") diff --git a/work/README.md b/work/README.md new file mode 100644 index 0000000..a097a96 --- /dev/null +++ b/work/README.md @@ -0,0 +1,190 @@ +# 让 f4hwn 5.9.0.CN 在本机(Windows)跑起来 + +本机实测记录。结论先说:**这个项目并不是"只能跑在 Linux",只有外壳脚本和 unix socket 是 +Linux 的;机器模型和工具本身跨平台。** 我在本机用 MSYS2 原生编了 QEMU 7.2,把 +`qemu/py32f071.c` 编进去,用户的固件已经跑起来、能按键、能出画面。 + +## 固件是什么 + +`04-20260827_白头佬汉化版_f4hwn.5.9.0.bin`,114,324 字节。 + +* 身份串:`UV-K5 Firmware, EGZUMER+F4HWN v5.9.0.CN` +* 向量表:SP=0x20004000,Reset=0x08002d49 —— 与仓库参考固件逐字节相同,说明它是 + **PY32F071 的应用镜像**,链接基址 `0x08002800`(`F:\pi` 里的逆向报告独立确认了同一基址) +* 它是应用镜像、不含 0..0x27FF 的出厂引导程序,所以**可以直接当 `-kernel` 用** + +## 为什么要包一层 ELF(重要) + +仓库 `py32f071.c` 的注释说 "`.elf/.bin` 都能启动",这句话对 `.bin` **不成立**: + +* QEMU 7.2 的 `armv7m_load_kernel(cpu, file, mem_base=0x2800, size)` 对 ELF 用程序头里的地址, + 对裸 `.bin` 则按 `mem_base` 加载 —— 也就是**容器地址 0x2800**; +* 而容器地址 0..0x1D800 是 flash 的别名区(映射到 flash 偏移 0x2800 起),所以裸 bin 会被写到 + flash 偏移 0x5000,**整整偏移 0x2800**,CPU 从地址 0 取向量表时读到的是空白,直接跑飞。 + +`tools/bin2elf.py` 就是补这个:把 `.bin` 包成一个 `PT_LOAD` 在 `0x08002800`、 +入口取镜像自身复位向量的 ELF32/ARM。 + +## 中文字体在 SPI flash 里,不在固件里 + +发行包里 `01/02/03` 三个文件是**首次刷机**才要的:`02` 是 16x16、`03` 是 8x8 的 GB2312 点阵 +字体包(UF2 容器)。它们烧到 SPI NOR 的固定位置: + + UF2 头里的目标地址 内容 大小 + 0x000A0000 16x16 字模 261,888 B(8184 字模 × 32 B) + 0x000E0000 8x8 字模 65,536 B(8192 字模 × 8 B) + +所以 `make_flash.py` 现在支持 `--blob`,`.uf2` 按自身块地址落盘: + + python tools/make_flash.py \ + --blob 0:work/f4hwn/02-大字体16x16.uf2 \ + --blob 0:work/f4hwn/03-小字体8x8.uf2 + +不写进去会怎样:字体区是空的(0xFF),汉字全变实心块。 + +## 关键地址(已用固件源码交叉验证) + +| 符号 | 地址 | 大小 | 说明 | +| --- | --- | --- | --- | +| gFrameBuffer | `0x200012BE` | 896 = 7 行 × 128 | 显示区,面板第 1..7 页 | +| gStatusLine | `0x2000163E` | 128 | 状态行,面板第 0 页 | + +两个地址**都不是 128 字节对齐**,相差正好 896 字节(帧缓冲在前、状态行在后,与声明顺序相反; +参考固件的 `0x200013DC / 0x2000175C` 也是这个关系)。 + +这两个值是**算出来的,不是看出来的**,方法可复用到任何新固件: + +1. 抓 16 KB SRAM:`python work/qmp.py dump 0x20000000 0x4000 work/s4.bin`。 +2. 从源码里挑"内容已知、落点已知"的位图(`App/driver/st7565.c`、`App/ui/status.c`、 + `App/bitmaps.c`): + * `gFontPowerSave` 在状态行 +0、`gFontDWR` +18、`gFontPttClassic` +54、 + `BITMAP_BatteryLevel1` +111(= `LCD_WIDTH - 17`); + * `BITMAP_VFO_Default`(VFO 箭头)由 `memcpy(p_line0 + 0, ...)` 画在**帧行偏移 0**。 +3. 在 SRAM 里搜这些字节串:状态行四个位图的间距要**同时**成立,只有一个基址满足; + 箭头的落点直接给出帧缓冲基址。两者独立得出 `0x2000163E` 与 `0x200012BE`(相差 896,吻合)。 + +之前我把基址取成 128 对齐的 `0x20001280 / 0x20001600`,偏了 **0x3E = 62 字节**。后果不是整体平移, +而是**每行 62 字节环绕折行**:渲染出的每一行 = 上一真实行的尾部 62 列 + 本行的头部 66 列, +字会从第 66 列被劈开,状态行还混进了帧行尾部——看起来就是"没对准"。 + +快速自检(对齐正确时应成立):帧行 3 是源码 `memset(gFrameBuffer[3], 0, 128)` 清掉的中缝, +应整行全 0;上/下 VFO 的帧行 0≡4、1≡5;帧行 2 与 6 只该在活动 VFO 的说明文字上不同。 + +面板侧两个细节(不影响取帧,但解释列号为什么 +4):驱动写 `Line + 176`、`Column + 4`, +`cmds[]` 用 `0xA1`(SEG 反向)+ `0xC0`(COM 正常)。 + +## 怎么跑 + + powershell -File work\run-emulator.ps1 # 起 QEMU(QMP tcp:4444,GDB tcp:1234) + powershell -File work\run-webui.ps1 # 起网页遥控,然后开 http://127.0.0.1:8080/ + powershell -File work\restore-flash.ps1 # 还原 flash.img(先停模拟器) + +命令行按键(走 TCP): + + python tools/key.py --socket 127.0.0.1:4444 MENU DOWN DOWN + +## 本机上的构件 + +| 路径 | 说明 | +| --- | --- | +| `F:\dsh-build\qemu-7.2.0` | QEMU 7.2.0 源码 + 打入的模型、patched SysTick、uv-k5-v3 注册 | +| `F:\dsh-build\qemu-7.2.0\build\qemu-system-arm.exe` | 编译产物;运行需要 `F:\msys64\mingw64\bin` 在 PATH | +| `F:\msys64` | MSYS2(gcc 16.2 / glib 2.90 / pixman / meson / ninja / gdb / perl) | +| `work/f4hwn/*.elf` | 由 .bin 包的 ELF | +| `assets/flash.img` | 校准 + 字体;`work/flash-base.img` 是干净副本 | + +## 为了在 Windows 上跑起来,对仓库做了什么 + +1. `qemu/py32f071.c`:补 `#include "qapi/visitor.h"`。原文件用 `visit_type_uint64` 却没包含声明它的 + 头,对**原版 QEMU 7.2 编译不过**(作者的环境里应该是被别的头间接带入的)。 +2. `tools/bin2elf.py`:新增(见上)。 +3. `tools/make_flash.py`:新增 `--blob ADDR:FILE`,`.uf2` 按块地址解析。 +4. `tools/uvk5_qmp.py`、`tools/key.py`:QMP 端点除 unix 路径外接受 `host:port`。 +5. `tools/uvk5_supervisor.py`:`wait_for_socket` 支持 TCP 端点。 +6. `tools/uvk5_lcd.py`、`tools/uvk5_stream.py`:帧暂存目录不再硬编码 `/dev/shm`(Windows 没有), + 退回系统临时目录。 + +Linux 上的行为都没变:地址默认值仍是 unix 路径,`/dev/shm` 存在时仍用 `/dev/shm`。 + +## 面板级设置:对比度与反显 + +菜单里的 **SetCtr(对比度)** 和 **SetInv(反显)** 属于面板,不属于帧缓冲: + +```c +gSetting_set_ctr = ...; // App/app/menu.c +ST7565_ContrastAndInv(); // → 0xE2、0x81 + (21 + set_ctr)、0xA6|set_inv +``` + +它们只往 ST7565 发命令,`gFrameBuffer` 一个字节都不改。网页渲染的是帧缓冲,所以"改了没反应"是必然的 +——除非把控制器本身也建模。现在 SPI1 后面挂了最小的 ST7565 模型(A0 接 PA6、CS 接 PB2,与 +`App/driver/st7565.c` 一致),解析 `0xA6/0xA7`(反显)、`0xAE/0xAF`(开屏)、`0x81 <值>`(对比度), +并暴露三个**只读**属性: + + qom-get /machine/panel invert 反显位(0xA7 之后为真) + qom-get /machine/panel contrast 0x81 后面那个值 + qom-get /machine/panel display-on 0xAF / 0xAE + +* **反显会真的作用到画面**:`tools/uvk5_lcd.py` 渲染时按该位取反,所以 60 号设置现在看得见。 +* **对比度只报告不渲染**:那是模拟量(玻璃多黑),渲染不出来。本机实测值 `36 = 21 + 15`, + 正好等于当时存着的对比度设置,说明命令一直都在发。 +* **display-on 只报告不动作**:软复位 `0xE2` 是否清掉开屏位我无法确证,拿不确定的语义去把用户画面 + 变黑比不动作更糟。 + +## 固件日志为什么要在网页里看得见 + +模型把串口按 `SERIAL <行>` 打到 **stderr**,而只有**服务器自己启动 QEMU** 时才会去读这个管道 +(`uvk5_supervisor` 在连接成功后 `pump_stream` 到日志缓冲)。所以: + +* `work/run-webui.ps1` 默认**自己启动模拟器**(页面上的 On/Off 也就是真的了), + 日志面板因此能看到固件串口(启动横幅 `UV-K5 Firmware, EGZUMER+F4HWN v5.9.0.CN` 就在里面); +* 加 `-Attach` 则回到"附着到别处启动的模拟器",此时服务器看不到那个 stderr 流,面板里只有 + QEMU/电源事件——**这正是之前看不到 debug 日志的原因**。 + +这条线上一根线上跑着两种东西:固件自己的可读输出,和 **CPS 编程协议(二进制)**。后者按文本解码会 +把面板刷成一屏控制字符、把可读的那行埋掉。所以 `uvk5_logs.describe_line()` 现在对"大部分字节不可打印" +的行只给**长度 + 十六进制头**,可读行照旧——两种数据都还在,只是不再互相盖住: + + [serial] UV-K5 Firmware, EGZUMER+F4HWN v5.9.0.CN + [serial] f0 aa 55 02 04 80 01 02 03 04 05 06 07 08 09 + [serial] 0e 0f 10 11 12 13 14 60 0c 1f 06 f0 15 c3 07 … +231 bytes + +回归测试在 `tools/test_uvk5_logs.py::test_pump_stream_summarises_binary_serial`。 + +## 我在这一路上搞错和修掉的东西 + +* **更正:固件确实在读 SPI flash。** 我先前写的"26 秒零访问"是**测量假象**——PowerShell 的 + `2>` 重定向把 QEMU 的 stderr 写成了 UTF-16LE,而我的过滤器在找 `FLASHREAD`/`LCDW` 这样的 + ASCII 行,于是"什么都没找到"被我当成了"什么都没发生"。按 UTF-16 解码后:SPI1 上是完整的 + ST7565 序列(`e2 a2 c0 a1 a6 a4 24 81 1f 2b…`),SPI2 上读的全是 `0x00A0xx`(设置区,48 个不同 + 地址,片选翻转 30 次)。 +* **再更正一次(同一个坑的第二种形态):字体包确实被读了。** 上面那句"字体包没被读"是我把探针 + **截断在前 80 次读**得出的——正是 AGENTS 里"没弄清数据形态之前不要截断诊断日志"警告过的错。 + 把上限放到 4000 次、并顺手在菜单里转一圈让固件画汉字之后,实测 3168 次读里: + `0x0A0000`(3)、`0x0B0000`(11)、`0x0C0000`(6)、`0x0E0000`(15) —— 都在字体区;另外 + `0x1E0000` 有 **1024 次、每步正好 32 字节**(= 连续走完 32 KB 的一张字体表)。 +* **外置 SPI flash 的分区**(由 `gitee.com/oldlicn/betula-multi-system-tool` 的官方数据反推, + 不靠那张分区图): + + | 偏移 | 大小 | 内容 | + | --- | --- | --- | + | `0x000000` | 128 KB | BL/多系统引导 + 设置(固件读 `0x00A0xx`)+ 校准(`0x010000`,与 `make_flash.py` 一致) | + | `0x020000` | 4 × 128 KB | 四个固件槽(官方"清空 0x20000-0x40000 … 0x80000-0xA0000"正好四段) | + | `0x0A0000` | 256 KB | 用户字体包 16x16(UF2 目标地址就是这里) | + | `0x0E0000` | 64 KB | 用户字体包 8x8 | + | `0x100000` | 1 MB | 出厂资源区:含拼音串,且 **`0x1E0000` 处有 32 KB 字体表**,固件开机会整张走过 | + + 仓库里 16 份官方恢复数据(每份 128 KB)可以直接拼回**真机整片 2 MB 数据**; + 把这些字节拼出来放在 `F:\dsh-build\flashdump\factory-2MB.img`,补上它之后再跑, + **画面上的字会变**——说明模拟器原来的镜像缺了固件真正在用的字体数据 + (`work/flash-with-factory-resource.img` 就是这个"原厂资源区 + 用户字体包 + 校准"的合成)。 + 剩下没落实的是:屏幕上的 16 像素大字与 `0xA0000` 的字模包**仍不能逐字节对上** + (2/24),与 `0x1E0000` 那张表也对不上(0/8),所以"哪个来源供哪一块文字"还没定论。 +* **修:Windows 上 `rename()` 不覆盖已存在的文件。** flash 回写走"写临时文件再改名", + 于是每一次回写都失败(`cannot replace`),设置因此**从不落盘**,而反复的告警把主循环挤住, + 连 QMP 的问候语都发不出来(表现为"power on failed: timed out")。改用 `g_rename()` + (需 ``;Windows 上是 MoveFileEx+替换,Unix 上就是 rename)。现在电源能开、设置能存。 +* **修:power on 失败时看不到原因。** supervisor 只在连接成功后才去读 QEMU 的 stderr, + 于是失败只剩一句"QMP 端口没出现"。现在会把 QEMU stderr 的**尾部**记进日志——上面那个 rename + 问题正是这样浮出来的。 +* **修:启动器在 TCP 端点下用错参数。** `default_launcher` 现在按端点类型生成 + `unix:…` 或 `tcp:host:port`(Windows 的 QEMU 根本没有 unix socket)。 diff --git a/work/boot-radio.ps1 b/work/boot-radio.ps1 new file mode 100644 index 0000000..d98f015 --- /dev/null +++ b/work/boot-radio.ps1 @@ -0,0 +1,19 @@ +# Boot one firmware image in the emulator, paused, with a key held from reset. +# +# powershell -File work/boot-radio.ps1 -Image -Qmp -Gdb [-Flash ] +# +# The quoting matters: passing a Windows path with spaces or commas straight through +# cmd /c from a caller that is itself quoted mangles it, which is what "unsupported +# machine type" and friends look like from outside. A script keeps one layer of it. +param( + [Parameter(Mandatory=$true)][string]$Image, + [int]$Qmp = 4470, + [int]$Gdb = 1260, + [string]$Flash = "$PSScriptRoot\ms-scratch.img" +) +$env:PATH = 'F:\msys64\mingw64\bin;' + $env:PATH +$env:UVK5_FLASH_IMAGE = $Flash +$qemu = 'F:\dsh-build\qemu-7.2.0\build\qemu-system-arm.exe' +& $qemu -M uv-k5-v3 -S -nographic -monitor none -serial null ` + -qmp "tcp:127.0.0.1:$Qmp,server=on,wait=off" ` + -kernel $Image -gdb "tcp::$Gdb" 2> "$PSScriptRoot\boot-radio-err.log" diff --git a/work/restore-flash.ps1 b/work/restore-flash.ps1 new file mode 100644 index 0000000..b41db42 --- /dev/null +++ b/work/restore-flash.ps1 @@ -0,0 +1,9 @@ +# Put the SPI flash image back to its known-good state. +# +# The firmware writes settings back into assets/flash.img (the PY25Q16 model +# flushes on exit), so a session can leave it changed. Stop the emulator first: +# a running QEMU holds the old image in memory and overwrites the file on exit. +$ErrorActionPreference = 'Stop' +$root = Join-Path $PSScriptRoot '..' +Copy-Item "$PSScriptRoot\flash-base.img" (Join-Path $root 'assets\flash.img') -Force +Write-Host "restored assets/flash.img from work/flash-base.img" diff --git a/work/run-emulator.ps1 b/work/run-emulator.ps1 new file mode 100644 index 0000000..1a9197a --- /dev/null +++ b/work/run-emulator.ps1 @@ -0,0 +1,26 @@ +# Start the emulated radio with the f4hwn 5.9.0.CN firmware (Windows build). +# +# powershell -File work\run-emulator.ps1 +# +# QEMU here is a native Windows build made with MSYS2, so it needs the MSYS2 +# mingw64 DLLs on PATH. QMP is TCP: a Windows QEMU has no unix sockets, which is +# the one thing about this project that really was Linux-only. +param( + [string]$Kernel = "$PSScriptRoot\f4hwn\EGZUMER+F4HWN-v5.9.0.CN.elf", + [string]$Flash = "$PSScriptRoot\..\assets\flash.img", + [string]$Serial = "$PSScriptRoot\serial.log", + [int]$QmpPort = 4444, + [int]$GdbPort = 1234 +) +$ErrorActionPreference = 'Stop' +$env:PATH = 'F:\msys64\mingw64\bin;' + $env:PATH +$qemu = 'F:\dsh-build\qemu-7.2.0\build\qemu-system-arm.exe' + +Write-Host "kernel : $Kernel" +Write-Host "flash : $Flash" +Write-Host "QMP : tcp:127.0.0.1:$QmpPort GDB: tcp::$GdbPort serial: $Serial" +& $qemu -M "uv-k5-v3,flash-image=$Flash" -kernel $Kernel ` + -display none -monitor none ` + -serial "file:$Serial" ` + -qmp "tcp:127.0.0.1:$QmpPort,server=on,wait=off" ` + -gdb "tcp::$GdbPort" diff --git a/work/run-webui.ps1 b/work/run-webui.ps1 new file mode 100644 index 0000000..b0b5ab6 --- /dev/null +++ b/work/run-webui.ps1 @@ -0,0 +1,37 @@ +# Serve the web remote control. +# +# powershell -File work\run-webui.ps1 # the page starts the emulator +# powershell -File work\run-webui.ps1 -Attach # attach to run-emulator.ps1 +# +# Owning the process is what puts the firmware's serial output in the page's log +# pane: the model prints it to stderr as "SERIAL ..." and the server reads QEMU's +# stderr. With -Attach the server never sees that stream, so the pane stays empty. +# Owning it also makes the On/Off buttons real. +param( + [int]$Port = 8080, + [int]$QmpPort = 4444, + [string]$Frame = '0x200012BE', # gFrameBuffer, proven against the firmware source + [string]$Status = '0x2000163E', # gStatusLine + [string]$Kernel = "$PSScriptRoot\f4hwn\EGZUMER+F4HWN-v5.9.0.CN.elf", + [string]$Flash = [System.IO.Path]::GetFullPath("$PSScriptRoot\..\assets\flash.img"), + [string]$Qemu = 'F:\dsh-build\qemu-7.2.0\build\qemu-system-arm.exe', + [switch]$Attach +) +$ErrorActionPreference = 'Stop' +# The QEMU this server spawns is a native Windows build, so the MSYS2 mingw64 DLLs +# have to be on the child's PATH -- inherited from here, since the child gets this +# environment. Without it QEMU dies at load and "power on" reports only that the QMP +# port never appeared. +$env:PATH = 'F:\msys64\mingw64\bin;' + $env:PATH +$py = 'C:\Users\Administrator\.dsh\dsh-runtimes\dsh-primary-runtime\dependencies\python\python.exe' +Set-Location (Join-Path $PSScriptRoot '..') + +$common = @('tools\webui.py', '--qmp', "127.0.0.1:$QmpPort", + '--frame-addr', $Frame, '--status-addr', $Status, '--port', $Port) +if ($Attach) { + Write-Host "attaching to an emulator already listening on 127.0.0.1:$QmpPort" + & $py @common --attach +} else { + Write-Host "the page will start: $Qemu" + & $py @common --qemu $Qemu --elf $Kernel --flash $Flash +}