Files
pikasTech-HWLAB/tools/hwlab-node.py
T
2026-07-16 16:08:05 +02:00

1551 lines
72 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# SPEC: PJ2026-010104 AI网关; PJ2026-010405 云端控制台。
# Implementation reference: draft-2026-07-13-p0-cloud-console.
"""面向 Windows HWPOD 的单文件 HWLAB 图形节点。
用户入口不接受命令行参数,也不依赖 HWLAB_* 环境变量。
持久配置统一存放在 ~/.hwlab/,并由设置界面或受控部署管理。
"""
from __future__ import annotations
import base64
import ctypes
import ctypes.wintypes as wt
import hashlib
import json
import logging
from logging.handlers import RotatingFileHandler
import os
from pathlib import Path
import queue
import random
import re
import shutil
import socket
import ssl
import struct
import subprocess
import sys
import threading
import time
import traceback
import urllib.parse
import urllib.request
WIN_WORD = getattr(wt, "WORD", ctypes.c_ushort)
WIN_HBITMAP = getattr(wt, "HBITMAP", wt.HANDLE)
WIN_HBRUSH = getattr(wt, "HBRUSH", wt.HANDLE)
WIN_HICON = getattr(wt, "HICON", wt.HANDLE)
WIN_HINSTANCE = getattr(wt, "HINSTANCE", wt.HANDLE)
WIN_WPARAM = ctypes.c_size_t
WIN_LPARAM = ctypes.c_ssize_t
WIN_LRESULT = ctypes.c_ssize_t
try:
import tkinter as tk
from tkinter import messagebox, ttk
except Exception: # pragma: no cover - import failure is reported in main.
tk = None
ttk = None
messagebox = None
APP_VERSION = "0.1.2"
NODE_VERSION = f"{APP_VERSION}-python-gui"
CONTRACT_VERSION = "hwpod-node-ops-v1"
APP_NAME = "HWLAB Node"
CONFIG_DIR = Path.home() / ".hwlab"
LOG_DIR = CONFIG_DIR / "logs"
UPDATE_DIR = CONFIG_DIR / "update"
CONFIG_PATH = CONFIG_DIR / "config.json"
STATE_PATH = CONFIG_DIR / "state.json"
LOG_PATH = LOG_DIR / "hwlab-node.log"
DEFAULT_CONFIG = {
"schemaVersion": 1,
"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,
"checkIntervalSeconds": 300,
"channel": "stable",
"metadataUrl": "https://hwlab.pikapython.com/v1/hwlab-node/update",
"downloadHostAllowlist": ["hwlab.pikapython.com"],
},
"logging": {
"maxUiLines": 1000,
"maxFileBytes": 5 * 1024 * 1024,
"redactSecrets": True,
},
}
SECRET_PATTERNS = [
re.compile(r"(authorization\s*:\s*bearer\s+)[^\s]+", re.I),
re.compile(r"(api[_-]?key\s*[=:]\s*)[^\s,;]+", re.I),
re.compile(r"(token\s*[=:]\s*)[^\s,;]+", re.I),
re.compile(r"(password\s*[=:]\s*)[^\s,;]+", re.I),
re.compile(r"hwl_live_[A-Za-z0-9_\-]+"),
]
def ensure_dirs() -> None:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
LOG_DIR.mkdir(parents=True, exist_ok=True)
UPDATE_DIR.mkdir(parents=True, exist_ok=True)
def deep_merge(default: dict, current: dict) -> dict:
result = dict(default)
for key, value in current.items():
if isinstance(value, dict) and isinstance(result.get(key), dict):
result[key] = deep_merge(result[key], value)
else:
result[key] = value
return result
def atomic_write_json(path: Path, value: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.replace(tmp, path)
def redact(text: object) -> str:
value = str(text)
for pattern in SECRET_PATTERNS:
value = pattern.sub(lambda match: (match.group(1) if match.groups() else "") + "<redacted>", value)
return value
def utc_now() -> str:
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def safe_int(value: object, default: int) -> int:
try:
number = int(value)
return number if number > 0 else default
except Exception:
return default
class ConfigStore:
def __init__(self, path: Path = CONFIG_PATH):
self.path = path
self.config = self.load()
def load(self) -> dict:
ensure_dirs()
if not self.path.exists():
atomic_write_json(self.path, DEFAULT_CONFIG)
return deep_merge(DEFAULT_CONFIG, {})
try:
loaded = json.loads(self.path.read_text(encoding="utf-8"))
if not isinstance(loaded, dict):
loaded = {}
except Exception:
backup = self.path.with_suffix(".invalid.json")
try:
shutil.copy2(self.path, backup)
except Exception:
pass
loaded = {}
merged = deep_merge(DEFAULT_CONFIG, loaded)
if merged != loaded:
atomic_write_json(self.path, merged)
return merged
def save(self, config: dict) -> None:
self.config = deep_merge(DEFAULT_CONFIG, config)
atomic_write_json(self.path, self.config)
def setup_logger() -> logging.Logger:
ensure_dirs()
logger = logging.getLogger("hwlab-node")
logger.setLevel(logging.INFO)
logger.handlers.clear()
handler = RotatingFileHandler(LOG_PATH, maxBytes=DEFAULT_CONFIG["logging"]["maxFileBytes"], backupCount=3, encoding="utf-8")
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
logger.addHandler(handler)
return logger
class DiagnosticBuffer:
def __init__(self, limit: int = 200):
self.limit = limit
self.lock = threading.Lock()
self.events: list[dict] = []
self.sequence = 0
def add(self, source: str, message: str, level: str = "ERROR", details: dict | None = None) -> dict:
with self.lock:
self.sequence += 1
event = {
"seq": self.sequence,
"level": str(level or "ERROR"),
"source": str(source or "unknown")[:80],
"message": redact(message)[:2000],
"details": details or {},
"observedAt": utc_now(),
}
self.events.append(event)
if len(self.events) > self.limit:
self.events = self.events[-self.limit:]
return dict(event)
def recent(self, limit: int = 50) -> list[dict]:
with self.lock:
return [dict(item) for item in self.events[-max(1, min(limit, self.limit)):]]
def since(self, sequence: int, limit: int = 20) -> list[dict]:
with self.lock:
selected = [dict(item) for item in self.events if int(item.get("seq") or 0) > sequence]
return selected[:max(1, limit)]
def summary(self) -> dict:
with self.lock:
last = dict(self.events[-1]) if self.events else None
return {"count": len(self.events), "lastSeq": self.sequence, "last": last}
DIAGNOSTICS = DiagnosticBuffer()
_EXCEPTION_HOOKS_INSTALLED = False
_EXCEPTION_HOOKS_LOCK = threading.Lock()
_DIAGNOSTIC_LOGGER: logging.Logger | None = None
_DIAGNOSTIC_BUS: "UiEventBus | None" = None
_ORIGINAL_STDERR = sys.stderr
def report_diagnostic(source: str, message: object, *, level: str = "ERROR", details: dict | None = None, logger: logging.Logger | None = None, bus: "UiEventBus | None" = None) -> dict:
event = DIAGNOSTICS.add(source, str(message), level=level, details=details)
target_logger = logger or _DIAGNOSTIC_LOGGER
if target_logger is not None:
log_level = logging.ERROR if str(level).upper() in ("ERROR", "CRITICAL") else logging.WARNING
target_logger.log(log_level, "diagnostic[%s] %s", event["source"], event["message"])
target_bus = bus or _DIAGNOSTIC_BUS
if target_bus is not None:
target_bus.emit("log", f"诊断[{event['source']}]: {event['message']}")
return event
class DiagnosticStderr:
def __init__(self, original, logger: logging.Logger):
self.original = original
self.logger = logger
self._buffer = ""
def write(self, text: str) -> int:
if self.original is not None:
self.original.write(text)
self._buffer += str(text)
while "\n" in self._buffer:
line, self._buffer = self._buffer.split("\n", 1)
if line.strip():
report_diagnostic("stderr", line.rstrip(), logger=self.logger)
return len(text)
def flush(self) -> None:
if self.original is not None:
self.original.flush()
def isatty(self) -> bool:
return bool(getattr(self.original, "isatty", lambda: False)())
def install_exception_hooks(logger: logging.Logger, bus: "UiEventBus | None" = None) -> None:
global _EXCEPTION_HOOKS_INSTALLED, _DIAGNOSTIC_LOGGER, _DIAGNOSTIC_BUS
with _EXCEPTION_HOOKS_LOCK:
_DIAGNOSTIC_LOGGER = logger
_DIAGNOSTIC_BUS = bus
if _EXCEPTION_HOOKS_INSTALLED:
return
def excepthook(exc_type, exc, tb):
report_diagnostic("sys.excepthook", exc, logger=logger, bus=bus, details={"trace": "".join(traceback.format_exception(exc_type, exc, tb))})
def threading_hook(args):
report_diagnostic("threading.excepthook", args.exc_value, logger=logger, bus=bus, details={"thread": getattr(args.thread, "name", None), "trace": "".join(traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback))})
def unraisable_hook(args):
obj = getattr(args, "object", None)
report_diagnostic("sys.unraisablehook", args.exc_value, logger=logger, bus=bus, details={"object": repr(obj)[:300], "errMsg": getattr(args, "err_msg", None), "trace": "".join(traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback))})
sys.excepthook = excepthook
threading.excepthook = threading_hook
sys.unraisablehook = unraisable_hook
sys.stderr = DiagnosticStderr(_ORIGINAL_STDERR, logger)
_EXCEPTION_HOOKS_INSTALLED = True
class UiEventBus:
def __init__(self):
self.queue: queue.Queue[tuple[str, object]] = queue.Queue()
def emit(self, kind: str, payload: object = None) -> None:
self.queue.put((kind, payload))
class NodeOpsExecutor:
def __init__(self, config: dict, logger: logging.Logger):
self.config = config
self.logger = logger
@property
def node_id(self) -> str:
return str(self.config.get("nodeId") or socket.gethostname())
def execute(self, plan: dict) -> dict:
if not isinstance(plan, dict):
raise ValueError("plan must be an object")
if plan.get("contractVersion") != CONTRACT_VERSION:
raise ValueError(f"contractVersion must be {CONTRACT_VERSION}")
ops = plan.get("ops")
if not isinstance(ops, list) or not ops:
raise ValueError("ops must be a non-empty array")
requested_node = str(plan.get("nodeId") or self.node_id)
if requested_node and requested_node != self.node_id:
return self._node_mismatch(plan, requested_node)
results = [self._execute_op(op if isinstance(op, dict) else {}) for op in ops]
failed = any(item.get("ok") is False for item in results)
return {
"ok": not failed,
"status": "failed" if failed else "completed",
"contractVersion": CONTRACT_VERSION,
"serviceId": "hwlab-node",
"nodeVersion": NODE_VERSION,
"nodeRole": "single-host-hwpod-node",
"acceptedInput": "hwpod-node-ops",
"specAuthority": "none",
"planId": plan.get("planId"),
"hwpodId": plan.get("hwpodId"),
"nodeId": requested_node,
"localNodeId": self.node_id,
"results": results,
"observedAt": utc_now(),
}
def capabilities(self) -> list[str]:
return [
"hwpod-node-ops",
"node.health",
"node.version",
"node.inventory",
"node.diagnostics",
"workspace.ls",
"workspace.cat",
"workspace.rg",
"workspace.write",
"workspace.replace",
"workspace.insert-after",
"cmd.run",
"debug.build",
"debug.download",
"debug.reset",
"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",
"layer": "hwpod-node",
"retryable": True,
"summary": f"hwlab-node {self.node_id} cannot execute plan for {requested_node}",
"details": {"localNodeId": self.node_id, "requestedNodeId": requested_node, "platform": sys.platform, "hostname": socket.gethostname()},
}
results = []
for index, op in enumerate(plan.get("ops") if isinstance(plan.get("ops"), list) else []):
results.append({"opId": str(op.get("opId") or f"op_{index + 1}"), "op": str(op.get("op") or "unknown"), "ok": False, "status": "blocked", "blocker": blocker})
return {"ok": False, "status": "blocked", "contractVersion": CONTRACT_VERSION, "serviceId": "hwlab-node", "nodeVersion": NODE_VERSION, "nodeId": requested_node, "localNodeId": self.node_id, "results": results, "blocker": blocker, "observedAt": utc_now()}
def _execute_op(self, op: dict) -> dict:
op_id = str(op.get("opId") or "op_unknown")
name = str(op.get("op") or "")
args = op.get("args") if isinstance(op.get("args"), dict) else {}
try:
if name == "node.health":
return self._ok(op_id, name, {"platform": sys.platform, "hostname": socket.gethostname(), "cwd": os.getcwd(), "diagnostics": DIAGNOSTICS.summary()})
if name == "node.version":
return self._ok(op_id, name, {"version": NODE_VERSION})
if name == "node.inventory":
return self._ok(op_id, name, self._node_inventory(args))
if name == "node.diagnostics":
return self._ok(op_id, name, self._node_diagnostics(args))
if name == "workspace.ls":
return self._ok(op_id, name, self._workspace_ls(args))
if name == "workspace.cat":
return self._ok(op_id, name, self._workspace_cat(args))
if name == "workspace.rg":
return self._ok(op_id, name, self._workspace_rg(args))
if name == "workspace.write":
return self._ok(op_id, name, self._workspace_write(args))
if name == "workspace.replace":
return self._ok(op_id, name, self._workspace_replace(args))
if name == "workspace.insert-after":
return self._ok(op_id, name, self._workspace_insert_after(args))
if name == "cmd.run":
return self._command_result(op_id, name, self._cmd_run(args, default_timeout_ms=30000))
if name in ("debug.build", "debug.download", "debug.reset"):
return self._command_result(op_id, name, self._debug_command(name, args))
if name == "io.uart.read":
output = self._uart_read(args)
if output.get("ok"):
return self._ok(op_id, name, output)
return self._blocked(op_id, name, output.get("blockerCode", "hwpod_uart_read_failed"), output.get("summary", "io.uart.read failed"), output.get("details", output))
return self._blocked(op_id, name, "unsupported_hwpod_node_op", f"unsupported hwpod-node op: {name}")
except Exception as exc:
return self._blocked(op_id, name, getattr(exc, "errno", None) or exc.__class__.__name__, str(exc), {"trace": traceback.format_exc(limit=4)})
def _ok(self, op_id: str, name: str, output: dict) -> dict:
return {"opId": op_id, "op": name, "ok": True, "status": "completed", "output": output}
def _failed(self, op_id: str, name: str, output: dict, code: str, summary: str) -> dict:
return {"opId": op_id, "op": name, "ok": False, "status": "failed", "output": output, "blocker": {"code": code, "layer": "hwpod-node", "retryable": True, "summary": summary}}
def _blocked(self, op_id: str, name: str, code: str, summary: str, details: dict | None = None) -> dict:
blocker = {"code": str(code), "layer": "hwpod-node", "retryable": True, "summary": summary}
if details is not None:
blocker["details"] = details
return {"opId": op_id, "op": name, "ok": False, "status": "blocked", "blocker": blocker}
def _command_result(self, op_id: str, name: str, output: dict) -> dict:
if output.get("blockerCode"):
return self._blocked(op_id, name, output["blockerCode"], output.get("stderr") or "command failed before process start", {"output": output})
if output.get("ok"):
return self._ok(op_id, name, output)
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 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 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(),
"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)
max_log_lines = safe_int(args.get("maxLogLines"), 80)
output = {"events": DIAGNOSTICS.recent(max_events), "summary": DIAGNOSTICS.summary(), "logPath": str(LOG_PATH)}
if args.get("includeLogTail") is not False:
try:
output["logTail"] = LOG_PATH.read_text(encoding="utf-8", errors="replace").splitlines()[-max_log_lines:]
except Exception as exc:
output["logTailError"] = str(exc)
return output
def _workspace_ls(self, args: dict) -> dict:
target = self._resolve_workspace_path(args, args.get("path") or ".")
entries = []
for child in target.iterdir():
entries.append({"name": child.name, "type": "dir" if child.is_dir() else "file" if child.is_file() else "other"})
return {"path": str(target), "entries": sorted(entries, key=lambda item: item["name"].lower())}
def _workspace_cat(self, args: dict) -> dict:
target = self._resolve_workspace_path(args, args.get("path"))
max_bytes = safe_int(args.get("maxBytes"), 65536)
data = target.read_bytes()
truncated = len(data) > max_bytes
return {"path": str(target), "content": data[:max_bytes].decode("utf-8", errors="replace"), "truncated": truncated}
def _workspace_rg(self, args: dict) -> dict:
pattern = str(args.get("pattern") or "")
if not pattern:
raise ValueError("pattern is required")
flags = re.I if args.get("ignoreCase") is True else 0
matcher = re.compile(pattern, flags)
root = self._workspace_root(args)
target = self._resolve_workspace_path(args, args.get("path") or ".")
max_files = safe_int(args.get("maxFiles"), 5000)
max_matches = safe_int(args.get("maxMatches"), 200)
max_bytes = safe_int(args.get("maxBytesPerFile"), 1024 * 1024)
matches: list[dict] = []
state = {"scannedFiles": 0, "skippedFiles": 0, "skippedDirectories": 0, "truncated": False}
files = [target] if target.is_file() else (path for path in target.rglob("*") if path.is_file())
skipped_dirs = {".git", "node_modules", ".worktree", "dist", "build", "__pycache__"}
for file_path in files:
parts = set(file_path.parts)
if parts & skipped_dirs:
state["skippedDirectories"] += 1
continue
if state["scannedFiles"] >= max_files or len(matches) >= max_matches:
state["truncated"] = True
break
try:
if file_path.stat().st_size > max_bytes:
state["skippedFiles"] += 1
continue
data = file_path.read_bytes()
if b"\0" in data:
state["skippedFiles"] += 1
continue
lines = data.decode("utf-8", errors="replace").splitlines()
except Exception:
state["skippedFiles"] += 1
continue
state["scannedFiles"] += 1
display = str(file_path.relative_to(root)) if file_path.is_relative_to(root) else str(file_path)
for number, line in enumerate(lines, start=1):
if matcher.search(line):
matches.append({"path": display, "line": number, "text": line[:600]})
if len(matches) >= max_matches:
state["truncated"] = True
break
return {"root": str(root), "path": str(target), "pattern": pattern, "matches": matches, **state}
def _expected_sha_ok(self, target: Path, expected_sha: object) -> None:
if not expected_sha:
return
current = sha256_bytes(target.read_bytes()) if target.exists() else None
if current != str(expected_sha):
raise ValueError(f"expectedSha mismatch for {target}: current={current} expected={expected_sha}")
def _workspace_write(self, args: dict) -> dict:
target = self._resolve_workspace_path(args, args.get("path"))
content = str(args.get("content") or "")
if args.get("lineEnding") == "crlf":
content = content.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "\r\n")
if args.get("finalNewline") is True and not content.endswith(("\n", "\r\n")):
content += "\r\n" if args.get("lineEnding") == "crlf" else "\n"
before_sha = sha256_bytes(target.read_bytes()) if target.exists() else None
if args.get("dryRun") is True:
return {"path": str(target), "dryRun": True, "beforeSha": before_sha, "afterSha": sha256_bytes(content.encode("utf-8"))}
self._expected_sha_ok(target, args.get("expectedSha"))
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return {"path": str(target), "beforeSha": before_sha, "afterSha": sha256_bytes(target.read_bytes()), "bytes": target.stat().st_size}
def _workspace_replace(self, args: dict) -> dict:
target = self._resolve_workspace_path(args, args.get("path"))
find = str(args.get("find") or "")
replace_with = str(args.get("replace") or "")
if not find:
raise ValueError("find is required")
self._expected_sha_ok(target, args.get("expectedSha"))
text = target.read_text(encoding="utf-8")
count = text.count(find)
if count == 0:
raise ValueError("find text was not found")
limit = safe_int(args.get("count"), count)
new_text = text.replace(find, replace_with, limit)
if args.get("dryRun") is not True:
target.write_text(new_text, encoding="utf-8")
return {"path": str(target), "replacements": min(count, limit), "dryRun": args.get("dryRun") is True, "afterSha": sha256_bytes(new_text.encode("utf-8"))}
def _workspace_insert_after(self, args: dict) -> dict:
target = self._resolve_workspace_path(args, args.get("path"))
anchor = str(args.get("anchor") or "")
line = str(args.get("line") or "")
if not anchor:
raise ValueError("anchor is required")
self._expected_sha_ok(target, args.get("expectedSha"))
text = target.read_text(encoding="utf-8")
index = text.find(anchor)
if index < 0:
raise ValueError("anchor was not found")
insert_at = text.find("\n", index)
insert_at = len(text) if insert_at < 0 else insert_at + 1
suffix = "" if line.endswith("\n") else "\n"
new_text = text[:insert_at] + line + suffix + text[insert_at:]
if args.get("dryRun") is not True:
target.write_text(new_text, encoding="utf-8")
return {"path": str(target), "anchor": anchor, "dryRun": args.get("dryRun") is True, "afterSha": sha256_bytes(new_text.encode("utf-8"))}
def _cmd_run(self, args: dict, default_timeout_ms: int) -> dict:
command = args.get("command")
argv = args.get("argv") if isinstance(args.get("argv"), list) else []
if not command:
return {"ok": False, "blockerCode": "invalid_command", "stderr": "command is required", "exitCode": None}
return self._spawn_output([str(command), *[str(item) for item in argv]], self._workspace_root(args), safe_int(args.get("timeoutMs"), default_timeout_ms))
def _debug_command(self, op: str, args: dict) -> dict:
runs = args.get("commandRuns") if isinstance(args.get("commandRuns"), list) else None
if runs:
outputs = []
ok = True
exit_code = 0
for index, run in enumerate(runs):
if not isinstance(run, dict) or not run.get("command"):
return {"ok": False, "blockerCode": "invalid_command_run", "stderr": f"commandRuns[{index}].command is required", "exitCode": None}
argv = run.get("argv") if isinstance(run.get("argv"), list) else []
output = self._spawn_output([str(run["command"]), *[str(item) for item in argv]], self._workspace_root(args), safe_int(run.get("timeoutMs") or args.get("timeoutMs"), 120000))
outputs.append({"index": index, "command": [str(run["command"]), *[str(item) for item in argv]], **output})
ok = ok and bool(output.get("ok"))
exit_code = output.get("exitCode")
if not output.get("ok"):
break
return {"ok": ok, "cwd": str(self._workspace_root(args)), "op": op, "bindingSource": "hwpod-node-ops.commandRuns", "commands": outputs, "commandCount": len(outputs), "exitCode": exit_code, "stdout": "".join(item.get("stdout", "") for item in outputs), "stderr": "".join(item.get("stderr", "") for item in outputs)}
if args.get("command"):
output = self._cmd_run(args, default_timeout_ms=120000)
return {"cwd": str(self._workspace_root(args)), "op": op, "bindingSource": "hwpod-node-ops.command", **output}
return {"ok": False, "blockerCode": "hwpod_node_op_not_configured", "stderr": f"{op} requires command or commandRuns", "exitCode": None}
def _spawn_output(self, command: list[str], cwd: Path, timeout_ms: int) -> dict:
started = time.time()
try:
completed = subprocess.run(command, cwd=str(cwd), capture_output=True, text=True, timeout=max(1, timeout_ms / 1000), shell=False)
return {"ok": completed.returncode == 0, "command": command, "cwd": str(cwd), "exitCode": completed.returncode, "stdout": completed.stdout, "stderr": completed.stderr, "elapsedMs": int((time.time() - started) * 1000)}
except FileNotFoundError as exc:
return {"ok": False, "blockerCode": "hwpod_node_command_spawn_failed", "command": command, "cwd": str(cwd), "exitCode": None, "stdout": "", "stderr": str(exc), "elapsedMs": int((time.time() - started) * 1000)}
except subprocess.TimeoutExpired as exc:
return {"ok": False, "blockerCode": "hwpod_node_command_timeout", "command": command, "cwd": str(cwd), "exitCode": None, "stdout": exc.stdout or "", "stderr": exc.stderr or "timeout", "elapsedMs": int((time.time() - started) * 1000)}
def _serial_command_base(self, args: dict) -> list[str]:
value = args.get("serialMonitorCommand")
if isinstance(value, list) and value:
return [str(item) for item in value]
return ["bun", "scripts/serial-monitor-cli.ts"]
def _uart_read(self, args: dict) -> dict:
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)
port = str(args.get("port") or args.get("resolvedPort") or args.get("targetPort") or "")
baud = args.get("baudRate") or args.get("baudrate")
status = self._spawn_output([*command_base, "monitor", "status"], serial_dir, timeout_ms)
status_body = self._parse_json(status.get("stdout"))
data = status_body.get("data") if isinstance(status_body.get("data"), dict) else {}
active_port = str(data.get("port") or "")
active_baud = data.get("baudRate")
monitor_start = None
matches = status.get("ok") and status_body.get("success") is True and data.get("isMonitoring") is True and (not port or active_port.lower() == port.lower()) and (not baud or int(active_baud or 0) == int(baud))
if not matches:
start_args = ["monitor", "start", *(["-p", port] if port else []), *(["-b", str(baud)] if baud else [])]
start = self._spawn_output([*command_base, *start_args], serial_dir, timeout_ms)
start_body = self._parse_json(start.get("stdout"))
monitor_start = start_body or start
if not start.get("ok") or start_body.get("success") is not True:
ports = self._spawn_output([*command_base, "ports"], serial_dir, timeout_ms)
return {"ok": False, "blockerCode": "hwpod_uart_monitor_start_failed", "summary": f"serial-monitor could not start on {port or 'the requested UART'}{('/' + str(baud)) if baud else ''}", "details": {"requestedPort": port or None, "baudRate": baud, "serialMonitor": {"dir": str(serial_dir), "command": command_base, "monitorStatus": status_body or status, "start": start_body or start, "ports": self._parse_json(ports.get("stdout")) or ports}}}
limit = max(1, min(safe_int(args.get("limit"), max(1, safe_int(args.get("maxBytes"), 4096) // 80)), 200))
fetch_args = ["fetch", "-l", str(limit), "--session-only"]
if args.get("since"):
fetch_args.extend(["-s", str(args["since"])])
fetch = self._spawn_output([*command_base, *fetch_args], serial_dir, timeout_ms)
fetch_body = self._parse_json(fetch.get("stdout"))
if not fetch.get("ok") or fetch_body.get("success") is not True:
return {"ok": False, "blockerCode": "hwpod_uart_read_failed", "summary": "serial-monitor fetch failed for io.uart.read", "details": {"serialMonitor": {"dir": str(serial_dir), "command": command_base, "fetch": fetch_body or fetch}}}
rows = fetch_body.get("data") if isinstance(fetch_body.get("data"), list) else []
text = "\n".join(str(row.get("data")) for row in rows if isinstance(row, dict) and row.get("data"))
max_bytes = safe_int(args.get("maxBytes"), 16384)
return {"ok": True, "bindingSource": "serial-monitor-cli", "serialMonitorDir": str(serial_dir), "requestedPort": port or None, "resolvedPort": active_port or port or None, "baudRate": baud or active_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": text[:max_bytes], "truncated": len(text) > max_bytes or fetch_body.get("truncated") is True, "monitorStatus": status_body, "monitorStarted": not matches, "monitorStart": monitor_start, "sourceFile": fetch_body.get("sourceFile")}
def _parse_json(self, text: object) -> dict:
try:
value = json.loads(str(text or ""))
return value if isinstance(value, dict) else {}
except Exception:
return {}
class WebSocketNodeClient:
def __init__(self, config: dict, executor: NodeOpsExecutor, logger: logging.Logger, bus: UiEventBus):
self.config = config
self.executor = executor
self.logger = logger
self.bus = bus
self.stop_event = threading.Event()
self.thread: threading.Thread | None = None
self.sock: socket.socket | None = None
self.manual_stop = False
self.connected = False
self.diagnostic_cursor = 0
def start(self) -> None:
if self.thread and self.thread.is_alive():
return
self.manual_stop = False
self.stop_event.clear()
self.thread = threading.Thread(target=self._run, name="hwlab-node-ws", daemon=True)
self.thread.start()
def stop(self) -> None:
self.manual_stop = True
self.stop_event.set()
self._close_socket()
def _run(self) -> None:
while not self.stop_event.is_set():
try:
self.bus.emit("connection", "connecting")
self._connect_once()
except Exception as exc:
self.logger.warning("websocket connection failed: %s", redact(exc))
self.bus.emit("log", f"连接失败: {redact(exc)}")
finally:
self.connected = False
self.bus.emit("connection", "manual-disconnected" if self.manual_stop else "reconnecting")
self._close_socket()
if self.manual_stop or self.stop_event.is_set():
break
time.sleep(3)
self.bus.emit("connection", "manual-disconnected")
def _connect_once(self) -> None:
ws_url = self._ws_url()
parsed = urllib.parse.urlparse(ws_url)
port = parsed.port or (443 if parsed.scheme == "wss" else 80)
host = parsed.hostname or ""
raw = socket.create_connection((host, port), timeout=10)
raw.settimeout(1.0)
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(),
"maxInFlight": 1,
"runtime": {
"kind": "python-tkinter-gui",
"installed": True,
"desktopVisible": True,
"currentVersion": APP_VERSION,
"updateChannel": str(self.config.get("update", {}).get("channel") or "stable"),
},
"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():
if time.time() - last_heartbeat > 15:
self._send_json({"type": "heartbeat", "nodeId": self.executor.node_id, "at": utc_now()})
last_heartbeat = time.time()
if time.time() - last_diagnostic_flush > 5:
self._flush_diagnostics()
last_diagnostic_flush = time.time()
try:
opcode, payload = self._recv_frame()
except socket.timeout:
continue
if opcode == 0x8:
break
if opcode == 0x9:
self._send_frame(payload, opcode=0xA)
continue
if opcode != 0x1:
continue
self._handle_message(payload.decode("utf-8", errors="replace"))
def _ws_url(self) -> str:
base = str(self.config.get("serverUrl") or DEFAULT_CONFIG["serverUrl"]).rstrip("/")
parsed = urllib.parse.urlparse(base)
scheme = "wss" if parsed.scheme == "https" else "ws"
return urllib.parse.urlunparse((scheme, parsed.netloc, "/v1/hwpod-node/ws", "", "", ""))
def _handshake(self, parsed: urllib.parse.ParseResult, host: str, port: int) -> None:
key = base64.b64encode(os.urandom(16)).decode("ascii")
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"
f"{credential_header}\r\n"
)
assert self.sock is not None
self.sock.sendall(request.encode("ascii"))
response = b""
while b"\r\n\r\n" not in response:
chunk = self.sock.recv(4096)
if not chunk:
break
response += chunk
if len(response) > 16384:
break
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":
self.connected = message.get("ok") is True
self.bus.emit("connection", "connected" if self.connected else "error")
self.bus.emit("log", f"注册结果: ok={message.get('ok')} message={message.get('message')}")
return
if message.get("type") != "hwpod-node-ops":
return
request_id = str(message.get("requestId") or "req_unknown")
try:
result = self.executor.execute(message.get("plan") or {})
except Exception as exc:
result = {"ok": False, "status": "failed", "results": [], "blocker": {"code": "hwpod_node_ws_run_failed", "layer": "hwpod-node", "retryable": True, "summary": str(exc)}}
self._send_json({"type": "hwpod-node-ops-result", "nodeId": self.executor.node_id, "requestId": request_id, "result": result, "observedAt": utc_now()})
def _flush_diagnostics(self) -> None:
for event in DIAGNOSTICS.since(self.diagnostic_cursor, 20):
self._send_json({"type": "hwpod-node-diagnostic", "nodeId": self.executor.node_id, "diagnostic": event, "observedAt": utc_now()})
self.diagnostic_cursor = max(self.diagnostic_cursor, safe_int(event.get("seq"), self.diagnostic_cursor))
def _send_json(self, value: dict) -> None:
self._send_frame(json.dumps(value, ensure_ascii=False).encode("utf-8"), opcode=0x1)
def _send_frame(self, payload: bytes, opcode: int = 0x1) -> None:
assert self.sock is not None
first = 0x80 | opcode
length = len(payload)
mask = os.urandom(4)
if length < 126:
header = struct.pack("!BB", first, 0x80 | length)
elif length < 65536:
header = struct.pack("!BBH", first, 0x80 | 126, length)
else:
header = struct.pack("!BBQ", first, 0x80 | 127, length)
masked = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
self.sock.sendall(header + mask + masked)
def _recv_exact(self, length: int) -> bytes:
assert self.sock is not None
chunks = []
remaining = length
while remaining > 0:
chunk = self.sock.recv(remaining)
if not chunk:
raise RuntimeError("websocket closed")
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def _recv_frame(self) -> tuple[int, bytes]:
head = self._recv_exact(2)
first, second = head[0], head[1]
opcode = first & 0x0F
masked = bool(second & 0x80)
length = second & 0x7F
if length == 126:
length = struct.unpack("!H", self._recv_exact(2))[0]
elif length == 127:
length = struct.unpack("!Q", self._recv_exact(8))[0]
mask = self._recv_exact(4) if masked else b""
payload = self._recv_exact(length) if length else b""
if masked:
payload = bytes(byte ^ mask[index % 4] for index, byte in enumerate(payload))
return opcode, payload
def _close_socket(self) -> None:
sock, self.sock = self.sock, None
if sock:
try:
sock.close()
except Exception:
pass
class UpdateManager:
def __init__(self, config: dict, logger: logging.Logger):
self.config = config
self.logger = logger
def check(self) -> dict:
update_config = self.config.get("update") if isinstance(self.config.get("update"), dict) else {}
url = str(update_config.get("metadataUrl") or DEFAULT_CONFIG["update"]["metadataUrl"])
channel = str(update_config.get("channel") or "stable")
parsed = urllib.parse.urlparse(url)
query = dict(urllib.parse.parse_qsl(parsed.query))
query.update({"platform": "windows" if sys.platform == "win32" else sys.platform, "channel": channel, "current": APP_VERSION})
request_url = urllib.parse.urlunparse(parsed._replace(query=urllib.parse.urlencode(query)))
request = urllib.request.Request(request_url, headers={"user-agent": f"hwlab-node/{APP_VERSION}"})
try:
with urllib.request.urlopen(request, timeout=10) as response:
if response.status == 204:
return {"ok": True, "updateAvailable": False, "latestVersion": APP_VERSION, "checkedAt": utc_now()}
body = json.loads(response.read().decode("utf-8"))
if not isinstance(body, dict):
raise ValueError("metadata response is not an object")
body["checkedAt"] = utc_now()
return body
except Exception as exc:
return {"ok": False, "updateAvailable": False, "error": str(exc), "checkedAt": utc_now()}
def apply_update(self, metadata: dict) -> dict:
if not metadata.get("updateAvailable"):
return {"ok": False, "error": "no update available"}
download_url = str(metadata.get("downloadUrl") or "")
parsed = urllib.parse.urlparse(download_url)
allowlist = self.config.get("update", {}).get("downloadHostAllowlist", ["hwlab.pikapython.com"])
if parsed.hostname not in allowlist or not parsed.path.endswith(".py"):
return {"ok": False, "error": f"download host/path is not allowed: {parsed.hostname}{parsed.path}"}
tmp = UPDATE_DIR / "hwlab-node.py.tmp"
backup = UPDATE_DIR / "hwlab-node.py.bak"
current = Path(__file__).resolve()
try:
with urllib.request.urlopen(download_url, timeout=30) as response:
data = response.read()
expected = metadata.get("sha256")
actual = sha256_bytes(data)
if expected and actual.lower() != str(expected).lower():
return {"ok": False, "error": f"sha256 mismatch: {actual}"}
if b"HWLAB" not in data[:4096] or b"APP_VERSION" not in data[:4096]:
return {"ok": False, "error": "downloaded file does not look like hwlab-node.py"}
tmp.write_bytes(data)
shutil.copy2(current, backup)
os.replace(tmp, current)
atomic_write_json(STATE_PATH, {"updatedAt": utc_now(), "fromVersion": APP_VERSION, "toVersion": metadata.get("latestVersion"), "backup": str(backup)})
subprocess.Popen([sys.executable, str(current)], cwd=str(current.parent), close_fds=True)
return {"ok": True, "restarting": True}
except Exception as exc:
return {"ok": False, "error": str(exc)}
class TrayIcon:
def __init__(self, bus: UiEventBus, logger: logging.Logger):
self.bus = bus
self.logger = logger
self.available = False
self.connection_state = "manual-disconnected"
self.update_available = False
self._hwnd = None
self._notify_data = None
self._thread = None
self._ready = threading.Event()
self._wndproc_ref = None
self._icon = None
self._icon_cache: dict[tuple[str, bool], int] = {}
def start(self) -> None:
if sys.platform != "win32":
return
self._thread = threading.Thread(target=self._run, name="hwlab-node-tray", daemon=True)
self._thread.start()
self._ready.wait(2)
def update(self, connection_state: str | None = None, update_available: bool | None = None) -> None:
if connection_state is not None:
self.connection_state = connection_state
if update_available is not None:
self.update_available = update_available
if self.available:
try:
self._modify_icon()
except Exception as exc:
report_diagnostic("tray.update", exc, logger=self.logger, bus=self.bus, details={"trace": traceback.format_exc()})
def remove(self) -> None:
if not self.available or not self._notify_data:
return
try:
shell32 = ctypes.windll.shell32
shell32.Shell_NotifyIconW.argtypes = [wt.DWORD, ctypes.c_void_p]
shell32.Shell_NotifyIconW.restype = wt.BOOL
shell32.Shell_NotifyIconW(0x00000002, ctypes.byref(self._notify_data))
except Exception as exc:
report_diagnostic("tray.remove", exc, logger=self.logger, bus=self.bus, details={"trace": traceback.format_exc()})
finally:
self.available = False
self._destroy_icon_cache()
def _run(self) -> None:
try:
self._create_window()
self._add_icon()
self.available = True
self._ready.set()
msg = wt.MSG()
user32 = ctypes.windll.user32
while user32.GetMessageW(ctypes.byref(msg), None, 0, 0) != 0:
user32.TranslateMessage(ctypes.byref(msg))
user32.DispatchMessageW(ctypes.byref(msg))
except Exception as exc:
report_diagnostic("tray.init", exc, logger=self.logger, bus=self.bus, details={"trace": traceback.format_exc()})
self._ready.set()
def _create_window(self) -> None:
user32 = ctypes.windll.user32
self._configure_win32_api()
hinst = ctypes.windll.kernel32.GetModuleHandleW(None)
wndproc_type = ctypes.WINFUNCTYPE(WIN_LRESULT, wt.HWND, wt.UINT, WIN_WPARAM, WIN_LPARAM)
def wndproc(hwnd, msg, wparam, lparam):
try:
if msg == 0x0400 + 20:
if int(lparam) == 0x0203:
self.bus.emit("tray", "show")
elif int(lparam) == 0x0205:
self._show_menu()
return 0
if msg == 0x0010:
self.remove()
user32.PostQuitMessage(0)
return 0
return self._def_window_proc(user32, hwnd, msg, wparam, lparam)
except Exception as exc:
report_diagnostic("tray.wndproc", exc, logger=self.logger, bus=self.bus, details={"msg": int(msg), "wparam": int(wparam), "lparam": int(lparam), "trace": traceback.format_exc()})
try:
return self._def_window_proc(user32, hwnd, msg, wparam, lparam)
except Exception:
return 0
self._wndproc_ref = wndproc_type(wndproc)
class WNDCLASSW(ctypes.Structure):
_fields_ = [("style", wt.UINT), ("lpfnWndProc", wndproc_type), ("cbClsExtra", ctypes.c_int), ("cbWndExtra", ctypes.c_int), ("hInstance", WIN_HINSTANCE), ("hIcon", WIN_HICON), ("hCursor", wt.HANDLE), ("hbrBackground", WIN_HBRUSH), ("lpszMenuName", wt.LPCWSTR), ("lpszClassName", wt.LPCWSTR)]
class_name = "HWLABNodeTrayWindow"
wc = WNDCLASSW(0, self._wndproc_ref, 0, 0, hinst, None, None, None, None, class_name)
user32.RegisterClassW(ctypes.byref(wc))
self._hwnd = user32.CreateWindowExW(0, class_name, APP_NAME, 0, 0, 0, 0, 0, None, None, hinst, None)
if not self._hwnd:
raise ctypes.WinError()
def _configure_win32_api(self) -> None:
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
shell32 = ctypes.windll.shell32
kernel32.GetModuleHandleW.argtypes = [wt.LPCWSTR]
kernel32.GetModuleHandleW.restype = WIN_HINSTANCE
user32.DefWindowProcW.argtypes = [wt.HWND, wt.UINT, WIN_WPARAM, WIN_LPARAM]
user32.DefWindowProcW.restype = WIN_LRESULT
user32.PostQuitMessage.argtypes = [ctypes.c_int]
user32.GetMessageW.argtypes = [ctypes.POINTER(wt.MSG), wt.HWND, wt.UINT, wt.UINT]
user32.GetMessageW.restype = ctypes.c_int
user32.TranslateMessage.argtypes = [ctypes.POINTER(wt.MSG)]
user32.TranslateMessage.restype = wt.BOOL
user32.DispatchMessageW.argtypes = [ctypes.POINTER(wt.MSG)]
user32.DispatchMessageW.restype = WIN_LRESULT
user32.RegisterClassW.argtypes = [ctypes.c_void_p]
user32.RegisterClassW.restype = WIN_WORD
user32.CreateWindowExW.argtypes = [wt.DWORD, wt.LPCWSTR, wt.LPCWSTR, wt.DWORD, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, wt.HWND, wt.HANDLE, WIN_HINSTANCE, ctypes.c_void_p]
user32.CreateWindowExW.restype = wt.HWND
shell32.Shell_NotifyIconW.argtypes = [wt.DWORD, ctypes.c_void_p]
shell32.Shell_NotifyIconW.restype = wt.BOOL
def _def_window_proc(self, user32, hwnd, msg, wparam, lparam):
return user32.DefWindowProcW(hwnd, msg, WIN_WPARAM(int(wparam)).value, WIN_LPARAM(int(lparam)).value)
def _add_icon(self) -> None:
self._notify_data = self._make_notify_data()
if not ctypes.windll.shell32.Shell_NotifyIconW(0x00000000, ctypes.byref(self._notify_data)):
raise ctypes.WinError()
def _modify_icon(self) -> None:
if not self._notify_data:
return
self._notify_data.hIcon = self._make_icon()
self._icon = self._notify_data.hIcon
self._notify_data.szTip = self._tooltip()
if not ctypes.windll.shell32.Shell_NotifyIconW(0x00000001, ctypes.byref(self._notify_data)):
raise ctypes.WinError()
def _make_notify_data(self):
class GUID(ctypes.Structure):
_fields_ = [("Data1", wt.DWORD), ("Data2", WIN_WORD), ("Data3", WIN_WORD), ("Data4", ctypes.c_ubyte * 8)]
class NOTIFYICONDATAW(ctypes.Structure):
_fields_ = [("cbSize", wt.DWORD), ("hWnd", wt.HWND), ("uID", wt.UINT), ("uFlags", wt.UINT), ("uCallbackMessage", wt.UINT), ("hIcon", WIN_HICON), ("szTip", wt.WCHAR * 128), ("dwState", wt.DWORD), ("dwStateMask", wt.DWORD), ("szInfo", wt.WCHAR * 256), ("uTimeoutOrVersion", wt.UINT), ("szInfoTitle", wt.WCHAR * 64), ("dwInfoFlags", wt.DWORD), ("guidItem", GUID), ("hBalloonIcon", WIN_HICON)]
data = NOTIFYICONDATAW()
data.cbSize = ctypes.sizeof(NOTIFYICONDATAW)
data.hWnd = self._hwnd
data.uID = 1
data.uFlags = 0x00000001 | 0x00000002 | 0x00000004
data.uCallbackMessage = 0x0400 + 20
data.hIcon = self._make_icon()
self._icon = data.hIcon
data.szTip = self._tooltip()
return data
def _tooltip(self) -> str:
state_map = {"connected": "已连接", "connecting": "连接中", "reconnecting": "重连中", "error": "错误"}
state = state_map.get(self.connection_state, "未连接")
update = "有更新" if self.update_available else "已是最新"
return f"HWLAB Node - {state} - {update}"[:127]
def _make_icon(self):
key = (self.connection_state, bool(self.update_available))
cached = self._icon_cache.get(key)
if cached:
return cached
try:
icon = self._make_color_icon()
if icon:
self._icon_cache[key] = icon
return icon
raise RuntimeError("CreateIconIndirect returned empty icon handle")
except Exception as exc:
report_diagnostic("tray.icon", exc, level="WARN", logger=self.logger, bus=self.bus, details={"trace": traceback.format_exc()})
if self._icon:
return self._icon
user32 = ctypes.windll.user32
user32.LoadIconW.argtypes = [WIN_HINSTANCE, ctypes.c_void_p]
user32.LoadIconW.restype = WIN_HICON
return user32.LoadIconW(None, ctypes.c_void_p(32512))
def _destroy_icon_cache(self) -> None:
if not self._icon_cache:
return
user32 = ctypes.windll.user32
user32.DestroyIcon.argtypes = [WIN_HICON]
user32.DestroyIcon.restype = wt.BOOL
for icon in set(self._icon_cache.values()):
try:
user32.DestroyIcon(icon)
except Exception:
pass
self._icon_cache.clear()
self._icon = None
def _make_color_icon(self):
size = 16
colors = {
"connected": (40, 180, 80),
"connecting": (60, 130, 230),
"reconnecting": (230, 170, 35),
"error": (220, 55, 55),
}
rgb = colors.get(self.connection_state, (150, 150, 150))
pixels = bytearray(size * size * 4)
cx = cy = 7.5
for y in range(size):
for x in range(size):
i = (y * size + x) * 4
dist = ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5
if dist <= 7:
pixels[i:i + 4] = bytes((rgb[2], rgb[1], rgb[0], 255))
else:
pixels[i:i + 4] = b"\x00\x00\x00\x00"
if self.update_available:
for y in range(0, 7):
for x in range(9, 16):
if (x - 12) ** 2 + (y - 3) ** 2 <= 12:
i = (y * size + x) * 4
pixels[i:i + 4] = bytes((0, 220, 255, 255))
class BITMAPINFOHEADER(ctypes.Structure):
_fields_ = [("biSize", wt.DWORD), ("biWidth", ctypes.c_long), ("biHeight", ctypes.c_long), ("biPlanes", WIN_WORD), ("biBitCount", WIN_WORD), ("biCompression", wt.DWORD), ("biSizeImage", wt.DWORD), ("biXPelsPerMeter", ctypes.c_long), ("biYPelsPerMeter", ctypes.c_long), ("biClrUsed", wt.DWORD), ("biClrImportant", wt.DWORD)]
class BITMAPINFO(ctypes.Structure):
_fields_ = [("bmiHeader", BITMAPINFOHEADER), ("bmiColors", wt.DWORD * 3)]
class ICONINFO(ctypes.Structure):
_fields_ = [("fIcon", wt.BOOL), ("xHotspot", wt.DWORD), ("yHotspot", wt.DWORD), ("hbmMask", WIN_HBITMAP), ("hbmColor", WIN_HBITMAP)]
gdi32 = ctypes.windll.gdi32
user32 = ctypes.windll.user32
user32.GetDC.argtypes = [wt.HWND]
user32.GetDC.restype = wt.HDC
user32.ReleaseDC.argtypes = [wt.HWND, wt.HDC]
user32.ReleaseDC.restype = ctypes.c_int
user32.CreateIconIndirect.argtypes = [ctypes.POINTER(ICONINFO)]
user32.CreateIconIndirect.restype = WIN_HICON
gdi32.CreateDIBSection.argtypes = [wt.HDC, ctypes.c_void_p, wt.UINT, ctypes.POINTER(ctypes.c_void_p), wt.HANDLE, wt.DWORD]
gdi32.CreateDIBSection.restype = WIN_HBITMAP
gdi32.CreateBitmap.argtypes = [ctypes.c_int, ctypes.c_int, wt.UINT, wt.UINT, ctypes.c_void_p]
gdi32.CreateBitmap.restype = WIN_HBITMAP
gdi32.DeleteObject.argtypes = [wt.HANDLE]
gdi32.DeleteObject.restype = wt.BOOL
hdc = user32.GetDC(None)
if not hdc:
raise ctypes.WinError()
bits = ctypes.c_void_p()
bmi = BITMAPINFO()
bmi.bmiHeader.biSize = ctypes.sizeof(BITMAPINFOHEADER)
bmi.bmiHeader.biWidth = size
bmi.bmiHeader.biHeight = -size
bmi.bmiHeader.biPlanes = 1
bmi.bmiHeader.biBitCount = 32
bmi.bmiHeader.biCompression = 0
color = None
mask = None
try:
color = gdi32.CreateDIBSection(hdc, ctypes.byref(bmi), 0, ctypes.byref(bits), None, 0)
if not color or not bits.value:
raise ctypes.WinError()
ctypes.memmove(bits.value, bytes(pixels), len(pixels))
mask_bits = (ctypes.c_ubyte * ((size * size) // 8))()
mask = gdi32.CreateBitmap(size, size, 1, 1, mask_bits)
if not mask:
raise ctypes.WinError()
icon_info = ICONINFO(True, 0, 0, mask, color)
icon = user32.CreateIconIndirect(ctypes.byref(icon_info))
if not icon:
raise ctypes.WinError()
return icon
finally:
if color:
gdi32.DeleteObject(color)
if mask:
gdi32.DeleteObject(mask)
user32.ReleaseDC(None, hdc)
def _show_menu(self) -> None:
user32 = ctypes.windll.user32
menu = user32.CreatePopupMenu()
items = [(1001, "打开"), (1002, "断开" if self.connection_state == "connected" else "连接"), (1003, "检查更新"), (1004, "退出")]
for item_id, text in items:
user32.AppendMenuW(menu, 0, item_id, text)
point = wt.POINT()
user32.GetCursorPos(ctypes.byref(point))
user32.SetForegroundWindow(self._hwnd)
command = user32.TrackPopupMenu(menu, 0x0100, point.x, point.y, 0, self._hwnd, None)
ctypes.windll.user32.DestroyMenu(menu)
actions = {1001: "show", 1002: "toggle", 1003: "check_update", 1004: "exit"}
if command in actions:
self.bus.emit("tray", actions[command])
class SettingsDialog:
def __init__(self, parent, config: dict, on_save):
self.parent = parent
self.config = json.loads(json.dumps(config))
self.on_save = on_save
self.window = tk.Toplevel(parent)
self.window.title("HWLAB Node 设置")
self.window.resizable(False, False)
self.server_var = tk.StringVar(value=self.config.get("serverUrl"))
self.node_var = tk.StringVar(value=self.config.get("nodeId"))
self.auto_connect_var = tk.BooleanVar(value=bool(self.config.get("autoConnect")))
update = self.config.get("update", {})
self.update_enabled_var = tk.BooleanVar(value=bool(update.get("enabled", True)))
self.auto_update_var = tk.BooleanVar(value=bool(update.get("autoApplyWhenIdle", False)))
self.interval_var = tk.StringVar(value=str(update.get("checkIntervalSeconds", 300)))
self.channel_var = tk.StringVar(value=str(update.get("channel", "stable")))
self._build()
self.window.transient(parent)
self.window.grab_set()
def _build(self) -> None:
frame = ttk.Frame(self.window, padding=12)
frame.grid(row=0, column=0, sticky="nsew")
labels = [("服务器 URL", self.server_var), ("节点 ID", self.node_var), ("更新频道", self.channel_var), ("检查间隔秒", self.interval_var)]
for row, (label, var) in enumerate(labels):
ttk.Label(frame, text=label).grid(row=row, column=0, sticky="w", pady=4)
ttk.Entry(frame, textvariable=var, width=46).grid(row=row, column=1, sticky="ew", pady=4)
ttk.Checkbutton(frame, text="启动后自动连接", variable=self.auto_connect_var).grid(row=4, column=0, columnspan=2, sticky="w", pady=4)
ttk.Checkbutton(frame, text="启用更新检查", variable=self.update_enabled_var).grid(row=5, column=0, columnspan=2, sticky="w", pady=4)
ttk.Checkbutton(frame, text="自动更新(空闲时)", variable=self.auto_update_var).grid(row=6, column=0, columnspan=2, sticky="w", pady=4)
buttons = ttk.Frame(frame)
buttons.grid(row=7, column=0, columnspan=2, sticky="e", pady=(12, 0))
ttk.Button(buttons, text="保存", command=self._save).grid(row=0, column=0, padx=4)
ttk.Button(buttons, text="取消", command=self.window.destroy).grid(row=0, column=1, padx=4)
def _save(self) -> None:
update = self.config.setdefault("update", {})
self.config["serverUrl"] = self.server_var.get().strip() or DEFAULT_CONFIG["serverUrl"]
self.config["nodeId"] = self.node_var.get().strip() or DEFAULT_CONFIG["nodeId"]
self.config["autoConnect"] = bool(self.auto_connect_var.get())
update["enabled"] = bool(self.update_enabled_var.get())
update["autoApplyWhenIdle"] = bool(self.auto_update_var.get())
update["checkIntervalSeconds"] = safe_int(self.interval_var.get(), 300)
update["channel"] = self.channel_var.get().strip() or "stable"
self.on_save(self.config)
self.window.destroy()
class HwlabNodeApp:
def __init__(self):
if tk is None:
raise RuntimeError("tkinter is not available")
ensure_dirs()
self.store = ConfigStore()
self.config = self.store.config
self.logger = setup_logger()
self.bus = UiEventBus()
install_exception_hooks(self.logger, self.bus)
self.executor = NodeOpsExecutor(self.config, self.logger)
self.client: WebSocketNodeClient | None = None
self.update_manager = UpdateManager(self.config, self.logger)
self.update_metadata: dict | None = None
self.connection_state = "manual-disconnected"
self.update_available = False
self.root = tk.Tk()
self.root.title(APP_NAME)
self.root.geometry("720x460")
self.status_var = tk.StringVar(value="未连接")
self.server_var = tk.StringVar(value=self._display_server())
self.version_var = tk.StringVar(value=f"v{APP_VERSION}")
self._build_ui()
self.tray = TrayIcon(self.bus, self.logger)
self.tray.start()
self.root.protocol("WM_DELETE_WINDOW", self._hide_to_tray)
self.root.after(150, self._poll_events)
self.root.after(800, self._startup_actions)
def _build_ui(self) -> None:
outer = ttk.Frame(self.root, padding=12)
outer.pack(fill="both", expand=True)
top = ttk.Frame(outer)
top.pack(fill="x")
ttk.Label(top, text=APP_NAME, font=("Segoe UI", 16, "bold")).grid(row=0, column=0, sticky="w")
ttk.Label(top, textvariable=self.version_var).grid(row=0, column=1, sticky="w", padx=10)
ttk.Label(top, text="状态:").grid(row=1, column=0, sticky="w", pady=(8, 0))
ttk.Label(top, textvariable=self.status_var).grid(row=1, column=1, sticky="w", pady=(8, 0))
ttk.Label(top, text="服务器:").grid(row=2, column=0, sticky="w")
ttk.Label(top, textvariable=self.server_var).grid(row=2, column=1, sticky="w")
buttons = ttk.Frame(outer)
buttons.pack(fill="x", pady=10)
self.connect_button = ttk.Button(buttons, text="连接", command=self.connect)
self.connect_button.pack(side="left", padx=(0, 6))
self.disconnect_button = ttk.Button(buttons, text="断开", command=self.disconnect)
self.disconnect_button.pack(side="left", padx=6)
ttk.Button(buttons, text="设置", command=self.show_settings).pack(side="left", padx=6)
ttk.Button(buttons, text="检查更新", command=self.check_update).pack(side="left", padx=6)
self.update_button = ttk.Button(buttons, text="更新", command=self.apply_update)
self.update_button.pack(side="left", padx=6)
self.update_button.state(["disabled"])
self.log_text = tk.Text(outer, height=18, wrap="word", state="disabled")
self.log_text.pack(fill="both", expand=True)
self._append_log(f"{APP_NAME} v{APP_VERSION} 已启动,配置目录: {CONFIG_DIR}")
def _startup_actions(self) -> None:
if self.config.get("autoConnect") is True:
self.connect()
if self.config.get("update", {}).get("enabled", True):
self.root.after(1000, self.check_update)
self._schedule_update_timer()
def _display_server(self) -> str:
parsed = urllib.parse.urlparse(str(self.config.get("serverUrl") or ""))
return parsed.netloc or str(self.config.get("serverUrl") or "")
def connect(self) -> None:
if self.client and self.client.thread and self.client.thread.is_alive():
return
self.executor = NodeOpsExecutor(self.config, self.logger)
self.client = WebSocketNodeClient(self.config, self.executor, self.logger, self.bus)
self.client.start()
def disconnect(self) -> None:
if self.client:
self.client.stop()
self._set_connection("manual-disconnected")
def show_settings(self) -> None:
SettingsDialog(self.root, self.config, self._save_settings)
def _save_settings(self, config: dict) -> None:
self.store.save(config)
self.config = self.store.config
self.server_var.set(self._display_server())
self.update_manager = UpdateManager(self.config, self.logger)
self._append_log("设置已保存")
def check_update(self) -> None:
self._append_log("正在检查更新...")
threading.Thread(target=self._check_update_worker, daemon=True).start()
def _check_update_worker(self) -> None:
result = self.update_manager.check()
self.bus.emit("update_checked", result)
def apply_update(self) -> None:
if not self.update_metadata:
return
self._append_log("正在应用更新...")
threading.Thread(target=self._apply_update_worker, daemon=True).start()
def _apply_update_worker(self) -> None:
result = self.update_manager.apply_update(self.update_metadata or {})
self.bus.emit("update_applied", result)
def _schedule_update_timer(self) -> None:
interval = safe_int(self.config.get("update", {}).get("checkIntervalSeconds"), 300)
self.root.after(interval * 1000, self._scheduled_update_check)
def _scheduled_update_check(self) -> None:
if self.config.get("update", {}).get("enabled", True):
self.check_update()
self._schedule_update_timer()
def _poll_events(self) -> None:
while True:
try:
kind, payload = self.bus.queue.get_nowait()
except queue.Empty:
break
if kind == "log":
self._append_log(str(payload))
elif kind == "connection":
self._set_connection(str(payload))
elif kind == "update_checked":
self._handle_update_checked(payload if isinstance(payload, dict) else {})
elif kind == "update_applied":
self._handle_update_applied(payload if isinstance(payload, dict) else {})
elif kind == "tray":
self._handle_tray(str(payload))
self.root.after(150, self._poll_events)
def _set_connection(self, state: str) -> None:
self.connection_state = state
labels = {"manual-disconnected": "未连接", "connecting": "连接中", "connected": "已连接", "reconnecting": "重连中", "error": "错误"}
self.status_var.set(labels.get(state, state))
self.tray.update(connection_state=state, update_available=self.update_available)
def _handle_update_checked(self, result: dict) -> None:
if not result.get("ok", True) and result.get("error"):
self._append_log(f"更新检查失败: {redact(result.get('error'))}")
return
self.update_available = result.get("updateAvailable") is True
self.update_metadata = result if self.update_available else None
self.tray.update(update_available=self.update_available)
if self.update_available:
self.update_button.state(["!disabled"])
self._append_log(f"发现新版本: {result.get('latestVersion')}")
if self.config.get("update", {}).get("autoApplyWhenIdle") is True and self.connection_state in ("manual-disconnected", "error"):
self.apply_update()
else:
self.update_button.state(["disabled"])
self._append_log("当前已是最新版本")
def _handle_update_applied(self, result: dict) -> None:
if result.get("ok"):
self._append_log("更新已应用,正在重启")
self.root.after(300, self.quit)
else:
self._append_log(f"更新失败: {redact(result.get('error'))}")
def _handle_tray(self, action: str) -> None:
if action == "show":
self.root.deiconify()
self.root.lift()
elif action == "toggle":
if self.connection_state == "connected":
self.disconnect()
else:
self.connect()
elif action == "check_update":
self.check_update()
elif action == "exit":
self.quit()
def _append_log(self, text: str) -> None:
line = f"[{time.strftime('%H:%M:%S')}] {redact(text)}"
self.logger.info(redact(text))
self.log_text.configure(state="normal")
self.log_text.insert("end", line + "\n")
max_lines = safe_int(self.config.get("logging", {}).get("maxUiLines"), 1000)
current_lines = int(self.log_text.index("end-1c").split(".")[0])
if current_lines > max_lines:
self.log_text.delete("1.0", f"{current_lines - max_lines}.0")
self.log_text.see("end")
self.log_text.configure(state="disabled")
def _hide_to_tray(self) -> None:
if self.tray.available:
self.root.withdraw()
self._append_log("已最小化到托盘")
else:
self.root.iconify()
self._append_log("托盘不可用,已最小化到任务栏")
def quit(self) -> None:
if self.client:
self.client.stop()
self.tray.remove()
atomic_write_json(STATE_PATH, {"lastExitAt": utc_now(), "connectionState": self.connection_state, "updateAvailable": self.update_available})
self.root.destroy()
def run(self) -> None:
self.root.mainloop()
def main() -> int:
ensure_dirs()
logger = setup_logger()
install_exception_hooks(logger)
try:
app = HwlabNodeApp()
app.run()
return 0
except Exception as exc:
report_diagnostic("startup.fatal", exc, logger=logger, details={"trace": traceback.format_exc()})
logger.error("fatal startup error: %s\n%s", redact(exc), traceback.format_exc())
if messagebox is not None:
try:
messagebox.showerror(APP_NAME, f"启动失败: {exc}\n日志: {LOG_PATH}")
except Exception:
pass
return 1
if __name__ == "__main__":
sys.exit(main())