1073 lines
37 KiB
Python
1073 lines
37 KiB
Python
"""硬件温度监控窗口。
|
||
|
||
数据来源(按优先级自动降级):
|
||
1. LibreHardwareMonitor / OpenHardwareMonitor 的 WMI 接口
|
||
—— 唯一能给出「每个 CPU 核心 / GPU / 主板 / 硬盘」真实温度的常规途径
|
||
2. nvidia-smi(NVIDIA 显卡)
|
||
3. 存储设备温度(原生 IOCTL,部分驱动支持)
|
||
4. psutil(仅 Linux/macOS 可用)
|
||
5. ACPI 热区(root\\WMI)—— 兜底,仅作参考
|
||
|
||
注意:Windows 用户态没有任何 API 能直接读到 CPU 核心温度(ThrottleStop / HWiNFO 之类
|
||
都靠内核驱动直接读 MSR)。因此 ACPI 热区的读数常常与真实核心温度相差极大,
|
||
本窗口把它单独归入「参考值」并明确标注,绝不冒充 CPU 核心温度。
|
||
|
||
窗口为独立非模态窗口,可与「解除占用」「微信多开」等窗口同时打开、自由切换。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import ctypes
|
||
import os
|
||
import subprocess
|
||
import time
|
||
from ctypes import wintypes
|
||
from typing import Any
|
||
|
||
import psutil
|
||
from PyQt6.QtCore import Qt, QThread, pyqtSignal
|
||
from PyQt6.QtWidgets import (
|
||
QApplication,
|
||
QFrame,
|
||
QHBoxLayout,
|
||
QLabel,
|
||
QMessageBox,
|
||
QProgressBar,
|
||
QPushButton,
|
||
QScrollArea,
|
||
QSizePolicy,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
|
||
from db import database
|
||
import ui.dialog_style as dialog_style
|
||
import ui.theme as theme
|
||
|
||
|
||
GROUP_ORDER = ("CPU", "GPU", "主板", "存储", "参考值", "其他")
|
||
|
||
GROUP_NOTES = {
|
||
"参考值": "ACPI 热区读数:由主板固件提供,通常不反映 CPU 核心温度(可能明显偏低),仅供对比参考。",
|
||
}
|
||
|
||
LHM_DOWNLOAD_URL = "https://github.com/LibreHardwareMonitor/LibreHardwareMonitor/releases"
|
||
|
||
# 自动刷新周期(毫秒):窗口打开期间每 3 秒重新采集一次
|
||
REFRESH_INTERVAL_MS = 3000
|
||
|
||
_CREATE_NO_WINDOW = 0x08000000
|
||
# ACPI / 存储的查询结果缓存时间(秒):与刷新周期一致,保证每次刷新的值都是最新的;
|
||
# 查询失败时用较长的 TTL,避免反复触发耗时的失败调用。
|
||
_ACPI_TTL_OK = 3.0
|
||
_ACPI_TTL_FAIL = 90.0
|
||
_STORAGE_TTL = 3.0
|
||
|
||
|
||
# ── 采集 ────────────────────────────────────────────────
|
||
def _field(obj: Any, name: str) -> Any:
|
||
"""大小写不敏感地读取 WMI 对象字段。"""
|
||
for key in (name, name.lower(), name.upper()):
|
||
try:
|
||
return getattr(obj, key)
|
||
except Exception:
|
||
continue
|
||
return None
|
||
|
||
|
||
def _classify(identifier: str, name: str) -> str:
|
||
s = f"{identifier} {name}".lower()
|
||
if any(k in s for k in ("gpu", "nvidia", "radeon", "graphics")):
|
||
return "GPU"
|
||
if any(k in s for k in ("cpu", "core", "package", "tctl", "tdie")):
|
||
return "CPU"
|
||
if any(k in s for k in ("nvme", "hdd", "ssd", "storage", "drive", "disk")):
|
||
return "存储"
|
||
if any(k in s for k in ("lpc", "mainboard", "motherboard", "superio", "nct")):
|
||
return "主板"
|
||
return "其他"
|
||
|
||
|
||
def _read_lhm() -> tuple[list[dict], str]:
|
||
"""LibreHardwareMonitor / OpenHardwareMonitor 暴露的 WMI 传感器。"""
|
||
try:
|
||
import wmi # type: ignore
|
||
except Exception:
|
||
return [], ""
|
||
|
||
namespaces = (
|
||
("root\\LibreHardwareMonitor", "LibreHardwareMonitor"),
|
||
("root\\OpenHardwareMonitor", "OpenHardwareMonitor"),
|
||
)
|
||
for ns, label in namespaces:
|
||
rows = None
|
||
try:
|
||
conn = wmi.WMI(namespace=ns)
|
||
try:
|
||
rows = conn.query(
|
||
"SELECT Name, Value, Identifier, SensorType FROM Sensor "
|
||
"WHERE SensorType='Temperature'"
|
||
)
|
||
except Exception:
|
||
# 个别版本/权限下不支持 WQL 过滤,退化为全量遍历
|
||
rows = conn.Sensor()
|
||
except Exception:
|
||
continue
|
||
if rows is None:
|
||
continue
|
||
|
||
out: list[dict] = []
|
||
for row in rows:
|
||
sensor_type = _field(row, "SensorType")
|
||
if sensor_type is not None and str(sensor_type).lower() != "temperature":
|
||
continue
|
||
try:
|
||
value = float(_field(row, "Value"))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
ident = str(_field(row, "Identifier") or "")
|
||
name = str(_field(row, "Name") or "").strip() or "传感器"
|
||
out.append(
|
||
{
|
||
"group": _classify(ident, name),
|
||
"name": name,
|
||
"value": value,
|
||
"trusted": True,
|
||
}
|
||
)
|
||
if out:
|
||
return out, label
|
||
return [], ""
|
||
|
||
|
||
def _find_nvidia_smi() -> str:
|
||
from shutil import which
|
||
|
||
exe = which("nvidia-smi")
|
||
if exe:
|
||
return exe
|
||
candidates = (
|
||
os.path.join(
|
||
os.environ.get("SystemRoot", r"C:\Windows"), "System32", "nvidia-smi.exe"
|
||
),
|
||
r"C:\Program Files\NVIDIA Corporation\NVSMI\nvidia-smi.exe",
|
||
)
|
||
for path in candidates:
|
||
if os.path.exists(path):
|
||
return path
|
||
return ""
|
||
|
||
|
||
def _read_nvidia() -> list[dict]:
|
||
exe = _find_nvidia_smi()
|
||
if not exe:
|
||
return []
|
||
try:
|
||
proc = subprocess.run(
|
||
[
|
||
exe,
|
||
"--query-gpu=index,name,temperature.gpu",
|
||
"--format=csv,noheader,nounits",
|
||
],
|
||
capture_output=True,
|
||
text=True,
|
||
timeout=4,
|
||
creationflags=_CREATE_NO_WINDOW if os.name == "nt" else 0,
|
||
)
|
||
except Exception:
|
||
return []
|
||
if proc.returncode != 0:
|
||
return []
|
||
|
||
out: list[dict] = []
|
||
for line in (proc.stdout or "").splitlines():
|
||
parts = [p.strip() for p in line.split(",")]
|
||
if len(parts) < 3:
|
||
continue
|
||
try:
|
||
value = float(parts[2])
|
||
except ValueError:
|
||
continue
|
||
name = parts[1] or f"GPU {parts[0]}"
|
||
out.append({"group": "GPU", "name": name, "value": value, "trusted": True})
|
||
return out
|
||
|
||
|
||
_acpi_cache: dict[str, Any] = {"ts": 0.0, "data": [], "ok": False}
|
||
|
||
|
||
def _read_acpi() -> list[dict]:
|
||
"""ACPI 热区,精度一般且通常需要管理员权限。"""
|
||
try:
|
||
import wmi # type: ignore
|
||
except Exception:
|
||
return []
|
||
try:
|
||
conn = wmi.WMI(namespace="root\\WMI")
|
||
zones = conn.MSAcpi_ThermalZoneTemperature()
|
||
except Exception:
|
||
return []
|
||
|
||
out: list[dict] = []
|
||
for i, zone in enumerate(zones):
|
||
try:
|
||
raw = float(_field(zone, "CurrentTemperature"))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
celsius = raw / 10.0 - 273.15
|
||
if not -40.0 <= celsius <= 150.0:
|
||
continue
|
||
inst = str(_field(zone, "InstanceName") or "")
|
||
tag = inst.rsplit("\\", 1)[-1] if "\\" in inst else f"ZONE{i + 1}"
|
||
out.append(
|
||
{
|
||
"group": "参考值",
|
||
"name": f"ACPI 热区 {tag}",
|
||
"value": celsius,
|
||
"trusted": False,
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def _read_acpi_cached() -> list[dict]:
|
||
now = time.monotonic()
|
||
ttl = _ACPI_TTL_OK if _acpi_cache["ok"] else _ACPI_TTL_FAIL
|
||
if _acpi_cache["ts"] and now - _acpi_cache["ts"] < ttl:
|
||
return list(_acpi_cache["data"])
|
||
data = _read_acpi()
|
||
_acpi_cache.update({"ts": now, "data": data, "ok": bool(data)})
|
||
return list(data)
|
||
|
||
|
||
# ── 存储设备温度(原生 IOCTL,不需要第三方软件) ────────────
|
||
_IOCTL_STORAGE_QUERY_PROPERTY = 0x2D1400
|
||
_STORAGE_DEVICE_TEMPERATURE_PROPERTY = 0x2D
|
||
_PROPERTY_STANDARD_QUERY = 0
|
||
_INVALID_HANDLE = ctypes.c_void_p(-1).value
|
||
|
||
_kernel32: Any = None
|
||
|
||
|
||
class _STORAGE_PROPERTY_QUERY(ctypes.Structure):
|
||
_fields_ = [
|
||
("PropertyId", ctypes.c_uint32),
|
||
("QueryType", ctypes.c_uint32),
|
||
("AdditionalParameters", ctypes.c_ubyte * 8),
|
||
]
|
||
|
||
|
||
class _STORAGE_TEMPERATURE_INFO(ctypes.Structure):
|
||
_fields_ = [
|
||
("Index", ctypes.c_ushort),
|
||
("Temperature", ctypes.c_short),
|
||
("OverThreshold", ctypes.c_short),
|
||
("UnderThreshold", ctypes.c_short),
|
||
("OverThresholdChangable", ctypes.c_ubyte),
|
||
("UnderThresholdChangable", ctypes.c_ubyte),
|
||
("EventGenerated", ctypes.c_ubyte),
|
||
("Reserved0", ctypes.c_ubyte),
|
||
("Reserved1", ctypes.c_uint32),
|
||
]
|
||
|
||
|
||
class _STORAGE_TEMPERATURE_DESCRIPTOR(ctypes.Structure):
|
||
_fields_ = [
|
||
("Version", ctypes.c_uint32),
|
||
("Size", ctypes.c_uint32),
|
||
("CriticalTemperature", ctypes.c_ushort),
|
||
("WarningTemperature", ctypes.c_ushort),
|
||
("InfoCount", ctypes.c_ushort),
|
||
("Reserved0", ctypes.c_ubyte * 2),
|
||
("Info", _STORAGE_TEMPERATURE_INFO * 8),
|
||
]
|
||
|
||
|
||
def _get_kernel32() -> Any:
|
||
global _kernel32
|
||
if _kernel32 is None:
|
||
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||
k32.CreateFileW.argtypes = [
|
||
wintypes.LPCWSTR,
|
||
wintypes.DWORD,
|
||
wintypes.DWORD,
|
||
wintypes.LPVOID,
|
||
wintypes.DWORD,
|
||
wintypes.DWORD,
|
||
wintypes.HANDLE,
|
||
]
|
||
k32.CreateFileW.restype = wintypes.HANDLE
|
||
k32.DeviceIoControl.argtypes = [
|
||
wintypes.HANDLE,
|
||
wintypes.DWORD,
|
||
wintypes.LPVOID,
|
||
wintypes.DWORD,
|
||
wintypes.LPVOID,
|
||
wintypes.DWORD,
|
||
ctypes.POINTER(wintypes.DWORD),
|
||
wintypes.LPVOID,
|
||
]
|
||
k32.DeviceIoControl.restype = wintypes.BOOL
|
||
k32.CloseHandle.argtypes = [wintypes.HANDLE]
|
||
k32.CloseHandle.restype = wintypes.BOOL
|
||
_kernel32 = k32
|
||
return _kernel32
|
||
|
||
|
||
def _disk_temperatures(index: int) -> list[float]:
|
||
"""读取某个物理磁盘的温度(首个为复合温度)。驱动不支持时返回空列表。"""
|
||
if os.name != "nt":
|
||
return []
|
||
try:
|
||
k32 = _get_kernel32()
|
||
handle = k32.CreateFileW(f"\\\\.\\PhysicalDrive{index}", 0, 3, None, 3, 0, None)
|
||
if not handle or handle == _INVALID_HANDLE:
|
||
return []
|
||
try:
|
||
query = _STORAGE_PROPERTY_QUERY()
|
||
query.PropertyId = _STORAGE_DEVICE_TEMPERATURE_PROPERTY
|
||
query.QueryType = _PROPERTY_STANDARD_QUERY
|
||
desc = _STORAGE_TEMPERATURE_DESCRIPTOR()
|
||
returned = wintypes.DWORD(0)
|
||
ok = k32.DeviceIoControl(
|
||
handle,
|
||
_IOCTL_STORAGE_QUERY_PROPERTY,
|
||
ctypes.byref(query),
|
||
ctypes.sizeof(query),
|
||
ctypes.byref(desc),
|
||
ctypes.sizeof(desc),
|
||
ctypes.byref(returned),
|
||
None,
|
||
)
|
||
if not ok:
|
||
return []
|
||
temps: list[float] = []
|
||
for i in range(min(int(desc.InfoCount), 8)):
|
||
value = float(desc.Info[i].Temperature)
|
||
if -40.0 <= value <= 150.0:
|
||
temps.append(value)
|
||
return temps
|
||
finally:
|
||
k32.CloseHandle(handle)
|
||
except Exception:
|
||
return []
|
||
|
||
|
||
_storage_cache: dict[str, Any] = {"ts": 0.0, "data": []}
|
||
|
||
|
||
def _read_storage_cached() -> list[dict]:
|
||
now = time.monotonic()
|
||
if _storage_cache["ts"] and now - _storage_cache["ts"] < _STORAGE_TTL:
|
||
return list(_storage_cache["data"])
|
||
|
||
out: list[dict] = []
|
||
for index in range(8):
|
||
temps = _disk_temperatures(index)
|
||
if not temps:
|
||
continue
|
||
out.append(
|
||
{
|
||
"group": "存储",
|
||
"name": f"磁盘 {index}",
|
||
"value": temps[0],
|
||
"trusted": True,
|
||
}
|
||
)
|
||
for i, value in enumerate(temps[1:], start=1):
|
||
out.append(
|
||
{
|
||
"group": "存储",
|
||
"name": f"磁盘 {index} 传感器 {i}",
|
||
"value": value,
|
||
"trusted": True,
|
||
}
|
||
)
|
||
_storage_cache.update({"ts": now, "data": out})
|
||
return list(out)
|
||
|
||
|
||
def _read_psutil() -> list[dict]:
|
||
getter = getattr(psutil, "sensors_temperatures", None)
|
||
if getter is None:
|
||
return []
|
||
try:
|
||
data = getter() or {}
|
||
except Exception:
|
||
return []
|
||
|
||
out: list[dict] = []
|
||
for chip, entries in data.items():
|
||
for entry in entries:
|
||
label = str(getattr(entry, "label", "") or chip)
|
||
try:
|
||
value = float(entry.current)
|
||
except Exception:
|
||
continue
|
||
out.append(
|
||
{
|
||
"group": _classify(f"{chip} {label}", label),
|
||
"name": label,
|
||
"value": value,
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def collect_temperatures() -> tuple[list[dict], str]:
|
||
"""返回 (传感器列表, 数据源描述)。任何来源失败都静默降级。
|
||
|
||
ACPI 热区仅在完全没有可信来源时才作为「参考值」出现,绝不冒充 CPU 核心温度。
|
||
"""
|
||
sensors: list[dict] = []
|
||
sources: list[str] = []
|
||
|
||
def _add(items: list[dict], label: str):
|
||
if items:
|
||
sensors.extend(items)
|
||
sources.append(label)
|
||
|
||
try:
|
||
lhm, lhm_label = _read_lhm()
|
||
except Exception:
|
||
lhm, lhm_label = [], ""
|
||
_add(lhm, lhm_label)
|
||
|
||
if not sensors:
|
||
try:
|
||
_add(_read_psutil(), "psutil")
|
||
except Exception:
|
||
pass
|
||
|
||
if not any(s["group"] == "GPU" for s in sensors):
|
||
try:
|
||
_add(_read_nvidia(), "nvidia-smi")
|
||
except Exception:
|
||
pass
|
||
|
||
if not any(s["group"] == "存储" for s in sensors):
|
||
try:
|
||
_add(_read_storage_cached(), "存储设备")
|
||
except Exception:
|
||
pass
|
||
|
||
if not any(s.get("trusted") for s in sensors):
|
||
try:
|
||
_add(_read_acpi_cached(), "ACPI 热区(仅供参考)")
|
||
except Exception:
|
||
pass
|
||
|
||
seen: set[tuple[str, str]] = set()
|
||
unique: list[dict] = []
|
||
for item in sensors:
|
||
key = (item["group"], item["name"])
|
||
if key in seen:
|
||
continue
|
||
seen.add(key)
|
||
item.setdefault("trusted", True)
|
||
unique.append(item)
|
||
|
||
unique.sort(
|
||
key=lambda s: (
|
||
GROUP_ORDER.index(s["group"]) if s["group"] in GROUP_ORDER else len(GROUP_ORDER),
|
||
s["name"],
|
||
)
|
||
)
|
||
return unique, "、".join(sources)
|
||
|
||
|
||
# ── 采集线程 ─────────────────────────────────────────────
|
||
class _TempWorker(QThread):
|
||
data = pyqtSignal(list, str)
|
||
|
||
def __init__(
|
||
self, interval_ms: int = REFRESH_INTERVAL_MS, parent: QWidget | None = None
|
||
):
|
||
super().__init__(parent)
|
||
self._interval_ms = max(500, int(interval_ms))
|
||
|
||
def run(self):
|
||
while not self.isInterruptionRequested():
|
||
started = time.monotonic()
|
||
try:
|
||
sensors, source = collect_temperatures()
|
||
except Exception:
|
||
sensors, source = [], ""
|
||
if self.isInterruptionRequested():
|
||
break
|
||
self.data.emit(sensors, source)
|
||
|
||
# 扣除采集耗时,使刷新节奏尽量贴近设定周期
|
||
elapsed_ms = int((time.monotonic() - started) * 1000)
|
||
remain = max(200, self._interval_ms - elapsed_ms)
|
||
while remain > 0 and not self.isInterruptionRequested():
|
||
step = min(200, remain)
|
||
self.msleep(step)
|
||
remain -= step
|
||
|
||
|
||
class _InstallWorker(QThread):
|
||
"""后台下载并解压温度采集组件(LibreHardwareMonitor)。"""
|
||
|
||
progress = pyqtSignal(int, int)
|
||
done = pyqtSignal(str)
|
||
failed = pyqtSignal(str)
|
||
|
||
def run(self):
|
||
try:
|
||
from ui.hwmon_tool import install
|
||
|
||
exe = install(
|
||
progress=lambda got, total: self.progress.emit(got, total),
|
||
is_cancelled=self.isInterruptionRequested,
|
||
)
|
||
except Exception as exc: # noqa: BLE001
|
||
self.failed.emit(str(exc))
|
||
return
|
||
if self.isInterruptionRequested():
|
||
return
|
||
self.done.emit(exe)
|
||
|
||
|
||
def _temp_color(value: float) -> str:
|
||
if value < 50:
|
||
return "#3fb950"
|
||
if value < 65:
|
||
return "#d29922"
|
||
if value < 80:
|
||
return "#f0883e"
|
||
return "#f85149"
|
||
|
||
|
||
def _solid(color: str) -> str:
|
||
"""把 rgba(...) 转成不透明 rgb(...),避免顶层窗口半透明背景异常。"""
|
||
c = (color or "").strip()
|
||
if c.startswith("rgba"):
|
||
return c.replace("rgba", "rgb", 1).rsplit(",", 1)[0] + ")"
|
||
return c
|
||
|
||
|
||
# ── 组件 ────────────────────────────────────────────────
|
||
class _SensorRow(QWidget):
|
||
"""单个传感器:名称 + 温度条 + 数值。"""
|
||
|
||
def __init__(
|
||
self, name: str, trusted: bool = True, parent: QWidget | None = None
|
||
):
|
||
super().__init__(parent)
|
||
self._trusted = trusted
|
||
lay = QHBoxLayout(self)
|
||
lay.setContentsMargins(0, 0, 0, 0)
|
||
lay.setSpacing(10)
|
||
|
||
self._name_lbl = QLabel(name)
|
||
self._name_lbl.setMinimumWidth(80)
|
||
self._name_lbl.setSizePolicy(
|
||
QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Preferred
|
||
)
|
||
self._name_lbl.setToolTip(name)
|
||
self._name_lbl.setStyleSheet("background:transparent;")
|
||
|
||
self._bar = QProgressBar()
|
||
self._bar.setRange(0, 100)
|
||
self._bar.setTextVisible(False)
|
||
self._bar.setFixedSize(170, 8)
|
||
|
||
self._value_lbl = QLabel("--")
|
||
self._value_lbl.setFixedWidth(62)
|
||
self._value_lbl.setAlignment(
|
||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
||
)
|
||
|
||
lay.addWidget(self._name_lbl, 1)
|
||
lay.addWidget(self._bar)
|
||
lay.addWidget(self._value_lbl)
|
||
|
||
def update_value(self, value: float):
|
||
# 参考值(如 ACPI 热区)用灰色 + "≈" 标注,避免被误读为 CPU 核心温度
|
||
if self._trusted:
|
||
color = _temp_color(value)
|
||
text = f"{value:.0f} °C"
|
||
else:
|
||
color = "#8b949e"
|
||
text = f"≈{value:.0f} °C"
|
||
self._bar.setValue(max(0, min(100, int(round(value)))))
|
||
self._bar.setStyleSheet(
|
||
"QProgressBar { background: rgba(128,128,128,45); border:none;"
|
||
" border-radius:4px; }"
|
||
f"QProgressBar::chunk {{ background: {color}; border-radius:4px; }}"
|
||
)
|
||
self._value_lbl.setText(text)
|
||
self._value_lbl.setStyleSheet(
|
||
f"color:{color}; font-weight:bold; background:transparent;"
|
||
)
|
||
|
||
|
||
class _GroupCard(QFrame):
|
||
"""按类别(CPU/GPU/...)聚合的卡片。"""
|
||
|
||
def __init__(self, title: str, note: str = "", parent: QWidget | None = None):
|
||
super().__init__(parent)
|
||
self.setObjectName("temp_card")
|
||
lay = QVBoxLayout(self)
|
||
lay.setContentsMargins(12, 10, 12, 12)
|
||
lay.setSpacing(8)
|
||
|
||
self._base_title = title
|
||
self._title_lbl = QLabel(title)
|
||
self._title_lbl.setObjectName("temp_card_title")
|
||
lay.addWidget(self._title_lbl)
|
||
|
||
if note:
|
||
note_lbl = QLabel(note)
|
||
note_lbl.setObjectName("temp_card_note")
|
||
note_lbl.setWordWrap(True)
|
||
lay.addWidget(note_lbl)
|
||
|
||
self._rows_box = QVBoxLayout()
|
||
self._rows_box.setContentsMargins(0, 0, 0, 0)
|
||
self._rows_box.setSpacing(6)
|
||
lay.addLayout(self._rows_box)
|
||
|
||
self._rows: dict[str, _SensorRow] = {}
|
||
|
||
def sync(self, items: list[dict]):
|
||
names = [str(it["name"]) for it in items]
|
||
if names != list(self._rows.keys()):
|
||
self._clear_rows()
|
||
for item in items:
|
||
row = _SensorRow(str(item["name"]), bool(item.get("trusted", True)))
|
||
self._rows_box.addWidget(row)
|
||
self._rows[str(item["name"])] = row
|
||
|
||
hottest = 0.0
|
||
for item in items:
|
||
value = float(item["value"])
|
||
hottest = max(hottest, value)
|
||
row = self._rows.get(str(item["name"]))
|
||
if row is not None:
|
||
row.update_value(value)
|
||
|
||
self._title_lbl.setText(f"{self._base_title} 最高 {hottest:.0f} °C")
|
||
|
||
def _clear_rows(self):
|
||
while self._rows_box.count():
|
||
item = self._rows_box.takeAt(0)
|
||
w = item.widget()
|
||
if w is not None:
|
||
w.setParent(None)
|
||
w.deleteLater()
|
||
self._rows.clear()
|
||
|
||
|
||
class TemperatureWindow(QWidget):
|
||
"""硬件温度窗口:非模态、可与其他功能窗口同时打开并自由切换。"""
|
||
|
||
def __init__(self, panel: QWidget | None = None):
|
||
super().__init__(panel)
|
||
self._panel = panel
|
||
self.setWindowTitle("硬件温度")
|
||
self.setMinimumSize(460, 400)
|
||
self.resize(540, 540)
|
||
# 独立顶层窗口 + 非模态:不阻塞其它窗口、可自由切换
|
||
self.setWindowFlags(
|
||
Qt.WindowType.Window
|
||
| Qt.WindowType.WindowTitleHint
|
||
| Qt.WindowType.WindowSystemMenuHint
|
||
| Qt.WindowType.WindowMinimizeButtonHint
|
||
| Qt.WindowType.WindowCloseButtonHint
|
||
)
|
||
self._worker: _TempWorker | None = None
|
||
self._install_worker: _InstallWorker | None = None
|
||
self._cards: dict[str, _GroupCard] = {}
|
||
self._auto_launch_done = False
|
||
self._build_ui()
|
||
self._apply_theme()
|
||
|
||
# 退出程序时确保采集/下载线程已停止,避免残留线程
|
||
app = QApplication.instance()
|
||
if app is not None:
|
||
app.aboutToQuit.connect(self._stop_worker)
|
||
app.aboutToQuit.connect(self._stop_install_worker)
|
||
|
||
# ── UI ──────────────────────────────────────────────
|
||
def _build_ui(self):
|
||
root = QVBoxLayout(self)
|
||
root.setContentsMargins(16, 14, 16, 14)
|
||
root.setSpacing(10)
|
||
|
||
head = QHBoxLayout()
|
||
head.setContentsMargins(0, 0, 0, 0)
|
||
head.setSpacing(8)
|
||
|
||
title = QLabel("硬件温度")
|
||
title.setStyleSheet("font-size:14px; font-weight:bold; background:transparent;")
|
||
self._status_lbl = QLabel("正在读取传感器…")
|
||
self._status_lbl.setObjectName("temp_sub")
|
||
|
||
self._refresh_btn = QPushButton("刷新")
|
||
self._refresh_btn.setFixedHeight(28)
|
||
self._refresh_btn.clicked.connect(self._restart_worker)
|
||
|
||
self._close_btn = QPushButton("关闭")
|
||
self._close_btn.setFixedHeight(28)
|
||
self._close_btn.clicked.connect(self.close)
|
||
|
||
head.addWidget(title)
|
||
head.addWidget(self._status_lbl)
|
||
head.addStretch()
|
||
head.addWidget(self._refresh_btn)
|
||
head.addWidget(self._close_btn)
|
||
root.addLayout(head)
|
||
|
||
self._scroll = QScrollArea()
|
||
self._scroll.setWidgetResizable(True)
|
||
self._scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||
self._scroll.setHorizontalScrollBarPolicy(
|
||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||
)
|
||
|
||
host = QWidget()
|
||
host.setObjectName("temp_host")
|
||
host_layout = QVBoxLayout(host)
|
||
host_layout.setContentsMargins(0, 0, 0, 0)
|
||
host_layout.setSpacing(8)
|
||
|
||
self._cards_box = QVBoxLayout()
|
||
self._cards_box.setContentsMargins(0, 0, 0, 0)
|
||
self._cards_box.setSpacing(8)
|
||
host_layout.addLayout(self._cards_box)
|
||
|
||
self._empty_lbl = QLabel(
|
||
"暂未读取到任何温度数据。\n\n"
|
||
"Windows 自身不提供 CPU 核心温度接口,需要由能直读 MSR 的程序提供数据:"
|
||
"安装并以管理员身份运行 LibreHardwareMonitor 后,本窗口会自动读到"
|
||
"CPU 各核心、GPU、主板、硬盘的温度。"
|
||
)
|
||
self._empty_lbl.setWordWrap(True)
|
||
self._empty_lbl.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||
host_layout.addWidget(self._empty_lbl)
|
||
host_layout.addStretch()
|
||
self._scroll.setWidget(host)
|
||
root.addWidget(self._scroll, 1)
|
||
|
||
bottom = QHBoxLayout()
|
||
bottom.setContentsMargins(0, 0, 0, 0)
|
||
bottom.setSpacing(8)
|
||
|
||
self._hint_lbl = QLabel("数据源:检测中…")
|
||
self._hint_lbl.setObjectName("temp_sub")
|
||
self._hint_lbl.setWordWrap(True)
|
||
|
||
self._guide_btn = QPushButton("说明")
|
||
self._guide_btn.setFixedHeight(28)
|
||
self._guide_btn.setVisible(False)
|
||
self._guide_btn.clicked.connect(self._show_guide)
|
||
|
||
self._integrate_btn = QPushButton("一键集成温度读取")
|
||
self._integrate_btn.setFixedHeight(28)
|
||
self._integrate_btn.setVisible(False)
|
||
self._integrate_btn.clicked.connect(self._on_integrate)
|
||
|
||
bottom.addWidget(self._hint_lbl, 1)
|
||
bottom.addWidget(self._guide_btn)
|
||
bottom.addWidget(self._integrate_btn)
|
||
root.addLayout(bottom)
|
||
|
||
def _apply_theme(self):
|
||
t = theme.current()
|
||
is_dark = theme.name() == "dark"
|
||
txt = t.get("search_color", "#eee")
|
||
sub = "#888" if is_dark else "#666"
|
||
border = t.get("menu_border", t.get("panel_border", "rgba(128,128,128,60)"))
|
||
inp_bg = t.get("search_bg", "rgba(0,0,0,20)")
|
||
hover = t.get("header_hover", "rgba(128,128,128,40)")
|
||
bg = _solid(t.get("menu_bg", "rgba(30,30,30,235)"))
|
||
line = t.get("search_border", border)
|
||
|
||
self.setStyleSheet(
|
||
f"""
|
||
QWidget {{
|
||
background: {bg};
|
||
color: {txt};
|
||
font-size: 12px;
|
||
}}
|
||
QWidget#temp_host, QScrollArea, QScrollArea > QWidget > QWidget {{
|
||
background: transparent;
|
||
}}
|
||
QFrame#temp_card {{
|
||
background: {inp_bg};
|
||
border: 1px solid {line};
|
||
border-radius: 10px;
|
||
}}
|
||
QFrame#temp_card QLabel {{ background: transparent; }}
|
||
QLabel#temp_card_title {{ font-size: 12px; font-weight: bold; }}
|
||
QLabel#temp_card_note {{ color: {sub}; font-size: 11px; }}
|
||
QLabel#temp_sub {{ color: {sub}; background: transparent; }}
|
||
QPushButton {{
|
||
background: {inp_bg};
|
||
color: {txt};
|
||
border: 1px solid {line};
|
||
border-radius: 6px;
|
||
padding: 4px 14px;
|
||
}}
|
||
QPushButton:hover {{ border-color: #4a9eff; color: #4a9eff; }}
|
||
QScrollBar:vertical {{
|
||
width: 8px; background: transparent; margin: 0;
|
||
}}
|
||
QScrollBar::handle:vertical {{
|
||
background: {t.get('scrollbar', 'rgba(128,128,128,80)')};
|
||
border-radius: 4px; min-height: 26px;
|
||
}}
|
||
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0px; }}
|
||
QScrollBar::add-page, QScrollBar::sub-page {{ background: transparent; }}
|
||
"""
|
||
)
|
||
|
||
# ── 生命周期 ─────────────────────────────────────────
|
||
def showEvent(self, event):
|
||
super().showEvent(event)
|
||
self._apply_theme()
|
||
self._start_worker()
|
||
self._maybe_auto_launch_sensors()
|
||
|
||
def hideEvent(self, event):
|
||
super().hideEvent(event)
|
||
self._stop_worker()
|
||
self._stop_install_worker()
|
||
|
||
def closeEvent(self, event):
|
||
self._stop_worker()
|
||
self._stop_install_worker()
|
||
super().closeEvent(event)
|
||
|
||
# ── 数据 ────────────────────────────────────────────
|
||
def _start_worker(self):
|
||
if self._worker is not None and self._worker.isRunning():
|
||
return
|
||
self._worker = _TempWorker(REFRESH_INTERVAL_MS, self)
|
||
self._worker.data.connect(self._on_data)
|
||
self._worker.start()
|
||
|
||
def _stop_worker(self):
|
||
worker = self._worker
|
||
self._worker = None
|
||
if worker is None:
|
||
return
|
||
try:
|
||
worker.requestInterruption()
|
||
if not worker.wait(3000):
|
||
worker.terminate()
|
||
worker.wait(800)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
worker.deleteLater()
|
||
except Exception:
|
||
pass
|
||
|
||
def _restart_worker(self):
|
||
self._stop_worker()
|
||
self._status_lbl.setText("正在读取传感器…")
|
||
self._start_worker()
|
||
|
||
def _on_data(self, sensors: list, source: str):
|
||
self._status_lbl.setText(
|
||
f"每 {REFRESH_INTERVAL_MS // 1000} 秒自动刷新 · 更新于 "
|
||
f"{time.strftime('%H:%M:%S')}"
|
||
)
|
||
|
||
grouped: dict[str, list[dict]] = {}
|
||
for item in sensors:
|
||
grouped.setdefault(str(item["group"]), []).append(item)
|
||
|
||
for group in GROUP_ORDER:
|
||
items = grouped.get(group, [])
|
||
card = self._cards.get(group)
|
||
if not items:
|
||
if card is not None:
|
||
card.setVisible(False)
|
||
continue
|
||
if card is None:
|
||
card = _GroupCard(group, GROUP_NOTES.get(group, ""))
|
||
self._cards_box.addWidget(card)
|
||
self._cards[group] = card
|
||
card.setVisible(True)
|
||
card.sync(items)
|
||
|
||
has_data = bool(sensors)
|
||
has_trusted = any(item.get("trusted", True) for item in sensors)
|
||
self._empty_lbl.setVisible(not has_data)
|
||
self._guide_btn.setVisible(not has_trusted)
|
||
self._integrate_btn.setVisible(not has_trusted)
|
||
if not has_trusted:
|
||
self._integrate_btn.setEnabled(self._install_worker is None)
|
||
|
||
if not has_data:
|
||
self._hint_lbl.setStyleSheet("")
|
||
self._hint_lbl.setText(
|
||
"未检测到任何可用的温度数据源。点击「一键集成温度读取」自动获取并启动"
|
||
"开源组件 LibreHardwareMonitor,即可读到每个 CPU 核心、GPU、硬盘温度。"
|
||
)
|
||
elif not has_trusted:
|
||
# 只有 ACPI 之类参考值:明确提示不可当作 CPU 核心温度
|
||
self._hint_lbl.setStyleSheet("color:#d29922; background:transparent;")
|
||
self._hint_lbl.setText(
|
||
f"数据源:{source or '未知'}。未检测到 CPU/GPU 核心温度来源,"
|
||
"以上数值仅供参考。点击「一键集成温度读取」可自动获取真实传感器数据。"
|
||
)
|
||
else:
|
||
self._hint_lbl.setStyleSheet("")
|
||
self._hint_lbl.setText(f"数据源:{source or '未知'}")
|
||
|
||
# ── 一键集成温度数据源 ────────────────────────────────
|
||
def _on_integrate(self):
|
||
from ui.hwmon_tool import find_exe, is_running
|
||
|
||
if is_running():
|
||
self._hint_lbl.setStyleSheet("color:#4a9eff; background:transparent;")
|
||
self._hint_lbl.setText(
|
||
"温度采集组件正在运行,传感器数据会在几秒内自动出现;"
|
||
"若长时间没有核心温度,请检查其是否以管理员身份运行。"
|
||
)
|
||
return
|
||
if self._install_worker is not None:
|
||
return
|
||
exe = find_exe()
|
||
if exe:
|
||
self._launch_sensors(exe)
|
||
else:
|
||
self._start_install()
|
||
|
||
def _start_install(self):
|
||
self._integrate_btn.setEnabled(False)
|
||
self._integrate_btn.setText("正在下载…")
|
||
self._hint_lbl.setStyleSheet("color:#4a9eff; background:transparent;")
|
||
self._hint_lbl.setText("正在从 GitHub 下载开源组件 LibreHardwareMonitor…")
|
||
|
||
worker = _InstallWorker(self)
|
||
worker.progress.connect(self._on_install_progress)
|
||
worker.done.connect(self._on_install_done)
|
||
worker.failed.connect(self._on_install_failed)
|
||
self._install_worker = worker
|
||
worker.start()
|
||
|
||
def _on_install_progress(self, got: int, total: int):
|
||
if total > 0:
|
||
percent = int(got * 100 / total)
|
||
self._integrate_btn.setText(f"下载中 {percent}%")
|
||
self._hint_lbl.setText(
|
||
f"正在下载 LibreHardwareMonitor… {percent}%({got // 1024} KB / {total // 1024} KB)"
|
||
)
|
||
else:
|
||
self._integrate_btn.setText("正在下载…")
|
||
self._hint_lbl.setText(f"正在下载 LibreHardwareMonitor… {got // 1024} KB")
|
||
|
||
def _on_install_done(self, exe: str):
|
||
self._stop_install_worker()
|
||
self._integrate_btn.setText("一键集成温度读取")
|
||
if self.isVisible():
|
||
self._launch_sensors(exe)
|
||
|
||
def _on_install_failed(self, message: str):
|
||
self._stop_install_worker()
|
||
self._integrate_btn.setText("一键集成温度读取")
|
||
self._integrate_btn.setEnabled(True)
|
||
self._hint_lbl.setStyleSheet("color:#d29922; background:transparent;")
|
||
self._hint_lbl.setText(f"自动获取组件失败:{message}")
|
||
ret = dialog_style.question(
|
||
self,
|
||
"自动获取失败",
|
||
f"下载或安装温度采集组件失败:\n{message}\n\n"
|
||
"是否打开官方下载页手动获取?(手动解压后放进程序同级目录也能被识别)",
|
||
)
|
||
if ret == QMessageBox.StandardButton.Yes:
|
||
import webbrowser
|
||
|
||
webbrowser.open(LHM_DOWNLOAD_URL)
|
||
|
||
def _launch_sensors(self, exe: str):
|
||
from ui.hwmon_tool import launch
|
||
|
||
ok, err = launch(exe)
|
||
if ok:
|
||
# 记住用户的选择,之后打开窗口时自动拉起(仅一次,避免反复弹 UAC)
|
||
try:
|
||
database.set_setting("temp_auto_start_sensors", "1")
|
||
except Exception:
|
||
pass
|
||
self._hint_lbl.setStyleSheet("color:#4a9eff; background:transparent;")
|
||
self._hint_lbl.setText(
|
||
"已启动 LibreHardwareMonitor(请在 UAC 弹窗中允许)。"
|
||
"首次运行它可能还会提示安装硬件访问驱动,按提示确认即可;"
|
||
"传感器数据将在几秒内自动出现。"
|
||
)
|
||
else:
|
||
dialog_style.warning(
|
||
self,
|
||
"启动失败",
|
||
f"无法启动温度采集组件:{err}\n\n可手动以管理员身份运行:\n{exe}",
|
||
)
|
||
|
||
def _stop_install_worker(self):
|
||
worker = self._install_worker
|
||
self._install_worker = None
|
||
if worker is None:
|
||
return
|
||
try:
|
||
worker.requestInterruption()
|
||
if not worker.wait(3000):
|
||
worker.terminate()
|
||
worker.wait(800)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
worker.deleteLater()
|
||
except Exception:
|
||
pass
|
||
|
||
def _maybe_auto_launch_sensors(self):
|
||
"""曾集成过的用户:打开窗口时自动拉起组件,省去每次手动启动。"""
|
||
if self._auto_launch_done or self._install_worker is not None:
|
||
return
|
||
try:
|
||
if str(database.get_setting("temp_auto_start_sensors", "0")) != "1":
|
||
return
|
||
except Exception:
|
||
return
|
||
self._auto_launch_done = True
|
||
from ui.hwmon_tool import find_exe, is_running, launch
|
||
|
||
if is_running():
|
||
return
|
||
exe = find_exe()
|
||
if exe:
|
||
launch(exe)
|
||
|
||
def _show_guide(self):
|
||
box = QMessageBox(self)
|
||
box.setWindowTitle("如何读取真实的核心温度")
|
||
box.setIcon(QMessageBox.Icon.Information)
|
||
box.setTextFormat(Qt.TextFormat.PlainText)
|
||
box.setText(
|
||
"Windows 自身不提供 CPU 核心温度接口。\n"
|
||
"ThrottleStop、HWiNFO、Core Temp 之类的工具之所以能显示每个核心的温度,"
|
||
"是因为它们加载了内核驱动,直接读取 CPU 的 MSR 温度寄存器。\n\n"
|
||
"点击「一键集成温度读取」后,本程序会自动完成:\n"
|
||
"1. 从官方 GitHub 下载开源组件 LibreHardwareMonitor(约 6 MB,装到用户目录,不写系统目录)\n"
|
||
"2. 以管理员身份启动它(读取硬件需要权限,会弹一次 UAC;首次运行它可能还会提示安装"
|
||
"硬件访问驱动,按提示确认即可)\n"
|
||
"3. 之后本窗口每 3 秒自动读取它暴露的传感器;以后再打开窗口会自动拉起组件,无需重复操作\n\n"
|
||
"本窗口自动读取的来源:\n"
|
||
"· LibreHardwareMonitor / OpenHardwareMonitor:每个 CPU 核心、GPU、主板、硬盘温度\n"
|
||
"· nvidia-smi:NVIDIA 显卡温度\n"
|
||
"· 存储设备:部分驱动支持直接读取磁盘温度"
|
||
)
|
||
open_btn = box.addButton("打开下载页", QMessageBox.ButtonRole.AcceptRole)
|
||
box.addButton("知道了", QMessageBox.ButtonRole.RejectRole)
|
||
box.setStyleSheet(dialog_style.stylesheet())
|
||
box.exec()
|
||
if box.clickedButton() is open_btn:
|
||
import webbrowser
|
||
|
||
webbrowser.open(LHM_DOWNLOAD_URL)
|