1024 lines
37 KiB
Python
1024 lines
37 KiB
Python
import argparse
|
|
import asyncio
|
|
import ctypes
|
|
from datetime import date, timedelta
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import queue
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from ctypes import wintypes
|
|
|
|
import psutil
|
|
from bleak import BleakClient, BleakScanner
|
|
|
|
|
|
DEFAULT_DEVICE_NAME = "RLCD-Metrics"
|
|
DEFAULT_CHAR_UUID = "9f4af548-1a14-4fc1-8f5a-4b9f00000002"
|
|
MAX_DISK_ROWS = 4
|
|
MAX_DISK_TEXT = 124
|
|
CODEX_DAILY_BAR_COUNT = 14
|
|
CODEX_REFRESH_SECONDS = 60.0
|
|
CODEX_USAGE_REFRESH_SECONDS = 300.0
|
|
ZHIPU_REFRESH_SECONDS = 60.0
|
|
ZHIPU_USAGE_REFRESH_SECONDS = 600.0
|
|
ZHIPU_DAILY_BAR_COUNT = 14
|
|
|
|
|
|
def clamp_percent(value: float) -> int:
|
|
if value != value:
|
|
return 0
|
|
return max(0, min(100, int(round(value))))
|
|
|
|
|
|
def mb(value: float) -> int:
|
|
return int(round(value / 1024 / 1024))
|
|
|
|
|
|
def clamp_kbps(value: float) -> int:
|
|
return max(0, min(999999, int(round(value))))
|
|
|
|
|
|
class _PdhCounterValue(ctypes.Structure):
|
|
_fields_ = [
|
|
("status", wintypes.LONG),
|
|
("double_value", ctypes.c_double),
|
|
]
|
|
|
|
|
|
class CpuPercentReader:
|
|
def __init__(self) -> None:
|
|
self.pdh = None
|
|
self.query = ctypes.c_void_p()
|
|
self.counter = ctypes.c_void_p()
|
|
self.available = False
|
|
|
|
if sys.platform != "win32":
|
|
return
|
|
|
|
try:
|
|
self.pdh = ctypes.WinDLL("pdh")
|
|
self.pdh.PdhOpenQueryW.restype = wintypes.LONG
|
|
self.pdh.PdhOpenQueryW.argtypes = [
|
|
wintypes.LPCWSTR,
|
|
ctypes.c_void_p,
|
|
ctypes.POINTER(ctypes.c_void_p),
|
|
]
|
|
self.pdh.PdhCollectQueryData.restype = wintypes.LONG
|
|
self.pdh.PdhCollectQueryData.argtypes = [ctypes.c_void_p]
|
|
self.pdh.PdhGetFormattedCounterValue.restype = wintypes.LONG
|
|
self.pdh.PdhGetFormattedCounterValue.argtypes = [
|
|
ctypes.c_void_p,
|
|
wintypes.DWORD,
|
|
ctypes.POINTER(wintypes.DWORD),
|
|
ctypes.POINTER(_PdhCounterValue),
|
|
]
|
|
|
|
add_counter = getattr(self.pdh, "PdhAddEnglishCounterW", self.pdh.PdhAddCounterW)
|
|
add_counter.restype = wintypes.LONG
|
|
add_counter.argtypes = [
|
|
ctypes.c_void_p,
|
|
wintypes.LPCWSTR,
|
|
ctypes.c_void_p,
|
|
ctypes.POINTER(ctypes.c_void_p),
|
|
]
|
|
|
|
if self.pdh.PdhOpenQueryW(None, None, ctypes.byref(self.query)) != 0:
|
|
return
|
|
if add_counter(
|
|
self.query,
|
|
r"\Processor Information(_Total)\% Processor Utility",
|
|
None,
|
|
ctypes.byref(self.counter),
|
|
) != 0:
|
|
return
|
|
|
|
self.pdh.PdhCollectQueryData(self.query)
|
|
self.available = True
|
|
except OSError:
|
|
self.available = False
|
|
|
|
def read_percent(self) -> int:
|
|
if self.available and self.pdh is not None:
|
|
value = _PdhCounterValue()
|
|
counter_type = wintypes.DWORD()
|
|
if self.pdh.PdhCollectQueryData(self.query) == 0:
|
|
rc = self.pdh.PdhGetFormattedCounterValue(
|
|
self.counter,
|
|
0x00000200,
|
|
ctypes.byref(counter_type),
|
|
ctypes.byref(value),
|
|
)
|
|
if rc == 0 and value.status == 0 and value.double_value >= 0:
|
|
return clamp_percent(value.double_value)
|
|
|
|
return clamp_percent(psutil.cpu_percent(interval=None))
|
|
|
|
|
|
class CpuFrequencyReader:
|
|
def __init__(self) -> None:
|
|
self.pdh = None
|
|
self.query = ctypes.c_void_p()
|
|
self.total_counter = ctypes.c_void_p()
|
|
self.core_counters: list[tuple[int, ctypes.c_void_p]] = []
|
|
self.base_mhz = self._base_mhz()
|
|
self.core_type_by_index: dict[int, str] = {}
|
|
self.available = False
|
|
|
|
if sys.platform != "win32":
|
|
return
|
|
|
|
try:
|
|
self.pdh = ctypes.WinDLL("pdh")
|
|
self.pdh.PdhOpenQueryW.restype = wintypes.LONG
|
|
self.pdh.PdhOpenQueryW.argtypes = [
|
|
wintypes.LPCWSTR,
|
|
ctypes.c_void_p,
|
|
ctypes.POINTER(ctypes.c_void_p),
|
|
]
|
|
self.pdh.PdhCollectQueryData.restype = wintypes.LONG
|
|
self.pdh.PdhCollectQueryData.argtypes = [ctypes.c_void_p]
|
|
self.pdh.PdhGetFormattedCounterValue.restype = wintypes.LONG
|
|
self.pdh.PdhGetFormattedCounterValue.argtypes = [
|
|
ctypes.c_void_p,
|
|
wintypes.DWORD,
|
|
ctypes.POINTER(wintypes.DWORD),
|
|
ctypes.POINTER(_PdhCounterValue),
|
|
]
|
|
|
|
add_counter = getattr(self.pdh, "PdhAddEnglishCounterW", self.pdh.PdhAddCounterW)
|
|
add_counter.restype = wintypes.LONG
|
|
add_counter.argtypes = [
|
|
ctypes.c_void_p,
|
|
wintypes.LPCWSTR,
|
|
ctypes.c_void_p,
|
|
ctypes.POINTER(ctypes.c_void_p),
|
|
]
|
|
|
|
if self.pdh.PdhOpenQueryW(None, None, ctypes.byref(self.query)) != 0:
|
|
return
|
|
if add_counter(
|
|
self.query,
|
|
r"\Processor Information(_Total)\% Processor Performance",
|
|
None,
|
|
ctypes.byref(self.total_counter),
|
|
) != 0:
|
|
self.total_counter = ctypes.c_void_p()
|
|
|
|
logical_count = psutil.cpu_count(logical=True) or 0
|
|
self.core_type_by_index = self._read_core_type_map(logical_count)
|
|
for index in range(logical_count):
|
|
counter = ctypes.c_void_p()
|
|
path = rf"\Processor Information(0,{index})\% Processor Performance"
|
|
if add_counter(self.query, path, None, ctypes.byref(counter)) == 0:
|
|
self.core_counters.append((index, counter))
|
|
|
|
if not self.total_counter and not self.core_counters:
|
|
return
|
|
|
|
self.pdh.PdhCollectQueryData(self.query)
|
|
self.available = True
|
|
except OSError:
|
|
self.available = False
|
|
|
|
@staticmethod
|
|
def _base_mhz() -> int:
|
|
freq = psutil.cpu_freq()
|
|
if freq and freq.max:
|
|
return int(round(freq.max))
|
|
if freq and freq.current:
|
|
return int(round(freq.current))
|
|
return 0
|
|
|
|
@staticmethod
|
|
def _fallback_core_type_map(logical_count: int) -> dict[int, str]:
|
|
if logical_count == 16:
|
|
return {
|
|
index: ("P" if index < 4 else "E" if index < 12 else "L")
|
|
for index in range(logical_count)
|
|
}
|
|
return {}
|
|
|
|
@staticmethod
|
|
def _read_core_type_map(logical_count: int) -> dict[int, str]:
|
|
fallback = CpuFrequencyReader._fallback_core_type_map(logical_count)
|
|
if sys.platform != "win32":
|
|
return fallback
|
|
|
|
try:
|
|
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
get_cpu_sets = kernel32.GetSystemCpuSetInformation
|
|
get_cpu_sets.restype = wintypes.BOOL
|
|
get_cpu_sets.argtypes = [
|
|
ctypes.c_void_p,
|
|
wintypes.ULONG,
|
|
ctypes.POINTER(wintypes.ULONG),
|
|
ctypes.c_void_p,
|
|
wintypes.ULONG,
|
|
]
|
|
|
|
returned = wintypes.ULONG()
|
|
get_cpu_sets(None, 0, ctypes.byref(returned), None, 0)
|
|
if returned.value <= 0:
|
|
return fallback
|
|
|
|
buffer = ctypes.create_string_buffer(returned.value)
|
|
if not get_cpu_sets(buffer, returned, ctypes.byref(returned), None, 0):
|
|
return fallback
|
|
|
|
base = ctypes.addressof(buffer)
|
|
offset = 0
|
|
infos: list[dict[str, int]] = []
|
|
while offset + 20 <= returned.value:
|
|
ptr = base + offset
|
|
size = ctypes.c_uint32.from_address(ptr).value
|
|
info_type = ctypes.c_uint32.from_address(ptr + 4).value
|
|
if size <= 0:
|
|
break
|
|
if info_type == 0:
|
|
infos.append(
|
|
{
|
|
"logical": ctypes.c_ubyte.from_address(ptr + 14).value,
|
|
"llc": ctypes.c_ubyte.from_address(ptr + 16).value,
|
|
"efficiency": ctypes.c_ubyte.from_address(ptr + 18).value,
|
|
}
|
|
)
|
|
offset += size
|
|
|
|
if not infos:
|
|
return fallback
|
|
|
|
min_efficiency = min(item["efficiency"] for item in infos)
|
|
max_efficiency = max(item["efficiency"] for item in infos)
|
|
if max_efficiency == min_efficiency:
|
|
return fallback
|
|
|
|
p_llc = {
|
|
item["llc"]
|
|
for item in infos
|
|
if item["efficiency"] == max_efficiency
|
|
}
|
|
type_map: dict[int, str] = {}
|
|
for item in infos:
|
|
logical = item["logical"]
|
|
if item["efficiency"] == max_efficiency:
|
|
type_map[logical] = "P"
|
|
elif item["llc"] not in p_llc:
|
|
type_map[logical] = "L"
|
|
else:
|
|
type_map[logical] = "E"
|
|
return type_map or fallback
|
|
except (AttributeError, OSError, ValueError):
|
|
return fallback
|
|
|
|
def _format_core_mhz(self, index: int, mhz: int) -> str:
|
|
ghz10 = max(0, int(round(mhz / 100)))
|
|
core_type = self.core_type_by_index.get(index, "C")
|
|
return f"{core_type}:{ghz10 // 10}.{ghz10 % 10}"
|
|
|
|
def _read_counter(self, counter: ctypes.c_void_p) -> float:
|
|
if self.pdh is None or not counter:
|
|
return -1.0
|
|
|
|
value = _PdhCounterValue()
|
|
counter_type = wintypes.DWORD()
|
|
rc = self.pdh.PdhGetFormattedCounterValue(
|
|
counter,
|
|
0x00000200,
|
|
ctypes.byref(counter_type),
|
|
ctypes.byref(value),
|
|
)
|
|
if rc != 0 or value.status != 0 or value.double_value < 0:
|
|
return -1.0
|
|
return value.double_value
|
|
|
|
def _perf_to_mhz(self, performance_percent: float) -> int:
|
|
if self.base_mhz <= 0 or performance_percent < 0:
|
|
return 0
|
|
return int(round(self.base_mhz * performance_percent / 100))
|
|
|
|
def read(self) -> tuple[int, str]:
|
|
if self.available and self.pdh is not None and self.base_mhz > 0:
|
|
if self.pdh.PdhCollectQueryData(self.query) == 0:
|
|
total_mhz = self._perf_to_mhz(self._read_counter(self.total_counter))
|
|
core_values: list[tuple[int, int]] = []
|
|
for index, counter in self.core_counters:
|
|
mhz = self._perf_to_mhz(self._read_counter(counter))
|
|
if mhz > 0:
|
|
core_values.append((index, mhz))
|
|
|
|
core_values.sort(key=lambda item: (-item[1], item[0]))
|
|
top_text = " ".join(
|
|
self._format_core_mhz(index, mhz)
|
|
for index, mhz in core_values[:4]
|
|
)
|
|
if total_mhz <= 0 and core_values:
|
|
total_mhz = core_values[0][1]
|
|
if total_mhz > 0:
|
|
return total_mhz, top_text
|
|
|
|
freq = psutil.cpu_freq()
|
|
return (int(round(freq.current)) if freq and freq.current else 0), ""
|
|
|
|
def read_mhz(self) -> int:
|
|
return self.read()[0]
|
|
|
|
|
|
class LogicalDiskRateReader:
|
|
def __init__(self, labels: list[str]) -> None:
|
|
self.pdh = None
|
|
self.query = ctypes.c_void_p()
|
|
self.counters: dict[str, tuple[ctypes.c_void_p, ctypes.c_void_p]] = {}
|
|
self.available = False
|
|
|
|
if sys.platform != "win32" or not labels:
|
|
return
|
|
|
|
try:
|
|
self.pdh = ctypes.WinDLL("pdh")
|
|
self.pdh.PdhOpenQueryW.restype = wintypes.LONG
|
|
self.pdh.PdhOpenQueryW.argtypes = [
|
|
wintypes.LPCWSTR,
|
|
ctypes.c_void_p,
|
|
ctypes.POINTER(ctypes.c_void_p),
|
|
]
|
|
self.pdh.PdhCollectQueryData.restype = wintypes.LONG
|
|
self.pdh.PdhCollectQueryData.argtypes = [ctypes.c_void_p]
|
|
self.pdh.PdhGetFormattedCounterValue.restype = wintypes.LONG
|
|
self.pdh.PdhGetFormattedCounterValue.argtypes = [
|
|
ctypes.c_void_p,
|
|
wintypes.DWORD,
|
|
ctypes.POINTER(wintypes.DWORD),
|
|
ctypes.POINTER(_PdhCounterValue),
|
|
]
|
|
|
|
add_counter = getattr(self.pdh, "PdhAddEnglishCounterW", self.pdh.PdhAddCounterW)
|
|
add_counter.restype = wintypes.LONG
|
|
add_counter.argtypes = [
|
|
ctypes.c_void_p,
|
|
wintypes.LPCWSTR,
|
|
ctypes.c_void_p,
|
|
ctypes.POINTER(ctypes.c_void_p),
|
|
]
|
|
|
|
if self.pdh.PdhOpenQueryW(None, None, ctypes.byref(self.query)) != 0:
|
|
return
|
|
|
|
for label in labels:
|
|
read_counter = ctypes.c_void_p()
|
|
write_counter = ctypes.c_void_p()
|
|
read_path = rf"\LogicalDisk({label}:)\Disk Read Bytes/sec"
|
|
write_path = rf"\LogicalDisk({label}:)\Disk Write Bytes/sec"
|
|
if add_counter(self.query, read_path, None, ctypes.byref(read_counter)) != 0:
|
|
continue
|
|
if add_counter(self.query, write_path, None, ctypes.byref(write_counter)) != 0:
|
|
continue
|
|
self.counters[label] = (read_counter, write_counter)
|
|
|
|
if self.counters:
|
|
self.pdh.PdhCollectQueryData(self.query)
|
|
self.available = True
|
|
except OSError:
|
|
self.available = False
|
|
|
|
def _read_kbps(self, counter: ctypes.c_void_p) -> int:
|
|
if self.pdh is None:
|
|
return -1
|
|
|
|
value = _PdhCounterValue()
|
|
counter_type = wintypes.DWORD()
|
|
rc = self.pdh.PdhGetFormattedCounterValue(
|
|
counter,
|
|
0x00000200,
|
|
ctypes.byref(counter_type),
|
|
ctypes.byref(value),
|
|
)
|
|
if rc != 0 or value.status != 0 or value.double_value < 0:
|
|
return -1
|
|
return clamp_kbps(value.double_value / 1024)
|
|
|
|
def read_rates(self) -> dict[str, tuple[int, int]]:
|
|
if not self.available or self.pdh is None:
|
|
return {}
|
|
|
|
if self.pdh.PdhCollectQueryData(self.query) != 0:
|
|
return {}
|
|
|
|
rates: dict[str, tuple[int, int]] = {}
|
|
for label, (read_counter, write_counter) in self.counters.items():
|
|
rates[label] = (self._read_kbps(read_counter), self._read_kbps(write_counter))
|
|
return rates
|
|
|
|
|
|
class CodexRateLimitReader:
|
|
def __init__(self, refresh_seconds: float = CODEX_REFRESH_SECONDS) -> None:
|
|
self.refresh_seconds = refresh_seconds
|
|
self.next_refresh = 0.0
|
|
self.cached = (-1, -1, -1, -1, -1)
|
|
self.usage_refresh_seconds = CODEX_USAGE_REFRESH_SECONDS
|
|
self.next_usage_refresh = 0.0
|
|
self.usage_cached = (-1, "")
|
|
self.process: subprocess.Popen | None = None
|
|
self.responses: queue.Queue[dict] = queue.Queue()
|
|
self.next_id = 1
|
|
self.initialized = False
|
|
|
|
def _find_codex_exe(self) -> str | None:
|
|
local_app_data = os.environ.get("LOCALAPPDATA")
|
|
if local_app_data:
|
|
root = Path(local_app_data) / "OpenAI" / "Codex" / "bin"
|
|
candidates = sorted(root.glob("*/codex.exe"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
if candidates:
|
|
return str(candidates[0])
|
|
return "codex.exe"
|
|
|
|
def _reader_loop(self, stdout) -> None:
|
|
for line in stdout:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
self.responses.put(json.loads(line))
|
|
except json.JSONDecodeError:
|
|
continue
|
|
|
|
def _stop(self) -> None:
|
|
if self.process is not None:
|
|
try:
|
|
self.process.kill()
|
|
except OSError:
|
|
pass
|
|
self.process = None
|
|
self.initialized = False
|
|
|
|
def _send(self, method: str, params: dict | None = None, expect_response: bool = True) -> int | None:
|
|
if self.process is None or self.process.stdin is None:
|
|
return None
|
|
|
|
request_id = None
|
|
message: dict = {"method": method}
|
|
if params is not None:
|
|
message["params"] = params
|
|
if expect_response:
|
|
request_id = self.next_id
|
|
self.next_id += 1
|
|
message["id"] = request_id
|
|
|
|
self.process.stdin.write(json.dumps(message, separators=(",", ":")) + "\n")
|
|
self.process.stdin.flush()
|
|
return request_id
|
|
|
|
def _wait_for(self, request_id: int, timeout: float) -> dict | None:
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
try:
|
|
message = self.responses.get(timeout=max(0.05, deadline - time.monotonic()))
|
|
except queue.Empty:
|
|
return None
|
|
if message.get("id") == request_id:
|
|
return message
|
|
return None
|
|
|
|
def _ensure_started(self) -> bool:
|
|
if self.process is not None and self.process.poll() is None and self.initialized:
|
|
return True
|
|
|
|
self._stop()
|
|
codex_exe = self._find_codex_exe()
|
|
if not codex_exe:
|
|
return False
|
|
|
|
creationflags = subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0
|
|
try:
|
|
self.process = subprocess.Popen(
|
|
[codex_exe, "app-server", "--stdio"],
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
text=True,
|
|
encoding="utf-8",
|
|
bufsize=1,
|
|
creationflags=creationflags,
|
|
)
|
|
except OSError:
|
|
self.process = None
|
|
return False
|
|
|
|
assert self.process.stdout is not None
|
|
threading.Thread(target=self._reader_loop, args=(self.process.stdout,), daemon=True).start()
|
|
|
|
init_id = self._send(
|
|
"initialize",
|
|
{
|
|
"clientInfo": {
|
|
"name": "rlcd_metrics_bridge",
|
|
"title": "RLCD Metrics Bridge",
|
|
"version": "0.1.0",
|
|
}
|
|
},
|
|
)
|
|
if init_id is None or self._wait_for(init_id, 5.0) is None:
|
|
self._stop()
|
|
return False
|
|
|
|
self._send("initialized", {}, expect_response=False)
|
|
self.initialized = True
|
|
return True
|
|
|
|
@staticmethod
|
|
def _mins_until(epoch_seconds) -> int:
|
|
if not isinstance(epoch_seconds, (int, float)):
|
|
return -1
|
|
return max(0, min(99999, int(round((epoch_seconds - time.time()) / 60))))
|
|
|
|
@staticmethod
|
|
def _read_percent(value) -> int:
|
|
if not isinstance(value, (int, float)):
|
|
return -1
|
|
return clamp_percent(value)
|
|
|
|
def read(self) -> tuple[int, int, int, int, int]:
|
|
now = time.monotonic()
|
|
if now < self.next_refresh:
|
|
return self.cached
|
|
|
|
if not self._ensure_started():
|
|
self.next_refresh = now + min(10.0, self.refresh_seconds)
|
|
return self.cached
|
|
|
|
request_id = self._send("account/rateLimits/read")
|
|
if request_id is None:
|
|
self.next_refresh = now + min(10.0, self.refresh_seconds)
|
|
return self.cached
|
|
|
|
message = self._wait_for(request_id, 8.0)
|
|
result = message.get("result") if message else None
|
|
rate_limits = result.get("rateLimits") if isinstance(result, dict) else None
|
|
if not isinstance(rate_limits, dict):
|
|
self._stop()
|
|
self.next_refresh = now + min(10.0, self.refresh_seconds)
|
|
return self.cached
|
|
|
|
self.next_refresh = now + self.refresh_seconds
|
|
primary = rate_limits.get("primary") or {}
|
|
secondary = rate_limits.get("secondary") or {}
|
|
reset_credits = result.get("rateLimitResetCredits") or {}
|
|
|
|
self.cached = (
|
|
self._read_percent(primary.get("usedPercent")),
|
|
self._read_percent(secondary.get("usedPercent")),
|
|
self._mins_until(primary.get("resetsAt")),
|
|
self._mins_until(secondary.get("resetsAt")),
|
|
int(reset_credits.get("availableCount", -1)) if isinstance(reset_credits, dict) else -1,
|
|
)
|
|
return self.cached
|
|
|
|
@staticmethod
|
|
def _tokens_to_m10(tokens) -> int:
|
|
if not isinstance(tokens, (int, float)) or tokens < 0:
|
|
return -1
|
|
return max(0, min(2147483647, int(round(tokens / 100000))))
|
|
|
|
@staticmethod
|
|
def _daily_bars_from_buckets(buckets: list[dict]) -> tuple[int, str]:
|
|
tokens_by_day: dict[str, int] = {}
|
|
latest_day: date | None = None
|
|
|
|
for bucket in buckets:
|
|
if not isinstance(bucket, dict):
|
|
continue
|
|
start_date = bucket.get("startDate")
|
|
tokens = bucket.get("tokens")
|
|
if isinstance(start_date, str) and isinstance(tokens, (int, float)) and tokens >= 0:
|
|
tokens_by_day[start_date] = int(tokens)
|
|
try:
|
|
bucket_day = date.fromisoformat(start_date)
|
|
except ValueError:
|
|
continue
|
|
if latest_day is None or bucket_day > latest_day:
|
|
latest_day = bucket_day
|
|
|
|
if latest_day is None:
|
|
return -1, ""
|
|
|
|
wanted_days = [
|
|
(latest_day - timedelta(days=offset)).isoformat()
|
|
for offset in range(CODEX_DAILY_BAR_COUNT - 1, -1, -1)
|
|
]
|
|
|
|
values = [tokens_by_day.get(day, 0) for day in wanted_days]
|
|
max_value = max(values) if values else 0
|
|
if max_value > 0:
|
|
percents = [max(0, min(100, int(round(value * 100 / max_value)))) for value in values]
|
|
else:
|
|
percents = [0 for _ in values]
|
|
|
|
today_tokens_m10 = CodexRateLimitReader._tokens_to_m10(tokens_by_day.get(latest_day.isoformat(), -1))
|
|
return today_tokens_m10, ":".join(str(value) for value in percents)
|
|
|
|
def read_usage_snapshot(self) -> tuple[int, str]:
|
|
now = time.monotonic()
|
|
if now < self.next_usage_refresh:
|
|
return self.usage_cached
|
|
|
|
if not self._ensure_started():
|
|
self.next_usage_refresh = now + min(15.0, self.usage_refresh_seconds)
|
|
return self.usage_cached
|
|
|
|
request_id = self._send("account/usage/read")
|
|
if request_id is None:
|
|
self.next_usage_refresh = now + min(15.0, self.usage_refresh_seconds)
|
|
return self.usage_cached
|
|
|
|
message = self._wait_for(request_id, 8.0)
|
|
result = message.get("result") if message else None
|
|
buckets = result.get("dailyUsageBuckets") if isinstance(result, dict) else None
|
|
if not isinstance(buckets, list) or not buckets:
|
|
self.next_usage_refresh = now + min(15.0, self.usage_refresh_seconds)
|
|
return self.usage_cached
|
|
|
|
self.next_usage_refresh = now + self.usage_refresh_seconds
|
|
self.usage_cached = self._daily_bars_from_buckets(buckets)
|
|
return self.usage_cached
|
|
|
|
|
|
class ZhipuStatusReader:
|
|
def __init__(self,
|
|
refresh_seconds: float = ZHIPU_REFRESH_SECONDS,
|
|
usage_refresh_seconds: float = ZHIPU_USAGE_REFRESH_SECONDS) -> None:
|
|
self.refresh_seconds = refresh_seconds
|
|
self.usage_refresh_seconds = usage_refresh_seconds
|
|
self.next_quota_refresh = 0.0
|
|
self.next_usage_refresh = 0.0
|
|
self.quota_cached = (0, -1, -1, -1, -1)
|
|
self.usage_cached = (-1, "")
|
|
|
|
@staticmethod
|
|
def _base_host() -> str:
|
|
base_url = os.environ.get("ZHIPU_BASE_URL", "https://open.bigmodel.cn").strip()
|
|
if "bigmodel.cn" in base_url.lower():
|
|
return "https://open.bigmodel.cn"
|
|
return "https://api.z.ai"
|
|
|
|
@classmethod
|
|
def _quota_url(cls) -> str:
|
|
return f"{cls._base_host()}/api/monitor/usage/quota/limit"
|
|
|
|
@classmethod
|
|
def _model_usage_url(cls) -> str:
|
|
now = time.localtime()
|
|
end = time.strftime("%Y-%m-%d %H:%M:%S", now)
|
|
start_date = date.today() - timedelta(days=ZHIPU_DAILY_BAR_COUNT - 1)
|
|
start = f"{start_date.isoformat()} 00:00:00"
|
|
query = urllib.parse.urlencode({
|
|
"startTime": start,
|
|
"endTime": end,
|
|
})
|
|
return f"{cls._base_host()}/api/monitor/usage/model-usage?{query}"
|
|
|
|
@staticmethod
|
|
def _mins_until(epoch_ms) -> int:
|
|
if not isinstance(epoch_ms, (int, float)) or epoch_ms <= 0:
|
|
return -1
|
|
return max(0, min(99999, int(round((epoch_ms / 1000 - time.time()) / 60))))
|
|
|
|
@staticmethod
|
|
def _read_percent(value) -> int:
|
|
if isinstance(value, (int, float)):
|
|
return clamp_percent(value)
|
|
try:
|
|
return clamp_percent(float(value))
|
|
except (TypeError, ValueError):
|
|
return -1
|
|
|
|
@staticmethod
|
|
def _tokens_to_m10(tokens) -> int:
|
|
if not isinstance(tokens, (int, float)) or tokens < 0:
|
|
return -1
|
|
return max(0, min(2147483647, int(round(tokens / 100000))))
|
|
|
|
@staticmethod
|
|
def _usage_to_daily_snapshot(data) -> tuple[int, str]:
|
|
if not isinstance(data, dict):
|
|
return (-1, "")
|
|
|
|
x_time = data.get("x_time")
|
|
tokens_usage = data.get("tokensUsage")
|
|
if not isinstance(x_time, list) or not isinstance(tokens_usage, list):
|
|
return (-1, "")
|
|
|
|
today = date.today()
|
|
wanted_days = [(today - timedelta(days=offset)).isoformat() for offset in range(ZHIPU_DAILY_BAR_COUNT - 1, -1, -1)]
|
|
tokens_by_day: dict[str, int] = {}
|
|
|
|
for stamp, tokens in zip(x_time, tokens_usage):
|
|
if not isinstance(stamp, str) or len(stamp) < 10:
|
|
continue
|
|
if not isinstance(tokens, (int, float)) or tokens < 0:
|
|
continue
|
|
day = stamp[:10]
|
|
tokens_by_day[day] = tokens_by_day.get(day, 0) + int(tokens)
|
|
|
|
values = [tokens_by_day.get(day, 0) for day in wanted_days]
|
|
max_value = max(values) if values else 0
|
|
if max_value > 0:
|
|
percents = [max(0, min(100, int(round(value * 100 / max_value)))) for value in values]
|
|
else:
|
|
percents = [0 for _ in values]
|
|
|
|
today_tokens_m10 = ZhipuStatusReader._tokens_to_m10(tokens_by_day.get(today.isoformat(), -1))
|
|
return today_tokens_m10, ":".join(str(value) for value in percents)
|
|
|
|
@staticmethod
|
|
def _read_json(url: str, api_key: str):
|
|
request = urllib.request.Request(
|
|
url,
|
|
headers={
|
|
"Authorization": api_key,
|
|
"Content-Type": "application/json",
|
|
"Accept-Language": "en-US,en",
|
|
},
|
|
method="GET",
|
|
)
|
|
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=15) as response:
|
|
body = response.read().decode("utf-8", errors="replace")
|
|
except urllib.error.HTTPError as exc:
|
|
if exc.code in (401, 403):
|
|
return None
|
|
return None
|
|
except OSError:
|
|
return None
|
|
|
|
try:
|
|
return json.loads(body)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
def _query_quota(self, api_key: str) -> tuple[int, int, int, int, int]:
|
|
payload = self._read_json(self._quota_url(), api_key)
|
|
if not isinstance(payload, dict):
|
|
return (2, -1, -1, -1, -1)
|
|
|
|
if payload.get("success") is False:
|
|
return (2, -1, -1, -1, -1)
|
|
|
|
data = payload.get("data")
|
|
limits = data.get("limits") if isinstance(data, dict) else None
|
|
if not isinstance(limits, list):
|
|
return (2, -1, -1, -1, -1)
|
|
|
|
five_percent = -1
|
|
week_percent = -1
|
|
five_reset = -1
|
|
week_reset = -1
|
|
|
|
for item in limits:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
if str(item.get("type", "")).upper() != "TOKENS_LIMIT":
|
|
continue
|
|
unit = item.get("unit")
|
|
percent = self._read_percent(item.get("percentage"))
|
|
if percent < 0:
|
|
continue
|
|
reset_mins = self._mins_until(item.get("nextResetTime"))
|
|
if unit == 3 and five_percent < 0:
|
|
five_percent = percent
|
|
five_reset = reset_mins
|
|
elif unit == 6 and week_percent < 0:
|
|
week_percent = percent
|
|
week_reset = reset_mins
|
|
|
|
return (1, five_percent, week_percent, five_reset, week_reset)
|
|
|
|
def _query_usage(self, api_key: str) -> tuple[int, str]:
|
|
payload = self._read_json(self._model_usage_url(), api_key)
|
|
if not isinstance(payload, dict) or payload.get("success") is False:
|
|
return self.usage_cached
|
|
|
|
return self._usage_to_daily_snapshot(payload.get("data"))
|
|
|
|
def read(self) -> tuple[int, int, int, int, int, int, str]:
|
|
api_key = os.environ.get("ZHIPU_API_KEY", "").strip()
|
|
if not api_key:
|
|
self.quota_cached = (0, -1, -1, -1, -1)
|
|
self.usage_cached = (-1, "")
|
|
self.next_quota_refresh = time.monotonic() + self.refresh_seconds
|
|
self.next_usage_refresh = time.monotonic() + self.usage_refresh_seconds
|
|
return (*self.quota_cached, *self.usage_cached)
|
|
|
|
now = time.monotonic()
|
|
if now >= self.next_quota_refresh:
|
|
self.next_quota_refresh = now + self.refresh_seconds
|
|
self.quota_cached = self._query_quota(api_key)
|
|
|
|
if now >= self.next_usage_refresh:
|
|
self.next_usage_refresh = now + self.usage_refresh_seconds
|
|
self.usage_cached = self._query_usage(api_key)
|
|
|
|
return (*self.quota_cached, *self.usage_cached)
|
|
|
|
|
|
class MetricsSampler:
|
|
def __init__(self, disk_path: str) -> None:
|
|
self.disk_path = disk_path
|
|
self.last_net = psutil.net_io_counters()
|
|
self.last_disk = psutil.disk_io_counters()
|
|
self.last_ts = time.monotonic()
|
|
self.cpu_percent = CpuPercentReader()
|
|
self.cpu_frequency = CpuFrequencyReader()
|
|
self.disk_rates = LogicalDiskRateReader(self._disk_labels())
|
|
self.codex = CodexRateLimitReader()
|
|
self.zhipu = ZhipuStatusReader()
|
|
psutil.cpu_percent(interval=None)
|
|
|
|
def _disk_labels(self) -> list[str]:
|
|
labels = []
|
|
seen = set()
|
|
|
|
for part in psutil.disk_partitions(all=False):
|
|
device = (part.device or part.mountpoint or "").upper()
|
|
if not device or device in seen:
|
|
continue
|
|
seen.add(device)
|
|
|
|
opts = (part.opts or "").lower()
|
|
if "cdrom" in opts or not part.fstype:
|
|
continue
|
|
|
|
label = device.replace("\\", "").replace("/", "")
|
|
if label.endswith(":"):
|
|
label = label[:-1]
|
|
if label:
|
|
labels.append(label)
|
|
|
|
return labels
|
|
|
|
def _disk_summary(self, rates_by_label: dict[str, tuple[int, int]]) -> tuple[int, int, str]:
|
|
total = 0
|
|
used = 0
|
|
parts = []
|
|
seen = set()
|
|
|
|
for part in psutil.disk_partitions(all=False):
|
|
device = (part.device or part.mountpoint or "").upper()
|
|
if not device or device in seen:
|
|
continue
|
|
seen.add(device)
|
|
|
|
opts = (part.opts or "").lower()
|
|
if "cdrom" in opts or not part.fstype:
|
|
continue
|
|
|
|
try:
|
|
usage = psutil.disk_usage(part.mountpoint)
|
|
except OSError:
|
|
continue
|
|
|
|
total += usage.total
|
|
used += usage.used
|
|
label = device.replace("\\", "").replace("/", "")
|
|
if label.endswith(":"):
|
|
label = label[:-1]
|
|
read_kbps, write_kbps = rates_by_label.get(label, (-1, -1))
|
|
parts.append(f"{label}:{clamp_percent(usage.percent)}:{read_kbps}:{write_kbps}")
|
|
|
|
if total <= 0:
|
|
try:
|
|
usage = psutil.disk_usage(self.disk_path)
|
|
return 1, clamp_percent(usage.percent), f"C:{clamp_percent(usage.percent)}:-1:-1"
|
|
except OSError:
|
|
return 0, 0, ""
|
|
|
|
disk_percent = clamp_percent((used / total) * 100)
|
|
selected = []
|
|
text_len = 0
|
|
for part in parts[:MAX_DISK_ROWS]:
|
|
extra = len(part) + (1 if selected else 0)
|
|
if text_len + extra > MAX_DISK_TEXT:
|
|
break
|
|
selected.append(part)
|
|
text_len += extra
|
|
text = " ".join(selected)
|
|
return len(parts), disk_percent, text
|
|
|
|
def sample_line(self) -> str:
|
|
now = time.monotonic()
|
|
elapsed = max(0.001, now - self.last_ts)
|
|
|
|
net = psutil.net_io_counters()
|
|
rx_kbps = int(max(0, round((net.bytes_recv - self.last_net.bytes_recv) / elapsed / 1024)))
|
|
tx_kbps = int(max(0, round((net.bytes_sent - self.last_net.bytes_sent) / elapsed / 1024)))
|
|
self.last_net = net
|
|
|
|
disk_io = psutil.disk_io_counters()
|
|
if disk_io is None or self.last_disk is None:
|
|
disk_read_kbps = 0
|
|
disk_write_kbps = 0
|
|
else:
|
|
disk_read_kbps = int(max(0, round((disk_io.read_bytes - self.last_disk.read_bytes) / elapsed / 1024)))
|
|
disk_write_kbps = int(max(0, round((disk_io.write_bytes - self.last_disk.write_bytes) / elapsed / 1024)))
|
|
self.last_disk = disk_io
|
|
self.last_ts = now
|
|
disk_rates = self.disk_rates.read_rates()
|
|
|
|
cpu = self.cpu_percent.read_percent()
|
|
cpu_mhz, cpu_core_text = self.cpu_frequency.read()
|
|
|
|
mem_info = psutil.virtual_memory()
|
|
mem_percent = clamp_percent(mem_info.percent)
|
|
mem_used_mb = mb(mem_info.used)
|
|
mem_total_mb = mb(mem_info.total)
|
|
|
|
disk_count, disk_percent, disk_text = self._disk_summary(disk_rates)
|
|
codex_5h, codex_week, codex_5h_reset, codex_week_reset, codex_credits = self.codex.read()
|
|
codex_today_tokens_m10, codex_daily_bars = self.codex.read_usage_snapshot()
|
|
zhipu_status, zhipu_5h, zhipu_week, zhipu_5h_reset, zhipu_week_reset, zhipu_today_tokens_m10, zhipu_daily_bars = self.zhipu.read()
|
|
|
|
return (
|
|
f"SYS8,{cpu},{cpu_mhz},{mem_percent},{mem_used_mb},{mem_total_mb},"
|
|
f"{disk_count},{disk_percent},{disk_read_kbps},{disk_write_kbps},"
|
|
f"{rx_kbps},{tx_kbps},"
|
|
f"{codex_5h},{codex_week},{codex_5h_reset},{codex_week_reset},{codex_credits},{codex_today_tokens_m10},"
|
|
f"{codex_daily_bars},"
|
|
f"{zhipu_status},{zhipu_5h},{zhipu_week},{zhipu_5h_reset},{zhipu_week_reset},{zhipu_today_tokens_m10},"
|
|
f"{zhipu_daily_bars},{cpu_core_text},{disk_text}"
|
|
)
|
|
|
|
|
|
async def find_device(name: str, address: str | None, timeout: float):
|
|
if address:
|
|
return await BleakScanner.find_device_by_address(address, timeout=timeout)
|
|
|
|
def match(device, adv):
|
|
return device.name == name or adv.local_name == name
|
|
|
|
return await BleakScanner.find_device_by_filter(match, timeout=timeout)
|
|
|
|
|
|
async def push_loop(args) -> int:
|
|
sampler = MetricsSampler(args.disk)
|
|
|
|
while True:
|
|
print(f"Scanning for {args.name}...", flush=True)
|
|
device = await find_device(args.name, args.address, args.scan_timeout)
|
|
if device is None:
|
|
print(f"Device not found: {args.name}", flush=True)
|
|
if args.once:
|
|
return 2
|
|
await asyncio.sleep(args.retry_seconds)
|
|
continue
|
|
|
|
print(f"Connecting to {device.name or args.name} ({device.address})...", flush=True)
|
|
try:
|
|
async with BleakClient(device, timeout=args.connect_timeout) as client:
|
|
print("BLE connected. Streaming metrics.", flush=True)
|
|
while True:
|
|
line = sampler.sample_line()
|
|
await client.write_gatt_char(args.char_uuid, line.encode("ascii"), response=True)
|
|
print(line, flush=True)
|
|
if args.once:
|
|
return 0
|
|
await asyncio.sleep(args.interval)
|
|
except KeyboardInterrupt:
|
|
return 130
|
|
except Exception as exc:
|
|
print(f"BLE push failed: {exc}", file=sys.stderr, flush=True)
|
|
if args.once:
|
|
return 3
|
|
await asyncio.sleep(args.retry_seconds)
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser(description="Push PC metrics to ESP32 RLCD over BLE.")
|
|
parser.add_argument("--name", default=DEFAULT_DEVICE_NAME)
|
|
parser.add_argument("--address", default=None)
|
|
parser.add_argument("--char-uuid", default=DEFAULT_CHAR_UUID)
|
|
parser.add_argument("--interval", type=float, default=1.0)
|
|
parser.add_argument("--scan-timeout", type=float, default=20.0)
|
|
parser.add_argument("--connect-timeout", type=float, default=20.0)
|
|
parser.add_argument("--retry-seconds", type=float, default=5.0)
|
|
parser.add_argument("--disk", default="C:\\")
|
|
parser.add_argument("--once", action="store_true")
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
try:
|
|
return asyncio.run(push_loop(args))
|
|
except KeyboardInterrupt:
|
|
return 130
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|