feat: consolidate VM automation in Python daemon
This commit is contained in:
+36
-14
@@ -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.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user