diff --git a/app/api.py b/app/api.py index 620f47b..0cc1411 100644 --- a/app/api.py +++ b/app/api.py @@ -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)), ) diff --git a/app/schemas.py b/app/schemas.py index c6a33a6..3b8048a 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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): diff --git a/app/services/orders.py b/app/services/orders.py index 07e2551..900c972 100644 --- a/app/services/orders.py +++ b/app/services/orders.py @@ -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)) diff --git a/tests/test_core_flow.py b/tests/test_core_flow.py index c072fb5..3300dd0 100644 --- a/tests/test_core_flow.py +++ b/tests/test_core_flow.py @@ -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)