Merge branch 'feat/hwpod-web-workspace-ops' into v0.3

This commit is contained in:
root
2026-07-21 10:52:29 +02:00
4 changed files with 99 additions and 2 deletions
+43
View File
@@ -305,6 +305,8 @@ class NodeOpsExecutor:
def __init__(self, config: dict, logger: logging.Logger):
self.config = config
self.logger = logger
self.uart = None
self.uart_lock = threading.Lock()
@property
def node_id(self) -> str:
@@ -801,6 +803,8 @@ class NodeOpsExecutor:
return ["bun", "scripts/serial-monitor-cli.ts"]
def _uart_control(self, args: dict, action: str) -> dict:
if str(args.get("uartBackend") or "").lower() == "pyserial":
return self._pyserial_control(args, action)
serial_dir = Path(str(args.get("serialMonitorDir") or Path.home() / ".agents" / "skills" / "serial-monitor"))
command_base = self._serial_command_base(args)
timeout_ms = safe_int(args.get("timeoutMs"), 10000)
@@ -831,6 +835,8 @@ class NodeOpsExecutor:
data = str(args.get("data") or "")
if not data:
return {"ok": False, "blockerCode": "hwpod_uart_data_required", "stderr": "UART data is required", "exitCode": None}
if str(args.get("uartBackend") or "").lower() == "pyserial":
return self._pyserial_write(args, data)
serial_dir = Path(str(args.get("serialMonitorDir") or Path.home() / ".agents" / "skills" / "serial-monitor"))
command = [*self._serial_command_base(args), "send", data]
output = self._spawn_output(command, serial_dir, safe_int(args.get("timeoutMs"), 10000))
@@ -838,6 +844,8 @@ class NodeOpsExecutor:
return {**output, "ok": output.get("ok") and body.get("success") is True, "bindingSource": "serial-monitor-cli", "bytes": len(data.encode("utf-8")), "response": body or None}
def _uart_read(self, args: dict) -> dict:
if str(args.get("uartBackend") or "").lower() == "pyserial":
return self._pyserial_read(args)
serial_dir = Path(str(args.get("serialMonitorDir") or Path.home() / ".agents" / "skills" / "serial-monitor"))
command_base = self._serial_command_base(args)
timeout_ms = safe_int(args.get("timeoutMs"), 10000)
@@ -891,6 +899,41 @@ class NodeOpsExecutor:
observation["rawArtifactRef"] = source_file
return {"ok": True, "workspacePath": str(self._workspace_root(args)), "bindingSource": "serial-monitor-cli", "serialMonitorDir": str(serial_dir), "requestedPort": port or None, "resolvedPort": resolved_port, "baudRate": resolved_baud, "command": [*command_base, *fetch_args], "data": rows, "count": fetch_body.get("count", len(rows)), "totalCount": fetch_body.get("totalCount"), "hasMore": fetch_body.get("hasMore", False), "text": truncated_text, "truncated": len(text) > max_bytes or fetch_body.get("truncated") is True, "monitorStatus": status_body, "monitorStarted": not matches, "monitorStart": monitor_start, "sourceFile": source_file or None, "observation": observation}
def _pyserial_control(self, args: dict, action: str) -> dict:
with self.uart_lock:
if action == "stop":
if self.uart is not None:
self.uart.close()
self.uart = None
return {"ok": True, "bindingSource": "pyserial", "status": "closed"}
import serial
port = str(args.get("port") or "")
baud = safe_int(args.get("baudRate") or args.get("baudrate"), 115200)
if self.uart is not None and (self.uart.port != port or self.uart.baudrate != baud):
self.uart.close()
self.uart = None
if self.uart is None or not self.uart.is_open:
self.uart = serial.Serial(port=port, baudrate=baud, timeout=0)
return {"ok": True, "bindingSource": "pyserial", "status": "open", "port": port, "baudRate": baud}
def _pyserial_write(self, args: dict, data: str) -> dict:
with self.uart_lock:
if self.uart is None or not self.uart.is_open:
return {"ok": False, "blockerCode": "hwpod_uart_not_open", "stderr": "UART must be opened before write", "exitCode": None}
payload = data.encode(str(args.get("encoding") or "utf-8"))
written = self.uart.write(payload)
return {"ok": written == len(payload), "bindingSource": "pyserial", "bytes": written, "port": self.uart.port, "baudRate": self.uart.baudrate}
def _pyserial_read(self, args: dict) -> dict:
with self.uart_lock:
if self.uart is None or not self.uart.is_open:
return {"ok": False, "blockerCode": "hwpod_uart_not_open", "summary": "UART must be opened before read"}
max_bytes = max(1, min(safe_int(args.get("maxBytes"), 16384), 1048576))
available = min(safe_int(getattr(self.uart, "in_waiting", 0), 0), max_bytes)
payload = self.uart.read(available) if available else b""
text = payload.decode(str(args.get("encoding") or "utf-8"), errors="replace")
return {"ok": True, "bindingSource": "pyserial", "port": self.uart.port, "baudRate": self.uart.baudrate, "bytes": len(payload), "text": text, "dataBase64": base64.b64encode(payload).decode("ascii"), "truncated": safe_int(getattr(self.uart, "in_waiting", 0), 0) > 0}
def _parse_json(self, text: object) -> dict:
try:
value = json.loads(str(text or ""))
+9
View File
@@ -267,9 +267,18 @@ test("hwpod-cli dry-run invokes hwpod-compiler-cli and exposes hwpod-node-ops pl
assert.equal(uartSessionOnly.payload.plan.ops[0].args.sessionOnly, true);
assert.equal(uartSessionOnly.payload.plan.ops[0].args.commandBinding.sessionOnly, true);
await runHwpodCtl([
"spec",
"set",
"spec.ioProbe.uart.backend",
"pyserial",
"--spec",
specPath
], { now: () => NOW });
const uartOpen = await runHwpodCli(["uart", "open", "--spec", specPath, "--port", "uart1", "--dry-run"], { now: () => NOW });
assert.equal(uartOpen.payload.plan.ops[0].op, "io.uart.open");
assert.equal(uartOpen.payload.plan.ops[0].args.port, "COM9");
assert.equal(uartOpen.payload.plan.ops[0].args.uartBackend, "pyserial");
const uartWrite = await runHwpodCli(["uart", "write", "--spec", specPath, "--port", "uart1", "--data", "status", "--dry-run"], { now: () => NOW });
assert.equal(uartWrite.payload.plan.ops[0].op, "io.uart.write");
assert.equal(uartWrite.payload.plan.ops[0].args.data, "status");
+43
View File
@@ -430,6 +430,49 @@ print(json.dumps({"result": result, "commands": executor.commands}))
]);
});
test("python hwpod-node keeps a pyserial connection across UART operations", () => {
const script = `
import importlib.util, json, logging, sys, types
spec = importlib.util.spec_from_file_location("hwlab_node", "tools/hwlab-node.py")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
class Port:
def __init__(self, port, baudrate, timeout):
self.port = port
self.baudrate = baudrate
self.is_open = True
self.buffer = bytearray(b"ready")
self.sent = bytearray()
@property
def in_waiting(self): return len(self.buffer)
def read(self, size):
value = bytes(self.buffer[:size])
del self.buffer[:size]
return value
def write(self, value):
self.sent.extend(value)
return len(value)
def close(self): self.is_open = False
sys.modules["serial"] = types.SimpleNamespace(Serial=Port)
executor = module.NodeOpsExecutor({"nodeId": "node-test"}, logging.getLogger("test"))
common = {"workspacePath": ".", "port": "COM4", "baudRate": 921600, "uartBackend": "pyserial"}
results = [
executor._execute_op({"opId": "open", "op": "io.uart.open", "args": common}),
executor._execute_op({"opId": "write", "op": "io.uart.write", "args": {**common, "data": "status"}}),
executor._execute_op({"opId": "read", "op": "io.uart.read", "args": {**common, "maxBytes": 16}}),
executor._execute_op({"opId": "close", "op": "io.uart.close", "args": common}),
]
print(json.dumps(results))
`;
const completed = spawnSync("python3", ["-c", script], { cwd: process.cwd(), encoding: "utf8" });
assert.equal(completed.status, 0, completed.stderr);
const results = JSON.parse(completed.stdout);
assert.deepEqual(results.map((item: any) => item.status), ["completed", "completed", "completed", "completed"]);
assert.equal(results[1].output.bytes, 6);
assert.equal(results[2].output.text, "ready");
assert.equal(results[2].output.bindingSource, "pyserial");
});
test("hwpod-node resolves Git for Windows when service PATH omits git", async () => {
const result = await resolveHwpodNodeCommand("git", {
platform: "win32",
+4 -2
View File
@@ -894,6 +894,7 @@ function uartReadOps(common: any, args: any, document: any) {
limit: numberValue(args.limit),
since: text(args.since),
sessionOnly: args.sessionOnly === true,
uartBackend: generated.backend,
serialMonitorDir: generated.serialMonitorDir,
serialMonitorCommand: generated.commandBase,
commandBinding: generated.binding,
@@ -907,7 +908,7 @@ function uartControlOps(common: any, intent: "io.uart.open" | "io.uart.close", a
const generated = serialMonitorBinding(args, document);
return [{
op: intent,
args: clean({ ...common, port: generated.physicalPort, baudRate: generated.baudRate, serialMonitorDir: generated.serialMonitorDir, serialMonitorCommand: generated.commandBase, timeoutMs: generated.timeoutMs, reason: text(args.reason) })
args: clean({ ...common, port: generated.physicalPort, baudRate: generated.baudRate, uartBackend: generated.backend, serialMonitorDir: generated.serialMonitorDir, serialMonitorCommand: generated.commandBase, timeoutMs: generated.timeoutMs, reason: text(args.reason) })
}];
}
@@ -915,7 +916,7 @@ function uartWriteOps(common: any, args: any, document: any) {
const generated = serialMonitorBinding(args, document);
return [{
op: "io.uart.write",
args: clean({ ...common, port: generated.physicalPort, baudRate: generated.baudRate, data: requiredText(args.data, "data"), serialMonitorDir: generated.serialMonitorDir, serialMonitorCommand: generated.commandBase, timeoutMs: generated.timeoutMs, reason: text(args.reason) })
args: clean({ ...common, port: generated.physicalPort, baudRate: generated.baudRate, data: requiredText(args.data, "data"), uartBackend: generated.backend, serialMonitorDir: generated.serialMonitorDir, serialMonitorCommand: generated.commandBase, timeoutMs: generated.timeoutMs, reason: text(args.reason) })
}];
}
@@ -1043,6 +1044,7 @@ function serialMonitorBinding(args: any, document: any) {
const requestedPort = text(args.port) || text(uart.id) || "uart1";
return {
requestedPort,
backend: text(args.uartBackend) || text(uart.backend) || "serial-monitor",
physicalPort: text(args.physicalPort) || text(uart.port) || text(uart.path) || text(ioProbe.port) || requestedPort,
baudRate: numberValue(args.baudRate ?? args.baudrate ?? uart.baudRate ?? uart.baudrate ?? ioProbe.baudRate ?? ioProbe.baudrate) ?? 115200,
serialMonitorDir: text(args.serialMonitorDir) || text(tooling.serialMonitorDir) || text(ioProbe.serialMonitorDir) || "C:\\Users\\liang\\.agents\\skills\\serial-monitor",