Files
niumasoftware/ui/temperature.py
T
2026-09-23 16:51:40 +08:00

1073 lines
37 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""硬件温度监控窗口。
数据来源(按优先级自动降级):
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)