diff --git a/CHANGELOG.md b/CHANGELOG.md index c417d82..d5e0151 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ ## [Unreleased] ### Добавлено +- **Загрузка GPU в «Мониторе»** — новый ряд между CPU и RAM: процент + график, под ним частота и + мощность (`2450 МГц · 21.4 Вт`), в мини-виде только процент. Данные — через **Intel IGCL**, + user-mode API графического драйвера (`ControlLib.dll` приезжает со штатным драйвером Intel и лежит + в System32): driver-free, прав администратора для чтения не нужно, опрос стоит 47 мкс и живёт в том + же таймере 1 Гц, что и остальные графики. Загрузка сверена с собственным счётчиком Windows + (`\GPU Engine(*)\Utilization Percentage`) — сходится. Температуру и обороты вентиляторов IGCL на + встроенном GPU не даёт (пути только для дискретных Arc), поэтому в ряду их нет. Инициализация + ленивая: пока виджет не открыт, IGCL в процесс не подгружается. Не Intel (в т.ч. AMD-модели) — + ряд просто не появляется, виджет остаётся прежним. Разбор раскладки телеметрии и замеры — + [docs/09-power-monitoring.md](docs/09-power-monitoring.md) (XIC-18). - **`OwlIgnoreDisplay` в config.json** — «Режим совы» умеет держать бодрой только систему, отпуская экран: с `true` он больше не выставляет `ES_DISPLAY_REQUIRED`, и экран гаснет по обычному таймауту схемы питания. Для сценария «комп не должен уснуть ради удалённого доступа», где подсвеченная diff --git a/CLAUDE.md b/CLAUDE.md index 2e00cb3..985fdf6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,7 +141,7 @@ WMI-событий). `Program.cs`: single-instance mutex → DI-контейне `TravelChargeMonitor` (SystemIntegration). - `src/Ui/` остальное: `QuickPanelForm` (панель по удержанию Mi / клику по трею — чистый view над контроллером, навигация с клавиатуры; ширина фиксированная — при скрытых режимах - растягиваются ячейки), `OsdForm` (всплывашки), `MonitorForm` (виджет Вт/CPU/RAM/°C), + растягиваются ячейки), `OsdForm` (всплывашки), `MonitorForm` (виджет Вт/CPU/GPU/RAM/°C), `FlyoutForm` + `FlyoutPalette` (общая база флайаутов: borderless tool-window, Region, Esc; палитра — единственный источник тёмных цветов флайаутов), `FormChrome` (DWM-тёмный заголовок + WM_SETREDRAW), `ModeUi` (режим → ключ локализации / вид OSD / акцент), @@ -170,7 +170,10 @@ WMI-событий). `Program.cs`: single-instance mutex → DI-контейне `AutoStart` (задача планировщика + самопочинка пути), `AwakeMode` («режим совы»: всегда `ES_SYSTEM_REQUIRED` + крышка на AC, экран держится `ES_DISPLAY_REQUIRED` — кроме `OwlIgnoreDisplay: true` в config.json, тогда только «не спать»), - `MicControl`, `KeyActions`, `Sound` (WAV-джинглы), `BatteryInfo`, `PowerDraw`; + `MicControl`, `KeyActions`, `Sound` (WAV-джинглы), `BatteryInfo`, `PowerDraw`, + `GpuTelemetry` (загрузка/ватты/частота iGPU через **Intel IGCL** — user-mode API драйвера Intel, + `ControlLib.dll` из System32; driver-free, без админа, ленивая инициализация; не Intel → ряд GPU + в «Мониторе» просто не появляется, см. `docs/09`); **HTTP API (XIC-13, opt-in)** — `HttpApi` (хост на `HttpListener`/http.sys, без ASP.NET Core; создаётся только при включённой фиче → выключено = 0 CPU), `ApiRouter` (авторизация Bearer+SHA-256 constant-time и белый список маршрутов — тестируется на фейках), `ApiSettings`/`ApiSettingsStore` diff --git a/README.md b/README.md index 0ca5045..8dfdcb6 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ auto refresh rate and "owl mode" (stay awake).* Monitor window

-*Monitor window: live graphs of power draw (W), CPU and RAM usage.* +*Monitor window: live graphs of power draw (W), CPU, GPU and RAM usage.*

Monitor — compact view @@ -35,10 +35,10 @@ auto refresh rate and "owl mode" (stay awake).* Monitor — watts only

-*In full view — live graphs: power draw (W), CPU, RAM, **hotspot temperature** -(hot zone in cherry red) and the connected adapter's wattage. Collapses into a compact line -(Power / CPU / RAM) or a single watts readout — via the "view" button or a double-click; -current direction is shown by color (charging green / discharging orange).* +*In full view — live graphs: power draw (W), CPU, **GPU** (clock and watts under the percentage), +RAM, **hotspot temperature** (hot zone in cherry red) and the connected adapter's wattage. Collapses +into a compact line (Power / CPU / GPU / RAM) or a single watts readout — via the "view" button or a +double-click; current direction is shown by color (charging green / discharging orange).* *The tray icon changes with the active mode:* @@ -411,6 +411,10 @@ None of this runs or spends resources while the API is off (the server simply is - The feature set depends on the model: firmware telemetry (fan RPM) is unsupported on the tested machine. **Temperature** is shown anyway — not from the firmware, but via Intel DPTF (WMI `EsifDeviceInformation`), as a line graph in the Monitor. +- **GPU usage** comes from Intel IGCL — the graphics driver's user-mode API (`ControlLib.dll` in + System32, installed with the Intel driver; no administrator rights needed). On machines without + Intel graphics the GPU row simply does not appear. This channel exposes no temperature or fan RPM + for integrated GPUs — only usage, power and clock. ## How it works diff --git a/README.ru.md b/README.ru.md index 6d35b2c..293d905 100644 --- a/README.ru.md +++ b/README.ru.md @@ -17,7 +17,7 @@ **Никакого WinRing0**, никаких сторонних драйверов и прямого доступа к EC.

- Панель быстрых настроек + Панель быстрых настроек

*Панель быстрых настроек (удержание кнопки Mi): пять режимов производительности, @@ -25,21 +25,21 @@ авто-герцовка и «режим совы» (не спать).*

- Окно «Монитор» + Окно «Монитор»

-*Окно «Монитор»: живые графики потребления (Вт), загрузки CPU и RAM.* +*Окно «Монитор»: живые графики потребления (Вт), загрузки CPU, GPU и RAM.*

- «Монитор» — компактный вид + «Монитор» — компактный вид    - «Монитор» — только ватты + «Монитор» — только ватты

-*В полном виде — живые графики: потребление (Вт), CPU, RAM, **температура** «горячей точки» -(горячая зона — вишнёвым) и мощность подключённого адаптера. Сворачивается в компактную строку -(Power / CPU / RAM) или в один показатель ватт — кнопкой «вид» или двойным кликом; направление -тока — цветом (заряд зелёный / разряд оранжевый).* +*В полном виде — живые графики: потребление (Вт), CPU, **GPU** (под процентом — частота и ватты), +RAM, **температура** «горячей точки» (горячая зона — вишнёвым) и мощность подключённого адаптера. +Сворачивается в компактную строку (Power / CPU / GPU / RAM) или в один показатель ватт — кнопкой +«вид» или двойным кликом; направление тока — цветом (заряд зелёный / разряд оранжевый).* *Значок в трее меняется по активному режиму:* @@ -412,6 +412,10 @@ curl -X POST http://192.168.1.50:58125/travel \ - Набор функций зависит от модели: прошивочная телеметрия (обороты вентиляторов) на проверенной машине не поддерживается. **Температуру** при этом показываем — не из прошивки, а через Intel DPTF (WMI `EsifDeviceInformation`), строкой-графиком в «Мониторе». +- **Загрузка GPU** идёт через Intel IGCL — user-mode API графического драйвера (`ControlLib.dll` + из System32, ставится вместе с драйвером Intel; прав администратора не требует). На машинах без + графики Intel ряд GPU в «Мониторе» просто не появляется. Температуру и обороты вентиляторов этот + канал на встроенном GPU не отдаёт — только загрузку, мощность и частоту. ## Как это работает diff --git a/ROADMAP.md b/ROADMAP.md index 98f11c2..9fdb2ba 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -55,6 +55,11 @@ через Intel DPTF: WMI `root\wmi\EsifDeviceInformation`, `.Temperature` уже в °C, берём максимум среди активных доменов. Ранее числился закрытым как «невозможно» — переоткрыт на проверенном сигнале (см. ниже). Горячая зона графика подсвечивается вишнёвым (порог от ACPI `CriticalTripPoint`) (unreleased). +- **Загрузка GPU в «Мониторе»** — ряд между CPU и RAM: процент + график, под ним частота и мощность + (`2450 МГц · 21.4 Вт`); в мини-виде только процент. Driver-free через **Intel IGCL** — user-mode API + графического драйвера (`ControlLib.dll` из System32, прав администратора не нужно даже на чтение): + один `ctlPowerTelemetryGet`, загрузка и ватты считаются как приращения накопительных счётчиков. + Не Intel → ряда просто нет. Разбор и замеры — [docs/09-power-monitoring.md](docs/09-power-monitoring.md) (XIC-18, unreleased). - **Сенсорный экран вкл/выкл** — то же, что тачпад, для тач-экрана ноутбука: действие для клавиш + ячейка в панели (левее тачпада, только если тачскрин есть) + OSD. Общая база `HidNodeToggle` (тачпад `U:0005`, тачскрин `U:0004`). Вклад контрибьютора (PR #4) (unreleased). @@ -120,7 +125,11 @@ - Ручной цикл частоты экрана по клавише — не нужна: висела «если попросят», запросов не было, авто-герцовка по питанию покрывает сценарий (XIC-2 отменена; вернёмся, если попросят тестеры). - ICC-профили, splash-скины, служба + named pipe + protobuf (архитектура MIControl). -- GPU-режим, обороты вентиляторов — WMI на нашей модели не отдаёт (температуры — берём через DPTF, см. «Сделано»). +- **Обороты вентиляторов** — WMI на нашей модели не отдаёт, EC закрыт своим драйвером; в Intel IGCL + `ctlEnumFans` на встроенном GPU тоже отказ (это путь для дискретных Arc, как и `ctlEnumTemperatureSensors`). + GPU-**режим** (переключение/профили самого GPU) — тоже нет. При этом **телеметрия** iGPU + ✅ **сделана** driver-free через IGCL (загрузка/ватты/частота, см. «Сделано»), а температуры — через + DPTF; так что «GPU не отдаётся» больше не верно целиком. - WinRing0 / прямой доступ к EC — цель проекта в том, чтобы обойтись без них. - Lottie-анимации — заменены алгоритмическими (+10 МБ и второй рендер-стек не окупаются). - NFC-фичи (метки → действия) — модуль есть в железе, но в Windows как устройство не выведен diff --git a/docs/09-power-monitoring.md b/docs/09-power-monitoring.md index 5b6864e..bba9c96 100644 --- a/docs/09-power-monitoring.md +++ b/docs/09-power-monitoring.md @@ -83,3 +83,56 @@ Driver-free (только чтение), на нём построены: Строка-график «горячей точки» в «Мониторе» — driver-free через **Intel DPTF** (WMI `root\wmi\EsifDeviceInformation`, `.Temperature` в °C, максимум активных доменов). Тема раньше числилась закрытой как «невозможно», переоткрыта на этом сигнале — см. ROADMAP и `05-open-questions.md`. + +## GPU: загрузка, мощность, частота (Intel IGCL) + +Ряд GPU берётся из **Intel IGCL** — user-mode API графического драйвера. `ControlLib.dll` лежит в +`System32` и приезжает со штатным драйвером Intel: **мы ничего не устанавливаем, и права +администратора для чтения не нужны** — тот же driver-free принцип, что и WMI. Реализация — +`src/SystemIntegration/GpuTelemetry.cs`, инициализация ленивая (пока виджет не открыли, IGCL в +процесс не подгружается). + +Всё берётся одним вызовом `ctlPowerTelemetryGet`. Раскладка `ctl_power_telemetry_t` (проверено на +TM2424, `ControlLib 1.2.291.0`, адаптер `0x8086:0xB080`): структура 512 байт, внутри цепочка +элементов `ctl_oc_telemetry_item_t` = `uint bSupported; uint units; uint type; pad; double value`. + +| смещение | поле | единицы | есть? | +|---|---|---|---| +| 8 | `timeStamp` | секунды | да | +| 32 | `gpuEnergyCounter` | джоули | да | +| 56 | `gpuVoltage` | — | **нет** | +| 80 | `gpuCurrentClockFrequency` | МГц | да | +| 104 | `gpuCurrentTemperature` | — | **нет** | +| 128 | `globalActivityCounter` | секунды активности | да | +| 152 | `renderComputeActivityCounter` | секунды активности | да | +| 176 | `mediaActivityCounter` | секунды активности | да | + +Счётчики **накопительные**, поэтому загрузка и ватты — приращения: +`загрузка % = Δactivity / Δtime · 100`, `Вт = Δjoules / Δtime`. Первая выборка после открытия +виджета только набирает базу и даёт `NaN` — график начинается со второй точки. При повторном +открытии зовём `Reset()`, иначе первое значение размажется по времени, что виджет был закрыт. + +Тонкости и границы: + +- `ctl_version_info_t` — это **простой `uint32`** (`major << 16 | minor`), а не структура из двух + `uint16`. `ctl_init_args_t` = 36 байт, `AppVersion = 1.0`; реализация рапортует 1.1. +- На старте сверяем `bSupported`/`units`/`type` по всем четырём используемым смещениям. Не совпало — + выключаемся навсегда: лучше не показать ряд, чем показать мусор. +- **Температуры и оборотов вентиляторов IGCL на встроенном GPU не даёт**: все `ctlEnum*` + (`TemperatureSensors`, `Fans`, `PowerDomains`, `EngineGroups`, `FrequencyDomains`) отвечают + отказом — это пути для дискретных Arc. Температуры мы и так берём через DPTF (выше). +- `renderCompute` почти дублирует `global` (разница ~1.3 п.п. под нагрузкой), `media` под 3D-нагрузкой + всегда 0 — в UI выведен только `global`. +- AMD-модели: IGCL там нет (`ctlInit` не поднимется, либо `ControlLib.dll` вообще отсутствует и + P/Invoke бросит `DllNotFoundException`) — ряд просто не появится. Аналог у AMD — `atiadlxx.dll`, + отдельная история. + +### Проверено на железе (TM2424, 2026-07-29) + +Прогон бенчмарка, 829 выборок / 216 с: загрузка 0.9 → 99.7 %, мощность 0.01 → **24 Вт**, +частота 900 → **2500 МГц** (под длительной нагрузкой оседает на 2390–2440 — троттлинг), +корреляция загрузки и мощности **r = 0.988**. Сверка загрузки с собственным счётчиком Windows +`\GPU Engine(*)\Utilization Percentage`: IGCL ~42.5 % против Windows ~45.4 % — сходится. +Цена вызова: **медиана 47 мкс**, p95 77 мкс (кадр при 60 Гц — 16666 мкс), поэтому опрос живёт +в том же UI-таймере 1 Гц, без воркера. `ctlInit` + enumerate — 8.9 мс однократно при первом показе; +приватная память не растёт (`ControlLib.dll` 296 КБ и `IntelControlLib.dll` 712 КБ мапятся как образы). diff --git a/docs/10-colors.md b/docs/10-colors.md index 2efc7b1..ea71405 100644 --- a/docs/10-colors.md +++ b/docs/10-colors.md @@ -90,6 +90,12 @@ |---|---| | OSD карточка / рамка | `#1C1C1E` / `#46464A` | | Монитор — CPU | `#5AAAFF` | +| Монитор — GPU (Amber 300) | `#FFD54F` | | Монитор — RAM | `#B39DDB` | | Монитор — Температура (норма/горячо) | `#FF6F61` / `#CE203E` | | Монитор — заряд / разряд | `#34C759` / `#FF9500` | + +В «Мониторе» цвет выбирается **с оглядкой на соседние ряды**, а не сам по себе. Порядок рядов — +питание → CPU → GPU → RAM → температура, поэтому у GPU оба соседа холодные (синий, сиреневый), и +тёплый Amber 300 читается там лучше всего; холодные варианты (cyan) сливались с синим CPU, а +розово-фиолетовые — с сиреневой RAM, плюс красно-розовая зона уже занята смыслом «горячо» (`#CE203E`). diff --git a/docs/img/monitor-mini.png b/docs/img/monitor-mini.png index d4184a8..647e41f 100644 Binary files a/docs/img/monitor-mini.png and b/docs/img/monitor-mini.png differ diff --git a/docs/img/monitor-mini.ru.png b/docs/img/monitor-mini.ru.png new file mode 100644 index 0000000..07e60ae Binary files /dev/null and b/docs/img/monitor-mini.ru.png differ diff --git a/docs/img/monitor-watts.png b/docs/img/monitor-watts.png index 5619c42..cd4b38b 100644 Binary files a/docs/img/monitor-watts.png and b/docs/img/monitor-watts.png differ diff --git a/docs/img/monitor-watts.ru.png b/docs/img/monitor-watts.ru.png new file mode 100644 index 0000000..5619c42 Binary files /dev/null and b/docs/img/monitor-watts.ru.png differ diff --git a/docs/img/monitor.png b/docs/img/monitor.png index 8e3f841..b79cb18 100644 Binary files a/docs/img/monitor.png and b/docs/img/monitor.png differ diff --git a/docs/img/monitor.ru.png b/docs/img/monitor.ru.png new file mode 100644 index 0000000..af9e438 Binary files /dev/null and b/docs/img/monitor.ru.png differ diff --git a/docs/img/quick-panel.png b/docs/img/quick-panel.png index f001405..7b4e53b 100644 Binary files a/docs/img/quick-panel.png and b/docs/img/quick-panel.png differ diff --git a/docs/img/quick-panel.ru.png b/docs/img/quick-panel.ru.png new file mode 100644 index 0000000..9163f5a Binary files /dev/null and b/docs/img/quick-panel.ru.png differ diff --git a/src/Localization/lang/en.json b/src/Localization/lang/en.json index c641711..24bbf0d 100644 --- a/src/Localization/lang/en.json +++ b/src/Localization/lang/en.json @@ -184,6 +184,7 @@ "monitor.ram.of": "{0:0.0} / {1:0.0} GB", "monitor.adapter": "Adapter {0} W", "monitor.watts.scale": "{0:0} W", + "monitor.gpu.sub": "{0:0} MHz · {1:0.0} W", "monitor.temp": "Temperature", "monitor.temp.c": "{0} °C", "osd.charging": "Charging", diff --git a/src/Localization/lang/ru.json b/src/Localization/lang/ru.json index 633e13f..585c3a6 100644 --- a/src/Localization/lang/ru.json +++ b/src/Localization/lang/ru.json @@ -184,6 +184,7 @@ "monitor.ram.of": "{0:0.0} / {1:0.0} ГБ", "monitor.adapter": "Адаптер {0} Вт", "monitor.watts.scale": "{0:0} Вт", + "monitor.gpu.sub": "{0:0} МГц · {1:0.0} Вт", "monitor.temp": "Температура", "monitor.temp.c": "{0} °C", "osd.charging": "Зарядка", diff --git a/src/Localization/lang/zh.json b/src/Localization/lang/zh.json index e3ce1e0..8c88614 100644 --- a/src/Localization/lang/zh.json +++ b/src/Localization/lang/zh.json @@ -184,6 +184,7 @@ "monitor.ram.of": "{0:0.0} / {1:0.0} GB", "monitor.adapter": "适配器 {0} 瓦", "monitor.watts.scale": "{0:0} 瓦", + "monitor.gpu.sub": "{0:0} MHz · {1:0.0} 瓦", "monitor.temp": "温度", "monitor.temp.c": "{0} °C", "osd.charging": "充电中", diff --git a/src/SystemIntegration/GpuTelemetry.cs b/src/SystemIntegration/GpuTelemetry.cs new file mode 100644 index 0000000..0eac65c --- /dev/null +++ b/src/SystemIntegration/GpuTelemetry.cs @@ -0,0 +1,160 @@ +using System.Runtime.InteropServices; + +namespace XiControl.SystemIntegration; + +/// +/// Телеметрия встроенного GPU через Intel IGCL — user-mode API графического драйвера +/// (ControlLib.dll приезжает со штатным драйвером Intel и лежит в System32). Мы ничего не +/// устанавливаем и не требуем прав администратора — это тот же driver-free принцип, что и WMI. +/// Отдаём загрузку %, мощность Вт и частоту МГц: температуры и обороты вентиляторов IGCL на +/// встроенном GPU не даёт (все ctlEnum* отвечают отказом — это пути для дискретных Arc). +/// +/// Загрузка и мощность — приращения накопительных счётчиков (секунды активности и джоули), +/// поэтому первый вызов только запоминает базу и возвращает false. Инициализация ленивая: пока +/// «Монитор» не открыли, IGCL в процесс не подгружается вообще. +/// +/// Нет драйвера Intel (в т.ч. AMD-модели) или структура телеметрии разложена иначе → +/// выключаемся навсегда, остаётся false, «Монитор» просто не рисует ряд. +/// +public sealed class GpuTelemetry : IDisposable +{ + // ctl_power_telemetry_t на ControlLib 1.2.291.0; проверено на TM2424 (Intel, 0x8086:0xB080). + // Внутри — цепочка ctl_oc_telemetry_item_t: uint bSupported; uint units; uint type; pad; double value. + private const int TelemetrySize = 512; + private const int OffTime = 8; // timeStamp, секунды + private const int OffEnergy = 32; // gpuEnergyCounter, джоули + private const int OffClock = 80; // gpuCurrentClockFrequency, МГц + private const int OffActive = 128; // globalActivityCounter, секунды активности + private const int ValueOffset = 16; // double внутри элемента + + // ctl_units_t / ctl_data_type_t — сверяем на старте, чтобы не считать мусор при другой раскладке + private const uint UnitsMHz = 0, UnitsJoules = 6, UnitsSeconds = 7, TypeDouble = 9; + + private const int InitArgsSize = 36; // ctl_init_args_t: Size, Version, AppVersion, flags, SupportedVersion, GUID + private const int AppVersion = 1 << 16; // ctl_version_info_t — это uint32 (major << 16 | minor), т.е. 1.0 + + private IntPtr _api, _dev, _buf; + private bool _off; // IGCL не поднялся / раскладка не та — больше не пытаемся + private bool _tried; // инициализацию уже делали (успешно или нет) + + private double _prevTime, _prevEnergy, _prevActive; + private bool _hasPrev; + + /// IGCL поднялся и телеметрия отдаёт нужные поля (ряд GPU в «Мониторе» имеет смысл). + public bool Available => !_off && _tried; + + /// Забыть базу — следующий вызов начнёт отсчёт заново (при повторном открытии виджета, + /// иначе первое значение размажется по всему времени, что «Монитор» был закрыт). + public void Reset() => _hasPrev = false; + + /// + /// Загрузка GPU (0..100), мощность (Вт) и текущая частота (МГц). false — телеметрия недоступна + /// либо это первая выборка и разностей ещё нет; значения в этом случае не осмысленны. + /// + public bool TryRead(out float loadPct, out float watts, out float mhz) + { + loadPct = watts = mhz = float.NaN; + if (!_tried) Init(); + if (_off) return false; + + try + { + if (!Fetch()) { _off = true; return false; } + + double t = Value(OffTime), energy = Value(OffEnergy), active = Value(OffActive); + mhz = (float)Value(OffClock); + + if (!_hasPrev) + { + (_prevTime, _prevEnergy, _prevActive) = (t, energy, active); + _hasPrev = true; + return false; // база есть, разности будут со следующего тика + } + + double dt = t - _prevTime; + if (dt <= 0) return false; // счётчик времени не двинулся (или перезапустился) — тик пропускаем + + loadPct = (float)Math.Clamp((active - _prevActive) / dt * 100.0, 0.0, 100.0); + watts = (float)Math.Max((energy - _prevEnergy) / dt, 0.0); + (_prevTime, _prevEnergy, _prevActive) = (t, energy, active); + return true; + } + catch (Exception ex) { Log.Ex("GpuTelemetry.Read", ex); _off = true; return false; } + } + + private void Init() + { + _tried = true; + try + { + IntPtr args = Marshal.AllocHGlobal(InitArgsSize); + try + { + Zero(args, InitArgsSize); + Marshal.WriteInt32(args, 0, InitArgsSize); + Marshal.WriteInt32(args, 8, AppVersion); + if (ctlInit(args, out _api) != 0) { _off = true; return; } + } + finally { Marshal.FreeHGlobal(args); } + + uint count = 0; + if (ctlEnumerateDevices(_api, ref count, null) != 0 || count == 0) { Close(); return; } + var devices = new IntPtr[count]; + if (ctlEnumerateDevices(_api, ref count, devices) != 0) { Close(); return; } + _dev = devices[0]; // встроенный GPU — единственный адаптер на наших моделях + + _buf = Marshal.AllocHGlobal(TelemetrySize); + if (!Fetch() || !LayoutMatches()) { Close(); return; } + } + catch (Exception ex) { Log.Ex("GpuTelemetry.Init", ex); Close(); } // нет ControlLib.dll (не Intel) и т.п. + } + + /// Поля лежат там, где мы их ждём, и поддержаны железом — иначе цифрам верить нельзя. + private bool LayoutMatches() => + Item(OffTime, UnitsSeconds) && Item(OffEnergy, UnitsJoules) && + Item(OffClock, UnitsMHz) && Item(OffActive, UnitsSeconds); + + private bool Item(int off, uint units) => + (uint)Marshal.ReadInt32(_buf, off) == 1 && + (uint)Marshal.ReadInt32(_buf, off + 4) == units && + (uint)Marshal.ReadInt32(_buf, off + 8) == TypeDouble; + + private bool Fetch() + { + Zero(_buf, TelemetrySize); + Marshal.WriteInt32(_buf, 0, TelemetrySize); + return ctlPowerTelemetryGet(_dev, _buf) == 0; + } + + private double Value(int off) => BitConverter.Int64BitsToDouble(Marshal.ReadInt64(_buf, off + ValueOffset)); + + private static void Zero(IntPtr p, int len) + { + for (int i = 0; i < len; i++) Marshal.WriteByte(p, i, 0); + } + + private void Close() + { + _off = true; + if (_buf != IntPtr.Zero) { Marshal.FreeHGlobal(_buf); _buf = IntPtr.Zero; } + if (_api != IntPtr.Zero) + { + try + { + int r = ctlClose(_api); + if (r != 0) Log.Write($"GpuTelemetry: ctlClose -> 0x{r:X8}"); + } + catch (Exception ex) { Log.Ex("GpuTelemetry.Close", ex); } + _api = IntPtr.Zero; + } + _dev = IntPtr.Zero; + } + + public void Dispose() => Close(); + + private const string Lib = "ControlLib.dll"; + [DllImport(Lib)] private static extern int ctlInit(IntPtr args, out IntPtr api); + [DllImport(Lib)] private static extern int ctlClose(IntPtr api); + [DllImport(Lib)] private static extern int ctlEnumerateDevices(IntPtr api, ref uint count, IntPtr[]? devices); + [DllImport(Lib)] private static extern int ctlPowerTelemetryGet(IntPtr device, IntPtr telemetry); +} diff --git a/src/Ui/MonitorForm.cs b/src/Ui/MonitorForm.cs index 3163757..96fcb6a 100644 --- a/src/Ui/MonitorForm.cs +++ b/src/Ui/MonitorForm.cs @@ -9,7 +9,7 @@ namespace XiControl.Ui; /// /// «Монитор»: живые графики с момента открытия — потребление (Вт, датчик батареи), -/// CPU % и RAM %. Семплирование (1 Гц) работает только пока окно видно. +/// CPU %, GPU % (Intel IGCL, если есть) и RAM %. Семплирование (1 Гц) работает только пока окно видно. /// Ватты честные только от батареи/на зарядке: на питании от сети датчика нет — «—». /// Три вида — полный (графики), мини (строка Power/CPU/RAM), только ватты; /// переключаются кнопкой «вид» или двойным кликом по виджету, выбор запоминается. @@ -20,6 +20,7 @@ public sealed class MonitorForm : FlyoutForm private static readonly Color DischargeCol = FlyoutPalette.Orange; // разряд батареи (вниз) private static readonly Color ChargeCol = FlyoutPalette.Green; // заряд в батарею (вверх) private static readonly Color CpuCol = FlyoutPalette.Blue; + private static readonly Color GpuCol = Color.FromArgb(255, 213, 79); // Amber 300: соседи по рядам холодные (CPU, RAM), а тёплые — через ряд private static readonly Color RamCol = Color.FromArgb(179, 157, 219); // сиреневый (зелёный ушёл под заряд) private static readonly Color TempCol = Color.FromArgb(255, 111, 97); // коралловый — температура (норма) private static readonly Color TempHotCol = Color.FromArgb(206, 32, 62); // вишнёвый — горячая/крит-зона (сочно на OLED) @@ -35,6 +36,7 @@ public sealed class MonitorForm : FlyoutForm private readonly System.Windows.Forms.Timer _tick = new() { Interval = 1000 }; private readonly List _power = new(); // Вт со знаком: + заряд в батарею, − разряд; NaN = от сети без заряда private readonly List _cpu = new(); // 0..100 + private readonly List _gpu = new(); // 0..100; NaN = нет данных private readonly List _ram = new(); // 0..100 private readonly List _temp = new(); // °C горячей точки (Intel DPTF); NaN = нет данных @@ -43,9 +45,12 @@ public sealed class MonitorForm : FlyoutForm private ManagementObjectSearcher? _battery; private ManagementObjectSearcher? _thermal; private readonly SystemIntegration.PowerDraw _powerDraw = new(); // живая мощность через Battery IOCTL + private readonly SystemIntegration.GpuTelemetry _gpuTel = new(); // iGPU через Intel IGCL (ленивая инициализация) private long _prevIdle, _prevKernel, _prevUser; private float _ramUsedGb, _ramTotalGb; private int _adapterWatts; // ватты подключённого PD-БП (0 — нет/не PD); MIFS, driver-free + private float _gpuMhz, _gpuWatts; // текущая частота и мощность GPU — в подстроку ряда + private bool _hasGpu; // IGCL поднялся → резервируем ряд GPU (иначе виджет как раньше) private bool _hasTemp; // на этой модели DPTF отдаёт температуры → резервируем строку/высоту private bool _tempOff; // класс DPTF отсутствует — больше не опрашиваем private float _critC; // критический порог, °C (0 = неизвестен); из ACPI-термозоны, best-effort @@ -89,9 +94,10 @@ public void Popup() { if (Visible) { Hide(); return; } - _power.Clear(); _cpu.Clear(); _ram.Clear(); _temp.Clear(); + _power.Clear(); _cpu.Clear(); _gpu.Clear(); _ram.Clear(); _temp.Clear(); _prevIdle = _prevKernel = _prevUser = 0; - Sample(); // первая точка сразу (заодно определит наличие DPTF-температур до ApplyView) + _gpuTel.Reset(); // иначе первая загрузка GPU размажется по времени, что виджет был закрыт + Sample(); // первая точка сразу (заодно определит наличие DPTF-температур и IGCL до ApplyView) ApplyView(); @@ -116,8 +122,9 @@ private void ApplyView() int w, h; switch (_view) { - case ViewKind.Mini: // Power | CPU | RAM + [развернуть]/[вид]/[крестик] справа - w = Sc(16) + Sc(104) + Sc(8) + Sc(72) + Sc(8) + Sc(72) + Sc(8) + Sc(18) + Sc(6) + Sc(18) + Sc(6) + Sc(18) + Sc(10); + case ViewKind.Mini: // Power | CPU | [GPU] | RAM + [развернуть]/[вид]/[крестик] справа + w = Sc(16) + Sc(104) + Sc(8) + Sc(72) + Sc(8) + (_hasGpu ? Sc(72) + Sc(8) : 0) + + Sc(72) + Sc(8) + Sc(18) + Sc(6) + Sc(18) + Sc(6) + Sc(18) + Sc(10); h = Sc(56); _corner = Sc(14); _close = new Rectangle(w - Sc(10) - Sc(18), (h - Sc(18)) / 2, Sc(18), Sc(18)); @@ -132,7 +139,8 @@ private void ApplyView() _expandBtn = Rectangle.Empty; break; default: - w = Sc(400); h = Sc(96) * (_hasTemp ? 4 : 3) + Sc(52); // + строка температуры, если DPTF отдаёт + // базовые три ряда (питание, CPU, RAM) + GPU, если есть IGCL, + температура, если есть DPTF + w = Sc(400); h = Sc(96) * (3 + (_hasGpu ? 1 : 0) + (_hasTemp ? 1 : 0)) + Sc(52); _corner = Sc(18); _close = new Rectangle(w - Sc(16) - Sc(22), Sc(14), Sc(22), Sc(22)); // как в панели _viewBtn = new Rectangle(_close.X - Sc(28), _close.Y, Sc(22), Sc(22)); @@ -241,12 +249,30 @@ protected override void OnMouseLeave(EventArgs e) private void Sample() { Push(_cpu, SampleCpu()); + Push(_gpu, SampleGpu()); Push(_ram, SampleRam()); Push(_power, SamplePowerWatts()); Push(_temp, SampleTempC()); try { _adapterWatts = _mifs.GetAdapterWatts(); } catch (Exception ex) { Log.Ex("Monitor.Adapter", ex); _adapterWatts = 0; } } + /// + /// Загрузка встроенного GPU через Intel IGCL — driver-free, права администратора не нужны + /// (детали и раскладка телеметрии — в ). Заодно + /// запоминаем частоту и мощность для подстроки ряда. Первая выборка после открытия виджета + /// только набирает базу счётчиков — она даёт NaN, график начинается со второй точки. + /// IGCL нет (не Intel) → ряд GPU не показываем вовсе. + /// + private float SampleGpu() + { + bool ok = _gpuTel.TryRead(out float load, out float watts, out float mhz); + if (!_gpuTel.Available) return float.NaN; // не Intel / телеметрия не та — ряда не будет + _hasGpu = true; + if (!ok) return float.NaN; + (_gpuMhz, _gpuWatts) = (mhz, watts); + return load; + } + /// /// Температура «горячей точки» через Intel DPTF (WMI EsifDeviceInformation) — driver-free: /// провайдер идёт со штатными драйверами Intel. Берём максимум среди активных доменов — это @@ -391,15 +417,28 @@ protected override void OnPaint(PaintEventArgs e) ? (pw >= 0 ? "+" : "") + Loc.T("monitor.watts", MathF.Abs(pw)) // всегда положительное число; направление — цветом : Loc.T("monitor.na"); + // ряды идут подряд, а GPU и температура опциональны → позицию считаем счётчиком, а не множителем + int row = 0; + Rectangle Next() => new(Sc(16), top + rowH * row++, Width - Sc(32), rowH); + float powerMax = NiceMax(_power); - DrawRow(g, new Rectangle(Sc(16), top, Width - Sc(32), rowH), + DrawRow(g, Next(), Loc.T("monitor.power"), powerText, pColor, _power, powerMax, sub: _adapterWatts > 0 ? Loc.T("monitor.adapter", _adapterWatts) : null, // рейтинг подключённого БП scaleLabel: Loc.T("monitor.watts.scale", powerMax), pick: v => v >= 0f ? ChargeCol : DischargeCol); // цвет по направлению тока - DrawRow(g, new Rectangle(Sc(16), top + rowH, Width - Sc(32), rowH), + DrawRow(g, Next(), "CPU", _cpu.Count > 0 && !float.IsNaN(_cpu[^1]) ? $"{_cpu[^1]:0}%" : "—", CpuCol, _cpu, 100f); - DrawRow(g, new Rectangle(Sc(16), top + rowH * 2, Width - Sc(32), rowH), + + if (_hasGpu) + { + float gl = _gpu.Count > 0 ? _gpu[^1] : float.NaN; + DrawRow(g, Next(), + "GPU", float.IsNaN(gl) ? "—" : $"{gl:0}%", GpuCol, _gpu, 100f, + _gpuMhz > 0 ? Loc.T("monitor.gpu.sub", _gpuMhz, _gpuWatts) : null); // «2450 МГц · 21 Вт» + } + + DrawRow(g, Next(), "RAM", _ram.Count > 0 ? $"{_ram[^1]:0}%" : "—", RamCol, _ram, 100f, _ramTotalGb > 0 ? Loc.T("monitor.ram.of", _ramUsedGb, _ramTotalGb) : null); @@ -407,7 +446,7 @@ protected override void OnPaint(PaintEventArgs e) { float tc = _temp.Count > 0 ? _temp[^1] : float.NaN; Color now = !float.IsNaN(tc) && tc >= HotAt ? TempHotCol : TempCol; // текущее значение — вишнёвым, если горячо - DrawRow(g, new Rectangle(Sc(16), top + rowH * 3, Width - Sc(32), rowH), + DrawRow(g, Next(), Loc.T("monitor.temp"), float.IsNaN(tc) ? "—" : Loc.T("monitor.temp.c", (int)tc), now, _temp, TempMax, scaleLabel: Loc.T("monitor.temp.c", (int)TempMax), pick: v => v >= HotAt ? TempHotCol : TempCol); // горячая зона на графике — вишнёвая @@ -421,6 +460,8 @@ private void PaintMini(Graphics g, float pw, Color pColor) int x = Sc(16); x = MiniCell(g, x, Sc(104), "Power", powerVal, pColor); x = MiniCell(g, x, Sc(72), "CPU", _cpu.Count > 0 && !float.IsNaN(_cpu[^1]) ? $"{_cpu[^1]:0}%" : "—", CpuCol); + if (_hasGpu) // в мини-виде у GPU только процент — частота и ватты живут в полном виде + x = MiniCell(g, x, Sc(72), "GPU", _gpu.Count > 0 && !float.IsNaN(_gpu[^1]) ? $"{_gpu[^1]:0}%" : "—", GpuCol); MiniCell(g, x, Sc(72), "RAM", _ram.Count > 0 ? $"{_ram[^1]:0}%" : "—", RamCol); Draw.ExpandButton(g, _expandBtn, _expandHover); // развернуть в полный @@ -546,6 +587,7 @@ protected override void Dispose(bool disposing) _battery?.Dispose(); _thermal?.Dispose(); _powerDraw.Dispose(); + _gpuTel.Dispose(); } base.Dispose(disposing); }