客户端心跳时打印显卡温度功耗和算力日志,并带回今日预估积分。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -316,6 +316,7 @@ def heartbeat(body: HeartbeatIn, request: Request, db: Db) -> HeartbeatOut:
|
||||
client_ip=client_ip(request),
|
||||
hardware_fingerprint=body.hardware_fingerprint,
|
||||
)
|
||||
days = node.recovery_days or 540
|
||||
return HeartbeatOut(
|
||||
sn=node.sn,
|
||||
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,
|
||||
recovery_days=node.recovery_days,
|
||||
recoverable_stake_amount=node.recoverable_stake_amount,
|
||||
purchase_amount=node.purchase_amount,
|
||||
estimated_daily_points=money(node.purchase_amount / Decimal(days)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -125,6 +125,8 @@ class HeartbeatOut(APIModel):
|
||||
gpu_generation: str = "10"
|
||||
recovery_days: int = 540
|
||||
recoverable_stake_amount: MoneyText | None = None
|
||||
purchase_amount: MoneyText | None = None
|
||||
estimated_daily_points: MoneyText | None = None
|
||||
|
||||
|
||||
class TaskOut(APIModel):
|
||||
|
||||
+284
-50
@@ -78,6 +78,7 @@ import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -86,17 +87,29 @@ import uuid
|
||||
CONFIG = json.loads(__CONFIG__)
|
||||
|
||||
|
||||
def hardware():
|
||||
gpu_model, vram_gb, gpu_uuid = _gpu()
|
||||
return {
|
||||
"sn": CONFIG["sn"],
|
||||
"token": CONFIG["token"],
|
||||
"gpu_model": gpu_model,
|
||||
"vram_gb": vram_gb,
|
||||
"cpu_count": os.cpu_count(),
|
||||
"memory_gb": _memory_gb(),
|
||||
"hardware_fingerprint": _fingerprint(gpu_uuid),
|
||||
}
|
||||
SHARES = 0
|
||||
_REFERENCE_MH = (
|
||||
("4090", 130.0),
|
||||
("4080 SUPER", 95.0),
|
||||
("4080", 90.0),
|
||||
("4070 TI SUPER", 75.0),
|
||||
("4070 TI", 70.0),
|
||||
("4070 SUPER", 62.0),
|
||||
("4070", 55.0),
|
||||
("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):
|
||||
@@ -134,72 +147,136 @@ def _board_serial():
|
||||
|
||||
def _gpu():
|
||||
probed = _pynvml()
|
||||
if probed[0]:
|
||||
if probed and probed.get("name"):
|
||||
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():
|
||||
try:
|
||||
import pynvml
|
||||
except ImportError:
|
||||
return None, None, None
|
||||
return None
|
||||
try:
|
||||
pynvml.nvmlInit()
|
||||
best_name = None
|
||||
best = None
|
||||
best_bytes = -1
|
||||
best_uuid = None
|
||||
for index in range(pynvml.nvmlDeviceGetCount()):
|
||||
handle = pynvml.nvmlDeviceGetHandleByIndex(index)
|
||||
name = pynvml.nvmlDeviceGetName(handle)
|
||||
if isinstance(name, bytes):
|
||||
name = name.decode()
|
||||
total = int(pynvml.nvmlDeviceGetMemoryInfo(handle).total)
|
||||
if total > best_bytes:
|
||||
uid = pynvml.nvmlDeviceGetUUID(handle)
|
||||
if isinstance(uid, bytes):
|
||||
uid = uid.decode()
|
||||
best_name = str(name)
|
||||
best_bytes = total
|
||||
best_uuid = str(uid)
|
||||
memory = pynvml.nvmlDeviceGetMemoryInfo(handle)
|
||||
total = int(memory.total)
|
||||
if total <= best_bytes:
|
||||
continue
|
||||
uid = pynvml.nvmlDeviceGetUUID(handle)
|
||||
if isinstance(uid, bytes):
|
||||
uid = uid.decode()
|
||||
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()
|
||||
except Exception:
|
||||
return None, None, None
|
||||
if not best_name or best_bytes < 0:
|
||||
return None, None, None
|
||||
return best_name, round(best_bytes / (1024 ** 3), 2), best_uuid
|
||||
return None
|
||||
return best
|
||||
|
||||
|
||||
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():
|
||||
query = "name,memory.total,memory.used,uuid,temperature.gpu,fan.speed,power.draw,utilization.gpu"
|
||||
try:
|
||||
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,
|
||||
timeout=8,
|
||||
text=True,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return None, None, None
|
||||
best_name = None
|
||||
return None
|
||||
best = None
|
||||
best_mib = -1.0
|
||||
best_uuid = None
|
||||
for line in output.splitlines():
|
||||
parts = [part.strip() for part in line.split(",", 2)]
|
||||
if len(parts) < 2:
|
||||
parts = [part.strip() for part in line.split(",")]
|
||||
if len(parts) < 8:
|
||||
continue
|
||||
name, memory = parts[0], parts[1]
|
||||
gpu_uuid = parts[2] if len(parts) > 2 else None
|
||||
try:
|
||||
mib = float(memory)
|
||||
except ValueError:
|
||||
total = _number(parts[-7])
|
||||
if total is None or total <= best_mib:
|
||||
continue
|
||||
if mib > best_mib:
|
||||
best_name = name or None
|
||||
best_mib = mib
|
||||
best_uuid = gpu_uuid or None
|
||||
if best_name is None:
|
||||
return None, None, None
|
||||
return best_name, round(best_mib / 1024, 2), best_uuid
|
||||
best_mib = total
|
||||
best = _blank_gpu()
|
||||
best.update({
|
||||
"name": ",".join(parts[:-7]).strip() or None,
|
||||
"vram_gb": round(total / 1024, 2),
|
||||
"vram_total_mb": int(round(total)),
|
||||
"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():
|
||||
@@ -233,28 +310,185 @@ def _memory_gb():
|
||||
|
||||
|
||||
def beat():
|
||||
body = json.dumps(hardware()).encode()
|
||||
global SHARES
|
||||
gpu = _gpu()
|
||||
payload = hardware_from(gpu)
|
||||
body = json.dumps(payload).encode()
|
||||
request = urllib.request.Request(
|
||||
CONFIG["base"] + "/api/v1/node/heartbeat",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
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__":
|
||||
_enable_ansi()
|
||||
SHARES = _load_shares()
|
||||
_log("INFO", "HyperNode client · " + CONFIG["sn"] + " · " + current_mode())
|
||||
while True:
|
||||
try:
|
||||
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:
|
||||
print(exc)
|
||||
_log("WARN", str(exc))
|
||||
time.sleep(60)
|
||||
"""
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
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"]})
|
||||
self.assertEqual(beat.status_code, 200, beat.text)
|
||||
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)
|
||||
self.assertEqual(client.status_code, 200, client.text)
|
||||
self.assertIn(node["node_token"], client.text)
|
||||
self.assertIn("nvidia-smi", 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"})
|
||||
self.assertEqual(duplicated.status_code, 409, duplicated.text)
|
||||
|
||||
Reference in New Issue
Block a user