"""硬件温度监控窗口。 数据来源(按优先级自动降级): 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)