feat: 支持 G14 Windows HWPOD 节点
增加节点凭据握手、工作区根目录约束和 CONSTAR MDTODO 数据源。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+73
-12
@@ -1,10 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Single-file HWLAB node GUI for D601/HWPOD.
|
||||
"""面向 Windows HWPOD 的单文件 HWLAB 图形节点。
|
||||
|
||||
The user-facing entrypoint intentionally accepts no command line arguments and
|
||||
does not require HWLAB_* environment variables. All durable configuration lives
|
||||
under ~/.hwlab/ and is managed by the settings dialog.
|
||||
用户入口不接受命令行参数,也不依赖 HWLAB_* 环境变量。
|
||||
持久配置统一存放在 ~/.hwlab/,并由设置界面或受控部署管理。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -51,7 +50,7 @@ except Exception: # pragma: no cover - import failure is reported in main.
|
||||
messagebox = None
|
||||
|
||||
|
||||
APP_VERSION = "0.1.1"
|
||||
APP_VERSION = "0.1.2"
|
||||
NODE_VERSION = f"{APP_VERSION}-python-gui"
|
||||
CONTRACT_VERSION = "hwpod-node-ops-v1"
|
||||
APP_NAME = "HWLAB Node"
|
||||
@@ -67,6 +66,10 @@ DEFAULT_CONFIG = {
|
||||
"serverUrl": "https://hwlab.pikapython.com",
|
||||
"nodeId": "node-d601-f103-v2",
|
||||
"autoConnect": False,
|
||||
"requireCredential": False,
|
||||
"credentialFile": str(CONFIG_DIR / "hwpod-node.token"),
|
||||
"allowedWorkspaceRoots": [],
|
||||
"defaultWorkspaceRoot": "",
|
||||
"update": {
|
||||
"enabled": True,
|
||||
"autoApplyWhenIdle": False,
|
||||
@@ -353,6 +356,31 @@ class NodeOpsExecutor:
|
||||
"io.uart.read",
|
||||
]
|
||||
|
||||
def allowed_workspace_roots(self) -> list[Path]:
|
||||
values = self.config.get("allowedWorkspaceRoots")
|
||||
if not isinstance(values, list):
|
||||
return []
|
||||
roots = []
|
||||
for value in values:
|
||||
if str(value).strip():
|
||||
roots.append(Path(str(value)).expanduser().resolve(strict=False))
|
||||
return roots
|
||||
|
||||
def _require_allowed_path(self, path: Path) -> Path:
|
||||
resolved = path.expanduser().resolve(strict=False)
|
||||
roots = self.allowed_workspace_roots()
|
||||
if not roots:
|
||||
return resolved
|
||||
resolved_key = os.path.normcase(str(resolved))
|
||||
for root in roots:
|
||||
root_key = os.path.normcase(str(root))
|
||||
try:
|
||||
if os.path.commonpath([resolved_key, root_key]) == root_key:
|
||||
return resolved
|
||||
except ValueError:
|
||||
continue
|
||||
raise PermissionError("workspace path is outside allowedWorkspaceRoots")
|
||||
|
||||
def _node_mismatch(self, plan: dict, requested_node: str) -> dict:
|
||||
blocker = {
|
||||
"code": "hwpod_node_id_mismatch",
|
||||
@@ -424,19 +452,33 @@ class NodeOpsExecutor:
|
||||
return self._failed(op_id, name, output, "hwpod_node_command_failed", f"{name} node-side command exited with {output.get('exitCode')}")
|
||||
|
||||
def _workspace_root(self, args: dict) -> Path:
|
||||
value = args.get("workspacePath") or args.get("workspaceRoot") or args.get("cwd") or os.getcwd()
|
||||
return Path(str(value)).expanduser()
|
||||
value = (
|
||||
args.get("workspacePath")
|
||||
or args.get("workspaceRoot")
|
||||
or args.get("cwd")
|
||||
or self.config.get("defaultWorkspaceRoot")
|
||||
or os.getcwd()
|
||||
)
|
||||
return self._require_allowed_path(Path(str(value)))
|
||||
|
||||
def _resolve_workspace_path(self, args: dict, relative: object) -> Path:
|
||||
text = str(relative or ".")
|
||||
candidate = Path(text)
|
||||
if candidate.is_absolute() or re.match(r"^[A-Za-z]:[\\/]", text):
|
||||
return candidate
|
||||
return self._workspace_root(args) / candidate
|
||||
return self._require_allowed_path(candidate)
|
||||
return self._require_allowed_path(self._workspace_root(args) / candidate)
|
||||
|
||||
def _node_inventory(self, args: dict) -> dict:
|
||||
root = self._workspace_root(args)
|
||||
return {"workspaceRoot": str(root), "workspaceExists": root.exists(), "workspaceIsDirectory": root.is_dir(), "platform": sys.platform, "hostname": socket.gethostname()}
|
||||
return {
|
||||
"workspaceRoot": str(root),
|
||||
"workspaceExists": root.exists(),
|
||||
"workspaceIsDirectory": root.is_dir(),
|
||||
"allowedWorkspaceRootCount": len(self.allowed_workspace_roots()),
|
||||
"capabilities": self.capabilities(),
|
||||
"platform": sys.platform,
|
||||
"hostname": socket.gethostname(),
|
||||
}
|
||||
|
||||
def _node_diagnostics(self, args: dict) -> dict:
|
||||
max_events = safe_int(args.get("maxEvents"), 50)
|
||||
@@ -700,7 +742,7 @@ class WebSocketNodeClient:
|
||||
self.sock = ssl.create_default_context().wrap_socket(raw, server_hostname=host) if parsed.scheme == "wss" else raw
|
||||
self._handshake(parsed, host, port)
|
||||
self.bus.emit("log", f"WebSocket 已打开: {ws_url}")
|
||||
self._send_json({"type": "register", "nodeId": self.executor.node_id, "name": self.executor.node_id, "capabilities": self.executor.capabilities(), "labels": {"platform": sys.platform, "hostname": socket.gethostname(), "version": NODE_VERSION, "startedAt": utc_now(), "diagnostics": DIAGNOSTICS.summary()}})
|
||||
self._send_json({"type": "register", "nodeId": self.executor.node_id, "name": self.executor.node_id, "capabilities": self.executor.capabilities(), "labels": {"platform": sys.platform, "hostname": socket.gethostname(), "version": NODE_VERSION, "startedAt": utc_now(), "allowedWorkspaceRootCount": len(self.executor.allowed_workspace_roots()), "diagnostics": DIAGNOSTICS.summary()}})
|
||||
last_heartbeat = 0.0
|
||||
last_diagnostic_flush = 0.0
|
||||
while not self.stop_event.is_set():
|
||||
@@ -734,13 +776,16 @@ class WebSocketNodeClient:
|
||||
path = parsed.path or "/"
|
||||
if parsed.query:
|
||||
path += "?" + parsed.query
|
||||
credential = self._credential()
|
||||
credential_header = f"X-HWPOD-Node-Token: {credential}\r\n" if credential else ""
|
||||
request = (
|
||||
f"GET {path} HTTP/1.1\r\n"
|
||||
f"Host: {host}:{port}\r\n"
|
||||
"Upgrade: websocket\r\n"
|
||||
"Connection: Upgrade\r\n"
|
||||
f"Sec-WebSocket-Key: {key}\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n\r\n"
|
||||
"Sec-WebSocket-Version: 13\r\n"
|
||||
f"{credential_header}\r\n"
|
||||
)
|
||||
assert self.sock is not None
|
||||
self.sock.sendall(request.encode("ascii"))
|
||||
@@ -755,6 +800,22 @@ class WebSocketNodeClient:
|
||||
if b" 101 " not in response.split(b"\r\n", 1)[0]:
|
||||
raise RuntimeError(f"websocket upgrade failed: {response[:200]!r}")
|
||||
|
||||
def _credential(self) -> str:
|
||||
path_value = str(self.config.get("credentialFile") or "").strip()
|
||||
required = self.config.get("requireCredential") is True
|
||||
if not path_value:
|
||||
if required:
|
||||
raise RuntimeError("credentialFile is required")
|
||||
return ""
|
||||
path = Path(path_value).expanduser()
|
||||
try:
|
||||
credential = path.read_text(encoding="utf-8").strip()
|
||||
except FileNotFoundError:
|
||||
credential = ""
|
||||
if required and not credential:
|
||||
raise RuntimeError("HWPOD node credential is missing")
|
||||
return credential
|
||||
|
||||
def _handle_message(self, text: str) -> None:
|
||||
message = json.loads(text)
|
||||
if message.get("type") == "ack" and message.get("requestId") == "register":
|
||||
|
||||
Reference in New Issue
Block a user