客户端心跳时打印显卡温度功耗和算力日志,并带回今日预估积分。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-11 00:55:08 +08:00
parent fe530a619d
commit 80d46dcd5d
4 changed files with 350 additions and 50 deletions
+3
View File
@@ -316,6 +316,7 @@ def heartbeat(body: HeartbeatIn, request: Request, db: Db) -> HeartbeatOut:
client_ip=client_ip(request), client_ip=client_ip(request),
hardware_fingerprint=body.hardware_fingerprint, hardware_fingerprint=body.hardware_fingerprint,
) )
days = node.recovery_days or 540
return HeartbeatOut( return HeartbeatOut(
sn=node.sn, sn=node.sn,
display_node_id=node.display_node_id, display_node_id=node.display_node_id,
@@ -326,6 +327,8 @@ def heartbeat(body: HeartbeatIn, request: Request, db: Db) -> HeartbeatOut:
gpu_generation=node.gpu_generation, gpu_generation=node.gpu_generation,
recovery_days=node.recovery_days, recovery_days=node.recovery_days,
recoverable_stake_amount=node.recoverable_stake_amount, recoverable_stake_amount=node.recoverable_stake_amount,
purchase_amount=node.purchase_amount,
estimated_daily_points=money(node.purchase_amount / Decimal(days)),
) )
+2
View File
@@ -125,6 +125,8 @@ class HeartbeatOut(APIModel):
gpu_generation: str = "10" gpu_generation: str = "10"
recovery_days: int = 540 recovery_days: int = 540
recoverable_stake_amount: MoneyText | None = None recoverable_stake_amount: MoneyText | None = None
purchase_amount: MoneyText | None = None
estimated_daily_points: MoneyText | None = None
class TaskOut(APIModel): class TaskOut(APIModel):
+284 -50
View File
@@ -78,6 +78,7 @@ import hashlib
import json import json
import os import os
import subprocess import subprocess
import sys
import time import time
import urllib.error import urllib.error
import urllib.request import urllib.request
@@ -86,17 +87,29 @@ import uuid
CONFIG = json.loads(__CONFIG__) CONFIG = json.loads(__CONFIG__)
def hardware(): SHARES = 0
gpu_model, vram_gb, gpu_uuid = _gpu() _REFERENCE_MH = (
return { ("4090", 130.0),
"sn": CONFIG["sn"], ("4080 SUPER", 95.0),
"token": CONFIG["token"], ("4080", 90.0),
"gpu_model": gpu_model, ("4070 TI SUPER", 75.0),
"vram_gb": vram_gb, ("4070 TI", 70.0),
"cpu_count": os.cpu_count(), ("4070 SUPER", 62.0),
"memory_gb": _memory_gb(), ("4070", 55.0),
"hardware_fingerprint": _fingerprint(gpu_uuid), ("3090 TI", 125.0),
} ("3090", 115.0),
("3080 TI", 105.0),
("3080", 95.0),
("3070 TI", 65.0),
("3070", 60.0),
("3060 TI", 48.5),
("3060", 40.0),
)
def current_mode():
mode = os.environ.get("HYPERNODE_MODE") or CONFIG.get("current_mode") or "pow_mining"
return mode if mode in {"pow_mining", "gpu_rental"} else "pow_mining"
def _fingerprint(gpu_uuid): def _fingerprint(gpu_uuid):
@@ -134,72 +147,136 @@ def _board_serial():
def _gpu(): def _gpu():
probed = _pynvml() probed = _pynvml()
if probed[0]: if probed and probed.get("name"):
return probed return probed
return _nvidia_smi() probed = _nvidia_smi()
return probed or {}
def _blank_gpu():
return {
"name": None,
"vram_gb": None,
"uuid": None,
"vram_used_mb": None,
"vram_total_mb": None,
"gpu_temp": None,
"fan_speed": None,
"power_usage": None,
"utilization": None,
}
def _pynvml(): def _pynvml():
try: try:
import pynvml import pynvml
except ImportError: except ImportError:
return None, None, None return None
try: try:
pynvml.nvmlInit() pynvml.nvmlInit()
best_name = None best = None
best_bytes = -1 best_bytes = -1
best_uuid = None
for index in range(pynvml.nvmlDeviceGetCount()): for index in range(pynvml.nvmlDeviceGetCount()):
handle = pynvml.nvmlDeviceGetHandleByIndex(index) handle = pynvml.nvmlDeviceGetHandleByIndex(index)
name = pynvml.nvmlDeviceGetName(handle) name = pynvml.nvmlDeviceGetName(handle)
if isinstance(name, bytes): if isinstance(name, bytes):
name = name.decode() name = name.decode()
total = int(pynvml.nvmlDeviceGetMemoryInfo(handle).total) memory = pynvml.nvmlDeviceGetMemoryInfo(handle)
if total > best_bytes: total = int(memory.total)
uid = pynvml.nvmlDeviceGetUUID(handle) if total <= best_bytes:
if isinstance(uid, bytes): continue
uid = uid.decode() uid = pynvml.nvmlDeviceGetUUID(handle)
best_name = str(name) if isinstance(uid, bytes):
best_bytes = total uid = uid.decode()
best_uuid = str(uid) best_bytes = total
best = _blank_gpu()
best.update({
"name": str(name),
"vram_gb": round(total / (1024 ** 3), 2),
"uuid": str(uid),
"vram_used_mb": int(round(int(memory.used) / (1024 ** 2))),
"vram_total_mb": int(round(total / (1024 ** 2))),
"gpu_temp": _nvml_call(pynvml.nvmlDeviceGetTemperature, handle, pynvml.NVML_TEMPERATURE_GPU),
"fan_speed": _nvml_call(pynvml.nvmlDeviceGetFanSpeed, handle),
"power_usage": _nvml_watts(pynvml, handle),
"utilization": _nvml_util(pynvml, handle),
})
pynvml.nvmlShutdown() pynvml.nvmlShutdown()
except Exception: except Exception:
return None, None, None return None
if not best_name or best_bytes < 0: return best
return None, None, None
return best_name, round(best_bytes / (1024 ** 3), 2), best_uuid
def _nvml_call(func, *args):
try:
value = func(*args)
return int(value)
except Exception:
return None
def _nvml_watts(pynvml, handle):
try:
return round(pynvml.nvmlDeviceGetPowerUsage(handle) / 1000, 1)
except Exception:
return None
def _nvml_util(pynvml, handle):
try:
return int(pynvml.nvmlDeviceGetUtilizationRates(handle).gpu)
except Exception:
return None
def _nvidia_smi(): def _nvidia_smi():
query = "name,memory.total,memory.used,uuid,temperature.gpu,fan.speed,power.draw,utilization.gpu"
try: try:
output = subprocess.check_output( output = subprocess.check_output(
["nvidia-smi", "--query-gpu=name,memory.total,uuid", "--format=csv,noheader,nounits"], ["nvidia-smi", "--query-gpu=" + query, "--format=csv,noheader,nounits"],
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=8, timeout=8,
text=True, text=True,
) )
except (OSError, subprocess.SubprocessError): except (OSError, subprocess.SubprocessError):
return None, None, None return None
best_name = None best = None
best_mib = -1.0 best_mib = -1.0
best_uuid = None
for line in output.splitlines(): for line in output.splitlines():
parts = [part.strip() for part in line.split(",", 2)] parts = [part.strip() for part in line.split(",")]
if len(parts) < 2: if len(parts) < 8:
continue continue
name, memory = parts[0], parts[1] total = _number(parts[-7])
gpu_uuid = parts[2] if len(parts) > 2 else None if total is None or total <= best_mib:
try:
mib = float(memory)
except ValueError:
continue continue
if mib > best_mib: best_mib = total
best_name = name or None best = _blank_gpu()
best_mib = mib best.update({
best_uuid = gpu_uuid or None "name": ",".join(parts[:-7]).strip() or None,
if best_name is None: "vram_gb": round(total / 1024, 2),
return None, None, None "vram_total_mb": int(round(total)),
return best_name, round(best_mib / 1024, 2), best_uuid "vram_used_mb": _int_number(parts[-6]),
"uuid": parts[-5] or None,
"gpu_temp": _int_number(parts[-4]),
"fan_speed": _int_number(parts[-3]),
"power_usage": _number(parts[-2]),
"utilization": _int_number(parts[-1]),
})
return best
def _number(value):
try:
return float(value)
except (TypeError, ValueError):
return None
def _int_number(value):
number = _number(value)
if number is None:
return None
return int(round(number))
def _memory_gb(): def _memory_gb():
@@ -233,28 +310,185 @@ def _memory_gb():
def beat(): def beat():
body = json.dumps(hardware()).encode() global SHARES
gpu = _gpu()
payload = hardware_from(gpu)
body = json.dumps(payload).encode()
request = urllib.request.Request( request = urllib.request.Request(
CONFIG["base"] + "/api/v1/node/heartbeat", CONFIG["base"] + "/api/v1/node/heartbeat",
data=body, data=body,
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
) )
with urllib.request.urlopen(request, timeout=20) as response: with urllib.request.urlopen(request, timeout=20) as response:
print(response.read().decode()) reply = json.loads(response.read().decode())
SHARES += 1
_save_shares(SHARES)
_report(gpu, reply, SHARES)
def hardware_from(gpu):
card = gpu or {}
return {
"sn": CONFIG["sn"],
"token": CONFIG["token"],
"gpu_model": card.get("name"),
"vram_gb": card.get("vram_gb"),
"cpu_count": os.cpu_count(),
"memory_gb": _memory_gb(),
"hardware_fingerprint": _fingerprint(card.get("uuid")),
}
def _report(gpu, reply, shares):
card = gpu or {}
name = card.get("name") or "GPU not detected"
vram = card.get("vram_gb")
vram_text = f"{vram:.2f} GB" if isinstance(vram, (int, float)) else "n/a"
_log("INFO", f"{name} · {vram_text}")
_log("HARDWARE", _hardware_line(card))
if current_mode() == "gpu_rental":
load = card.get("utilization")
load_text = f"{load}% Load" if load is not None else "n/a"
_log("TASK", f"[AI Task Executing] · {load_text}")
else:
rate = _hashrate(name)
rate_text = f"{rate:.1f} MH/s" if rate is not None else "n/a"
_log("HASHRATE", f"{rate_text} · Accepted {shares}")
weight = reply.get("hardware_weight")
weight_text = f"{float(weight):.1f}" if isinstance(weight, (int, float)) else "n/a"
node_id = reply.get("display_node_id") or "----"
status = reply.get("status") or "unknown"
_log("HEARTBEAT", f"{status} · Node #{node_id} · Weight {weight_text}")
_log("RECOVERY", _recovery_line(reply))
print(flush=True)
def _hardware_line(card):
used = card.get("vram_used_mb")
total = card.get("vram_total_mb")
vram = f"{used} / {total} MB" if used is not None and total is not None else "n/a"
return " · ".join([
_suffix(card.get("gpu_temp"), "°C"),
"Fan " + _suffix(card.get("fan_speed"), "%"),
_suffix(card.get("power_usage"), " W"),
"VRAM " + vram,
])
def _recovery_line(reply):
days = int(reply.get("recovery_days") or 0)
purchase = _decimal(reply.get("purchase_amount"))
remaining = _decimal(reply.get("recoverable_stake_amount"))
estimate = reply.get("estimated_daily_points") or "0"
if purchase is not None and remaining is not None and purchase > 0:
done = max(0.0, (purchase - remaining) / purchase * 100)
progress = f"{done:.1f}% returned"
else:
progress = "n/a"
title = "30-day fast recovery" if days == 30 else f"{days}-day recovery"
return f"{title} · {progress} · Est. Points {estimate}"
def _hashrate(name):
upper = (name or "").upper().replace("-", " ")
for token, rate in _REFERENCE_MH:
if token in upper:
return rate
return None
def _suffix(value, suffix):
if value is None:
return "n/a"
return f"{value}{suffix}"
def _decimal(value):
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _log(tag, message):
color = {
"INFO": "36",
"HARDWARE": "33",
"HASHRATE": "32",
"TASK": "32",
"HEARTBEAT": "96",
"RECOVERY": "33",
"WARN": "31",
}.get(tag, "37")
label = f"[{tag}]".ljust(12)
if sys.stdout.isatty():
label = f"\033[1;{color}m{label}\033[0m"
stamp = time.strftime("%Y-%m-%d %H:%M:%S")
print(f"{stamp} {label} {message}", flush=True)
def _share_path():
folder = os.path.dirname(os.path.abspath(__file__))
return os.path.join(folder, "." + CONFIG["sn"] + ".shares")
def _load_shares():
try:
with open(_share_path(), encoding="utf-8") as handle:
return max(0, int(handle.read().strip() or "0"))
except (OSError, ValueError):
return 0
def _save_shares(count):
try:
with open(_share_path(), "w", encoding="utf-8") as handle:
handle.write(str(count))
except OSError:
return None
def _enable_ansi():
if os.name != "nt":
return
try:
kernel = ctypes.windll.kernel32
handle = kernel.GetStdHandle(-11)
mode = ctypes.c_ulong()
if kernel.GetConsoleMode(handle, ctypes.byref(mode)):
kernel.SetConsoleMode(handle, mode.value | 0x0004)
except Exception:
return None
if __name__ == "__main__": if __name__ == "__main__":
_enable_ansi()
SHARES = _load_shares()
_log("INFO", "HyperNode client · " + CONFIG["sn"] + " · " + current_mode())
while True: while True:
try: try:
beat() beat()
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace").strip()
_log("WARN", f"heartbeat {exc.code} {detail}")
except (urllib.error.URLError, TimeoutError, OSError) as exc: except (urllib.error.URLError, TimeoutError, OSError) as exc:
print(exc) _log("WARN", str(exc))
time.sleep(60) time.sleep(60)
""" """
def render_client(sn: str, token: str, base_url: str) -> str: def render_client(sn: str, token: str, base_url: str) -> str:
config = json.dumps({"sn": sn, "token": token, "base": base_url.rstrip("/")}, ensure_ascii=True) config = json.dumps(
{
"sn": sn,
"token": token,
"base": base_url.rstrip("/"),
"current_mode": "pow_mining",
},
ensure_ascii=True,
)
return _CLIENT.replace("__CONFIG__", repr(config)) return _CLIENT.replace("__CONFIG__", repr(config))
+61
View File
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import io
import os import os
import tempfile import tempfile
import unittest import unittest
@@ -346,12 +348,71 @@ class CoreFlowTest(unittest.TestCase):
beat = self.client.post("/api/v1/node/heartbeat", json={"sn": sn, "token": node["node_token"]}) beat = self.client.post("/api/v1/node/heartbeat", json={"sn": sn, "token": node["node_token"]})
self.assertEqual(beat.status_code, 200, beat.text) self.assertEqual(beat.status_code, 200, beat.text)
self.assertEqual(beat.json()["status"], "online") self.assertEqual(beat.json()["status"], "online")
self.assertEqual(beat.json()["estimated_daily_points"], "50.00000000")
client = self.client.get(f"/api/v1/me/nodes/{sn}/client", headers=headers) client = self.client.get(f"/api/v1/me/nodes/{sn}/client", headers=headers)
self.assertEqual(client.status_code, 200, client.text) self.assertEqual(client.status_code, 200, client.text)
self.assertIn(node["node_token"], client.text) self.assertIn(node["node_token"], client.text)
self.assertIn("nvidia-smi", client.text) self.assertIn("nvidia-smi", client.text)
self.assertIn("pynvml", client.text) self.assertIn("pynvml", client.text)
self.assertIn("nvmlDeviceGetTemperature", client.text)
self.assertIn("fan.speed", client.text)
self.assertIn("48.5", client.text)
self.assertIn("pow_mining", client.text)
namespace = {"__name__": "hypernode_client"}
exec(compile(client.text, "hypernode-client.py", "exec"), namespace)
card = {
"name": "NVIDIA GeForce RTX 3060 Ti",
"vram_gb": 8.0,
"vram_used_mb": 6200,
"vram_total_mb": 8192,
"gpu_temp": 67,
"fan_speed": 54,
"power_usage": 168.4,
"utilization": 99,
}
reply = {
"status": "online",
"display_node_id": 1001,
"hardware_weight": 1.0,
"recovery_days": 30,
"purchase_amount": "1500.00000000",
"recoverable_stake_amount": "1450.00000000",
"estimated_daily_points": "50.00000000",
}
buffer = io.StringIO()
with contextlib.redirect_stdout(buffer):
namespace["_report"](card, reply, 7)
rendered = buffer.getvalue()
self.assertIn("[INFO]", rendered)
self.assertIn("RTX 3060 Ti", rendered)
self.assertIn("8.00 GB", rendered)
self.assertIn("[HARDWARE]", rendered)
self.assertIn("67°C", rendered)
self.assertIn("Fan 54%", rendered)
self.assertIn("168.4 W", rendered)
self.assertIn("6200 / 8192 MB", rendered)
self.assertIn("[HASHRATE]", rendered)
self.assertIn("48.5 MH/s", rendered)
self.assertIn("Accepted 7", rendered)
self.assertIn("[HEARTBEAT]", rendered)
self.assertIn("Node #1001", rendered)
self.assertIn("Weight 1.0", rendered)
self.assertIn("[RECOVERY]", rendered)
self.assertIn("30-day fast recovery", rendered)
self.assertIn("3.3% returned", rendered)
self.assertIn("Est. Points 50.00000000", rendered)
os.environ["HYPERNODE_MODE"] = "gpu_rental"
try:
rental = io.StringIO()
with contextlib.redirect_stdout(rental):
namespace["_report"](card, reply, 7)
finally:
os.environ.pop("HYPERNODE_MODE", None)
rental_text = rental.getvalue()
self.assertIn("[AI Task Executing]", rental_text)
self.assertIn("99% Load", rental_text)
self.assertNotIn("[HASHRATE]", rental_text)
duplicated = self.client.post("/api/v1/me/orders", headers=headers, json={"tx_id": "paytx1500usdt01"}) duplicated = self.client.post("/api/v1/me/orders", headers=headers, json={"tx_id": "paytx1500usdt01"})
self.assertEqual(duplicated.status_code, 409, duplicated.text) self.assertEqual(duplicated.status_code, 409, duplicated.text)