Merge branch 'feat/hwpod-web-workspace-ops' into v0.3
This commit is contained in:
@@ -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 ""))
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user