feat: consolidate VM automation in Python daemon

This commit is contained in:
2026-08-16 16:19:19 +09:00
parent 2477777e1a
commit 7cbbb709eb
16 changed files with 434 additions and 635 deletions
+36 -14
View File
@@ -1,26 +1,48 @@
# Development tools
## QEMU console OCR
## Host support
Capture the current QEMU VGA screen and print detected console text:
Automation currently supports **Windows 10/11 only**. It requires `uv`, QEMU
with WHPX support, and `ffmpeg.exe` on `PATH`. The Python implementation uses
portable APIs where possible, but other hosts are not supported yet.
## QEMU and FreeDOS automation
Start the Python daemon and QEMU:
```powershell
uv run python tools/qemu_ocr.py
uv run ferro-vm start
uv run ferro-vm status
```
Useful options:
`TCPAGENT.EXE` connects only to `127.0.0.1:5558`. Local commands use the
Windows named pipe `\\.\pipe\ferrolang-vm`; there is no controller or observer
TCP port. Monitor the append-only structured log in another terminal:
```powershell
# Machine-readable boxes, confidence scores, and text
uv run python tools/qemu_ocr.py --json
lnav .qemu/ferro-vm.log
```
# OCR an existing screenshot without recapturing
Commands:
```powershell
uv run ferro-vm ping
uv run ferro-vm exec 'dir C:\FEC'
uv run ferro-vm put fec/src/check.c 'C:\FEC\SRC\CHECK.C'
uv run ferro-vm get 'C:\FEC\TEST.OK' .qemu/TEST.OK
uv run ferro-vm screenshot
uv run ferro-vm ocr
uv run ferro-vm stop
```
The daemon logs command metadata, DOS output, exit status, transfers, and
agent lifecycle events as UTF-8 lines. It deliberately never logs raw binary
payloads or protocol hex.
## Standalone OCR
`tools/qemu_ocr.py` remains available for OCRing an existing image:
```powershell
uv run python tools/qemu_ocr.py --image .qemu/qemu-screen.png
# Save extracted text
uv run python tools/qemu_ocr.py -o .qemu/qemu-screen.txt
```
The tool invokes `.qemu/screenshot.ps1`, runs RapidOCR with ONNX Runtime, sorts
recognized lines by screen position, and emits UTF-8 text. Screenshots and OCR
output under `.qemu/` remain ignored build artifacts.
-122
View File
@@ -1,122 +0,0 @@
#!/usr/bin/env python3
"""Stage changed source files into the running FreeDOS VM over DOSAGENT.
Usage: python tools/dos_stage.py host/file=C:\\FEC\\DEST.FILE [...]
The DOS agent uses a 115200-baud virtual COM1 link and accepts at most 4096
binary payload bytes per ASCII-hex WRITE request.
"""
import hashlib
import socket
import sys
from pathlib import Path
HOST = "127.0.0.1"
PORT = 5555
DEFAULT_CHUNK_SIZE = 4096
def request(line: str) -> str:
with socket.create_connection((HOST, PORT), timeout=30) as sock:
sock.sendall((line + "\n").encode("ascii"))
response = bytearray()
while not response.endswith(b"\n"):
part = sock.recv(65536)
if not part:
break
response.extend(part)
text = response.decode("ascii", "strict").strip()
if text.startswith("ERR "):
raise RuntimeError(bytes.fromhex(text[4:]).decode("utf-8", "replace"))
if not text.startswith("OK"):
raise RuntimeError("unexpected DOSAGENT response: " + text)
return text
def recv_line(sock: socket.socket) -> bytes:
line = bytearray()
while not line.endswith(b"\n"):
part = sock.recv(1)
if not part:
raise RuntimeError("TCP agent closed the connection")
line.extend(part)
return bytes(line).strip()
def fnv1a(data: bytes) -> int:
value = 2166136261
for byte in data:
value = ((value ^ byte) * 16777619) & 0xFFFFFFFF
return value
def binary_stage(local: Path, remote: str) -> None:
data = local.read_bytes()
path_hex = remote.encode("ascii").hex().upper()
with socket.create_connection((HOST, PORT), timeout=30) as sock:
sock.sendall(("PUT %s %d\n" % (path_hex, len(data))).encode("ascii"))
sock.sendall(data)
response = recv_line(sock)
if not response.startswith(b"OK"):
raise RuntimeError("PUT failed: " + response.decode("ascii", "replace"))
sock.sendall(("HASH %s\n" % path_hex).encode("ascii"))
header = recv_line(sock).decode("ascii", "strict")
fields = header.split()
if len(fields) != 3 or fields[0] != "STAT":
raise RuntimeError("HASH failed: " + header)
remote_length = int(fields[1])
remote_hash = int(fields[2], 16)
if remote_length != len(data) or remote_hash != fnv1a(data):
raise RuntimeError("verification mismatch for " + remote)
print("staged %s -> %s (%d bytes, sha256 %s, binary TCP)" % (
local, remote, len(data), hashlib.sha256(data).hexdigest()))
def stage(local: Path, remote: str, chunk_size: int) -> None:
data = local.read_bytes()
remote_hex = remote.encode("ascii").hex().upper()
for offset in range(0, max(1, len(data)), chunk_size):
part = data[offset : offset + chunk_size]
mode = "T" if offset == 0 else "A"
request("WRITE %s %s %s" % (remote_hex, mode, part.hex().upper()))
remote_data = bytearray()
offset = 0
while True:
response = request("READ %s %u" % (remote_hex, offset))
_, eof, payload = response.split(" ", 2)
piece = bytes.fromhex(payload)
remote_data.extend(piece)
offset += len(piece)
if eof == "1":
break
if bytes(remote_data) != data:
raise RuntimeError("verification mismatch for " + remote)
print("staged %s -> %s (%d bytes, sha256 %s)" % (
local, remote, len(data), hashlib.sha256(data).hexdigest()))
def main() -> int:
args = sys.argv[1:]
chunk_size = DEFAULT_CHUNK_SIZE
binary = False
if args and args[0] == "--binary":
binary = True
args = args[1:]
if len(args) >= 2 and args[0] == "--chunk-size":
chunk_size = int(args[1])
args = args[2:]
if not args:
print("usage: dos_stage.py [--binary] [--chunk-size N] LOCAL=DOS_PATH [... ]", file=sys.stderr)
return 2
for spec in args:
if "=" not in spec:
raise SystemExit("missing '=' in " + spec)
local, remote = spec.split("=", 1)
if binary:
binary_stage(Path(local), remote)
else:
stage(Path(local), remote, chunk_size)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -9
View File
@@ -14,19 +14,11 @@ from rapidocr import RapidOCR
ROOT = Path(__file__).resolve().parent.parent
DEFAULT_IMAGE = ROOT / ".qemu" / "qemu-screen.png"
SCREENSHOT = ROOT / ".qemu" / "screenshot.ps1"
def capture() -> Path:
subprocess.run(
[
"powershell",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
str(SCREENSHOT),
],
["ferro-vm", "screenshot"],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
+16 -9
View File
@@ -1,8 +1,13 @@
# FreeDOS resident TCP agent
`TCPAGENT.EXE` is a foreground resident automation process. It uses the mTCP
packet-driver stack and maintains an outbound connection to QEMU's host at
`10.0.2.2:5558`. The host relay keeps the existing Pi tool endpoint on 5555.
packet-driver stack and maintains an outbound connection to the QEMU host at
`10.0.2.2:5558`.
The Windows-only Python `ferro-vm` daemon owns that listener. It logs metadata
and decoded command output to `.qemu/ferro-vm.log`; it does not expose an
observer/controller TCP port or emit binary payloads to the log. Local host
control uses a Windows named pipe.
## Build in FreeDOS
@@ -17,16 +22,18 @@ therefore distributed under GPLv3 when linked with mTCP.
## Protocol
Legacy text commands remain for Pi tool compatibility: `PING`, `READ`,
`WRITE`, `LIST`, and `EXEC`. Fast staging uses binary framing:
`PING`, `READ`, `WRITE`, `LIST`, and `EXEC` use text commands. Fast transfer
commands are:
- `PUT <hex DOS path> <byte length>\n<raw bytes>` -> `OK\r\n`
- `GET <hex DOS path>\n` -> `DATA <length>\r\n<raw bytes>`
- `HASH <hex DOS path>\n` -> `STAT <length> <FNV1A32>\r\n`
Use `python tools/dos_stage.py --binary LOCAL=C:\\DOS\\PATH` for verified
staging. Verification reads only the length and FNV-1a hash, avoiding a slow
full-file return transfer.
The host invokes them through:
Use `.qemu/reset.ps1` to restart QEMU. It waits for an established TCP agent
and a successful PONG rather than sleeping for a fixed boot duration.
```powershell
uv run ferro-vm put host-file 'C:\DOS\FILE'
uv run ferro-vm get 'C:\DOS\FILE' host-file
```
No DOS-side change is required for the Python host automation.