ESP32/tools/pc-metrics/push-metrics-ble.py
2026-06-24 21:01:33 +08:00

1151 lines
42 KiB
Python

import argparse
import asyncio
import ctypes
from datetime import date, datetime, timedelta
import json
import os
from pathlib import Path
import queue
import sqlite3
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"
DEFAULT_STATUS_CHAR_UUID = "9f4af548-1a14-4fc1-8f5a-4b9f00000003"
MAX_DISK_ROWS = 4
MAX_DISK_TEXT = 124
CODEX_DAILY_BAR_COUNT = 14
CODEX_REFRESH_SECONDS = 300.0
CCSWITCH_CODEX_REFRESH_SECONDS = 60.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))))
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))))
def daily_bars_from_token_map(tokens_by_day: dict[str, int], count: int) -> tuple[int, str]:
today = date.today()
wanted_days = [(today - timedelta(days=offset)).isoformat() for offset in range(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 = tokens_to_m10(tokens_by_day.get(today.isoformat(), 0))
return today_tokens_m10, ":".join(str(value) for value in percents)
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.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
class CcswitchCodexUsageReader:
DEDUP_WINDOW_SECONDS = 10 * 60
def __init__(self, refresh_seconds: float = CCSWITCH_CODEX_REFRESH_SECONDS) -> None:
self.refresh_seconds = refresh_seconds
self.next_refresh = 0.0
self.cached = (-1, "")
@staticmethod
def _candidate_paths() -> list[Path]:
paths: list[Path] = []
override = os.environ.get("CCSWITCH_DB_PATH", "").strip()
if override:
paths.append(Path(override).expanduser())
home = Path.home()
paths.append(home / ".cc-switch" / "cc-switch.db")
appdata = os.environ.get("APPDATA")
if appdata:
paths.append(Path(appdata) / "com.ccswitch.desktop" / "cc-switch.db")
local_appdata = os.environ.get("LOCALAPPDATA")
if local_appdata:
paths.append(Path(local_appdata) / "com.ccswitch.desktop" / "cc-switch.db")
unique: list[Path] = []
seen: set[str] = set()
for path in paths:
key = str(path).lower()
if key not in seen:
seen.add(key)
unique.append(path)
return unique
@classmethod
def _find_db(cls) -> Path | None:
for path in cls._candidate_paths():
if path.is_file():
return path
return None
@staticmethod
def _connect_readonly(path: Path) -> sqlite3.Connection:
sqlite_path = urllib.parse.quote(str(path).replace("\\", "/"), safe="/:")
con = sqlite3.connect(f"file:{sqlite_path}?mode=ro", uri=True, timeout=1.0)
con.row_factory = sqlite3.Row
return con
@classmethod
def _effective_filter_sql(cls) -> str:
return f"""
NOT (
COALESCE(l.data_source, 'proxy') IN ('session_log', 'codex_session', 'gemini_session', 'opencode_session')
AND EXISTS (
SELECT 1
FROM proxy_request_logs proxy_dedup
WHERE COALESCE(proxy_dedup.data_source, 'proxy') = 'proxy'
AND proxy_dedup.app_type = l.app_type
AND proxy_dedup.status_code >= 200
AND proxy_dedup.status_code < 300
AND proxy_dedup.input_tokens = l.input_tokens
AND proxy_dedup.output_tokens = l.output_tokens
AND proxy_dedup.cache_read_tokens = l.cache_read_tokens
AND (
proxy_dedup.cache_creation_tokens = l.cache_creation_tokens
OR (
l.cache_creation_tokens = 0
AND COALESCE(l.data_source, 'proxy') IN ('codex_session', 'gemini_session', 'opencode_session')
)
)
AND proxy_dedup.created_at BETWEEN
l.created_at - {cls.DEDUP_WINDOW_SECONDS}
AND l.created_at + {cls.DEDUP_WINDOW_SECONDS}
AND (
LOWER(proxy_dedup.model) = LOWER(l.model)
OR LOWER(proxy_dedup.model) = 'unknown'
OR LOWER(l.model) = 'unknown'
)
)
)
"""
def _query_daily_tokens(self, db_path: Path) -> dict[str, int]:
start_day = date.today() - timedelta(days=CODEX_DAILY_BAR_COUNT - 1)
start_ts = int(datetime.combine(start_day, datetime.min.time()).timestamp())
real_total_sql = """
(
CASE
WHEN COALESCE(l.input_tokens, 0) >= COALESCE(l.cache_read_tokens, 0)
THEN COALESCE(l.input_tokens, 0) - COALESCE(l.cache_read_tokens, 0)
ELSE COALESCE(l.input_tokens, 0)
END
+ COALESCE(l.output_tokens, 0)
+ COALESCE(l.cache_creation_tokens, 0)
+ COALESCE(l.cache_read_tokens, 0)
)
"""
sql = f"""
SELECT date(l.created_at, 'unixepoch', 'localtime') AS usage_day,
SUM({real_total_sql}) AS real_total_tokens
FROM proxy_request_logs l
WHERE l.app_type = 'codex'
AND l.status_code >= 200
AND l.status_code < 300
AND l.created_at >= ?
AND {self._effective_filter_sql()}
GROUP BY usage_day
"""
tokens_by_day: dict[str, int] = {}
with self._connect_readonly(db_path) as con:
for row in con.execute(sql, (start_ts,)):
usage_day = row["usage_day"]
tokens = row["real_total_tokens"]
if isinstance(usage_day, str) and isinstance(tokens, (int, float)) and tokens >= 0:
tokens_by_day[usage_day] = int(tokens)
return tokens_by_day
def read_usage_snapshot(self) -> tuple[int, str]:
now = time.monotonic()
if now < self.next_refresh:
return self.cached
db_path = self._find_db()
if db_path is None:
self.next_refresh = now + min(15.0, self.refresh_seconds)
return self.cached
try:
tokens_by_day = self._query_daily_tokens(db_path)
except sqlite3.Error:
self.next_refresh = now + min(15.0, self.refresh_seconds)
return self.cached
self.cached = daily_bars_from_token_map(tokens_by_day, CODEX_DAILY_BAR_COUNT)
self.next_refresh = now + self.refresh_seconds
return self.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.codex_usage = CcswitchCodexUsageReader()
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_usage.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)
def status_callback(_sender, data: bytearray):
text = bytes(data).decode("ascii", errors="replace").strip()
if not text:
return
if text.startswith("STA2,"):
parts = text.split(",")
if len(parts) >= 10:
try:
battery = int(parts[2])
battery_mv = int(parts[3])
temperature_c10 = int(parts[4])
humidity = int(parts[5])
writes = int(parts[6])
uptime = int(parts[7])
internal_heap_kb = int(parts[8])
psram_kb = int(parts[9])
print(
"BLE board: "
f"bat={battery}% {battery_mv}mV "
f"env={temperature_c10 / 10:.1f}C/{humidity}% "
f"writes={writes} up={uptime}s "
f"heap={internal_heap_kb}K psram={psram_kb}K",
flush=True,
)
return
except ValueError:
pass
print(f"BLE status: {text}", flush=True)
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)
try:
await client.start_notify(args.status_char_uuid, status_callback)
print("BLE status notify subscribed.", flush=True)
except Exception as exc:
print(f"BLE status notify unavailable: {exc}", file=sys.stderr, 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("--status-char-uuid", default=DEFAULT_STATUS_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())