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: live graphs of power draw (W), CPU and RAM usage.*
+*Monitor window: live graphs of power draw (W), CPU, GPU and RAM usage.*
@@ -35,10 +35,10 @@ auto refresh rate and "owl mode" (stay awake).*
-*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);
}