Files
serverinfotest/host/send_sysinfo.py
DESKTOP-PUHFO60\dsx2016 f83df3fd39 Ship Windows plug-and-play host exe with USB wait and autostart.
Users no longer need Python: run.bat or install_autostart.ps1; agent reconnects when CYD is plugged in.
2026-10-27 20:23:57 +08:00

513 lines
15 KiB
Python

#!/usr/bin/env python3
"""
Cross-platform host agent: push CPU / GPU / RAM / disk to CYD over USB serial.
Works on Windows 10/11, Linux, and macOS (CPU/RAM/disk). GPU utilization is
best-effort via optional vendor backends (NVIDIA NVML first, then CLI fallbacks).
Protocol (one line, ASCII-safe):
SI|host=...|cpu=...|cpu%=..|gpu=...|gpu%=..|ram=used/total|disk=used/total
Deps (required): pip install -r requirements.txt
psutil, pyserial
Optional GPU: pip install nvidia-ml-py
"""
from __future__ import annotations
import argparse
import glob
import os
import platform
import re
import socket
import subprocess
import sys
import time
from typing import List, Optional, Tuple
try:
import psutil
except ImportError:
print("Missing psutil. Run: pip install -r requirements.txt", file=sys.stderr)
raise SystemExit(1)
try:
import serial
from serial.tools import list_ports
except ImportError:
print("Missing pyserial. Run: pip install -r requirements.txt", file=sys.stderr)
raise SystemExit(1)
def sanitize(s: str, max_len: int = 40) -> str:
if not s:
return ""
t = re.sub(r"[|=\r\n]+", " ", str(s)).strip()
t = re.sub(r"\s+", " ", t)
return t[:max_len].strip()
def cpu_name() -> str:
# Linux
if sys.platform.startswith("linux"):
try:
with open("/proc/cpuinfo", "r", encoding="utf-8", errors="ignore") as f:
for line in f:
if line.lower().startswith("model name") or line.lower().startswith(
"hardware"
):
name = line.split(":", 1)[1].strip()
name = re.sub(r"\(R\)|\(TM\)|CPU @.*|with Radeon.*", "", name)
return sanitize(name, 44) or "CPU"
except OSError:
pass
# macOS
if sys.platform == "darwin":
try:
out = subprocess.check_output(
["sysctl", "-n", "machdep.cpu.brand_string"],
stderr=subprocess.DEVNULL,
text=True,
timeout=2,
).strip()
if out:
return sanitize(out, 44)
except (OSError, subprocess.SubprocessError):
pass
# Windows — registry (no admin)
if sys.platform == "win32":
try:
import winreg
key = winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"HARDWARE\DESCRIPTION\System\CentralProcessor\0",
)
name, _ = winreg.QueryValueEx(key, "ProcessorNameString")
winreg.CloseKey(key)
name = re.sub(r"\(R\)|\(TM\)|CPU @.*|with Radeon.*", "", str(name))
return sanitize(name, 44) or "CPU"
except OSError:
pass
# Fallback
return sanitize(platform.processor() or "CPU", 44)
def cpu_pct() -> float:
# Non-blocking after first call; warm up once at start.
return round(float(psutil.cpu_percent(interval=None)), 1)
def ram_gb() -> Tuple[float, float]:
vm = psutil.virtual_memory()
total = round(vm.total / (1024**3), 1)
used = round(vm.used / (1024**3), 1)
return used, total
def disk_root() -> str:
if sys.platform == "win32":
return os.environ.get("SystemDrive", "C:") + "\\"
return "/"
def disk_gb() -> Tuple[float, float]:
root = disk_root()
try:
u = psutil.disk_usage(root)
except OSError:
# Last resort: first mount with size
for p in psutil.disk_partitions(all=False):
try:
u = psutil.disk_usage(p.mountpoint)
if u.total > 0:
break
except OSError:
continue
else:
return 0.0, 0.0
total = round(u.total / (1024**3), 1)
used = round(u.used / (1024**3), 1)
return used, total
_VIRTUAL_GPU_RE = re.compile(
r"Microsoft Basic|Remote Desktop|Oray|IddDriver|Virtual|Parsec|"
r"TeamViewer|VNC|Indirect|QXL|VMware|VirtualBox|Hyper-V|llvmpipe|SVGA",
re.I,
)
def _is_real_gpu_name(name: str) -> bool:
return bool(name) and not _VIRTUAL_GPU_RE.search(name)
# --- GPU: optional layered backends ---
def _gpu_nvidia_nvml() -> Optional[Tuple[str, float]]:
try:
import pynvml # provided by nvidia-ml-py
except ImportError:
return None
try:
pynvml.nvmlInit()
try:
count = pynvml.nvmlDeviceGetCount()
if count < 1:
return None
# Prefer highest utilization discrete card
best = None
for i in range(count):
h = pynvml.nvmlDeviceGetHandleByIndex(i)
raw = pynvml.nvmlDeviceGetName(h)
name = raw.decode("utf-8", "ignore") if isinstance(raw, bytes) else str(raw)
name = name.replace("NVIDIA GeForce ", "").replace("NVIDIA ", "")
util = pynvml.nvmlDeviceGetUtilizationRates(h).gpu
cand = (sanitize(name, 44), float(util))
if best is None or cand[1] > best[1]:
best = cand
return best
finally:
try:
pynvml.nvmlShutdown()
except Exception:
pass
except Exception:
return None
def _gpu_nvidia_smi() -> Optional[Tuple[str, float]]:
try:
out = subprocess.check_output(
[
"nvidia-smi",
"--query-gpu=name,utilization.gpu",
"--format=csv,noheader,nounits",
],
stderr=subprocess.DEVNULL,
text=True,
timeout=3,
)
except (OSError, subprocess.SubprocessError):
return None
best = None
for line in out.splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) < 2:
continue
name = parts[0].replace("NVIDIA GeForce ", "").replace("NVIDIA ", "")
try:
util = float(parts[1])
except ValueError:
continue
cand = (sanitize(name, 44), util)
if best is None or cand[1] > best[1]:
best = cand
return best
def _gpu_amd_cli() -> Optional[Tuple[str, float]]:
# Linux ROCm / Windows amd-smi when present
for cmd in (
["amd-smi", "metric", "--json"],
["rocm-smi", "--showuse", "--showproductname", "--csv"],
["rocm-smi", "-u", "--showproductname"],
):
try:
out = subprocess.check_output(
cmd, stderr=subprocess.DEVNULL, text=True, timeout=4
)
except (OSError, subprocess.SubprocessError):
continue
# Heuristic parse — keep resilient across CLI churn
name = "AMD GPU"
util = -1.0
m = re.search(r"Card series:\s*(.+)", out, re.I)
if m:
name = m.group(1).strip()
m = re.search(r"GPU\[0\].*?=\s*(\d+)", out)
if not m:
m = re.search(r"GPU use \(%\):\s*(\d+)", out, re.I)
if not m:
m = re.search(r"\"gpu_activity\"\s*:\s*\{\s*\"value\"\s*:\s*(\d+)", out)
if m:
util = float(m.group(1))
if util >= 0:
return sanitize(name, 44), util
return None
def _gpu_name_windows() -> str:
try:
import winreg
base = r"SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}"
root = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, base)
best = ""
i = 0
while True:
try:
sub = winreg.EnumKey(root, i)
except OSError:
break
i += 1
if not re.fullmatch(r"\d{4}", sub):
continue
try:
k = winreg.OpenKey(root, sub)
name, _ = winreg.QueryValueEx(k, "DriverDesc")
winreg.CloseKey(k)
except OSError:
continue
name = str(name)
if not _is_real_gpu_name(name):
continue
if re.search(r"NVIDIA|GeForce|RTX|GTX|Radeon|AMD|Intel Arc", name, re.I):
best = name
break
if not best:
best = name
winreg.CloseKey(root)
best = best.replace("NVIDIA GeForce ", "").replace("NVIDIA ", "").replace("AMD ", "")
return sanitize(best, 44)
except OSError:
return ""
def _gpu_name_linux() -> str:
# lspci is widely available
try:
out = subprocess.check_output(
["lspci"], stderr=subprocess.DEVNULL, text=True, timeout=3
)
except (OSError, subprocess.SubprocessError):
# DRM card names
for path in sorted(glob.glob("/sys/class/drm/card*/device/vendor")):
try:
card = path.rsplit("/device/", 1)[0]
# uevent MODALIAS / device product via lspci preferred
_ = card
except OSError:
pass
return ""
for line in out.splitlines():
if "VGA" in line or "3D" in line or "Display" in line:
name = line.split(":", 2)[-1].strip() if ":" in line else line
name = re.sub(r"^.*?(VGA|3D|Display) compatible controller:\s*", "", line)
if _is_real_gpu_name(name):
name = name.replace("NVIDIA Corporation ", "").replace(
"Advanced Micro Devices, Inc. [AMD/ATI] ", ""
)
return sanitize(name, 44)
return ""
def _gpu_name_macos() -> str:
try:
out = subprocess.check_output(
["system_profiler", "SPDisplaysDataType"],
stderr=subprocess.DEVNULL,
text=True,
timeout=5,
)
except (OSError, subprocess.SubprocessError):
return "Apple GPU"
m = re.search(r"Chipset Model:\s*(.+)", out)
if m:
return sanitize(m.group(1).strip(), 44)
return "Apple GPU"
def gpu_info() -> Tuple[str, float]:
"""Return (name, utilization%) — util -1 if unknown."""
for fn in (_gpu_nvidia_nvml, _gpu_nvidia_smi, _gpu_amd_cli):
got = fn()
if got:
return got
# Name-only fallbacks
name = ""
if sys.platform == "win32":
name = _gpu_name_windows()
elif sys.platform.startswith("linux"):
name = _gpu_name_linux()
elif sys.platform == "darwin":
name = _gpu_name_macos()
return (name or "GPU", -1.0)
def build_line() -> str:
host = sanitize(socket.gethostname(), 36)
cpu = cpu_name()
c_pct = cpu_pct()
gpu, g_pct = gpu_info()
ram_u, ram_t = ram_gb()
disk_u, disk_t = disk_gb()
gpu_field = f"gpu%={g_pct}" if g_pct >= 0 else "gpu%=-1"
return (
f"SI|host={host}|cpu={cpu}|cpu%={c_pct}|gpu={gpu}|"
f"{gpu_field}|ram={ram_u}/{ram_t}|disk={disk_u}/{disk_t}"
)
_USB_HINT = re.compile(
r"CH340|CP210|CP2102|CP210x|SILABS|Silicon Labs|USB[- ]?SERIAL|"
r"USB[- ]?UART|FT232|FTDI|wchusb|QinHeng|ESP32|Espressif|1A86|10C4",
re.I,
)
def list_port_infos():
infos = list(list_ports.comports())
if infos:
return infos
# Extra Linux globs when pyserial enumeration is empty
out = []
if sys.platform.startswith("linux"):
for path in sorted(
glob.glob("/dev/ttyUSB*")
+ glob.glob("/dev/ttyACM*")
+ glob.glob("/dev/tty.usb*")
):
out.append(type("P", (), {"device": path, "description": path, "hwid": ""})())
return out
def find_ports() -> List[str]:
return sorted({p.device for p in list_port_infos()})
def score_port(p) -> int:
blob = f"{getattr(p, 'description', '')} {getattr(p, 'hwid', '')} {p.device}"
score = 0
if _USB_HINT.search(blob):
score += 100
if sys.platform.startswith("linux") and (
"ttyUSB" in p.device or "ttyACM" in p.device
):
score += 50
if sys.platform == "darwin" and "usb" in p.device.lower():
score += 50
return score
def pick_port(explicit: str, wait: bool = True) -> str:
if explicit:
while True:
if any(p.device == explicit for p in list_port_infos()) or os.path.exists(
explicit
):
return explicit
if not wait:
raise SystemExit(f"Port not found: {explicit}")
print(f"Waiting for {explicit} ...", flush=True)
time.sleep(1.0)
while True:
infos = list_port_infos()
if infos:
ranked = sorted(infos, key=lambda p: (score_port(p), p.device))
best = ranked[-1]
if len(infos) > 1:
print(
"Available ports:",
", ".join(f"{p.device}({getattr(p, 'description', '')})" for p in infos),
flush=True,
)
print(f"Using port: {best.device}", flush=True)
return best.device
if not wait:
raise SystemExit("No serial ports found. Plug in the CYD USB cable.")
print("Waiting for CYD USB serial ...", flush=True)
time.sleep(1.0)
def open_serial(port: str, baud: int):
ser = serial.Serial()
ser.port = port
ser.baudrate = baud
ser.timeout = 0.2
ser.write_timeout = 2
# Avoid toggling DTR/RTS reset on open when possible
ser.dtr = False
ser.rts = False
ser.open()
return ser
def run_session(port: str, baud: int, interval_ms: int, once: bool) -> None:
ser = open_serial(port, baud)
time.sleep(0.35)
try:
while True:
line = build_line()
ser.write((line + "\n").encode("utf-8", errors="ignore"))
ser.flush()
print(line, flush=True)
if once:
return
time.sleep(max(0.2, interval_ms / 1000.0))
finally:
try:
ser.close()
except Exception:
pass
def main() -> int:
ap = argparse.ArgumentParser(
description="Push host sysinfo to CYD over USB serial (no Python needed if using .exe)"
)
ap.add_argument("--port", "-p", default="", help="Serial port (COMx / /dev/ttyUSB0)")
ap.add_argument("--baud", "-b", type=int, default=460800)
ap.add_argument("--interval-ms", "-i", type=int, default=1000)
ap.add_argument("--once", action="store_true", help="Send one line and exit")
ap.add_argument(
"--no-wait",
action="store_true",
help="Exit immediately if port missing (default: wait / reconnect)",
)
args = ap.parse_args()
# Warm CPU percent baseline
psutil.cpu_percent(interval=None)
print(
f"CYD host agent ({platform.system()} {platform.release()}) "
f"baud={args.baud} interval={args.interval_ms}ms Ctrl+C to stop",
flush=True,
)
try:
while True:
port = pick_port(args.port, wait=not args.no_wait)
print(
f"Sending host info -> {port}",
flush=True,
)
try:
run_session(port, args.baud, args.interval_ms, args.once)
if args.once:
return 0
except serial.SerialException as e:
print(f"Serial lost ({e}); will retry ...", flush=True)
if args.once or args.no_wait:
return 2
time.sleep(1.0)
except OSError as e:
print(f"Port error ({e}); will retry ...", flush=True)
if args.once or args.no_wait:
return 2
time.sleep(1.0)
except KeyboardInterrupt:
print("\nStopped.")
return 0
if __name__ == "__main__":
raise SystemExit(main())